Wymcp.Tool behaviour (Wymcp v0.1.1)

View Source

Behaviour for MCP tools using the action-dispatched pattern.

Each tool exposes multiple actions under a single tool name. The use Wymcp.Tool macro generates the inputSchema from actions/0 (via Wymcp.Tool.Schema), handles dispatch via run_action/3, validates required fields, rejects unknown parameters, applies defaults, injects hints, and formats errors.

Usage

defmodule MyApp.Tools.Tasks do
  use Wymcp.Tool

  @impl true
  def name, do: "tasks"

  @impl true
  def description, do: "Task management"

  @impl true
  def actions do
    %{
      create: %{
        description: "Create a task",
        properties: %{"name" => %{"type" => "string"}},
        required: ["name"],
        defaults: %{}
      }
    }
  end

  @impl Wymcp.Tool
  def run_action(:create, %{"name" => name}, _ctx) do
    {:ok, %{message: "Created #{name}"}, %{id: 1}}
  end
end

Action schema format

Each action in the actions/0 map is keyed by the action name: an atom that must not contain a newline (validated at boot and at runtime registration, like :description below). Each action must have:

  • :description — human-readable description of the action, emitted verbatim into the action summaries (the tools/list action enum, the help index) and into help's tool and action levels. Must not contain a newline — validated at boot and at runtime registration. See "Consumer-authored text" below.
  • :properties — JSON Schema properties for the action's data parameter

Optional fields:

  • :required — list of unconditionally required property names (defaults to []). Every listed field must be present in data (AND-semantics).
  • :required_one_of — list of groups, where each group is a list of property names. At least one group must be fully present (OR-of-AND semantics). Combines with :required — both checks run, both must pass. Enforced at dispatch; surfaced by the help tool.
  • :defaults — map of default values merged into data before dispatch (defaults to %{}). Every key must be declared in :properties, and no key may also appear in :required — validated at boot and at runtime registration.
  • :notes — long-form notes surfaced by the help tool.
  • :related — list of related action name strings surfaced by the help tool.
  • :examples — list of example payload maps surfaced by the help tool.

Defaults are applied after the dispatch gates run: a value supplied via :defaults does not count toward satisfying :required or :required_one_of, and it fills only a key the caller omitted — the merge goes by key presence, so a caller sending the key as null keeps the null. Both checks run against the caller's data as received.

Action schemas are validated at server boot via Wymcp.Router.init/1 and at runtime registration via Wymcp.Session.register_tool/2. A malformed schema (e.g. a :required_one_of group referencing a field not declared in :properties, or a key outside the field list above) raises ArgumentError immediately, surfacing the misconfiguration before any request is served. Validation also rejects dead config it can localize to a nameable pair of declarations — a :required_one_of group that is a strict superset of another, a :defaults key that is also :required — and does not compute global properties of a schema.

Example: OR-of-AND required group

get_pull_request: %{
  description: "Get pull request details",
  properties: %{
    "url" => %{"type" => "string"},
    "project_key" => %{"type" => "string"},
    "repo_slug" => %{"type" => "string"},
    "pr_id" => %{"type" => "integer"}
  },
  required_one_of: [["url"], ["project_key", "repo_slug", "pr_id"]]
}

Consumer-authored text

Wymcp emits consumer-authored text without altering it. Every string a consuming application writes for the framework to pass on — a tool's description/0, an action schema's :description, :notes, :related and :examples, property "description" values, Wymcp.Hint descriptions, and Wymcp.Router's :instructions and :server_info — reaches the wire exactly as written. The framework may add separation and structure around the text: it prefixes each action description with its action name to form the action summaries (Wymcp.Tool.Schema.action_summaries/1), sorts actions by name, places the summaries in JSON arrays, and joins them with a separator. It never edits the characters. Names — tool, action, property — are identifiers rather than prose and sit outside this contract, except that an action name is half of every joined summary, so the newline constraint below covers it too.

