Wymcp. Tool behaviour
(Wymcp v0.2.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
endAction 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 each of the moments in "When
schemas are validated" below, like :description). Each action must have:
:description— human-readable description of the action, emitted verbatim into the action summaries (thetools/listaction enum, the help index) and into help's tool and action levels. Must not contain a newline — validated at each of those moments. See "Consumer-authored text" below.:properties— JSON Schema properties for the action'sdataparameter
Optional fields:
:required— list of unconditionally required property names (defaults to[]). Every listed field must be present indata(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 thehelptool.:defaults— map of default values merged intodatabefore dispatch (defaults to%{}). Every key must be declared in:properties, and no key may also appear in:required— validated at each of those moments.:notes— long-form notes surfaced by thehelptool.:related— list of related action name strings surfaced by thehelptool.:examples— list of example payload maps surfaced by thehelptool.
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.
When schemas are validated
Action schemas are validated at three moments: at the tool module's own
compile, for a module built with use Wymcp.Tool; when your mount module
compiles, 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.
All three moments run the same validate_actions!/1, so a rule holds
identically at each. The tool-module moment is the macro's alone: a
behaviour-only tool — one that declares @behaviour Wymcp.Tool without
the macro — never runs it, and its schemas are first read at wire-in. The
other two are the wire-in sites, which catch a schema however it was
declared.
Which moment reports a given tool first is not fixed, and the ordering is
not worth relying on. The macro's hook is @after_verify, which the
compiler runs only once the whole compilation set is compiled — so when a
mount module in that same set lists the tool, Wymcp.Router.init/1 raises
during the mount module's compile and the hook never runs; the error names
the mount file. A tool no mount module names, or one whose mount defers
init/1 to runtime (config :phoenix, :plug_init_mode, :runtime, the
usual dev and test setting), is caught by the hook instead. Either way the
build fails before a request is served, which is the guarantee — the file
named in the error is the part that varies.
A tool that reaches neither moment is not covered here at all: a behaviour-only tool that is never wired in is first checked where a reader obtains one of its schemas, which raises rather than serve an action schema missing a mandatory key (the read-side corollary under "The action-schema invariant").
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 each of the moments in "When schemas are validated" above, 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 callshints/2with the action and hint_context, injecting the result{:error, reason}— error; the framework callshandle_error/1and sends the result as an isError response{:error, reason, hint_context}— error with hints; the framework callshandle_error/1,hints/2, andaction_context/2, then sends structured JSON witherror,hints, and optionalcontextkeys
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:toolin telemetry{:error, message, :dispatch | :tool}— an error carrying its own classification::dispatchmeans a gate rejected the call before the action handler ran (the generatedrun/2returns this for its own dispatch-gate rejections),:toolmeans 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 type — action not a string, data not an
object — is a malformed request, and Wymcp.Methods.ToolsCall answers it
as -32602 from two hand-written checks of its own. A wrong name is a
vocabulary mistake, and a generic schema-path rejection would name no key
and suggest 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 vocabulary surfaces cannot drift
into two error dialects. Types are the one carve-out: help hand-writes its
schema, so it owns the types of the keys it declares and answers a mistyped
tool or action from its own gate rather than as the framework's
-32602 — the framework's two type checks cover only its own
action/data keys (Wymcp.Help states the rule at its gate).
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 wherever validate_actions!/1 runs
(unknown implies rejected), and every key inside it is checked by a
validator in that chain which 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 rejected —
validate_known_keys!/3subtracts the vocabulary from an action schema's keys and raises on whatever is left, so a misspelling fails at one of the moments above instead of vanishing silently from help andtools/list. - known implies validated —
Wymcp.ActionSchemaInvariantTestderives one cell per key fromaction_schema_keys/0and 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.
A read-side corollary follows: an action schema obtained for reading
carries its mandatory keys, and that is checked where it is obtained — not
where a field is read. fetch_action_schema!/3 and
fetch_action_schemas!/1 are the only paths by which a reader obtains an
action schema, and each checks the mandatory pair — :description and
:properties, the two mandatory_action_schema_keys/0 names — by calling
the same two validators the wire-in chain runs, so one definition of
carrying a key serves both sides. A reader holding a schema may therefore
access those keys directly; a reader meeting a schema without them is
looking at a tool no validator ever saw, and obtaining raises rather than
let it publish an entry whose silence about :properties would read as a
claim that the action takes none.
Optional keys carry no such guarantee and are read with their default.
The check covers exactly what was obtained: the one-schema form checks one,
the all-schemas form checks all. That scope is the design, and over the wire
it reads as malformed-sibling isolation: a tool with one bad action
schema still serves its healthy actions through tools/call, which obtains
only the schema of the action it dispatches. The malformation surfaces on
every surface that renders a whole tool — tools/list, and both of
Wymcp.Help's whole-tool answers, its server index and its tool level, all
three of which obtain every schema a tool declares — and at the bad
action's own call, which raises and is answered in the tool dialect. Help's
action level is the one-schema surface, and is consistent with dispatch:
both hold the actions map as name material and obtain only the schema they
render.
Each entry point obtains once and passes the map it obtained to whatever it
calls — a path that obtained twice could observe two different maps, for a
tool whose actions/0 result varies between calls, and each map would be
individually valid.
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 guarded —
Wymcp.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-in —
validate_callback_surface!/1runs atWymcp.Router.init/1andWymcp.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 oflib/formodule.<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, ornil. Receives(action_atom, ctx), wherectxis the sameWymcp.Context.t()passed torun_action/3. Called by thehelptool 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 returnnilor a map — any other return raises, on the dispatch and help paths alike. Read per-request data fromctx.assignsrather than the process dictionary —action_contextmay be invoked from a process that did not run the auth plug.title/0— returns a display title for the tool, ornil. A tool that does not export it gets no"title"key in itstools/listdefinition.annotations/0— returns an MCP annotations map, ornil. 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, ornil. When present,tools/listincludes"outputSchema"in the definition andtools/callvalidates 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]
S -->|"fetch_action_schemas!/1"| T
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
Types
One action's definition map, as actions/0 declares it: what the
action does (:description), the parameters it takes (:properties),
and the optional constraint and documentation keys — see "Action
schema format" in the moduledoc. :description and :properties are
mandatory; the other six keys are optional.
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
@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()] }
One action's definition map, as actions/0 declares it: what the
action does (:description), the parameters it takes (:properties),
and the optional constraint and documentation keys — see "Action
schema format" in the moduledoc. :description and :properties are
mandatory; the other six keys are optional.
@type hint() :: Wymcp.Hint.t()
Callbacks
@callback action_context(action :: atom(), ctx :: Wymcp.Context.t()) :: map() | nil
@callback actions() :: %{required(atom()) => action_schema()}
@callback annotations() :: map() | nil
@callback definition() :: map()
@callback description() :: String.t()
@callback input_schema() :: map()
@callback name() :: String.t()
@callback output_schema() :: map() | nil
@callback title() :: String.t() | nil
Functions
Validate every action schema in module. Raises ArgumentError with a
descriptive message on the first malformed action.
Called at all three of the moments a tool's schemas are checked: by
__after_verify__/1 while a use Wymcp.Tool module itself compiles, by
Wymcp.Router.init/1 while a mount module compiles, and by
Wymcp.Session.register_tool/2 at runtime registration — so a
misconfigured tool fails as early as its own build, and no later than the
point it is wired in.
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 while a mount module compiles 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.
That tolerance is why this check carries more weight than its size
suggests. use Wymcp.Tool validates a module's action schemas at that
module's own compile, and a module declaring the behaviour by hand never
runs that code — so the two classes are not guarded alike: for a macro
tool, wire-in is a second opinion, while for a behaviour-only tool it is
the first moment anything reads its schemas at all, and the only one if it
is never wired in.
Reaching the module uses Code.ensure_compiled/1, which waits: at a mount
module's compile the tool modules are in the same parallel-compiler run and
have no .beam yet, so a non-waiting load would refuse every real mount.
The compiler's error answer is not diagnostic — it reports the same
:unavailable for a module it merely could not supply in time as for one
genuinely waiting on its own caller — so the single raise names both causes
rather than guessing between them. Do not split it by reason.