Building an MCP Tool with Spring Boot AI
I wanted Claude to be able to poke at a Kafka cluster directly — list topics, peek at messages, describe consumer groups — without me hand-rolling a REST client and a chat-tool bridge for each operation. That's exactly the gap the Model Context Protocol (MCP) fills, and Spring AI ships a server starter that makes the Spring half almost boring. Here's how the pieces actually fit together, including the one part that isn't boring.
The shape of an MCP server in Spring
An MCP server exposes tools: named, typed operations a client (Claude Code, in my case) can discover and invoke. In Spring AI, a "tool" is just a method. You annotate it, register the bean, and the framework handles JSON-schema generation, discovery, and the wire protocol.
My stack was deliberately bleeding-edge: Java 25, Spring Boot 4.0.2, Spring AI 2.0.0-M2. The only dependency that matters for the MCP half is the transport-specific starter:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>There are three flavours. spring-ai-starter-mcp-server speaks STDIO — the client spawns your jar as a subprocess and talks over stdin/stdout. The -webmvc and -webflux variants speak SSE, running your server as a normal web app the client connects to over HTTP. I picked WebMVC + SSE because I wanted a long-lived server I could share, not a per-session subprocess.
A tool is just an annotated method
The whole tool layer is a plain @Component with methods that return JSON strings. Each method gets a @Tool description and each parameter a @ToolParam — that text is what the model reads to decide when and how to call you, so it's worth writing like documentation rather than an afterthought.
@Component
@RequiredArgsConstructor
@Slf4j
public class KafkaToolProvider {
private final KafkaService kafkaService;
private final ObjectMapper objectMapper;
@Tool(description = "List all topics in the Kafka cluster")
public String listTopics() {
try {
List<String> topics = kafkaService.listTopics();
return toJson(Map.of("topics", topics, "count", topics.size()));
} catch (Exception e) {
log.error("listTopics failed", e);
return errorResponse(e.getMessage());
}
}
@Tool(description = "Describe a topic: partitions, replicas, ISR and configs")
public String describeTopic(
@ToolParam(description = "The topic name") String topicName) {
// ... delegates to kafkaService.describeTopic(topicName)
}
}I kept a hard line between the tool layer and the work. KafkaToolProvider only does two things: catch exceptions and serialise. The actual Kafka calls — AdminClient, KafkaTemplate, ephemeral consumers — live in a separate KafkaService. That separation means I could lift the same service behind a REST controller or a scheduled job later without touching the tool definitions.
@Tool vs @McpTool — the one real decision
Spring AI gives you two annotations and they are not interchangeable. @McpTool auto-registers — drop it on a method and you're done — but it's MCP-only. @Tool needs an explicit registration bean, but the same methods can also be handed to a Spring AI ChatClient as function-calling tools.
I went with @Tool precisely for that optionality, which means one extra bean in the application class:
@Bean
public ToolCallbackProvider kafkaTools(KafkaToolProvider provider) {
return MethodToolCallbackProvider.builder()
.toolObjects(provider)
.build();
}If you're only ever going to serve MCP and never wire these into a chat model, @McpTool is less ceremony. If there's any chance the same capabilities get reused inside an agent loop, pay the one-bean tax now.
Configuration: endpoints, not annotations
The SSE transport surfaces two HTTP endpoints — a GET stream and a POST channel — both configured in application.properties rather than code:
spring.ai.mcp.server.transport=sse
spring.ai.mcp.server.name=kafka-mcp-server
spring.ai.mcp.server.version=1.0.0
spring.ai.mcp.server.sse-endpoint=/sse
spring.ai.mcp.server.sse-message-endpoint=/mcp/message
server.port=9085Registering it with Claude Code is then a one-liner:
claude mcp add kafka-mcp-server --transport sse \
http://localhost:9085/sse --scope projectThe part that isn't boring: a 202, not a 200
Here's where the pre-release stack bit me. The MCP SSE contract is that POST /mcp/message should answer 202 Accepted — "got it, the actual response will come down the SSE stream" — but Spring AI 2.0.0-M2's server returns a plain 200 OK. Claude's client treats that mismatch as a protocol error and the tool calls never complete.
The fix is a thin servlet filter that rewrites the status code on exactly that one path:
@Component
public class McpMessageStatusFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
if ("POST".equals(request.getMethod())
&& request.getRequestURI().endsWith("/mcp/message")) {
HttpServletResponseWrapper wrapper = new HttpServletResponseWrapper(response) {
@Override
public void setStatus(int sc) {
super.setStatus(sc == HttpServletResponse.SC_OK
? HttpServletResponse.SC_ACCEPTED : sc);
}
};
chain.doFilter(req, wrapper);
} else {
chain.doFilter(req, res);
}
}
}It's a hack, and I treat it as one — a compatibility shim that should evaporate the moment the milestone catches up to the spec. But it's the honest reality of building on -M2 dependencies: the framework is 95% there, and you write the last 5% yourself. Worth knowing before you assume a clean GA-style experience.
What I'd tell someone starting fresh
Lean on the structure: a thin @Tool provider over a real service layer, descriptions written for the model to read, configuration in properties, and a healthy suspicion of any pre-release transport. The MCP plumbing genuinely is a few annotations and a bean — the value is all in the operations you choose to expose and how clearly you describe them. For Kafka that turned out to be ten tools covering topics, messaging, consumer groups and cluster introspection, and from the client side it just feels like Claude suddenly understands my broker.
The full source — service layer, all the DTOs, and that filter in context — is on GitHub: zakariahere/kafka-mcp-server-sbai.