From d73620e30591e9ff3ecebc97004c5743fe0c4eb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 15:09:20 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01FtpJvZfg4nvLvtzgJTDWpB --- README.md | 1 + mcp-secure/.gitignore | 1 + mcp-secure/README.md | 52 ++++++ .../output/01-no-token-discovery-rejected.txt | 10 ++ .../output/02-read-scope-lookup-succeeds.txt | 10 ++ .../output/03-write-scope-refund-succeeds.txt | 10 ++ .../output/04-both-scopes-both-succeed.txt | 4 + .../output/05-audit-log-both-outcomes.txt | 5 + mcp-secure/output/06-starter-aop-renamed.txt | 19 +++ mcp-secure/pom.xml | 93 ++++++++++ mcp-secure/scripts/run-all.sh | 8 + .../mcpsecure/McpSecureApplication.java | 13 ++ .../mcpsecure/audit/ToolAuditAspect.java | 86 ++++++++++ .../com/ankurm/mcpsecure/domain/Order.java | 16 ++ .../mcpsecure/domain/OrderRepository.java | 48 ++++++ .../ankurm/mcpsecure/domain/OrderStatus.java | 5 + .../mcpsecure/security/DemoJwtIssuer.java | 80 +++++++++ .../mcpsecure/security/McpSecurityConfig.java | 50 ++++++ .../mcpsecure/tools/SecureOrderTools.java | 45 +++++ mcp-secure/src/main/resources/application.yml | 15 ++ .../mcpsecure/McpToolAuthorizationTest.java | 161 ++++++++++++++++++ .../mcpsecure/ToolAuditLoggingTest.java | 91 ++++++++++ .../mcpsecure/support/AuditLogCapture.java | 45 +++++ .../ankurm/mcpsecure/support/Transcript.java | 47 +++++ 24 files changed, 915 insertions(+) create mode 100644 mcp-secure/.gitignore create mode 100644 mcp-secure/README.md create mode 100644 mcp-secure/output/01-no-token-discovery-rejected.txt create mode 100644 mcp-secure/output/02-read-scope-lookup-succeeds.txt create mode 100644 mcp-secure/output/03-write-scope-refund-succeeds.txt create mode 100644 mcp-secure/output/04-both-scopes-both-succeed.txt create mode 100644 mcp-secure/output/05-audit-log-both-outcomes.txt create mode 100644 mcp-secure/output/06-starter-aop-renamed.txt create mode 100644 mcp-secure/pom.xml create mode 100755 mcp-secure/scripts/run-all.sh create mode 100644 mcp-secure/src/main/java/com/ankurm/mcpsecure/McpSecureApplication.java create mode 100644 mcp-secure/src/main/java/com/ankurm/mcpsecure/audit/ToolAuditAspect.java create mode 100644 mcp-secure/src/main/java/com/ankurm/mcpsecure/domain/Order.java create mode 100644 mcp-secure/src/main/java/com/ankurm/mcpsecure/domain/OrderRepository.java create mode 100644 mcp-secure/src/main/java/com/ankurm/mcpsecure/domain/OrderStatus.java create mode 100644 mcp-secure/src/main/java/com/ankurm/mcpsecure/security/DemoJwtIssuer.java create mode 100644 mcp-secure/src/main/java/com/ankurm/mcpsecure/security/McpSecurityConfig.java create mode 100644 mcp-secure/src/main/java/com/ankurm/mcpsecure/tools/SecureOrderTools.java create mode 100644 mcp-secure/src/main/resources/application.yml create mode 100644 mcp-secure/src/test/java/com/ankurm/mcpsecure/McpToolAuthorizationTest.java create mode 100644 mcp-secure/src/test/java/com/ankurm/mcpsecure/ToolAuditLoggingTest.java create mode 100644 mcp-secure/src/test/java/com/ankurm/mcpsecure/support/AuditLogCapture.java create mode 100644 mcp-secure/src/test/java/com/ankurm/mcpsecure/support/Transcript.java 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 + + 4.0.0-M2 + + 20260625105758 + + + +$ grep -i aspectj ~/.m2/repository/org/springframework/boot/spring-boot-dependencies/4.1.1/spring-boot-dependencies-4.1.1.pom + + 1.9.25.1 + spring-boot-starter-aspectj + 4.1.1 diff --git a/mcp-secure/pom.xml b/mcp-secure/pom.xml new file mode 100644 index 0000000..3ff0615 --- /dev/null +++ b/mcp-secure/pom.xml @@ -0,0 +1,93 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + mcp-secure + 1.0.0 + mcp-secure + 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. + + + 25 + + 2.0.1 + + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + + + org.springframework.ai + spring-ai-starter-mcp-server-webmvc + + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + + + org.springframework.boot + spring-boot-starter-aspectj + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.security + spring-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + -Duser.timezone=UTC -Dstdout.encoding=UTF-8 -Dfile.encoding=UTF-8 + + + + + diff --git a/mcp-secure/scripts/run-all.sh b/mcp-secure/scripts/run-all.sh new file mode 100755 index 0000000..7068247 --- /dev/null +++ b/mcp-secure/scripts/run-all.sh @@ -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 diff --git a/mcp-secure/src/main/java/com/ankurm/mcpsecure/McpSecureApplication.java b/mcp-secure/src/main/java/com/ankurm/mcpsecure/McpSecureApplication.java new file mode 100644 index 0000000..60e78b8 --- /dev/null +++ b/mcp-secure/src/main/java/com/ankurm/mcpsecure/McpSecureApplication.java @@ -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); + } + +} diff --git a/mcp-secure/src/main/java/com/ankurm/mcpsecure/audit/ToolAuditAspect.java b/mcp-secure/src/main/java/com/ankurm/mcpsecure/audit/ToolAuditAspect.java new file mode 100644 index 0000000..80131b3 --- /dev/null +++ b/mcp-secure/src/main/java/com/ankurm/mcpsecure/audit/ToolAuditAspect.java @@ -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. + *

