Wymcp.Context (Wymcp v0.8.7)

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

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

t()

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.

Types

content()

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

t()

@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

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.

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: its clientCapabilities carried no elicitation key, or one naming url alone; 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.

image(base64_data, mime_type)

json(data)

text(text)