Skip to content

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.

  • 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.
Editorpom.xml
pom.xml
<?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.

EditorThe tool
src/main/java/dev/mcpdev/springai/AddTools.java
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.
@Component
public 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);
}
}
  • @McpTool on a method of any Spring bean registers a tool. The annotation scanner is on by default. The annotations live in org.springframework.ai.mcp.annotation.
  • @McpToolParam describes each argument; the starter turns the method signature into the tool’s input schema.
  • generateOutputSchema = true with a record return type gives the tool an output schema, and the result arrives as structuredContent ({"sum": 5}).
  • annotations sets the hints clients read before calling a tool. Without them, Spring AI 2.0.2 advertised destructiveHint: true and openWorldHint: true for our add tool, and a client that respects hints may ask for approval on every call.
Editorapplication.yml
src/main/resources/application.yml
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: STATELESS
TerminalBuild and start
Terminal window
mvn -q -B package -DskipTests
java -jar target/spring-ai-server.jar

ExpectedStarted Application in about 2–3 seconds, listening on 127.0.0.1:8332.

TerminalCall the tool with the Inspector
Terminal window
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 json

ExpectedstructuredContent 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.

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.

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.

Desktop clients such as Claude Desktop or Codex start a local server as a child process. Build the stdio variant:

TerminalBuild the stdio jar
Terminal window
mvn -q -B package -DskipTests -Pstdio
Editorapplication-stdio.yml
src/main/resources/application-stdio.yml
# 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:

Terminal window
java -jar /absolute/path/to/target/spring-ai-server-stdio.jar --spring.profiles.active=stdio

Two settings in that file matter:

  • protocol: STREAMABLE. The stdio transport is only configured for non-stateless servers. With STATELESS inherited from application.yml, the application started, printed nothing on stdout and exited. Our client reported Connection closed. The same happens with the HTTP starter and stdio: 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.

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.

Sources

MCPDev is community documentation. It is not affiliated with the Model Context Protocol project or with the vendors of the clients it covers.