@McpTool, @McpResource, @McpPrompt — into a running MCP server around beans you likely already have.
This article builds the smallest version of that: one existing-looking Spring service, three annotations, and Streamable HTTP — Spring AI 2.0’s default MCP server transport — carrying real JSON-RPC traffic between a real client and this application. Every code block links to a file in a companion repository that compiles and runs; every request and response you see below is quoted from a captured run, not typed in by hand.
Versions. Spring Boot 4.1.1 and Spring AI 2.0.1 (GA 12 June 2026, this maintenance release 21 August 2026), on Java 25 (LTS) — the same baseline as the ChatClient article this one follows. The MCP annotations live inspring-ai-mcp-annotations, packageorg.springframework.ai.mcp.annotation. The client used to drive this module’s tests is the official MCP Java SDK,io.modelcontextprotocol.sdk:mcp2.0.0 — the same library MCP Inspector and Claude Desktop are themselves built on.
What an MCP server actually hands a client
An MCP server doesn’t expose “an API” in the REST sense — it exposes three specific kinds of thing, each with its own JSON-RPC method family. A tool is a method a client can call with arguments and get a typed result back, the closest thing to a REST endpoint. A resource is readable content addressed by a URI, more like a GET than an RPC — a client reads it to load context, not to trigger an action. A prompt is a canned, parameterized instruction the client can fetch and hand to its own model; your server never calls a model itself, it just supplies the text. Spring AI 2.0 maps all three onto ordinary Spring beans with@McpTool, @McpResource, and @McpPrompt, and none of the three methods in this article’s one @Service class import anything MCP-specific beyond the annotation itself.
The smallest MCP server that compiles
One dependency turns a Spring Boot 4.1 application into an MCP server over servlet-stack Spring MVC:<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
Full file, with the BOM import: mcp-server/pom.xml. Then one existing-looking service class, annotated:
@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));
}
}
Source: OrderTools.java (the resource and prompt methods are further down this page). OrderRepository is a plain @Repository with an in-memory Map standing in for whatever really backs order lookups at your shop — OrderRepository.java. Nothing about lookupOrder’s body is MCP-aware; the annotation is the entire integration.
That’s enough for a real handshake. Against a running instance (./scripts/run.sh):
$ 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"}}}'
{"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"}}}
Output: 01-initialize-and-list-tools.txt. That response came back as one plain JSON object, no session header needed yet — the next section is about what changes for every request after this one.
Going deeper: the starter’s own dependency is one Boot-4 naming cycle behind
spring-ai-starter-mcp-server-webmvc‘s own POM still pulls in spring-boot-starter-web, not the newer spring-boot-starter-webmvc split that Boot 4 introduced (checked directly in the resolved POM, not assumed from the artifact’s name). Both resolve to the same servlet-stack Spring MVC underneath, so this compiles and runs either way — it’s simply evidence the starter hasn’t been updated for Boot 4’s naming split yet, the same kind of lag the ChatClient article found in older 1.x tutorials, just one step further upstream this time.
The one property that decides whether /mcp exists at all
The module above only works because its application.yml sets one property explicitly:
spring:
ai:
mcp:
server:
name: order-lookup-server
version: 1.0.0
protocol: streamable
Full file: application.yml. Delete that last line and the module still compiles, still starts, still logs Registered tools: 1 — and every request to /mcp returns a plain 404, because the endpoint was never registered:
$ 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", ...}'
{"timestamp":"2026-09-23T11:44:01.196Z","status":404,"error":"Not Found","path":"/mcp"}
Output, and the fix confirmed by the same run: 06-protocol-property-unset-404.txt. The confusing part is that the jar’s own spring-configuration-metadata.json lists streamable as this property’s default, which reads like “you can leave it out.” Boot’s own --debug condition-evaluation report says why that default never fires:
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'; ...
The fingerprint of this bug: a Spring AI MCP server that starts cleanly, logsRegistered tools: Nwith a sensible-looking count, and then 404s on every single request to/mcp. There is no startup error to grep for — the condition simply never matched, so nothing ever mapped the path. If your bean registration logs look right and the endpoint still 404s, check for this property before anything else.
What tools/list and tools/call actually look like on the wire
The initialize response above is a fixed point: read it once, and every request after it needs one more thing — a session. The response carries an Mcp-Session-Id header, and every subsequent request on this connection has to send it back:
$ curl -X POST http://localhost:8080/mcp -H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" -H "Mcp-Session-Id: <id from above>" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
id:59682b84-e49c-42d2-908e-a8105aa31514
event:message
data:{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"lookup_order", ...
Output: same file, 01-initialize-and-list-tools.txt. Notice the shape changed: initialize came back as one plain JSON object, but tools/list arrives SSE-framed — an id:/event:/data: block, text/event-stream, chunked. That is what “Streamable HTTP” means in practice: the same POST /mcp endpoint answers some requests as plain JSON and others as a one-shot SSE stream, depending on what the server needs to send back, and a client has to handle both on the same connection. Calling the tool itself is the same shape as tools/list, just with a different method and params:
$ curl ... -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"lookup_order","arguments":{"orderId":"ORD-1001"}}}'
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}}
Output: 02-call-tool-lookup-order.txt. The Order record came back serialized as a JSON string inside a text content block, not as structured JSON of its own — every tool result in MCP is a list of content blocks (text, image, or embedded resource), and a plain object return type gets JSON-stringified into one text block by Spring AI on your behalf.
Going deeper: the notification between initialize and your first real call, and a protocol-version detail
Between initialize and tools/list there’s one more required message, a notification rather than a request — notifications/initialized, sent with the session header and no id field, telling the server the client accepted its capabilities. It gets a bare 202 with an empty body, which is easy to mistake for a bug if you’re expecting a JSON-RPC response and don’t get one — there isn’t one, by design, since a notification has no id to correlate a reply against.
The protocolVersion in the examples above is 2025-06-18, echoed straight back by the server because that’s what curl asked for. The official MCP Java SDK client doesn’t send that version by default, though — left unconfigured, it negotiates 2025-11-25, a newer protocol revision, and this server accepts that too and echoes it back instead. Worth knowing if you copy a protocolVersion literal out of an older tutorial: the value isn’t magic, it’s whatever the two sides agree on, and the SDK client’s own default has already moved past what most hand-written curl examples (including the ones above, kept at 2025-06-18 for readability) still use.
Resources and prompts: the two annotations tool-only tutorials skip
A tool answers “do this and tell me what happened.” A resource answers “what exists that I could look at?” — useful when a client doesn’t already have an order ID and needs to discover one:@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"));
}
Source: same OrderTools.java. Reading it is resources/read against the URI the annotation declared, not a method name:
$ curl ... -d '{"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"orders://catalog"}}'
data:{"jsonrpc":"2.0","id":4,"result":{"contents":[{"uri":"orders://catalog","mimeType":"text/plain","text":"ORD-1001\nORD-1002\nORD-1003\nORD-1004"}]}}
Output: 03-read-resource-catalog.txt. A prompt is different again — it never touches the repository directly, it just returns text a client should send to its own model:
@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.";
}
$ curl ... -d '{"jsonrpc":"2.0","id":5,"method":"prompts/get","params":{"name":"summarize_order","arguments":{"orderId":"ORD-1002"}}}'
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."}}]}}
Output: 04-get-prompt-summarize-order.txt. This prompt deliberately tells the model to call lookup_order itself rather than embedding the order data — a prompt supplies instructions, a tool supplies facts, and mixing the two roles is how a “ready-made prompt” quietly turns into stale, baked-in data.
The parameter annotation is not the same one twice. A tool method’s arguments take@McpToolParam; a prompt method’s arguments take@McpArg— two different annotation types for the same job, one per callback kind. Reach for@McpToolParamon a@McpPromptmethod (an easy autocomplete mistake, since both live in the same package) and it simply won’t compile, which is the friendly version of this trap.
When a tool throws: still a 200, and a duplicated message worth knowing about
Calllookup_order with an ID that doesn’t exist and the HTTP status stays exactly the same:
$ curl ... -d '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"lookup_order","arguments":{"orderId":"ORD-9999"}}}'
Response (200), note isError:true inside a 200:
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}}
Output: 05-call-tool-lookup-order-not-found.txt. HTTP 200 is correct here, not a bug — the JSON-RPC request itself succeeded; it’s the tool that failed, and MCP represents that as isError: true inside an otherwise-normal result, never as an HTTP error code. A client has to check that field on every tool call; treating a non-200 as the only failure signal will silently swallow every tool-level error this server ever produces.
Look closer at that message, though: "No order with ID ORD-9999\nNo order with ID ORD-9999" — the same sentence, twice, joined by a newline, and that is a real Spring AI 2.0.1 quirk, not a typo in this repository. Disassembling AbstractSyncMcpToolMethodCallback.createSyncErrorResult with javap -p -c shows why: the error text is built as exception.getMessage() + System.lineSeparator() + findCauseUsingPlainJava(exception).getMessage(). That second half is meant to add the root cause’s message when a wrapped exception hides the real reason — but for a plain IllegalArgumentException with no cause, its own “root cause” is itself, so the same message gets appended a second time.
Going deeper: why this matters more than a cosmetic double line
An MCP client typically hands a tool’s error text straight to the model that called the tool, as-is, so the model can decide what to do next. A duplicated sentence is harmless there — a model reads past it without comment. It stops being harmless the moment your own code parses or displays that string directly: a UI that shows the raw error text to a human support agent, or a test asserting on an exact error message, will see the duplication and either look broken or fail an equality check that a single-line message would have passed. The practical fix, if you’re writing tool methods and want single-line error text regardless of this behaviour, is to throw an exception whose getMessage() is already what you want displayed and accept the repeated line, or catch it at the call boundary and rebuild a clean message before it reaches createSyncErrorResult. Nothing in the public Javadoc documents this; it was only visible by decompiling the class.
Testing it: MCP Inspector works immediately, Claude Desktop needs one extra step
MCP Inspector speaks Streamable HTTP natively — point its URL field athttp://localhost:8080/mcp after ./scripts/run.sh and it drives the same handshake shown above, with a UI over it.
Claude Desktop is less direct, and this is worth knowing before you spend time on it: its local claude_desktop_config.json launches MCP servers as stdio subprocesses, and does not speak Streamable HTTP to a local URL directly. The common bridge is the mcp-remote npm package, run as the subprocess and pointed at your local server:
{
"mcpServers": {
"order-lookup": {
"command": "npx",
"args": ["mcp-remote", "http://127.0.0.1:8080/mcp"]
}
}
}
The other route is Claude’s custom connectors feature (Customize > Connectors > Add custom connector), but that one explicitly does not reach localhost: per Anthropic’s own documentation, a custom connector’s connection originates from Anthropic’s servers, not your machine, so the server has to be reachable over the public internet — a tunnel (ngrok or similar) or a real deployment, not ./scripts/run.sh on your laptop.
Which one to reach for: Inspector for development, because it talks tolocalhostdirectly with no bridge and no deployment. Themcp-remoteconfig above for trying a local server from inside Claude Desktop itself. A public custom connector only once the server is actually deployed somewhere Anthropic’s servers can reach — treating it as a shortcut to test a server still running on your own machine doesn’t work, by design.
What this order-lookup server leaves out
- Authentication and authorization — this server answers every request from anyone who can reach it; nothing here scopes a caller to particular orders or requires a token at all. A future post in this series covers securing an MCP server with Spring Security 7 and OAuth2, scope-per-tool.
- Calling someone else’s MCP server — this article is entirely the server side. The reverse direction —
ChatClientregistering an external MCP server’s tools and calling them — is next in this series. - Resource templates and subscriptions — this server’s one resource is a fixed URI; MCP also supports parameterized resource templates and change subscriptions, neither exercised here.
- Persistence —
OrderRepositoryis an in-memory map so the module needs no database to run; the MCP-facing code doesn’t change if it’s replaced with a real one. - Structured tool output —
lookupOrderreturns a record that gets JSON-stringified into a text block; MCP also has a dedicated structured-content path this module doesn’t use.
Should you expose this service over MCP at all? If the only consumer will ever be your own frontend, a REST controller is fewer moving parts and no new protocol to reason about. MCP earns its place when the caller is an AI agent you don’t control the code of — Claude Desktop, a coding assistant, another team’s agent — where a JSON-RPC contract with self-describing tool schemas lets that agent discover what’s callable instead of you writing bespoke integration code per client. An internal service with no agent consumer in sight doesn’t need this yet, and bolting on MCP before an actual agent exists to call it is effort spent on a client that may never show up.
Every Spring AI article on this site
| Article | Covers |
|---|---|
| Spring AI 2.0 in 10 Minutes: ChatClient on Spring Boot 4.1 | the beginner-level ChatClient build this article’s versions and setup follow |
| Spring AI 1.x to 2.0: The Migration Guide | what breaks, and what breaks silently, upgrading an existing 1.x application |
| Production-Grade RAG with Spring AI | chunking, ingestion, retrieval, reranking, and a faithfulness check against pgvector |
| Spring AI RAG in Java: Complete Code Tour | the same RAG project, file by file |
| Vector Embeddings and Semantic Search in Pure Java | the mechanics of embeddings and cosine similarity, without Spring AI — useful background before the RAG articles |
Further reading
- Companion repository for this article: asmhatre/spring-ai, mcp-server module
- Official reference: Spring AI – MCP Server Annotations
- Official reference: Model Context Protocol Specification
- Official docs: Get started with custom connectors using remote MCP
- Inspector: modelcontextprotocol/inspector
No Comments yet!