# Test MCP servers

`com.tnsai.mcp.testing` lets you test MCP code without a network or a child process. `MockMcpServer` answers JSON-RPC in memory, `MockMcpTransport` connects an `McpClient` to it, and `McpServerTestKit` wires both into a JUnit 5 test. `McpInspectorClient` records the messages that pass through any transport, and `McpSchemaValidator` checks their shape.

The package ships in `tnsai-mcp` itself, not in a test artifact. That is why `tnsai-mcp` depends on `junit-jupiter-api` at compile scope.

## Example

<!-- java-contract: src/main/java/com/example/tnsai/docs/McpTestingExample.java -->
```java
package com.example.tnsai.docs;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.TextNode;
import com.tnsai.mcp.client.McpClient;
import com.tnsai.mcp.testing.McpInspectorClient;
import com.tnsai.mcp.testing.McpSchemaValidator;
import com.tnsai.mcp.testing.McpServerTestKit;
import com.tnsai.mcp.testing.MockMcpServer;
import com.tnsai.mcp.testing.MockMcpTransport;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

/** Compilation contract for mcp/testing.md. */
public final class McpTestingExample {

    @RegisterExtension
    McpServerTestKit kit = new McpServerTestKit();     // a fresh server and connected client per test

    @Test
    void echoesText() throws Exception {
        kit.getServer().whenTool("echo", args -> TextNode.valueOf((String) args.get("text")));

        kit.assertToolExists("echo");
        kit.assertToolReturns("echo", Map.of("text", "hi"),
            content -> "hi".equals(content.get(0).path("text").asText()));
    }

    @Test
    void reportsToolErrors() throws Exception {
        kit.getServer().whenToolThrows("search", -32000, "index offline");
        try {
            kit.getClient().executeTool("search", Map.of("q", "docs"));
            throw new AssertionError("expected an error");
        } catch (McpClient.McpException e) {
            if (e.getCode() != -32000) {
                throw new AssertionError("unexpected code " + e.getCode());
            }
        }
    }

    static String resourcesAndPrompts() throws Exception {
        MockMcpServer server = new MockMcpServer("docs", "1.0.0")
            .whenResource("notes://today", "Ship the docs.")
            .whenPrompt("greet", TextNode.valueOf("Hello"));   // register before initialize()

        McpClient client = McpClient.create(new MockMcpTransport(server));
        client.connect();
        client.initialize();
        try {
            return client.readResource("notes://today").get(0).getText();
        } finally {
            client.disconnect();
        }
    }

    static Map<String, Long> recordTraffic() throws Exception {
        McpInspectorClient inspector = new McpInspectorClient(
            new MockMcpTransport(new MockMcpServer().whenToolReturns("ping_tool", TextNode.valueOf("pong"))));
        McpClient client = McpClient.create(inspector);
        client.connect();
        client.initialize();
        client.executeTool("ping_tool", Map.of());

        for (McpInspectorClient.MessageRecord record : inspector.getMessages()) {
            List<String> errors = "outgoing".equals(record.direction())
                ? McpSchemaValidator.validateRequest(record.content())
                : McpSchemaValidator.validateResponse(record.content());
            if (!errors.isEmpty()) {
                throw new AssertionError(record.method() + ": " + errors);
            }
        }
        return inspector.getMethodCounts();             // responses carry no method and count as "unknown"
    }

    static List<String> checkToolDefinition(JsonNode tool) {
        return McpSchemaValidator.validateToolDefinition(tool);
    }
}
```

## Test kit

`McpServerTestKit` is a JUnit 5 extension. Register it with `@RegisterExtension` on an instance field. Before each test it creates a `MockMcpServer("test-server", "1.0.0")`, a `MockMcpTransport` and an `McpClient`, then calls `connect()` and `initialize()`. After each test it disconnects the client.

| Method | Does |
|--------|------|
| `getServer()` | Returns the mock server, so the test can register tools |
| `getTransport()` | Returns the mock transport |
| `getClient()` | Returns the connected, initialized client |
| `assertToolExists(name)` | Calls `tools/list` and throws `AssertionError` that lists the tools present when `name` is missing |
| `assertToolReturns(name, args, check)` | Calls the tool and throws `AssertionError` when `check` rejects the result |

