MCP Server

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.

Written against Spring AI 2.0.1, Spring Boot 4.1.x and Java 21 (verified 2026-09-29). This page adds the last capability of the running DocsAssistant service that other programs use: an MCP server at /mcp that lets any MCP host (an IDE assistant, Claude Code, another agent) search the documentation, read its pages, and draft an issue about a problem, with the same guarded tools the chat endpoint uses. The protocol itself and the framework-independent design of a good server are on Model Context Protocol and Designing Good MCP Servers; the depth of the Spring Boot server (both web stacks, every annotation attribute, the plain Java SDK, stdio) is on Spring Boot MCP Servers, and this page does not repeat it. What it adds is the DocsAssistant side: which of its tools to expose and how, identity and authorisation on the endpoint, and tests. Every class on this page was compiled against the 2.0.1 jars. The server was run, secured, against a local authorization server and called with curl, the MCP Inspector CLI and the MCP Java SDK client, and the JUnit test below passed on Java 21.

Protocol version

Spring AI 2.0.1 builds on MCP Java SDK 2.0.0, whose protocol constants stop at the 2025-11-25 revision of the specification (verified in mcp-core 2.0.0 and 2.0.1 on 2026-09-29). The site’s MCP reference is written against 2026-07-28. In practice the DocsAssistant server negotiated protocolVersion 2025-11-25 in the run, so hosts that speak the earlier initialize handshake work, and anything that exists only in the newer revision (server/discover, the stateless request core, multi-round-trip elicitation) does not. A client that starts with server/discover gets an error. Tell the Inspector --protocol-era legacy when you test this server. The full list of consequences is under The protocol-version caveat; check the release notes of Spring AI before relying on the gap, since a later release may close it.

The pieces

flowchart LR H["MCP host or Inspector"] -->|"POST /mcp"| S["Spring Security chain: token, audience, Origin, Host"] S --> T["Streamable HTTP transport"] T --> R["Tool, resource, prompt and completion registries"] R --> A["searchDocs: existing @Tool bean, converted"] R --> B["open_issue: @McpTool, scope checked"] R --> C["docs:// resource, prompt, completion"] A --> V["Vector store"] B --> Q["Approval queue"] C --> V
Figure 1. The request path of the DocsAssistant MCP server

Spring AI does three jobs for you. The transport starter creates the HTTP endpoint. The annotation scanner finds @McpTool, @McpResource, @McpPrompt and @McpComplete methods on any Spring bean and registers them. And a converter turns every ToolCallback, list of callbacks or ToolCallbackProvider bean into an MCP tool. Everything else, in particular who may call the server, is ordinary Spring Security.

Starters and protocol modes

The dependency chooses the web stack and the property chooses the protocol.

Table 1. The server starters
Dependency Serves Notes

spring-ai-starter-mcp-server

standard input and output

No web server. spring.ai.mcp.server.stdio=true. The host starts the process; use it for local tools

spring-ai-starter-mcp-server-webmvc

HTTP on the servlet stack

The choice of the running example, because DocsAssistant is a servlet application

spring-ai-starter-mcp-server-webflux

HTTP on the reactive stack

Pairs with spring.ai.mcp.server.type=ASYNC and Mono or Flux return types

Table 2. The three HTTP protocol modes (spring.ai.mcp.server.protocol)
Mode What it does What to know

STREAMABLE (default)

Streamable HTTP with a session: one endpoint, POST for requests, GET for a stream of server messages

Supports everything that needs a channel back to the client: logging, progress, elicitation, sampling, list-changed notifications. The session lives in one instance, so a replicated deployment needs session affinity

STATELESS

Streamable HTTP without a session: each request stands alone

Scales behind any load balancer. Methods that take McpSyncRequestContext or McpAsyncRequestContext need a channel back and are skipped

SSE

The older HTTP+SSE transport

Deprecated. Serve it only for a client that cannot speak Streamable HTTP; the security module does not support it

Run in STATELESS mode, the DocsAssistant server listed searchDocs and count_hits only. open_issue was left out with a warning in the log (Stateless servers doesn’t support bidirectional parameters. Skipping method …​), because it takes a McpSyncRequestContext. Prompts and resource templates were served in both modes. Decide the mode first: a tool that must log to the client, ask a question or report progress wants STREAMABLE; a server whose tools are plain functions of their arguments is better STATELESS.

