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
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.