An MCP server with Spring AI
- Spec
2025-11-25- Evidence
- E2Reproducedarm64
- Spring AI MCP server starter
org.springframework.ai:spring-ai-starter-mcp-server-webmvc@2.0.2- Java SDK
io.modelcontextprotocol.sdk:mcp@2.1.0- MCP Inspector
@modelcontextprotocol/inspector@2.12.0- Verified
Spring AI turns any Spring bean method into an MCP tool with one annotation. This guide builds the same add tool as the other server guides, serves it over HTTP, then over stdio, and shows two settings that make the stdio version fail without a clear error.
Before you start
Section titled “Before you start”- JDK 17 or later and Maven. Spring AI 2.0.2 builds on Spring Boot 4.1.2. We built for Java 17 and ran on JDK 25 with Maven 3.9.9.
- Node.js 22.19 or later, for the MCP Inspector used to test.
Build it
Section titled “Build it”<?xml version="1.0" encoding="UTF-8"?><!-- MCP server with the Spring AI starter. HTTP (default): mvn -q -B package -DskipTests java -jar target/spring-ai-server.jar stdio: mvn -q -B package -DskipTests -Pstdio java -jar target/spring-ai-server-stdio.jar The two transports need different starters, so each build picks one. --><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>dev.mcpdev</groupId> <artifactId>spring-ai-server</artifactId> <version>1.0.0</version>
<properties> <java.version>17</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>${spring-ai.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>
<profiles> <profile> <!-- Streamable HTTP on Spring MVC (Tomcat). --> <id>http</id> <activation><activeByDefault>true</activeByDefault></activation> <properties><jar.name>spring-ai-server</jar.name></properties> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId> </dependency> </dependencies> </profile> <profile> <!-- stdio: no web server; uses src/main/resources/application-stdio.yml. --> <id>stdio</id> <properties><jar.name>spring-ai-server-stdio</jar.name></properties> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-server</artifactId> </dependency> </dependencies> </profile> </profiles>
<build> <finalName>${jar.name}</finalName> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build></project>The Spring AI BOM manages all Spring AI versions. HTTP and stdio need different starters: spring-ai-starter-mcp-server-webmvc for HTTP and spring-ai-starter-mcp-server for stdio. The two Maven profiles pick one per build.
package dev.mcpdev.springai;
import org.springframework.ai.mcp.annotation.McpTool;import org.springframework.ai.mcp.annotation.McpToolParam;import org.springframework.stereotype.Component;
// Any Spring bean with @McpTool methods is picked up by the annotation scanner.@Componentpublic class AddTools {
/** The result type becomes the tool's output schema and structuredContent. */ public record Sum(double sum) {}
// Without annotations, Spring AI 2.0.2 advertises destructiveHint=true and openWorldHint=true, // which can make clients ask for approval on every call. This tool only does arithmetic. @McpTool(name = "add", description = "Add two numbers and return the sum.", generateOutputSchema = true, annotations = @McpTool.McpAnnotations(title = "Add", readOnlyHint = true, destructiveHint = false, idempotentHint = true, openWorldHint = false)) public Sum add( @McpToolParam(description = "First number", required = true) double a, @McpToolParam(description = "Second number", required = true) double b) { return new Sum(a + b); }}@McpToolon a method of any Spring bean registers a tool. The annotation scanner is on by default. The annotations live inorg.springframework.ai.mcp.annotation.@McpToolParamdescribes each argument; the starter turns the method signature into the tool’s input schema.generateOutputSchema = truewith a record return type gives the tool an output schema, and the result arrives asstructuredContent({"sum": 5}).annotationssets the hints clients read before calling a tool. Without them, Spring AI 2.0.2 advertiseddestructiveHint: trueandopenWorldHint: truefor ouraddtool, and a client that respects hints may ask for approval on every call.
server: address: 127.0.0.1 port: 8332
spring: main: banner-mode: off ai: mcp: server: name: hello-spring version: 1.0.0 # STATELESS: no Mcp-Session-Id; any instance can answer any request. protocol: STATELESSRun it over HTTP
Section titled “Run it over HTTP”mvn -q -B package -DskipTestsjava -jar target/spring-ai-server.jarExpectedStarted Application in about 2–3 seconds, listening on 127.0.0.1:8332.
npx -y @modelcontextprotocol/inspector@2.12.0 --cli \ http://127.0.0.1:8332/mcp --protocol-era auto \ --method tools/call --tool-name add --tool-arg a=2 b=3 --format jsonExpectedstructuredContent is {"sum": 5}.
The endpoint is /mcp. To change it, set spring.ai.mcp.server.streamable-http.mcp-endpoint. That is the property name in the starter’s configuration metadata; one table in the Spring AI docs shows a different prefix.
STATELESS or STREAMABLE
Section titled “STATELESS or STREAMABLE”spring.ai.mcp.server.protocol picks the HTTP mode. What we saw with each:
STATELESS |
STREAMABLE (the default) |
|
|---|---|---|
Mcp-Session-Id |
Not used | Returned by initialize; required afterwards |
GET /mcp |
405 |
400 without a session |
| Server-to-client requests (sampling, elicitation, ping) | Not supported, per the Spring AI docs | Supported |
| Behind a load balancer | Any instance can answer | Needs sticky sessions |
Choose STATELESS unless your tools need to ask the client something mid-call. It is also closer to the direction of the 2026-07-28 spec, which has no protocol sessions.
Protocol versions
Section titled “Protocol versions”We connected with the TypeScript client SDK in its three negotiation modes:
| Client setting | Result |
|---|---|
| Default (2025 handshake) | Connected, 2025-11-25 |
auto (try 2026-07-28, fall back) |
Connected, 2025-11-25 |
| Pinned to 2026-07-28 | Version negotiation failed: the server did not offer pinned protocol version 2026-07-28 via server/discover (no fallback in pin mode) |
A raw server/discover request to the STATELESS server returned Method not found: server/discover (-32601). The Inspector with --protocol-era auto falls back on its own; --protocol-era modern fails the same way the pinned client does.
Run it over stdio
Section titled “Run it over stdio”Desktop clients such as Claude Desktop or Codex start a local server as a child process. Build the stdio variant:
mvn -q -B package -DskipTests -Pstdio# Activated with --spring.profiles.active=stdio. stdout carries JSON-RPC, so nothing else may print there.spring: main: web-application-type: none ai: mcp: server: stdio: true # The stdio transport is only configured for non-stateless servers, so override STATELESS here. protocol: STREAMABLE# Spring Boot logs to stdout by default. Turn the console off; log to a file if you need logs.logging: threshold: console: "OFF"The client’s command is then:
java -jar /absolute/path/to/target/spring-ai-server-stdio.jar --spring.profiles.active=stdioTwo settings in that file matter:
protocol: STREAMABLE. The stdio transport is only configured for non-stateless servers. WithSTATELESSinherited fromapplication.yml, the application started, printed nothing on stdout and exited. Our client reportedConnection closed. The same happens with the HTTP starter andstdio: true: there is no stdio transport in it.logging.threshold.console: "OFF". Spring Boot writes its log to stdout, the same stream that carries JSON-RPC. With the console log on, about a dozen log lines came before the first response. Our TypeScript client skipped them, but nothing guarantees that every client does. Keep stdout for protocol messages only; log to a file if you need logs.
Before you expose it
Section titled “Before you expose it”Per the Spring AI docs, the HTTP transports accept unauthenticated requests: anyone who can reach the port can list and call your tools. In our test the server also accepted a request with any Host header, so it has no DNS rebinding protection of its own. The example binds to 127.0.0.1 for that reason. To share it, put it behind a proxy and add authentication. See Deploy behind a reverse proxy and OAuth for MCP servers.
Related
Section titled “Related”Sources
MCPDev is community documentation. It is not affiliated with the Model Context Protocol project or with the vendors of the clients it covers.