Skip to main content

Build an MCP Server with Spring AI 2.0: @McpTool, @McpResource, and Streamable HTTP

Expose an existing Spring service as an MCP server with @McpTool, @McpResource, and @McpPrompt, over Streamable HTTP — Spring AI 2.0’s default transport. Real JSON-RPC request/response traffic, a 404 gotcha in the protocol property, a duplicated-error-message quirk found by disassembling the jar, and what it actually takes to reach the server from MCP Inspector and Claude Desktop.

You already have a Spring service worth calling — an order-lookup repository, in this article, but it could just as easily be your inventory service or your ticketing backend. Now something wants to call it that isn’t a browser: Claude Desktop, an IDE agent, MCP Inspector, some other MCP-speaking client. Writing a bespoke JSON API and a client for it is one option. The other is MCP, the Model Context Protocol, and Spring AI 2.0 turns three annotations — @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 in spring-ai-mcp-annotations, package org.springframework.ai.mcp.annotation. The client used to drive this module’s tests is the official MCP Java SDK, io.modelcontextprotocol.sdk:mcp 2.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.
MCP client Inspector / Claude Desktop POST /mcp Streamable HTTP, JSON-RPC OrderTools @McpTool / @McpResource / @McpPrompt OrderRepository plain @Repository bean tools/call -> lookupOrder, resources/read -> orderCatalog, prompts/get -> summarizeOrderPrompt. All three methods delegate to the same repository bean a REST controller would inject.

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'; ...
@ConditionalOnProperty checks the raw Environment McpServerProperties field default: “streamable” never consulted here runs first, during autoconfiguration matching only applied later, when something actually binds this class
The fingerprint of this bug: a Spring AI MCP server that starts cleanly, logs Registered tools: N with 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 @McpToolParam on a @McpPrompt method (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

Call lookup_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 at http://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 to localhost directly with no bridge and no deployment. The mcp-remote config 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 — ChatClient registering 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.
  • PersistenceOrderRepository is 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 outputlookupOrder returns 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

ArticleCovers
Spring AI 2.0 in 10 Minutes: ChatClient on Spring Boot 4.1the beginner-level ChatClient build this article’s versions and setup follow
Spring AI 1.x to 2.0: The Migration Guidewhat breaks, and what breaks silently, upgrading an existing 1.x application
Production-Grade RAG with Spring AIchunking, ingestion, retrieval, reranking, and a faithfulness check against pgvector
Spring AI RAG in Java: Complete Code Tourthe same RAG project, file by file
Vector Embeddings and Semantic Search in Pure Javathe mechanics of embeddings and cosine similarity, without Spring AI — useful background before the RAG articles

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.