pom.xml: the server and its security module
<dependencies>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springaicommunity</groupId>
        <artifactId>mcp-server-security</artifactId>
        <version>0.1.14</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
    </dependency>
</dependencies>

The Spring AI BOM manages the starter but not the community module, so its version is explicit.

application.properties: the server, its identity and where tokens come from
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.instructions=Search the Irurueta documentation with searchDocs before answering. open_issue only drafts an issue.

spring.security.oauth2.resourceserver.jwt.issuer-uri=${MCP_ISSUER_URI}
docsassistant.mcp.allowed-origins=${MCP_ALLOWED_ORIGINS}
docsassistant.mcp.allowed-hosts=${MCP_ALLOWED_HOSTS}

The other properties under spring.ai.mcp.server (all present in the starter’s metadata) are type (SYNC or ASYNC), stdio, base-url, request-timeout (default 20 s), streamable-http.mcp-endpoint (default /mcp), streamable-http.keep-alive-interval, streamable-http.disallow-delete, sse-endpoint, capabilities.tool, .resource, .prompt and .completion (each on by default), the three …​-change-notification switches, tool-response-mime-type, annotation-scanner.enabled, and expose-mcp-client-tools. Source: Spring AI: MCP Server Boot Starters. The instructions text is offered to hosts as guidance for the model, so write it for the model and keep secrets out of it.

Exposing the existing @Tool beans

searchDocs is already a @Tool method on DocsTools, written for the chat endpoint. The MCP starter converts any ToolCallbackProvider bean into MCP tools, so sharing it takes a bean and no new code in the tool:

DocsMcpServerConfig.java: one tool shared between the chat endpoint and the MCP server
package com.example.docsassistant;

import org.springframework.ai.support.ToolCallbacks;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
class DocsMcpServerConfig {

    // The MCP starter turns every ToolCallbackProvider bean into MCP tools. Only searchDocs, which is read-only,
    // is offered this way; openIssue gets its own MCP tool below because it needs the caller's identity.
    @Bean
    ToolCallbackProvider docsToolCallbacks(DocsTools docsTools) {
        return ToolCallbackProvider.from(ToolCallbacks.from(docsTools));
    }
}

Run against the stub vector store, tools/call for searchDocs returned the hits as a JSON text block. The conversion has three consequences worth knowing. The tool keeps the method name (searchDocs, not a snake-case name), it carries no tool annotations (so a host sees the pessimistic defaults: not read-only, possibly destructive), and its result is text, with no output schema. If those matter, write the tool as an @McpTool.

Not every chat tool should be served this way. openIssue on Tool Calling was written for the chat endpoint, and exposing it through the same converter failed twice over in the run:

  • Its guard is @PreAuthorize("hasRole('MAINTAINER')"), but an MCP caller authenticates with an OAuth access token, whose authorities are scopes (SCOPE_docs:read and so on). The call returned Access Denied for a caller who held every scope. The guard is doing its job; it just speaks another vocabulary.

  • It reads the requester from the ToolContext. The converter calls the tool with a context whose only entry is the MCP exchange (McpToolUtils.TOOL_CONTEXT_MCP_EXCHANGE_KEY), so the requester entry the chat controller supplies would not be there, and the tool refuses to draft an issue without a requester.

Everything registered as a ToolCallback bean becomes reachable by every client that can authenticate, so audit what your chat tools can do before you serve them, and give the write tools an MCP-specific shape: a scope check, the caller’s identity from the security context, and the same approval step.

A tool that needs the caller: @McpTool

open_issue is an @McpTool on a Spring bean. The @PreAuthorize annotation works on it as on any bean, evaluated against the token’s authorities.

IssueMcpTools.java: a guarded write tool with a request context
package com.example.docsassistant;

import org.springframework.ai.mcp.annotation.McpTool;
import org.springframework.ai.mcp.annotation.McpToolParam;
import org.springframework.ai.mcp.annotation.context.McpSyncRequestContext;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;

@Component
class IssueMcpTools {

    private final IssueApprovalQueue approvals;

    IssueMcpTools(IssueApprovalQueue approvals) {
        this.approvals = approvals;
    }

