Wymcp. Context
(Wymcp v0.6.2)
View Source
Tool execution context and result builders.
Every tool receives a %Context{} as its first argument. The struct
carries the session reference, request metadata, and per-session
assigns. Module functions build MCP-compliant content arrays that
tools return in their result tuples.
Assigns
On the legacy lane, assigns merges per-request conn.assigns (set by
upstream plugs like auth) under the session's accumulated state —
session assigns win on collision, so accumulated tool state is not
overwritten by plug defaults. On the modern lane there is no session:
assigns is the filtered per-request conn.assigns alone, and a
{:ok, content, assigns_updates} return has nowhere to persist (the
next request is a fresh context).
The merge is legacy-only: it exists only because sessions do, and it
goes at the legacy decommission.
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, :wymcp_session_pid, etc.) are filtered
out and not visible in ctx.assigns.
Tools can update session-persistent assigns by returning
{:ok, content, assigns_updates} where assigns_updates is a map
that gets merged into the session's assigns for future requests.
Persisting them is legacy-only: it needs a session, and it goes at the
legacy decommission.
Tools
tools carries the tool modules resolved for this request — the
session's merged list on the legacy lane, the mount list on the
modern lane. Wymcp.Help answers from it; tools may read it for
introspection.
Era
era records which lane served the request — :modern or :legacy,
set by Wymcp.Methods.ToolsCall from the
era classification. A hand-built
context never reached the wire: it takes the struct default, :modern,
unless the test sets era: explicitly — Wymcp.Testing.build_context/1
passes it through like any field. Read it as provenance; never branch on
it — tools are defined once and serve both eras. Wymcp.Telemetry carries
the contract this field feeds.
era is legacy-only: it exists only because two eras do, and it goes at
the legacy decommission.
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: on the modern lane from the
answers the client threaded back with this round, merged over the ones
earlier rounds carried; %{} on a first round, on the legacy lane, 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.
answers is not legacy-only: the field exists because the modern lane
does.
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 modern lane has no session to 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. On the legacy lane the
round trip blocks inside elicit/4 and the body runs once, which
satisfies the rule trivially. A tool is written once and serves both.
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 three things it cannot compute — elicit/4,
log/4 and report_progress/4 each talk to something outside the
tool.
The deliberate split between "build content" (pure) and "interact with session" (effectful) keeps the common case simple: most tools just compute a result and return it.
The round trip the diagram below draws is legacy-only: it runs over a session's SSE stream. The contract survives via MRTR on the modern lane — the sequence above — and the diagram goes at the legacy decommission.
sequenceDiagram
autonumber
participant T as Tool
participant C as Context
participant S as Session
participant ST as Transport.Stream
participant CL as Client
T->>C: elicit(ctx, message, schema)
C->>S: check_capability
S-->>C: :ok
C->>S: await_client_response(request_id, message, timeout)
S->>ST: push request (ack to session)
ST->>CL: SSE event
ST-->>S: push ack
Note over S: caller held (server-request round trip)
CL->>S: POST response (deliver_response)
S-->>C: {:ok, result}
C-->>T: {:ok, result}
Summary
Types
The per-call tool execution context — the struct every tool receives
as its first argument: session reference, request identity and
metadata, merged assigns, the request's tool list, the serving era,
and the answers this round of the call carries. On the wire path the
serving lane builds it; tests build it with
Wymcp.Testing.build_context/1.
Functions
Asks the human user for structured input mid-tool-execution (form mode).
Sends a log message notification to the client via the SSE stream. This function is legacy-only: MCP logging exists only on the legacy lane, and it goes at the legacy decommission.
Sends a progress notification to the client via the SSE stream.
Types
@type t() :: %Wymcp.Context{ answers: map(), assigns: map(), era: :modern | :legacy, meta: map() | nil, request_id: term(), session_id: String.t() | nil, session_pid: pid() | nil, tools: [module()] }
The per-call tool execution context — the struct every tool receives
as its first argument: session reference, request identity and
metadata, merged assigns, the request's tool list, the serving era,
and the answers this round of the call carries. On the wire path the
serving lane 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.
How the answer arrives differs by lane, and a tool never learns which
lane served it. On the modern lane 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. On the legacy lane the call pushes an elicitation/create request
over the session's SSE stream and blocks until the user responds, so
the body runs once.
The error vocabulary, in full:
{:error, :not_supported}— the client did not declare form-mode elicitation. On the modern lane that means itsclientCapabilitiescarried noelicitationkey, or one namingurlalone; a context that never reached the wire reads the same way.{:error, error_map}— the client answered with a JSON-RPC error.{:error, :no_session}— a dead session: sessions end at DELETE, idle timeout, or crash and are never restarted, and this surface answers values, never exits.{:error, :unencodable}—message/schemaproduced a requestJSONcannot encode (theschemais the usual offender); answered synchronously in the calling process, with aLogger.warningnaming the session.{:error, :no_stream}— the session has no registered SSE stream.{:error, :disconnected}— the stream's chunk write failed; the client is gone.{:error, :stream_down}— the stream process died before acknowledging the push.{:error, :timeout}— the push-leg ack window (~5 s) expired (a wedged stream), or the client never answered withinopts[:timeout]; a:timeoutarriving only after the full timeout means the request was delivered.
Six of those are legacy-only — :no_session, :unencodable,
:no_stream, :disconnected, :stream_down and :timeout all name a
session's SSE stream. The contract survives via
MRTR on the modern lane, and they go at the
legacy decommission. opts[:timeout] is legacy-only for the same
reason: no call blocks on the modern lane, so the option is read
nowhere there.
On the modern lane 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}.
Sends a log message notification to the client via the SSE stream. This function is legacy-only: MCP logging exists only on the legacy lane, and it goes at the legacy decommission.
The message is filtered against the session's configured log level
(set via logging/setLevel). Messages below the threshold are
silently dropped.
The data parameter can be any JSON-serializable term — a string
for simple messages, or a map/list for structured data.
On the modern lane there is no session and no logging capability (the
Logging feature is Deprecated in 2026-07-28, and wymcp's modern lane
implements none of it): a session-less context answers
{:error, :not_supported}. With a live session the always-:ok
contract holds — filtered-out and undeliverable messages still answer
:ok.
Sends a progress notification to the client via the SSE stream.
The SSE delivery is legacy-only: it needs a session's stream. The contract survives via progress notifications riding modern SSE responses, and the delivery path goes at the legacy decommission.
Only sends if the request included a progressToken in _meta.
The progress value must increase with each call. The total
and message parameters are optional.
No-ops silently when there is no progress token or no session — this lets tools call report_progress unconditionally without checking whether the client requested progress updates.