Add mcp-server module: order-lookup service exposed via @McpTool/@McpResource/@McpPrompt over Streamable HTTP

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FtpJvZfg4nvLvtzgJTDWpB
This commit is contained in:
Claude
2026-09-23 11:56:37 +00:00
parent c1fc41e0de
commit 9919da58a7
21 changed files with 704 additions and 0 deletions
@@ -0,0 +1,13 @@
package com.ankurm.mcpserver;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class McpServerApplication {
public static void main(String[] args) {
SpringApplication.run(McpServerApplication.class, args);
}
}
@@ -0,0 +1,11 @@
package com.ankurm.mcpserver.domain;
import java.math.BigDecimal;
import java.util.List;
/**
* The existing domain type this module pretends predates MCP entirely -- the point of this
* article is exposing a service that already looks like this, not designing one around MCP.
*/
public record Order(String id, String customer, OrderStatus status, List<String> items, BigDecimal total) {
}
@@ -0,0 +1,41 @@
package com.ankurm.mcpserver.domain;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.stereotype.Repository;
/**
* Stand-in for a real order-lookup service (a JPA repository, a call to another microservice,
* whatever it actually is at your shop). The MCP-facing code in {@code tools} never needs to
* change if this gets replaced with the real thing -- it depends on this interface's shape, not
* its storage.
*/
@Repository
public class OrderRepository {
private final Map<String, Order> orders = new LinkedHashMap<>();
public OrderRepository() {
orders.put("ORD-1001", new Order("ORD-1001", "Priya Nair", OrderStatus.SHIPPED,
List.of("Mechanical keyboard", "USB-C cable"), new BigDecimal("4899.00")));
orders.put("ORD-1002", new Order("ORD-1002", "Rohan Mehta", OrderStatus.PENDING,
List.of("27\" monitor"), new BigDecimal("18999.00")));
orders.put("ORD-1003", new Order("ORD-1003", "Ankita Rao", OrderStatus.DELIVERED,
List.of("Laptop stand", "Wireless mouse", "USB hub"), new BigDecimal("3450.00")));
orders.put("ORD-1004", new Order("ORD-1004", "Vikram Shah", OrderStatus.CANCELLED,
List.of("Graphics card"), new BigDecimal("42999.00")));
}
public Optional<Order> findById(String id) {
return Optional.ofNullable(orders.get(id));
}
public List<Order> findAll() {
return List.copyOf(orders.values());
}
}
@@ -0,0 +1,5 @@
package com.ankurm.mcpserver.domain;
public enum OrderStatus {
PENDING, SHIPPED, DELIVERED, CANCELLED
}
@@ -0,0 +1,53 @@
package com.ankurm.mcpserver.tools;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.ai.mcp.annotation.McpArg;
import org.springframework.ai.mcp.annotation.McpPrompt;
import org.springframework.ai.mcp.annotation.McpResource;
import org.springframework.ai.mcp.annotation.McpTool;
import org.springframework.ai.mcp.annotation.McpToolParam;
import org.springframework.stereotype.Service;
import com.ankurm.mcpserver.domain.Order;
import com.ankurm.mcpserver.domain.OrderRepository;
/**
* The existing service this article exposes over MCP. Nothing here imports an MCP class except
* the three annotations -- {@link OrderRepository} and {@link Order} are ordinary Spring beans
* and domain types that would exist whether or not an AI agent ever called them.
*/
@Service
public class OrderTools {
private final OrderRepository repository;
public OrderTools(OrderRepository repository) {
this.repository = repository;
}
@McpTool(name = "lookup_order", description = "Look up a single order by its ID and return its customer, status, items, and total.")
public Order lookupOrder(
@McpToolParam(description = "The order ID, e.g. ORD-1001", required = true) String orderId) {
return repository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("No order with ID " + orderId));
}
@McpResource(uri = "orders://catalog", name = "order-catalog",
description = "Every order ID currently in the system, one per line -- read this first if you don't already have an order ID to look up.",
mimeType = "text/plain")
public String orderCatalog() {
return repository.findAll().stream().map(Order::id).collect(Collectors.joining("\n"));
}
@McpPrompt(name = "summarize_order", description = "A ready-made prompt asking a model to write a one-paragraph customer-support summary of one order.")
public String summarizeOrderPrompt(
@McpArg(name = "orderId", description = "The order ID to summarize, e.g. ORD-1001", required = true) String orderId) {
return "Call the lookup_order tool for order " + orderId
+ ", then write a single short paragraph a customer support agent could paste into a reply: "
+ "state the order status in plain language, list the items, and mention the total. "
+ "Do not invent any detail the tool did not return.";
}
}
@@ -0,0 +1,15 @@
spring:
application:
name: mcp-server
ai:
mcp:
server:
name: order-lookup-server
version: 1.0.0
# The jar's own spring-configuration-metadata.json lists "streamable" as this
# property's default -- but that is only the @ConfigurationProperties field default,
# and the autoconfiguration class that actually wires the /mcp endpoint is gated by
# @ConditionalOnProperty, which only ever looks at the Environment, never at a field
# default. Leave this property unset and McpServerStreamableHttpWebMvcAutoConfiguration
# never matches, full stop -- see the article for the real condition-evaluation log line.
protocol: streamable
@@ -0,0 +1,93 @@
package com.ankurm.mcpserver;
import java.util.Map;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpSchema;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Drives the real, running MCP server over its real Streamable HTTP transport, using the
* official MCP Java SDK client (the same client library MCP Inspector and Claude Desktop are
* themselves built on) -- not a mock, not an in-process shortcut. This class asserts on the
* parsed, typed results; {@link OrderMcpTranscriptTest} separately captures the literal wire
* bytes for the article, over a deliberately simpler raw HTTP path (see its class comment for
* why the two are kept apart).
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderMcpServerTest {
@LocalServerPort
private int port;
private McpSyncClient client;
@BeforeEach
void connect() {
HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport
.builder("http://localhost:" + port)
.endpoint("/mcp")
.build();
this.client = McpClient.sync(transport).build();
this.client.initialize();
}
@AfterEach
void disconnect() {
this.client.closeGracefully();
}
@Test
void listsTheRegisteredTool() {
McpSchema.ListToolsResult tools = this.client.listTools();
assertThat(tools.tools()).extracting(McpSchema.Tool::name).containsExactly("lookup_order");
}
@Test
void callsLookupOrderTool() {
McpSchema.CallToolResult result = this.client
.callTool(new McpSchema.CallToolRequest("lookup_order", Map.of("orderId", "ORD-1001")));
assertThat(result.isError()).isNotEqualTo(Boolean.TRUE);
String text = ((McpSchema.TextContent) result.content().get(0)).text();
assertThat(text).contains("Priya Nair").contains("SHIPPED").contains("4899.00");
}
@Test
void callingLookupOrderWithAnUnknownIdReturnsAToolErrorNotAProtocolError() {
McpSchema.CallToolResult result = this.client
.callTool(new McpSchema.CallToolRequest("lookup_order", Map.of("orderId", "ORD-9999")));
assertThat(result.isError()).isEqualTo(Boolean.TRUE);
String text = ((McpSchema.TextContent) result.content().get(0)).text();
assertThat(text).contains("No order with ID ORD-9999");
}
@Test
void readsTheOrderCatalogResource() {
McpSchema.ReadResourceResult result = this.client
.readResource(new McpSchema.ReadResourceRequest("orders://catalog"));
String text = ((McpSchema.TextResourceContents) result.contents().get(0)).text();
assertThat(text).contains("ORD-1001").contains("ORD-1002").contains("ORD-1003").contains("ORD-1004");
}
@Test
void getsTheSummarizeOrderPrompt() {
McpSchema.GetPromptResult result = this.client
.getPrompt(new McpSchema.GetPromptRequest("summarize_order", Map.of("orderId", "ORD-1002")));
String text = ((McpSchema.TextContent) result.messages().get(0).content()).text();
assertThat(text).contains("ORD-1002").contains("lookup_order");
}
}
@@ -0,0 +1,123 @@
package com.ankurm.mcpserver;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import com.ankurm.mcpserver.support.Transcript;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Captures the literal JSON-RPC bytes for the article by talking to the real, running server
* with a plain {@code java.net.http.HttpClient} -- deliberately not the MCP SDK client that
* {@link OrderMcpServerTest} uses for assertions. An earlier version of this test tried to
* capture the SDK client's traffic with a servlet filter and failed intermittently: the
* Streamable HTTP transport writes each response during an async servlet dispatch, and can
* multiplex more than one JSON-RPC message onto the same long-lived HTTP exchange, so a filter
* keyed by "one request equals one response equals one method" is fighting the protocol rather
* than observing it. One request per {@code HttpClient.send()} call, read synchronously, sidesteps
* that entirely -- what is captured here is exactly, deterministically, what the socket carried.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderMcpTranscriptTest {
@LocalServerPort
private int port;
private final HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
@Test
void capturesRealJsonRpcTranscripts() throws Exception {
String base = "http://localhost:" + this.port + "/mcp";
String initRequest = """
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"transcript-capture","version":"1.0"}}}""";
HttpResponse<String> initResponse = post(base, initRequest, null);
assertThat(initResponse.statusCode()).isEqualTo(200);
String sessionId = initResponse.headers().firstValue("Mcp-Session-Id").orElseThrow();
try (Transcript out = new Transcript("01-initialize-and-list-tools.txt",
"POST /mcp -- initialize, then tools/list")) {
out.line("Request (initialize):").blank().line(initRequest).blank();
out.line("Response (%s):", initResponse.statusCode()).blank().line(initResponse.body()).blank();
String initializedNotification = """
{"jsonrpc":"2.0","method":"notifications/initialized"}""";
post(base, initializedNotification, sessionId);
String listRequest = """
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}""";
HttpResponse<String> listResponse = post(base, listRequest, sessionId);
assertThat(listResponse.body()).contains("lookup_order");
out.line("Request (tools/list):").blank().line(listRequest).blank();
out.line("Response (%s):", listResponse.statusCode()).blank().line(listResponse.body());
}
String callRequest = """
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"lookup_order","arguments":{"orderId":"ORD-1001"}}}""";
HttpResponse<String> callResponse = post(base, callRequest, sessionId);
assertThat(callResponse.body()).contains("Priya Nair").contains("SHIPPED");
try (Transcript out = new Transcript("02-call-tool-lookup-order.txt",
"POST /mcp -- tools/call lookup_order, a real order")) {
out.line("Request:").blank().line(callRequest).blank();
out.line("Response (%s):", callResponse.statusCode()).blank().line(callResponse.body());
}
String readRequest = """
{"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"orders://catalog"}}""";
HttpResponse<String> readResponse = post(base, readRequest, sessionId);
assertThat(readResponse.body()).contains("ORD-1001").contains("ORD-1004");
try (Transcript out = new Transcript("03-read-resource-catalog.txt",
"POST /mcp -- resources/read orders://catalog")) {
out.line("Request:").blank().line(readRequest).blank();
out.line("Response (%s):", readResponse.statusCode()).blank().line(readResponse.body());
}
String promptRequest = """
{"jsonrpc":"2.0","id":5,"method":"prompts/get","params":{"name":"summarize_order","arguments":{"orderId":"ORD-1002"}}}""";
HttpResponse<String> promptResponse = post(base, promptRequest, sessionId);
assertThat(promptResponse.body()).contains("ORD-1002").contains("lookup_order");
try (Transcript out = new Transcript("04-get-prompt-summarize-order.txt",
"POST /mcp -- prompts/get summarize_order")) {
out.line("Request:").blank().line(promptRequest).blank();
out.line("Response (%s):", promptResponse.statusCode()).blank().line(promptResponse.body());
}
String notFoundRequest = """
{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"lookup_order","arguments":{"orderId":"ORD-9999"}}}""";
HttpResponse<String> notFoundResponse = post(base, notFoundRequest, sessionId);
assertThat(notFoundResponse.statusCode()).isEqualTo(200);
assertThat(notFoundResponse.body()).contains("\"isError\":true").contains("No order with ID ORD-9999");
try (Transcript out = new Transcript("05-call-tool-lookup-order-not-found.txt",
"POST /mcp -- tools/call lookup_order, an order ID that does not exist")) {
out.line("Request:").blank().line(notFoundRequest).blank();
out.line("Response (%s), note isError:true inside a 200 -- HTTP itself never fails for a tool error:",
notFoundResponse.statusCode()).blank().line(notFoundResponse.body());
}
}
private HttpResponse<String> post(String url, String jsonBody, String sessionId) throws Exception {
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url))
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
if (sessionId != null) {
builder.header("Mcp-Session-Id", sessionId);
}
return this.httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString());
}
}
@@ -0,0 +1,47 @@
package com.ankurm.mcpserver.support;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Writes a numbered transcript under {@code output/} (repository root, not {@code docs/}) and
* echoes it to the console. Every console block quoted in the article comes out of one of these
* files verbatim.
*/
public final class Transcript implements AutoCloseable {
private final Path path;
private final StringWriter buffer = new StringWriter();
private final PrintWriter out = new PrintWriter(buffer);
public Transcript(String fileName, String title) {
this.path = Path.of("output", fileName);
out.println("# " + title);
out.println();
}
public Transcript line(String format, Object... args) {
out.println(args.length == 0 ? format : String.format(format, args));
return this;
}
public Transcript blank() {
out.println();
return this;
}
@Override
public void close() {
out.flush();
try {
Files.createDirectories(path.getParent());
Files.writeString(path, buffer.toString());
} catch (IOException e) {
throw new IllegalStateException("could not write " + path, e);
}
System.out.print(buffer);
}
}