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);
+ }
+}