Class 7: Testing MCP Servers
Duration: ~85 minutes | Level: Intermediate | Prerequisites: Class 6: Error Handling
This class builds directly on Class 6. If you skipped it, clone the class_6 branch to start from the same place:
git clone --branch class_6 https://github.com/the-mcp-guy/mcp-java-sdk-course.git
Every Handler Is a Plain Java Method
Every tool, resource and prompt in this course has been built the same way: a spec() that describes the thing to the protocol, and beside it a package-private method that takes plain arguments and returns a plain result:
public AsyncToolSpecification spec() { ... } // protocol wiring
Mono<CallToolResult> search(Map<String, Object> arguments) { ... } // the actual work
search(...) is an ordinary Java method. It does not know what MCP is. It takes a Map and returns a Mono, and a test can call it directly. Two callers reach the same method by two different routes:
The left route needs a packaged server and a transport. The right route needs a constructor call and a method call.
We have spent three classes checking behaviour by pasting JSON into a terminal. That was how we learned the protocol, and it cannot tell us on the next build whether everything still works. This class replaces it.
What We'll Build
Eight test classes over the code we already have:
| File | Covers |
|---|---|
CalculateToolTest | a synchronous handler, the simplest possible shape |
SearchCustomersToolTest | an asynchronous handler, with StepVerifier |
FindCustomerToolTest | the lookup from Class 6, with a test that would catch the silent failure |
CustomerProfileResourceTest | a resource read, and the -32002 exception it throws |
AccountReviewPromptTest | prompt arguments, which the handler checks itself |
ToolsServerIT | the packaged JAR, driven over real stdio by a real client |
ResourcesServerIT | the two list calls, and a binary read |
PromptsServerIT | the prompt menu, and a ResourceLink travelling as a link |
The first five call a handler method directly, without starting anything: those are the unit tests. The last three start the packaged server as a separate process and talk to it over the protocol: those are the integration tests.
Two Dependencies
Add these to pom.xml beside the JUnit dependency that has been there since Class 1:
<!-- Class 7. assertThat(...) reads better than assertEquals(expected, actual). -->
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.7</version>
<scope>test</scope>
</dependency>
<!-- Class 7. StepVerifier, for asserting on a Mono without blocking.
Must match the reactor-core version that mcp-core brings in. -->
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<version>3.7.0</version>
<scope>test</scope>
</dependency>
assertThat(...) comes from AssertJ, StepVerifier from reactor-test. Importing reactor-bom into <dependencyManagement> would let you drop the <version> here and keep the two Reactor artifacts together; this course writes it out to keep pom.xml flat.
No mocking library. Mockito is a common choice for tests like these, and it is not needed here. CustomerRepository is an interface with a small in-memory implementation, and in Class 6 we added a Failure flag to it, so a test can ask for a backend that works, one that fails, or one that hangs.
Mockito is the right choice when we need behaviour that existing implementations cannot give us, such as checking how many times a method was called. When a real implementation already does the job, using it keeps the test closer to the code that runs.
Where This Code Goes
Test sources mirror the main tree, which is what lets a test call a package-private method:
src/test/java/com/themcpguy/
├── McpTestServer.java shared setup, not a test
├── ToolsServerIT.java integration, runs after package
├── ResourcesServerIT.java integration
├── PromptsServerIT.java integration
├── tools/
│ ├── CalculateToolTest.java
│ └── SearchCustomersToolTest.java
├── resources/
│ └── CustomerProfileResourceTest.java
├── prompts/
│ └── AccountReviewPromptTest.java
└── errors/
└── FindCustomerToolTest.java
The packages have to match exactly. CalculateTool.evaluate(...) is package-private, which means only code in the same package may call it. The test therefore has to live in com.themcpguy.tools as well. Put it anywhere else and it will not compile. The handlers stay package-private for that reason: they are internal to the tool.
A Synchronous Handler
Start with calculate, because it is pure computation: no I/O, and nothing to wait for. Create src/test/java/com/themcpguy/tools/CalculateToolTest.java:
package com.themcpguy.tools;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The simplest shape: a synchronous handler, called directly.
* <p>
* No server, no transport, no JSON-RPC. evaluate(...) is package-private and this test
* lives in the same package, which is the whole reason the handler was split out from
* spec() back in Class 3.
*/
class CalculateToolTest {
private final CalculateTool tool =
new CalculateTool(new JacksonMcpJsonMapper(new ObjectMapper()));
/** Every tool answers with content; this pulls the first block out as text. */
private static String textOf(CallToolResult result) {
return ((TextContent) result.content().getFirst()).text();
}
@Test
@DisplayName("evaluates an expression and reports the result as JSON")
void shouldEvaluateExpression() {
CallToolResult result = tool.evaluate(Map.of("expression", "2 + 3 * 4"));
assertThat(result.isError()).isFalse();
assertThat(textOf(result)).contains("14");
}
@Test
@DisplayName("a missing expression is a tool error, not an exception")
void shouldReturnToolErrorWhenExpressionMissing() {
CallToolResult result = tool.evaluate(Map.of());
assertThat(result.isError()).isTrue();
assertThat(textOf(result)).contains("expression");
}
@Test
@DisplayName("division by zero produces a tool error, not an exception")
void shouldReturnToolErrorForDivisionByZero() {
CallToolResult result = tool.evaluate(Map.of("expression", "1 / 0"));
assertThat(result.isError()).isTrue();
assertThat(textOf(result)).containsIgnoringCase("zero");
}
}
@DisplayName is doing the naming. JUnit is the test framework Maven already runs for us, and both @Test and @DisplayName come from it. @DisplayName attaches a readable sentence to each test, and that sentence is what your IDE shows. It can hold spaces and punctuation, which a Java method name cannot. Maven's own report keeps the Java method name unless maven-surefire-plugin is configured with a statelessTestsetReporter that turns phrased names on.
The method name still matters, because that is what a stack trace and a failing build print. The names here all begin with should, so each reads as the expectation it checks. Other codebases name the same test differently:
| Style | The same test, named that way | What you read from the name |
|---|---|---|
should prefix | shouldReturnToolErrorWhenExpressionMissing | the expectation, as a sentence |
| method_state_result | evaluate_missingExpression_returnsError | the method under test first |
| no prefix | missingExpressionIsAToolError | the fact being asserted |
JUnit accepts all three. What matters is that a failing test tells us what broke before we open the file, so pick one style and keep to it.
The handler is called directly. Nothing starts a server, opens a transport or performs the handshake, so the test costs what an ordinary method call costs.
textOf(...) is worth writing once. CallToolResult.content() returns List<Content>, so every test that wants the string has to take the first element, cast it to TextContent and call .text().
A tool failure arrives as a value. result.isError() is true, and no exception is thrown. That is the tool-error contract from Class 6, so a test expecting an exception here would be checking the wrong design.
An Asynchronous Handler
search_customers returns Mono<CallToolResult>, so the value to assert on arrives only once the Mono is subscribed to. Subscribing is what starts the work: a Mono describes a computation and holds off running it until something asks for the value. StepVerifier subscribes, waits, and lets us make assertions about what came out.
Create src/test/java/com/themcpguy/tools/SearchCustomersToolTest.java:
package com.themcpguy.tools;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.themcpguy.tools.CustomerRepository.Failure;
import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import reactor.test.StepVerifier;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* An asynchronous handler returns Mono<CallToolResult>, so the assertions go inside
* a StepVerifier rather than being made on a returned value.
*/
class SearchCustomersToolTest {
private static final JacksonMcpJsonMapper JSON =
new JacksonMcpJsonMapper(new ObjectMapper());
private SearchCustomersTool toolBackedBy(CustomerRepository repository) {
return new SearchCustomersTool(JSON, repository);
}
private static String textOf(CallToolResult result) {
return ((TextContent) result.content().getFirst()).text();
}
@Test
@DisplayName("a matching query returns the customer as JSON")
void shouldFindCustomerByName() {
StepVerifier.create(toolBackedBy(CustomerRepository.inMemory())
.search(Map.of("query", "globex")))
.assertNext(result -> {
assertThat(result.isError()).isFalse();
assertThat(textOf(result)).contains("Globex Industries").contains("CUST-2");
})
.verifyComplete();
}
@Test
@DisplayName("a blank query is rejected by the handler, not the schema")
void shouldReturnToolErrorForBlankQuery() {
StepVerifier.create(toolBackedBy(CustomerRepository.inMemory())
.search(Map.of("query", " ")))
.assertNext(result -> {
assertThat(result.isError()).isTrue();
assertThat(textOf(result)).contains("non-blank");
})
.verifyComplete();
}
@Test
@DisplayName("limit is capped, so a model asking for 9999 cannot dump the database")
void shouldCapTheLimit() {
StepVerifier.create(toolBackedBy(CustomerRepository.inMemory())
.search(Map.of("query", "example", "limit", 9999)))
.assertNext(result -> assertThat(result.isError()).isFalse())
.verifyComplete();
}
@Test
@DisplayName("a broken backend becomes a tool error, and the Mono still completes")
void shouldReturnToolErrorWhenBackendFails() {
StepVerifier.create(toolBackedBy(CustomerRepository.inMemory(Failure.BROKEN))
.search(Map.of("query", "globex")))
.assertNext(result -> {
assertThat(result.isError()).isTrue();
assertThat(textOf(result)).contains("no connections available");
})
.verifyComplete();
}
}
The shape is always the same: StepVerifier.create(theMono), then .assertNext(result -> ...) for the value we expect, then .verifyComplete(). Nothing runs until that last line:
verifyComplete() also asserts that the Mono finished instead of hanging or failing.
.block() is the tempting shortcut, and it is worth avoiding:
CallToolResult result = toolBackedBy(CustomerRepository.inMemory())
.search(Map.of("query", "globex"))
.block();
That would pass this test too, and it turns two different problems into one: a Mono that completes empty gives us null, and a Mono that fails throws an exception. StepVerifier reports each as what it is.
Failure flag, doing its real jobThe last test asks for the failing backend and asserts the tool answers with an error:
CustomerRepository.inMemory() // answers normally
CustomerRepository.inMemory(Failure.BROKEN) // fails immediately
Class 6 introduced that flag as scaffolding and said a real project would inject a failing stub: a stand-in written to behave one particular way. The flag is that stand-in, and the Class 6 error path is checked on every build.
The assertion pins the demo message. Class 6 explains why customer-db: no connections available in pool should not reach a model that will repeat it to the user. When the handler returns one sentence written for a person instead, this assertion moves with it.
Catching the Silent Handler
In Class 6 we registered find_customer twice, once written correctly and once with a single line missing, and saw what the broken one looks like from the outside: no reply at all. Here is the same failure caught by a test.
The lookup is three steps, and the code looks completely normal:
Mono.fromFuture(...) ask the repository for matches
.flatMap(... findFirst() ...) take the one match, if there is one
.map(this::toResult) turn it into a CallToolResult
When the list comes back empty, findFirst() returns an empty Optional, Mono.justOrEmpty turns that into an empty Mono, and map is skipped. The two paths through that chain end differently:
The right-hand path finishes without producing a result. The SDK does not send a response, and the client waits until its own timeout expires.
This is an ordinary lookup. Any "find one record by id" method has this shape, and the missing piece is one line at the end of the chain. A reviewer reading it sees three steps that all look correct, which is why a test catches it more reliably.
Create src/test/java/com/themcpguy/errors/FindCustomerToolTest.java:
package com.themcpguy.errors;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.themcpguy.tools.CustomerRepository;
import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import reactor.test.StepVerifier;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Both tests assert that a result comes back. The second one is what fails if the
* defaultIfEmpty line is ever removed from the handler.
*/
class FindCustomerToolTest {
private static final JacksonMcpJsonMapper JSON =
new JacksonMcpJsonMapper(new ObjectMapper());
/** The third argument is Class 6's switch, left on, which is how real code would have it. */
private FindCustomerTool tool() {
return new FindCustomerTool(JSON, CustomerRepository.inMemory(), true);
}
private static String textOf(CallToolResult result) {
return ((TextContent) result.content().getFirst()).text();
}
@Test
@DisplayName("a customer that exists comes back as JSON")
void shouldFindExistingCustomer() {
StepVerifier.create(tool().find(Map.of("customerId", "CUST-2")))
.assertNext(result -> {
assertThat(result.isError()).isFalse();
assertThat(textOf(result)).contains("Globex Industries");
})
.verifyComplete();
}
@Test
@DisplayName("a customer that does not exist still produces a result")
void shouldStillEmitWhenCustomerMissing() {
StepVerifier.create(tool().find(Map.of("customerId", "NOPE")))
.assertNext(result -> {
assertThat(result.isError()).isTrue();
assertThat(textOf(result)).contains("No customer with id 'NOPE'");
})
.verifyComplete();
}
}
Neither test mentions the bug. They both check that a result comes back, for a customer that exists and for one that does not. That is all a real test suite would contain, because the broken version is not something we would keep.
The second test is the one that guards against it. To see why, open FindCustomerTool and delete the defaultIfEmpty line, then run the test again:
FindCustomerToolTest.shouldStillEmitWhenCustomerMissing
expectation "assertNext" failed (expected: onNext(); actual: onComplete())
onComplete() arriving on its own is Reactor saying the Mono finished without producing anything. That is the silent handler, caught at build time, with the test name pointing at the case that broke. Put the line back and it passes again.
This is the general shape worth copying:
Assert that a result was produced, not only that it was correct.
.assertNext(...).verifyComplete() does both, because assertNext fails when nothing arrives. We do not need a test that describes the bug; we need one that would notice it.
Resources and Prompts
Neither has an isError flag, so their failures are exceptions, and the assertion style changes to match. Create src/test/java/com/themcpguy/resources/CustomerProfileResourceTest.java:
package com.themcpguy.resources;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.themcpguy.tools.CustomerRepository;
import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema.ErrorCodes;
import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult;
import io.modelcontextprotocol.spec.McpSchema.TextResourceContents;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* A resource read is synchronous and returns contents or throws an exception, so the
* failing case is asserted with assertThatThrownBy rather than by inspecting a flag.
*/
class CustomerProfileResourceTest {
private final CustomerProfileResource resource = new CustomerProfileResource(
new JacksonMcpJsonMapper(new ObjectMapper()), CustomerRepository.inMemory());
@Test
@DisplayName("reading a concrete URI returns that customer's profile")
void shouldReadOneCustomer() {
ReadResourceResult result = resource.read("customers://CUST-2");
TextResourceContents contents = (TextResourceContents) result.contents().getFirst();
assertThat(contents.uri()).isEqualTo("customers://CUST-2");
assertThat(contents.mimeType()).isEqualTo("application/json");
assertThat(contents.text()).contains("Globex Industries").contains("\"contacts\"");
}
@Test
@DisplayName("the profile fields appear in the order the handler declares them")
void shouldKeepFieldOrderStable() {
String json = ((TextResourceContents) resource.read("customers://CUST-2")
.contents().getFirst()).text();
assertThat(json).containsSubsequence(
"\"id\"", "\"name\"", "\"email\"", "\"accountStatus\"", "\"contacts\"");
}
@Test
@DisplayName("an unknown customer fails with -32002, not a generic internal error")
void shouldFailWithResourceNotFoundForUnknownCustomer() {
assertThatThrownBy(() -> resource.read("customers://NOPE"))
.isInstanceOf(McpError.class)
.satisfies(error -> assertThat(((McpError) error).getJsonRpcError().code())
.isEqualTo(ErrorCodes.RESOURCE_NOT_FOUND));
}
}
assertThatThrownBy replaces isError(), and it checks the error code, not just the type of exception. In Class 4 we deliberately returned -32002 for a customer that does not exist, instead of the generic internal error a plain exception would produce.
Without this test, someone could change that back and the build would still pass. The server would start, the read would still fail, and only the code on the wire would differ:
{"error": {"code": -32002, "message": "..."}} the client can tell "not found" apart
{"error": {"code": -32603, "message": "..."}} the client only knows something broke
The middle test is about field order. CustomerProfileResource builds its JSON with a LinkedHashMap, so the fields come out in the order the handler lists them. containsSubsequence asserts that the five names appear in that order and leaves the rest of the string free, so adding a field later will not break it.
Order is worth pinning down because a map whose iteration order is unspecified, such as Map.of, arranges the fields differently on each JVM run:
| Map type | Order within one JVM run | Order across JVM runs |
|---|---|---|
LinkedHashMap | insertion order | insertion order |
Map.of | fixed, chosen once as the JVM starts | varies from run to run |
That is why comparing two reads inside one test would not catch a switch to Map.of: both reads run in the same JVM and see the same order. containsSubsequence against the declared names catches it on the runs where the order comes out wrong.
Now the prompt. Create src/test/java/com/themcpguy/prompts/AccountReviewPromptTest.java:
package com.themcpguy.prompts;
import com.themcpguy.tools.CustomerRepository;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema.ErrorCodes;
import io.modelcontextprotocol.spec.McpSchema.GetPromptResult;
import io.modelcontextprotocol.spec.McpSchema.Role;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Prompts are worth testing precisely because the SDK does not validate their
* arguments: every required-argument check is yours, so every one of them can be wrong.
*/
class AccountReviewPromptTest {
private final AccountReviewPrompt prompt =
new AccountReviewPrompt(CustomerRepository.inMemory());
private static String firstMessage(GetPromptResult result) {
return ((TextContent) result.messages().getFirst().content()).text();
}
@Test
@DisplayName("the account data is inlined into the message, not left for the host")
void shouldInlineAccountData() {
GetPromptResult result = prompt.get(Map.of("customerId", "CUST-3"));
assertThat(result.messages()).hasSize(1);
assertThat(result.messages().getFirst().role()).isEqualTo(Role.USER);
assertThat(firstMessage(result))
.contains("Stark Holdings Ltd")
.contains("SUSPENDED")
.contains("nobody on file");
}
@Test
@DisplayName("tone changes the closing instruction, and the account data stays")
void shouldChangeOnlyTheClosingInstructionWhenToneChanges() {
String brief = firstMessage(prompt.get(Map.of("customerId", "CUST-3")));
String formal = firstMessage(prompt.get(Map.of("customerId", "CUST-3", "tone", "formal")));
assertThat(brief).contains("short summary");
assertThat(formal).contains("written handover");
assertThat(formal).contains("Stark Holdings Ltd");
}
@Test
@DisplayName("a missing required argument is rejected with -32602")
void shouldFailWithInvalidParamsWhenCustomerIdMissing() {
assertThatThrownBy(() -> prompt.get(Map.of()))
.isInstanceOf(McpError.class)
.satisfies(error -> assertThat(((McpError) error).getJsonRpcError().code())
.isEqualTo(ErrorCodes.INVALID_PARAMS));
}
@Test
@DisplayName("a null argument map produces an McpError, not a NullPointerException")
void shouldRejectNullArgumentsWithMcpError() {
assertThatThrownBy(() -> prompt.get(null))
.isInstanceOf(McpError.class);
}
@Test
@DisplayName("a customerId sent as a number reports not-found, not a ClassCastException")
void shouldReportNotFoundForNumericCustomerId() {
Map<String, Object> arguments = new HashMap<>();
arguments.put("customerId", 42);
assertThatThrownBy(() -> prompt.get(arguments))
.isInstanceOf(McpError.class)
.satisfies(error -> assertThat(((McpError) error).getJsonRpcError().code())
.isEqualTo(ErrorCodes.RESOURCE_NOT_FOUND));
}
}
Prompts deserve more test attention than tools. Class 5 established that prompts/get does not validate arguments at all: required(true) is documentation. The SDK handles the two requests differently:
Every check the prompt handler makes is hand-written, and hand-written checks are the ones that get deleted by accident. This is what reaches the handler:
| What the client sends | What the handler receives | Result |
|---|---|---|
"arguments": {"customerId": "CUST-3"} | a Map holding the String "CUST-3" | the lookup hits, GetPromptResult |
"arguments": {} | an empty Map | the required-argument check throws an McpError, -32602 |
no arguments member at all | null | the null becomes an empty Map, so the same check throws, -32602 |
"arguments": {"customerId": 42} | a Map holding the Integer 42 | toString() gives "42", the lookup misses, -32002 |
prompt.get(null) is the third row. A handler that went straight to arguments.get(...) would throw a NullPointerException, and the caller would see a generic internal error that never says which argument was missing.
The numeric case is the fourth. JSON cannot mark 42 as a string, so a client may legitimately send it as a number. AccountReviewPrompt reads the argument with toString(), so the lookup runs as normal and does not find a customer. Had the handler used a (String) cast, the same request would have produced a ClassCastException and -32603 instead. Asserting the code is what tells those two apart.
The prompts section of the specification names only -32602 and -32603. This course answers -32002 because the value the prompt could not find is a resource, customers://42, and that is the code the resources section gives for a missing resource.
The Real JAR, Over Real stdio
Unit tests show that our handlers work. They do not show that the server starts, that the capabilities are declared, that the tools are registered, or that the JAR is packaged correctly. To check those, we start the server and send it a real request.
All three integration tests need the same three things, so they are worth writing once: the packaged JAR, a subprocess running one of our servers, and a connected client. Create src/test/java/com/themcpguy/McpTestServer.java:
package com.themcpguy;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.ServerParameters;
import io.modelcontextprotocol.client.transport.StdioClientTransport;
import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper;
import io.modelcontextprotocol.spec.McpSchema.Implementation;
import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.stream.Stream;
/**
* Starts one of the course servers as a subprocess and hands back a connected client.
* <p>
* The JAR is located rather than hard coded, so renaming the project or changing its
* version does not break the tests, and no build tool or IDE setting is required.
*/
final class McpTestServer {
private McpTestServer() {
}
static McpSyncClient start(String mainClass) {
ServerParameters parameters = ServerParameters.builder("java")
.args("-cp", locateJar().toString(), mainClass)
.build();
McpSyncClient client = McpClient
.sync(new StdioClientTransport(parameters, new JacksonMcpJsonMapper(new ObjectMapper())))
.clientInfo(Implementation.builder("integration-test", "1.0.0").build())
.requestTimeout(Duration.ofSeconds(20))
.build();
client.initialize();
return client;
}
/**
* Finds the packaged JAR by asking the class loader where this test class came from.
* That is target/test-classes, so its parent is target, which is where the JAR lands.
* The shade plugin also leaves original-*.jar behind, which is the unshaded one.
*/
private static Path locateJar() {
Path target = testClassesDirectory().getParent();
try (Stream<Path> files = Files.list(target)) {
return files
.filter(file -> file.getFileName().toString().endsWith(".jar"))
.filter(file -> !file.getFileName().toString().startsWith("original-"))
.findFirst()
.orElseThrow(() -> new IllegalStateException(
"No packaged JAR in " + target + ". These are integration tests: "
+ "run 'mvn verify', or 'mvn package' first if you are "
+ "starting them from an IDE."));
} catch (IOException e) {
throw new IllegalStateException("Could not read " + target, e);
}
}
private static Path testClassesDirectory() {
try {
return Path.of(McpTestServer.class.getProtectionDomain()
.getCodeSource().getLocation().toURI());
} catch (URISyntaxException e) {
throw new IllegalStateException("Could not work out where the test classes are", e);
}
}
}
locateJar() is the part worth reading. The obvious approach is to write the path out:
"target/mcp-java-sdk-course-1.0.0-SNAPSHOT.jar"
which works until the version changes, and then every integration test fails with a message about a missing file. Renaming the project breaks it too.
Instead the helper asks the class loader, the part of the JVM that loaded the class, which file that class came from:
Neither the version nor the artifact name appears along that path. Two JARs land in target/, which is why the second filter is there:
target/
├── mcp-java-sdk-course-1.0.0-SNAPSHOT.jar the shaded JAR, the one to launch
├── original-mcp-java-sdk-course-1.0.0-SNAPSHOT.jar the build from before the dependencies
│ were packed in, cannot start on its own
├── classes/
└── test-classes/
If neither filter finds a JAR at all, the test fails with a message telling you to package first.
requestTimeout(Duration.ofSeconds(20)) writes out the SDK's own default. Leaving it off behaves the same, and writing it says in the helper how long an integration test waits for a server that stops answering.
The Failsafe plugin, added at the end of this class, can pass it in, which is the usual Maven answer:
<systemPropertyVariables>
<server.jar>${project.build.finalName}.jar</server.jar>
</systemPropertyVariables>
That property only exists inside the Maven run, so starting the test any other way breaks it. Starting a single integration test from the IDE is normal while writing one, and the classpath lookup behaves the same whichever of Maven, an IDE or a plain java command started it.
Each server gets a short test class. Create src/test/java/com/themcpguy/ToolsServerIT.java:
package com.themcpguy;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The Class 3 server, running as a real subprocess and answering over the same stdio
* transport Claude Desktop uses.
* <p>
* Named *IT so Failsafe runs it after package, when the JAR exists.
*/
class ToolsServerIT {
private McpSyncClient client;
@BeforeEach
void startServer() {
client = McpTestServer.start("com.themcpguy.tools.ToolsMcpServer");
}
@AfterEach
void stopServer() {
if (client != null) {
client.closeGracefully();
}
}
@Test
@DisplayName("the packaged JAR advertises all three tools")
void shouldListAllThreeTools() {
assertThat(client.listTools().tools())
.extracting(McpSchema.Tool::name)
.contains("calculate", "search_customers", "add_contact");
}
@Test
@DisplayName("a real tool call goes out and comes back over stdio")
void shouldCallSearchCustomersOverStdio() {
CallToolResult result = client.callTool(
CallToolRequest.builder("search_customers")
.arguments(Map.of("query", "globex"))
.build());
assertThat(result.isError()).isFalse();
assertThat(((TextContent) result.content().getFirst()).text()).contains("Globex Industries");
}
@Test
@DisplayName("the SDK rejects arguments that do not match the schema")
void shouldEnforceSchemaValidationOverTheWire() {
CallToolResult result = client.callTool(
CallToolRequest.builder("search_customers")
.arguments(Map.of())
.build());
assertThat(result.isError()).isTrue();
assertThat(((TextContent) result.content().getFirst()).text())
.contains("required property 'query' not found");
}
@Test
@DisplayName("adding a contact is visible to a later search, in the same process")
void shouldSeeAddedContactInLaterSearch() {
client.callTool(CallToolRequest.builder("add_contact")
.arguments(Map.of("customerId", "CUST-2",
"name", "Dana Wu", "email", "[email protected]"))
.build());
CallToolResult found = client.callTool(
CallToolRequest.builder("search_customers")
.arguments(Map.of("query", "dana"))
.build());
assertThat(((TextContent) found.content().getFirst()).text()).contains("Globex Industries");
}
}
@BeforeEach and @AfterEach are JUnit's per-test hooks, so each test gets a server process of its own. This is one of them from end to end:
The three middle messages are the handshake we have been typing out for three classes, and client.initialize() inside the helper performs all of it, with the reply checked.
The last test reaches something a unit test cannot: it calls add_contact, then search_customers, and confirms the second call finds what the first one wrote. Both requests go to the same server process, so they share one repository.
The other two servers
Resources and prompts each have behaviour that only appears once a real client is asking. Create src/test/java/com/themcpguy/ResourcesServerIT.java:
package com.themcpguy;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.BlobResourceContents;
import io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest;
import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult;
import io.modelcontextprotocol.spec.McpSchema.TextResourceContents;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The Class 4 server, over the same stdio transport. Resources are the primitive where
* the two list calls answer different questions, which only shows up over the protocol.
*/
class ResourcesServerIT {
private McpSyncClient client;
@BeforeEach
void startServer() {
client = McpTestServer.start("com.themcpguy.resources.ResourcesMcpServer");
}
@AfterEach
void stopServer() {
if (client != null) {
client.closeGracefully();
}
}
@Test
@DisplayName("resources/list returns the static resource only")
void shouldListOnlyTheStaticResource() {
assertThat(client.listResources().resources())
.extracting(McpSchema.Resource::uri)
.containsExactly("customers://directory");
}
@Test
@DisplayName("resources/templates/list returns the two templates")
void shouldListBothTemplates() {
assertThat(client.listResourceTemplates().resourceTemplates())
.extracting(McpSchema.ResourceTemplate::uriTemplate)
.containsExactly("customers://{customerId}", "customers://{customerId}/badge.png");
}
@Test
@DisplayName("reading a concrete URI is routed to the template handler")
void shouldReadOneCustomerThroughTheTemplate() {
ReadResourceResult result = client.readResource(
ReadResourceRequest.builder("customers://CUST-2").build());
TextResourceContents contents = (TextResourceContents) result.contents().getFirst();
assertThat(contents.mimeType()).isEqualTo("application/json");
assertThat(contents.text()).contains("Globex Industries");
}
@Test
@DisplayName("the badge template answers with base64 rather than text")
void shouldReadTheBadgeAsBinary() {
ReadResourceResult result = client.readResource(
ReadResourceRequest.builder("customers://CUST-2/badge.png").build());
BlobResourceContents contents = (BlobResourceContents) result.contents().getFirst();
assertThat(contents.mimeType()).isEqualTo("image/png");
assertThat(contents.blob()).startsWith("iVBORw0KGgo");
}
}
The first two tests are the pair from Class 4. resources/list and resources/templates/list answer different questions, and containsExactly on each makes that concrete: one static URI in the first, two patterns in the second. A unit test on CustomerDirectoryResource cannot show the difference, because the difference is in how the server registers them.
The fourth test reads the badge and checks the contents came back as BlobResourceContents with base64, which is the binary path from Class 4 end to end.
Now the prompts. Create src/test/java/com/themcpguy/PromptsServerIT.java:
package com.themcpguy;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest;
import io.modelcontextprotocol.spec.McpSchema.GetPromptResult;
import io.modelcontextprotocol.spec.McpSchema.ResourceLink;
import io.modelcontextprotocol.spec.McpSchema.Role;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The Class 5 server. Over the protocol we can check the two things a unit test cannot:
* what a host is offered in its menu, and what a prompt's messages look like on the wire.
*/
class PromptsServerIT {
private McpSyncClient client;
@BeforeEach
void startServer() {
client = McpTestServer.start("com.themcpguy.prompts.PromptsMcpServer");
}
@AfterEach
void stopServer() {
if (client != null) {
client.closeGracefully();
}
}
@Test
@DisplayName("prompts/list offers both prompts, with their titles and arguments")
void shouldListBothPromptsWithArguments() {
var prompts = client.listPrompts().prompts();
assertThat(prompts).extracting(McpSchema.Prompt::name)
.containsExactly("account_review", "escalation_note");
assertThat(prompts.getFirst().title()).isEqualTo("Account review before a call");
assertThat(prompts.getFirst().arguments())
.extracting(McpSchema.PromptArgument::name)
.containsExactly("customerId", "tone");
}
@Test
@DisplayName("account_review comes back with the account data already in the message")
void shouldInlineAccountDataOverTheWire() {
GetPromptResult result = client.getPrompt(GetPromptRequest.builder("account_review")
.arguments(Map.of("customerId", "CUST-3"))
.build());
assertThat(result.messages()).hasSize(1);
assertThat(((TextContent) result.messages().getFirst().content()).text())
.contains("Stark Holdings Ltd")
.contains("SUSPENDED");
}
@Test
@DisplayName("escalation_note sends a resource link and an assistant turn, not account data")
void shouldSendLinkAndAssistantTurn() {
GetPromptResult result = client.getPrompt(GetPromptRequest.builder("escalation_note")
.arguments(Map.of("customerId", "CUST-2"))
.build());
assertThat(result.messages()).hasSize(3);
ResourceLink link = (ResourceLink) result.messages().get(1).content();
assertThat(link.uri()).isEqualTo("customers://CUST-2");
assertThat(result.messages().getLast().role()).isEqualTo(Role.ASSISTANT);
assertThat(((TextContent) result.messages().getLast().content()).text())
.startsWith("ESCALATION NOTE");
}
}
The first test is the menu. title() and the argument names are what Claude Desktop renders, which is as close as code gets to the screenshot in Class 5.
The third is the one worth having. escalation_note returns three messages: a text instruction, a ResourceLink, and an ASSISTANT turn. The cast to ResourceLink proves the link travelled as a link and not as inlined text, which is the design Class 5 was built around.
IT, and this is not a style ruleSurefire is the plugin Maven's default build uses to run tests, and it runs anything called *Test during the test phase. Failsafe is its sibling from the same project, and it runs *IT classes in the integration-test phase:
A test named McpServerTest lands in the first box, to the left of the point where the JAR appears. It would try to launch a JAR that has not been built yet, fail, and stop the build before package could create one. On a clean checkout it would always fail. Surefire is already part of Maven's default build, so only Failsafe has to be added to pom.xml, above the shade plugin:
<!-- Class 7. Runs *IT classes in the integration-test phase, after package,
so the JAR the test launches actually exists. -->
<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>
The integration-test goal deliberately leaves the build passing, so the post-integration-test phase still gets to shut down whatever the tests started. verify is the goal that reads the results and fails the build. Bind only the first, and failing integration tests print their failures under a green BUILD SUCCESS.
Running Them
mvn package
Compiles the code, runs the seventeen unit tests, and builds the JAR. Fast, and it is what we run while working:
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.165 s -- in com.themcpguy.tools.SearchCustomersToolTest
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.007 s -- in com.themcpguy.tools.CalculateToolTest
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.008 s -- in com.themcpguy.resources.CustomerProfileResourceTest
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.005 s -- in com.themcpguy.prompts.AccountReviewPromptTest
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.003 s -- in com.themcpguy.errors.FindCustomerToolTest
Tests run: 17, Failures: 0, Errors: 0, Skipped: 0
Note what is missing: none of the *IT classes ran. Failsafe holds them until the integration-test phase, so during test the JAR they launch has yet to be built.
mvn verify
Compiles, runs the seventeen unit tests, builds the JAR, and then runs the eleven integration tests against that JAR:
Tests run: 17, Failures: 0, Errors: 0, Skipped: 0
T E S T S
Running com.themcpguy.ToolsServerIT
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.841 s -- in com.themcpguy.ToolsServerIT
Running com.themcpguy.ResourcesServerIT
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.246 s -- in com.themcpguy.ResourcesServerIT
Running com.themcpguy.PromptsServerIT
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.190 s -- in com.themcpguy.PromptsServerIT
Tests run: 11, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 7.224 s
Those Time elapsed values, drawn on one axis:
The five unit-test classes sit flat on the axis: they stay inside the JVM that is already running and finish in thousandths of a second. Each integration test class starts a separate java process, waits for it to boot and complete the handshake, then shuts it down again, which takes one to two seconds.
Those numbers come from one machine, and yours will differ. The ratio between the two groups is what holds on any machine.
mvn package is quick enough to run constantly while working. mvn verify is what a build server should run on every push, where a few extra seconds do not cost anything. If the integration tests become slow enough to interrupt you locally, that is an argument for letting the build server run them, not for deleting them.
What Testing Cannot Tell Us
Every test above answers "does my server do what I wrote". None of them answers "does the model use it correctly", and only a person can answer it.
A tool can pass every test and still be described so vaguely that Claude ignores it, or so eagerly that it calls it for everything. Class 3 showed this: calculate needed prompting before it fired, while search_customers fired on its own, and no assertion in this class would have predicted either.
| Question | Answered by |
|---|---|
| Does the handler compute the right answer? | a unit test |
| Does the server start, register its tools and answer over stdio? | an integration test |
| Does the model use the tool the way we meant it to? | a person, reading the conversation |
The last row stays manual. Connect the server to Claude Desktop and ask real questions, phrased the way a user would phrase them, instead of "call the search_customers tool":
- Does it reach for the tool without being told the tool exists?
- When the tool returns an error, does the answer become "I could not reach the customer system", or does it invent a customer?
- When a search comes back empty, does it say so?
Those are questions about the descriptions we wrote.
What We Built
src/test/java/com/themcpguy/
├── McpTestServer.java shared setup: finds the JAR, starts a server
├── ToolsServerIT.java 4 tests, real JAR over real stdio
├── ResourcesServerIT.java 4 tests, both list calls and a binary read
├── PromptsServerIT.java 3 tests, the menu and a resource link
├── tools/CalculateToolTest.java 3 tests, synchronous handler
├── tools/SearchCustomersToolTest.java 4 tests, Mono + a broken backend
├── resources/CustomerProfileResourceTest.java 3 tests, contents and -32002
├── prompts/AccountReviewPromptTest.java 5 tests, arguments nobody validates for you
└── errors/FindCustomerToolTest.java 2 tests, including the silent handler
Nothing in the suite uses a mocking framework. Only the three *IT classes start a server, because checking that the servers start is their purpose.
Keep the protocol wiring in spec() and the work in a plain method beside it. That is the pattern this course has used since Class 3. A handler written that way can be tested with a normal method call. A handler written inside the lambda you hand to the SDK cannot be, and every test then needs a running server.
What's Next
The tests show the server works. The next question is what happens when someone points it at data we did not intend to expose, or feeds a tool description into a model that treats it as an instruction.
Further Reading
- Maven Surefire Plugin: Inclusions and Exclusions of Tests: the four name patterns Surefire matches during the
testphase, includingTest*.java, which this class does not use. - Maven Failsafe Plugin: Inclusions and Exclusions of Tests: the three patterns that decide whether a class counts as an integration test, and how to change them.
- Introduction to the Build Lifecycle: the full ordered list of Maven phases, where you can see that
integration-testsits betweenpackageandverify. - Maven Surefire Plugin: Using JUnit 5 Platform: how to make
@DisplayNametext appear in Maven's own report, withstatelessTestsetReporterandusePhrasedTestCaseMethodName. - Testing, Reactor 3 reference guide: the rest of
StepVerifierbeyondassertNextandverifyComplete, includingexpectErrorandwithVirtualTimefor a handler that waits. - StepVerifier, reactor-test API: every expectation method on the builder, and the note that nothing is subscribed until a
verifymethod is called. - Display Names, JUnit 5.14.4 User Guide: what
@DisplayNameaccepts, and theDisplayNameGeneratoroption for deriving readable names without annotating every method. - AssertJ core assertions guide: the assertions this class uses,
containsSubsequence,extracting,satisfiesandassertThatThrownBy, each with worked examples. - MCP Client, Java SDK documentation:
McpSyncClient,StdioClientTransportandServerParameterswritten up by the SDK maintainers, which is the client halfMcpTestServerassembles by hand. - MCP Inspector: a user interface that lists a server's tools, resources and prompts and calls them by hand, for exploring a server without writing a test first.
Sources
- Maven Failsafe Plugin: that the
integration-testgoal deliberately leaves the build passing and theverifygoal is what fails it, which is why both goals are bound. - maven-failsafe-plugin metadata, Maven Central: the plugin version pinned in
pom.xml. - mcp-core 2.0.0 POM, Maven Central: that
mcp-corebrings inreactor-core3.7.0, which is the versionreactor-testis pinned to. - assertj-core metadata, Maven Central: that 3.27.7 is the current release of the 3.x line.
- McpSchema.java, MCP Java SDK v2.0.0: that
ErrorCodes.RESOURCE_NOT_FOUNDis-32002andErrorCodes.INVALID_PARAMSis-32602. - McpAsyncServer.java, MCP Java SDK v2.0.0: that
prompts/getchecks only the prompt name, so every required-argument check in the handler is our own. - McpClient.java, MCP Java SDK v2.0.0: that the client request timeout defaults to twenty seconds.
- jsv-messages.properties, networknt json-schema-validator 2.0.0: the exact wording
required property 'query' not foundthatToolsServerITasserts on. - MCP specification 2025-11-25: Prompts, Error Handling: that the prompts section names
-32602and-32603only. - MCP specification 2025-11-25: Resources, Error Handling: that
-32002is the resource-not-found code this course reuses for a prompt whose customer is missing. - MCP specification 2025-11-25: Transports: the stdio rules the subprocess in the integration tests has to obey.
- java.util.Map, Java SE 21 API: that the iteration order of
Map.ofis unspecified and subject to change. - Getting Started, Reactor 3 reference guide: that Reactor publishes
reactor-bomsoreactor-coreandreactor-testcannot drift apart. - LLM02:2025 Sensitive Information Disclosure, OWASP: why the backend error text asserted in
SearchCustomersToolTestis the kind of message Class 6 says should not reach the model.