Add mcp-secure module: OAuth2 resource server, per-tool scopes, and MDC audit logging for an MCP server

- JWT bearer authentication via spring-boot-starter-oauth2-resource-server, validated against
  an RSA keypair DemoJwtIssuer generates and signs with locally, so the whole module runs and
  tests deterministically with no external Authorization Server.
- @PreAuthorize on @McpTool methods maps SCOPE_orders:read / SCOPE_orders:write to lookup_order
  and refund_order -- confirmed empirically that method security actually applies to a bean the
  MCP server autoconfiguration invokes via reflection, since it invokes the Spring-proxied bean.
- SecurityFilterChain requires authentication on every request, so tool discovery (initialize/
  tools-list) is rejected before it ever reaches the MCP dispatcher -- no anonymous tool listing.
- ToolAuditAspect logs every tool call through MDC (subject, scopes, tool, outcome), pinned to
  @Order(150) -- between AuthorizationInterceptorsOrder.PRE_FILTER (100) and PRE_AUTHORIZE (200)
  -- so it wraps @PreAuthorize's interceptor and still logs denied calls, not only successful
  ones. Verified with a real Logback ListAppender reading back real MDC contents.

Two real findings worth a note: Spring Boot 4.0 renamed spring-boot-starter-aop to
spring-boot-starter-aspectj (the old artifact stops existing after 4.0.0-M2); and Spring AI's
AbstractSyncMcpToolMethodCallback.createSyncErrorResult concatenates an exception's message with
its root cause's message, which duplicates the text when they're the same exception -- visible
directly in the captured output when @PreAuthorize denies a call ("Access Denied\nAccess Denied").