One constraint follows from the separator: neither an action schema's :description nor an action name may contain a newline. The action enum's description in tools/list joins the action summaries with a newline, a summary is <action>: <description>, and an embedded newline in either half would make the boundary between summaries ambiguous — so wymcp refuses such a tool at boot (Wymcp.Router.init/1) and at runtime registration (Wymcp.Session.register_tool/2) instead of reshaping the text. No other consumer-authored field is ever joined, so newlines stay legal everywhere else, including the tool-level description/0 and :notes.

This module is the contract's definition home, and the surface is wider than one module: Wymcp.Hint descriptions and Wymcp.Router's :instructions and :server_info are consumer-authored text too, and are governed by the contract stated here rather than by a restatement of their own.

Return values from run_action/3

  • {:ok, response_data} — success, response sent as JSON
  • {:ok, response_data, hint_context} — success with hints; the framework calls hints/2 with the action and hint_context, injecting the result
  • {:error, reason} — error; the framework calls handle_error/1 and sends the result as an isError response
  • {:error, reason, hint_context} — error with hints; the framework calls handle_error/1, hints/2, and action_context/2, then sends structured JSON with error, hints, and optional context keys

The generated run/2

use Wymcp.Tool also generates run/2 — the framework's entry point to the tool. It takes a Wymcp.Context.t() and the raw arguments map, hands both to Wymcp.Tool.dispatch/3, and never touches the HTTP layer. One clause, guarded on the arguments being a map: everything a caller can get wrong about that map — including sending no action at all — is a dispatch gate's structured answer, never a missing clause. It returns:

  • {:ok, content} — success
  • {:error, message} — an error the tool answered with; classified :tool in telemetry
  • {:error, message, :dispatch | :tool} — an error carrying its own classification: :dispatch means a gate rejected the call before the action handler ran (the generated run/2 returns this for its own dispatch-gate rejections), :tool means the tool ran and answered with an error

The classification surfaces as the error_kind metadata key on [:wymcp, :tool, :stop] — see Wymcp.Telemetry. A hand-written run/2 may use the three-element error form to classify its own gate rejections; the two-element form always classifies :tool. This error tuple is not run_action/3's {:error, reason, hint_context} — there the third element is a hint-context map consumed inside dispatch, never a classification atom.

Wymcp.Methods.ToolsCall builds the JSON-RPC response from the returned tuple (and additionally accepts {:ok, content, assigns_updates} from hand-written run/2 implementations — see the assigns section of Wymcp.Session).

An uncaught exception from run/2 is contained rather than propagated: Wymcp.Methods.ToolsCall rescues it and answers with isError: true and a JSON diagnostic body (errorType, tool, exception, message), so a tool need not rescue defensively in its own run/2.

Dispatch errors and self-correction

Six dispatch gates reject a call before the action handler runs, in this order: a key outside the arguments vocabulary, an absent action, an unknown action name, a missing :required field, an unsatisfied :required_one_of group, and an unknown key inside data. Each answers with isError: true content rather than a JSON-RPC error, and each carries a help pointer naming the call that would explain the surface it just refused. The three data-level gates additionally carry an input_schema digest of the action's properties, required fields and defaults, plus a required_one_of member when the action declares any groups. The three above them carry no digest: at arguments level the vocabulary — action and data — is the whole contract, and the two action gates answer with the list of valid action names instead.

The division of labour between these gates and argument validation is deliberate: argument validation owns structure, dispatch gates own vocabulary. A wrong typeaction not a string, data not an object — is a malformed request, and Wymcp.Methods.ToolsCall answers it as -32602 from the schema validator. A wrong name is a vocabulary mistake, where a schema validator produces schema-path prose that names no key and suggests no next action; a gate answers it in the tool dialect instead. One consequence is visible to a caller who makes both mistakes at once: the type error is answered first, and the stray key goes unnamed until the retry.

The gates run as one chain and the first to fire answers, so a call with several mistakes surfaces them one per attempt. The point is that a confident LLM can attempt a call and learn from the error without a help round-trip: the rejection is the documentation for the call it just refused. Wymcp.Help gates its own vocabulary — tool and action — through the same check_arguments/4, and renders its unknown-target errors through the same pointer helpers, so the two surfaces cannot drift into two error dialects.

