Versions. Spring Boot 4.1.1 and Spring AI 2.0.1, on Java 25 (LTS) — the same baseline as the rest of this series. Spring Security is 7.1.1, managed directly by the Boot 4.1.1 parent (checked in its POM, not assumed from the artifact’s own release notes). The JWT library underneath isnimbus-jose-jwt10.9.1, pulled in transitively byspring-security-oauth2-jose. This article assumes tokens come from a real Authorization Server in production — see running your own or validating against one, both already on this site.
A resource server that trusts nobody by default
Addingspring-boot-starter-oauth2-resource-server to a Spring Boot application doesn’t protect anything by itself — it makes JWT validation available. What actually locks the door is a SecurityFilterChain that says every request needs to be authenticated, and a JwtDecoder bean telling Spring Security how to check a token’s signature:
@Bean
public SecurityFilterChain mcpSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
Source: McpSecurityConfig.java. A real resource server points its JwtDecoder at an Authorization Server’s JWKS endpoint with NimbusJwtDecoder.withJwkSetUri(...), fetching real public keys over the network. This article’s companion repo can’t assume you have one of those running, so it mints its own: one RSA keypair, generated once per JVM, that both signs every token the repo’s tests issue and validates every token the running server accepts.
JwtDecoder bean, not a redesign:
/**
* A real resource server points this at an issuer: NimbusJwtDecoder.withJwkSetUri(...)
* or .withIssuerLocation(...), fetching a real JWKS over the network. This module
* validates against the one RSA keypair DemoJwtIssuer generated for this JVM instead.
*/
@Bean
public JwtDecoder jwtDecoder(DemoJwtIssuer issuer) {
return NimbusJwtDecoder.withPublicKey(issuer.publicKey()).build();
}
Source: DemoJwtIssuer.java.
Going deeper on this section
- Companion repo: mcp-secure module
- Run your own Authorization Server: Spring Authorization Server: Running Your Own OAuth2 / OIDC Provider
- Validate against a real IdP: Spring Security OAuth2 Resource Server: JWT Validation
No anonymous tool discovery
The MCP server article showed a client callingtools/list before ever calling a tool — that’s how MCP Inspector and Claude Desktop both work, and it’s exactly the request this article’s SecurityFilterChain refuses to let through unauthenticated. authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) covers the whole application, including the handshake:
client.initialize() with no bearer token threw:
java.lang.RuntimeException: Client failed to initialize by explicit API call
caused by [0]: io.modelcontextprotocol.client.transport.McpHttpClientTransportAuthorizationException: Authorization error when sending message
Output: 01-no-token-discovery-rejected.txt. There’s no partial handshake to inspect here — the HTTP request carrying the MCP initialize call never reaches the DispatcherServlet, because Spring Security’s filter chain runs first and returns 401 on its own. A client with no token, or a token signed by the wrong key, learns nothing about this server: not its name, not its tool count, not a single tool’s description.
Why this matters more for MCP than for a typical REST API. A tool’s description is itself information — “refund_order: refund an order by ID” tells an attacker a refund endpoint exists before they’ve proven anything. Rejecting discovery, not just execution, is a small config decision with an outsized effect on what an unauthenticated caller can learn about a system just by asking it politely.
Going deeper on this section
- Companion repo: McpToolAuthorizationTest.java, the no-token test
- Related: Build an MCP Server with Spring AI 2.0 — the unprotected version this article secures
One scope per tool, with the annotation you already know
Nothing about mapping a scope to an MCP tool needs an MCP-specific mechanism.@McpTool marks a method as a tool; @PreAuthorize is the ordinary Spring Security method-security annotation, and the two stack on the same method exactly the way @PreAuthorize stacks on any other bean method:
@McpTool(name = "lookup_order",
description = "Look up a single order by its ID and return its customer, status, items, and total.")
@PreAuthorize("hasAuthority('SCOPE_orders:read')")
public Order lookupOrder(String orderId) { ... }
@McpTool(name = "refund_order",
description = "Refund an order by ID, marking it REFUNDED.")
@PreAuthorize("hasAuthority('SCOPE_orders:write')")
public Order refundOrder(String orderId) { ... }
Source: SecureOrderTools.java. SCOPE_orders:read is not a made-up string: Spring Security’s default JwtGrantedAuthoritiesConverter reads a token’s scope (or scp) claim, splits it on spaces, and prefixes each entry with SCOPE_ to produce a granted authority — confirmed directly in the class’s bytecode, not assumed from its Javadoc. A token minted with {"scope": "orders:read orders:write"} becomes two authorities, and hasAuthority('SCOPE_orders:read') checks for one of them.
Calling both tools with a token that only carries orders:read:
lookup_order (scope orders:read present):
isError = false
text = {"id":"ORD-1001","customer":"Priya Nair","status":"SHIPPED","items":["Mechanical keyboard","USB-C cable"],"total":4899.00}
refund_order (scope orders:write absent):
isError = true
text = Access Denied
Access Denied
Output: 02-read-scope-lookup-succeeds.txt. A write-scoped token flips the outcome — refund_order succeeds, lookup_order is denied:
refund_order (scope orders:write present):
isError = false
text = {"id":"ORD-1002","customer":"Rohan Mehta","status":"REFUNDED","items":["27\" monitor"],"total":18999.00}
lookup_order (scope orders:read absent):
isError = true
text = Access Denied
Access Denied
Output: 03-write-scope-refund-succeeds.txt. And a token with both scopes does both:
lookup_order: isError=false text={"id":"ORD-1003","customer":"Ankita Rao","status":"DELIVERED","items":["Laptop stand","Wireless mouse","USB hub"],"total":3450.00}
refund_order: isError=false text={"id":"ORD-1003","customer":"Ankita Rao","status":"REFUNDED","items":["Laptop stand","Wireless mouse","USB hub"],"total":3450.00}
Output: 04-both-scopes-both-succeed.txt. This only works at all because of something worth stating plainly: the bean the MCP server autoconfiguration invokes through reflection is the Spring-proxied bean from the application context, the same proxy method security always relies on for any other @Service. That isn’t guaranteed by MCP’s design — it would break silently if the autoconfiguration ever grabbed a raw, unproxied instance instead — which is exactly why this article’s tests call the real running server over real HTTP instead of asserting it from reading the source.
Notice the duplicated text. Both denial responses above readAccess Denied\nAccess Denied, notAccess Deniedonce. That’s not a copy-paste mistake in this article — it’s what the server actually returns, and the next section explains exactly why.
Going deeper on this section
- Companion repo: SecureOrderTools.java
- Official reference: Spring Security – Method Security
What a denied tool call actually returns, and why the message repeats
The MCP server article established that a failing tool call comes back as HTTP200 with isError:true, never an HTTP error status. A @PreAuthorize denial follows the exact same shape — it’s a tool-level failure, not a protocol-level or transport-level one, because the exception is thrown from inside the same reflective method invocation any other tool exception would come from.
What’s specific to this exception is the doubled text. AbstractSyncMcpToolMethodCallback.createSyncErrorResult(Exception), inside spring-ai-mcp-annotations-2.0.1.jar (not part of this article’s own companion repo, but decompiled from the real jar this repo depends on, not read from a changelog), builds the tool’s error content like this:
// Roughly what the bytecode does:
String text = exception.getMessage() + System.lineSeparator() + rootCause(exception).getMessage();
return CallToolResult.builder().isError(true).addTextContent(text).build();
The method assumes a caught exception usually wraps a more specific root cause — a service exception wrapping a database error, say — and prints both messages so nothing gets lost. @PreAuthorize‘s denial throws AuthorizationDeniedException directly, with no wrapped cause: exception and rootCause(exception) resolve to the same object, so its one message, "Access Denied", gets concatenated with itself. Nothing is wrong; the same formatting logic that would usefully show two different messages for a different kind of failure just has nothing different to show here.
Going deeper: AuthorizationDeniedException, not AccessDeniedException
Code written to catch the older, more familiar org.springframework.security.access.AccessDeniedException around a method-security check still works — Spring Security 7’s @PreAuthorize interceptor actually throws org.springframework.security.authorization.AuthorizationDeniedException, a subclass, confirmed by disassembling its class file. This module’s own audit aspect (next section) deliberately catches on the parent type for exactly that reason: matching only the subclass would silently stop classifying denials correctly the moment a differently-shaped authorization failure came through the older type.
Going deeper on this section
- Companion repo: McpToolAuthorizationTest.java
- Related: Build an MCP Server with Spring AI 2.0 — the isError:true finding this section builds on
Auditing every call, including the ones that get denied
A tool-call audit log that only records successes is worse than no audit log at all — the calls worth investigating are disproportionately the denied ones. Logging every@McpTool invocation through MDC is one @Aspect:
@Around("@annotation(org.springframework.ai.mcp.annotation.McpTool)")
public Object audit(ProceedingJoinPoint joinPoint) throws Throwable {
String tool = mcpToolName(joinPoint);
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
MDC.put("mcp.tool", tool);
MDC.put("mcp.subject", authentication.getName());
MDC.put("mcp.scopes", authentication.getAuthorities().toString());
try {
Object result = joinPoint.proceed();
MDC.put("mcp.outcome", "success");
log.info("mcp tool call");
return result;
}
catch (Throwable t) {
MDC.put("mcp.outcome", t instanceof AccessDeniedException ? "denied" : "error");
MDC.put("mcp.exception", t.getClass().getSimpleName());
log.warn("mcp tool call failed");
throw t;
}
finally {
MDC.clear();
}
}
Source: ToolAuditAspect.java. Whether this actually logs a denied call at all turns out to depend on one number most Spring Security users never look at.
AuthorizationInterceptorsOrder constant’s getOrder() against this exact dependency version shows PRE_AUTHORIZE = 200; an @Aspect with no explicit @Order gets no guarantee of running outside that interceptor, and a denied call could end up never reaching this aspect’s catch block at all — the audit log would be silently blind to exactly the calls it exists to catch. Pinning ToolAuditAspect to @Order(150), between PRE_FILTER (100) and PRE_AUTHORIZE (200), places it outside the authorization check on purpose.
A real Logback appender attached to the real MCP_AUDIT logger, reading back the real MDC contents of one successful call and one denied one:
event 1: mcp tool call {mcp.outcome=success, mcp.scopes=[SCOPE_orders:read, FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=2026-09-23T15:12:38.937894021Z]], mcp.subject=audit-test-user, mcp.tool=lookup_order}
event 2: mcp tool call failed {mcp.exception=AuthorizationDeniedException, mcp.outcome=denied, mcp.scopes=[FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=2026-09-23T15:12:38.949708690Z], SCOPE_orders:read], mcp.subject=audit-test-user, mcp.tool=refund_order}
Output: 05-audit-log-both-outcomes.txt. Both events logged, the denial correctly tagged mcp.outcome=denied with the real exception class name attached — and mcp.tool reads lookup_order, the MCP-facing tool name, not the Java method name lookupOrder, because the aspect reads it off the @McpTool annotation itself rather than off the join point’s method signature.
Going deeper: FactorGrantedAuthority and Spring Security’s authentication factors
Neither authority list above was written by hand — both came straight out of a real Authentication object, and both carry a FactorGrantedAuthority [authority=FACTOR_BEARER, ...] entry alongside the expected SCOPE_orders:read. Spring Security’s JWT bearer authentication now automatically tags a successful authentication with which authentication factor produced it — part of Spring Security’s step-up/multi-factor authorization support, where a @PreAuthorize expression can require not just a scope but a specific factor and a recency window on it. This article doesn’t use that feature, but any code inspecting getAuthorities() directly (rather than going through hasAuthority(...)) should expect to see it mixed in.
Going deeper on this section
- Companion repo: ToolAuditAspect.java and its regression test, ToolAuditLoggingTest.java
- Official reference: Spring Security – Authorization Interceptors Order
A starter that quietly stopped existing
Wiring@Aspect classes into a fresh Spring Boot 4.1 project by memory reaches for spring-boot-starter-aop, the same artifact that has worked since Boot 1.x. It doesn’t resolve:
[ERROR] 'dependencies.dependency.version' for org.springframework.boot:spring-boot-starter-aop:jar is missing.
Checking Maven Central’s metadata for the artifact directly shows why: the last version published is 4.0.0-M2 — a milestone, not even a GA release. Spring Boot 4.0 renamed it to spring-boot-starter-aspectj, confirmed by grepping spring-boot-dependencies-4.1.1.pom for every AspectJ-related coordinate it manages:
<aspectj.version>1.9.25.1</aspectj.version>
<artifactId>spring-boot-starter-aspectj</artifactId>
<version>4.1.1</version>
Full transcript, including the exact commands run: 06-starter-aop-renamed.txt. Nothing else about using it changed — the dependency still exists, still pulls in the same AspectJ weaver jar, still makes @Aspect/@Pointcut syntax parseable under ordinary proxy-based Spring AOP with no actual weaving involved. Only the coordinate moved.
If you’re upgrading an older project to Boot 4.x and it declares spring-boot-starter-aop explicitly (rather than getting AOP support transitively from something like Spring Data or Spring Security’s method security, which don’t need this starter at all), this is a silent break: the dependency simply won’t resolve against the 4.1.1 BOM, with an error that says nothing about a rename.
Going deeper on this section
- Companion repo: pom.xml, with the full comment explaining the swap
Should you protect an MCP server this way?
Everything in this article covers authentication and coarse-grained authorization — who can call this server at all, and which named scope a given call needs. It does not cover a harder problem this series has not addressed yet: a model deciding, on its own, which tool to call and with what arguments, entirely within the scopes it was legitimately granted. A token scoped toorders:write can refund any order it can name, not just ones some higher-level business rule would allow; OAuth2 scopes are a coarse, static permission boundary, not a row-level or business-rule authorization system.
Should you even ship a write-capable tool at all? Arefund_ordertool, once registered, is one confidently-worded prompt away from being called. Scoping it behindorders:writestops an under-scoped caller; it does nothing to stop a properly-scoped caller from refunding the wrong order because a model reasoned its way there. For anything genuinely destructive, a human-in-the-loop confirmation step above the tool layer is worth more than another scope.
Every Spring AI article on this site
| Article | Covers |
|---|---|
| Spring AI 2.0 in 10 Minutes: ChatClient on Spring Boot 4.1 | the beginner-level ChatClient build this article’s versions and setup follow |
| Build an MCP Server with Spring AI 2.0 | the unsecured order-lookup server this article puts a resource server in front of |
| Spring AI MCP Client: Calling External MCP Servers from ChatClient | the client side of MCP — ChatClient calling tools from real external servers over stdio |
| Spring AI 1.x to 2.0: The Migration Guide | what breaks, and what breaks silently, upgrading an existing 1.x application |
| Production-Grade RAG with Spring AI | chunking, ingestion, retrieval, reranking, and a faithfulness check against pgvector |
| Spring AI RAG in Java: Complete Code Tour | the same RAG project, file by file |
| Vector Embeddings and Semantic Search in Pure Java | the mechanics of embeddings and cosine similarity, without Spring AI |
Further reading
- Companion repository for this article: asmhatre/spring-ai, mcp-secure module
- Run your own Authorization Server: Spring Authorization Server: Running Your Own OAuth2 / OIDC Provider
- Validate against a real IdP: Spring Security OAuth2 Resource Server: JWT Validation
- Official reference: Spring Security – OAuth2 Resource Server JWT
- Official reference: Spring Security – Method Security
- Official reference: Model Context Protocol Specification
No Comments yet!