+ * 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 items, BigDecimal total) { + + public Order withStatus(OrderStatus newStatus) { + return new Order(id, customer, newStatus, items, total); + } + +} diff --git a/mcp-secure/src/main/java/com/ankurm/mcpsecure/domain/OrderRepository.java b/mcp-secure/src/main/java/com/ankurm/mcpsecure/domain/OrderRepository.java new file mode 100644 index 0000000..fd3e73c --- /dev/null +++ b/mcp-secure/src/main/java/com/ankurm/mcpsecure/domain/OrderRepository.java @@ -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 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 findById(String id) { + return Optional.ofNullable(orders.get(id)); + } + + public List 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; + } + +} diff --git a/mcp-secure/src/main/java/com/ankurm/mcpsecure/domain/OrderStatus.java b/mcp-secure/src/main/java/com/ankurm/mcpsecure/domain/OrderStatus.java new file mode 100644 index 0000000..f3c3f07 --- /dev/null +++ b/mcp-secure/src/main/java/com/ankurm/mcpsecure/domain/OrderStatus.java @@ -0,0 +1,5 @@ +package com.ankurm.mcpsecure.domain; + +public enum OrderStatus { + PENDING, SHIPPED, DELIVERED, CANCELLED, REFUNDED +} diff --git a/mcp-secure/src/main/java/com/ankurm/mcpsecure/security/DemoJwtIssuer.java b/mcp-secure/src/main/java/com/ankurm/mcpsecure/security/DemoJwtIssuer.java new file mode 100644 index 0000000..8c15265 --- /dev/null +++ b/mcp-secure/src/main/java/com/ankurm/mcpsecure/security/DemoJwtIssuer.java @@ -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 -- run + * one of your own 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_} granted authority), and + * an expiry. + */ + public String issueToken(String subject, Set 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); + } + } + +} diff --git a/mcp-secure/src/main/java/com/ankurm/mcpsecure/security/McpSecurityConfig.java b/mcp-secure/src/main/java/com/ankurm/mcpsecure/security/McpSecurityConfig.java new file mode 100644 index 0000000..a62fd54 --- /dev/null +++ b/mcp-secure/src/main/java/com/ankurm/mcpsecure/security/McpSecurityConfig.java @@ -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. + *

+ * {@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 appender = new ListAppender<>(); + + public AuditLogCapture() { + this.auditLogger = (Logger) LoggerFactory.getLogger("MCP_AUDIT"); + this.appender.start(); + this.auditLogger.addAppender(this.appender); + } + + public List 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); + } + +} diff --git a/mcp-secure/src/test/java/com/ankurm/mcpsecure/support/Transcript.java b/mcp-secure/src/test/java/com/ankurm/mcpsecure/support/Transcript.java new file mode 100644 index 0000000..ac78877 --- /dev/null +++ b/mcp-secure/src/test/java/com/ankurm/mcpsecure/support/Transcript.java @@ -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); + } +}