One rejection in this family is not isError content, and its answer is era-varying. An unknown tool name never reaches a tool module at all in either era; which JSON-RPC error comes back depends on the lane. The modern lane answers -32602 (Invalid params), naming under data.error the tool that was asked for — there -32601 is reserved for methods the server does not implement, which that lane answers with HTTP 404, so an unknown tool carrying the same code at 200 would be indistinguishable by code alone. The legacy lane keeps its -32601 (Method not found), with the same data an unrecognised legacy method gets — the original request and nothing more, so the tool is not named.

The action-schema invariant

Every key in the action-schema vocabulary is validated: a key outside action_schema_keys/0 is rejected at wire-in (unknown implies rejected), and every key inside it is checked by a validator in validate_actions!/1 that rejects at least a wrong-type value (known implies validated). That rule is the action-schema invariant.

Two clauses, each with its own enforcement:

  • unknown implies rejectedvalidate_known_keys!/3 subtracts the vocabulary from an action schema's keys and raises on whatever is left, so a misspelling fails at wire-in instead of vanishing silently from help and tools/list.
  • known implies validatedWymcp.ActionSchemaInvariantTest derives one cell per key from action_schema_keys/0 and asserts each one rejects a wrong-type value, so a key joining the vocabulary without a validator fails that test rather than shipping unchecked.

The invariant is about coverage, not depth: a validator satisfies it by rejecting a wrong type. What a well-typed value may contain is that key's own contract — the framework validates no property values at all.

The callback-surface invariant

Every callback a wymcp behaviour declares optional is one the framework probes before calling, so its absence is a value; every other declared callback is required and verified when the module is wired in. That rule is the callback-surface invariant, and it holds across every wymcp behaviour — Wymcp.Auth and Wymcp.Server included, both of which satisfy it by declaring nothing optional.

Three clauses, each with its own enforcement:

  • optional implies guardedWymcp.CallbackSurfaceInvariantTest's optional column sweeps the framework's entry points with a tool that defines no optional callback at all.
  • required implies verified at wire-invalidate_callback_surface!/1 runs at Wymcp.Router.init/1 and Wymcp.Session.register_tool/2, and the compiler warns any module that declares the behaviour and misses one.
  • called implies declared — enforced by nothing automatic. Both the check and the invariant test derive from behaviour_info/1, so a call site whose callback was never declared is invisible to them. Closing that would need static analysis of lib/ for module.<fun>() call sites, out of proportion to the risk; the gap is recorded here rather than hidden.

Optional callbacks

Three callbacks are optional, and for each the framework probes the module before calling, so an absent one is a value rather than a crash:

  • action_context/2 — returns a map of runtime context for the given action, or nil. Receives (action_atom, ctx), where ctx is the same Wymcp.Context.t() passed to run_action/3. Called by the help tool at action level and during normal action dispatch. The map appears under a "context" key in the response. The callback is optional in the strict sense: a tool that does not export it gets no "context" key, silently. When defined it must return nil or a map — any other return raises, on the dispatch and help paths alike. Read per-request data from ctx.assigns rather than the process dictionary — action_context may be invoked from a process that did not run the auth plug.
  • title/0 — returns a display title for the tool, or nil. A tool that does not export it gets no "title" key in its tools/list definition.
  • annotations/0 — returns an MCP annotations map, or nil. A tool that does not export it gets no "annotations" key in its definition.

Required callbacks the macro defaults

use Wymcp.Tool supplies working defaults for three required callbacks via defoverridable, so a macro user implements only name/0, description/0, actions/0 and run_action/3. A module implementing the behaviour without the macro must define all three itself — nothing probes for them:

  • hints/2 — returns a list of follow-up action suggestions. Default: []
  • handle_error/1 — formats an error reason into a string. Default: "Operation failed: #{inspect(reason)}"
  • output_schema/0 — returns a JSON Schema map describing the structure of the tool's response, or nil. When present, tools/list includes "outputSchema" in the definition and tools/call validates the response against it, returning "structuredContent" alongside "content". Default: nil