    @PreAuthorize("hasAuthority('SCOPE_issues:draft')")
    @McpTool(name = "open_issue",
            description = "Draft a GitHub issue about a documentation problem. The issue is not created: a maintainer "
                    + "approves the draft first.",
            annotations = @McpTool.McpAnnotations(readOnlyHint = false, destructiveHint = false,
                    idempotentHint = false, openWorldHint = false))
    public String openIssue(McpSyncRequestContext context,
            @McpToolParam(description = "Short imperative title, at most 80 characters", required = true) String title,
            @McpToolParam(description = "Markdown description with the page it affects", required = true) String body) {
        if (title.length() > 80) {
            throw new IllegalArgumentException("title is " + title.length() + " characters; shorten it to 80");
        }
        // the requester is the authenticated caller of this request, never an argument the model can fill in
        Authentication caller = SecurityContextHolder.getContext().getAuthentication();
        context.info("Drafting an issue for " + caller.getName());   // a logging notification to the client
        String draftId = this.approvals.submit(new IssueDraft(caller.getName(), title.strip(), body));
        return "Draft " + draftId + " is waiting for maintainer approval; the issue has not been created yet.";
    }
}

Four details in the listing carry the design:

  • The scope check hasAuthority('SCOPE_issues:draft') lets a token with only docs:read list and search but not draft. In the run such a caller received an error result whose text was Access Denied (the Inspector CLI reported tool_is_error), and nothing reached the approval queue. With @EnableMethodSecurity the check runs before the method body.

  • The requester is the authenticated caller. The name comes from SecurityContextHolder (the token’s subject, alice in the run), never from an argument the model can fill in. The same rule as for memory IDs applies.

  • annotations = @McpTool.McpAnnotations(…​) sets the hints a host may show. Without them the wire result advertises the most pessimistic values; with them open_issue says it is not read-only, not destructive (it creates a draft), not idempotent and not open-world. They are hints for a user interface, not a permission system.

  • McpSyncRequestContext gives the method a channel back to the client. It offers info, debug, warn and error for log notifications, progress(…​), elicit(…​) for asking the user a question (check elicitEnabled() first), sample(…​) for asking the client’s model (check sampleEnabled()), roots() and ping(). Here it sends one log line.

For a tool that only needs request data, not a channel back, take a McpTransportContext instead. It works in both protocol modes:

StatelessLookupTools.java: a tool that also works in STATELESS mode
package com.example.docsassistant;

import io.modelcontextprotocol.common.McpTransportContext;
import org.springframework.ai.mcp.annotation.McpTool;
import org.springframework.ai.mcp.annotation.McpToolParam;
import org.springframework.stereotype.Component;

/** Works in the STATELESS protocol mode too: it needs no channel back to the client. */
@Component
class StatelessLookupTools {

    private final DocsTools docsTools;

    StatelessLookupTools(DocsTools docsTools) {
        this.docsTools = docsTools;
    }

    @McpTool(name = "count_hits", description = "Count how many passages match a query.",
            annotations = @McpTool.McpAnnotations(readOnlyHint = true, destructiveHint = false, openWorldHint = false))
    public int countHits(McpTransportContext transportContext,
            @McpToolParam(description = "What to look for", required = true) String query) {
        // transportContext carries request data that a context extractor copied in (empty by default)
        return this.docsTools.searchDocs(query, null).size();
    }
}

The transport context carries values that a context extractor on the transport provider copies from the HTTP request; the auto-configuration installs none, so it is empty until you provide your own transport provider bean (the mechanism is described on the Spring Boot MCP servers page, and a claim, never a raw token, is what to copy into it).

Resources, prompts and completions

Tools are for actions. The rest of the protocol is for context the host can offer to the user. All three below use the annotations and the conventions of Spring Boot MCP Servers; what is new is that they sit on the same vector store as the chat endpoint.

DocsMcpPrimitives.java: a resource template, a prompt and a completion
package com.example.docsassistant;

import java.util.List;
import java.util.stream.Collectors;

import io.modelcontextprotocol.spec.McpSchema.CompleteRequest;
import io.modelcontextprotocol.spec.McpSchema.GetPromptResult;
import io.modelcontextprotocol.spec.McpSchema.PromptMessage;
import io.modelcontextprotocol.spec.McpSchema.Role;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import org.springframework.ai.mcp.annotation.McpArg;
import org.springframework.ai.mcp.annotation.McpComplete;
import org.springframework.ai.mcp.annotation.McpPrompt;
import org.springframework.ai.mcp.annotation.McpResource;
import org.springframework.stereotype.Component;

