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
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 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 (the tools/list action 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's data parameter, 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, false or 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.Actions states why, under "The action-schema invariant").

Optional fields:

  • :required — list of unconditionally required property names (defaults to []). Every listed property name 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 %{}), 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 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.

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 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; 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.

CallbackMust return
actions/0a map, and each action's schema in it a map
name/0a non-empty string
description/0a string, "" included
title/0a string or nil, when exported
annotations/0a map or nil, when exported
output_schema/0a map or nil
input_schema/0a 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, 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 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

action_schema()

@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.

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

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()}
  | {: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

action_schema_keys()

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.

build_definition(module)

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_output_schema_root(module)

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.

mandatory_action_schema_keys()

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_callback_shape!(module)

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.