Wymcp. Tool behaviour
(Wymcp v0.9.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,
checks required properties, refuses unknown keys at any depth of data and
values its schema refuses, 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, a map keyed by property name. A key that is not a string is rejected at each of the moments in "When schemas are validated" below, never converted to one — here and in every schema below it: a nested property, an"items"schema, an"additionalProperties"schema, whatever it holds. There, too, a"properties"must be a map and an"additionalProperties"true,falseor a schema, and the property schemas together must form a JSON Schema JSV can build (Wymcp.Tool.ValueGate); nothing else in a property schema is checked (Wymcp.Tool.Actionsstates why, under "The action-schema invariant").
Optional fields:
:required— list of unconditionally required property names (defaults to[]). Every listed property name 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%{}), keyed by property name: every key is a string naming a property declared in:properties, a default carries no key the unknown-key gate would refuse from a caller in the same place, every value satisfies its property's schema, 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.
A property counts as supplied when the caller's data carries its key,
whatever the value: a property sent as null satisfies :required,
counts as present in a :required_one_of group, and keeps its null
against a default. Defaults are applied after the dispatch gates run, so a
value :defaults adds does not count toward satisfying :required or
:required_one_of, and a default fills only a property the caller
omitted. Both checks run against the caller's data as received.
A null is still a value: the value gate checks it against the
property's schema like any other, so a property a caller may clear by
sending null declares that, as "type" => ["string", "null"], and help
publishes it so.
When schemas are validated
Action schemas are validated at two moments: at the tool module's own
compile, for a module built with use Wymcp.Tool; and when your mount
module compiles, via Wymcp.Router.init/1. A malformed schema
(e.g. a :required_one_of group referencing a property name not declared in
:properties, or a key outside the field list above) raises a
Wymcp.ConfigurationError 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.
An output schema is read as a JSON Schema at the same two moments, through
build_output_schema_root/1: a schema output_schema/0 returns that JSV
cannot build — a "type" naming no type, a $ref to a location the schema
does not hold, a remote $ref, which is never fetched, or a value JSON
cannot carry, such as a Regex where a "pattern" string belongs — raises
a Wymcp.ConfigurationError on that callback, saying what the build stopped
on. A schema that compiles is one tools/call can validate a tool's
structured content against.
Each action's schema is read as a JSON Schema at the same two moments,
through Wymcp.Tool.ValueGate.build_roots!/2, with format asserted: a
schema JSV cannot build — a format name it does not know, a value JSON
cannot carry, such as a keyword list where a property schema belongs —
raises a Wymcp.ConfigurationError on actions/0, naming the action,
and so does a :defaults value its property's schema refuses. The roots
built are what the value gate checks a call's data against.
Both moments run the same check — the tool-module moment through
validate_callback_shape!/1, the wire-in site through the same body,
which also hands back what it built for the mount to store — whose first
part is Wymcp.Tool.Actions.fetch_validated!/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 is the wire-in site, which catches 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 is caught by the hook instead,
and the error names the tool's own file. Either way the build fails before
a request is served, which is the guarantee, and the error names a file of
yours.
The hook reports a configuration refusal of its tool the way the compiler
reports its own errors: an error: block at the tool module's file, on the
def line of the callback the refusal names — your own def, where you
override a callback the macro defaults — with that source line beneath it.
It reports on the use Wymcp.Tool line instead when the refusal names no
callback, or when the module was compiled without debug info
(elixirc_options: [debug_info: false]) and the def lines cannot be
read. The message is the configuration refusal's own, and the compile then
ends with a nonzero exit. It is an error rather than a warning because the
guarantee above is that the build fails, and a warning fails it only under
--warnings-as-errors.
A raise that is not a configuration refusal of the tool being checked is
not reported this way: your callback's own error — a read of absent
application config, say — or a configuration refusal of another module
that your callback reached ends the compile as it was raised, with its
stack trace, because that trace is what points at your code.
Wymcp.ConfigurationError states why the two are told apart.
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
whose mandatory keys are missing or malformed (the read-side corollary,
Wymcp.Tool.Actions).
Both moments read more than the action schemas. The callback-shape check
calls every zero-arity callback — name/0, description/0,
title/0, annotations/0, output_schema/0 and input_schema/0
alongside actions/0 — and at the mount-module moment init/1 then
builds each tool's tools/list definition from them, the root each
action's data is checked against and, for a tool with an output schema,
the root tools/call validates structured content against. All of them must be
callable with no runtime state — they run during the consuming
application's build, before config/runtime.exs and before any
supervision tree exists. A callback reaching for either fails that build
only where the reach raises, surfacing as its own error and stack trace at
whichever moment reports the tool first, as above; a defaulted read
silently supplies the compile-environment value, which is then what is
served until the next build. Wymcp.Router's __using__/1 documentation
states the contract that follows: what was validated is what is sent.
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; a return outside this vocabulary is a fault, contained
as the next paragraph says.
A fault out of run/2 is contained rather than
propagated, and this boundary contains all three kinds — a raise, an exit and
a throw, in the request process. Wymcp.Methods.ToolsCall catches each of
them and answers with isError: true and a JSON diagnostic body, so a tool
need not guard its own run/2 defensively. errorType is "tool_fault"
whatever the kind — the value names the boundary, not what happened at it —
tool names the tool, exception carries the exception struct's name for a
raise and "exit" or "throw" for the other two, and message carries the
fault bounded: an exception's message, or an
exit's or throw's reason. The reader is a client's LLM, and an error body
may not spend its budget echoing what the tool was
handed. The same fault emits [:wymcp, :tool, :error]
(Wymcp.Telemetry), where that rendering rides as error and the term it
was made from rides whole as crash_reason.
A linked process's abnormal exit is a signal the boundary cannot see: it
kills the request process rather than unwinding through it, so no catch
reaches it. A tool that links to a process that may crash still takes the
request down.
Dispatch errors and self-correction
The dispatch gates reject a call before the action handler runs, in this
order: a key outside the arguments vocabulary, an argument of the wrong
JSON type, an absent action, an unknown action name, a missing
:required field, an unsatisfied :required_one_of group, an unknown key
anywhere in data, and a value in data its schema refuses — the
value gate. 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 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 gates
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 unknown-key gate reads data against the action's :properties at
every depth. Every object schema whose key set is closed refuses a key
outside its property names: data itself, every object schema under it
that declares properties and writes additionalProperties as neither
true nor a schema — absent or false, since a schema carrying any other
value there never registers — and one that
declares no properties but writes additionalProperties: false, which
admits no key at all.
additionalProperties: true opens an object; an additionalProperties
schema admits extra keys and closes their values by that schema; any other
object schema that declares no properties stays free-form. The gate
reads keys, never values — those are the value gate's, below — and which
keywords it follows is Wymcp.Tool.KeySet's to state. One answer covers
every unknown key, and
its size stays bounded however many the caller sent: unknown_count is
their number, unknown lists the first ten in sort order as
data paths, and allowed
maps the data path of each object holding one of those to that object's
property names, with "<any string key>" among them where an
additionalProperties schema admits any string key — data itself under
"" — so the retry needs no help call. A bulk call is
refused whole, whichever item carried the key. Help and the
input_schema digest publish the same closure, as
additionalProperties: false on each nested object it applies to, so the
schema a caller reads states what this gate enforces.
The value gate runs last, so it only ever meets a call whose keys are all
known. It checks every value in data, as the caller sent it and before
:defaults are applied, against the action's schema with format
asserted, and an explicit null is a value like any other. One answer
covers every offending value: invalid lists the first ten
data paths in sort order, each with its
problems, and invalid_count counts them all, so a bulk call is refused
whole, however many of its items offend. A call that passes reaches
run_action/3 with data as the gate read it, so a whole-number float
such as 2.0, sent where the schema says "integer", arrives as 2.
Wymcp.Tool.ValueGate states how a problem is worded, why no value the
caller sent is quoted, and what building the answer costs.
The gates that quote the call back — unknown_arguments, unknown_action,
unknown_params and invalid_values — quote it under the rule
Wymcp.Bound states in "What a refusal quotes": each quoted value goes
through Wymcp.Bound.echo/1 — and so does each invalid_values
problem, whose text names no sent value (Wymcp.Tool.ValueGate says
which keywords it rewrites) — and unknown_arguments lists the first ten
unknown keys in sort order with unknown_count their number, the shape
unknown_params has.
Everything inside a call's arguments is the dispatch gates' to refuse,
and a JSON-RPC error is left for what fails the CallToolRequest schema
itself — no tool name, arguments that are not an object, an unknown
tool (below). The spec files input validation under tool execution
errors, and a client shows the model an isError answer, where a
protocol error it only may; so a wrong type is answered as a stray key
is, naming the argument and pointing at help. The arguments-level gate
checks names before types: a caller who makes both mistakes at once is
told about the stray key first, and meets the type on 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 runs its own arguments — tool and action —
through the same check_arguments/4, names and types alike, with the
types read from the schema it hand-writes, and renders its unknown-target
errors through the same pointer helpers, so the two surfaces cannot drift
into two error dialects. The data-level gates are a generated tool's
alone: help takes no data.
One rejection in this family is not isError content. An unknown
tool name never reaches a tool module at all: the answer is -32602
(Invalid params), naming the tool that was asked for in the envelope's
message — -32601 is reserved for methods the server does not
implement, which are answered with HTTP 404, so an unknown tool carrying
the same code at 200 would be indistinguishable by code alone.
The action-schema invariant
Stated in Wymcp.Tool.Actions, beside the chain that enforces it: the rule
that a key outside the vocabulary is rejected and a key inside it reaches a
validator, its three clauses, and the read-side corollary that an action
schema obtained for reading carries its mandatory keys. The vocabulary and
the format catalogue are here, because a consumer writes against them; the
rule about them is there, because that is where it is enforced.
The callback-surface invariant
Stated in Wymcp.CallbackSurface, beside the check that enforces it. This
behaviour's share is its optional callbacks, each probed before it
is called (→ "Optional callbacks" below), and the check's second call
site: the hook use Wymcp.Tool injects runs it at a macro tool's own
compile, ahead of the callback-shape check.
The callback-shape invariant
Every zero-arity callback this behaviour declares is shape-checked at a
use Wymcp.Tool module's own compile and at the
registration moment: a return
without the shape below is refused at whichever runs first, by a message
naming the tool, the callback, the shape and the value returned. That rule
is the callback-shape invariant.
| Callback | Must return |
|---|---|
actions/0 | a map, and each action's schema in it a map |
name/0 | a non-empty string |
description/0 | a string, "" included |
title/0 | a string or nil, when exported |
annotations/0 | a map or nil, when exported |
output_schema/0 | a map or nil |
input_schema/0 | a map |
A map here is never a struct. A struct passes is_map/1 and then fails
further in, naming no callback, so the check refuses one — although the
callbacks' types say map(), because no typespec expresses a map that is
not a struct.
The rule is about shape: whether a value is a string, a map or nil at
all. What a well-shaped value contains is another layer's — the action
schemas' is Wymcp.Tool.Actions' and Wymcp.Tool.ValueGate's, which
refuses one JSV cannot build, and an output schema's is
build_output_schema_root/1's, which refuses a map JSV cannot build as a
JSON Schema — or no layer's: wymcp holds no content rule the spec does not.
validate_callback_shape!/1 is the check.
Wymcp.CallbackShapeInvariantTest derives its cells from
behaviour_info(:callbacks), one wrong return per cell, so a zero-arity
callback declared later fails that test until the check covers it.
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 the root built from it at the registration moment, returning"structuredContent"alongside"content". Default:nil
The remaining two required callbacks — input_schema/0 and run/2 — 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.
A tool's tools/list definition is not a callback at all. The framework
assembles it from the callbacks above, through
Wymcp.Tool.build_definition/1, so title/0, annotations/0 and
output_schema/0 are the whole of what a tool contributes to it beyond its
name, description and input schema.
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_schemas!/1"| AC[Tool.Actions]
D -->|"action_names/1, fetch_schema/2"| AC
D -->|"check/2, close_properties/1"| KS[Tool.KeySet]
T -->|"fetch_validated!/1"| AC
T -->|"validate!/3"| CS[CallbackSurface]
T -->|"rescues, at its own compile"| CE[ConfigurationError]
T -->|"build/1"| JV[JSV]
T -->|"build_roots!/2"| VG[Tool.ValueGate]
D -->|"build_root!/3, check/2"| VG
AC -->|"action_schema_keys/0"| T
AC -->|"schema_offences/1, default_offences/2"| KS
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 schema 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. Which keys are mandatory is the type's
own statement below: a key written bare is required, a key under
optional(...) is not. Stated there rather than restated here, because
the type's split is what a cell pins to the runtime list; a sentence
naming the keys would be a fourth statement pinned by nothing.
Functions
The action-schema key vocabulary: every key an action schema may carry.
Builds a tool's definition — the wire object a tools/list entry carries:
name, description, inputSchema, plus title, annotations, and
outputSchema when the tool declares them.
Build module's output schema into the JSV.Root that tools/call
validates structured content against, or return nil when
output_schema/0 returns nil.
The mandatory half of action_schema_keys/0: the keys every action schema
must carry. What being mandatory guarantees a reader is the read-side
corollary stated in Wymcp.Tool.Actions.
Validate what module's zero-arity callbacks return, against the shapes in
"The callback-shape invariant" above. Raises Wymcp.ConfigurationError
naming the module, the callback, the shape it must return and the value it
returned, and carrying the module and the callback.
Types
@type action_schema() :: %{ :description => String.t(), :properties => %{optional(String.t()) => map()}, optional(:required) => [String.t()], optional(:required_one_of) => [[String.t()]], optional(:defaults) => %{optional(String.t()) => term()}, optional(:notes) => String.t(), optional(:related) => [String.t()], optional(:examples) => [map()] }
One action's schema 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. Which keys are mandatory is the type's
own statement below: a key written bare is required, a key under
optional(...) is not. Stated there rather than restated here, because
the type's split is what a cell pins to the runtime list; a sentence
naming the keys would be a fourth statement pinned by nothing.
@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 description() :: String.t()
@callback input_schema() :: map()
@callback name() :: String.t()
@callback output_schema() :: map() | nil
@callback run(ctx :: Wymcp.Context.t(), arguments :: map()) :: {:ok, content :: term()} | {:error, message :: String.t()} | {:error, message :: String.t(), :dispatch | :tool}
@callback title() :: String.t() | nil
Functions
The action-schema key vocabulary: every key an action schema may carry.
This list is the one code home for the vocabulary. action_schema/0 and
the "Action schema format" section restate it, and
Wymcp.ActionSchemaInvariantTest pins both restatements to it;
Wymcp.Tool.Actions' validator chain reads it here at each validation. A
key exists for the framework by joining this list.
Builds a tool's definition — the wire object a tools/list entry carries:
name, description, inputSchema, plus title, annotations, and
outputSchema when the tool declares them.
The one assembly for every tool, Wymcp.Help included, so a key added here
reaches every entry without hand-sync. Wymcp.Router.init/1 builds each
mount tool's definition through the same assembly at the registration
moment — handed the input schema it has already read there, so the tool's
actions are obtained once — and stores the result in the mount's
configuration, which is what tools/list serves.
title/0 and annotations/0 are optional callbacks, probed before the
call; output_schema/0 is required and called outright. All three read
nil as the omit-the-key signal, and an empty annotations map is omitted
too. Each value is placed as returned: holding a return to its shape is the
callback-shape check's job (validate_callback_shape!/1), and
Wymcp.Router.init/1 runs it before building any definition.
A caller running at compile time must know the module is already compiled.
The optional-callback probe is Code.ensure_loaded?/1, which answers false
for a module still compiling in the same run and never waits, and the two
optional keys are then omitted rather than the call failing.
Wymcp.Router.init/1 satisfies this through the callback-surface check it
runs first.
Build module's output schema into the JSV.Root that tools/call
validates structured content against, or return nil when
output_schema/0 returns nil.
The callback-shape check calls it once at either registration moment: it
refuses a schema JSV cannot build there, and at the wire-in site
Wymcp.Router.init/1 stores the root that one call returned, so the root
stored is the one the check accepted, built once. A schema
JSV cannot build raises Wymcp.ConfigurationError naming the tool and what
the build stopped on — where in the schema it failed and why, or, where
JSV raises instead of returning an error (a value JSON cannot carry, such
as a tuple or a Regex, or a keyword of the wrong type), JSV's own
message — and carrying the module and {:output_schema, 0}, so the
report lands on the tool's output_schema/0.
JSV's default resolvers fetch nothing over the network, so a remote $ref
is refused here rather than resolved.
The mandatory half of action_schema_keys/0: the keys every action schema
must carry. What being mandatory guarantees a reader is the read-side
corollary stated in Wymcp.Tool.Actions.
Validate what module's zero-arity callbacks return, against the shapes in
"The callback-shape invariant" above. Raises Wymcp.ConfigurationError
naming the module, the callback, the shape it must return and the value it
returned, and carrying the module and the callback.
It then builds what the registration moment stores from those returns:
output_schema/0's schema through build_output_schema_root/1, which
refuses a map JSV cannot build, and one root per action through
Wymcp.Tool.ValueGate.build_roots!/2, which refuses an action schema JSV
cannot build and a :defaults value its property schema refuses.
Wymcp.Tool.Actions.fetch_validated!/1 runs first, so the actions/0
container and every action schema are checked before any other callback is
called: a generated input_schema/0 builds from those schemas, and on a
malformed container it would raise the read side's refusal, whose "never
validated" is false for a tool that is being validated.
title/0 and annotations/0 are optional, and are checked only when
module exports them. That probe does not wait for a module still
compiling, which is why Wymcp.Router.init/1 runs this check — through
the same body, which also returns what it built — after the
callback-surface check, Wymcp.CallbackSurface.validate!/3, which does.