The client is initialized before the test body runs, so the server's capabilities are fixed while it has no tools, resources or prompts. Tool calls still work, because `McpClient` does not check the tools capability. Resource and prompt calls do: through the kit's client they fail with `McpException` "Server does not support resources" (or prompts). To test those, build the server first and initialize your own client, as `resourcesAndPrompts()` does.

## Mock server

`MockMcpServer()` is named `mock-mcp-server`, version `1.0.0`; the other constructor takes a name and version. Each registration method returns the server, so calls chain.

| Method | Registers |
|--------|-----------|
| `whenTool(name, handler)` | A tool that calls `handler` with the arguments as a `Map<String, Object>` |
| `whenToolReturns(name, result)` | A tool that always returns `result` |
| `whenToolThrows(name, code, message)` | A tool that always fails with that JSON-RPC error |
| `whenResource(uri, text)` | A `text/plain` resource |
| `whenPrompt(name, result)` | A prompt whose `prompts/get` result is `result`, returned as is |

The server answers `initialize`, `ping`, `tools/list`, `tools/call`, `resources/list`, `resources/read`, `prompts/list` and `prompts/get`. Other methods get `-32601`. A message that is not JSON gets `-32700`; one without `jsonrpc: "2.0"` or without a method gets `-32600`. A message with no id is a notification and gets no answer. Unlike the real server, the mock accepts every method before `initialize`.

A tool's result is placed in the `text` field of one `text` content item, and `isError` is always false. `McpClient.executeTool` returns the `content` array, so the value is at `content.get(0).path("text")`. Return a `TextNode` to keep the result shaped like real MCP text content; another node is placed in `text` unchanged.

An unknown tool, prompt or resource, or a call without its `name` or `uri`, gets `-32602`. `whenToolThrows` errors keep their own code, and the client throws `McpClient.McpException` with that code in `getCode()`. Any other exception in a handler becomes `-32603`.

`tools/list` describes every tool as `Mock tool: <name>` with the input schema `{"type": "object"}`. Resources are listed with their URI as the name.

You can also call `processMessage(json)` directly. It returns the response as a string, or null for a notification.

## Inspector

`McpInspectorClient` wraps another `McpTransport` and records each message as a `MessageRecord(timestamp, direction, method, content, latency)`. `direction` is `outgoing`, `incoming` or `error`.

| Method | Returns |
|--------|---------|
| `getMessages()` | A copy of every record, oldest first |
| `getMessagesByMethod(method)` | The records with that method |
| `getMethodCounts()` | The number of records per method |
| `getAverageLatency()` | The mean of the recorded latencies, or zero |
| `clear()` | Drops the records |

The method is read from the message's `method` field, so responses have none. They are missing from `getMessagesByMethod` and are counted as `unknown`. An incoming record's latency is measured from the last outgoing message, not from the request with the same id, so it is only accurate when one request is in flight. A failed send adds an `error` record with the exception message as its content.

The inspector works with real transports too: wrap a `StdioTransport` or `StreamableHttpTransport` to see what a server sends.

## Schema validator

`McpSchemaValidator` checks JSON-RPC shape. Each `validate` method returns a list of problems; an empty list means the message passed.

| Method | Checks |
|--------|--------|
| `validateRequest(json)` | Parses; `jsonrpc` is `"2.0"`; `method` is present and a string; `id`, if present, is a number, string or null; `params`, if present, is an object or array |
| `validateResponse(json)` | Parses; `jsonrpc` is `"2.0"`; `id` is present; exactly one of `result` and `error`; `error` is an object with an integer `code` and a string `message` |
| `validateToolDefinition(node)` | An object with a non-blank string `name`; `description`, if present, is a string; `inputSchema`, if present, is an object with a `type` |
| `isValid(json)` | Validates as a request if there is a `method`, as a response if there is a `result` or `error`, and returns false otherwise |

The validator does not check that a method is an MCP method or that params match the method.

## Related

- [MCP client](client.md)
- [MCP server](server.md)
- [Transports](transports.md)
- [MCP registry](registry.md)
