Spring Boot MCP Servers
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against the 2026-07-28 revision of
the specification, Spring AI 2.0.1, Spring Boot 4.1.1 and the MCP Java SDK 2.0.x (Spring AI 2.0.1 resolves it to 2.0.0;
2.0.1 is the newest release), building with Java 21, as checked on 2026-09-29 (see the
Model Context Protocol baseline). Spring AI 2.1.0-M1 and Spring Boot 4.2.0-M2 exist as
milestones and are not used. Every listing was compiled and run: the Boot build passed the shared Inspector contract script
in both the STREAMABLE and the STATELESS protocol modes, and the annotation, @Tool-bean, ASYNC, stdio, plain-SDK
and Spring Security variants were each run separately against the same sources. The protocol depth lives here; how the
same starters fit into a whole Spring AI application (exposing its existing @Tool beans, securing the endpoint with
the community mcp-server-security module, testing it with JUnit) is on Spring AI: MCP
Server, and the client side on Spring AI: MCP Client.
This is the Java build of the docs-assistant server introduced on Python MCP Servers
and repeated in TypeScript on Node.js MCP Servers. It is a Spring Boot application like
any other, so the conventions of REST APIs with Spring MVC and WebFlux, and the security setup
of Spring Security, apply to it directly.
The protocol-version caveat
Read this first, because it changes what the Java build can do. The MCP Java SDK, and therefore Spring AI, speak the
2025-11-25 revision of the protocol, not 2026-07-28. That was verified against the SDK’s own source (its
ProtocolVersions constants stop at 2025-11-25), against the release that Spring AI 2.0.1 pulls in, and at runtime:
-
A client that sends an
initializerequest with protocol version 2025-11-25, or a 2026-07-28 client that falls back to it, gets a normal answer that negotiatesprotocolVersion2025-11-25. Hosts such as Claude Code, VS Code and the Inspector’slegacyera therefore work against a Spring AI server today. -
A 2026-07-28 client that starts with
server/discovergets an error instead:-32601from theSTREAMABLEmode, whose message complains about a missing session id, and-32603fromSTATELESS. The Inspector CLI with--protocol-era modernfails with "the server did not offer pinned protocol version 2026-07-28 via server/discover". Use--protocol-era legacywhen you test a Java server. -
Anything that belongs to the newer revision is therefore absent: no
input_requiredmulti-round-trip elicitation, nottlMs/cacheScopehints, noMcp-MethodandMcp-Namerequest headers, nosubscriptions/listen. Elicitation on the Java side is the earlier server-initiated request made through the server exchange. Becausedocs-assistant’s multi-round-trip step needs the newer protocol, the Java build makes `componenta required argument, and the contract script skips that one step for it. -
The
STREAMABLEmode keeps a session id, so a load balancer needs affinity or a single instance; theSTATELESSmode holds no session and scales behind a balancer freely, at the price of features that need a channel back to the client (see Transports). -
Spring AI’s own reference pages still describe the streamable transport as introduced with spec version 2025-03-26 and do not mention 2026-07-28. The Java SDK is not one of the SDKs the protocol’s release announcement lists as updated for the new revision.
This is a snapshot: newer Spring AI and SDK releases may add the revision, and a Java server built today keeps working with today’s clients because clients still speak the 2025-11-25 handshake. Check the release notes before relying on the absence, and treat the restrictions above as the price of the JVM stack for now, not as a design choice.
Choosing a starter and a protocol
Spring AI ships one starter per transport family. The dependency selects the web stack; a property selects the protocol.
| Dependency | Property | Server |
|---|---|---|
|
|
Standard input and output. The host launches the process; no web server. |
|
|
Servlet-based (Spring MVC). Blocking programming model. |
|
|
Reactive (Spring WebFlux, Reactor Netty). Pairs naturally with |
Use the WebMVC starter unless the application is already reactive; see Reactive Programming with Project Reactor for the trade-offs. The three protocols are:
| Protocol | What it is | When to use it |
|---|---|---|
|
Streamable HTTP with a session: one endpoint, POST for requests, optional server-sent events for streamed messages and server-initiated requests. |
Servers that push notifications (changed tools, progress) or make server-initiated requests such as elicitation and sampling. Needs a single instance or session affinity. |
|
Streamable HTTP without a session. Each request stands alone. |
Microservices and cloud deployments that scale horizontally. No server-initiated requests, so no elicitation through the server exchange. |
|
The older HTTP+SSE transport, deprecated in Spring AI since 2.0.0. |
Only to serve a client that cannot speak Streamable HTTP. New servers should use |
spring.ai.mcp.server.type chooses the programming model behind the endpoint: SYNC (the default) builds an
McpSyncServer and registers only annotated methods that return plain values, and ASYNC builds an McpAsyncServer and
registers only methods that return Mono or Flux. The two do not mix: the framework filters out the methods that do
not match and logs a warning for each one at startup. Running the project below in ASYNC mode listed the reactive tool
and dropped search_docs. The STATELESS mode filters as well: a method that takes McpSyncRequestContext or
McpAsyncRequestContext, which need a channel back to the client, is ignored there, and the stateless replacement is
McpTransportContext.
The properties the running example uses, and the ones you will reach for next, all live under
spring.ai.mcp.server:
| Property | Default | Meaning |
|---|---|---|
|
|
Reported as the server’s name and version. |
|
|
|
|
|
|
|
|
Serve over stdio (with the stdio starter). |
|
none |
Text a host may show to the model to explain how to use the server. |
|
|
Path of the Streamable HTTP endpoint ( |
|
|
Switch a whole capability off. |
|
|
Scan Spring beans for the |
|
|
Timeout for requests the server makes to the client. |
|
|
Re-expose tools obtained from MCP clients in this application. |
The docs-assistant server with Spring AI
Build file
The BOM manages every Spring AI artifact, including the annotations module, so the build needs one dependency. Boot 4.1 is the parent:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<properties>
<java.version>21</java.version>
<spring-ai.version>2.0.1</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>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
</dependencies>
spring.application.name=docs-assistant
spring.ai.mcp.server.name=docs-assistant
spring.ai.mcp.server.version=1.0.0
spring.ai.mcp.server.protocol=STREAMABLE
spring.ai.mcp.server.type=SYNC
spring.main.banner-mode=off
Stubbed store and the application class
The store is the same stub as on the other stacks; a real build implements search() against a Spring AI VectorStore
as described on Integrating with Spring AI and keeps the record
types, so the tool below does not change.
package dev.example.docsassistant;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import org.springframework.stereotype.Component;
/** Stubbed in-memory documentation index shared by the docs-assistant examples. */
@Component
public class DocStore {
public record Page(String component, String page, String title, String text) {}
public record Hit(String component, String page, String title, int score, String snippet) {}
public record SearchResult(String query, String component, List<Hit> hits) {}
static final List<Page> PAGES = List.of(
new Page("algebra", "matrices", "Matrices", "Matrix decompositions such as LU, QR and SVD solve linear systems."),
new Page("algebra", "wrappers", "Wrappers", "Wrapper classes hide the decomposition details behind a simple solver."),
new Page("units", "conversion", "Unit conversion", "Convert lengths, masses and times between unit systems."),
new Page("statistics", "gaussian", "Gaussian distributions", "Normal distributions, means and covariance matrices."));
public static final Set<String> COMPONENTS = Set.of("algebra", "units", "statistics");
public SearchResult search(String query, String component, int topK) {
var words = Arrays.stream(query.toLowerCase().split("\\s+")).filter(w -> !w.isBlank()).toList();
var hits = PAGES.stream()
.filter(p -> p.component().equals(component))
.map(p -> {
var haystack = (p.title() + " " + p.text()).toLowerCase();
int score = (int) words.stream().filter(haystack::contains).count();
return new Hit(p.component(), p.page(), p.title(), score, p.text());
})
.filter(h -> h.score() > 0)
.sorted(Comparator.comparingInt(Hit::score).reversed().thenComparing(Hit::page))
.limit(topK)
.toList();
return new SearchResult(query, component, hits);
}
public String read(String component, String page) {
return PAGES.stream()
.filter(p -> p.component().equals(component) && p.page().equals(page))
.findFirst()
.map(p -> "= " + p.title() + "\n\n" + p.text() + "\n")
.orElseThrow(() -> new IllegalArgumentException("unknown page " + component + "/" + page));
}
}
package dev.example.docsassistant;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DocsAssistantApplication {
public static void main(String[] args) {
SpringApplication.run(DocsAssistantApplication.class, args);
}
}
Tool, resource template and prompt
There is no MCP-specific configuration class. The auto-configuration scans every Spring bean for the @Mcp…
annotations and registers what it finds with the server:
package dev.example.docsassistant;
import io.modelcontextprotocol.spec.McpSchema.GetPromptResult;
import io.modelcontextprotocol.spec.McpSchema.PromptMessage;
import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult;
import io.modelcontextprotocol.spec.McpSchema.Role;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import io.modelcontextprotocol.spec.McpSchema.TextResourceContents;
import java.util.List;
import org.springframework.ai.mcp.annotation.McpArg;
import org.springframework.ai.mcp.annotation.McpPrompt;
import org.springframework.ai.mcp.annotation.McpResource;
import org.springframework.ai.mcp.annotation.McpTool;
import org.springframework.ai.mcp.annotation.McpToolParam;
import org.springframework.stereotype.Component;
@Component
public class DocsTools {
private final DocStore store;
public DocsTools(DocStore store) {
this.store = store;
}
@McpTool(name = "search_docs",
description = "Search the documentation of one component and return the best matching pages.",
generateOutputSchema = true,
annotations = @McpTool.McpAnnotations(readOnlyHint = true, destructiveHint = false, openWorldHint = false))
public DocStore.SearchResult searchDocs(
@McpToolParam(description = "Free-text query", required = true) String query,
@McpToolParam(description = "algebra, units or statistics", required = true) String component,
@McpToolParam(description = "Maximum number of hits", required = false) Integer topK) {
if (!DocStore.COMPONENTS.contains(component)) {
throw new IllegalArgumentException("Unknown component '" + component + "'");
}
return store.search(query, component, topK == null ? 3 : topK);
}
@McpResource(uri = "docs://{component}/{page}", name = "docs-page",
description = "The AsciiDoc source of one documentation page", mimeType = "text/asciidoc")
public ReadResourceResult readPage(String component, String page) {
return ReadResourceResult.builder(List.of(
TextResourceContents.builder("docs://" + component + "/" + page, store.read(component, page))
.mimeType("text/asciidoc").build())).build();
}
@McpPrompt(name = "answer_with_citations",
description = "Instruct the model to answer from the docs and cite page URIs.")
public GetPromptResult answerWithCitations(
@McpArg(name = "question", required = true) String question,
@McpArg(name = "component", required = false) String component) {
String c = component == null ? "algebra" : component;
String text = "Answer the question using only search_docs results for component '" + c + "'. "
+ "Cite every claim as docs://" + c + "/<page>.\n\nQuestion: " + question;
return GetPromptResult.builder(List.of(new PromptMessage(Role.USER, TextContent.builder(text).build())))
.build();
}
}
The Spring AI annotations live in the org.springframework.ai.mcp.annotation package (the spring-ai-mcp-annotations
module, pulled in by the starter); the message and result types come from the MCP Java SDK’s McpSchema. Run with
./mvnw spring-boot:run, the server listens on http://localhost:8080/mcp. See the
Spring AI MCP server annotations reference
for every attribute.
The annotations, one by one
@McpTool and @McpToolParam
@McpTool marks a method as a tool. Its attributes are name (defaults to the method name), description, title,
annotations (a nested @McpTool.McpAnnotations with title, readOnlyHint, destructiveHint, idempotentHint and
openWorldHint), generateOutputSchema and metaProvider. @McpToolParam describes a parameter with description and
required. The input schema is generated from the method signature; with generateOutputSchema = true an output schema is
generated from the return type, and the returned object is delivered as structuredContent next to its JSON text, which
is what the contract script’s structured-content step checks.
One default deserves attention. If you do not set the hints, the wire result says readOnlyHint: false,
destructiveHint: true, idempotentHint: false and openWorldHint: true, which is the most pessimistic reading. That is
why the listing sets destructiveHint = false explicitly next to readOnlyHint = true: without it, a read-only tool was
advertised with destructiveHint: true as well, and a host that shows or acts on the hints would treat a search as
potentially destructive. As everywhere in MCP, these annotations are untrusted hints for the host’s user interface, not
a permission system; a real gate belongs in the server (see MCP Security).
Failures follow the ordinary Spring AI @Tool contract. A RuntimeException becomes an error result (isError: true) that
the model can read and react to, which is what happens for an unknown component. A checked exception declared on the
method or an Error fails the call instead of reaching the model. A protocol-level McpError always propagates to the
protocol layer.
@McpResource
@McpResource(uri = "docs://{component}/{page}") registers a resource template; the variables in the URI are bound to the
method parameters by name. The method may return a String (wrapped with the default text/plain type) or a
ReadResourceResult (as above, which lets you set the MIME type per content block); the reference lists the other
accepted return types. name, title,
description, mimeType and a nested annotations (audience, priority, last-modified) complete the metadata.
@McpPrompt and @McpArg
@McpPrompt registers a prompt template; @McpArg(name, description, required) documents each argument. Return a
GetPromptResult for full control over roles and messages; the reference lists the simpler return types the framework
also accepts.
@McpComplete
Completion is the protocol’s autocompletion: while a user types an argument of a prompt, or a variable of a resource
template, the client asks the server for candidate values. @McpComplete marks the method that answers, with exactly one
of prompt (the name of a prompt whose argument is completed) or uri (a resource URI template). One method answers
for every argument of its prompt or template, so take the whole CompleteRequest and switch on
request.argument().name(). The simpler String prefix signature cannot tell the arguments apart and would offer
component names for page too:
package dev.example.docsassistant;
import io.modelcontextprotocol.spec.McpSchema.CompleteRequest;
import java.util.List;
import java.util.Map;
import org.springframework.ai.mcp.annotation.McpComplete;
import org.springframework.stereotype.Component;
@Component
public class DocsCompletions {
/** Completes both variables of the resource template, switching on the argument's name. */
@McpComplete(uri = "docs://{component}/{page}")
public List<String> completePageUri(CompleteRequest request) {
String prefix = request.argument().value();
return switch (request.argument().name()) {
case "component" -> components(prefix);
case "page" -> {
// Narrow to the component the user already filled in, when the client sends it as context.
Map<String, String> filled = request.context() == null ? Map.of() : request.context().arguments();
String component = filled == null ? null : filled.get("component");
yield DocStore.PAGES.stream()
.filter(p -> component == null || p.component().equals(component))
.map(DocStore.Page::page)
.filter(p -> p.startsWith(prefix))
.sorted()
.toList();
}
default -> List.of();
};
}
/** Only the prompt's `component` argument has a closed set of values; `question` is free text. */
@McpComplete(prompt = "answer_with_citations")
public List<String> completePromptArgument(CompleteRequest request) {
return "component".equals(request.argument().name()) ? components(request.argument().value()) : List.of();
}
private static List<String> components(String prefix) {
return DocStore.COMPONENTS.stream().sorted().filter(c -> c.startsWith(prefix)).toList();
}
}
A completion/complete request for the component variable of the resource template with the prefix a returned:
{ "jsonrpc": "2.0", "id": 2, "result": { "completion": { "values": ["algebra"], "total": 1, "hasMore": false } } }
These requests were captured from the STREAMABLE mode on 2026-09-29:
-
The
pagevariable with the prefixmreturned["matrices"]. -
The
pagevariable with an empty prefix and"context": {"arguments": {"component": "algebra"}}returned["matrices", "wrappers"].request.context()carries the values the user has already filled in, and it isnullwhen the client sends none. -
The prompt’s
componentargument with the prefixsreturned["statistics"]. -
The prompt’s free-text
questionargument returned[].
Scanning, sync and async
The annotation scanner is on by default (spring.ai.mcp.server.annotation-scanner.enabled). It finds the annotated methods
on any Spring bean, so annotate a @Component, not a plain class you construct yourself. As noted above, only the methods
that match the server type register: with SYNC, methods returning plain values; with ASYNC, methods returning Mono
or Flux. A reactive variant of the search tool is the same annotation on a different return type:
@McpTool(name = "search_docs_async", description = "Reactive variant of search_docs.")
public Mono<DocStore.SearchResult> searchDocsAsync(
@McpToolParam(description = "Free-text query", required = true) String query,
@McpToolParam(description = "algebra, units or statistics", required = true) String component) {
return Mono.fromSupplier(() -> store.search(query, component, 3));
}
In ASYNC mode this method registered and the synchronous search_docs did not.
Exposing existing @Tool beans
An application that already uses Spring AI for a chat model often has tools written with Spring AI’s @Tool, for use by a
ChatClient (see AI-Assisted Development and
Tool Design for the model-facing side). Those tools can be served over MCP as they are. The
starter converts every ToolCallback, list of ToolCallback and ToolCallbackProvider bean it finds into an MCP tool
(spring.ai.mcp.server.tool-callback-converter, on by default), so wrapping the bean is the whole job:
@Component
public class CatalogTools {
@Tool(description = "List the documentation components that can be searched.")
public List<String> listComponents() {
return DocStore.COMPONENTS.stream().sorted().toList();
}
}
@Configuration
class ToolConfig {
@Bean
ToolCallbackProvider catalogToolCallbacks(CatalogTools tools) {
return ToolCallbackProvider.from(ToolCallbacks.from(tools)); // the MCP starter converts these into MCP tools
}
}
with the imports org.springframework.ai.tool.annotation.Tool, org.springframework.ai.tool.ToolCallbackProvider and
org.springframework.ai.support.ToolCallbacks. The tool appeared in tools/list and worked, and comparing it with the
native @McpTool shows what conversion costs:
| Aspect | @Tool bean exposed through the converter |
@McpTool |
|---|---|---|
Tool name |
The method name, |
The |
Annotations (hints) |
None reported |
Set per tool through |
Output schema and |
None: the result is text |
Opt in with |
Resources, prompts, completions |
Not applicable |
|
Use conversion to share a tool between a chat client and an MCP server with no rewrite, and @McpTool for tools that are
part of the MCP contract. Two things follow from the conversion being automatic. Everything registered as a
ToolCallback bean becomes reachable by every MCP client, so audit what your chat tools can do before you serve them,
and put write and delete tools behind an approval step in the tool itself rather than trusting a client’s prompt. And
tool names are de-duplicated with the first registration winning, so two beans that produce the same name shadow
each other.
Serving over stdio
For a server the host launches as a subprocess, swap the starter for spring-ai-starter-mcp-server and switch off
everything that would write to standard output:
spring.application.name=docs-assistant
spring.ai.mcp.server.name=docs-assistant
spring.ai.mcp.server.version=1.0.0
spring.ai.mcp.server.stdio=true
# stdout carries the protocol: no banner, no web server, no console logging
spring.main.web-application-type=none
spring.main.banner-mode=off
logging.threshold.console=off
The rule from Transports applies in full: a banner or a log line on standard output corrupts the
protocol stream. With the settings above a piped initialize request produced exactly one JSON line on stdout and the
Inspector CLI listed and called the tools; Spring AI’s reference suggests logging.pattern.console= (an empty pattern),
which also works but makes Logback print a configuration error on stderr at startup. Register it with a host as
java -jar docs-assistant.jar (see Connecting Hosts).
Securing the endpoint
Spring AI’s HTTP servers do not authenticate anything on their own. Its reference states that the transports are
authentication-agnostic and that, by default, the /mcp endpoint accepts every request, so any client that can reach it can
list and invoke every registered tool, resource and prompt; the stdio transport is not network-accessible and is exempt.
Registering a tool is a decision to expose it, so put a security boundary in front before the server leaves localhost.
Spring Security provides it, as for any other endpoint of the application. Add
spring-boot-starter-security-oauth2-resource-server (the older spring-boot-starter-oauth2-resource-server name is
deprecated in Boot 4.1) and a filter chain:
@Configuration
@EnableWebSecurity
class McpSecurityConfig {
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/mcp").hasAuthority("SCOPE_docs:read") // the MCP endpoint
.anyRequest().authenticated()) // everything else, REST included
.oauth2ResourceServer(rs -> rs.jwt(withDefaults()))
.csrf(csrf -> csrf.disable()) // bearer tokens only, no cookies
.build();
}
}
with Customizer.withDefaults imported statically. The issuer, audience and key location come from the environment through
the Boot properties spring.security.oauth2.resourceserver.jwt.issuer-uri, .audiences and .jwk-set-uri, set from
MCP_ISSUER, MCP_AUDIENCE and MCP_JWKS_URI, never from source. Run against a throw-away key and a local JWKS endpoint,
the endpoint answered as follows, and a @RestController in the same application was protected by the same chain:
| Request | POST /mcp |
GET /api/components (REST) |
|---|---|---|
No token |
401 |
401 |
Valid token with |
200 |
200 |
Valid token without the scope |
403 |
n/a (any authenticated caller) |
Token issued for another audience |
401 |
n/a |
The complete resource-server configuration — Protected Resource Metadata (RFC 9728) at
/.well-known/oauth-protected-resource, the resource_metadata parameter in the 401 challenge, the audience check
rationale and the choice of authorization server — is on Authorization; the fundamentals of
OAuth are under OAuth Reference. To use the caller’s identity inside a tool,
the transport provider’s builder accepts a contextExtractor(…), an McpTransportContextExtractor from the MCP Java SDK, that
copies request data (for example the Authorization header) into the McpTransportContext that a tool reads with
McpSyncRequestContext.transportContext(). The auto-configuration sets no extractor, so to use one you declare your own
WebMvcStreamableServerTransportProvider bean, which makes the auto-configured provider back off; the default context is
empty by design. Copy claims, not raw tokens, and do not forward the caller’s token to another API.
The plain MCP Java SDK
Spring AI is a layer over the MCP Java SDK (io.modelcontextprotocol.sdk:mcp), which is usable on its own in an
application that does not use Spring. The SDK provides the server builder and transports: a stdio provider, a servlet-based
Streamable HTTP provider, a servlet-based stateless transport, and the older SSE provider. This stdio server, with one tool,
compiled against io.modelcontextprotocol.sdk:mcp:2.0.1 on Java 21 and answered tools/list and tools/call through the
Inspector CLI:
package dev.example;
import io.modelcontextprotocol.json.McpJsonDefaults;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpSyncServer;
import io.modelcontextprotocol.server.transport.StdioServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import io.modelcontextprotocol.spec.McpSchema.ToolAnnotations;
import java.util.List;
import java.util.Map;
public final class PlainDocsServer {
private static final String INPUT_SCHEMA = """
{"type":"object",
"properties":{"query":{"type":"string"},"component":{"type":"string"}},
"required":["query","component"]}""";
public static void main(String[] args) throws InterruptedException {
var transport = new StdioServerTransportProvider(McpJsonDefaults.getMapper());
Tool searchDocs = Tool.builder("search_docs", McpJsonDefaults.getMapper(), INPUT_SCHEMA)
.description("Search the documentation of one component.")
.annotations(new ToolAnnotations(null, true, false, null, false, null))
.build();
McpSyncServer server = McpServer.sync(transport)
.serverInfo("docs-assistant", "1.0.0")
.capabilities(ServerCapabilities.builder().tools(true).build())
.toolCall(searchDocs, (exchange, request) -> {
Map<String, Object> arguments = request.arguments();
String text = "hits for '" + arguments.get("query") + "' in " + arguments.get("component");
return CallToolResult.builder()
.content(List.of(TextContent.builder(text).build()))
.isError(false)
.build();
})
.build();
Thread.currentThread().join(); // stdio: the transport reads System.in until the host closes it
}
}
The mcp artifact brings the core SDK and a Jackson 3 JSON module; the SLF4J binding in the run wrote to standard error,
which is what a stdio server needs. For HTTP, replace the transport with HttpServletStreamableServerTransportProvider
(its builder takes the endpoint path, a security validator and a request-size limit) and register it as a servlet in
Jetty, Tomcat or Undertow, or use the stateless servlet transport for a session-free server. Tools, resources and prompts
are registered on the builder with toolCall, resources, resourceTemplates, prompts and completions. The SDK is
protocol-2025-11-25 like Spring AI, so the caveat above applies unchanged. See the
MCP Java SDK documentation.
Testing the Java server
The shared contract script (see Testing & Debugging) runs against the Java server with the legacy protocol era:
PROTOCOL_ERA=legacy ./contract.sh http://127.0.0.1:8080/mcp
It passed five of its six steps in the STREAMABLE mode and the same five in STATELESS mode, with the multi-round-trip
elicitation step skipped because it needs the 2026-07-28 protocol. Spring’s own test support, @SpringBootTest with a
random port and the MCP Java SDK’s client, can call the same endpoint from a JUnit test, in the style of
Unit and Integration Testing.