Wymcp. Testing
(Wymcp v0.8.7)
View Source
Conveniences for testing Wymcp tools from a consuming application's test suite.
Two groups of helpers:
Direct tool testing —
build_context/1,run_tool/3, and theunwrap_*extractors: assert onrun/2's tagged return tuples (the return contract, including the classified error form, lives inWymcp.Tool), unit-testing a tool module in isolation.HTTP response testing — the request builders (
build_request/2and itstools/callspecialisations),put_mirrored_headers/2and the*_responseextractors: build a request body the router serves, put the headers that body implies on the connection, and pull content out of aPlug.Connresponse body, integration-testing throughWymcp.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
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.
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"}
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()
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"}
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"
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.
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"}]
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" => %{}
}
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.
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"})
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.
Examples
iex> Wymcp.Testing.unwrap_json([%{"type" => "text", "text" => ~s({"id": 7})}])
%{"id" => 7}
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"}
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"