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,10 @@
# Calling initialize() with no Authorization header at all
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
Spring Security's filter chain rejects the request with HTTP 401 before the
DispatcherServlet ever hands it to the MCP server -- there is no 'anonymous tools/list'
response to intercept, because the initialize handshake itself never completes.
@@ -0,0 +1,10 @@
# A token with only orders:read calling lookup_order, then refund_order
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
@@ -0,0 +1,10 @@
# A token with only orders:write calling refund_order, then lookup_order
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
@@ -0,0 +1,4 @@
# A token with both orders:read and orders:write calling both tools
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}
@@ -0,0 +1,5 @@
# MDC contents of the real MCP_AUDIT log events for a successful call and a 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}
@@ -0,0 +1,19 @@
# Confirming spring-boot-starter-aop no longer resolves against Boot 4.1.1, and the renamed replacement
$ sed pom.xml to use spring-boot-starter-aop, then: mvn dependency:resolve
[ERROR] 'dependencies.dependency.version' for org.springframework.boot:spring-boot-starter-aop:jar is missing.
$ curl -s https://repo1.maven.org/maven2/org/springframework/boot/spring-boot-starter-aop/maven-metadata.xml | tail -5
<version>4.0.0-M2</version>
</versions>
<lastUpdated>20260625105758</lastUpdated>
</versioning>
</metadata>
$ grep -i aspectj ~/.m2/repository/org/springframework/boot/spring-boot-dependencies/4.1.1/spring-boot-dependencies-4.1.1.pom
<aspectj.version>1.9.25.1</aspectj.version>
<artifactId>spring-boot-starter-aspectj</artifactId>
<version>4.1.1</version>