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:
@@ -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/).
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
target/
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -0,0 +1,93 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>mcp-secure</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>mcp-secure</name>
|
||||
<description>The mcp-server module's order-lookup tools, behind an OAuth2 resource server: JWT validation, a scope per tool, unauthenticated discovery rejected, and every call audit-logged through MDC.</description>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
<!-- Spring AI is not managed by the Spring Boot BOM: this pair is yours to keep compatible. -->
|
||||
<spring-ai.version>2.0.1</spring-ai.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-bom</artifactId>
|
||||
<version>${spring-ai.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- @EnableMethodSecurity's @PreAuthorize enforcement, and the audit-log aspect, both need
|
||||
AspectJ's pointcut expression parser on the classpath even under plain proxy-based AOP
|
||||
(no weaving happens, but AnnotationAwareAspectJAutoProxyCreator still needs the parser
|
||||
to read @Aspect/@Pointcut syntax). In Spring Boot 4.0 the starter that supplies this was
|
||||
renamed: spring-boot-starter-aop no longer exists past 4.0.0-M2. It is
|
||||
spring-boot-starter-aspectj now. See the article's "going deeper" note. -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-aspectj</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<argLine>-Duser.timezone=UTC -Dstdout.encoding=UTF-8 -Dfile.encoding=UTF-8</argLine>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerates output/01 through output/05 (the test suite writes these itself, overwriting
|
||||
# each file in place). output/06-starter-aop-renamed.txt is a separately captured investigation
|
||||
# transcript, not test output -- its own header says how to reproduce it.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
rm -rf target
|
||||
mvn -q -o test
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<ILoggingEvent> appender = new ListAppender<>();
|
||||
|
||||
public AuditLogCapture() {
|
||||
this.auditLogger = (Logger) LoggerFactory.getLogger("MCP_AUDIT");
|
||||
this.appender.start();
|
||||
this.auditLogger.addAppender(this.appender);
|
||||
}
|
||||
|
||||
public List<ILoggingEvent> events() {
|
||||
return List.copyOf(this.appender.list);
|
||||
}
|
||||
|
||||
public String describe(ILoggingEvent event) {
|
||||
return event.getMessage() + " " + event.getMDCPropertyMap().entrySet().stream()
|
||||
.sorted(java.util.Map.Entry.comparingByKey())
|
||||
.map(e -> e.getKey() + "=" + e.getValue())
|
||||
.collect(Collectors.joining(", ", "{", "}"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
this.auditLogger.detachAppender(this.appender);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.ankurm.mcpsecure.support;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Writes a numbered transcript under {@code output/} and echoes it to the console. Every console
|
||||
* block quoted in the article comes out of one of these files verbatim.
|
||||
*/
|
||||
public final class Transcript implements AutoCloseable {
|
||||
|
||||
private final Path path;
|
||||
private final StringWriter buffer = new StringWriter();
|
||||
private final PrintWriter out = new PrintWriter(buffer);
|
||||
|
||||
public Transcript(String fileName, String title) {
|
||||
this.path = Path.of("output", fileName);
|
||||
out.println("# " + title);
|
||||
out.println();
|
||||
}
|
||||
|
||||
public Transcript line(String format, Object... args) {
|
||||
out.println(args.length == 0 ? format : String.format(format, args));
|
||||
return this;
|
||||
}
|
||||
|
||||
public Transcript blank() {
|
||||
out.println();
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
out.flush();
|
||||
try {
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, buffer.toString());
|
||||
}
|
||||
catch (IOException e) {
|
||||
throw new IllegalStateException("could not write " + path, e);
|
||||
}
|
||||
System.out.print(buffer);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user