Wymcp.Testing (Wymcp v0.8.7)

View Source

Conveniences for testing Wymcp tools from a consuming application's test suite.

Two groups of helpers:

  1. Direct tool testing — build_context/1, run_tool/3, and the unwrap_* extractors: assert on run/2's tagged return tuples (the return contract, including the classified error form, lives in Wymcp.Tool), unit-testing a tool module in isolation.

  2. HTTP response testing — the request builders (build_request/2 and its tools/call specialisations), put_mirrored_headers/2 and the *_response extractors: build a request body the router serves, put the headers that body implies on the connection, and pull content out of a Plug.Conn response body, integration-testing through Wymcp.Router.

Direct tool testing

ctx = Wymcp.Testing.build_context()
assert {:ok, content} = MyTool.run(ctx, %{"action" => "create", "data" => %{"name" => "x"}})
assert "expected" = Wymcp.Testing.unwrap_text(content)

A generated run/2 takes the whole arguments object, whose key set is closed to action and data — an action's own fields go inside data. A key outside that set is answered by a dispatch gate, not run; see "Dispatch errors and self-correction" in Wymcp.Tool.

With assigns

ctx = Wymcp.Testing.build_context(assigns: %{current_scope: scope})
assert {:ok, content} = ScopedTool.run(ctx, %{"action" => "list"})

HTTP response testing

body = Wymcp.Testing.build_action_request("tasks", "get", %{"id" => "7"})

conn =
  conn(:post, "/", JSON.encode!(body))
  |> put_req_header("content-type", "application/json")
  |> Wymcp.Testing.put_mirrored_headers(body)
  |> MyApp.Mcp.call(MyApp.Mcp.init([]))

assert "expected" = Wymcp.Testing.text_response(conn)

A built body carries the per-request protocol fields (protocol_fields/0) and put_mirrored_headers/2 derives the mirrored headers from it, so the request passes Wymcp.Plugs.ProtocolFields and Wymcp.Plugs.HeaderBinding as a compliant client's would for a use Wymcp.Tool tool — a hand-written schema's header annotations need their headers added, as mirrored_headers/1 says; what the mount's wire checks ask for — a bearer token, an allowed Origin — is the test's to add. The mount is called directly, so the path is the router's own "/"; a Phoenix forward strips its prefix before the router sees the path.

Summary

Functions

image_response/1 for audio content: extracts the single content item of a successful tool response.

Builds a tools/call request body for an action-dispatched tool: wraps the action name and optional data into the arguments object. An empty data is omitted rather than sent as an empty object.

Builds a complete tools/call JSON-RPC request body: build_request/2 for "tools/call", with tool_name and arguments as its params.

Builds a Wymcp.Context for calling a tool's run/2 directly, without a router. One field takes a builder default — :request_id is 1; every other field defers to the struct's own default (:assigns to %{}, :tools to [], :answers to %{}, :meta to nil). Pass overrides for the ones a test cares about (most often :assigns). A context whose meta declares elicitation is run through run_tool/3, never handed to run/2 directly: Wymcp.Context.elicit/4 raises where no round is open.

Builds a complete JSON-RPC request body for method, for POSTing through the router (request id fixed at 1), carrying protocol_fields/0 under params._meta beside the params given. Its mirrored headers are put_mirrored_headers/2's.

Extracts the text of an isError: true tool response from a router Plug.Conn — raising on a successful result, the mirror of text_response/1.

Extracts the single content item of a successful tool response — for image content, where the item map ("data", "mimeType") is the assertion target rather than a text field.

Like text_response/1, then decodes the text as JSON — for tools that answer with Wymcp.Context.json/1 content.

The mirrored headers body implies, as {wire name, value} pairs — what Wymcp.Plugs.HeaderBinding compares against the body on every request: MCP-Protocol-Version and Mcp-Method always, and Mcp-Name for a tools/call whose name is a string. A generated input schema annotates no argument, so these are all a call to a use Wymcp.Tool tool is compared on. A tool whose hand-written schema annotates further arguments needs those headers on top.

The per-request protocol fields the request builders put under params._meta: the one protocol version wymcp serves, and empty client capabilities — a client declaring no form elicitation, so a tool that calls Wymcp.Context.elicit/4 under a built request answers {:error, :not_supported}.

Puts every header mirrored_headers/1 derives from body onto conn.

Runs a tool inside the round boundary the wire uses, and answers what the run answered — or the question it stopped on.

