Testing Your Server

Copy Markdown View Source

Noizu.MCP.Test runs a real client session against your server over an in-memory transport — full protocol semantics (handshake, capabilities, validation, notifications) with no I/O. Connections are isolated per test: async: true is safe.

defmodule MyApp.MCPTest do
  use ExUnit.Case, async: true
  import Noizu.MCP.Test

  setup do
    %{client: connect(MyApp.MCP)}
  end

  test "search returns hits", %{client: client} do
    assert {:ok, result} = call_tool(client, "search_docs", %{"query" => "installation"})
    assert result.is_error == false
    assert [%{type: :text, text: text}] = result.content
    assert text =~ "hits"
  end
end

connect/2 starts the server's supervision tree on demand (no need to add it to your test app's supervisor) and performs the initialize handshake. Options include protocol_version: and client capabilities: overrides for negotiation tests.

The wrappers

Feature wrappers mirror the client API and return decoded structs: call_tool/4, list_tools/2, list_resources/2, list_resource_templates/2, read_resource/3, subscribe/2, unsubscribe/2, list_prompts/2, get_prompt/4, complete/4, set_log_level/2.

Lower-level escape hatches:

{:ok, result} = request(client, "ping")                  # any method, decoded result
id = send_request(client, "tools/call", %{...})          # fire without waiting
{:ok, result} = await(client, id)                        # ... collect later
notify(client, "notifications/cancelled", %{"requestId" => id})
cancel(client, id, "reason")                             # sugar for the above
deliver_raw(client, ~s({"jsonrpc": "2.0"...}))           # malformed-input tests

Notifications and progress

test "subscription fan-out", %{client: client} do
  assert {:ok, _} = request(client, "resources/subscribe", %{"uri" => "config://app"})
  MyApp.MCP.notify_resource_updated("config://app")

  params = assert_notification(client, "notifications/resources/updated")
  assert params["uri"] == "config://app"
end

test "progress", %{client: client} do
  {:ok, _} = call_tool(client, "long_task", %{}, progress_token: "t1")
  params = assert_progress(client)
  assert params["progressToken"] == "t1"
end

test "silence", %{client: client} do
  {:ok, _} = call_tool(client, "quick", %{})
  refute_notification(client, "notifications/progress")
end

Matchers buffer out-of-order traffic per session, so interleaved notifications don't flake.

Testing sampling / elicitation / roots

Tools that call Ctx.sample/elicit/list_roots need a client that advertises those capabilities — give connect/2 a handler:

defmodule StubHandler do
  @behaviour Noizu.MCP.Client.Handler

  @impl true
  def handle_sampling(_params, _state),
    do: {:ok, %{"role" => "assistant", "content" => %{"type" => "text", "text" => "stub"}, "model" => "stub"}}

  @impl true
  def handle_elicitation(_params, _state), do: {:ok, :accept, %{"confirm" => true}}
end

client = connect(MyApp.MCP, handler: StubHandler)
assert {:ok, result} = call_tool(client, "consult_llm", %{"question" => "?"})

Testing toolkit and hidden tools

Toolkit tools (use Noizu.MCP.Server.Toolkit + @mcp) test exactly like classic ones — call_tool/4 by wire name. For hidden items, assert both halves of the contract: excluded from listings, still callable:

test "hidden tools are unlisted but callable", %{client: client} do
  {:ok, tools} = list_tools(client)
  refute "internal_tool" in Enum.map(tools, & &1.name)

  assert {:ok, result} = call_tool(client, "internal_tool", %{})
  assert result.is_error == false
end

test "catalog reveals hidden tools", %{client: client} do
  {:ok, result} = call_tool(client, "catalog", %{"type" => "tools"})
  entry = Enum.find(result.structured["tools"], &(&1["name"] == "internal_tool"))
  assert entry["hidden"] == true
end

The same pattern covers hidden prompts (list_prompts/2 + get_prompt/4) and hidden resources (list_resources/2 + read_resource/3). For session-gated visibility, flip the gate and assert the list_changed notification:

{:ok, _} = call_tool(client, "unlock", %{})        # sets the session assign
assert_notification(client, "notifications/tools/list_changed")
{:ok, tools} = list_tools(client)                  # now includes gated tools

Conformance

The library's own suite validates wire output against the vendored official JSON schema (priv/spec/2025-11-25/schema.json). If you extend the protocol surface by hand (request/3 with custom result shapes), consider doing the same — see test/noizu/mcp/conformance_test.exs in the source repo for the pattern.