Testing
MCPClient is shipped in the main artifact - it has no test framework dependency - and drives a server the way a real client does: it runs the handshake, keeps the Mcp-Session-Id header across calls and reads the SSE channel.
Every call returns a CompletionStage, which is what a MCP interaction needs: a tool using sampling or elicitation only answers once the client responded on the SSE channel, so its invocation cannot be awaited first.
A first test
Combine it with fusion-testing, which starts the container and a server on a random port:
@FusionSupport
class MyToolsTest {
@Test
void call(@Fusion final URI mcpEndpoint, @Fusion final HttpClient http) {
try (final var client = new MCPClient(mcpEndpoint, http)) {
client.initialize().toCompletableFuture().join(); (1)
final var response = client.call(2, "tools/call", """
{"name": "my/tool", "arguments": {"name": "fusion"}}""") (2)
.toCompletableFuture()
.join();
assertEquals(200, response.statusCode());
assertJsonEquals("""
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [{"text": "{\\"greeting\\":\\"hello fusion\\"}", "type": "text"}],
"isError": false,
"structuredContent": {"greeting": "hello fusion"}
}
}""", response.body()); (3)
}
}
}
-
initialize+notifications/initialized, and the session is kept for the next calls. -
call(id, method, params)sends a JSON-RPC request. -
io.yupiik.fusion.testing.assertion.JsonAsserts#assertJsonEqualscompares formatted JSON, so the assertion reads like the wire.
The URI and HttpClient come from test beans - see demo/src/test/java/.../test/TestBeans.java and RandomPort in this repository for a two class setup binding the port to 0.
The API
| Method | Sends |
|---|---|
|
|
the handshake, capturing the session id. |
|
|
a JSON-RPC request. |
|
|
a notification, i.e. no id. |
|
|
a response to a request the server sent on the SSE channel. |
|
|
any raw message, including a batch. |
|
|
opens the event stream, resuming it when an id is given. |
|
|
the next server to client message, without the SSE framing. |
|
|
|
|
|
reads or forces the session id, the latter being handy to test the invalid cases. |
Parameters and bodies accept either a string - used as is, which keeps the JSON readable in a test - or any object, serialized with the JsonMapper given to the constructor:
try (final var client = new MCPClient(mcpEndpoint, http, jsonMapper)) { // @Fusion JsonMapper jsonMapper
client.initialize(Map.of("sampling", Map.of())).toCompletableFuture().join();
client.call(2, "tools/call", Map.of("name", "my/tool", "arguments", Map.of("name", "fusion")))
.toCompletableFuture()
.join();
}
|
NOTE
|
passing an object without a mapper fails immediately with an explicit message, it never sends a broken body. |
Asserting the descriptors
The most valuable test of a MCP server is often the descriptor one: it locks what the model reads.
@Test
void listTools(@Fusion final URI mcpEndpoint, @Fusion final HttpClient http) {
try (final var client = new MCPClient(mcpEndpoint, http)) {
client.initialize().toCompletableFuture().join();
assertJsonEquals("""
{
"jsonrpc": "2.0",
"id": 2,
"result": {"tools": [{
"description": "Greets someone by name.",
"inputSchema": {
"description": "Input request for my/tool",
"properties": {"name": {"description": "Who to greet.", "type": "string"}},
"required": ["name"],
"type": "object"
},
"name": "my/tool",
"outputSchema": {"properties": {"greeting": {"type": "string"}}, "required": [], "type": "object"},
"title": "my/tool"
}]}
}""", client.call(2, "tools/list", "{}").toCompletableFuture().join().body());
}
}
Alternatively inject DescriptorService and assert on the model instead of the JSON:
@Test
void onlyAnnotatedMethodsAreExposed(@Fusion final DescriptorService descriptors) {
assertEquals(List.of("my/tool"), descriptors.tools().tools().stream().map(ListToolsResponse.Tool::name).toList());
assertFalse(descriptors.isTool("initialize"));
}
Notifications
Open the stream and read the next message - the two compose. Messages are buffered, so publishing before opening is fine and makes the test deterministic:
@Test
void logging(@Fusion final URI mcpEndpoint, @Fusion final HttpClient http, @Fusion final MCPNotifier notifier) {
try (final var client = new MCPClient(mcpEndpoint, http)) {
client.initialize().toCompletableFuture().join();
notifier.log(LoggingLevel.warning, "test", "hello sse!");
assertJsonEquals("""
{
"jsonrpc": "2.0",
"method": "notifications/message",
"params": {"level": "warning", "logger": "test", "data": "hello sse!"}
}""",
client.openSse().thenCompose(client::nextMessage).toCompletableFuture().join());
}
}
Server requests: the full round trip
A tool using sampling only completes once the client answered, so its call must not be awaited before the answer is sent - which is exactly what the asynchronous API expresses:
@Test
void sampling(@Fusion final URI mcpEndpoint, @Fusion final HttpClient http) throws Exception {
try (final var client = new MCPClient(mcpEndpoint, http)) {
client.initialize("""
{"sampling": {}}""").toCompletableFuture().join(); (1)
final var call = client.call(2, "tools/call", """
{"name": "my/ask", "arguments": {"question": "how are you?"}}"""); (2)
final var request = client.openSse().thenCompose(client::nextMessage).toCompletableFuture().join(); (3)
assertTrue(request.contains("\\"method\\":\\"sampling/createMessage\\""), request);
client.respond(idOf(request), """
{"role": "assistant", "model": "test-model",
"content": {"type": "text", "text": "fine, thanks!"}}""")
.toCompletableFuture()
.join(); (4)
assertTrue(call.toCompletableFuture().get(30, SECONDS).body().contains("fine, thanks!")); (5)
}
}
-
Declare the capability, else the tool fails fast with
Client does not support 'sampling'. - Not awaited: the tool is blocked on the client answer.
-
The server request, read from the SSE channel; parse its
idto answer it. -
respond(id, result)posts the response on the same endpoint - it answers202. - The tool result is now available.
Transport level tests
client.initialize().toCompletableFuture().join();
assertEquals(204, client.terminate().toCompletableFuture().join().statusCode()); // DELETE /mcp
assertEquals(404, client.call(3, "ping", "{}").toCompletableFuture().join().statusCode()); // the session is gone
new MCPClient(endpoint, http).session("made-up"); // force an invalid session id
client.openSse("12"); // resume from Last-Event-ID: 12
client.post("[{...},{...}]"); // a batch
|
TIP
|
this repository uses exactly that - |