Extracts the text of a successful single-item tool response from a router Plug.Conn — raising if the result is isError: true, so a test failure names the wrong branch instead of asserting on error text as if it were data.

Decodes the text of a single-item text content array as JSON — for tools that answer with Wymcp.Context.json/1 content.

Unwraps a one-item content array, raising with the full content in the message when there are zero or several items — the failure names what actually arrived instead of a bare MatchError.

Extracts the text of a single-item text content array — the shape most tool responses have. Raises when the content is not exactly one text item.

Functions

audio_response(conn)

image_response/1 for audio content: extracts the single content item of a successful tool response.

build_action_request(tool_name, action, data \\ %{})

Builds a tools/call request body for an action-dispatched tool: wraps the action name and optional data into the arguments object. An empty data is omitted rather than sent as an empty object.

Examples

iex> Wymcp.Testing.build_action_request("tasks", "get", %{"id" => "7"})["params"]["arguments"]
%{"action" => "get", "data" => %{"id" => "7"}}

iex> Wymcp.Testing.build_action_request("tasks", "list")["params"]["arguments"]
%{"action" => "list"}

build_call_request(tool_name, arguments)

Builds a complete tools/call JSON-RPC request body: build_request/2 for "tools/call", with tool_name and arguments as its params.

Examples

iex> Wymcp.Testing.build_call_request("tasks", %{"action" => "get"})["method"]
"tools/call"

iex> Wymcp.Testing.build_call_request("tasks", %{"action" => "get"})["params"]["_meta"]
Wymcp.Testing.protocol_fields()

build_context(opts \\ [])

Builds a Wymcp.Context for calling a tool's run/2 directly, without a router. One field takes a builder default — :request_id is 1; every other field defers to the struct's own default (:assigns to %{}, :tools to [], :answers to %{}, :meta to nil). Pass overrides for the ones a test cares about (most often :assigns). A context whose meta declares elicitation is run through run_tool/3, never handed to run/2 directly: Wymcp.Context.elicit/4 raises where no round is open.

Overrides merge by presence, so an explicit nil is honoured rather than replaced: build_context(meta: nil) yields a context whose :meta really is nil. A key that is not a Wymcp.Context field raises KeyError, so a typo fails loudly instead of vanishing. Presence is the only check — values are never validated, so an explicit nil is honoured even where the field's type excludes it: tools: nil builds a context that violates its own contract and misbehaves only where the field is read.

Examples

iex> Wymcp.Testing.build_context().request_id
1

iex> Wymcp.Testing.build_context(assigns: %{user: "u1"}).assigns
%{user: "u1"}

build_request(method, params \\ %{})

Builds a complete JSON-RPC request body for method, for POSTing through the router (request id fixed at 1), carrying protocol_fields/0 under params._meta beside the params given. Its mirrored headers are put_mirrored_headers/2's.

A "_meta" in params merges over protocol_fields/0 key by key: the keys it names win and every other key keeps the builder's value, so a test that departs from the default client — declaring a capability, naming another protocol version — states only the departure. Anything else passes through as written, so a test can build the malformed body it sends: a "_meta" that is not a map replaces the fields whole, and a key set to nil stays nil. A merge never removes a key, so a body missing a protocol field is not built here.

Examples

iex> Wymcp.Testing.build_request("tools/list")
%{
  "jsonrpc" => "2.0",
  "id" => 1,
  "method" => "tools/list",
  "params" => %{"_meta" => Wymcp.Testing.protocol_fields()}
}

iex> capabilities = Wymcp.ProtocolVersion.client_capabilities_field()
iex> meta = %{capabilities => %{"elicitation" => %{}}}
iex> Wymcp.Testing.build_request("tools/list", %{"_meta" => meta})["params"]["_meta"]
%{
  "io.modelcontextprotocol/protocolVersion" => "2026-07-28",
  "io.modelcontextprotocol/clientCapabilities" => %{"elicitation" => %{}}
}

iex> version = Wymcp.ProtocolVersion.protocol_version_field()
iex> Wymcp.Testing.build_request("tools/list", %{"_meta" => %{version => nil}})["params"]["_meta"]
%{
  "io.modelcontextprotocol/protocolVersion" => nil,
  "io.modelcontextprotocol/clientCapabilities" => %{}
}

iex> Wymcp.Testing.build_request("tools/list", %{"_meta" => "x"})["params"]["_meta"]
"x"