@Component
class DocsMcpPrimitives {

    private static final List<String> COMPONENTS = List.of("hermes", "irurueta-algebra", "irurueta-sorting",
            "irurueta-statistics", "irurueta-units");

    private final DocsTools docsTools;

    DocsMcpPrimitives(DocsTools docsTools) {
        this.docsTools = docsTools;
    }

    // A resource template: the client reads docs://irurueta-algebra/matrices and the variables bind by name
    @McpResource(uri = "docs://{component}/{page}", name = "docs-page",
            description = "The indexed passages of one documentation page", mimeType = "text/plain")
    public String readPage(String component, String page) {
        return this.docsTools.searchDocs(page, component).stream()
                .filter(hit -> hit.sourcePath() != null && hit.sourcePath().contains(page))
                .map(DocsTools.DocHit::content)
                .collect(Collectors.joining("\n\n"));
    }

    @McpPrompt(name = "answer_from_docs", description = "Answer a question from the documentation and cite the pages.")
    public GetPromptResult answerFromDocs(
            @McpArg(name = "question", description = "What the user wants to know", required = true) String question,
            @McpArg(name = "component", description = "Restrict the search to one component", required = false) String component) {
        String scope = component == null || component.isBlank() ? "any component" : component;
        String text = "Use the searchDocs tool (" + scope + ") and answer only from its results. "
                + "Cite the source path of every claim.\n\nQuestion: " + question;
        return GetPromptResult.builder(List.of(new PromptMessage(Role.USER, TextContent.builder(text).build()))).build();
    }

    // One method answers for every argument of the template, so switch on the argument being typed
    @McpComplete(uri = "docs://{component}/{page}")
    public List<String> completeUri(CompleteRequest request) {
        if (!"component".equals(request.argument().name())) {
            return List.of();
        }
        String prefix = request.argument().value();
        return COMPONENTS.stream().filter(c -> c.startsWith(prefix)).toList();
    }
}

Run against the stub store, the server answered:

Table 3. Requests and results, STREAMABLE mode
Request Result

resources/templates/list

One template, docs://{component}/{page}, name docs-page, MIME type text/plain

resources/read for docs://irurueta-algebra/matrices

The passage text as text/plain

prompts/get for answer_from_docs with a question

One user message that tells the model to use the search tool and cite source paths

completion/complete for the component variable with the prefix irurueta-s

irurueta-sorting and irurueta-statistics

A resource URI built from client input is a lookup key, not a file path: the method above asks the vector store and never opens a file. Keep it that way. The prompt names the tool in its text, so the tool name in the prompt and the name the server registers (searchDocs for the converted tool) must agree.

Securing the endpoint

The MCP server has no authentication of its own. The Spring AI reference states that, by default, the HTTP endpoint accepts every request, so anyone who can reach it can list and call every registered tool, resource and prompt. The stdio transport is not network-accessible and is exempt. Put a security boundary in front before the server leaves your machine. Two layers are involved: Spring Security authenticates and authorises the HTTP request, and the community module mcp-server-security (org.springaicommunity:mcp-server-security) adds what MCP asks of a protected server.

sequenceDiagram participant C as MCP client participant S as DocsAssistant /mcp participant A as Authorization server C->>S: POST /mcp without a token S-->>C: 401, WWW-Authenticate: Bearer resource_metadata=... C->>S: GET /.well-known/oauth-protected-resource/mcp S-->>C: resource, authorization_servers C->>A: authorization request for the resource A-->>C: access token, audience is the MCP endpoint C->>S: POST /mcp with the access token S->>S: signature, issuer, audience, scope S-->>C: 200
Figure 2. What a client and the server exchange when a token is required

The module’s configurer wires that exchange into the Spring Security chain.

McpSecurityConfig.java: OAuth 2.0 resource server for the MCP endpoint
package com.example.docsassistant;

import java.util.List;

import org.springaicommunity.mcp.security.server.config.McpServerOAuth2Configurer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration(proxyBeanMethods = false)
@EnableWebSecurity
@EnableMethodSecurity
@Profile("!apikey")
class McpSecurityConfig {

