Files
Claude d73620e305 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
2026-09-23 15:13:36 +00:00

2.7 KiB

mcp-secure

The mcp-server article's order-lookup tools, behind a real OAuth2 resource server: JWT validation, one scope per tool, unauthenticated tool discovery rejected outright, and every tool call audit-logged through MDC -- including denied calls, not only successful ones.

Companion code for Securing an MCP Server with Spring Security 7 on ankurm.com.

Versions

Component Version
Spring Boot 4.1.1
Spring AI 2.0.1
Spring Security 7.1.1 (managed by the Boot 4.1.1 parent)
Java 25 (LTS)
nimbus-jose-jwt 10.9.1 (pulled in transitively by spring-security-oauth2-jose)

Quickstart

./scripts/run-all.sh

Runs the full test suite against a real, running Spring Boot application on a random port, over the real Streamable HTTP MCP transport, with real signed JWTs -- no mocks, no external Authorization Server. Output lands in output/.

What's here

File What it does
security/DemoJwtIssuer.java Generates one RSA keypair per JVM and mints real signed JWTs against it -- stands in for a real Authorization Server so this module has no external process to run.
security/McpSecurityConfig.java The SecurityFilterChain requiring a valid bearer token on every request, and the JwtDecoder bean validating against DemoJwtIssuer's public key.
tools/SecureOrderTools.java lookup_order behind SCOPE_orders:read, refund_order behind SCOPE_orders:write -- ordinary @PreAuthorize on ordinary @McpTool methods.
audit/ToolAuditAspect.java Logs every tool call's subject, scopes, tool name and outcome through MDC -- ordered to still catch denied calls, see its Javadoc.

Output files

File What it captures
output/01-no-token-discovery-rejected.txt initialize() with no bearer token at all
output/02-read-scope-lookup-succeeds.txt A read-scoped token calling both tools
output/03-write-scope-refund-succeeds.txt A write-scoped token calling both tools
output/04-both-scopes-both-succeed.txt A token with both scopes
output/05-audit-log-both-outcomes.txt Real MDC contents of one successful and one denied call

Requirements

Nothing beyond the JDK and Maven -- no external Authorization Server, no Docker. DemoJwtIssuer keeps the whole thing self-contained; see its Javadoc for what to swap in for production.