5/5 tests pass against a real running server over real Streamable HTTP, with real signed JWTs.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FtpJvZfg4nvLvtzgJTDWpB
This commit is contained in:
Claude
2026-09-23 15:13:36 +00:00
parent 60849f4319
commit d73620e305
24 changed files with 915 additions and 0 deletions
@@ -0,0 +1,13 @@
package com.ankurm.mcpsecure;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class McpSecureApplication {
public static void main(String[] args) {
SpringApplication.run(McpSecureApplication.class, args);
}
}
@@ -0,0 +1,86 @@
package com.ankurm.mcpsecure.audit;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.ai.mcp.annotation.McpTool;
import org.springframework.core.annotation.Order;
import org.springframework.security.authorization.method.AuthorizationInterceptorsOrder;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
/**
* Logs every {@code @McpTool} invocation -- who called it, with what scopes, and whether it
* succeeded, was denied, or threw -- through MDC, so a structured log backend can filter and
* aggregate on {@code mcp.tool}, {@code mcp.subject}, and {@code mcp.outcome} without parsing the
* message text.
* <p>
* <b>Ordering matters here and is not the obvious default.</b> Spring Security's
* {@code @PreAuthorize} interceptor runs at
* {@link AuthorizationInterceptorsOrder#PRE_AUTHORIZE} (order value {@code 200}, confirmed by
* printing every {@code AuthorizationInterceptorsOrder} constant's {@code getOrder()} on this
* exact dependency version). Spring's AOP advisor ordering runs <i>lower</i> numbers
* <i>outermost</i> -- so an aspect with no explicit {@code @Order} does not reliably wrap
* {@code @PreAuthorize}'s interceptor, and a denied call can end up never reaching this aspect's
* {@code catch} block at all, which would make this audit log silently blind to every
* authorization failure -- the exact calls a real audit log exists to catch. Pinning this
* aspect's order to {@code 150} -- between {@code PRE_FILTER} ({@code 100}) and
* {@code PRE_AUTHORIZE} ({@code 200}) -- places it outside the authorization check, so the
* {@code AuthorizationDeniedException} that {@code @PreAuthorize} throws on a denial (a subclass
* of the older, more familiar {@code AccessDeniedException} -- this class's {@code catch} matches
* on that parent type on purpose) still passes through this aspect's {@code catch} on its way
* out. {@code ToolAuditLoggingTest} calls an under-scoped token against {@code refund_order} and
* asserts the denial was logged as {@code denied}, not silently dropped.
*/
@Aspect
@Component
@Order(150)
public class ToolAuditAspect {
private static final Logger log = LoggerFactory.getLogger("MCP_AUDIT");
@Around("@annotation(org.springframework.ai.mcp.annotation.McpTool)")
public Object audit(ProceedingJoinPoint joinPoint) throws Throwable {
String tool = mcpToolName(joinPoint);
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String subject = (authentication != null) ? authentication.getName() : "anonymous";
String scopes = (authentication != null) ? authentication.getAuthorities().toString() : "[]";
MDC.put("mcp.tool", tool);
MDC.put("mcp.subject", subject);
MDC.put("mcp.scopes", scopes);
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 org.springframework.security.access.AccessDeniedException
? "denied" : "error");
MDC.put("mcp.exception", t.getClass().getSimpleName());
log.warn("mcp tool call failed");
throw t;
}
finally {
MDC.clear();
}
}
/**
* The MCP-facing tool name ({@code lookup_order}) rather than the Java method name
* ({@code lookupOrder}) -- that's the name a client, and this log's readers, actually see.
*/
private String mcpToolName(ProceedingJoinPoint joinPoint) {
java.lang.reflect.Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
McpTool annotation = method.getAnnotation(McpTool.class);
return (annotation != null && !annotation.name().isBlank()) ? annotation.name() : method.getName();
}
}
@@ -0,0 +1,16 @@
package com.ankurm.mcpsecure.domain;
import java.math.BigDecimal;
import java.util.List;
/**
* Same shape as the mcp-server article's order type -- this module is about what stands between
* a model and this domain, not the domain itself.
*/
public record Order(String id, String customer, OrderStatus status, List<String> items, BigDecimal total) {
public Order withStatus(OrderStatus newStatus) {
return new Order(id, customer, newStatus, items, total);
}
}
@@ -0,0 +1,48 @@
package com.ankurm.mcpsecure.domain;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.stereotype.Repository;
/**
* Stand-in for a real order-lookup service. Two operations on purpose: one read, one write --
* {@code refund} exists so this article has a tool worth putting behind a stricter scope than
* {@code lookupOrder}.
*/
@Repository
public class OrderRepository {
private final Map<String, Order> orders = new LinkedHashMap<>();
public OrderRepository() {
orders.put("ORD-1001", new Order("ORD-1001", "Priya Nair", OrderStatus.SHIPPED,
List.of("Mechanical keyboard", "USB-C cable"), new BigDecimal("4899.00")));
orders.put("ORD-1002", new Order("ORD-1002", "Rohan Mehta", OrderStatus.PENDING,
List.of("27\" monitor"), new BigDecimal("18999.00")));
orders.put("ORD-1003", new Order("ORD-1003", "Ankita Rao", OrderStatus.DELIVERED,
List.of("Laptop stand", "Wireless mouse", "USB hub"), new BigDecimal("3450.00")));
}
public Optional<Order> findById(String id) {
return Optional.ofNullable(orders.get(id));
}
public List<Order> findAll() {
return List.copyOf(orders.values());
}
public Order refund(String id) {
Order existing = orders.get(id);
if (existing == null) {
throw new IllegalArgumentException("No order with ID " + id);
}
Order refunded = existing.withStatus(OrderStatus.REFUNDED);
orders.put(id, refunded);
return refunded;
}
}
@@ -0,0 +1,5 @@
package com.ankurm.mcpsecure.domain;
public enum OrderStatus {
PENDING, SHIPPED, DELIVERED, CANCELLED, REFUNDED
}
@@ -0,0 +1,80 @@
package com.ankurm.mcpsecure.security;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import java.util.Set;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import org.springframework.stereotype.Component;
/**
* Stands in for a real Authorization Server. A resource server never mints its own tokens in
* production -- <a href="https://ankurm.com/spring-authorization-server-oauth2-oidc-provider/">run
* one of your own</a> or point at a managed IdP, then swap {@link McpSecurityConfig}'s
* {@code JwtDecoder} bean from {@code withPublicKey} to {@code withJwkSetUri}. Keeping this
* module self-contained (no external IdP process, no network call, no wall-clock-sensitive JWKS
* cache) is what makes its tests deterministic: the same RSA keypair signs every token this
* module issues and validates every token this module's {@code JwtDecoder} accepts, generated
* fresh each time the application context starts.
*/
@Component
public class DemoJwtIssuer {
private static final String ISSUER = "https://mcp-secure.demo.ankurm.com";
private final KeyPair keyPair;
public DemoJwtIssuer() {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
this.keyPair = generator.generateKeyPair();
}
catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("RSA not available on this JVM", e);
}
}
public RSAPublicKey publicKey() {
return (RSAPublicKey) keyPair.getPublic();
}
/**
* Mints a real, signed JWT -- subject, one or more scopes as a single space-delimited
* {@code scope} claim (the shape {@link org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter}
* reads by default, turning each entry into a {@code SCOPE_<value>} granted authority), and
* an expiry.
*/
public String issueToken(String subject, Set<String> scopes, Duration ttl) {
try {
Instant now = Instant.now();
JWTClaimsSet claims = new JWTClaimsSet.Builder()
.subject(subject)
.issuer(ISSUER)
.claim("scope", String.join(" ", scopes))
.issueTime(Date.from(now))
.expirationTime(Date.from(now.plus(ttl)))
.build();
SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.RS256), claims);
jwt.sign(new RSASSASigner((RSAPrivateKey) keyPair.getPrivate()));
return jwt.serialize();
}
catch (JOSEException e) {
throw new IllegalStateException("Failed to sign demo JWT", e);
}
}
}
@@ -0,0 +1,50 @@
package com.ankurm.mcpsecure.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.web.SecurityFilterChain;
/**
* Everything under {@code /mcp} -- including the {@code initialize} and {@code tools/list}
* requests a client sends before it ever calls a tool -- requires a valid, signed bearer token.
* There is no anonymous discovery: a client with no token, or an expired or badly-signed one,
* never learns what tools this server exposes.
* <p>
* {@code @EnableMethodSecurity} is what makes {@code @PreAuthorize} on the tool methods in
* {@link com.ankurm.mcpsecure.tools.SecureOrderTools} take effect. See
* {@link com.ankurm.mcpsecure.audit.ToolAuditAspect}'s Javadoc for why that class is ordered
* relative to {@code @PreAuthorize} the way it is.
*/
@Configuration
@EnableMethodSecurity
public class McpSecurityConfig {
@Bean
public SecurityFilterChain mcpSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
// A JSON-RPC API with no browser session and no cookies has nothing for CSRF
// protection to defend -- every request already carries its own bearer token.
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(org.springframework.security.config.http.SessionCreationPolicy.STATELESS));
return http.build();
}
/**
* A real resource server points this at an issuer: {@code NimbusJwtDecoder.withJwkSetUri(...)}
* or {@code .withIssuerLocation(...)}, fetching a real JWKS over the network. This module
* validates against the one RSA keypair {@link DemoJwtIssuer} generated for this JVM instead,
* so the whole thing runs and tests deterministically with no external Authorization Server.
*/
@Bean
public JwtDecoder jwtDecoder(DemoJwtIssuer issuer) {
return NimbusJwtDecoder.withPublicKey(issuer.publicKey()).build();
}
}
@@ -0,0 +1,45 @@
package com.ankurm.mcpsecure.tools;
import org.springframework.ai.mcp.annotation.McpTool;
import org.springframework.ai.mcp.annotation.McpToolParam;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import com.ankurm.mcpsecure.domain.Order;
import com.ankurm.mcpsecure.domain.OrderRepository;
/**
* The same {@code lookup_order} tool from the mcp-server article, plus one write-shaped tool,
* {@code refund_order}, each behind its own scope. {@code @PreAuthorize} here is the ordinary
* Spring Security method-security annotation -- nothing MCP-specific about it. It works because
* the bean the MCP server autoconfiguration calls through is the Spring-proxied bean from the
* application context, the same proxy a {@code @Service} always gets when method security is
* enabled; see {@code McpToolAuthorizationTest} for the run that confirms it.
*/
@Service
public class SecureOrderTools {
private final OrderRepository repository;
public SecureOrderTools(OrderRepository repository) {
this.repository = repository;
}
@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(
@McpToolParam(description = "The order ID, e.g. ORD-1001", required = true) String orderId) {
return repository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("No order with ID " + orderId));
}
@McpTool(name = "refund_order",
description = "Refund an order by ID, marking it REFUNDED. A real deployment would also touch a payments API -- this demo only changes order state.")
@PreAuthorize("hasAuthority('SCOPE_orders:write')")
public Order refundOrder(
@McpToolParam(description = "The order ID to refund, e.g. ORD-1001", required = true) String orderId) {
return repository.refund(orderId);
}
}
@@ -0,0 +1,15 @@
spring:
application:
name: mcp-secure
ai:
mcp:
server:
name: secure-order-server
version: 1.0.0
# See the mcp-server article for why this property has to be set explicitly even though
# the jar's own metadata lists "streamable" as its default.
protocol: streamable
logging:
level:
MCP_AUDIT: INFO