- 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
53 lines
2.7 KiB
Markdown
53 lines
2.7 KiB
Markdown
# mcp-secure
|
|
|
|
The [mcp-server](../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](https://ankurm.com/spring-ai-2-0-mcp-server-security/)
|
|
on [ankurm.com](https://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
|
|
|
|
```bash
|
|
./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`](src/main/java/com/ankurm/mcpsecure/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`](src/main/java/com/ankurm/mcpsecure/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`](src/main/java/com/ankurm/mcpsecure/tools/SecureOrderTools.java) | `lookup_order` behind `SCOPE_orders:read`, `refund_order` behind `SCOPE_orders:write` -- ordinary `@PreAuthorize` on ordinary `@McpTool` methods. |
|
|
| [`audit/ToolAuditAspect.java`](src/main/java/com/ankurm/mcpsecure/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.
|