The remaining three required callbacks — input_schema/0, run/2 and definition/0 — come from __before_compile__ and are not overridable, which is why a tool with no action dispatch cannot use the macro at all. Wymcp.Help is the framework's one such tool; see its moduledoc.

flowchart TD
    subgraph Tool Behaviour
        T[Wymcp.Tool] --> D["dispatch/3"]
        D --> A["action dispatch"]
        A --> R["handle_result/4"]
    end
    subgraph External
        T --> S[Tool.Schema]
        D --> C[Context]
        R --> HN[Hint]
        A -->|"module.run_action/3"| CB(Consumer Tool)
        R -->|"module.hints/2"| CB
        R -->|"module.action_context/2"| CB
    end

Summary

Functions

Validate every action schema in module. Raises ArgumentError with a descriptive message on the first malformed action.

Validate that module exports every callback this behaviour declares outside @optional_callbacks. Raises ArgumentError naming the module and the missing function/arity entries.

Types

action_schema()

@type action_schema() :: %{
  :description => String.t(),
  :properties => map(),
  optional(:required) => [String.t()],
  optional(:required_one_of) => [[String.t()]],
  optional(:defaults) => map(),
  optional(:notes) => String.t(),
  optional(:related) => [String.t()],
  optional(:examples) => [map()]
}

hint()

@type hint() :: Wymcp.Hint.t()

Callbacks

action_context(action, ctx)

(optional)
@callback action_context(action :: atom(), ctx :: Wymcp.Context.t()) :: map() | nil

actions()

@callback actions() :: %{required(atom()) => action_schema()}

annotations()

(optional)
@callback annotations() :: map() | nil

definition()

@callback definition() :: map()

description()

@callback description() :: String.t()

handle_error(error)

@callback handle_error(error :: term()) :: String.t()

hints(action, hint_context)

@callback hints(action :: atom(), hint_context :: map()) :: [hint()]

input_schema()

@callback input_schema() :: map()

name()

@callback name() :: String.t()

output_schema()

@callback output_schema() :: map() | nil

run(ctx, arguments)

@callback run(ctx :: Wymcp.Context.t(), arguments :: map()) ::
  {:ok, content :: term()}
  | {:ok, content :: term(), assigns_updates :: map()}
  | {:error, message :: String.t()}
  | {:error, message :: String.t(), :dispatch | :tool}

run_action(action, data, ctx)

@callback run_action(action :: atom(), data :: map(), ctx :: Wymcp.Context.t()) ::
  {:ok, term()}
  | {:ok, term(), map()}
  | {:error, term()}
  | {:error, term(), map()}

title()

(optional)
@callback title() :: String.t() | nil

Functions

validate_actions!(module)

Validate every action schema in module. Raises ArgumentError with a descriptive message on the first malformed action.

Called by Wymcp.Router.init/1 at boot and by Wymcp.Session.register_tool/2 at runtime registration, so a misconfigured tool fails when it is wired in rather than at its first request.

validate_callback_surface!(module)

Validate that module exports every callback this behaviour declares outside @optional_callbacks. Raises ArgumentError naming the module and the missing function/arity entries.

This is the callback-surface invariant's required half, and the required set is derived — behaviour_info(:callbacks) -- behaviour_info(:optional_callbacks) — never listed, so a callback added to this behaviour joins the check by being declared.

Called by Wymcp.Router.init/1 at boot and by Wymcp.Session.register_tool/2 at runtime registration, ahead of every other wire-in validation at both sites: those validations call module.name() and module.actions(), so a module missing either would otherwise raise UndefinedFunctionError instead of a message naming the fix.

Completeness is all this verifies. A module that exports everything passes whether or not it declared @behaviour Wymcp.Tool — the invariant governs what a declaration promises, not who declared it, matching the duck-typing tolerance Wymcp.Router already grants server modules.