Wymcp.Context (Wymcp v0.6.1)

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]
  # ...
end

Internal 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: complete

Three 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 running Wymcp.Tool.run/2, so a spawned process can be neither counted nor unwound — elicit/4 raises there by name rather than ending a round that is not its own; and a catch around an elicit intercepts that unwind, which a rescue deliberately 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

t()

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

content()

@type content() :: [%{required(String.t()) => binary()}, ...]

t()

@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

audio(base64_data, mime_type)

elicit(ctx, message, schema, opts \\ %{})

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 its clientCapabilities carried no elicitation key, or one naming url alone; 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/schema produced a request JSON cannot encode (the schema is the usual offender); answered synchronously in the calling process, with a Logger.warning naming 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 within opts[:timeout]; a :timeout arriving 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}.

image(base64_data, mime_type)

json(data)

log(ctx, level, data, opts \\ [])

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.

progress_token(context)

report_progress(ctx, progress, total \\ nil, message \\ nil)

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.

text(text)