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.
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
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.
| Dependency | Serves | Notes |
|---|---|---|
|
standard input and output |
No web server. |
|
HTTP on the servlet stack |
The choice of the running example, because DocsAssistant is a servlet application |
|
HTTP on the reactive stack |
Pairs with |
| Mode | What it does | What to know |
|---|---|---|
|
Streamable HTTP with a session: one endpoint, |
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 |
|
Streamable HTTP without a session: each request stands alone |
Scales behind any load balancer. Methods that take |
|
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.
<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.
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:
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));
}
}
Source: Spring AI: MCP Server Boot Starters.
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:readand so on). The call returnedAccess Deniedfor 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 therequesterentry 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.
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.";
}
}
Source: Spring AI: MCP Server Annotations.
Four details in the listing carry the design:
-
The scope check
hasAuthority('SCOPE_issues:draft')lets a token with onlydocs:readlist and search but not draft. In the run such a caller received an error result whose text wasAccess Denied(the Inspector CLI reportedtool_is_error), and nothing reached the approval queue. With@EnableMethodSecuritythe check runs before the method body. -
The requester is the authenticated caller. The name comes from
SecurityContextHolder(the token’s subject,alicein 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 themopen_issuesays 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. -
McpSyncRequestContextgives the method a channel back to the client. It offersinfo,debug,warnanderrorfor log notifications,progress(…),elicit(…)for asking the user a question (checkelicitEnabled()first),sample(…)for asking the client’s model (checksampleEnabled()),roots()andping(). 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:
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();
}
}
Source: Spring AI: MCP Server Annotations.
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.
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();
}
}
Source: Spring AI: MCP Server Annotations.
Run against the stub store, the server answered:
| Request | Result |
|---|---|
|
One template, |
|
The passage text as |
|
One user message that tells the model to use the search tool and cite source paths |
|
|
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.
The module’s configurer wires that exchange into the Spring Security chain.
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();
}
}
Source: Spring AI: MCP Security and Spring AI Community: mcp-security.
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:
| Request | Status | What happened |
|---|---|---|
|
|
|
|
|
The protected resource metadata (RFC 9728): |
Valid token, audience equal to the endpoint URL |
|
Session created, tools listed |
Valid signature but audience |
|
|
Valid token with the audience set to the host only (no |
|
The audience must be the endpoint’s own URL |
|
|
|
|
|
|
Request without an |
|
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.
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();
}
}
Source: Spring AI: MCP Security and Spring AI Community: mcp-security.
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).
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.
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.
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.