Skip to main content

Module 7: Testing MCP Servers

Duration: ~30 minutes | Level: Intermediate | Prerequisites: Module 6: Error Handling


Why Testing MCP Servers Is Different

Testing an MCP server feels different from testing a REST controller. There's no HTTP to mock, no JSON body to assert against. You're testing handlers that run in response to protocol messages.

But the good news: tool handlers are just functions. They take a map of arguments and return a result. That's easy to test in isolation.

The strategy:

  1. Unit tests: test tool handlers directly, without the MCP protocol
  2. Integration tests: spin up a real server and client, exercise the full protocol
  3. Manual tests: connect Claude Desktop, verify behaviour in a real AI conversation

Unit Testing Tool Handlers

Extract your tool logic into testable methods:

// Production class
public class CustomerTools {

private final CustomerRepository repository;

public CustomerTools(CustomerRepository repository) {
this.repository = repository;
}

public McpSchema.CallToolResult searchCustomers(Map<String, Object> args) {
String query = (String) args.get("query");
int limit = Optional.ofNullable(args.get("limit"))
.map(l -> ((Number) l).intValue())
.orElse(10);

if (query == null || query.isBlank()) {
return CallToolResult.builder().isError(true).addTextContent("'query' is required").build();
}

var results = repository.search(query, Math.min(50, limit));
return CallToolResult.builder().addTextContent(toJson(results)).build();
}
}
// Test class
// Requires: org.mockito:mockito-core (JUnit Jupiter provides the assertions)
// Static imports:
// import static org.mockito.Mockito.mock;
// import static org.mockito.Mockito.when;
// import static org.mockito.Mockito.verify;
// import static org.junit.jupiter.api.Assertions.assertTrue;
// import static org.junit.jupiter.api.Assertions.assertFalse;

class CustomerToolsTest {

private CustomerRepository mockRepo = mock(CustomerRepository.class);
private CustomerTools tools = new CustomerTools(mockRepo);

@Test
void searchCustomers_returnsResults_whenQueryMatches() {
when(mockRepo.search("acme", 10))
.thenReturn(List.of(new Customer("1", "Acme Corp", "[email protected]")));

var args = Map.of("query", "acme");
var result = tools.searchCustomers(args);

assertFalse(result.isError());
// CallToolResult.content() returns List<Content>; cast to TextContent for .text()
assertTrue(((TextContent) result.content().get(0)).text().contains("Acme Corp"));
}

@Test
void searchCustomers_returnsError_whenQueryIsEmpty() {
var result = tools.searchCustomers(Map.of("query", ""));

assertTrue(result.isError());
assertTrue(((TextContent) result.content().get(0)).text().contains("'query' is required"));
}

@Test
void searchCustomers_capsLimit_at50() {
when(mockRepo.search("test", 50)).thenReturn(List.of());

tools.searchCustomers(Map.of("query", "test", "limit", 9999));

verify(mockRepo).search("test", 50); // verifies the cap was applied
}
}

Integration Testing Over Real stdio

For an end-to-end check that goes through the actual MCP protocol, including the initialise handshake, capability negotiation, and tool dispatch, launch the server as a subprocess from the test and drive it with a real McpSyncClient over stdio. This is the same path Claude Desktop uses, so a passing test means the deployable JAR works.