    @Bean
    @Order(1) // before the catch-all API chain of SecurityConfig
    SecurityFilterChain mcpSecurity(HttpSecurity http,
            @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuerUri,
            @Value("${docsassistant.mcp.allowed-origins}") List<String> allowedOrigins,
            @Value("${docsassistant.mcp.allowed-hosts}") List<String> allowedHosts) throws Exception {
        return http
                .securityMatcher("/mcp/**", "/.well-known/oauth-protected-resource/**") // only the MCP paths
                .authorizeHttpRequests(auth -> auth
                        .anyRequest().authenticated())              // every MCP request needs a valid access token
                .with(McpServerOAuth2Configurer.mcpServerOAuth2(), mcp -> {
                    mcp.authorizationServer(issuerUri);             // where tokens come from; also advertised to clients
                    mcp.validateAudienceClaim(true);                // reject tokens issued for another resource
                    mcp.resourceName("DocsAssistant MCP server");   // shown in the protected resource metadata
                    mcp.allowedOrigins(allowedOrigins);             // browser origins that may call the server
                    mcp.allowedHosts(allowedHosts);                 // Host header values the server answers to
                })
                .build();
    }
}

This chain is not the only one in the service. The api chain of SecurityConfig on Security & Guardrails has no securityMatcher, so it matches every request and ends in anyRequest().denyAll(). Spring Security allows one such catch-all chain and wants it last: a second one fails at startup with "A filter chain that matches any request …​ has already been configured". So the MCP chain declares securityMatcher for /mcp/ and the protected resource metadata under /.well-known/oauth-protected-resource/, and @Order(1) puts it first. A request to those paths is handled by the MCP chain alone; every other request (/api/, /actuator/) falls through to the api chain, which keeps its own rules, including the public /actuator/health. The API-key chain below is scoped the same way.

What the configurer contributes, as observed against a local authorization server that served a discovery document and signing keys:

Table 4. Behaviour of the secured endpoint
Request Status What happened

POST /mcp, no token

401

WWW-Authenticate: Bearer resource_metadata=http://<host>/.well-known/oauth-protected-resource/mcp

GET /.well-known/oauth-protected-resource/mcp

200

The protected resource metadata (RFC 9728): resource, authorization_servers and the resource name

Valid token, audience equal to the endpoint URL

200

Session created, tools listed

Valid signature but audience https://other.example/api

401

invalid_token, the aud claim is not valid

Valid token with the audience set to the host only (no /mcp)

401

The audience must be the endpoint’s own URL

Origin header not in the allowed list

403

Invalid Origin header, in a JSON-RPC error body

Host header not in the allowed list

421

Invalid Host header

Request without an Origin header

200

Non-browser clients send none, and the check does not apply to them

The audience deserves attention. A token is only useful to the server it was issued for, so validateAudienceClaim(true) makes the server reject tokens whose aud is another resource, which stops a token stolen from another service from working here. The expected audience is derived from the request URL. In the run, sending the same valid token with a different Host header failed the audience check, and the resource metadata advertised that host too. Behind a reverse proxy the server therefore has to see the public host and scheme, so configure forwarded-header handling (Spring Boot’s server.forward-headers-strategy) and make the proxy set them, or every valid token will be rejected. allowedOrigins and allowedHosts protect against DNS-rebinding attacks from a browser: list the origins of the web tools that may call the server, and the host names it is served under.

The @PreAuthorize scope checks of the tools are the second layer. Authentication answers who is calling and for which resource; the scope answers what they may do. Map your authorization server’s scopes to the tool families (docs:read for search and read, issues:draft for the guarded tool) and keep the list short. Never forward the caller’s token to another API from a tool; a tool that needs GitHub or a database uses credentials of its own. Details of the OAuth roles, resource indicators and the flow are on Authorization and MCP Security; the general OAuth material is under OAuth Reference (for example Discovery, Metadata & Client Registration), and Spring Security’s model is on Spring Security.

