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 initialize request with protocol version 2025-11-25, or a 2026-07-28 client that falls back to it, gets a normal answer that negotiates protocolVersion 2025-11-25. Hosts such as Claude Code, VS Code and the Inspector’s legacy era therefore work against a Spring AI server today.

  • A 2026-07-28 client that starts with server/discover gets an error instead: -32601 from the STREAMABLE mode, whose message complains about a missing session id, and -32603 from STATELESS. The Inspector CLI with --protocol-era modern fails with "the server did not offer pinned protocol version 2026-07-28 via server/discover". Use --protocol-era legacy when you test a Java server.

  • Anything that belongs to the newer revision is therefore absent: no input_required multi-round-trip elicitation, no ttlMs / cacheScope hints, no Mcp-Method and Mcp-Name request headers, no subscriptions/listen. Elicitation on the Java side is the earlier server-initiated request made through the server exchange. Because docs-assistant’s multi-round-trip step needs the newer protocol, the Java build makes `component a required argument, and the contract script skips that one step for it.

  • The STREAMABLE mode keeps a session id, so a load balancer needs affinity or a single instance; the STATELESS mode 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

spring-ai-starter-mcp-server

spring.ai.mcp.server.stdio=true

Standard input and output. The host launches the process; no web server.

spring-ai-starter-mcp-server-webmvc

spring.ai.mcp.server.protocol=STREAMABLE, STATELESS or SSE

Servlet-based (Spring MVC). Blocking programming model.

spring-ai-starter-mcp-server-webflux

spring.ai.mcp.server.protocol=STREAMABLE, STATELESS or SSE

Reactive (Spring WebFlux, Reactor Netty). Pairs naturally with type=ASYNC and Mono / Flux return types.

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 (the default)

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.

STATELESS

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.

SSE

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

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

name, version

mcp-server, 1.0.0

Reported as the server’s name and version.

protocol

streamable

STREAMABLE, STATELESS or SSE.

type

sync

SYNC or ASYNC.

stdio

false

Serve over stdio (with the stdio starter).

instructions

none

Text a host may show to the model to explain how to use the server.

streamable-http.mcp-endpoint

/mcp

Path of the Streamable HTTP endpoint (stateless.mcp-endpoint for the stateless mode).

capabilities.tool, .resource, .prompt, .completion

true

Switch a whole capability off.

annotation-scanner.enabled

true

Scan Spring beans for the @Mcp…​ annotations.

request-timeout

20s

Timeout for requests the server makes to the client.

expose-mcp-client-tools

false

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:

pom.xml (abridged to the relevant parts)
<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>
application.properties
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.

DocStore.java
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));
    }
}
DocsAssistantApplication.java
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:

DocsTools.java
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:

DocsCompletions.java
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 page variable with the prefix m returned ["matrices"].

  • The page variable 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 is null when the client sends none.

  • The prompt’s component argument with the prefix s returned ["statistics"].

  • The prompt’s free-text question argument 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:

CatalogTools.java and ToolConfig.java
@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, listComponents, unless the @Tool sets one

The name attribute (search_docs)

Annotations (hints)

None reported

Set per tool through @McpTool.McpAnnotations

Output schema and structuredContent

None: the result is text

Opt in with generateOutputSchema = true

Resources, prompts, completions

Not applicable

@McpResource, @McpPrompt, @McpComplete

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:

application.properties for a stdio server
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 docs:read

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:

PlainDocsServer.java
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.