Wymcp. Context
(Wymcp v0.8.3)
View Source
Tool execution context and result builders.
Every tool receives a %Context{} as its first argument. The struct
carries the request's identity and metadata, the per-request assigns,
the request's tool list, and the answers this round of the call
carries. Module functions build MCP-compliant content arrays that tools
return in their result tuples.
Assigns
assigns is the filtered per-request conn.assigns (set by upstream
plugs like auth). Nothing persists across requests: the server is
stateless, and no return of run/2 writes to it — the next request is a
fresh context.
This means auth plugs can store data in conn.assigns and tools will
see it in ctx.assigns without any process dictionary workarounds:
# In your auth plug:
{:ok, Plug.Conn.assign(conn, :current_scope, scope)}
# In your tool's run_action:
def run_action(:create, data, ctx) do
scope = ctx.assigns[:current_scope]
# ...
endInternal wymcp keys (:wymcp, and every wymcp_* assign) are filtered
out and not visible in ctx.assigns.
Tools
tools carries the tool modules serving this request — the mount list.
Wymcp.Help answers from it; tools may read it for introspection.
Answers
answers carries what the client has already told this call — one
entry per elicit/4 the run has reached, keyed by that call's position
in run order. The framework sets it from the answers the client threaded
back with this round, merged over the ones earlier rounds carried; %{}
on a first round and on a context built by hand. elicit/4 is its only
reader inside a tool
run — a tool never looks inside it, and nothing branches on whether it
is empty; Wymcp.Methods.ToolsCall reads it once more after the run,
to mint the requestState an interrupted call carries back.
A test can preset it to stand in for a retry: a context built by
Wymcp.Testing.build_context/1 with a meta declaring elicitation and
an "elicit-1" entry under answers: makes the first elicit of a run
answer at once instead of ending it — without the capability in meta
the elicit answers {:error, :not_supported} and never reads
answers. Such a run goes through Wymcp.Testing.run_tool/3: only a
run inside that boundary has a round to count its elicits in, and an
elicit that finds the capability but no open round raises. The helper
is also the way to see the question a run stopped on.
Re-execution
A tool that elicits must be safe to run again up to its last unanswered elicit: the work before an elicit runs once per round, so it is side-effect-free or idempotent; the work after the final answer runs once.
The rule exists because the server holds no state between requests, so nothing can hold a blocked caller. A call that reaches an elicit nothing answers ends there; the client is told what to ask and sends the whole call again with the answer, so a tool asking two questions runs its body three times, stopping one question later each round.
sequenceDiagram
autonumber
participant CL as Client
participant TC as Methods.ToolsCall
participant T as Tool
participant C as Context
CL->>TC: tools/call, no answers
TC->>T: run(ctx, arguments)
T->>C: elicit(ctx, message, schema)
C-->>TC: ends the round — nothing answers elicit 1
TC-->>CL: input_required, requestState empty
CL->>TC: tools/call again, inputResponses for elicit 1
TC->>T: run(ctx, arguments)
Note over T: the work before the elicit runs a second time
T->>C: elicit(ctx, message, schema)
C-->>T: {:ok, answer}
T-->>TC: {:ok, content}
TC-->>CL: completeThree things break the rule, and all three are the tool's to avoid:
- Work that repeats. A write before an elicit happens again on every round. Ask first, then write.
- A run whose shape does not follow from its answers. Branching on the clock or on randomness moves which elicit is which, and answers then land on questions they were not given for.
- Eliciting from a spawned process, or inside a
catch. The run's position counter and the unwind that ends a round both belong to the process runningWymcp.Tool.run/2, so a spawned process can be neither counted nor unwound —elicit/4raises there by name rather than ending a round that is not its own; and acatcharound an elicit intercepts that unwind, which arescuedeliberately does not.
Design decisions
Result builders (text/1, json/1, image/2, audio/2) are pure
functions — no side effects, no process messages. This makes tools
easy to test in isolation. The %Context{} struct is what a tool
reaches through for the one thing it cannot compute — elicit/4 asks
the client.
The deliberate split between "build content" (pure) and "ask the client" (a round of its own) keeps the common case simple: most tools just compute a result and return it.
Summary
Types
The per-call tool execution context — the struct every tool receives
as its first argument: request identity and metadata, the per-request
assigns, the request's tool list, and the answers this round of the
call carries. On the wire path Wymcp.Methods.ToolsCall builds it;
tests build it with Wymcp.Testing.build_context/1.
Functions
Asks the human user for structured input mid-tool-execution (form mode).
Types
@type t() :: %Wymcp.Context{ answers: map(), assigns: map(), meta: map() | nil, request_id: term(), tools: [module()] }
The per-call tool execution context — the struct every tool receives
as its first argument: request identity and metadata, the per-request
assigns, the request's tool list, and the answers this round of the
call carries. On the wire path Wymcp.Methods.ToolsCall builds it;
tests build it with Wymcp.Testing.build_context/1.
Functions
Asks the human user for structured input mid-tool-execution (form mode).
The schema must be a flat JSON Schema object (primitive properties
only, no nested objects). The client renders appropriate UI controls
for each field type.
On {:ok, response} the response includes an "action" field:
"accept" (user submitted), "decline" (user refused), or "cancel"
(user dismissed). When action is "accept", "content" contains the
validated form data. A decline and a cancel are answers like any other:
the call returns, the tool decides what to do, and the question is
never re-asked.
The answer arrives under MRTR: the call
either finds its answer among the ones the client has already sent back
and returns it, or ends the run — the tools/call answers the
input-required result Wymcp.Modern.input_required_result/3 builds, and
the client retries the whole call carrying the answer. A tool body
therefore runs once per round — the re-execution rule it must be safe
for is stated in the Re-execution section of the Wymcp.Context
moduledoc, with what breaks it.
The error vocabulary, in full:
{:error, :not_supported}— the client did not declare form-mode elicitation: itsclientCapabilitiescarried noelicitationkey, or one namingurlalone; a context that never reached the wire reads the same way.
The call also raises ArgumentError when no tool run is open in the
calling process — a test calling run/2 directly rather than through
Wymcp.Testing.run_tool/3, or an elicit from a process the tool
spawned; why the run's own process is the only one that can count and
unwind it is the Re-execution section's. The capability check comes
first, so a context that declares no form elicitation still answers
{:error, :not_supported}.
opts is accepted and unread: no call blocks, so there is no timeout to
take, and the arity stays what a tool written for an earlier revision
calls.