error_response(conn)

Extracts the text of an isError: true tool response from a router Plug.Conn — raising on a successful result, the mirror of text_response/1.

image_response(conn)

Extracts the single content item of a successful tool response — for image content, where the item map ("data", "mimeType") is the assertion target rather than a text field.

json_response(conn)

Like text_response/1, then decodes the text as JSON — for tools that answer with Wymcp.Context.json/1 content.

mirrored_headers(body)

The mirrored headers body implies, as {wire name, value} pairs — what Wymcp.Plugs.HeaderBinding compares against the body on every request: MCP-Protocol-Version and Mcp-Method always, and Mcp-Name for a tools/call whose name is a string. A generated input schema annotates no argument, so these are all a call to a use Wymcp.Tool tool is compared on. A tool whose hand-written schema annotates further arguments needs those headers on top.

Read off the body rather than written by hand, so a test states each fact once and the headers cannot drift from the body they mirror. A header whose body field is absent, or whose value is not a string — a protocolVersion or name of another type, a params or _meta that is not an object — is omitted rather than sent: a test driving the missing-header or wrong-typed-field rejection wants exactly that, and Plug.Conn.put_req_header/3 refuses a value that is not a binary.

Examples

iex> Wymcp.Testing.mirrored_headers(Wymcp.Testing.build_action_request("tasks", "get"))
[
  {"mcp-protocol-version", "2026-07-28"},
  {"mcp-method", "tools/call"},
  {"mcp-name", "tasks"}
]

iex> Wymcp.Testing.mirrored_headers(%{"method" => "tools/list"})
[{"mcp-method", "tools/list"}]

protocol_fields()

The per-request protocol fields the request builders put under params._meta: the one protocol version wymcp serves, and empty client capabilities — a client declaring no form elicitation, so a tool that calls Wymcp.Context.elicit/4 under a built request answers {:error, :not_supported}.

Examples

iex> Wymcp.Testing.protocol_fields()
%{
  "io.modelcontextprotocol/protocolVersion" => "2026-07-28",
  "io.modelcontextprotocol/clientCapabilities" => %{}
}

put_mirrored_headers(conn, body)

Puts every header mirrored_headers/1 derives from body onto conn.

run_tool(tool, ctx, arguments)

Runs a tool inside the round boundary the wire uses, and answers what the run answered — or the question it stopped on.

tool.run(ctx, arguments) is called inside the same boundary Wymcp.Methods.ToolsCall uses, so a run that reaches an elicit/4 nothing answers ends there and comes back as {:input_required, input_requests} — the wire-shaped map, keyed the way the client will see it, to assert a question's message and schema against. Every other run answers its own return value untouched.

Two things a hand-built context decides. Its meta is what makes elicit/4 believe the client can answer at all: without a declared elicitation capability the call answers {:error, :not_supported} and the tool takes its fallback, which is the path to test that way. Its answers stand in for a client's retry, keyed by each elicit's position in run order:

ctx =
  Wymcp.Testing.build_context(
    meta: %{
      Wymcp.ProtocolVersion.client_capabilities_field() => %{"elicitation" => %{}}
    },
    answers: %{"elicit-1" => %{"action" => "accept", "content" => %{"branch" => "main"}}}
  )

assert {:ok, content} = Wymcp.Testing.run_tool(MyTool, ctx, %{"action" => "create"})

text_response(conn)

Extracts the text of a successful single-item tool response from a router Plug.Conn — raising if the result is isError: true, so a test failure names the wrong branch instead of asserting on error text as if it were data.

unwrap_json(content)

Decodes the text of a single-item text content array as JSON — for tools that answer with Wymcp.Context.json/1 content.

Examples

iex> Wymcp.Testing.unwrap_json([%{"type" => "text", "text" => ~s({"id": 7})}])
%{"id" => 7}

unwrap_single(content)

Unwraps a one-item content array, raising with the full content in the message when there are zero or several items — the failure names what actually arrived instead of a bare MatchError.

Examples

iex> Wymcp.Testing.unwrap_single([%{"type" => "image"}])
%{"type" => "image"}

unwrap_text(content)

Extracts the text of a single-item text content array — the shape most tool responses have. Raises when the content is not exactly one text item.

Examples

iex> Wymcp.Testing.unwrap_text([%{"type" => "text", "text" => "done"}])
"done"