Limits of the community module (version 0.1.14, checked against the jar): it supports the servlet stack only (no WebFlux server), only STREAMABLE and STATELESS (not SSE), and JWT access tokens (not opaque tokens). Its documentation in the Spring AI reference is marked as work in progress, and one of its examples names a McpResourceServerConfigurer class that is not in the 0.1.14 jar; the class to use is McpServerOAuth2Configurer, as above. It also offers a sessionBinding customizer on the configurers and an authorization-server module (mcp-authorization-server) that adds dynamic client registration to Spring Authorization Server; read the project’s documentation before relying on them. For an existing enterprise authorization server, the resource-server side above is all you need.

API keys for machine clients

A CI job or a script that calls the server without a person present can use an API key instead. The module’s McpApiKeyConfigurer reads the key from a header (by default X-API-key, in the form <id>.<secret>) and validates it against an ApiKeyEntityRepository.

McpApiKeySecurityConfig.java: an API key for one CI client
package com.example.docsassistant;

import java.util.List;

import org.springaicommunity.mcp.security.server.apikey.memory.ApiKeyEntityImpl;
import org.springaicommunity.mcp.security.server.apikey.memory.InMemoryApiKeyEntityRepository;
import org.springaicommunity.mcp.security.server.config.McpApiKeyConfigurer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

/** For machine clients such as a CI job: one API key per client, sent as X-API-key: <id>.<secret>. */
@Configuration(proxyBeanMethods = false)
@EnableWebSecurity
@Profile("apikey")
class McpApiKeySecurityConfig {

    @Bean
    @Order(1) // before the catch-all API chain of SecurityConfig
    SecurityFilterChain mcpApiKeySecurity(HttpSecurity http,
            @Value("${docsassistant.mcp.ci-key-secret}") String ciKeySecret) throws Exception {
        // The secret comes from the environment (DOCSASSISTANT_MCP_CI_KEY_SECRET); the repository stores it hashed.
        // InMemoryApiKeyEntityRepository is meant for samples: implement ApiKeyEntityRepository over your own store.
        var ciKey = ApiKeyEntityImpl.builder().id("ci01").name("CI pipeline").secret(ciKeySecret).build();
        return http
                .securityMatcher("/mcp/**") // only the MCP endpoint
                .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
                .with(McpApiKeyConfigurer.mcpServerApiKey(),
                        apiKey -> apiKey.apiKeyRepository(new InMemoryApiKeyEntityRepository<>(List.of(ciKey))))
                .build();
    }
}

In the run the endpoint answered 401 without the header, 401 for a wrong secret and 200 for the right key. The in-memory repository hashes the secrets with bcrypt and is meant for samples; a real deployment implements ApiKeyEntityRepository over its own store, gives every client its own key and revokes keys individually. The secret is read from docsassistant.mcp.ci-key-secret, that is, from the environment variable DOCSASSISTANT_MCP_CI_KEY_SECRET, never from source. An API key identifies a client, not a user, so tools that act for a person want OAuth.

Testing

Test at three levels, each with a different job.

A JUnit test against a real server

@SpringBootTest on a random port, the MCP Java SDK client and a small in-process authorization server exercise the whole chain: transport, security, method security and the tools. The test below passed on Java 21 (four tests).

DocsMcpServerTest.java: 401s, a reader who cannot draft, a drafter who can
package com.example.docsassistant;

import static org.assertj.core.api.Assertions.assertThat;

import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;

import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import com.sun.net.httpserver.HttpServer;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.Filter;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
        "spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:19100",
        "docsassistant.mcp.allowed-origins=http://localhost:6274",
        "docsassistant.mcp.allowed-hosts=localhost:*" })
@Import(DocsMcpServerTest.StubBeans.class)
class DocsMcpServerTest {

    static final String ISSUER = "http://localhost:19100";
    static final List<IssueDraft> DRAFTS = new CopyOnWriteArrayList<>();

    static HttpServer issuer;
    static RSAKey signingKey;

    @LocalServerPort
    int port;

    @TestConfiguration
    static class StubBeans {

        @Bean
        VectorStore vectorStore() {
            return new VectorStore() {
                @Override public void add(List<Document> documents) { }
                @Override public void delete(List<String> idList) { }
                @Override public void delete(Filter.Expression filterExpression) { }
                @Override public List<Document> similaritySearch(SearchRequest request) {
                    return List.of(Document.builder().text("Matrices support LU, QR and SVD.")
                            .metadata("source_path", "irurueta-algebra/matrices.adoc").score(0.9).build());
                }
                @Override public String getName() { return "stub"; }
            };
        }

