diff --git a/README.md b/README.md
index 12e677a..503bcde 100644
--- a/README.md
+++ b/README.md
@@ -8,5 +8,6 @@ Runnable companion code for the Spring AI articles on [ankurm.com](https://ankur
| [`rag/`](rag) | Ingest PDFs, chunk, retrieve from pgvector, rerank, answer, check the answer. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Production-grade RAG with Spring AI](https://ankurm.com/production-rag-spring-ai-java/) and [the complete example](https://ankurm.com/spring-ai-rag-complete-example/) |
| [`mcp-server/`](mcp-server) | An order-lookup service exposed as MCP tools, a resource, and a prompt with `@McpTool`/`@McpResource`/`@McpPrompt`, served over Streamable HTTP (Spring AI 2.0's default MCP server transport). Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Build an MCP Server with Spring AI 2.0](https://ankurm.com/spring-ai-2-0-mcp-server-streamable-http/) |
| [`mcp-client/`](mcp-client) | `ChatClient` calling tools from two real external MCP servers (filesystem, git) over stdio via `defaultToolCallbacks(ToolCallbackProvider...)`, contrasted with a local `@Tool` method, with every call logged through one Micrometer `ObservationHandler`. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Spring AI MCP Client: Calling External MCP Servers from ChatClient](https://ankurm.com/spring-ai-2-0-mcp-client/) |
+| [`mcp-secure/`](mcp-secure) | The mcp-server article's order-lookup tools behind a real OAuth2 resource server: JWT validation, one scope per tool via `@PreAuthorize`, unauthenticated tool discovery rejected outright, and every call audit-logged through MDC -- denials included. Spring Boot 4.1.1, Spring AI 2.0.1, Spring Security 7.1.1, Java 25. | [Securing an MCP Server with Spring Security 7](https://ankurm.com/spring-ai-2-0-mcp-server-security/) |
Upgrading from Spring AI 1.x: [migration guide](https://ankurm.com/spring-ai-1-to-2-migration-guide/).
diff --git a/mcp-secure/.gitignore b/mcp-secure/.gitignore
new file mode 100644
index 0000000..2f7896d
--- /dev/null
+++ b/mcp-secure/.gitignore
@@ -0,0 +1 @@
+target/
diff --git a/mcp-secure/README.md b/mcp-secure/README.md
new file mode 100644
index 0000000..90338bd
--- /dev/null
+++ b/mcp-secure/README.md
@@ -0,0 +1,52 @@
+# 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.
diff --git a/mcp-secure/output/01-no-token-discovery-rejected.txt b/mcp-secure/output/01-no-token-discovery-rejected.txt
new file mode 100644
index 0000000..82740e5
--- /dev/null
+++ b/mcp-secure/output/01-no-token-discovery-rejected.txt
@@ -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.
diff --git a/mcp-secure/output/02-read-scope-lookup-succeeds.txt b/mcp-secure/output/02-read-scope-lookup-succeeds.txt
new file mode 100644
index 0000000..e7babb9
--- /dev/null
+++ b/mcp-secure/output/02-read-scope-lookup-succeeds.txt
@@ -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
diff --git a/mcp-secure/output/03-write-scope-refund-succeeds.txt b/mcp-secure/output/03-write-scope-refund-succeeds.txt
new file mode 100644
index 0000000..d549768
--- /dev/null
+++ b/mcp-secure/output/03-write-scope-refund-succeeds.txt
@@ -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
diff --git a/mcp-secure/output/04-both-scopes-both-succeed.txt b/mcp-secure/output/04-both-scopes-both-succeed.txt
new file mode 100644
index 0000000..73ea623
--- /dev/null
+++ b/mcp-secure/output/04-both-scopes-both-succeed.txt
@@ -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}
diff --git a/mcp-secure/output/05-audit-log-both-outcomes.txt b/mcp-secure/output/05-audit-log-both-outcomes.txt
new file mode 100644
index 0000000..4ac8705
--- /dev/null
+++ b/mcp-secure/output/05-audit-log-both-outcomes.txt
@@ -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}
diff --git a/mcp-secure/output/06-starter-aop-renamed.txt b/mcp-secure/output/06-starter-aop-renamed.txt
new file mode 100644
index 0000000..5a7f2b4
--- /dev/null
+++ b/mcp-secure/output/06-starter-aop-renamed.txt
@@ -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
+
+
+ * Ordering matters here and is not the obvious default. 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 lower numbers
+ * outermost -- 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();
+ }
+
+}
diff --git a/mcp-secure/src/main/java/com/ankurm/mcpsecure/domain/Order.java b/mcp-secure/src/main/java/com/ankurm/mcpsecure/domain/Order.java
new file mode 100644
index 0000000..e45f1b8
--- /dev/null
+++ b/mcp-secure/src/main/java/com/ankurm/mcpsecure/domain/Order.java
@@ -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
+ * {@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();
+ }
+
+}
diff --git a/mcp-secure/src/main/java/com/ankurm/mcpsecure/tools/SecureOrderTools.java b/mcp-secure/src/main/java/com/ankurm/mcpsecure/tools/SecureOrderTools.java
new file mode 100644
index 0000000..e29d8b8
--- /dev/null
+++ b/mcp-secure/src/main/java/com/ankurm/mcpsecure/tools/SecureOrderTools.java
@@ -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);
+ }
+
+}
diff --git a/mcp-secure/src/main/resources/application.yml b/mcp-secure/src/main/resources/application.yml
new file mode 100644
index 0000000..7c1fc6d
--- /dev/null
+++ b/mcp-secure/src/main/resources/application.yml
@@ -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
diff --git a/mcp-secure/src/test/java/com/ankurm/mcpsecure/McpToolAuthorizationTest.java b/mcp-secure/src/test/java/com/ankurm/mcpsecure/McpToolAuthorizationTest.java
new file mode 100644
index 0000000..c71391a
--- /dev/null
+++ b/mcp-secure/src/test/java/com/ankurm/mcpsecure/McpToolAuthorizationTest.java
@@ -0,0 +1,161 @@
+package com.ankurm.mcpsecure;
+
+import java.time.Duration;
+import java.util.Map;
+import java.util.Set;
+
+import io.modelcontextprotocol.client.McpClient;
+import io.modelcontextprotocol.client.McpSyncClient;
+import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
+import io.modelcontextprotocol.spec.McpSchema;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+
+import com.ankurm.mcpsecure.security.DemoJwtIssuer;
+import com.ankurm.mcpsecure.support.Transcript;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Drives the real, running, secured MCP server over its real Streamable HTTP transport with the
+ * official MCP Java SDK client -- the same client this series' mcp-server article used, now with
+ * an {@code Authorization} header the transport's {@code httpRequestCustomizer} attaches per
+ * request. Every token here is a real, signed JWT from {@link DemoJwtIssuer}; nothing about
+ * authentication or authorization is mocked.
+ */
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+class McpToolAuthorizationTest {
+
+ @LocalServerPort
+ private int port;
+
+ @Autowired
+ private DemoJwtIssuer issuer;
+
+ private McpSyncClient client;
+
+ @AfterEach
+ void disconnect() {
+ if (client != null) {
+ client.closeGracefully();
+ }
+ }
+
+ private McpSyncClient clientWithToken(String token) {
+ HttpClientStreamableHttpTransport.Builder builder = HttpClientStreamableHttpTransport
+ .builder("http://localhost:" + port)
+ .endpoint("/mcp");
+ if (token != null) {
+ builder.httpRequestCustomizer((requestBuilder, method, uri, body, ctx) ->
+ requestBuilder.header("Authorization", "Bearer " + token));
+ }
+ return McpClient.sync(builder.build()).build();
+ }
+
+ @Test
+ void toolDiscoveryWithNoTokenIsRejectedBeforeItReachesMcp() {
+ try (Transcript t = new Transcript("01-no-token-discovery-rejected.txt",
+ "Calling initialize() with no Authorization header at all")) {
+ client = clientWithToken(null);
+
+ Throwable thrown = org.assertj.core.api.Assertions.catchThrowable(() -> client.initialize());
+
+ t.line("client.initialize() with no bearer token threw:")
+ .line(" %s: %s", thrown.getClass().getName(), thrown.getMessage())
+ .blank();
+ Throwable cause = thrown.getCause();
+ int depth = 0;
+ while (cause != null && depth < 5) {
+ t.line(" caused by [%d]: %s: %s", depth, cause.getClass().getName(), cause.getMessage());
+ cause = cause.getCause();
+ depth++;
+ }
+ t.blank().line("Spring Security's filter chain rejects the request with HTTP 401 before the")
+ .line("DispatcherServlet ever hands it to the MCP server -- there is no 'anonymous tools/list'")
+ .line("response to intercept, because the initialize handshake itself never completes.");
+
+ assertThat(thrown).isNotNull();
+ }
+ }
+
+ @Test
+ void readScopedTokenCanLookupButNotRefund() {
+ String token = issuer.issueToken("test-user-read", Set.of("orders:read"), Duration.ofMinutes(5));
+ try (Transcript t = new Transcript("02-read-scope-lookup-succeeds.txt",
+ "A token with only orders:read calling lookup_order, then refund_order")) {
+ client = clientWithToken(token);
+ client.initialize();
+
+ McpSchema.CallToolResult lookup = client
+ .callTool(new McpSchema.CallToolRequest("lookup_order", Map.of("orderId", "ORD-1001")));
+ t.line("lookup_order (scope orders:read present):")
+ .line(" isError = %s", lookup.isError())
+ .line(" text = %s", ((McpSchema.TextContent) lookup.content().get(0)).text())
+ .blank();
+
+ McpSchema.CallToolResult refund = client
+ .callTool(new McpSchema.CallToolRequest("refund_order", Map.of("orderId", "ORD-1001")));
+ t.line("refund_order (scope orders:write absent):")
+ .line(" isError = %s", refund.isError())
+ .line(" text = %s", ((McpSchema.TextContent) refund.content().get(0)).text());
+
+ assertThat(lookup.isError()).isNotEqualTo(Boolean.TRUE);
+ assertThat(refund.isError()).isEqualTo(Boolean.TRUE);
+ }
+ }
+
+ @Test
+ void writeScopedTokenCanRefundButNotLookup() {
+ String token = issuer.issueToken("test-user-write", Set.of("orders:write"), Duration.ofMinutes(5));
+ try (Transcript t = new Transcript("03-write-scope-refund-succeeds.txt",
+ "A token with only orders:write calling refund_order, then lookup_order")) {
+ client = clientWithToken(token);
+ client.initialize();
+
+ McpSchema.CallToolResult refund = client
+ .callTool(new McpSchema.CallToolRequest("refund_order", Map.of("orderId", "ORD-1002")));
+ t.line("refund_order (scope orders:write present):")
+ .line(" isError = %s", refund.isError())
+ .line(" text = %s", ((McpSchema.TextContent) refund.content().get(0)).text())
+ .blank();
+
+ McpSchema.CallToolResult lookup = client
+ .callTool(new McpSchema.CallToolRequest("lookup_order", Map.of("orderId", "ORD-1002")));
+ t.line("lookup_order (scope orders:read absent):")
+ .line(" isError = %s", lookup.isError())
+ .line(" text = %s", ((McpSchema.TextContent) lookup.content().get(0)).text());
+
+ assertThat(refund.isError()).isNotEqualTo(Boolean.TRUE);
+ assertThat(lookup.isError()).isEqualTo(Boolean.TRUE);
+ }
+ }
+
+ @Test
+ void tokenWithBothScopesCanDoBoth() {
+ String token = issuer.issueToken("test-admin", Set.of("orders:read", "orders:write"), Duration.ofMinutes(5));
+ try (Transcript t = new Transcript("04-both-scopes-both-succeed.txt",
+ "A token with both orders:read and orders:write calling both tools")) {
+ client = clientWithToken(token);
+ client.initialize();
+
+ McpSchema.CallToolResult lookup = client
+ .callTool(new McpSchema.CallToolRequest("lookup_order", Map.of("orderId", "ORD-1003")));
+ McpSchema.CallToolResult refund = client
+ .callTool(new McpSchema.CallToolRequest("refund_order", Map.of("orderId", "ORD-1003")));
+
+ t.line("lookup_order: isError=%s text=%s", lookup.isError(),
+ ((McpSchema.TextContent) lookup.content().get(0)).text())
+ .line("refund_order: isError=%s text=%s", refund.isError(),
+ ((McpSchema.TextContent) refund.content().get(0)).text());
+
+ assertThat(lookup.isError()).isNotEqualTo(Boolean.TRUE);
+ assertThat(refund.isError()).isNotEqualTo(Boolean.TRUE);
+ }
+ }
+
+}
diff --git a/mcp-secure/src/test/java/com/ankurm/mcpsecure/ToolAuditLoggingTest.java b/mcp-secure/src/test/java/com/ankurm/mcpsecure/ToolAuditLoggingTest.java
new file mode 100644
index 0000000..5c673d7
--- /dev/null
+++ b/mcp-secure/src/test/java/com/ankurm/mcpsecure/ToolAuditLoggingTest.java
@@ -0,0 +1,91 @@
+package com.ankurm.mcpsecure;
+
+import java.time.Duration;
+import java.util.Map;
+import java.util.Set;
+
+import ch.qos.logback.classic.spi.ILoggingEvent;
+
+import io.modelcontextprotocol.client.McpClient;
+import io.modelcontextprotocol.client.McpSyncClient;
+import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
+import io.modelcontextprotocol.spec.McpSchema;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+
+import com.ankurm.mcpsecure.security.DemoJwtIssuer;
+import com.ankurm.mcpsecure.support.AuditLogCapture;
+import com.ankurm.mcpsecure.support.Transcript;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Proves {@link com.ankurm.mcpsecure.audit.ToolAuditAspect} actually logs both outcomes -- a
+ * successful call and one {@code @PreAuthorize} denies -- by attaching a real Logback appender to
+ * the real {@code MCP_AUDIT} logger and reading back the real MDC contents of what the aspect
+ * logged, driven by a real running server over real HTTP. See the aspect's Javadoc for why its
+ * {@code @Order} is what makes the denied case reach this test's appender at all.
+ */
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+class ToolAuditLoggingTest {
+
+ @LocalServerPort
+ private int port;
+
+ @Autowired
+ private DemoJwtIssuer issuer;
+
+ private McpSyncClient client;
+
+ @AfterEach
+ void disconnect() {
+ if (client != null) {
+ client.closeGracefully();
+ }
+ }
+
+ @Test
+ void bothSuccessAndDenialAreAuditLogged() {
+ String token = issuer.issueToken("audit-test-user", Set.of("orders:read"), Duration.ofMinutes(5));
+
+ try (Transcript t = new Transcript("05-audit-log-both-outcomes.txt",
+ "MDC contents of the real MCP_AUDIT log events for a successful call and a denied one");
+ AuditLogCapture capture = new AuditLogCapture()) {
+
+ HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport
+ .builder("http://localhost:" + port)
+ .endpoint("/mcp")
+ .httpRequestCustomizer((builder, method, uri, body, ctx) ->
+ builder.header("Authorization", "Bearer " + token))
+ .build();
+ client = McpClient.sync(transport).build();
+ client.initialize();
+
+ client.callTool(new McpSchema.CallToolRequest("lookup_order", Map.of("orderId", "ORD-1001")));
+ client.callTool(new McpSchema.CallToolRequest("refund_order", Map.of("orderId", "ORD-1001")));
+
+ assertThat(capture.events()).hasSize(2);
+
+ ILoggingEvent successEvent = capture.events().get(0);
+ ILoggingEvent deniedEvent = capture.events().get(1);
+
+ t.line("event 1: %s", capture.describe(successEvent)).blank()
+ .line("event 2: %s", capture.describe(deniedEvent));
+
+ assertThat(successEvent.getMDCPropertyMap()).containsEntry("mcp.outcome", "success")
+ .containsEntry("mcp.subject", "audit-test-user");
+ // Spring Security 7's @PreAuthorize interceptor throws AuthorizationDeniedException,
+ // not the plain AccessDeniedException you'd write for a hand-rolled check -- it's a
+ // subclass (see the article), which is why ToolAuditAspect's instanceof check on the
+ // parent type still classifies this correctly as "denied".
+ assertThat(deniedEvent.getMDCPropertyMap()).containsEntry("mcp.outcome", "denied")
+ .containsEntry("mcp.exception", "AuthorizationDeniedException");
+ }
+ }
+
+}
diff --git a/mcp-secure/src/test/java/com/ankurm/mcpsecure/support/AuditLogCapture.java b/mcp-secure/src/test/java/com/ankurm/mcpsecure/support/AuditLogCapture.java
new file mode 100644
index 0000000..a0c7827
--- /dev/null
+++ b/mcp-secure/src/test/java/com/ankurm/mcpsecure/support/AuditLogCapture.java
@@ -0,0 +1,45 @@
+package com.ankurm.mcpsecure.support;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+
+import org.slf4j.LoggerFactory;
+
+/**
+ * Attaches a real Logback {@code ListAppender} to the {@code MCP_AUDIT} logger for the life of
+ * one test, so a test can assert on the MDC contents of the log events {@link
+ * com.ankurm.mcpsecure.audit.ToolAuditAspect} actually produced -- not a mock of the aspect, the
+ * aspect's own real output.
+ */
+public final class AuditLogCapture implements AutoCloseable {
+
+ private final Logger auditLogger;
+ private final ListAppender