Test helpers for asserting on tools, prompts, and resources defined in a
Phantom.Router.
Drives router handlers directly without booting an HTTP transport, so
unit-level tests can assert on the final response of a tool/prompt/
resource regardless of whether the handler returned synchronously
({:reply, _, _}) or asynchronously ({:noreply, _} + Session.respond/2
from a Task).
Example
defmodule MyApp.MCP.RouterTest do
use ExUnit.Case, async: true
import Phantom.Test
setup do
Phantom.Test.start(router: MyApp.MCP.Router)
{:ok, session: build_session(MyApp.MCP.Router)}
end
test "echo tool", %{session: session} do
result = call_tool(session, :echo_tool, %{message: "hi"})
assert_tool_text(result, "hi")
end
test "tool that elicits info", %{session: session} do
expect_elicit(fn _elicit ->
{:ok, %{"action" => "accept", "content" => %{"name" => "Joe"}}}
end)
result = call_tool(session, :elicit_tool, %{})
assert_tool_text(result, ~r/Joe/)
end
endPubSub
PubSub-driven features (Phantom.Tracker, resource subscriptions,
list-changed notifications across nodes) are opt-in. Pass :pubsub
to start/1 to enable them:
Phantom.Test.start(router: MyApp.MCP.Router, pubsub: MyApp.PubSub)Side-channel assertions (assert_progress_seen/1, assert_client_log_seen/1)
work without PubSub: they read messages cast directly to the test process.
Summary
Functions
Assert that a client log was emitted during the most recent dispatch.
Assert the dispatcher returned an :elicitation_required JSON-RPC error
(code -32042).
Assert the dispatcher returned a JSON-RPC error.
Assert that a progress notification was emitted during the most recent dispatch.
Assert a get_prompt/3 result contains a message matching opts.
Assert a read_resource/3 result contains a base64-encoded blob.
Assert a read_resource/3 result contains text content matching expected.
Assert the result is a task, from a tool that answered with one or from
get_task/3.
Assert the tool returned audio content.
Assert the tool returned an embedded resource.
Assert the tool returned an error response (isError: true) with the given message.
Assert the tool returned image content.
Assert the tool returned a resource link.
Assert the tool returned a text content payload.
Build a fresh Phantom.Request struct with a random id and the given method.
Build a Phantom.Session for use with the blocking dispatchers below.
Call a tool by name and block until the final response arrives.
Dispatch tasks/cancel for a task. See get_task/3.
Run prompt completion. Returns the dispatcher's reply map (under :completion)
or {:jsonrpc_error, error_map}.
Run resource completion against a URI template. See complete_prompt/5.
Register a responder for form-mode elicitations.
Register a responder for url-mode elicitations. See expect_elicit/1.
Drain progress notifications and client logs cast to the test mailbox.
Get a prompt by name. Blocks until the final response arrives.
Dispatch tasks/get for a task. Declares the Tasks extension on the
request, so the session must be built with :protocol_version.
Dispatch resources/list with an optional cursor.
Read a resource by name and path params; blocks until the final response.
Refute that any client log was emitted.
Refute that any progress notification was emitted.
Register the router with Phantom.Cache and, optionally, start
Phantom.Tracker against the given :pubsub.
Dispatch tasks/update with responses to a task's input requests. See
get_task/3.
Functions
Assert that a client log was emitted during the most recent dispatch.
Options: :level (atom), :domain (string), :data (map for partial match).
Assert the dispatcher returned an :elicitation_required JSON-RPC error
(code -32042).
Options: :message matches the elicitation message string.
Assert the dispatcher returned a JSON-RPC error.
Options: :code, :message, :data.
Assert that a progress notification was emitted during the most recent dispatch.
Options:
:progress- exact value:total- exact total:steps- exact number of progress notifications observed
Drains any matching messages from the mailbox.
Assert a get_prompt/3 result contains a message matching opts.
Options: :role, :type, :text (binary or regex).
Assert a read_resource/3 result contains a base64-encoded blob.
When :mime_type is provided, asserts on it too.
Assert a read_resource/3 result contains text content matching expected.
Accepts both {:ok, uri, content} and a raw content map.
Assert the result is a task, from a tool that answered with one or from
get_task/3.
Options: :id matches the task ID, :status the status (such as
:completed).
Assert the tool returned audio content.
Assert the tool returned an embedded resource.
Assert the tool returned an error response (isError: true) with the given message.
Assert the tool returned image content.
Assert the tool returned a resource link.
Assert the tool returned a text content payload.
expected may be a binary or a regex.
Build a fresh Phantom.Request struct with a random id and the given method.
@spec build_session( module(), keyword() ) :: Phantom.Session.t()
Build a Phantom.Session for use with the blocking dispatchers below.
The session's :pid is wired during dispatch — you do not need to set
it. Options:
:id- session id (default: a fresh UUIDv7):assigns- session assigns (default:%{}):allowed_tools/:allowed_prompts/:allowed_resource_templates- allow-lists, like in
Phantom.Router.connect/2.
- allow-lists, like in
:pubsub- attach a pubsub server to the session.:client_capabilities- override the default capabilities map. Defaults to%{elicitation: %{"url" => %{}}, sampling: %{}, roots: %{}}so both form-mode and url-mode elicitation are supported in tests.:protocol_version- a stateless protocol version such as"2026-07-28". Requests then carry the per-request_metathat protocol requires. Omit it to use theinitialize-based protocol.:log_level- with:protocol_version, the client log level each request asks for (default: none, so no client logs are sent).
Call a tool by name and block until the final response arrives.
Returns the unwrapped result payload (the value the MCP client
would see under "result") for {:reply, _, _} and Session.respond/2
paths, or {:jsonrpc_error, error_map} if the dispatcher returned an
error tuple (e.g. validation failure, {:elicitation_required, _}).
Options:
:timeout- ms to wait for an async response (default: 1000):progress_token- setsparams._meta.progressTokenon the request:tasks- declares the Tasks extension on the request, so the tool may answer with aPhantom.Tasks. Requires a session built with:protocol_version.
@spec cancel_task(Phantom.Session.t(), String.t(), keyword()) :: any()
Dispatch tasks/cancel for a task. See get_task/3.
@spec complete_prompt( Phantom.Session.t(), atom() | String.t(), String.t(), String.t(), keyword() ) :: any()
Run prompt completion. Returns the dispatcher's reply map (under :completion)
or {:jsonrpc_error, error_map}.
@spec complete_resource( Phantom.Session.t(), String.t(), String.t(), String.t(), keyword() ) :: any()
Run resource completion against a URI template. See complete_prompt/5.
@spec expect_elicit((Phantom.Elicit.t() -> any())) :: :ok
Register a responder for form-mode elicitations.
The responder receives a Phantom.Elicit struct and must return
{:ok, response_map} (e.g. {:ok, %{"action" => "accept", "content" => %{...}}}),
:error, :not_supported, or :timeout.
Stored in the calling test process; ExUnit isolates per test.
@spec expect_elicit_url((Phantom.Elicit.t() -> any())) :: :ok
Register a responder for url-mode elicitations. See expect_elicit/1.
@spec flush_notifications() :: :ok
Drain progress notifications and client logs cast to the test mailbox.
Get a prompt by name. Blocks until the final response arrives.
@spec get_task(Phantom.Session.t(), String.t(), keyword()) :: any()
Dispatch tasks/get for a task. Declares the Tasks extension on the
request, so the session must be built with :protocol_version.
Returns the task as the client sees it, or {:jsonrpc_error, error_map}.
@spec list_resources(Phantom.Session.t(), String.t() | nil, keyword()) :: any()
Dispatch resources/list with an optional cursor.
@spec read_resource( Phantom.Session.t(), atom() | String.t(), keyword() | map(), keyword() ) :: any()
Read a resource by name and path params; blocks until the final response.
Returns {:ok, uri, content} on success, or
{:jsonrpc_error, error_map} if dispatch returned an error tuple.
Path params accept atom or string keys.
Refute that any client log was emitted.
Refute that any progress notification was emitted.
@spec start(keyword()) :: :ok
Register the router with Phantom.Cache and, optionally, start
Phantom.Tracker against the given :pubsub.
Options:
:router- required. The MCP router module.:pubsub- optional. Phoenix.PubSub server name. If supplied, ensuresPhantom.Trackeris running and bound to it.
Dispatch tasks/update with responses to a task's input requests. See
get_task/3.