Wymcp.Context (Wymcp v0.1.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).

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.

Tools

tools carries the tool modules resolved for this request — the session's merged list on the legacy lane, the compile-time 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 (docs/glossary.md, era classification). A hand-built context never reached the wire and takes the struct default, :modern. 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.

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 there for future phases when tools need to call sample/3 or elicit/4, which communicate with the session GenServer.

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.

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: sample(ctx, prompt) or 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

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.

Sends a progress notification to the client via the SSE stream.

Asks the client's LLM a question mid-tool-execution.

Types

content()

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

t()

@type t() :: %Wymcp.Context{
  assigns: map(),
  era: :modern | :legacy,
  meta: map() | nil,
  request_id: term(),
  session_id: String.t() | nil,
  session_pid: pid() | nil,
  tools: [module()]
}

Functions

audio(base64_data, mime_type)

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

Asks the human user for structured input mid-tool-execution (form mode).

Pushes an elicitation/create request to the client via the SSE stream and blocks until the user responds. The client renders a form based on the JSON Schema and returns typed, validated data.

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. The error vocabulary is Wymcp.Context.sample/3's, in full — :not_supported (client lacks the elicitation capability), :no_session, :unencodable (the schema is the usual offender), :no_stream, :disconnected, :stream_down, and the two-sense :timeout — plus {:error, error_map} when the client answers with a JSON-RPC error.

image(base64_data, mime_type)

json(data)

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

Sends a log message notification to the client via the SSE stream.

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.

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.

sample(ctx, prompt, opts \\ %{})

Asks the client's LLM a question mid-tool-execution.

Pushes a sampling/createMessage request to the client via the SSE stream and blocks until the client responds. The client has full discretion over model selection, prompt modification, and approval.

opts are merged into the params of the request. Common options:

  • "maxTokens" (integer, required by spec but defaults to 1024)
  • "modelPreferences" (map with "hints", priority axes)
  • "systemPrompt" (string)
  • "temperature" (float)

Returns the client's {:ok, result} or {:error, error_map} (the client answered with a JSON-RPC error), or one of these tuples — the full vocabulary:

  • {:error, :not_supported} — the client did not declare the sampling capability.
  • {:error, :no_session} — no session (session_pid is nil), or a dead one: sessions end at DELETE, idle timeout, or crash and are never restarted, and this surface answers values, never exits.
  • {:error, :unencodable}prompt/opts produced a request JSON cannot encode; 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 the sampling timeout; a :timeout arriving only after the full timeout means the request was delivered.

text(text)