class McpServerIT {

private McpSyncClient client;

@BeforeEach
void setUp() {
// ServerParameters describes how to launch the server process. Adjust the
// command and arguments to point at the JAR you just packaged with `mvn package`.
var serverParameters = ServerParameters.builder("java")
.args("-jar", "target/my-mcp-server-1.0-SNAPSHOT.jar")
.build();

McpJsonMapper jsonMapper = new JacksonMcpJsonMapper(new ObjectMapper());
var clientTransport = new StdioClientTransport(serverParameters, jsonMapper);

client = McpClient.sync(clientTransport)
.clientInfo(new McpSchema.Implementation("test-client", "1.0.0"))
.build();
client.initialize();
}

@AfterEach
void tearDown() {
client.closeGracefully();
}

@Test
void toolsListReturnsSearchCustomers() {
var tools = client.listTools().tools();

assertTrue(tools.stream().anyMatch(t -> t.name().equals("search_customers")));
}

@Test
void searchCustomersToolExecutes() {
var result = client.callTool(
new McpSchema.CallToolRequest("search_customers",
Map.of("query", "acme"))
);

assertFalse(result.isError());
assertFalse(result.content().isEmpty());
}

@Test
void searchCustomersToolReturnsError_forEmptyQuery() {
var result = client.callTool(
new McpSchema.CallToolRequest("search_customers",
Map.of("query", ""))
);

assertTrue(result.isError());
}
}
Keep this test out of the test phase

Surefire runs any class named *Test during Maven's test phase, which happens before package. On a clean checkout, mvn package would run this class before the JAR exists: the subprocess launch fails and the build aborts, so the JAR is never produced. Name the class McpServerIT instead and run it with the Failsafe plugin, whose integration-test phase runs after package:

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.5.2</version>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>

Now mvn verify packages a fresh JAR first and then exercises it, and plain mvn package still succeeds. This also guarantees the test never runs against a stale JAR left over from an earlier build.

The Java SDK 2.0.x does not currently publish an in-process mock transport pair as part of its public API (the mcp-test module ships only abstract base classes and JSON utilities; the MockMcpClientTransport used in the SDK's own tests lives under mcp-core's test sources and is not exported). Subprocess testing is the supported alternative and has the advantage of catching JAR-packaging bugs that an in-JVM test would miss.

If you prefer not to fork a process per test, structure the bulk of your coverage as unit tests on extracted handler methods (above) and keep one or two subprocess tests as a smoke check.

The optional mcp-test dependency is useful if you want to subclass AbstractMcpSyncServerTests and reuse the SDK's own conformance tests against your server:

<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp-test</artifactId>
<version>2.0.0</version>
<scope>test</scope>
</dependency>

Testing Resources

@Test
void configResourceIsAccessible() {
var resources = client.listResources().resources();
assertTrue(resources.stream().anyMatch(r -> r.uri().equals("config://app/settings")));

var content = client.readResource(
new McpSchema.ReadResourceRequest("config://app/settings")
);

assertFalse(content.contents().isEmpty());
assertEquals("application/json", content.contents().get(0).mimeType());
}

Manual Testing with Claude Desktop

Unit and integration tests verify correctness. Manual testing with Claude Desktop verifies usability, does the AI actually use your tools the way you intended?

Checklist for manual testing:

  • Server appears in Claude Desktop's tool panel after restart
  • Tool descriptions make the AI use tools at the right time
  • Tool results are incorporated correctly into AI responses
  • Error messages produce helpful AI responses (not confusion)
  • Edge cases (empty results, missing records) are handled gracefully
  • The AI doesn't hallucinate data when the tool returns nothing

Test with realistic user prompts, not just "call the search_customers tool with query=test." The AI's decision to call a tool is determined by the prompt and description, test that the AI figures out when to call it, not just that it can.


Key Takeaways

  • Extract handler logic to plain methods, unit test them directly
  • For end-to-end coverage, drive the packaged JAR over stdio with a real McpSyncClient; the SDK does not yet publish an in-process mock transport pair
  • Test both the happy path and error cases
  • Manual testing with Claude Desktop validates usability, not just correctness
  • Test whether the AI uses your tools at the right times, not just whether the tools work in isolation

Companion code

projects/mcp-java-sdk/tools-server/src/test/ contains 25 unit tests across CalculateToolTest, ReadFileToolTest (with JUnit @TempDir sandboxes), and SearchCustomersToolTest (using reactor-test's StepVerifier to assert on the Mono). Run them with mvn -B -pl tools-server test.

→ Module 8: Security