        @Bean
        IssueApprovalQueue approvals() {
            return draft -> {
                DRAFTS.add(draft);
                return "D" + DRAFTS.size();
            };
        }
    }

    /** A minimal authorization server: discovery document and signing keys, all a resource server needs. */
    @BeforeAll
    static void startIssuer() throws Exception {
        signingKey = new RSAKeyGenerator(2048).keyID("test").generate();
        String jwks = new JWKSet(signingKey.toPublicJWK()).toString();
        String discovery = "{\"issuer\":\"" + ISSUER + "\",\"jwks_uri\":\"" + ISSUER + "/jwks\"}";
        issuer = HttpServer.create(new InetSocketAddress("localhost", 19100), 0);
        issuer.createContext("/jwks", exchange -> respond(exchange, jwks));
        issuer.createContext("/.well-known", exchange -> respond(exchange, discovery));
        issuer.start();
    }

    @AfterAll
    static void stopIssuer() {
        issuer.stop(0);
    }

    private static void respond(com.sun.net.httpserver.HttpExchange exchange, String json) throws java.io.IOException {
        byte[] body = json.getBytes(StandardCharsets.UTF_8);
        exchange.getResponseHeaders().add("Content-Type", "application/json");
        exchange.sendResponseHeaders(200, body.length);
        exchange.getResponseBody().write(body);
        exchange.close();
    }

    /** The audience is the URL of the MCP endpoint that the token is meant for. */
    private String token(String scopes, String audience) throws Exception {
        JWTClaimsSet claims = new JWTClaimsSet.Builder().issuer(ISSUER).subject("alice").audience(audience)
                .claim("scope", scopes)
                .issueTime(new Date()).expirationTime(new Date(System.currentTimeMillis() + 300_000)).build();
        SignedJWT jwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).keyID("test").build(), claims);
        jwt.sign(new RSASSASigner(signingKey));
        return jwt.serialize();
    }

    private McpSyncClient client(String accessToken) {
        var transport = HttpClientStreamableHttpTransport.builder("http://localhost:" + this.port)
                .endpoint("/mcp")
                .httpRequestCustomizer((request, method, uri, body, context) ->
                        request.header("Authorization", "Bearer " + accessToken))
                .build();
        return McpClient.sync(transport).requestTimeout(Duration.ofSeconds(10)).build();
    }

    private HttpResponse<String> postInitialize(String accessToken) throws Exception {
        var request = HttpRequest.newBuilder(URI.create("http://localhost:" + this.port + "/mcp"))
                .header("Content-Type", "application/json")
                .header("Accept", "application/json, text/event-stream")
                .POST(HttpRequest.BodyPublishers.ofString("""
                        {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25",
                         "capabilities":{},"clientInfo":{"name":"test","version":"1"}}}"""));
        if (accessToken != null) {
            request.header("Authorization", "Bearer " + accessToken);
        }
        return HttpClient.newHttpClient().send(request.build(), HttpResponse.BodyHandlers.ofString());
    }

    @Test
    void requestWithoutTokenIsRejectedAndPointsToTheResourceMetadata() throws Exception {
        HttpResponse<String> response = postInitialize(null);

        assertThat(response.statusCode()).isEqualTo(401);
        assertThat(response.headers().firstValue("WWW-Authenticate").orElse(""))
                .contains("resource_metadata=http://localhost:" + this.port + "/.well-known/oauth-protected-resource/mcp");
    }

    @Test
    void tokenIssuedForAnotherResourceIsRejected() throws Exception {
        String foreign = token("docs:read issues:draft", "https://other.example/api");

        assertThat(postInitialize(foreign).statusCode()).isEqualTo(401);
    }

    @Test
    void readerSeesTheToolsButCannotDraftAnIssue() throws Exception {
        String reader = token("docs:read", "http://localhost:" + this.port + "/mcp");
        try (McpSyncClient client = client(reader)) {
            client.initialize();

            List<String> names = new ArrayList<>(client.listTools().tools().stream().map(Tool::name).toList());
            assertThat(names).contains("searchDocs", "open_issue", "count_hits");

            CallToolResult denied = client.callTool(CallToolRequest.builder("open_issue")
                    .arguments(Map.of("title", "Fix a typo", "body", "See the matrices page")).build());
            assertThat(denied.isError()).isTrue();
            assertThat(DRAFTS).isEmpty();
        }
    }

    @Test
    void drafterGetsADraftOwnedByTheTokenSubject() throws Exception {
        String drafter = token("docs:read issues:draft", "http://localhost:" + this.port + "/mcp");
        try (McpSyncClient client = client(drafter)) {
            client.initialize();

            CallToolResult result = client.callTool(CallToolRequest.builder("open_issue")
                    .arguments(Map.of("title", "Fix a typo", "body", "See the matrices page")).build());

            assertThat(result.isError()).isFalse();
            assertThat(((TextContent) result.content().get(0)).text()).contains("waiting for maintainer approval");
            assertThat(DRAFTS).extracting(IssueDraft::requester).containsExactly("alice");
        }
    }
}

