From 9919da58a78dbb6c6231b25c12a9772c89930889 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 11:56:37 +0000 Subject: [PATCH] Add mcp-server module: order-lookup service exposed via @McpTool/@McpResource/@McpPrompt over Streamable HTTP Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01FtpJvZfg4nvLvtzgJTDWpB --- README.md | 1 + mcp-server/.gitignore | 1 + mcp-server/README.md | 91 +++++++++++++ .../output/01-initialize-and-list-tools.txt | 21 +++ .../output/02-call-tool-lookup-order.txt | 13 ++ .../output/03-read-resource-catalog.txt | 13 ++ .../output/04-get-prompt-summarize-order.txt | 13 ++ .../05-call-tool-lookup-order-not-found.txt | 13 ++ .../output/06-protocol-property-unset-404.txt | 35 +++++ mcp-server/pom.xml | 70 ++++++++++ mcp-server/scripts/run-all.sh | 14 ++ mcp-server/scripts/run.sh | 18 +++ .../mcpserver/McpServerApplication.java | 13 ++ .../com/ankurm/mcpserver/domain/Order.java | 11 ++ .../mcpserver/domain/OrderRepository.java | 41 ++++++ .../ankurm/mcpserver/domain/OrderStatus.java | 5 + .../ankurm/mcpserver/tools/OrderTools.java | 53 ++++++++ mcp-server/src/main/resources/application.yml | 15 +++ .../ankurm/mcpserver/OrderMcpServerTest.java | 93 +++++++++++++ .../mcpserver/OrderMcpTranscriptTest.java | 123 ++++++++++++++++++ .../ankurm/mcpserver/support/Transcript.java | 47 +++++++ 21 files changed, 704 insertions(+) create mode 100644 mcp-server/.gitignore create mode 100644 mcp-server/README.md create mode 100644 mcp-server/output/01-initialize-and-list-tools.txt create mode 100644 mcp-server/output/02-call-tool-lookup-order.txt create mode 100644 mcp-server/output/03-read-resource-catalog.txt create mode 100644 mcp-server/output/04-get-prompt-summarize-order.txt create mode 100644 mcp-server/output/05-call-tool-lookup-order-not-found.txt create mode 100644 mcp-server/output/06-protocol-property-unset-404.txt create mode 100644 mcp-server/pom.xml create mode 100644 mcp-server/scripts/run-all.sh create mode 100644 mcp-server/scripts/run.sh create mode 100644 mcp-server/src/main/java/com/ankurm/mcpserver/McpServerApplication.java create mode 100644 mcp-server/src/main/java/com/ankurm/mcpserver/domain/Order.java create mode 100644 mcp-server/src/main/java/com/ankurm/mcpserver/domain/OrderRepository.java create mode 100644 mcp-server/src/main/java/com/ankurm/mcpserver/domain/OrderStatus.java create mode 100644 mcp-server/src/main/java/com/ankurm/mcpserver/tools/OrderTools.java create mode 100644 mcp-server/src/main/resources/application.yml create mode 100644 mcp-server/src/test/java/com/ankurm/mcpserver/OrderMcpServerTest.java create mode 100644 mcp-server/src/test/java/com/ankurm/mcpserver/OrderMcpTranscriptTest.java create mode 100644 mcp-server/src/test/java/com/ankurm/mcpserver/support/Transcript.java diff --git a/README.md b/README.md index ec8f9cb..9037680 100644 --- a/README.md +++ b/README.md @@ -6,5 +6,6 @@ Runnable companion code for the Spring AI articles on [ankurm.com](https://ankur |---|---|---| | [`getting-started/`](getting-started) | One `ChatClient` bean, three endpoints (plain call, templated system prompt, streaming), and a test proving `spring.ai.model.chat` switches providers with zero code change. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Spring AI 2.0 in 10 Minutes: ChatClient on Spring Boot 4.1](https://ankurm.com/spring-ai-2-0-chatclient-boot-4-1/) | | [`rag/`](rag) | Ingest PDFs, chunk, retrieve from pgvector, rerank, answer, check the answer. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Production-grade RAG with Spring AI](https://ankurm.com/production-rag-spring-ai-java/) and [the complete example](https://ankurm.com/spring-ai-rag-complete-example/) | +| [`mcp-server/`](mcp-server) | An order-lookup service exposed as MCP tools, a resource, and a prompt with `@McpTool`/`@McpResource`/`@McpPrompt`, served over Streamable HTTP (Spring AI 2.0's default MCP server transport). Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Build an MCP Server with Spring AI 2.0](https://ankurm.com/spring-ai-2-0-mcp-server-streamable-http/) | Upgrading from Spring AI 1.x: [migration guide](https://ankurm.com/spring-ai-1-to-2-migration-guide/). diff --git a/mcp-server/.gitignore b/mcp-server/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/mcp-server/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 0000000..0def972 --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,91 @@ +# mcp-server + +An existing Spring service -- an order-lookup repository, nothing MCP-aware about it -- exposed +as MCP tools, resources, and a prompt with `@McpTool`/`@McpResource`/`@McpPrompt`, served over +Streamable HTTP: Spring AI 2.0's default MCP server transport. + +Companion code for [Build an MCP Server with Spring AI 2.0](https://ankurm.com/spring-ai-2-0-mcp-server-streamable-http/) on [ankurm.com](https://ankurm.com). + +## Versions + +Spring Boot **4.1.1**, Spring AI **2.0.1**, Java **25** (LTS) -- same baseline as the rest of this +repository. + +## Quickstart + +``` +./scripts/run.sh +``` + +Then, in another shell: + +``` +curl -X POST http://localhost:8080/mcp \ + -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' +``` + +or point [MCP Inspector](https://github.com/modelcontextprotocol/inspector) or Claude Desktop's +MCP settings at `http://localhost:8080/mcp`. + +## What's exposed + +| Kind | Name | What it does | +|---|---|---| +| Tool | `lookup_order` | Looks up one order by ID; returns customer, status, items, total | +| Resource | `orders://catalog` | Every order ID currently in the system, one per line | +| Prompt | `summarize_order` | A ready-made prompt asking a model to summarize one order for support | + +All three live in [`OrderTools.java`](src/main/java/com/ankurm/mcpserver/tools/OrderTools.java), +which imports nothing MCP-specific except the three annotations -- the domain type +([`Order.java`](src/main/java/com/ankurm/mcpserver/domain/Order.java)) and the repository behind +it ([`OrderRepository.java`](src/main/java/com/ankurm/mcpserver/domain/OrderRepository.java)) +would exist whether or not MCP was ever wired in. + +## The one property that matters + +```yaml +spring: + ai: + mcp: + server: + protocol: streamable +``` + +The jar's own `spring-configuration-metadata.json` lists `streamable` as this property's default +-- but leave it out of `application.yml` and the `/mcp` endpoint never gets registered at all +(a plain 404, not a clearer error). See +[`output/06-protocol-property-unset-404.txt`](output/06-protocol-property-unset-404.txt) for the +real condition-evaluation log line that explains why, and the article for the full story. + +## Tests and captured output + +`OrderMcpServerTest` drives the real, running server over its real Streamable HTTP transport +using the official MCP Java SDK client (`io.modelcontextprotocol.sdk:mcp`, the same library MCP +Inspector and Claude Desktop are themselves built on) -- not a mock -- and asserts on the parsed, +typed results. `OrderMcpTranscriptTest` separately captures the literal JSON-RPC bytes that cross +the wire, with a plain synchronous `java.net.http.HttpClient` driving the protocol by hand +(initialize, read the `Mcp-Session-Id` header, `notifications/initialized`, then one request per +method): an earlier version tried to capture the SDK client's own traffic with a shared servlet +filter and that was intermittently flaky, because the Streamable HTTP transport writes responses +during an async servlet dispatch and can multiplex more than one JSON-RPC message onto one +long-lived HTTP exchange -- one request per `HttpClient.send()`, read back synchronously, +sidesteps that instead of fighting it. Either way, every request and response quoted in the +article is real, not reconstructed. + +| Output file | What it captures | +|---|---| +| `output/01-initialize-and-list-tools.txt` | The MCP handshake, then `tools/list` | +| `output/02-call-tool-lookup-order.txt` | `tools/call` for a real order | +| `output/03-read-resource-catalog.txt` | `resources/read` for the order catalog | +| `output/04-get-prompt-summarize-order.txt` | `prompts/get` for the summarize prompt | +| `output/05-call-tool-lookup-order-not-found.txt` | `tools/call` for an order ID that doesn't exist -- a tool error, not a protocol error | +| `output/06-protocol-property-unset-404.txt` | Captured manually: the 404 you get without `spring.ai.mcp.server.protocol` set, and the real Boot condition-evaluation log line that explains it | + +Run `mvn -o test` (or `./scripts/run-all.sh`) to regenerate 01 through 05. + +## Regenerating output + +``` +./scripts/run-all.sh +``` diff --git a/mcp-server/output/01-initialize-and-list-tools.txt b/mcp-server/output/01-initialize-and-list-tools.txt new file mode 100644 index 0000000..74c320a --- /dev/null +++ b/mcp-server/output/01-initialize-and-list-tools.txt @@ -0,0 +1,21 @@ +# POST /mcp -- initialize, then tools/list + +Request (initialize): + +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"transcript-capture","version":"1.0"}}} + +Response (200): + +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"completions":{},"logging":{},"prompts":{"listChanged":true},"resources":{"subscribe":false,"listChanged":true},"tools":{"listChanged":true}},"serverInfo":{"name":"order-lookup-server","version":"1.0.0"}}} + +Request (tools/list): + +{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} + +Response (200): + +id:59682b84-e49c-42d2-908e-a8105aa31514 +event:message +data:{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"lookup_order","title":"lookup_order","description":"Look up a single order by its ID and return its customer, status, items, and total.","inputSchema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"orderId":{"type":"string","description":"The order ID, e.g. ORD-1001"}},"required":["orderId"]},"annotations":{"title":"","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":true}}]}} + + diff --git a/mcp-server/output/02-call-tool-lookup-order.txt b/mcp-server/output/02-call-tool-lookup-order.txt new file mode 100644 index 0000000..73fd695 --- /dev/null +++ b/mcp-server/output/02-call-tool-lookup-order.txt @@ -0,0 +1,13 @@ +# POST /mcp -- tools/call lookup_order, a real order + +Request: + +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"lookup_order","arguments":{"orderId":"ORD-1001"}}} + +Response (200): + +id:59682b84-e49c-42d2-908e-a8105aa31514 +event:message +data:{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"{\"id\":\"ORD-1001\",\"customer\":\"Priya Nair\",\"status\":\"SHIPPED\",\"items\":[\"Mechanical keyboard\",\"USB-C cable\"],\"total\":4899.00}"}],"isError":false}} + + diff --git a/mcp-server/output/03-read-resource-catalog.txt b/mcp-server/output/03-read-resource-catalog.txt new file mode 100644 index 0000000..deb1ce2 --- /dev/null +++ b/mcp-server/output/03-read-resource-catalog.txt @@ -0,0 +1,13 @@ +# POST /mcp -- resources/read orders://catalog + +Request: + +{"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"orders://catalog"}} + +Response (200): + +id:59682b84-e49c-42d2-908e-a8105aa31514 +event:message +data:{"jsonrpc":"2.0","id":4,"result":{"contents":[{"uri":"orders://catalog","mimeType":"text/plain","text":"ORD-1001\nORD-1002\nORD-1003\nORD-1004"}]}} + + diff --git a/mcp-server/output/04-get-prompt-summarize-order.txt b/mcp-server/output/04-get-prompt-summarize-order.txt new file mode 100644 index 0000000..aa59d20 --- /dev/null +++ b/mcp-server/output/04-get-prompt-summarize-order.txt @@ -0,0 +1,13 @@ +# POST /mcp -- prompts/get summarize_order + +Request: + +{"jsonrpc":"2.0","id":5,"method":"prompts/get","params":{"name":"summarize_order","arguments":{"orderId":"ORD-1002"}}} + +Response (200): + +id:59682b84-e49c-42d2-908e-a8105aa31514 +event:message +data:{"jsonrpc":"2.0","id":5,"result":{"messages":[{"role":"assistant","content":{"type":"text","text":"Call the lookup_order tool for order ORD-1002, 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."}}]}} + + diff --git a/mcp-server/output/05-call-tool-lookup-order-not-found.txt b/mcp-server/output/05-call-tool-lookup-order-not-found.txt new file mode 100644 index 0000000..0a81686 --- /dev/null +++ b/mcp-server/output/05-call-tool-lookup-order-not-found.txt @@ -0,0 +1,13 @@ +# POST /mcp -- tools/call lookup_order, an order ID that does not exist + +Request: + +{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"lookup_order","arguments":{"orderId":"ORD-9999"}}} + +Response (200), note isError:true inside a 200 -- HTTP itself never fails for a tool error: + +id:59682b84-e49c-42d2-908e-a8105aa31514 +event:message +data:{"jsonrpc":"2.0","id":6,"result":{"content":[{"type":"text","text":"No order with ID ORD-9999\nNo order with ID ORD-9999"}],"isError":true}} + + diff --git a/mcp-server/output/06-protocol-property-unset-404.txt b/mcp-server/output/06-protocol-property-unset-404.txt new file mode 100644 index 0000000..765df00 --- /dev/null +++ b/mcp-server/output/06-protocol-property-unset-404.txt @@ -0,0 +1,35 @@ +# The 404 that shows up with no spring.ai.mcp.server.protocol set, and why + +Captured manually against a standalone run of this module with application.yml's +`spring.ai.mcp.server.protocol: streamable` line removed -- everything else identical. +Reproduced with: `mvn -o spring-boot:run --server.port=18099 --debug`, then: + +$ curl -X POST http://localhost:18099/mcp -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' + +{"timestamp":"2026-09-23T11:44:01.196Z","status":404,"error":"Not Found","path":"/mcp"} + +The real reason, from Boot's own --debug condition-evaluation report (trimmed to the one line +that matters): + +McpServerStreamableHttpWebMvcAutoConfiguration: + Did not match: + - AllNestedConditions 1 matched 1 did not; NestedCondition on + McpServerAutoConfiguration.EnabledStreamableServerCondition.StreamableEnabledCondition + @ConditionalOnProperty (spring.ai.mcp.server.protocol=STREAMABLE) did not find + property 'protocol'; NestedCondition on + McpServerAutoConfiguration.EnabledStreamableServerCondition.McpServerEnabledCondition + @ConditionalOnProperty (spring.ai.mcp.server.enabled=true) matched + +The jar's spring-configuration-metadata.json lists "streamable" as spring.ai.mcp.server.protocol's +default value -- but that default is only ever applied when something actually binds the +@ConfigurationProperties object (McpServerProperties). The @ConditionalOnProperty guarding +McpServerStreamableHttpWebMvcAutoConfiguration runs before any binding happens, checking the +Spring Environment directly for a literal "spring.ai.mcp.server.protocol" entry. No entry, no +match, no RouterFunction registered for /mcp -- the endpoint simply does not exist, and every +request to it 404s exactly like a request to a path nobody ever mapped, which is exactly what it +is. Setting spring.ai.mcp.server.protocol: streamable explicitly in application.yml fixes it, and +that same run then returns a normal 200 with a real MCP initialize response, e.g.: + +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"completions":{},"logging":{},"prompts":{"listChanged":true},"resources":{"subscribe":false,"listChanged":true},"tools":{"listChanged":true}},"serverInfo":{"name":"order-lookup-server","version":"1.0.0"}}} diff --git a/mcp-server/pom.xml b/mcp-server/pom.xml new file mode 100644 index 0000000..8ef4ebc --- /dev/null +++ b/mcp-server/pom.xml @@ -0,0 +1,70 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + mcp-server + 1.0.0 + mcp-server + An existing Spring Boot service exposed as MCP tools with @McpTool/@McpResource, over Streamable HTTP -- Spring AI 2.0's default MCP server transport + + + 25 + + 2.0.1 + + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + + + + org.springframework.ai + spring-ai-starter-mcp-server-webmvc + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + -Duser.timezone=UTC -Dstdout.encoding=UTF-8 -Dfile.encoding=UTF-8 + + + + + diff --git a/mcp-server/scripts/run-all.sh b/mcp-server/scripts/run-all.sh new file mode 100644 index 0000000..2953b6d --- /dev/null +++ b/mcp-server/scripts/run-all.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Regenerates every reproducible file under output/. Run from the module root +# (spring-ai/mcp-server). Needs Maven with the dependencies already resolved once online. +# +# output/06-*.txt is NOT regenerated here -- it was captured manually against a standalone run +# with spring.ai.mcp.server.protocol commented out in application.yml. See its own header. +set -eu +cd "$(dirname "$0")/.." + +mvn -q -o test +echo +echo "Regenerated output/01 through 05 -- real JSON-RPC exchanges captured by" +echo "OrderMcpTranscriptTest against the real running Streamable HTTP endpoint." +echo "output/06-protocol-property-unset-404.txt was captured manually -- see its header." diff --git a/mcp-server/scripts/run.sh b/mcp-server/scripts/run.sh new file mode 100644 index 0000000..938493f --- /dev/null +++ b/mcp-server/scripts/run.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Starts the application, killing any previous instance first. +# +# Usage: +# ./scripts/run.sh +# +# Then, in another shell (or point MCP Inspector / Claude Desktop at the same URL): +# curl -X POST http://localhost:8080/mcp \ +# -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ +# -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' +set -eu +cd "$(dirname "$0")/.." + +for p in $(ps -eo pid,cmd | grep '[M]cpServerApplication' | awk '{print $1}'); do + kill -9 "$p" +done + +mvn -q -o org.springframework.boot:spring-boot-maven-plugin:run diff --git a/mcp-server/src/main/java/com/ankurm/mcpserver/McpServerApplication.java b/mcp-server/src/main/java/com/ankurm/mcpserver/McpServerApplication.java new file mode 100644 index 0000000..29baab4 --- /dev/null +++ b/mcp-server/src/main/java/com/ankurm/mcpserver/McpServerApplication.java @@ -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); + } + +} diff --git a/mcp-server/src/main/java/com/ankurm/mcpserver/domain/Order.java b/mcp-server/src/main/java/com/ankurm/mcpserver/domain/Order.java new file mode 100644 index 0000000..18de1f0 --- /dev/null +++ b/mcp-server/src/main/java/com/ankurm/mcpserver/domain/Order.java @@ -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 items, BigDecimal total) { +} diff --git a/mcp-server/src/main/java/com/ankurm/mcpserver/domain/OrderRepository.java b/mcp-server/src/main/java/com/ankurm/mcpserver/domain/OrderRepository.java new file mode 100644 index 0000000..55d0412 --- /dev/null +++ b/mcp-server/src/main/java/com/ankurm/mcpserver/domain/OrderRepository.java @@ -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 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 findById(String id) { + return Optional.ofNullable(orders.get(id)); + } + + public List findAll() { + return List.copyOf(orders.values()); + } + +} diff --git a/mcp-server/src/main/java/com/ankurm/mcpserver/domain/OrderStatus.java b/mcp-server/src/main/java/com/ankurm/mcpserver/domain/OrderStatus.java new file mode 100644 index 0000000..1ef360b --- /dev/null +++ b/mcp-server/src/main/java/com/ankurm/mcpserver/domain/OrderStatus.java @@ -0,0 +1,5 @@ +package com.ankurm.mcpserver.domain; + +public enum OrderStatus { + PENDING, SHIPPED, DELIVERED, CANCELLED +} diff --git a/mcp-server/src/main/java/com/ankurm/mcpserver/tools/OrderTools.java b/mcp-server/src/main/java/com/ankurm/mcpserver/tools/OrderTools.java new file mode 100644 index 0000000..186b16b --- /dev/null +++ b/mcp-server/src/main/java/com/ankurm/mcpserver/tools/OrderTools.java @@ -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."; + } + +} diff --git a/mcp-server/src/main/resources/application.yml b/mcp-server/src/main/resources/application.yml new file mode 100644 index 0000000..a967cd0 --- /dev/null +++ b/mcp-server/src/main/resources/application.yml @@ -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 diff --git a/mcp-server/src/test/java/com/ankurm/mcpserver/OrderMcpServerTest.java b/mcp-server/src/test/java/com/ankurm/mcpserver/OrderMcpServerTest.java new file mode 100644 index 0000000..4b043fa --- /dev/null +++ b/mcp-server/src/test/java/com/ankurm/mcpserver/OrderMcpServerTest.java @@ -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"); + } + +} diff --git a/mcp-server/src/test/java/com/ankurm/mcpserver/OrderMcpTranscriptTest.java b/mcp-server/src/test/java/com/ankurm/mcpserver/OrderMcpTranscriptTest.java new file mode 100644 index 0000000..4cd447c --- /dev/null +++ b/mcp-server/src/test/java/com/ankurm/mcpserver/OrderMcpTranscriptTest.java @@ -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 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 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 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 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 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 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 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()); + } + +} diff --git a/mcp-server/src/test/java/com/ankurm/mcpserver/support/Transcript.java b/mcp-server/src/test/java/com/ankurm/mcpserver/support/Transcript.java new file mode 100644 index 0000000..3d8baf0 --- /dev/null +++ b/mcp-server/src/test/java/com/ankurm/mcpserver/support/Transcript.java @@ -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); + } +}