Building an MCP Server in Java with Spring AI: Let Claude Call Your APIs
Your ops teammate wants to look up an order in Claude. All they can do is paste the order ID into the chat box, wait for the model to reply "I can't see your database," and then manually paste in the logs. The company ha
Your ops teammate wants to look up an order in Claude. All they can do is paste the order ID into the chat box, wait for the model to reply "I can't see your database," and then manually paste in the logs. The company has dozens of internal systems, and every new AI client means rewriting the integration from scratch. This post walks through building an MCP Server with Spring AI that turns your Java endpoints into tools Claude and Cursor can call directly — no client changes required. The code is a complete project, and you don't even need an LLM API key to run it.
1. What the Problem Actually Is: The Glue Code Between AI Clients and Internal Systems
MCP isn't solving "the model isn't smart enough." It's solving "the model can't reach your systems."
Let's set the scene. You have an order service, an inventory service, and a logging platform. Now three different groups want to use AI: the product manager uses Claude Desktop to look up data, backend engineers use Cursor to write code and want to check production orders on the side, and your in-house support agent needs to look up orders too.
Three groups, three clients, three different integrations.
Claude Desktop wants its MCP config, Cursor wants .cursor/mcp.json, and your in-house agent wants a JSON Schema for function calling. So you write three sets of descriptions, three sets of parameter validation, and three sets of error handling. Next month a fourth client shows up, and you do it all over again.
MCP turns this from M×N into M+N. All M clients implement the same protocol, all N systems expose the same protocol, and nothing in between needs to be paired up.
Think of it like phone charging ports: round pins, flat pins, ribbon cables — every charger matched exactly one phone. Then USB-C arrived, and now the charger only has to deliver power and the phone only has to receive it, because the shape in the middle is fixed. MCP is USB-C for AI tool calls.
Let's nail down the roles in the protocol first, so the code later makes sense:
RoleWhat it isExampleHostThe AI application the user interacts withClaude Desktop, Cursor, your in-house support consoleClientA connector inside the Host; one Client connects to one ServerCreated by the Host at startup from its configServerThe program you write that exposes tools and dataThe order management Server in this post
A single Host can open multiple Clients and connect to multiple Servers. Configure three MCP Servers in Claude Desktop and it's running three Clients.
Next, the three kinds of things a Server can offer — this is where a lot of people get confused right away:
| Type | Who decides | Typical use case | Analogy |
|---|---|---|---|
| Tools | The model | Look up an order, file a ticket, calculate shipping | Gives the model a pair of hands |
| Resources | The client or the user | Config files, troubleshooting runbooks, log snippets | Gives the model a book it can page through on its own |
| Prompts | The user picks explicitly | Prebuilt prompt templates | Shortcuts for the user to trigger |
The difference comes down to control. With Tools, the model decides for itself that it should make a call. With Resources, the client stuffs content into the context. With Prompts, a human clicks something in the UI.
Two misconceptions come up over and over, so let's clear them up first.
Misconception #1: MCP is a model capability. It isn't. MCP is a transport protocol — it lives at the same layer as HTTP. The model has no idea MCP exists; all it sees is "here are some tools, and here's what their parameters look like." It's the host that translates MCP's tool list into the Function Calling format the model understands.
Misconception #2: write a solid MCP server and the model will use it correctly. Also no. What you name a tool, how you write its description, whether the parameter docs are clear — all of that directly determines whether the model picks the right one. Take a single lookup endpoint: describe it as "query order" and the model will happily reach for it when it means to look up a user. Describe it as "look up order details by order number; order numbers start with SO" and the hit rate changes immediately.
So this article isn't just about "how to expose your API" — it's also about "how to get the model to use it correctly."
2. Under the Hood: What Actually Happens During a Tool Call at the Protocol Level
MCP messages are just JSON-RPC 2.0 — there is no magic involved. Once you understand these three steps, any error you run into while debugging points you straight at where to look.
2.1 Three Steps: Handshake, List, Call
Step one is the handshake. When a client connects, it first sends initialize to tell the server which capabilities it supports and which protocol version it's speaking. The server replies with its own capability list.
{"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"cursor","version":"1.0"}}}
One item in the server's response matters more than the rest: the Mcp-Session-Id response header. Every request that follows has to carry it, and that's how the server recognizes "this is still the same client."
Step two is listing. The client sends tools/list and the server returns an array of tools, each with a name, a description, and an inputSchema (a standard JSON Schema).
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
What the model sees as "tools" is simply this array after the host has transformed it. Which means a sloppy description leaves the model guessing.
Step three is the call. The model decides to use a tool, and the host sends tools/call with the tool name and an argument object.
{"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"get_order","arguments":{"orderNo":"SO20261027001"}}}
Once the Server finishes executing, it returns a content array. The entries can be text, images, or resource references. The model reads that content and keeps reasoning.
There's one more step worth knowing about: notifications/initialized. After the handshake, the Client sends a notification saying it's ready. This one carries no id and never gets a response — in JSON-RPC, anything without an id is a notification, so don't sit there waiting for a reply.
2.2 Transport: stdio and Streamable HTTP
The same messages can travel over two different pipes.
stdioStreamable HTTPHow to connectHost spawns a child process and exchanges data over stdin/stdoutHTTP POST, with the server streaming responses back over SSEBest forLocal tools, single user, Claude DesktopShared team use, server deployment, multiple clientsAuthHandled by OS user permissions; the protocol doesn't careYou handle it yourselfDeploymentTied to the clientRuns as a standalone service you can scale and monitor
In stdio mode, one JSON per line goes into the child process's stdin, and results come back out of stdout. Dead simple, but there's one detail that will bite you: stdout must contain protocol data and nothing else. Not a single extra character. Drop a stray System.out.println into your code, or let a logging framework print one INFO line to the console, and the client's JSON parser dies instantly. I'll come back to this pitfall later.
Streamable HTTP is the mainstream choice today. The 2025-03-26 spec revision introduced it to replace the earlier two-endpoint HTTP+SSE design — that older approach kept one long-lived connection open for receiving messages and used a separate endpoint for sending them, which broke easily behind gateways and load balancers. The new design folds sending and receiving into a single /mcp endpoint; responses can be plain JSON or upgrade to an SSE stream, and clients can reconnect and pick up where they left off after a drop.
When the server returns SSE, this is what you see in curl:
event: message
data: {"jsonrpc":"2.0","id":2,"result":{"tools":[...]}}
The JSON isn't corrupted — that's just the streaming frame format. Client libraries unpack it for you.
2.3 What the framework does for you
Hand-rolling this protocol takes a few hundred lines: parsing JSON-RPC, maintaining sessions, reflecting Java methods into JSON Schema, serializing return values into content.
Spring AI handles all of it. You write an ordinary Spring Bean and slap @McpTool on a method, and it does four things:
- Scans the container for every method annotated with
@McpTool - Reads the method signature and
@McpToolParamto generate a JSON Schema - Registers the tool in the MCP Server's capability list
- On
tools/call, invokes it reflectively and serializes the return value into content
One detail deserves its own paragraph: parameter names are read from the bytecode, not from annotations. @McpToolParam only has two attributes, description and required — there's no name. So you must compile with -parameters, or reflection will hand you arg0, arg1 and friends, and the generated schema will use arg0 as the parameter names. Seeing arg0, the model can only guess based on position — get more than a couple of parameters and it will guess wrong.
Spring Boot's parent POM enables -parameters by default, so as long as you don't mess with the compiler config yourself, you're fine. But if you're migrating a legacy project, or your company has a shared compiler plugin configuration, check this one carefully.
2.4 Sync, async, stateful, stateless
Four terms, two pairs. Don't let them trip you up.
Sync and async describe how you write your tool methods. A sync method returns its result directly and Spring runs it on the MVC thread pool; an async method returns Mono or Flux, which suits cases where the downstream call is already reactive. This article sticks to sync throughout for simplicity.
Stateful (streamable/sse) and stateless describe the session. In stateful mode, the server remembers each client and supports server-initiated operations — sampling (asking the model on the client side to generate content for you), elicitation (asking the user to supply more information). Stateless means one request, one response, nothing retained server-side. You can scale replicas freely, but you lose all the two-way capabilities.
The default is streamable, i.e. stateful. That's enough for most use cases. If you genuinely need stateless, set one line — protocol: stateless — and accept that you can no longer use the two-way methods like McpSyncRequestContext. Call them anyway and they're silently ignored, no error raised. That's a nasty one.
Three: Building an order management MCP Server from scratch
Goal: a service on port 8080 exposing three order tools, one troubleshooting-runbook resource, and one refund-review prompt — configured in Cursor and ready to go.
No API key, no model required. An MCP Server just publishes capabilities; who calls them is the client's business.
3.1 Dependencies and Configuration
pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>mcp-order-server</artifactId>
<version>1.0.0</version>
<name>mcp-order-server</name>
<properties>
<java.version>21</java.version>
<spring-ai.version>2.0.2</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>2.0.2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
<version>2.0.2</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>4.1.2</version>
</plugin>
</plugins>
</build>
</project>
One starter is all you need — it pulls in Web MVC, the MCP protocol implementation, and the annotation scanner.
src/main/resources/application.yml:
server:
port: 8080
spring:
application:
name: mcp-order-server
ai:
mcp:
server:
name: order-admin
version: 1.0.0
protocol: streamable
type: sync
instructions: "订单与售后工具集。订单号格式为 SO 开头,查订单前先确认格式。写操作会真实修改数据,调用前必须先向用户确认。"
tool-response-mime-type:
get_order: application/json
streamable-http:
mcp-endpoint: /mcp
instructions ends up in the server description the client sees, which acts as a general prompt for the model. Spelling out "this is the order domain, confirm before any write" once beats repeating it in every tool description.
tool-response-mime-type sets the MIME type for returned content; some clients render based on it. It runs fine without it.
The main class:
package com.example.mcpserver;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class McpOrderServerApplication {
public static void main(String[] args) {
SpringApplication.run(McpOrderServerApplication.class, args);
}
}
3.2 The Tool Class: Three Methods Covering Read, Search, and Write
This is the heart of the article. Pay attention to the annotations on each method and why they're written the way they are.
package com.example.mcpserver.tools;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.ai.mcp.annotation.McpTool;
import org.springframework.ai.mcp.annotation.McpToolParam;
import org.springframework.stereotype.Component;
@Component
public class OrderTools {
public record Order(String orderNo, String customer, String status,
BigDecimal amount, LocalDateTime createdAt) {
}
public record OrderPage(List<Order> items, int page, int size, long total) {
}
private final Map<String, Order> orders = new ConcurrentHashMap<>();
public OrderTools() {
seed("SO20261027001", "张伟", "PAID", "399.00", 2);
seed("SO20261027002", "张伟", "SHIPPED", "1299.00", 5);
seed("SO20261027003", "李娜", "REFUNDING", "89.90", 1);
seed("SO20261027004", "王强", "PAID", "2480.00", 9);
seed("SO20261027005", "李娜", "CLOSED", "59.00", 20);
}
private void seed(String orderNo, String customer, String status, String amount, int daysAgo) {
orders.put(orderNo, new Order(orderNo, customer, status, new BigDecimal(amount),
LocalDateTime.now().minusDays(daysAgo)));
}
@McpTool(
name = "get_order",
description = "按订单号查询订单详情,返回状态、金额、下单时间。订单号格式为 SO 加日期加序号。这是只读操作。",
annotations = @McpTool.McpAnnotations(readOnlyHint = true, openWorldHint = false))
public Order getOrder(
@McpToolParam(description = "订单号,例如 SO20261027001", required = true) String orderNo) {
Order order = orders.get(orderNo.trim().toUpperCase(Locale.ROOT));
if (order == null) {
throw new IllegalArgumentException("订单不存在: " + orderNo);
}
return order;
}
@McpTool(
name = "search_orders",
description = "按客户姓名模糊查询订单,可以再按状态过滤。用于排查某个用户的订单情况,返回分页结果。这是只读操作。",
annotations = @McpTool.McpAnnotations(readOnlyHint = true, openWorldHint = false))
public OrderPage searchOrders(
@McpToolParam(description = "客户姓名,支持部分匹配", required = true) String customer,
@McpToolParam(description = "订单状态,可选值 PAID、SHIPPED、REFUNDING、CLOSED,不传表示全部", required = false) String status,
@McpToolParam(description = "页码,从 1 开始,默认 1", required = false) Integer page) {
int current = (page == null || page < 1) ? 1 : page;
int size = 10;
List<Order> matched = orders.values().stream()
.filter(order -> order.customer().contains(customer))
.filter(order -> status == null || status.isBlank()
|| order.status().equalsIgnoreCase(status))
.sorted(Comparator.comparing(Order::createdAt).reversed())
.toList();
int from = Math.min((current - 1) * size, matched.size());
int to = Math.min(from + size, matched.size());
return new OrderPage(matched.subList(from, to), current, size, matched.size());
}
@McpTool(
name = "create_refund_ticket",
description = "为指定订单创建退款工单。这是写操作,会真实修改订单状态为 REFUNDING,调用前必须先向用户确认订单号和原因。",
annotations = @McpTool.McpAnnotations(readOnlyHint = false, destructiveHint = true, idempotentHint = false))
public String createRefundTicket(
@McpToolParam(description = "订单号", required = true) String orderNo,
@McpToolParam(description = "退款原因,写清楚用户诉求", required = true) String reason) {
Order updated = orders.computeIfPresent(orderNo.trim().toUpperCase(Locale.ROOT), (no, old) -> {
if ("CLOSED".equals(old.status())) {
throw new IllegalStateException("订单已关闭,不能退款: " + no);
}
return new Order(old.orderNo(), old.customer(), "REFUNDING", old.amount(), old.createdAt());
});
if (updated == null) {
throw new IllegalArgumentException("订单不存在: " + orderNo);
}
return "已创建退款工单,订单 " + updated.orderNo() + " 状态变为 REFUNDING,原因:" + reason;
}
}
A few design decisions are worth spelling out.
Why return a record instead of a Map. When you return an object, the framework serializes it to JSON with Jackson. A record's field names become the schema's field names, so the model sees a stable structure. Map<String, Object> works too, but there the field names live in string literals you type by hand — change one and forget another, and the model starts guessing.
Why the lookup method throws IllegalArgumentException instead of returning null. When you throw RuntimeException, the framework converts it into a "tool execution failed" result for the model. The model sees the error message and can retry with a different order number. Return null and all the model sees is null — it can't tell "not found" from "the system is broken," so it often confidently makes up an order.
Why write operations need destructiveHint. This isn't decoration. Clients read this field to decide whether to show a confirmation dialog. Mark it readOnlyHint = false plus destructiveHint = true and Cursor and Claude Desktop will make the user click before calling the tool. Skip it, and the model may flip the order to refunded before you've even read the prompt.
Why pagination. See the pitfalls section — returning a huge list is the fastest way to blow up your token budget.
3.3 Resources and Prompts: Attaching the Troubleshooting Guide and Review Template
Beyond tools, add one resource and one prompt. A resource is content the client can read on its own; a prompt is a template the user can pick from the UI.
package com.example.mcpserver.resources;
import java.util.Map;
import org.springframework.ai.mcp.annotation.McpResource;
import org.springframework.stereotype.Component;
@Component
public class OpsResourceProvider {
private static final Map<String, String> RUNBOOKS = Map.of(
"order", """
# 订单服务排障手册
1. 先确认订单号格式是 SO 加 8 位日期加 3 位序号。
2. 用 get_order 查看当前状态,PAID 表示已付款未发货。
3. 状态卡在 REFUNDING 超过 24 小时,查退款任务表 refund_task。
4. 金额对不上,先看订单表的 amount 和支付流水表的 pay_amount 是否一致。
""",
"payment", """
# 支付服务排障手册
1. 支付回调丢失时,先查网关日志里的 notify 记录。
2. 重复扣款优先看幂等键 out_trade_no 是否重复。
3. 退款失败看渠道返回码,不要直接重试,先确认渠道侧状态。
""");
@McpResource(
uri = "ops://runbook/{service}",
name = "runbook",
title = "服务排障手册",
description = "按服务名读取线上排障步骤,例如 ops://runbook/order",
mimeType = "text/markdown")
public String runbook(String service) {
String content = RUNBOOKS.get(service);
if (content == null) {
throw new IllegalArgumentException("没有该服务的排障手册: " + service);
}
return content;
}
}
{service} inside uri is a template variable, filled in by the method parameters in order. When the client reads ops://runbook/order, the framework passes order into runbook(String service).
Note that the resource method's parameter has no @McpToolParam, and doesn't need one — the client isn't "filling in parameters," it's "requesting this URI."
For prompts, we'll add a refund review template plus argument autocompletion:
package com.example.mcpserver.prompts;
import java.util.List;
import io.modelcontextprotocol.spec.McpSchema.GetPromptResult;
import io.modelcontextprotocol.spec.McpSchema.PromptMessage;
import io.modelcontextprotocol.spec.McpSchema.Role;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import org.springframework.ai.mcp.annotation.McpArg;
import org.springframework.ai.mcp.annotation.McpComplete;
import org.springframework.ai.mcp.annotation.McpPrompt;
import org.springframework.stereotype.Component;
@Component
public class RefundPromptProvider {
private static final List<String> SAMPLE_ORDER_NOS = List.of(
"SO20261027001", "SO20261027002", "SO20261027003");
@McpPrompt(
name = "refund-review",
description = "生成一份退款审核清单,把订单事实和用户诉求一起交给模型判断")
public GetPromptResult refundReview(
@McpArg(name = "orderNo", description = "订单号", required = true) String orderNo,
@McpArg(name = "reason", description = "用户退款原因", required = false) String reason) {
String text = """
你是电商售后审核员。根据下面的信息判断是否同意退款,并给出处理建议。
订单号:%s
用户诉求:%s
要求:
1. 先调用 get_order 查订单真实状态,不要凭用户描述下结论。
2. 订单已发货的,说明需要先确认物流签收情况。
3. 订单已关闭的,直接说明不能退款并给出替代方案。
""".formatted(orderNo, reason == null ? "未提供" : reason);
PromptMessage message = new PromptMessage(Role.USER, TextContent.builder(text).build());
return GetPromptResult.builder(List.of(message))
.description("退款审核")
.build();
}
@McpComplete(prompt = "refund-review")
public List<String> completeOrderNo(String prefix) {
return SAMPLE_ORDER_NOS.stream()
.filter(orderNo -> orderNo.startsWith(prefix))
.toList();
}
}
@McpComplete handles input completion. As the user types SO2026 in the client, matching order numbers pop up in the suggestion box. The method takes a single String prefix and returns the matches. Users notice this feature right away and it costs almost nothing to implement — add it to every prompt that takes an ID.
3.4 Run It, Then Verify with curl First
mvn spring-boot:run
The startup log shows Tomcat listening on 8080. Don't rush to wire up a client; walk the protocol through with curl first — that way, when something breaks, you can tell whether it's the server or the client config.
# 1. 初始化,同时把会话 ID 抓出来
SESSION_ID=$(curl -s -D - -o /tmp/mcp-init.json \
-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"}}}' \
| grep -i '^mcp-session-id:' | tr -d '\r' | awk '{print $2}')
echo "session: $SESSION_ID"
cat /tmp/mcp-init.json
# 2. 发就绪通知,这条没有响应体
curl -s -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H "Mcp-Session-Id: $SESSION_ID" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
# 3. 列工具,应该看到三个名字
curl -s -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H "Mcp-Session-Id: $SESSION_ID" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
# 4. 真调一次
curl -s -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H "Mcp-Session-Id: $SESSION_ID" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_order","arguments":{"orderNo":"SO20261027001"}}}'
In the response to step 3, inputSchema.properties should list the names you wrote: orderNo, customer, status, page. If you see arg0 or arg1, stop right there — the build didn't include -parameters. Fix that before touching any client.
The response to step 4 looks roughly like this (SSE frame format — the JSON comes after data:):
event: message
data: {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"{\"orderNo\":\"SO20261027001\",\"customer\":\"张伟\",\"status\":\"PAID\",\"amount\":399.00,\"createdAt\":\"2026-10-25T10:12:33\"}"}]}}
Note that content is a string, not an object. MCP content blocks come in only a few flavors — text, image — so structured data has to be serialized into text and stuffed in. The client deserializes it on the other end.
3.5 Hooking It Up to Cursor
Create .cursor/mcp.json at the project root:
{
"mcpServers": {
"order-admin": {
"url": "http://localhost:8080/mcp"
}
}
}
Restart Cursor and open the MCP panel in settings. order-admin should be green; expand it and you'll see the three tools get_order, search_orders, create_refund_ticket, plus the runbook resource and the refund-review prompt.
Then type "what's going on with order SO20261027001?" in the chat. The model calls get_order first, then turns the result into plain language. When it calls a write tool, Cursor pops up a confirmation — that's destructiveHint doing its job.
Claude Desktop can also be pointed at an HTTP-based MCP server, but support for local HTTP varies widely across versions. The safe bet is the stdio version.
3.6 The stdio build: letting Claude Desktop launch it directly
The stdio version uses exactly the same code as above — you only swap the starter and the packaging.
Replace the dependency with:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server</artifactId>
<version>2.0.2</version>
</dependency>
This starter brings in only spring-boot-starter, with no web container. The process never binds a port; it communicates purely over stdin/stdout.
Add one line to your config:
spring:
ai:
mcp:
server:
name: order-admin
version: 1.0.0
stdio: true
type: sync
If you just want to try it locally, putting stdio: true in the config is enough — add mvn spring-boot:run and the terminal will simply sit waiting on stdin.
Then comes the fatal detail. Spring Boot writes logs to stdout by default, while the stdio protocol reserves stdout for JSON-RPC messages and nothing else. A single Started McpOrderServerApplication in 1.2 seconds printed at startup is enough to blow up the client's parser.
The fix is to push logging over to stderr. Add a src/main/resources/logback-spring.xml:
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="STDERR" class="ch.qos.logback.core.ConsoleAppender">
<target>System.err</target>
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDERR"/>
</root>
</configuration>
That <target>System.err</target> line is the critical one. Without it, logs bleed into the protocol stream.
After packaging, register it with Claude Desktop via claude_desktop_config.json (on macOS: ~/Library/Application Support/Claude/, on Windows: %APPDATA%\Claude\):
{
"mcpServers": {
"order-admin": {
"command": "/usr/bin/java",
"args": ["-jar", "/opt/mcp/mcp-order-server-1.0.0.jar"]
}
}
}
command must be an absolute path. Claude Desktop spawns child processes with a different PATH than your terminal, so a bare java will most likely not resolve.
Restart Claude Desktop and the three tools show up under the tool icon in the bottom-right of the chat box. The first call is slower because of JVM cold start.
4. Pitfalls and best practices
Every item in this section is something that genuinely breaks in production — not a theoretical risk.
4.1 Not a single extra character on stdout
In stdio mode, System.out.println("debug"), some third-party library's banner, or a logging framework's default output will all corrupt the protocol stream. What you see: the client reports "Unexpected token" or simply fails to connect, while the server-side logs look perfectly healthy.
To track it down, run java -jar xxx.jar by hand and inspect the terminal output. If the first line isn't JSON, the stream is contaminated.
Beyond switching logback over to stderr, also avoid System.out inside your tool methods. Use a logging framework and set the level properly.
4.2 Parameter names come out as arg0
As noted earlier, @McpToolParam has no name attribute, so parameter names depend on the -parameters compiler flag. The Spring Boot parent enables it by default, but it gets lost in these situations:
- A company-internal parent POM overrides the
maven-compiler-pluginconfiguration - You're on Gradle and forgot
options.compilerArgs << '-parameters' - You run straight from the IDE, whose compiler settings don't match Maven's
The way to confirm is to look at the schema returned by tools/list. If you see arg0, go back and check your compiler configuration.
4.3 RuntimeException and checked exceptions mean completely different things
This one trips people up the most, because both styles "compile and run" just fine.
- Throwing a
RuntimeException: the framework catches it and converts it into a tool-execution failure result. The model can see the error message and retry with different parameters. - Throwing a checked exception (
throws IOExceptiondeclared in the method signature): it propagates straight up. The tool call fails and the model sees nothing.
So the criteria are clear:
| Case | What to throw | Why |
|---|---|---|
| Invalid parameters, order not found | RuntimeException | The model understands it and can retry with different parameters |
| Downstream timeout, transient failure | RuntimeException | The model can retry later or switch tools |
| Insufficient permissions, internal implementation details | Checked exception | You don't want the model seeing stack traces or internal information |
| Errors that should never be retried | Checked exception | Fail the call outright instead of wasting tokens |
My own habit: anything at the business layer that's self-explanatory becomes a RuntimeException, and anything touching internal implementation gets wrapped in a checked exception and thrown upward.
4.4 Don't be greedy with return values
Whatever a tool returns goes straight into the model's context and eats into the token budget.
A search_orders that returns 500 orders at 200 characters each is 100k tokens. The context blows up immediately; the client either errors out or silently truncates, and what the user sees is "the model is making things up."
Three rules:
- Lists must be paginated, 10 to 20 items by default
- Return only the fields the model needs to make a decision — don't leak internal fields like
createdBy,updatedBy,version - Truncate long text fields, e.g. give only the first 100 characters of a product description
When you do need the full dataset, add a separate "export" tool that hands back a file path or a download link instead of cramming the content into the context window.
4.5 MCP Servers Have No Auth by Default
What spring-ai-starter-mcp-server-webmvc boots is a bare Spring MVC application with zero authentication. Anyone can POST to /mcp; anyone can call your create_refund_ticket.
Binding to 0.0.0.0 with no protection in front of it is the same as publishing your internal APIs to the public internet.
The bare minimum:
- During development, use
server.address: 127.0.0.1so the server only listens on localhost - In production, put it behind a gateway and gate it with an API key or OAuth2
- Add Spring Security to the app and require authentication on the
/mcppath - Mark every write operation with
destructiveHint = trueso the client asks for confirmation
Say it in the tool description too: "This is a write operation — always confirm with the user before calling it." Models do read descriptions, and most of the time they'll ask first.
4.6 Don't Cram Too Many Tools into One Server
Once you pass roughly 15 tools, the odds of the model picking the wrong one climb noticeably. Near-identical names are worse: with both get_order and query_order in play, the model basically flips a coin every time.
Split servers by business domain — one for orders, one for inventory, one for logs, each deployed independently. Configure several servers on the client and the model sees a grouped, smaller list of tools; shrink the candidate set and accuracy goes up.
4.7 Timeouts and Long-Running Tasks
The default request timeout is 20 seconds (spring.ai.mcp.server.request-timeout), which is nowhere near enough for batch jobs.
Two options: raise the timeout, or emit progress notifications from inside the tool via McpSyncRequestContext. The second is better — the client can render a progress bar, and the user won't assume it's hung. Progress notifications do require the client to send a progress token and the server to run in stateful mode; in stateless mode those calls are silently dropped.
5. Performance Comparison and Technology Choices
There are three ways to build an MCP server in the Java ecosystem, and picking the wrong one will cost you a lot of time.
| Option | Setup cost | Flexibility | Best for |
|---|---|---|---|
| Spring AI Starter | Low — annotations are the tools | Medium — special cases need workarounds | Teams with an existing Spring Boot project |
| Official MCP Java SDK, hand-rolled | Medium — you manage sessions and registration yourself | High — full control of the protocol layer | Non-Spring projects, fine-grained control |
| LangChain4j | Low (client side) | Weak server-side support | Projects already using LangChain4j for agents |
Spring AI's strength is that it wraps up annotation scanning, schema generation, and serialization for you. That's also its weakness: the moment you want to change the schema generation rules or control the content structure yourself, you have to drop down to the SDK layer. Most business scenarios never need to.
The official SDK fits two situations: your service isn't Spring-based (plain Netty or Vert.x, say), or you need to implement a custom transport.
On the LangChain4j side, MCP is mainly a client story — langchain4j-mcp pulls tools from a remote server into AiServices, which makes writing agents very convenient. There's no matching server starter, though, so if you want to expose Java APIs to other clients you're back to Spring AI or the official SDK.
When not to use MCP. If you have a single application, a single model, and a handful of internal functions, plain function calling is simpler — one less hop of inter-process communication, one less thing to deploy. MCP earns its keep through cross-client reuse; it starts paying off once three or more clients need the same set of capabilities.
6. Wrapping Up
MCP isn't mysterious. JSON-RPC messages, a three-step handshake, two transports. Spring AI handles the reflection and serialization; what's left for you to write is a Spring bean with a few annotations on it.
What actually decides success is three small things:
Write tool descriptions the way you'd write a handover doc for a new teammate. Don't write "query order" — a phrase only you understand. Keep parameter names and schemas clean; the moment something like arg0 shows up, the model starts guessing. Keep return payloads small — tokens cost money.
Don't cut corners on security. MCP servers are naked by default, and write operations have no confirmation step by default. Add authentication before you ship, and tag destructiveHint correctly.
One last practical note: get two read-only tools working, wire them into Cursor, and use them yourself for a week. You'll quickly find out which descriptions are terrible and which return values are too large — that firsthand feel beats reading ten docs.
By 会编程的吕洞宾
References
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.