ExUnit assertions for testing an MCP server in process.
Import the module in a test case and run requests with a direct client from
client!/2 or client_as/3, or with Snodo.Test.dispatch/2:
defmodule MyServerTest do
use ExUnit.Case, async: true
import Snodo.Test.Assertions
test "greets" do
client = client!(MyServer.runtime())
result = assert_tool_ok(Snodo.Client.call_tool(client, "greet", %{"name" => "Ada"}))
assert [%{"text" => "Hello, Ada!"}] = result["content"]
end
endEvery assertion returns the value it matched, so a test can go on to match
its fields. A failed assertion raises ExUnit.AssertionError with the
protocol error's code, message, and data, or the isError result's
content, in its message. An argument of the wrong type, such as a kind
other than :form, :url, :sampling, or :roots, raises
ArgumentError.
Responses
The assertions accept a response in any of these forms:
- what
Snodo.Clientreturns:{:ok, result},{:input_required, result}, or{:error, %Snodo.Error{}}; - what
Snodo.Test.dispatch/2returns:{:ok, response}with the JSON-RPC response map; - a JSON-RPC response map on its own.
A JSON-RPC response is decoded the way Snodo.Client decodes one, so both
paths reach the same assertion.
ExUnit
ExUnit ships with Elixir, so it is on the code path of every Mix project,
but snodo does not list it as an application and it is not started
outside a test run. Phoenix.ConnTest and Oban.Testing call ExUnit from
library code the same way. This module refers to ExUnit only to raise
ExUnit.AssertionError when an assertion fails, so it compiles in every
environment and nothing in it runs unless a test calls it.
Summary
Types
An answer to one input request: the response map the request expects, or a
function that receives the request's params and returns that map.
Answers keyed by input request kind (:form, :url, :sampling, or
:roots) or by input request ID. An ID key wins over a kind key.
A response from Snodo.Client, from Snodo.Test.dispatch/2, or a JSON-RPC response map.
Functions
Builds the options that answer an input_required result on the next
request.
Asserts that a response is an input_required result.
Asserts that a list includes the entry named name.
Asserts that a response is a JSON-RPC error, such as an authorization refusal or a protocol error.
Asserts that a tools/call succeeded as a response but the tool reported
an error, with "isError" => true.
Asserts that a tools/call succeeded and the tool did not report an error.
Builds a direct client for runtime with Snodo.Client.direct/2, and
fails the test when the runtime refuses it.
Builds a direct client whose requests run as principal.
Asserts that a list does not include the entry named name, as when an
authorization policy hides a component from a principal.
Types
An answer to one input request: the response map the request expects, or a
function that receives the request's params and returns that map.
@type answers() :: %{optional(Snodo.Client.input_kind() | String.t()) => answer()} | keyword(answer())
Answers keyed by input request kind (:form, :url, :sampling, or
:roots) or by input request ID. An ID key wins over a kind key.
@type response() :: Snodo.Client.response() | {:ok, map()} | {:stream, term()} | map()
A response from Snodo.Client, from Snodo.Test.dispatch/2, or a JSON-RPC response map.
Functions
Builds the options that answer an input_required result on the next
request.
Takes the result, or the {:input_required, result} tuple, and answers
keyed by input request ID or by kind. Returns input_responses: with an
answer for every input request and, when the result carries one,
request_state:, ready to pass to Snodo.Client.call_tool/4 or
Snodo.Client.request/4. Fails when an input request has no answer.
result = assert_input_required(Snodo.Client.call_tool(client, "deploy"), :form)
Snodo.Client.call_tool(client, "deploy", %{},
answer_input(result, form: %{"action" => "accept", "content" => %{"confirm" => true}})
)
|> assert_tool_ok()
@spec assert_input_required(response(), Snodo.Client.input_kind() | nil) :: map()
Asserts that a response is an input_required result.
Returns the result map. When kind is given (:form, :url,
:sampling, or :roots), at least one of its input requests must be of
that kind.
@spec assert_listed(response() | Snodo.Client.Page.t() | [map()], String.t()) :: map()
Asserts that a list includes the entry named name.
name matches an entry's "name", "uri", or "uriTemplate". The list
is a response from Snodo.Client.list_tools/1 and the other list
functions, a Snodo.Client.Page, a response to a list request from
Snodo.Client.request/4 or Snodo.Test.dispatch/2, or a plain list.
Returns the entry.
@spec assert_refused(response(), integer() | nil) :: Snodo.Error.t()
Asserts that a response is a JSON-RPC error, such as an authorization refusal or a protocol error.
Returns the Snodo.Error. When code is given, the error must carry it.
A transport error (kind: :transport) fails the assertion: the client
reports one when the server's response was malformed or the connection
failed, and neither is the server refusing the request.
Asserts that a tools/call succeeded as a response but the tool reported
an error, with "isError" => true.
Returns the result map. When text is given, the text of the result's
content must contain it (a string) or match it (a regex).
Asserts that a tools/call succeeded and the tool did not report an error.
Returns the result map. Fails with the protocol error for an error
response, with the input requests for an input_required result, and with
the content for a result with "isError" => true.
@spec client!(Snodo.Server.Runtime.t(), keyword()) :: Snodo.Client.t()
Builds a direct client for runtime with Snodo.Client.direct/2, and
fails the test when the runtime refuses it.
Takes the options of Snodo.Client.direct/2, and:
:answers- canned answers to input requests, keyed by kind (:form,:url,:sampling, or:roots). Each answer is the response map, or a function of the request'sparamsthat returns it. They become:input_handlers, so a call answers the server's input requests and returns the final result. An answer replaces an:input_handlersentry of the same kind.
accepted = %{"action" => "accept", "content" => %{"name" => "Ada"}}
client = client!(MyServer.runtime(), answers: %{form: accepted})
assert_tool_ok(Snodo.Client.call_tool(client, "greet_interactively"))
@spec client_as(Snodo.Server.Runtime.t(), term(), keyword()) :: Snodo.Client.t()
Builds a direct client whose requests run as principal.
Handlers and authorization policies read principal as context.auth, as
they would after a transport authenticated the request. Other options are
those of client!/2.
reader = client_as(MyServer.runtime(), %{"role" => "reader"})
assert_refused(Snodo.Client.call_tool(reader, "publish", %{}), -32_003)
@spec refute_listed(response() | Snodo.Client.Page.t() | [map()], String.t()) :: [ map() ]
Asserts that a list does not include the entry named name, as when an
authorization policy hides a component from a principal.
Takes the lists assert_listed/2 takes. Returns the list's entries.