Source: Spring AI: MCP Server Boot Starters for the starter; the in-process authorization server and the assertions are this page’s own.

Three points about the test. The token’s audience is the URL of the random port, so it is minted inside the test method after the port is known. Stub beans replace the vector store and the approval queue, so no database or model is needed. And the assertions on the queue check the important negative: a denied call must leave it empty.

The Inspector

The MCP Inspector is the tool for looking at the server from the outside. Its CLI form is the one to script; it needs a --header for the token and, for this server, the legacy protocol era.

Calling the server with the Inspector CLI (token from the environment)
export MCP_TEST_TOKEN=...   # an access token for this server, from your authorization server

npx @modelcontextprotocol/inspector --cli http://localhost:8080/mcp --transport http \
  --protocol-era legacy --header "Authorization: Bearer ${MCP_TEST_TOKEN}" --method tools/list

npx @modelcontextprotocol/inspector --cli http://localhost:8080/mcp --transport http \
  --protocol-era legacy --header "Authorization: Bearer ${MCP_TEST_TOKEN}" \
  --method tools/call --tool-name open_issue \
  --tool-arg title="Fix a typo" --tool-arg body="See the matrices page"

Source: MCP Inspector.

Against the running server tools/list returned searchDocs, open_issue (with the annotations set above) and count_hits, and tools/call returned the draft text, owned by the token’s subject. With a token that lacked the scope, the CLI printed the error result and its exit reported tool_is_error. The web client (npx @modelcontextprotocol/inspector) shows the same traffic in a browser and can run the OAuth flow; the options, the exit codes and a CI recipe are on Testing & Debugging. The Inspector’s web client needs its own origin in docsassistant.mcp.allowed-origins.

A raw session with curl

When something is wrong at the protocol level, three requests show it: initialize, the initialized notification, and the call.

The Streamable HTTP handshake with curl
INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'

# the Mcp-Session-Id response header identifies the session (STREAMABLE mode): keep it for the next requests
SESSION=$(curl -si http://localhost:8080/mcp -H "Authorization: Bearer ${MCP_TEST_TOKEN}" \
  -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -d "$INIT" \
  | awk -F': ' 'tolower($1)=="mcp-session-id"{print $2}' | tr -d '\r')

curl -s http://localhost:8080/mcp -H "Authorization: Bearer ${MCP_TEST_TOKEN}" -H "Mcp-Session-Id: ${SESSION}" \
  -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'

curl -s http://localhost:8080/mcp -H "Authorization: Bearer ${MCP_TEST_TOKEN}" -H "Mcp-Session-Id: ${SESSION}" \
  -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"searchDocs","arguments":{"query":"matrix inverse"}}}'

The first request prints nothing: the pipeline keeps only the Mcp-Session-Id header in SESSION, and every later request sends it back. Drop the awk step to see the initialize result itself. The response to tools/call in a session comes framed as a server-sent event (event:message followed by a data: line), which is why raw output looks different from plain JSON.

Registering the server with a host

Hosts such as IDE assistants and command-line agents register a remote server by URL and an authentication method; Connecting Hosts shows how, and MCP Client shows the other direction, DocsAssistant consuming a server. Two operational notes apply to any deployment. Give the endpoint its own rate limit and timeouts at the gateway, since a model can call a tool in a loop. And do not switch on spring.ai.mcp.server.expose-mcp-client-tools casually: it re-exposes the tools of the MCP clients of this application, which would make DocsAssistant a proxy that hands its GitHub access to every caller of /mcp.