From 81a18ed02e50762e908e180bf6fee4d990cf385d Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Mon, 2 Mar 2026 11:31:34 +0100 Subject: [PATCH 01/15] Client Auth Conformance Tests - change parent POM (#827) - Small javaformat fixes - Closes gh-827 Signed-off-by: Daniel Garnier-Moiroux --- .../client-spring-http-client/README.md | 10 +++---- .../client-spring-http-client/pom.xml | 30 +++++++++++++++---- .../configuration/DefaultConfiguration.java | 4 +-- .../client/scenario/DefaultScenario.java | 7 ++--- 4 files changed, 34 insertions(+), 17 deletions(-) diff --git a/conformance-tests/client-spring-http-client/README.md b/conformance-tests/client-spring-http-client/README.md index 876a86e1d..afbf64773 100644 --- a/conformance-tests/client-spring-http-client/README.md +++ b/conformance-tests/client-spring-http-client/README.md @@ -67,7 +67,7 @@ cd conformance-tests/client-spring-http-client This creates an executable JAR at: ``` -target/client-spring-http-client-0.18.0-SNAPSHOT.jar +target/client-spring-http-client-1.1.0-SNAPSHOT.jar ``` ## Running Tests @@ -79,7 +79,7 @@ Run the full auth suite: ```bash npx @modelcontextprotocol/conformance@0.1.15 client \ --spec-version 2025-11-25 \ - --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-0.18.0-SNAPSHOT.jar" \ + --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.0-SNAPSHOT.jar" \ --suite auth ``` @@ -88,7 +88,7 @@ Run a single scenario: ```bash npx @modelcontextprotocol/conformance@0.1.15 client \ --spec-version 2025-11-25 \ - --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-0.18.0-SNAPSHOT.jar" \ + --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.0-SNAPSHOT.jar" \ --scenario auth/metadata-default ``` @@ -97,7 +97,7 @@ Run with verbose output: ```bash npx @modelcontextprotocol/conformance@0.1.15 client \ --spec-version 2025-11-25 \ - --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-0.18.0-SNAPSHOT.jar" \ + --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.0-SNAPSHOT.jar" \ --scenario auth/metadata-default \ --verbose ``` @@ -108,7 +108,7 @@ You can also run the client manually if you have a test server: ```bash export MCP_CONFORMANCE_SCENARIO=auth/metadata-default -java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-0.18.0-SNAPSHOT.jar http://localhost:3000/mcp +java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.0-SNAPSHOT.jar http://localhost:3000/mcp ``` ## Known Issues diff --git a/conformance-tests/client-spring-http-client/pom.xml b/conformance-tests/client-spring-http-client/pom.xml index 94923fb5c..46dae68ef 100644 --- a/conformance-tests/client-spring-http-client/pom.xml +++ b/conformance-tests/client-spring-http-client/pom.xml @@ -4,14 +4,11 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - org.springframework.boot - spring-boot-starter-parent - 4.0.2 - + io.modelcontextprotocol.sdk + conformance-tests + 1.1.0-SNAPSHOT - io.modelcontextprotocol.sdk client-spring-http-client - 1.0.0-SNAPSHOT jar MCP Conformance Tests - Spring HTTP Client Spring HTTP Client conformance tests for the Java MCP SDK @@ -25,10 +22,23 @@ 17 + 4.0.2 2.0.0-M2 true + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + org.springframework.boot @@ -63,6 +73,14 @@ org.springframework.boot spring-boot-maven-plugin + ${spring-boot.version} + + + + repackage + + + diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/DefaultConfiguration.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/DefaultConfiguration.java index acf26d94e..12a9c4a5c 100644 --- a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/DefaultConfiguration.java +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/DefaultConfiguration.java @@ -24,8 +24,8 @@ public class DefaultConfiguration { @Bean DefaultScenario defaultScenario(McpClientRegistrationRepository clientRegistrationRepository, - ServletWebServerApplicationContext serverCtx, - OAuth2AuthorizedClientRepository oAuth2AuthorizedClientRepository) { + ServletWebServerApplicationContext serverCtx, + OAuth2AuthorizedClientRepository oAuth2AuthorizedClientRepository) { return new DefaultScenario(clientRegistrationRepository, serverCtx, oAuth2AuthorizedClientRepository); } diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java index d82637de9..907cea10d 100644 --- a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java @@ -29,8 +29,7 @@ public class DefaultScenario implements Scenario { - private static final Logger log = LoggerFactory - .getLogger(DefaultScenario.class); + private static final Logger log = LoggerFactory.getLogger(DefaultScenario.class); private final ServletWebServerApplicationContext serverCtx; @@ -39,8 +38,8 @@ public class DefaultScenario implements Scenario { private McpSyncClient client; public DefaultScenario(McpClientRegistrationRepository clientRegistrationRepository, - ServletWebServerApplicationContext serverCtx, - OAuth2AuthorizedClientRepository oAuth2AuthorizedClientRepository) { + ServletWebServerApplicationContext serverCtx, + OAuth2AuthorizedClientRepository oAuth2AuthorizedClientRepository) { this.serverCtx = serverCtx; this.authorizedClientManager = new DefaultOAuth2AuthorizedClientManager(clientRegistrationRepository, oAuth2AuthorizedClientRepository); From d4c4a2517fd171a1e3a27641f59be151767e03fa Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Mon, 2 Mar 2026 11:56:39 +0100 Subject: [PATCH 02/15] Fix instructions for running conformance tests - The server-servlet app depends on mcp, which must be either installed or included in the compile artifact for `mvn exec:java` to pick it up. - Change the build instructions to build from root, use a `-pl` flag to target the servlet app. - Disable the exec plugin from the root pom. Signed-off-by: Daniel Garnier-Moiroux --- conformance-tests/VALIDATION_RESULTS.md | 7 +++---- conformance-tests/client-jdk-http-client/README.md | 14 +++++++------- conformance-tests/server-servlet/pom.xml | 1 + pom.xml | 9 +++++++++ 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/conformance-tests/VALIDATION_RESULTS.md b/conformance-tests/VALIDATION_RESULTS.md index 19e74330c..7d2a09726 100644 --- a/conformance-tests/VALIDATION_RESULTS.md +++ b/conformance-tests/VALIDATION_RESULTS.md @@ -78,8 +78,7 @@ Uses the `client-spring-http-client` module with Spring Security OAuth2 and the ### Server ```bash # Start server -cd conformance-tests/server-servlet -../../mvnw compile exec:java -Dexec.mainClass="io.modelcontextprotocol.conformance.server.ConformanceServlet" +./mvnw compile -pl conformance-tests/server-servlet -am exec:java # Run tests (in another terminal) npx @modelcontextprotocol/conformance server --url http://localhost:8080/mcp --suite active @@ -94,7 +93,7 @@ cd conformance-tests/client-jdk-http-client # Run all scenarios for scenario in initialize tools_call elicitation-sep1034-client-defaults sse-retry; do npx @modelcontextprotocol/conformance client \ - --command "java -jar target/client-jdk-http-client-1.0.0-SNAPSHOT.jar" \ + --command "java -jar target/client-jdk-http-client-1.1.0-SNAPSHOT.jar" \ --scenario $scenario done ``` @@ -111,7 +110,7 @@ cd conformance-tests/client-spring-http-client # Run auth suite npx @modelcontextprotocol/conformance@0.1.15 client \ --spec-version 2025-11-25 \ - --command "java -jar target/client-spring-http-client-0.18.0-SNAPSHOT.jar" \ + --command "java -jar target/client-spring-http-client-1.1.0-SNAPSHOT.jar" \ --suite auth ``` diff --git a/conformance-tests/client-jdk-http-client/README.md b/conformance-tests/client-jdk-http-client/README.md index 44eccedf0..ba5f4fed1 100644 --- a/conformance-tests/client-jdk-http-client/README.md +++ b/conformance-tests/client-jdk-http-client/README.md @@ -54,7 +54,7 @@ cd conformance-tests/client-jdk-http-client This creates an executable JAR at: ``` -target/client-jdk-http-client-1.0.0-SNAPSHOT.jar +target/client-jdk-http-client-1.1.0-SNAPSHOT.jar ``` ## Running Tests @@ -65,19 +65,19 @@ Run a single scenario: ```bash npx @modelcontextprotocol/conformance client \ - --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.0.0-SNAPSHOT.jar" \ + --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.0-SNAPSHOT.jar" \ --scenario initialize npx @modelcontextprotocol/conformance client \ - --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.0.0-SNAPSHOT.jar" \ + --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.0-SNAPSHOT.jar" \ --scenario tools_call npx @modelcontextprotocol/conformance client \ - --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.0.0-SNAPSHOT.jar" \ + --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.0-SNAPSHOT.jar" \ --scenario elicitation-sep1034-client-defaults npx @modelcontextprotocol/conformance client \ - --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.0.0-SNAPSHOT.jar" \ + --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.0-SNAPSHOT.jar" \ --scenario sse-retry ``` @@ -85,7 +85,7 @@ Run with verbose output: ```bash npx @modelcontextprotocol/conformance client \ - --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.0.0-SNAPSHOT.jar" \ + --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.0-SNAPSHOT.jar" \ --scenario initialize \ --verbose ``` @@ -96,7 +96,7 @@ You can also run the client manually if you have a test server: ```bash export MCP_CONFORMANCE_SCENARIO=initialize -java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.0.0-SNAPSHOT.jar http://localhost:3000/mcp +java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.1.0-SNAPSHOT.jar http://localhost:3000/mcp ``` ## Test Results diff --git a/conformance-tests/server-servlet/pom.xml b/conformance-tests/server-servlet/pom.xml index 68da42158..66acea835 100644 --- a/conformance-tests/server-servlet/pom.xml +++ b/conformance-tests/server-servlet/pom.xml @@ -65,6 +65,7 @@ 3.1.0 io.modelcontextprotocol.conformance.server.ConformanceServlet + false diff --git a/pom.xml b/pom.xml index 049536e0d..a0ddd2eed 100644 --- a/pom.xml +++ b/pom.xml @@ -263,6 +263,15 @@ maven-deploy-plugin ${maven-deploy-plugin.version} + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + true + ignored + + From ce2c747c9282d4dad47b174a25319e7b2d71553c Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Mon, 2 Mar 2026 12:00:08 +0100 Subject: [PATCH 03/15] Bump spring-javaformat 0.0.43 -> 0.0.47 Signed-off-by: Daniel Garnier-Moiroux --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index a0ddd2eed..b1eedd38e 100644 --- a/pom.xml +++ b/pom.xml @@ -88,7 +88,7 @@ 4.0.0-M13 3.4.5 3.3.0 - 0.0.43 + 0.0.47 1.0.0-alpha.4 0.0.4 1.6.2 From 7f68ea5bc004852e970086f9f4d5b96234496de9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= Date: Mon, 2 Mar 2026 15:26:03 +0100 Subject: [PATCH 04/15] Support resource subscriptions (#839) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clients can now subscribe to specific resources and receive targeted notifications when those resources change. Previously, calling `notifyResourceUpdated` on the server would broadcast the notification to every connected client regardless of interest — now only sessions that have explicitly subscribed to a given resource URI receive the update, making resource change propagation both correct and efficient. The subscription lifecycle is fully handled: clients can subscribe and unsubscribe at any time, and the server cleans up subscription state when a session closes. Supersedes #838. Resolves #837, #776. --------- Signed-off-by: Dariusz Jędrzejczyk --- conformance-tests/VALIDATION_RESULTS.md | 23 +-- conformance-tests/conformance-baseline.yml | 5 - conformance-tests/server-servlet/README.md | 14 +- .../server/McpAsyncServer.java | 98 +++++++++-- ...HttpServletSseServerTransportProvider.java | 19 ++ ...vletStreamableServerTransportProvider.java | 12 ++ .../StdioServerTransportProvider.java | 16 +- ...aultMcpStreamableServerSessionFactory.java | 39 +++- .../spec/McpServerSession.java | 28 ++- .../spec/McpServerTransportProviderBase.java | 17 ++ .../spec/McpStreamableServerSession.java | 31 +++- ...stractMcpClientServerIntegrationTests.java | 89 ++++++++++ .../MockMcpServerTransport.java | 1 + .../MockMcpServerTransportProvider.java | 8 + .../server/ResourceSubscriptionTests.java | 166 ++++++++++++++++++ 15 files changed, 506 insertions(+), 60 deletions(-) create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/server/ResourceSubscriptionTests.java diff --git a/conformance-tests/VALIDATION_RESULTS.md b/conformance-tests/VALIDATION_RESULTS.md index 7d2a09726..8edc7ad71 100644 --- a/conformance-tests/VALIDATION_RESULTS.md +++ b/conformance-tests/VALIDATION_RESULTS.md @@ -2,27 +2,22 @@ ## Summary -**Server Tests:** 37/40 passed (92.5%) +**Server Tests:** 40/40 passed (100%) **Client Tests:** 3/4 scenarios passed (9/10 checks passed) **Auth Tests:** 12/14 scenarios fully passing (178 passed, 1 failed, 1 warning, 85.7% scenarios, 98.9% checks) ## Server Test Results -### Passing (37/40) +### Passing (40/40) - **Lifecycle & Utilities (4/4):** initialize, ping, logging-set-level, completion-complete - **Tools (11/11):** All scenarios including progress notifications ✨ - **Elicitation (10/10):** SEP-1034 defaults (5 checks), SEP-1330 enums (5 checks) -- **Resources (4/6):** list, read-text, read-binary, templates-read +- **Resources (6/6):** list, read-text, read-binary, templates-read, subscribe, unsubscribe - **Prompts (4/4):** list, simple, with-args, embedded-resource, with-image - **SSE Transport (2/2):** Multiple streams - **Security (2/2):** Localhost validation passes, DNS rebinding protection -### Failing (3/40) - -1. **resources-subscribe** - Not implemented in SDK -2. **resources-unsubscribe** - Not implemented in SDK - ## Client Test Results ### Passing (3/4 scenarios, 9/10 checks) @@ -68,10 +63,9 @@ Uses the `client-spring-http-client` module with Spring Security OAuth2 and the ## Known Limitations -1. **Resource Subscriptions:** SDK doesn't implement `resources/subscribe` and `resources/unsubscribe` handlers -2. **Client SSE Retry:** Client doesn't parse or respect the `retry:` field, reconnects immediately, and doesn't send Last-Event-ID header -3. **Auth Scope Step-Up:** Client does not fully handle scope step-up challenges where the server requests additional scopes after initial authorization -4. **Auth Basic CIMD:** Minor conformance warning in the basic Client-Initiated Metadata Discovery flow +1. **Client SSE Retry:** Client doesn't parse or respect the `retry:` field, reconnects immediately, and doesn't send Last-Event-ID header +2. **Auth Scope Step-Up:** Client does not fully handle scope step-up challenges where the server requests additional scopes after initial authorization +3. **Auth Basic CIMD:** Minor conformance warning in the basic Client-Initiated Metadata Discovery flow ## Running Tests @@ -118,6 +112,5 @@ npx @modelcontextprotocol/conformance@0.1.15 client \ ### High Priority 1. Fix client SSE retry field handling in `HttpClientStreamableHttpTransport` -2. Implement resource subscription handlers in `McpStatelessAsyncServer` -3. Implement CIMD -4. Implement scope step up +2. Implement CIMD +3. Implement scope step up diff --git a/conformance-tests/conformance-baseline.yml b/conformance-tests/conformance-baseline.yml index 4ab144063..d2990c155 100644 --- a/conformance-tests/conformance-baseline.yml +++ b/conformance-tests/conformance-baseline.yml @@ -2,11 +2,6 @@ # This file lists known failing scenarios that are expected to fail until fixed. # See: https://github.com/modelcontextprotocol/conformance/blob/main/SDK_INTEGRATION.md -server: - # Resource subscription not implemented in SDK - - resources-subscribe - - resources-unsubscribe - client: # SSE retry field handling not implemented # - Client does not parse or respect retry: field timing diff --git a/conformance-tests/server-servlet/README.md b/conformance-tests/server-servlet/README.md index bd86636b6..ef327ecf6 100644 --- a/conformance-tests/server-servlet/README.md +++ b/conformance-tests/server-servlet/README.md @@ -4,7 +4,7 @@ This module contains a comprehensive MCP (Model Context Protocol) server impleme ## Conformance Test Results -**Status: 37 out of 40 tests passing (92.5%)** +**Status: 40 out of 40 tests passing (100%)** The server has been validated against the official [MCP conformance test suite](https://github.com/modelcontextprotocol/conformance). See [VALIDATION_RESULTS.md](../VALIDATION_RESULTS.md) for detailed results. @@ -22,9 +22,8 @@ The server has been validated against the official [MCP conformance test suite]( - SEP-1034: Default values for all primitive types - SEP-1330: All enum schema variants -✅ **Resources** (4/6) -- List, read text/binary, templates -- ⚠️ Subscribe/unsubscribe (SDK limitation) +✅ **Resources** (6/6) +- List, read text/binary, templates, subscribe, unsubscribe ✅ **Prompts** (4/4) - Simple, parameterized, embedded resources, images @@ -191,12 +190,7 @@ curl -X POST http://localhost:8080/mcp \ ## Known Limitations -See [VALIDATION_RESULTS.md](../VALIDATION_RESULTS.md) for details on: - -1. **Resource Subscriptions** - Not implemented in Java SDK -2. **DNS Rebinding Protection** - Missing Host/Origin validation - -These are SDK-level limitations that require fixes in the core framework. +See [VALIDATION_RESULTS.md](../VALIDATION_RESULTS.md) for details on remaining client-side limitations. ## References diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index 32256987a..b078493ef 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -5,10 +5,12 @@ package io.modelcontextprotocol.server; import java.time.Duration; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; @@ -25,7 +27,6 @@ import io.modelcontextprotocol.spec.McpSchema.CompleteResult.CompleteCompletion; import io.modelcontextprotocol.spec.McpSchema.ErrorCodes; import io.modelcontextprotocol.spec.McpSchema.LoggingLevel; -import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification; import io.modelcontextprotocol.spec.McpSchema.PromptReference; import io.modelcontextprotocol.spec.McpSchema.ResourceReference; import io.modelcontextprotocol.spec.McpSchema.SetLevelRequest; @@ -111,12 +112,10 @@ public class McpAsyncServer { private final ConcurrentHashMap prompts = new ConcurrentHashMap<>(); - // FIXME: this field is deprecated and should be remvoed together with the - // broadcasting loggingNotification. - private LoggingLevel minLoggingLevel = LoggingLevel.DEBUG; - private final ConcurrentHashMap completions = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> resourceSubscriptions = new ConcurrentHashMap<>(); + private List protocolVersions; private McpUriTemplateManagerFactory uriTemplateManagerFactory = new DefaultMcpUriTemplateManagerFactory(); @@ -149,8 +148,11 @@ public class McpAsyncServer { this.protocolVersions = mcpTransportProvider.protocolVersions(); - mcpTransportProvider.setSessionFactory(transport -> new McpServerSession(UUID.randomUUID().toString(), - requestTimeout, transport, this::asyncInitializeRequestHandler, requestHandlers, notificationHandlers)); + mcpTransportProvider.setSessionFactory(transport -> { + String sessionId = UUID.randomUUID().toString(); + return new McpServerSession(sessionId, requestTimeout, transport, this::asyncInitializeRequestHandler, + requestHandlers, notificationHandlers, () -> this.cleanupForSession(sessionId)); + }); } McpAsyncServer(McpStreamableServerTransportProvider mcpTransportProvider, McpJsonMapper jsonMapper, @@ -174,8 +176,9 @@ public class McpAsyncServer { this.protocolVersions = mcpTransportProvider.protocolVersions(); - mcpTransportProvider.setSessionFactory(new DefaultMcpStreamableServerSessionFactory(requestTimeout, - this::asyncInitializeRequestHandler, requestHandlers, notificationHandlers)); + mcpTransportProvider.setSessionFactory( + new DefaultMcpStreamableServerSessionFactory(requestTimeout, this::asyncInitializeRequestHandler, + requestHandlers, notificationHandlers, sessionId -> this.cleanupForSession(sessionId))); } private Map prepareNotificationHandlers(McpServerFeatures.Async features) { @@ -215,6 +218,10 @@ private Map> prepareRequestHandlers() { requestHandlers.put(McpSchema.METHOD_RESOURCES_LIST, resourcesListRequestHandler()); requestHandlers.put(McpSchema.METHOD_RESOURCES_READ, resourcesReadRequestHandler()); requestHandlers.put(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, resourceTemplateListRequestHandler()); + if (Boolean.TRUE.equals(this.serverCapabilities.resources().subscribe())) { + requestHandlers.put(McpSchema.METHOD_RESOURCES_SUBSCRIBE, resourcesSubscribeRequestHandler()); + requestHandlers.put(McpSchema.METHOD_RESOURCES_UNSUBSCRIBE, resourcesUnsubscribeRequestHandler()); + } } // Add prompts API handlers if provider exists @@ -685,12 +692,73 @@ public Mono notifyResourcesListChanged() { } /** - * Notifies clients that the resources have updated. - * @return A Mono that completes when all clients have been notified + * Notifies only the sessions that have subscribed to the updated resource URI. + * @param resourcesUpdatedNotification the notification containing the updated + * resource URI + * @return A Mono that completes when all subscribed sessions have been notified */ public Mono notifyResourcesUpdated(McpSchema.ResourcesUpdatedNotification resourcesUpdatedNotification) { - return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_RESOURCES_UPDATED, - resourcesUpdatedNotification); + return Mono.defer(() -> { + String uri = resourcesUpdatedNotification.uri(); + Set subscribedSessions = this.resourceSubscriptions.get(uri); + if (subscribedSessions == null || subscribedSessions.isEmpty()) { + logger.debug("No sessions subscribed to resource URI: {}", uri); + return Mono.empty(); + } + return Flux.fromIterable(subscribedSessions) + .flatMap(sessionId -> this.mcpTransportProvider + .notifyClient(sessionId, McpSchema.METHOD_NOTIFICATION_RESOURCES_UPDATED, + resourcesUpdatedNotification) + .doOnError(e -> logger.error("Failed to notify session {} of resource update for {}", sessionId, + uri, e)) + .onErrorComplete()) + .then(); + }); + } + + private Mono cleanupForSession(String sessionId) { + return Mono.fromRunnable(() -> { + removeSessionSubscriptions(sessionId); + }); + } + + private void removeSessionSubscriptions(String sessionId) { + this.resourceSubscriptions.forEach((uri, sessions) -> sessions.remove(sessionId)); + this.resourceSubscriptions.entrySet().removeIf(entry -> entry.getValue().isEmpty()); + } + + private McpRequestHandler resourcesSubscribeRequestHandler() { + return (exchange, params) -> Mono.defer(() -> { + McpSchema.SubscribeRequest subscribeRequest = jsonMapper.convertValue(params, + new TypeRef() { + }); + String uri = subscribeRequest.uri(); + String sessionId = exchange.sessionId(); + this.resourceSubscriptions.computeIfAbsent(uri, k -> Collections.newSetFromMap(new ConcurrentHashMap<>())) + .add(sessionId); + logger.debug("Session {} subscribed to resource URI: {}", sessionId, uri); + + return Mono.just(Map.of()); + }); + } + + private McpRequestHandler resourcesUnsubscribeRequestHandler() { + return (exchange, params) -> Mono.defer(() -> { + McpSchema.UnsubscribeRequest unsubscribeRequest = jsonMapper.convertValue(params, + new TypeRef() { + }); + String uri = unsubscribeRequest.uri(); + String sessionId = exchange.sessionId(); + Set sessions = this.resourceSubscriptions.get(uri); + if (sessions != null) { + sessions.remove(sessionId); + if (sessions.isEmpty()) { + this.resourceSubscriptions.remove(uri, sessions); + } + } + logger.debug("Session {} unsubscribed from resource URI: {}", sessionId, uri); + return Mono.just(Map.of()); + }); } private McpRequestHandler resourcesListRequestHandler() { @@ -878,10 +946,6 @@ private McpRequestHandler setLoggerRequestHandler() { exchange.setMinLoggingLevel(newMinLoggingLevel.level()); - // FIXME: this field is deprecated and should be removed together - // with the broadcasting loggingNotification. - this.minLoggingLevel = newMinLoggingLevel.level(); - return Mono.just(Map.of()); }); }; diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java index 7037ff293..d3648a06f 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java @@ -228,6 +228,25 @@ public Mono notifyClients(String method, Object params) { .then(); } + @Override + public Mono notifyClient(String sessionId, String method, Object params) { + return Mono.defer(() -> { + // Need to iterate in O(n) because the transport session id + // is different from the server-logical session id (in streamable http this + // design issue was solved) + McpServerSession session = sessions.values() + .stream() + .filter(s -> sessionId.equals(s.getId())) + .findFirst() + .orElse(null); + if (session == null) { + logger.debug("Session {} not found", sessionId); + return Mono.empty(); + } + return session.sendNotification(method, params); + }); + } + /** * Handles GET requests to establish SSE connections. *

diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java index d7561188c..95edb63a0 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java @@ -206,6 +206,18 @@ public Mono notifyClients(String method, Object params) { }); } + @Override + public Mono notifyClient(String sessionId, String method, Object params) { + return Mono.defer(() -> { + McpStreamableServerSession session = this.sessions.get(sessionId); + if (session == null) { + logger.debug("Session {} not found", sessionId); + return Mono.empty(); + } + return session.sendNotification(method, params); + }); + } + /** * Initiates a graceful shutdown of the transport. * @return A Mono that completes when all cleanup operations are finished diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java index d288ea3d6..79be014a6 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java @@ -98,12 +98,26 @@ public void setSessionFactory(McpServerSession.Factory sessionFactory) { @Override public Mono notifyClients(String method, Object params) { if (this.session == null) { - return Mono.error(new IllegalStateException("No session to close")); + return Mono.error(new IllegalStateException("No session to notify")); } return this.session.sendNotification(method, params) .doOnError(e -> logger.error("Failed to send notification: {}", e.getMessage())); } + @Override + public Mono notifyClient(String sessionId, String method, Object params) { + return Mono.defer(() -> { + if (this.session == null) { + return Mono.error(new IllegalStateException("No session to notify")); + } + if (!this.session.getId().equals(sessionId)) { + return Mono.error(new IllegalStateException("Existing session id " + this.session.getId() + + " doesn't match the notification target: " + sessionId)); + } + return this.session.sendNotification(method, params); + }); + } + @Override public Mono closeGracefully() { if (this.session == null) { diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/DefaultMcpStreamableServerSessionFactory.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/DefaultMcpStreamableServerSessionFactory.java index f497afd43..65da43202 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/DefaultMcpStreamableServerSessionFactory.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/DefaultMcpStreamableServerSessionFactory.java @@ -10,6 +10,9 @@ import java.time.Duration; import java.util.Map; import java.util.UUID; +import java.util.function.Function; + +import reactor.core.publisher.Mono; /** * A default implementation of {@link McpStreamableServerSession.Factory}. @@ -26,29 +29,53 @@ public class DefaultMcpStreamableServerSessionFactory implements McpStreamableSe Map notificationHandlers; + private final Function> onClose; + /** - * Constructs an instance + * Constructs an instance. * @param requestTimeout timeout for requests * @param initRequestHandler initialization request handler * @param requestHandlers map of MCP request handlers keyed by method name * @param notificationHandlers map of MCP notification handlers keyed by method name + * @param onClose reactive callback invoked with the session ID when a session is + * closed */ public DefaultMcpStreamableServerSessionFactory(Duration requestTimeout, McpStreamableServerSession.InitRequestHandler initRequestHandler, - Map> requestHandlers, - Map notificationHandlers) { + Map> requestHandlers, Map notificationHandlers, + Function> onClose) { this.requestTimeout = requestTimeout; this.initRequestHandler = initRequestHandler; this.requestHandlers = requestHandlers; this.notificationHandlers = notificationHandlers; + this.onClose = onClose; + } + + /** + * Constructs an instance. + * @param requestTimeout timeout for requests + * @param initRequestHandler initialization request handler + * @param requestHandlers map of MCP request handlers keyed by method name + * @param notificationHandlers map of MCP notification handlers keyed by method name + * @deprecated Use + * {@link #DefaultMcpStreamableServerSessionFactory(Duration, McpStreamableServerSession.InitRequestHandler, Map, Map, Function)} + * instead + */ + @Deprecated + public DefaultMcpStreamableServerSessionFactory(Duration requestTimeout, + McpStreamableServerSession.InitRequestHandler initRequestHandler, + Map> requestHandlers, + Map notificationHandlers) { + this(requestTimeout, initRequestHandler, requestHandlers, notificationHandlers, sessionId -> Mono.empty()); } @Override public McpStreamableServerSession.McpStreamableServerSessionInit startSession( McpSchema.InitializeRequest initializeRequest) { - return new McpStreamableServerSession.McpStreamableServerSessionInit( - new McpStreamableServerSession(UUID.randomUUID().toString(), initializeRequest.capabilities(), - initializeRequest.clientInfo(), requestTimeout, requestHandlers, notificationHandlers), + String sessionId = UUID.randomUUID().toString(); + return new McpStreamableServerSession.McpStreamableServerSessionInit(new McpStreamableServerSession(sessionId, + initializeRequest.capabilities(), initializeRequest.clientInfo(), requestTimeout, requestHandlers, + notificationHandlers, () -> this.onClose.apply(sessionId)), this.initRequestHandler.handle(initializeRequest)); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java index ecb1dafd8..fc011a4e3 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerSession.java @@ -7,6 +7,7 @@ import java.time.Duration; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; @@ -65,25 +66,47 @@ public class McpServerSession implements McpLoggableSession { private volatile McpSchema.LoggingLevel minLoggingLevel = McpSchema.LoggingLevel.INFO; + private final Supplier> onClose; + /** * Creates a new server session with the given parameters and the transport to use. * @param id session id + * @param requestTimeout duration to wait for request responses before timing out * @param transport the transport to use * @param initHandler called when a * {@link io.modelcontextprotocol.spec.McpSchema.InitializeRequest} is received by the * server * @param requestHandlers map of request handlers to use * @param notificationHandlers map of notification handlers to use + * @param onClose supplier of a reactive callback invoked when the session is closed */ public McpServerSession(String id, Duration requestTimeout, McpServerTransport transport, McpInitRequestHandler initHandler, Map> requestHandlers, - Map notificationHandlers) { + Map notificationHandlers, Supplier> onClose) { this.id = id; this.requestTimeout = requestTimeout; this.transport = transport; this.initRequestHandler = initHandler; this.requestHandlers = requestHandlers; this.notificationHandlers = notificationHandlers; + this.onClose = onClose; + } + + /** + * Creates a new server session with the given parameters and the transport to use. + * @param id session id + * @param requestTimeout duration to wait for request responses before timing out + * @param transport the transport to use + * @param initHandler called when a + * {@link io.modelcontextprotocol.spec.McpSchema.InitializeRequest} is received by the + * server + * @param requestHandlers map of request handlers to use + * @param notificationHandlers map of notification handlers to use + */ + public McpServerSession(String id, Duration requestTimeout, McpServerTransport transport, + McpInitRequestHandler initHandler, Map> requestHandlers, + Map notificationHandlers) { + this(id, requestTimeout, transport, initHandler, requestHandlers, notificationHandlers, Mono::empty); } /** @@ -318,12 +341,13 @@ private MethodNotFoundError getMethodNotFoundError(String method) { @Override public Mono closeGracefully() { // TODO: clear pendingResponses and emit errors? - return this.transport.closeGracefully(); + return this.onClose.get().onErrorComplete().then(this.transport.closeGracefully()); } @Override public void close() { // TODO: clear pendingResponses and emit errors? + this.onClose.get().onErrorComplete().subscribe(); this.transport.close(); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerTransportProviderBase.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerTransportProviderBase.java index acb1ecac6..8d5e0f847 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerTransportProviderBase.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpServerTransportProviderBase.java @@ -45,6 +45,23 @@ public interface McpServerTransportProviderBase { */ Mono notifyClients(String method, Object params); + /** + * Sends a notification to a specific client session. Transport providers that support + * resource subscriptions must override this method to enable per-session + * notifications. The default implementation returns an error indicating that this + * operation is not supported. + * @param sessionId the id of the session to notify + * @param method the name of the notification method to be called on the client + * @param params parameters to be sent with the notification + * @return a Mono that completes when the notification has been sent, or empty if the + * session is not found + */ + default Mono notifyClient(String sessionId, String method, Object params) { + return Mono.error( + new UnsupportedOperationException("This transport provider does not support per-session notifications. " + + "Override notifyClient() to enable resource subscription support.")); + } + /** * Immediately closes all the transports with connected clients and releases any * associated resources. diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java index 95f8959f5..9ec2117bb 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpStreamableServerSession.java @@ -62,6 +62,8 @@ public class McpStreamableServerSession implements McpLoggableSession { private volatile McpSchema.LoggingLevel minLoggingLevel = McpSchema.LoggingLevel.INFO; + private final Supplier> onClose; + /** * Create an instance of the streamable session. * @param id session ID @@ -71,11 +73,12 @@ public class McpStreamableServerSession implements McpLoggableSession { * @param requestHandlers the map of MCP request handlers keyed by method name * @param notificationHandlers the map of MCP notification handlers keyed by method * name + * @param onClose supplier of a reactive callback invoked when the session is closed */ public McpStreamableServerSession(String id, McpSchema.ClientCapabilities clientCapabilities, McpSchema.Implementation clientInfo, Duration requestTimeout, - Map> requestHandlers, - Map notificationHandlers) { + Map> requestHandlers, Map notificationHandlers, + Supplier> onClose) { this.id = id; this.missingMcpTransportSession = new MissingMcpTransportSession(id); this.listeningStreamRef = new AtomicReference<>(this.missingMcpTransportSession); @@ -84,6 +87,24 @@ public McpStreamableServerSession(String id, McpSchema.ClientCapabilities client this.requestTimeout = requestTimeout; this.requestHandlers = requestHandlers; this.notificationHandlers = notificationHandlers; + this.onClose = onClose; + } + + /** + * Create an instance of the streamable session. + * @param id session ID + * @param clientCapabilities client capabilities + * @param clientInfo client info + * @param requestTimeout timeout to use for requests + * @param requestHandlers the map of MCP request handlers keyed by method name + * @param notificationHandlers the map of MCP notification handlers keyed by method + * name + */ + public McpStreamableServerSession(String id, McpSchema.ClientCapabilities clientCapabilities, + McpSchema.Implementation clientInfo, Duration requestTimeout, + Map> requestHandlers, + Map notificationHandlers) { + this(id, clientCapabilities, clientInfo, requestTimeout, requestHandlers, notificationHandlers, Mono::empty); } @Override @@ -126,6 +147,7 @@ public Mono sendNotification(String method, Object params) { } public Mono delete() { + // onClose is invoked inside closeGracefully return this.closeGracefully().then(Mono.fromRunnable(() -> { // TODO: review in the context of history storage // delete history, etc. @@ -258,15 +280,16 @@ private MethodNotFoundError getMethodNotFoundError(String method) { @Override public Mono closeGracefully() { - return Mono.defer(() -> { + return this.onClose.get().onErrorComplete().then(Mono.defer(() -> { McpLoggableSession listeningStream = this.listeningStreamRef.getAndSet(missingMcpTransportSession); return listeningStream.closeGracefully(); // TODO: Also close all the open streams - }); + })); } @Override public void close() { + this.onClose.get().onErrorComplete().subscribe(); McpLoggableSession listeningStream = this.listeningStreamRef.getAndSet(missingMcpTransportSession); if (listeningStream != null) { listeningStream.close(); diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java index 270bc4308..1ed9b270a 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java @@ -1746,6 +1746,95 @@ void testStructuredOutputRuntimeToolAddition(String clientType) { } } + // --------------------------------------- + // Resource Subscription Tests + // --------------------------------------- + + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testResourceSubscription(String clientType) throws InterruptedException { + + var clientBuilder = clientBuilders.get(clientType); + + String resourceUri = "test://subscribable-resource"; + var receivedContents = new AtomicReference>(); + var latch = new CountDownLatch(1); + + McpServerFeatures.SyncResourceSpecification resourceSpec = new McpServerFeatures.SyncResourceSpecification( + McpSchema.Resource.builder() + .uri(resourceUri) + .name("Subscribable Resource") + .mimeType("text/plain") + .build(), + (exchange, req) -> new McpSchema.ReadResourceResult( + List.of(new McpSchema.TextResourceContents(resourceUri, "text/plain", "initial content")))); + + McpSyncServer mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder().resources(true, false).build()) + .resources(resourceSpec) + .build(); + + try (var mcpClient = clientBuilder.resourcesUpdateConsumer(contents -> { + receivedContents.set(contents); + latch.countDown(); + }).build()) { + + mcpClient.initialize(); + + mcpClient.subscribeResource(new McpSchema.SubscribeRequest(resourceUri)); + + mcpServer.notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification(resourceUri)); + + assertThat(latch.await(5, TimeUnit.SECONDS)) + .as("client should receive the resources/updated notification within 5 seconds") + .isTrue(); + assertThat(receivedContents.get()).isNotEmpty(); + } + finally { + mcpServer.closeGracefully(); + } + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @MethodSource("clientsForTesting") + void testResourceSubscription_afterUnsubscribe_noNotification(String clientType) throws InterruptedException { + + var clientBuilder = clientBuilders.get(clientType); + + String resourceUri = "test://subscribable-resource-unsub"; + var notificationCount = new java.util.concurrent.atomic.AtomicInteger(0); + + McpServerFeatures.SyncResourceSpecification resourceSpec = new McpServerFeatures.SyncResourceSpecification( + McpSchema.Resource.builder() + .uri(resourceUri) + .name("Subscribable Resource") + .mimeType("text/plain") + .build(), + (exchange, req) -> new McpSchema.ReadResourceResult( + List.of(new McpSchema.TextResourceContents(resourceUri, "text/plain", "content")))); + + McpSyncServer mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder().resources(true, false).build()) + .resources(resourceSpec) + .build(); + + try (var mcpClient = clientBuilder.resourcesUpdateConsumer(contents -> notificationCount.incrementAndGet()) + .build()) { + + mcpClient.initialize(); + + mcpClient.subscribeResource(new McpSchema.SubscribeRequest(resourceUri)); + mcpClient.unsubscribeResource(new McpSchema.UnsubscribeRequest(resourceUri)); + + mcpServer.notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification(resourceUri)); + + assertThat(notificationCount.get()).as("no notification should be received after unsubscribing").isZero(); + } + finally { + mcpServer.closeGracefully(); + } + } + private double evaluateExpression(String expression) { // Simple expression evaluator for testing return switch (expression) { diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpServerTransport.java b/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpServerTransport.java index fac26596a..9d43968e5 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpServerTransport.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpServerTransport.java @@ -6,6 +6,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CountDownLatch; import java.util.function.BiConsumer; import io.modelcontextprotocol.json.McpJsonDefaults; diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpServerTransportProvider.java b/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpServerTransportProvider.java index e955be89f..9488870e5 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpServerTransportProvider.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/MockMcpServerTransportProvider.java @@ -38,6 +38,14 @@ public Mono notifyClients(String method, Object params) { return session.sendNotification(method, params); } + @Override + public Mono notifyClient(String sessionId, String method, Object params) { + if (session != null && session.getId().equals(sessionId)) { + return session.sendNotification(method, params); + } + return Mono.empty(); + } + @Override public Mono closeGracefully() { return session.closeGracefully(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/ResourceSubscriptionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/ResourceSubscriptionTests.java new file mode 100644 index 000000000..016e25e9f --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/ResourceSubscriptionTests.java @@ -0,0 +1,166 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package io.modelcontextprotocol.server; + +import java.util.UUID; + +import io.modelcontextprotocol.MockMcpServerTransport; +import io.modelcontextprotocol.MockMcpServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.ProtocolVersions; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for resource subscription logic in {@link McpAsyncServer}. Uses + * {@link MockMcpServerTransportProvider} to drive sessions directly without a real + * network stack. + */ +class ResourceSubscriptionTests { + + private static final String RESOURCE_URI = "test://resource/1"; + + private static final McpSchema.Implementation SERVER_INFO = new McpSchema.Implementation("test-server", "1.0.0"); + + private static final McpSchema.Implementation CLIENT_INFO = new McpSchema.Implementation("test-client", "1.0.0"); + + private static McpAsyncServer buildServer(MockMcpServerTransportProvider transportProvider) { + return McpServer.async(transportProvider) + .serverInfo(SERVER_INFO) + .capabilities(McpSchema.ServerCapabilities.builder().resources(true, false).build()) + .build(); + } + + private static McpSchema.JSONRPCRequest initRequest() { + return new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, + UUID.randomUUID().toString(), + new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_11_25, null, CLIENT_INFO)); + } + + private static McpSchema.JSONRPCNotification initializedNotification() { + return new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_NOTIFICATION_INITIALIZED, + null); + } + + private static McpSchema.JSONRPCRequest subscribeRequest(String uri) { + return new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_RESOURCES_SUBSCRIBE, + UUID.randomUUID().toString(), new McpSchema.SubscribeRequest(uri)); + } + + private static McpSchema.JSONRPCRequest unsubscribeRequest(String uri) { + return new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_RESOURCES_UNSUBSCRIBE, + UUID.randomUUID().toString(), new McpSchema.UnsubscribeRequest(uri)); + } + + @Test + void notifyResourcesUpdated_noSubscribers_completesEmpty() { + MockMcpServerTransport transport = new MockMcpServerTransport(); + MockMcpServerTransportProvider transportProvider = new MockMcpServerTransportProvider(transport); + McpAsyncServer server = buildServer(transportProvider); + + transportProvider.simulateIncomingMessage(initRequest()); + transportProvider.simulateIncomingMessage(initializedNotification()); + transport.clearSentMessages(); + + StepVerifier.create(server.notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification(RESOURCE_URI))) + .verifyComplete(); + + assertThat(transport.getAllSentMessages()).as("no notification should be sent when nobody is subscribed") + .isEmpty(); + + server.closeGracefully().block(); + } + + @Test + void notifyResourcesUpdated_afterSubscribe_notifiesSession() { + MockMcpServerTransport transport = new MockMcpServerTransport(); + MockMcpServerTransportProvider transportProvider = new MockMcpServerTransportProvider(transport); + McpAsyncServer server = buildServer(transportProvider); + + transportProvider.simulateIncomingMessage(initRequest()); + transportProvider.simulateIncomingMessage(initializedNotification()); + transportProvider.simulateIncomingMessage(subscribeRequest(RESOURCE_URI)); + transport.clearSentMessages(); + + StepVerifier.create(server.notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification(RESOURCE_URI))) + .verifyComplete(); + + McpSchema.JSONRPCMessage sent = transport.getLastSentMessage(); + assertThat(sent).isInstanceOf(McpSchema.JSONRPCNotification.class); + McpSchema.JSONRPCNotification notification = (McpSchema.JSONRPCNotification) sent; + assertThat(notification.method()).isEqualTo(McpSchema.METHOD_NOTIFICATION_RESOURCES_UPDATED); + + server.closeGracefully().block(); + } + + @Test + void notifyResourcesUpdated_differentUri_doesNotNotifySession() { + MockMcpServerTransport transport = new MockMcpServerTransport(); + MockMcpServerTransportProvider transportProvider = new MockMcpServerTransportProvider(transport); + McpAsyncServer server = buildServer(transportProvider); + + transportProvider.simulateIncomingMessage(initRequest()); + transportProvider.simulateIncomingMessage(initializedNotification()); + transportProvider.simulateIncomingMessage(subscribeRequest(RESOURCE_URI)); + transport.clearSentMessages(); + + StepVerifier + .create(server.notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification("test://other/resource"))) + .verifyComplete(); + + assertThat(transport.getAllSentMessages()) + .as("notification for a different URI should not reach a session subscribed to a different URI") + .isEmpty(); + + server.closeGracefully().block(); + } + + @Test + void notifyResourcesUpdated_afterUnsubscribe_doesNotNotifySession() { + MockMcpServerTransport transport = new MockMcpServerTransport(); + MockMcpServerTransportProvider transportProvider = new MockMcpServerTransportProvider(transport); + McpAsyncServer server = buildServer(transportProvider); + + transportProvider.simulateIncomingMessage(initRequest()); + transportProvider.simulateIncomingMessage(initializedNotification()); + transportProvider.simulateIncomingMessage(subscribeRequest(RESOURCE_URI)); + transportProvider.simulateIncomingMessage(unsubscribeRequest(RESOURCE_URI)); + transport.clearSentMessages(); + + StepVerifier.create(server.notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification(RESOURCE_URI))) + .verifyComplete(); + + assertThat(transport.getAllSentMessages()).as("no notification should be sent after the session unsubscribed") + .isEmpty(); + + server.closeGracefully().block(); + } + + @Test + void notifyResourcesUpdated_afterSessionClose_doesNotNotifySession() { + MockMcpServerTransport transport = new MockMcpServerTransport(); + MockMcpServerTransportProvider transportProvider = new MockMcpServerTransportProvider(transport); + McpAsyncServer server = buildServer(transportProvider); + + transportProvider.simulateIncomingMessage(initRequest()); + transportProvider.simulateIncomingMessage(initializedNotification()); + transportProvider.simulateIncomingMessage(subscribeRequest(RESOURCE_URI)); + + // Close the session; onClose must fire and remove the subscription + transportProvider.closeGracefully().block(); + transport.clearSentMessages(); + + StepVerifier.create(server.notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification(RESOURCE_URI))) + .verifyComplete(); + + assertThat(transport.getAllSentMessages()).as("no notification should be sent after the session has closed") + .isEmpty(); + + server.closeGracefully().block(); + } + +} From 05f37ad027e9fca4469ad6cc0df0db704c78fb75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=ED=98=95=EC=A4=80?= Date: Tue, 3 Mar 2026 00:57:08 +0900 Subject: [PATCH 05/15] Use explicit UTF-8 charset in StdioServerTransportProvider (#826) Also add a test simulating a different locale in an isolated process. Resolves #295 --- .../StdioServerTransportProvider.java | 2 +- .../StdioServerTransportProviderTests.java | 38 +++++++++ .../server/transport/StdioUtf8TestServer.java | 82 +++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioUtf8TestServer.java diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java index 79be014a6..66cc304d6 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/StdioServerTransportProvider.java @@ -214,7 +214,7 @@ private void startInboundProcessing() { inboundReady.tryEmitValue(null); BufferedReader reader = null; try { - reader = new BufferedReader(new InputStreamReader(inputStream)); + reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); while (!isClosing.get()) { try { String line = reader.readLine(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java index 5390cc4c2..6c2cc2bf4 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioServerTransportProviderTests.java @@ -4,9 +4,11 @@ package io.modelcontextprotocol.server.transport; +import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.InputStream; +import java.io.InputStreamReader; import java.io.PrintStream; import java.nio.charset.StandardCharsets; import java.util.Map; @@ -135,6 +137,42 @@ void shouldHandleIncomingMessages() throws Exception { }).verifyComplete(); } + @Test + void shouldHandleUtf8MessagesWithNonUtf8DefaultCharset() throws Exception { + String utf8Content = "한글 漢字 café 🎉"; + String jsonMessage = "{\"jsonrpc\":\"2.0\",\"method\":\"test\"," + "\"params\":{\"message\":\"" + utf8Content + + "\"},\"id\":1}\n"; + + // Start a subprocess with non-UTF-8 default charset + String javaHome = System.getProperty("java.home"); + String classpath = System.getProperty("java.class.path"); + ProcessBuilder pb = new ProcessBuilder(javaHome + "/bin/java", "-Dfile.encoding=ISO-8859-1", "-cp", classpath, + StdioUtf8TestServer.class.getName()); + pb.redirectErrorStream(false); + Process process = pb.start(); + + try { + // Write UTF-8 encoded JSON-RPC message to the subprocess stdin + process.getOutputStream().write(jsonMessage.getBytes(StandardCharsets.UTF_8)); + process.getOutputStream().flush(); + process.getOutputStream().close(); + + // Read the echoed message from subprocess stdout + String result; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + result = reader.readLine(); + } + + // Verify that multi-byte UTF-8 characters survived the round trip + assertThat(result).isEqualTo(utf8Content); + } + finally { + process.destroyForcibly(); + process.waitFor(10, TimeUnit.SECONDS); + } + } + @Test void shouldNotifyClients() { // Set session factory diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioUtf8TestServer.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioUtf8TestServer.java new file mode 100644 index 000000000..3fc3a716d --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/transport/StdioUtf8TestServer.java @@ -0,0 +1,82 @@ +/* + * Copyright 2024-2024 the original author or authors. + */ + +package io.modelcontextprotocol.server.transport; + +import java.io.OutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpServerSession; +import reactor.core.publisher.Mono; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Minimal STDIO server process for testing UTF-8 encoding behavior. + * + *

+ * This class is spawned as a subprocess with {@code -Dfile.encoding=ISO-8859-1} to + * simulate a non-UTF-8 default charset environment. It uses + * {@link StdioServerTransportProvider} to read a JSON-RPC message from stdin and echoes + * the received {@code params.message} value back to stdout, allowing the parent test to + * verify that multi-byte UTF-8 characters are preserved regardless of the JVM default + * charset. + * + * @see StdioServerTransportProviderTests#shouldHandleUtf8MessagesWithNonUtf8DefaultCharset + */ +public class StdioUtf8TestServer { + + @SuppressWarnings("unchecked") + public static void main(String[] args) throws Exception { + // Capture the original stdout for echoing the result later + PrintStream originalOut = System.out; + + // Redirect System.out to stderr so that logger output does not + // interfere with the test result written to stdout + System.setOut(new PrintStream(System.err, true)); + + CountDownLatch messageLatch = new CountDownLatch(1); + StringBuilder receivedMessage = new StringBuilder(); + + StdioServerTransportProvider transportProvider = new StdioServerTransportProvider(McpJsonDefaults.getMapper(), + System.in, OutputStream.nullOutputStream()); + + McpServerSession.Factory sessionFactory = transport -> { + McpServerSession session = mock(McpServerSession.class); + when(session.handle(any())).thenAnswer(invocation -> { + McpSchema.JSONRPCMessage msg = invocation.getArgument(0); + if (msg instanceof McpSchema.JSONRPCRequest request) { + Map params = (Map) request.params(); + receivedMessage.append(params.get("message")); + } + messageLatch.countDown(); + return Mono.empty(); + }); + when(session.closeGracefully()).thenReturn(Mono.empty()); + return session; + }; + + // Start processing stdin + transportProvider.setSessionFactory(sessionFactory); + + // Wait for the message to be processed + if (messageLatch.await(10, TimeUnit.SECONDS)) { + // Write the received message to the original stdout in UTF-8 + originalOut.write(receivedMessage.toString().getBytes(StandardCharsets.UTF_8)); + originalOut.write('\n'); + originalOut.flush(); + } + + transportProvider.closeGracefully().block(java.time.Duration.ofSeconds(5)); + } + +} From ed02736c2c61111fa955d008f0a44e5e3e77a7c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= Date: Mon, 2 Mar 2026 17:08:14 +0100 Subject: [PATCH 06/15] Update dependabot PR labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dariusz Jędrzejczyk --- .github/dependabot.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c25de745b..2ce5f4c7c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,11 +4,17 @@ updates: directory: '/' schedule: interval: monthly + labels: + - 'github_actions' + - 'waiting for triage' - package-ecosystem: 'maven' directory: '/' schedule: interval: monthly open-pull-requests-limit: 10 + labels: + - 'dependencies' + - 'waiting for triage' ignore: # Freeze production dependencies of mcp-core - dependency-name: 'org.slf4j:slf4j-api' From 26304a7a39f80311f4128ba8a94d2301bb0b3f95 Mon Sep 17 00:00:00 2001 From: Christian Tzolov <1351573+tzolov@users.noreply.github.com> Date: Tue, 3 Mar 2026 10:45:04 +0100 Subject: [PATCH 07/15] Document resource subscription support in server and client guides (#843) Signed-off-by: Christian Tzolov --- docs/client.md | 43 +++++++++++++++++++++++++++++++++++++++++++ docs/server.md | 42 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/docs/client.md b/docs/client.md index 6a99928c5..1702936f0 100644 --- a/docs/client.md +++ b/docs/client.md @@ -408,6 +408,49 @@ Resources represent server-side data sources that clients can access using URI t .subscribe(); ``` +### Resource Subscriptions + +When the server advertises `resources.subscribe` support, clients can subscribe to individual resources and receive a callback whenever the server pushes a `notifications/resources/updated` notification for that URI. The SDK automatically re-reads the resource on notification and delivers the updated contents to the registered consumer. + +Register a consumer on the client builder, then subscribe/unsubscribe at any time: + +=== "Sync API" + + ```java + McpSyncClient client = McpClient.sync(transport) + .resourcesUpdateConsumer(contents -> { + // called with the updated resource contents after each notification + System.out.println("Resource updated: " + contents); + }) + .build(); + + client.initialize(); + + // Subscribe to a specific resource URI + client.subscribeResource(new McpSchema.SubscribeRequest("custom://resource")); + + // ... later, stop receiving updates + client.unsubscribeResource(new McpSchema.UnsubscribeRequest("custom://resource")); + ``` + +=== "Async API" + + ```java + McpAsyncClient client = McpClient.async(transport) + .resourcesUpdateConsumer(contents -> Mono.fromRunnable(() -> { + System.out.println("Resource updated: " + contents); + })) + .build(); + + client.initialize() + .then(client.subscribeResource(new McpSchema.SubscribeRequest("custom://resource"))) + .subscribe(); + + // ... later, stop receiving updates + client.unsubscribeResource(new McpSchema.UnsubscribeRequest("custom://resource")) + .subscribe(); + ``` + ### Prompt System The prompt system enables interaction with server-side prompt templates. These templates can be discovered and executed with custom parameters, allowing for dynamic text generation based on predefined patterns. diff --git a/docs/server.md b/docs/server.md index 0753726e2..f9f3aa683 100644 --- a/docs/server.md +++ b/docs/server.md @@ -33,7 +33,7 @@ The server supports both synchronous and asynchronous APIs, allowing for flexibl McpSyncServer syncServer = McpServer.sync(transportProvider) .serverInfo("my-server", "1.0.0") .capabilities(ServerCapabilities.builder() - .resources(false, true) // Enable resource support with list changes + .resources(false, true) // Resource support: subscribe=false, listChanged=true .tools(true) // Enable tool support with list changes .prompts(true) // Enable prompt support with list changes .completions() // Enable completions support @@ -57,7 +57,7 @@ The server supports both synchronous and asynchronous APIs, allowing for flexibl McpAsyncServer asyncServer = McpServer.async(transportProvider) .serverInfo("my-server", "1.0.0") .capabilities(ServerCapabilities.builder() - .resources(false, true) // Enable resource support with list changes + .resources(false, true) // Resource support: subscribe=false, listChanged=true .tools(true) // Enable tool support with list changes .prompts(true) // Enable prompt support with list changes .completions() // Enable completions support @@ -319,7 +319,7 @@ The server can be configured with various capabilities: ```java var capabilities = ServerCapabilities.builder() - .resources(false, true) // Resource support (subscribe, listChanged) + .resources(true, true) // Resource support: subscribe=true, listChanged=true .tools(true) // Tool support with list changes notifications .prompts(true) // Prompt support with list changes notifications .completions() // Enable completions support @@ -438,6 +438,42 @@ Resources provide context to AI models by exposing data such as: File contents, ); ``` +### Resource Subscriptions + +When the `subscribe` capability is enabled, clients can subscribe to specific resources and receive targeted `notifications/resources/updated` notifications when those resources change. Only sessions that have explicitly subscribed to a given URI receive the notification — not every connected client. + +Enable subscription support in the server capabilities: + +```java +McpSyncServer server = McpServer.sync(transportProvider) + .serverInfo("my-server", "1.0.0") + .capabilities(ServerCapabilities.builder() + .resources(true, false) // subscribe=true, listChanged=false + .build()) + .resources(myResourceSpec) + .build(); +``` + +When a subscribed resource changes, notify only the interested sessions: + +=== "Sync" + + ```java + server.notifyResourcesUpdated( + new McpSchema.ResourcesUpdatedNotification("custom://resource") + ); + ``` + +=== "Async" + + ```java + server.notifyResourcesUpdated( + new McpSchema.ResourcesUpdatedNotification("custom://resource") + ).subscribe(); + ``` + +If no sessions are subscribed to the given URI the call completes immediately without sending any messages. Subscription state is automatically cleaned up when a client session closes. + ### Resource Template Specification Resource templates allow servers to expose parameterized resources using URI templates: From 6e4ce1c1a313cccaf1b4f1c31a69525d59d11cfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= Date: Thu, 5 Mar 2026 09:53:37 +0100 Subject: [PATCH 08/15] Fix elicitation and resource subscription tests that deadlock on a single CPU (#854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dariusz Jędrzejczyk --- ...stractMcpClientServerIntegrationTests.java | 18 +++++++++------- .../client/AbstractMcpAsyncClientTests.java | 21 +++++++------------ 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java index 1ed9b270a..e5d55c39d 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java @@ -53,7 +53,6 @@ import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; @@ -404,6 +403,8 @@ void testCreateElicitationSuccess(String clientType) { .addContent(new McpSchema.TextContent("CALL RESPONSE")) .build(); + AtomicReference elicitResultRef = new AtomicReference<>(); + McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder() .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) .callHandler((exchange, request) -> { @@ -414,13 +415,9 @@ void testCreateElicitationSuccess(String clientType) { Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) .build(); - StepVerifier.create(exchange.createElicitation(elicitationRequest)).consumeNextWith(result -> { - assertThat(result).isNotNull(); - assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT); - assertThat(result.content().get("message")).isEqualTo("Test message"); - }).verifyComplete(); - - return Mono.just(callResponse); + return exchange.createElicitation(elicitationRequest) + .doOnNext(elicitResultRef::set) + .thenReturn(callResponse); }) .build(); @@ -438,6 +435,11 @@ void testCreateElicitationSuccess(String clientType) { assertThat(response).isNotNull(); assertThat(response).isEqualTo(callResponse); + assertWith(elicitResultRef.get(), result -> { + assertThat(result).isNotNull(); + assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT); + assertThat(result.content().get("message")).isEqualTo("Test message"); + }); } finally { mcpServer.closeGracefully().block(); diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java index bee8f4f16..2ef45a1e0 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpAsyncClientTests.java @@ -610,22 +610,17 @@ void testListAllResourceTemplatesReturnsImmutableList() { }); } - // @Test + @Test void testResourceSubscription() { withClient(createMcpTransport(), mcpAsyncClient -> { - StepVerifier.create(mcpAsyncClient.listResources()).consumeNextWith(resources -> { - if (!resources.resources().isEmpty()) { - Resource firstResource = resources.resources().get(0); - - // Test subscribe - StepVerifier.create(mcpAsyncClient.subscribeResource(new SubscribeRequest(firstResource.uri()))) - .verifyComplete(); - - // Test unsubscribe - StepVerifier.create(mcpAsyncClient.unsubscribeResource(new UnsubscribeRequest(firstResource.uri()))) - .verifyComplete(); + StepVerifier.create(mcpAsyncClient.listResources().flatMap(resources -> { + if (resources.resources().isEmpty()) { + return Mono.empty(); } - }).verifyComplete(); + Resource firstResource = resources.resources().get(0); + return mcpAsyncClient.subscribeResource(new SubscribeRequest(firstResource.uri())) + .then(mcpAsyncClient.unsubscribeResource(new UnsubscribeRequest(firstResource.uri()))); + })).verifyComplete(); }); } From abcd19c0c2b6bd30ae6a845b7748560b24c698f5 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Fri, 13 Mar 2026 10:12:57 +0100 Subject: [PATCH 09/15] HttpClientStreamHttpTransport: add authorization error handler (#861) HttpClientStreamHttpTransport: add authorization error handler - Closes #240 Signed-off-by: Daniel Garnier-Moiroux --- .../HttpClientStreamableHttpTransport.java | 274 ++++++----- ...ClientTransportAuthorizationException.java | 31 ++ ...cpHttpClientAuthorizationErrorHandler.java | 104 ++++ ...tpClientAuthorizationErrorHandlerTest.java | 48 ++ ...eamableHttpTransportErrorHandlingTest.java | 445 +++++++++++++++++- 5 files changed, 771 insertions(+), 131 deletions(-) create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/transport/McpHttpClientTransportAuthorizationException.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientAuthorizationErrorHandler.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientAuthorizationErrorHandlerTest.java diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java index d6b01e17f..57a27a3fd 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java @@ -1,5 +1,5 @@ /* - * Copyright 2024-2025 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.client.transport; @@ -23,6 +23,7 @@ import io.modelcontextprotocol.client.McpAsyncClient; import io.modelcontextprotocol.client.transport.ResponseSubscribers.ResponseEvent; import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer; +import io.modelcontextprotocol.client.transport.customizer.McpHttpClientAuthorizationErrorHandler; import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonDefaults; @@ -50,6 +51,7 @@ import reactor.core.publisher.Mono; import reactor.util.function.Tuple2; import reactor.util.function.Tuples; +import reactor.util.retry.Retry; /** * An implementation of the Streamable HTTP protocol as defined by the @@ -72,6 +74,7 @@ *

* * @author Christian Tzolov + * @author Daniel Garnier-Moiroux * @see Streamable * HTTP transport specification @@ -115,6 +118,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport { private final boolean openConnectionOnStartup; + private final McpHttpClientAuthorizationErrorHandler authorizationErrorHandler; + private final boolean resumableStreams; private final McpAsyncHttpClientRequestCustomizer httpRequestCustomizer; @@ -132,7 +137,7 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport { private HttpClientStreamableHttpTransport(McpJsonMapper jsonMapper, HttpClient httpClient, HttpRequest.Builder requestBuilder, String baseUri, String endpoint, boolean resumableStreams, boolean openConnectionOnStartup, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer, - List supportedProtocolVersions) { + McpHttpClientAuthorizationErrorHandler authorizationErrorHandler, List supportedProtocolVersions) { this.jsonMapper = jsonMapper; this.httpClient = httpClient; this.requestBuilder = requestBuilder; @@ -140,6 +145,7 @@ private HttpClientStreamableHttpTransport(McpJsonMapper jsonMapper, HttpClient h this.endpoint = endpoint; this.resumableStreams = resumableStreams; this.openConnectionOnStartup = openConnectionOnStartup; + this.authorizationErrorHandler = authorizationErrorHandler; this.activeSession.set(createTransportSession()); this.httpRequestCustomizer = httpRequestCustomizer; this.supportedProtocolVersions = Collections.unmodifiableList(supportedProtocolVersions); @@ -239,7 +245,6 @@ public Mono closeGracefully() { } private Mono reconnect(McpTransportStream stream) { - return Mono.deferContextual(ctx -> { if (stream != null) { @@ -275,121 +280,120 @@ private Mono reconnect(McpTransportStream stream) { var transportContext = connectionCtx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY); return Mono.from(this.httpRequestCustomizer.customize(builder, "GET", uri, null, transportContext)); }) - .flatMapMany( - requestBuilder -> Flux.create( - sseSink -> this.httpClient - .sendAsync(requestBuilder.build(), - responseInfo -> ResponseSubscribers.sseToBodySubscriber(responseInfo, - sseSink)) - .whenComplete((response, throwable) -> { - if (throwable != null) { - sseSink.error(throwable); - } - else { - logger.debug("SSE connection established successfully"); - } - })) - .map(responseEvent -> (ResponseSubscribers.SseResponseEvent) responseEvent) - .flatMap(responseEvent -> { - int statusCode = responseEvent.responseInfo().statusCode(); - - if (statusCode >= 200 && statusCode < 300) { - - if (MESSAGE_EVENT_TYPE.equals(responseEvent.sseEvent().event())) { - String data = responseEvent.sseEvent().data(); - // Per 2025-11-25 spec (SEP-1699), servers may - // send SSE events - // with empty data to prime the client for - // reconnection. - // Skip these events as they contain no JSON-RPC - // message. - if (data == null || data.isBlank()) { - logger.debug("Skipping SSE event with empty data (stream primer)"); - return Flux.empty(); - } - try { - // We don't support batching ATM and probably - // won't since the next version considers - // removing it. - McpSchema.JSONRPCMessage message = McpSchema - .deserializeJsonRpcMessage(this.jsonMapper, data); - - Tuple2, Iterable> idWithMessages = Tuples - .of(Optional.ofNullable(responseEvent.sseEvent().id()), - List.of(message)); - - McpTransportStream sessionStream = stream != null ? stream - : new DefaultMcpTransportStream<>(this.resumableStreams, - this::reconnect); - logger.debug("Connected stream {}", sessionStream.streamId()); - - return Flux.from(sessionStream.consumeSseStream(Flux.just(idWithMessages))); - - } - catch (IOException ioException) { - return Flux.error(new McpTransportException( - "Error parsing JSON-RPC message: " + responseEvent, ioException)); - } - } - else { - logger.debug("Received SSE event with type: {}", responseEvent.sseEvent()); - return Flux.empty(); - } - } - else if (statusCode == METHOD_NOT_ALLOWED) { // NotAllowed - logger - .debug("The server does not support SSE streams, using request-response mode."); + .flatMapMany(requestBuilder -> Flux.create(sseSink -> this.httpClient + .sendAsync(requestBuilder.build(), this.toSendMessageBodySubscriber(sseSink)) + .whenComplete((response, throwable) -> { + if (throwable != null) { + sseSink.error(throwable); + } + else { + logger.debug("SSE connection established successfully"); + } + })).flatMap(responseEvent -> { + int statusCode = responseEvent.responseInfo().statusCode(); + if (statusCode == 401 || statusCode == 403) { + logger.debug("Authorization error in reconnect with code {}", statusCode); + return Mono.error( + new McpHttpClientTransportAuthorizationException( + "Authorization error connecting to SSE stream", + responseEvent.responseInfo())); + } + + if (!(responseEvent instanceof ResponseSubscribers.SseResponseEvent sseResponseEvent)) { + return Flux.error(new McpTransportException( + "Unrecognized server error when connecting to SSE stream, status code: " + + statusCode)); + } + else if (statusCode >= 200 && statusCode < 300) { + if (MESSAGE_EVENT_TYPE.equals(sseResponseEvent.sseEvent().event())) { + String data = sseResponseEvent.sseEvent().data(); + // Per 2025-11-25 spec (SEP-1699), servers may + // send SSE events + // with empty data to prime the client for + // reconnection. + // Skip these events as they contain no JSON-RPC + // message. + if (data == null || data.isBlank()) { + logger.debug("Skipping SSE event with empty data (stream primer)"); return Flux.empty(); } - else if (statusCode == NOT_FOUND) { - - if (transportSession != null && transportSession.sessionId().isPresent()) { - // only if the request was sent with a session id - // and the response is 404, we consider it a - // session not found error. - logger.debug("Session not found for session ID: {}", - transportSession.sessionId().get()); - String sessionIdRepresentation = sessionIdOrPlaceholder(transportSession); - McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException( - "Session not found for session ID: " + sessionIdRepresentation); - return Flux.error(exception); - } - return Flux.error( - new McpTransportException("Server Not Found. Status code:" + statusCode - + ", response-event:" + responseEvent)); - } - else if (statusCode == BAD_REQUEST) { - if (transportSession != null && transportSession.sessionId().isPresent()) { - // only if the request was sent with a session id - // and thre response is 404, we consider it a - // session not found error. - String sessionIdRepresentation = sessionIdOrPlaceholder(transportSession); - McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException( - "Session not found for session ID: " + sessionIdRepresentation); - return Flux.error(exception); - } - return Flux.error( - new McpTransportException("Bad Request. Status code:" + statusCode - + ", response-event:" + responseEvent)); + try { + // We don't support batching ATM and probably + // won't since the next version considers + // removing it. + McpSchema.JSONRPCMessage message = McpSchema + .deserializeJsonRpcMessage(this.jsonMapper, data); - } + Tuple2, Iterable> idWithMessages = Tuples + .of(Optional.ofNullable(sseResponseEvent.sseEvent().id()), List.of(message)); + + McpTransportStream sessionStream = stream != null ? stream + : new DefaultMcpTransportStream<>(this.resumableStreams, this::reconnect); + logger.debug("Connected stream {}", sessionStream.streamId()); - return Flux.error(new McpTransportException( - "Received unrecognized SSE event type: " + responseEvent.sseEvent().event())); - }).flatMap( - jsonrpcMessage -> this.handler.get().apply(Mono.just(jsonrpcMessage))) - .onErrorMap(CompletionException.class, t -> t.getCause()) - .onErrorComplete(t -> { - this.handleException(t); - return true; - }) - .doFinally(s -> { - Disposable ref = disposableRef.getAndSet(null); - if (ref != null) { - transportSession.removeConnection(ref); + return Flux.from(sessionStream.consumeSseStream(Flux.just(idWithMessages))); + + } + catch (IOException ioException) { + return Flux.error(new McpTransportException( + "Error parsing JSON-RPC message: " + responseEvent, ioException)); } - })) + } + else { + logger.debug("Received SSE event with type: {}", sseResponseEvent.sseEvent()); + return Flux.empty(); + } + } + else if (statusCode == METHOD_NOT_ALLOWED) { // NotAllowed + logger.debug("The server does not support SSE streams, using request-response mode."); + return Flux.empty(); + } + else if (statusCode == NOT_FOUND) { + + if (transportSession != null && transportSession.sessionId().isPresent()) { + // only if the request was sent with a session id + // and the response is 404, we consider it a + // session not found error. + logger.debug("Session not found for session ID: {}", + transportSession.sessionId().get()); + String sessionIdRepresentation = sessionIdOrPlaceholder(transportSession); + McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException( + "Session not found for session ID: " + sessionIdRepresentation); + return Flux.error(exception); + } + return Flux.error( + new McpTransportException("Server Not Found. Status code:" + statusCode + + ", response-event:" + responseEvent)); + } + else if (statusCode == BAD_REQUEST) { + if (transportSession != null && transportSession.sessionId().isPresent()) { + // only if the request was sent with a session id + // and thre response is 404, we consider it a + // session not found error. + String sessionIdRepresentation = sessionIdOrPlaceholder(transportSession); + McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException( + "Session not found for session ID: " + sessionIdRepresentation); + return Flux.error(exception); + } + return Flux.error(new McpTransportException( + "Bad Request. Status code:" + statusCode + ", response-event:" + responseEvent)); + } + return Flux.error(new McpTransportException( + "Received unrecognized SSE event type: " + sseResponseEvent.sseEvent().event())); + }) + .retryWhen(authorizationErrorRetrySpec()) + .flatMap(jsonrpcMessage -> this.handler.get().apply(Mono.just(jsonrpcMessage))) + .onErrorMap(CompletionException.class, t -> t.getCause()) + .onErrorComplete(t -> { + this.handleException(t); + return true; + }) + .doFinally(s -> { + Disposable ref = disposableRef.getAndSet(null); + if (ref != null) { + transportSession.removeConnection(ref); + } + })) .contextWrite(ctx) .subscribe(); @@ -400,6 +404,25 @@ else if (statusCode == BAD_REQUEST) { } + private Retry authorizationErrorRetrySpec() { + return Retry.from(companion -> companion.flatMap(retrySignal -> { + if (!(retrySignal.failure() instanceof McpHttpClientTransportAuthorizationException authException)) { + return Mono.error(retrySignal.failure()); + } + if (retrySignal.totalRetriesInARow() >= this.authorizationErrorHandler.maxRetries()) { + return Mono.error(retrySignal.failure()); + } + return Mono.deferContextual(ctx -> { + var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY); + return Mono + .from(this.authorizationErrorHandler.handle(authException.getResponseInfo(), transportContext)) + .switchIfEmpty(Mono.just(false)) + .flatMap(shouldRetry -> shouldRetry ? Mono.just(retrySignal.totalRetries()) + : Mono.error(retrySignal.failure())); + }); + })); + } + private BodyHandler toSendMessageBodySubscriber(FluxSink sink) { BodyHandler responseBodyHandler = responseInfo -> { @@ -478,6 +501,13 @@ public Mono sendMessage(McpSchema.JSONRPCMessage sentMessage) { })).onErrorMap(CompletionException.class, t -> t.getCause()).onErrorComplete().subscribe(); })).flatMap(responseEvent -> { + int statusCode = responseEvent.responseInfo().statusCode(); + if (statusCode == 401 || statusCode == 403) { + logger.debug("Authorization error in sendMessage with code {}", statusCode); + return Mono.error(new McpHttpClientTransportAuthorizationException( + "Authorization error when sending message", responseEvent.responseInfo())); + } + if (transportSession.markInitialized( responseEvent.responseInfo().headers().firstValue("mcp-session-id").orElseGet(() -> null))) { // Once we have a session, we try to open an async stream for @@ -488,8 +518,6 @@ public Mono sendMessage(McpSchema.JSONRPCMessage sentMessage) { String sessionRepresentation = sessionIdOrPlaceholder(transportSession); - int statusCode = responseEvent.responseInfo().statusCode(); - if (statusCode >= 200 && statusCode < 300) { String contentType = responseEvent.responseInfo() @@ -605,6 +633,7 @@ else if (statusCode == BAD_REQUEST) { return Flux.error( new RuntimeException("Failed to send message: " + responseEvent)); }) + .retryWhen(authorizationErrorRetrySpec()) .flatMap(jsonRpcMessage -> this.handler.get().apply(Mono.just(jsonRpcMessage))) .onErrorMap(CompletionException.class, t -> t.getCause()) .onErrorComplete(t -> { @@ -664,6 +693,8 @@ public static class Builder { private List supportedProtocolVersions = List.of(ProtocolVersions.MCP_2024_11_05, ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25); + private McpHttpClientAuthorizationErrorHandler authorizationErrorHandler = McpHttpClientAuthorizationErrorHandler.NOOP; + /** * Creates a new builder with the specified base URI. * @param baseUri the base URI of the MCP server @@ -801,6 +832,17 @@ public Builder asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer as return this; } + /** + * Sets the handler to be used when the server responds with HTTP 401 or HTTP 403 + * when sending a message. + * @param authorizationErrorHandler the handler + * @return this builder + */ + public Builder authorizationErrorHandler(McpHttpClientAuthorizationErrorHandler authorizationErrorHandler) { + this.authorizationErrorHandler = authorizationErrorHandler; + return this; + } + /** * Sets the connection timeout for the HTTP client. * @param connectTimeout the connection timeout duration @@ -845,7 +887,7 @@ public HttpClientStreamableHttpTransport build() { HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build(); return new HttpClientStreamableHttpTransport(jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, httpClient, requestBuilder, baseUri, endpoint, resumableStreams, openConnectionOnStartup, - httpRequestCustomizer, supportedProtocolVersions); + httpRequestCustomizer, authorizationErrorHandler, supportedProtocolVersions); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/McpHttpClientTransportAuthorizationException.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/McpHttpClientTransportAuthorizationException.java new file mode 100644 index 000000000..31e5ae95e --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/McpHttpClientTransportAuthorizationException.java @@ -0,0 +1,31 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.client.transport; + +import java.net.http.HttpResponse; + +import io.modelcontextprotocol.spec.McpTransportException; + +/** + * Thrown when the MCP server responds with an authorization error (HTTP 401 or HTTP 403). + * Subclass of {@link McpTransportException} for targeted retry handling in + * {@link HttpClientStreamableHttpTransport}. + * + * @author Daniel Garnier-Moiroux + */ +public class McpHttpClientTransportAuthorizationException extends McpTransportException { + + private final HttpResponse.ResponseInfo responseInfo; + + public McpHttpClientTransportAuthorizationException(String message, HttpResponse.ResponseInfo responseInfo) { + super(message); + this.responseInfo = responseInfo; + } + + public HttpResponse.ResponseInfo getResponseInfo() { + return responseInfo; + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientAuthorizationErrorHandler.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientAuthorizationErrorHandler.java new file mode 100644 index 000000000..c98fac61d --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientAuthorizationErrorHandler.java @@ -0,0 +1,104 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.client.transport.customizer; + +import java.net.http.HttpResponse; + +import io.modelcontextprotocol.client.transport.McpHttpClientTransportAuthorizationException; +import io.modelcontextprotocol.common.McpTransportContext; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** + * Handle security-related errors in HTTP-client based transports. This class handles MCP + * server responses with status code 401 and 403. + * + * @see MCP + * Specification: Authorization + * @author Daniel Garnier-Moiroux + */ +public interface McpHttpClientAuthorizationErrorHandler { + + /** + * Handle authorization error (HTTP 401 or 403), and signal whether the HTTP request + * should be retried or not. If the publisher returns true, the original transport + * method (connect, sendMessage) will be replayed with the original arguments. + * Otherwise, the transport will throw an + * {@link McpHttpClientTransportAuthorizationException}, indicating the error status. + *

+ * If the returned {@link Publisher} errors, the error will be propagated to the + * calling method, to be handled by the caller. + *

+ * The number of retries is bounded by {@link #maxRetries()}. + * @param responseInfo the HTTP response information + * @param context the MCP client transport context + * @return {@link Publisher} emitting true if the original request should be replayed, + * false otherwise. + */ + Publisher handle(HttpResponse.ResponseInfo responseInfo, McpTransportContext context); + + /** + * Maximum number of authorization error retries the transport will attempt. When the + * handler signals a retry via {@link #handle}, the transport will replay the original + * request at most this many times. If the authorization error persists after + * exhausting all retries, the transport will propagate the + * {@link McpHttpClientTransportAuthorizationException}. + *

+ * Defaults to {@code 1}. + * @return the maximum number of retries + */ + default int maxRetries() { + return 1; + } + + /** + * A no-op handler, used in the default use-case. + */ + McpHttpClientAuthorizationErrorHandler NOOP = new Noop(); + + /** + * Create a {@link McpHttpClientAuthorizationErrorHandler} from a synchronous handler. + * Will be subscribed on {@link Schedulers#boundedElastic()}. The handler may be + * blocking. + * @param handler the synchronous handler + * @return an async handler + */ + static McpHttpClientAuthorizationErrorHandler fromSync(Sync handler) { + return (info, context) -> Mono.fromCallable(() -> handler.handle(info, context)) + .subscribeOn(Schedulers.boundedElastic()); + } + + /** + * Synchronous authorization error handler. + */ + interface Sync { + + /** + * Handle authorization error (HTTP 401 or 403), and signal whether the HTTP + * request should be retried or not. If the return value is true, the original + * transport method (connect, sendMessage) will be replayed with the original + * arguments. Otherwise, the transport will throw an + * {@link McpHttpClientTransportAuthorizationException}, indicating the error + * status. + * @param responseInfo the HTTP response information + * @param context the MCP client transport context + * @return true if the original request should be replayed, false otherwise. + */ + boolean handle(HttpResponse.ResponseInfo responseInfo, McpTransportContext context); + + } + + class Noop implements McpHttpClientAuthorizationErrorHandler { + + @Override + public Publisher handle(HttpResponse.ResponseInfo responseInfo, McpTransportContext context) { + return Mono.just(false); + } + + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientAuthorizationErrorHandlerTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientAuthorizationErrorHandlerTest.java new file mode 100644 index 000000000..2812522f5 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientAuthorizationErrorHandlerTest.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ +package io.modelcontextprotocol.client.transport.customizer; + +import java.net.http.HttpResponse; + +import io.modelcontextprotocol.common.McpTransportContext; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import static org.mockito.Mockito.mock; + +/** + * @author Daniel Garnier-Moiroux + */ +class McpHttpClientAuthorizationErrorHandlerTest { + + private final HttpResponse.ResponseInfo responseInfo = mock(HttpResponse.ResponseInfo.class); + + private final McpTransportContext context = McpTransportContext.EMPTY; + + @Test + void whenTrueThenRetry() { + McpHttpClientAuthorizationErrorHandler handler = McpHttpClientAuthorizationErrorHandler + .fromSync((info, ctx) -> true); + StepVerifier.create(handler.handle(responseInfo, context)).expectNext(true).verifyComplete(); + } + + @Test + void whenFalseThenError() { + McpHttpClientAuthorizationErrorHandler handler = McpHttpClientAuthorizationErrorHandler + .fromSync((info, ctx) -> false); + StepVerifier.create(handler.handle(responseInfo, context)).expectNext(false).verifyComplete(); + } + + @Test + void whenExceptionThenPropagate() { + McpHttpClientAuthorizationErrorHandler handler = McpHttpClientAuthorizationErrorHandler + .fromSync((info, ctx) -> { + throw new IllegalStateException("sync handler error"); + }); + StepVerifier.create(handler.handle(responseInfo, context)) + .expectErrorMatches(t -> t instanceof IllegalStateException && t.getMessage().equals("sync handler error")) + .verify(); + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java index b82d6eb2c..c4857e5b4 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java @@ -1,26 +1,24 @@ /* - * Copyright 2025-2025 the original author or authors. + * Copyright 2025-2026 the original author or authors. */ package io.modelcontextprotocol.client.transport; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; - import java.io.IOException; import java.net.InetSocketAddress; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; +import java.util.function.Predicate; import com.sun.net.httpserver.HttpServer; - +import io.modelcontextprotocol.client.transport.customizer.McpHttpClientAuthorizationErrorHandler; +import io.modelcontextprotocol.common.McpTransportContext; +import org.reactivestreams.Publisher; import io.modelcontextprotocol.server.transport.TomcatTestUtil; import io.modelcontextprotocol.spec.HttpHeaders; import io.modelcontextprotocol.spec.McpClientTransport; @@ -28,14 +26,30 @@ import io.modelcontextprotocol.spec.McpTransportException; import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException; import io.modelcontextprotocol.spec.ProtocolVersions; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.InstanceOfAssertFactories.type; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + /** * Tests for error handling changes in HttpClientStreamableHttpTransport. Specifically * tests the distinction between session-related errors and general transport errors for * 404 and 400 status codes. * * @author Christian Tzolov + * @author Daniel Garnier-Moiroux */ @Timeout(15) public class HttpClientStreamableHttpTransportErrorHandlingTest { @@ -46,11 +60,17 @@ public class HttpClientStreamableHttpTransportErrorHandlingTest { private HttpServer server; - private AtomicReference serverResponseStatus = new AtomicReference<>(200); + private final AtomicInteger serverResponseStatus = new AtomicInteger(200); + + private final AtomicInteger serverSseResponseStatus = new AtomicInteger(200); - private AtomicReference currentServerSessionId = new AtomicReference<>(null); + private final AtomicReference currentServerSessionId = new AtomicReference<>(null); - private AtomicReference lastReceivedSessionId = new AtomicReference<>(null); + private final AtomicReference lastReceivedSessionId = new AtomicReference<>(null); + + private final AtomicInteger processedMessagesCount = new AtomicInteger(0); + + private final AtomicInteger processedSseConnectCount = new AtomicInteger(0); private McpClientTransport transport; @@ -88,6 +108,20 @@ else if ("POST".equals(httpExchange.getRequestMethod())) { else { httpExchange.sendResponseHeaders(status, 0); } + processedMessagesCount.incrementAndGet(); + } + else if ("GET".equals(httpExchange.getRequestMethod())) { + int status = serverSseResponseStatus.get(); + if (status == 200) { + httpExchange.getResponseHeaders().set("Content-Type", "text/event-stream"); + httpExchange.sendResponseHeaders(200, 0); + String sseData = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"test\",\"params\":{}}\n\n"; + httpExchange.getResponseBody().write(sseData.getBytes()); + } + else { + httpExchange.sendResponseHeaders(status, 0); + } + processedSseConnectCount.incrementAndGet(); } httpExchange.close(); }); @@ -103,6 +137,7 @@ void stopServer() { if (server != null) { server.stop(0); } + StepVerifier.create(transport.closeGracefully()).verifyComplete(); } /** @@ -334,6 +369,386 @@ else if (status == 404) { StepVerifier.create(transport.closeGracefully()).verifyComplete(); } + @Nested + class AuthorizationError { + + @Nested + class SendMessage { + + @ParameterizedTest + @ValueSource(ints = { 401, 403 }) + void invokeHandler(int httpStatus) { + serverResponseStatus.set(httpStatus); + + AtomicReference capturedResponseInfo = new AtomicReference<>(); + AtomicReference capturedContext = new AtomicReference<>(); + + var authTransport = HttpClientStreamableHttpTransport.builder(HOST) + .authorizationErrorHandler((responseInfo, context) -> { + capturedResponseInfo.set(responseInfo); + capturedContext.set(context); + return Mono.just(false); + }) + .build(); + + StepVerifier.create(authTransport.sendMessage(createTestRequestMessage())) + .expectErrorMatches(authorizationError(httpStatus)) + .verify(); + assertThat(processedMessagesCount.get()).isEqualTo(1); + assertThat(capturedResponseInfo.get()).isNotNull(); + assertThat(capturedResponseInfo.get().statusCode()).isEqualTo(httpStatus); + assertThat(capturedContext.get()).isNotNull(); + + StepVerifier.create(authTransport.closeGracefully()).verifyComplete(); + } + + @Test + void defaultHandler() { + serverResponseStatus.set(401); + + var authTransport = HttpClientStreamableHttpTransport.builder(HOST).build(); + + StepVerifier.create(authTransport.sendMessage(createTestRequestMessage())) + .expectErrorMatches(authorizationError(401)) + .verify(); + assertThat(processedMessagesCount.get()).isEqualTo(1); + + StepVerifier.create(authTransport.closeGracefully()).verifyComplete(); + } + + @Test + void retry() { + serverResponseStatus.set(401); + var authTransport = HttpClientStreamableHttpTransport.builder(HOST) + .authorizationErrorHandler((responseInfo, context) -> { + serverResponseStatus.set(200); + return Mono.just(true); + }) + .build(); + StepVerifier.create(authTransport.sendMessage(createTestRequestMessage())).verifyComplete(); + // initial request + retry + assertThat(processedMessagesCount.get()).isEqualTo(2); + + StepVerifier.create(authTransport.closeGracefully()).verifyComplete(); + } + + @Test + void retryAtMostOnce() { + serverResponseStatus.set(401); + var authTransport = HttpClientStreamableHttpTransport.builder(HOST) + .authorizationErrorHandler((responseInfo, context) -> Mono.just(true)) + .build(); + StepVerifier.create(authTransport.sendMessage(createTestRequestMessage())) + .expectErrorMatches(authorizationError(401)) + .verify(); + // initial request + 1 retry (maxRetries default is 1) + assertThat(processedMessagesCount.get()).isEqualTo(2); + + StepVerifier.create(authTransport.closeGracefully()).verifyComplete(); + } + + @Test + void customMaxRetries() { + serverResponseStatus.set(401); + var authTransport = HttpClientStreamableHttpTransport.builder(HOST) + .authorizationErrorHandler(new McpHttpClientAuthorizationErrorHandler() { + @Override + public Publisher handle(HttpResponse.ResponseInfo responseInfo, + McpTransportContext context) { + return Mono.just(true); + } + + @Override + public int maxRetries() { + return 3; + } + }) + .build(); + StepVerifier.create(authTransport.sendMessage(createTestRequestMessage())) + .expectErrorMatches(authorizationError(401)) + .verify(); + // initial request + 3 retries + assertThat(processedMessagesCount.get()).isEqualTo(4); + + StepVerifier.create(authTransport.closeGracefully()).verifyComplete(); + } + + @Test + void noRetry() { + serverResponseStatus.set(401); + + var authTransport = HttpClientStreamableHttpTransport.builder(HOST) + .authorizationErrorHandler((responseInfo, context) -> Mono.just(false)) + .build(); + + StepVerifier.create(authTransport.sendMessage(createTestRequestMessage())) + .expectErrorMatches(authorizationError(401)) + .verify(); + assertThat(processedMessagesCount.get()).isEqualTo(1); + + StepVerifier.create(authTransport.closeGracefully()).verifyComplete(); + } + + @Test + void propagateHandlerError() { + serverResponseStatus.set(401); + var authTransport = HttpClientStreamableHttpTransport.builder(HOST) + .authorizationErrorHandler( + (responseInfo, context) -> Mono.error(new IllegalStateException("handler error"))) + .build(); + + StepVerifier.create(authTransport.sendMessage(createTestRequestMessage())) + .expectErrorMatches(throwable -> throwable instanceof IllegalStateException + && throwable.getMessage().equals("handler error")) + .verify(); + + StepVerifier.create(authTransport.closeGracefully()).verifyComplete(); + } + + @Test + void emptyHandler() { + serverResponseStatus.set(401); + var authTransport = HttpClientStreamableHttpTransport.builder(HOST) + .authorizationErrorHandler((responseInfo, context) -> Mono.empty()) + .build(); + + StepVerifier.create(authTransport.sendMessage(createTestRequestMessage())) + .expectErrorMatches(authorizationError(401)) + .verify(); + + StepVerifier.create(authTransport.closeGracefully()).verifyComplete(); + } + + } + + @Nested + class Connect { + + @ParameterizedTest + @ValueSource(ints = { 401, 403 }) + void invokeHandler(int httpStatus) { + serverSseResponseStatus.set(httpStatus); + @SuppressWarnings("unchecked") + AtomicReference capturedException = new AtomicReference<>(); + + AtomicReference capturedResponseInfo = new AtomicReference<>(); + AtomicReference capturedContext = new AtomicReference<>(); + + var authTransport = HttpClientStreamableHttpTransport.builder(HOST) + .authorizationErrorHandler((responseInfo, context) -> { + capturedResponseInfo.set(responseInfo); + capturedContext.set(context); + return Mono.just(false); + }) + .openConnectionOnStartup(true) + .build(); + authTransport.setExceptionHandler(capturedException::set); + + var messages = new ArrayList(); + StepVerifier.create(authTransport.connect(msg -> msg.doOnNext(messages::add))).verifyComplete(); + Awaitility.await() + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThat(processedSseConnectCount.get()).isEqualTo(1)); + assertThat(messages).isEmpty(); + assertThat(capturedResponseInfo.get()).isNotNull(); + assertThat(capturedResponseInfo.get().statusCode()).isEqualTo(httpStatus); + assertThat(capturedContext.get()).isNotNull(); + assertThat(capturedException.get()).hasMessage("Authorization error connecting to SSE stream") + .asInstanceOf(type(McpHttpClientTransportAuthorizationException.class)) + .extracting(McpHttpClientTransportAuthorizationException::getResponseInfo) + .extracting(HttpResponse.ResponseInfo::statusCode) + .isEqualTo(httpStatus); + + StepVerifier.create(authTransport.closeGracefully()).verifyComplete(); + } + + @Test + void defaultHandler() { + serverSseResponseStatus.set(401); + AtomicReference capturedException = new AtomicReference<>(); + var authTransport = HttpClientStreamableHttpTransport.builder(HOST) + .openConnectionOnStartup(true) + .build(); + authTransport.setExceptionHandler(capturedException::set); + + StepVerifier.create(authTransport.connect(msg -> msg)).verifyComplete(); + Awaitility.await() + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThat(processedSseConnectCount.get()).isEqualTo(1)); + assertThat(capturedException.get()).isInstanceOf(McpHttpClientTransportAuthorizationException.class); + + StepVerifier.create(authTransport.closeGracefully()).verifyComplete(); + } + + @Test + void retry() { + serverSseResponseStatus.set(401); + AtomicReference capturedException = new AtomicReference<>(); + var authTransport = HttpClientStreamableHttpTransport.builder(HOST) + .openConnectionOnStartup(true) + .authorizationErrorHandler((responseInfo, context) -> { + serverSseResponseStatus.set(200); + return Mono.just(true); + }) + .build(); + authTransport.setExceptionHandler(capturedException::set); + + var messages = new ArrayList(); + var messageHandlerClosed = new AtomicBoolean(false); + StepVerifier + .create(authTransport + .connect(msg -> msg.doOnNext(messages::add).doFinally(s -> messageHandlerClosed.set(true)))) + .verifyComplete(); + Awaitility.await() + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThat(messageHandlerClosed).isTrue()); + assertThat(processedSseConnectCount.get()).isEqualTo(2); + assertThat(messages).hasSize(1); + assertThat(capturedException.get()).isNull(); + assertThat(messageHandlerClosed.get()).isTrue(); + + StepVerifier.create(authTransport.closeGracefully()).verifyComplete(); + } + + @Test + void retryAtMostOnce() { + serverSseResponseStatus.set(401); + AtomicReference capturedException = new AtomicReference<>(); + var authTransport = HttpClientStreamableHttpTransport.builder(HOST) + .openConnectionOnStartup(true) + .authorizationErrorHandler((responseInfo, context) -> { + return Mono.just(true); + }) + .build(); + authTransport.setExceptionHandler(capturedException::set); + + var messages = new ArrayList(); + StepVerifier.create(authTransport.connect(msg -> msg.doOnNext(messages::add))).verifyComplete(); + Awaitility.await() + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThat(capturedException.get()).isNotNull()); + // initial request + 1 retry (maxRetries default is 1) + assertThat(processedSseConnectCount.get()).isEqualTo(2); + assertThat(messages).isEmpty(); + assertThat(capturedException.get()).isInstanceOf(McpHttpClientTransportAuthorizationException.class); + + StepVerifier.create(authTransport.closeGracefully()).verifyComplete(); + } + + @Test + void customMaxRetries() { + serverSseResponseStatus.set(401); + AtomicReference capturedException = new AtomicReference<>(); + var authTransport = HttpClientStreamableHttpTransport.builder(HOST) + .openConnectionOnStartup(true) + .authorizationErrorHandler(new McpHttpClientAuthorizationErrorHandler() { + @Override + public Publisher handle(HttpResponse.ResponseInfo responseInfo, + McpTransportContext context) { + return Mono.just(true); + } + + @Override + public int maxRetries() { + return 3; + } + }) + .build(); + authTransport.setExceptionHandler(capturedException::set); + + var messages = new ArrayList(); + StepVerifier.create(authTransport.connect(msg -> msg.doOnNext(messages::add))).verifyComplete(); + Awaitility.await() + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThat(capturedException.get()).isNotNull()); + // initial request + 3 retries + assertThat(processedSseConnectCount.get()).isEqualTo(4); + assertThat(messages).isEmpty(); + assertThat(capturedException.get()).isInstanceOf(McpHttpClientTransportAuthorizationException.class); + + StepVerifier.create(authTransport.closeGracefully()).verifyComplete(); + } + + @Test + void noRetry() { + serverSseResponseStatus.set(401); + AtomicReference capturedException = new AtomicReference<>(); + var authTransport = HttpClientStreamableHttpTransport.builder(HOST) + .openConnectionOnStartup(true) + .authorizationErrorHandler((responseInfo, context) -> { + // if there was a retry, the request would succeed. + serverSseResponseStatus.set(200); + return Mono.just(false); + }) + .build(); + authTransport.setExceptionHandler(capturedException::set); + + var messages = new ArrayList(); + StepVerifier.create(authTransport.connect(msg -> msg.doOnNext(messages::add))).verifyComplete(); + Awaitility.await() + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThat(processedSseConnectCount.get()).isEqualTo(1)); + assertThat(messages).isEmpty(); + assertThat(capturedException.get()).isInstanceOf(McpHttpClientTransportAuthorizationException.class); + + StepVerifier.create(authTransport.closeGracefully()).verifyComplete(); + } + + @Test + void emptyHandler() { + serverSseResponseStatus.set(401); + AtomicReference capturedException = new AtomicReference<>(); + var authTransport = HttpClientStreamableHttpTransport.builder(HOST) + .openConnectionOnStartup(true) + .authorizationErrorHandler((responseInfo, context) -> Mono.empty()) + .build(); + authTransport.setExceptionHandler(capturedException::set); + + var messages = new ArrayList(); + StepVerifier.create(authTransport.connect(msg -> msg.doOnNext(messages::add))).verifyComplete(); + Awaitility.await() + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThat(processedSseConnectCount.get()).isEqualTo(1)); + assertThat(messages).isEmpty(); + assertThat(capturedException.get()).isInstanceOf(McpHttpClientTransportAuthorizationException.class); + + StepVerifier.create(authTransport.closeGracefully()).verifyComplete(); + } + + @Test + void propagateHandlerError() { + serverSseResponseStatus.set(401); + AtomicReference capturedException = new AtomicReference<>(); + var authTransport = HttpClientStreamableHttpTransport.builder(HOST) + .openConnectionOnStartup(true) + .authorizationErrorHandler( + (responseInfo, context) -> Mono.error(new IllegalStateException("handler error"))) + .build(); + authTransport.setExceptionHandler(capturedException::set); + + var messages = new ArrayList(); + StepVerifier.create(authTransport.connect(msg -> msg.doOnNext(messages::add))).verifyComplete(); + Awaitility.await() + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThat(processedSseConnectCount.get()).isEqualTo(1)); + assertThat(messages).isEmpty(); + assertThat(capturedException.get()).isInstanceOf(IllegalStateException.class) + .hasMessage("handler error"); + + StepVerifier.create(authTransport.closeGracefully()).verifyComplete(); + } + + } + + private static Predicate authorizationError(int httpStatus) { + return throwable -> throwable instanceof McpHttpClientTransportAuthorizationException + && throwable.getMessage().contains("Authorization error") + && ((McpHttpClientTransportAuthorizationException) throwable).getResponseInfo() + .statusCode() == httpStatus; + } + + } + private McpSchema.JSONRPCRequest createTestRequestMessage() { var initializeRequest = new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_03_26, McpSchema.ClientCapabilities.builder().roots(true).build(), From c4b585795ece2a569c1f93ffbb801b0a90a7f65b Mon Sep 17 00:00:00 2001 From: Christian Tzolov <1351573+tzolov@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:41:49 +0100 Subject: [PATCH 10/15] fix: prepare POMs for Maven Central release readiness (#863) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix malformed SCM developerConnection URL (slash → colon) across all modules - Add mcp-json-jackson3 to mcp-bom dependency management - Update license URL to HTTPS - Fix POM's scm definitions Signed-off-by: Christian Tzolov Signed-off-by: Christian Tzolov --------- Signed-off-by: Christian Tzolov --- .../client-jdk-http-client/pom.xml | 11 +++--- .../client-spring-http-client/pom.xml | 6 +-- conformance-tests/pom.xml | 8 ++-- conformance-tests/server-servlet/pom.xml | 4 +- mcp-bom/pom.xml | 19 +++++++--- mcp-core/pom.xml | 20 +++++----- mcp-json-jackson2/pom.xml | 38 ++++++++++--------- mcp-json-jackson3/pom.xml | 38 ++++++++++--------- mcp-test/pom.xml | 8 ++-- mcp/pom.xml | 6 +-- pom.xml | 24 ++++++------ 11 files changed, 97 insertions(+), 85 deletions(-) diff --git a/conformance-tests/client-jdk-http-client/pom.xml b/conformance-tests/client-jdk-http-client/pom.xml index f30361438..54618f15c 100644 --- a/conformance-tests/client-jdk-http-client/pom.xml +++ b/conformance-tests/client-jdk-http-client/pom.xml @@ -16,14 +16,14 @@ https://github.com/modelcontextprotocol/java-sdk - git://github.com/modelcontextprotocol/java-sdk.git - git@github.com/modelcontextprotocol/java-sdk.git + scm:git:git://github.com/modelcontextprotocol/java-sdk.git + scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git true - + io.modelcontextprotocol.sdk @@ -57,7 +57,8 @@ - io.modelcontextprotocol.conformance.client.ConformanceJdkClientMcpClient + + io.modelcontextprotocol.conformance.client.ConformanceJdkClientMcpClient @@ -79,4 +80,4 @@ - + \ No newline at end of file diff --git a/conformance-tests/client-spring-http-client/pom.xml b/conformance-tests/client-spring-http-client/pom.xml index 46dae68ef..90ed576cf 100644 --- a/conformance-tests/client-spring-http-client/pom.xml +++ b/conformance-tests/client-spring-http-client/pom.xml @@ -16,8 +16,8 @@ https://github.com/modelcontextprotocol/java-sdk - git://github.com/modelcontextprotocol/java-sdk.git - git@github.com/modelcontextprotocol/java-sdk.git + scm:git:git://github.com/modelcontextprotocol/java-sdk.git + scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git @@ -106,4 +106,4 @@ - + \ No newline at end of file diff --git a/conformance-tests/pom.xml b/conformance-tests/pom.xml index d1bef2a24..7329fe849 100644 --- a/conformance-tests/pom.xml +++ b/conformance-tests/pom.xml @@ -16,18 +16,18 @@ https://github.com/modelcontextprotocol/java-sdk - git://github.com/modelcontextprotocol/java-sdk.git - git@github.com/modelcontextprotocol/java-sdk.git + scm:git:git://github.com/modelcontextprotocol/java-sdk.git + scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git true - + client-jdk-http-client client-spring-http-client server-servlet - + \ No newline at end of file diff --git a/conformance-tests/server-servlet/pom.xml b/conformance-tests/server-servlet/pom.xml index 66acea835..289599a5e 100644 --- a/conformance-tests/server-servlet/pom.xml +++ b/conformance-tests/server-servlet/pom.xml @@ -16,8 +16,8 @@ https://github.com/modelcontextprotocol/java-sdk - git://github.com/modelcontextprotocol/java-sdk.git - git@github.com/modelcontextprotocol/java-sdk.git + scm:git:git://github.com/modelcontextprotocol/java-sdk.git + scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git diff --git a/mcp-bom/pom.xml b/mcp-bom/pom.xml index fb6f3a32a..aa6cc7914 100644 --- a/mcp-bom/pom.xml +++ b/mcp-bom/pom.xml @@ -16,13 +16,13 @@ Java SDK MCP BOM Java SDK MCP Bill of Materials - https://github.com/modelcontextprotocol/java-sdk + https://github.com/modelcontextprotocol/java-sdk - - https://github.com/modelcontextprotocol/java-sdk - git://github.com/modelcontextprotocol/java-sdk.git - git@github.com/modelcontextprotocol/java-sdk.git - + + https://github.com/modelcontextprotocol/java-sdk + scm:git:git://github.com/modelcontextprotocol/java-sdk.git + scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git + @@ -47,6 +47,13 @@ ${project.version} + + + io.modelcontextprotocol.sdk + mcp-json-jackson3 + ${project.version} + + io.modelcontextprotocol.sdk diff --git a/mcp-core/pom.xml b/mcp-core/pom.xml index 4de0fba2b..3f7fa0b83 100644 --- a/mcp-core/pom.xml +++ b/mcp-core/pom.xml @@ -16,8 +16,8 @@ https://github.com/modelcontextprotocol/java-sdk - git://github.com/modelcontextprotocol/java-sdk.git - git@github.com/modelcontextprotocol/java-sdk.git + scm:git:git://github.com/modelcontextprotocol/java-sdk.git + scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git @@ -164,14 +164,14 @@ test - - - com.google.code.gson - gson - 2.10.1 - test - + + + com.google.code.gson + gson + 2.10.1 + test + - + \ No newline at end of file diff --git a/mcp-json-jackson2/pom.xml b/mcp-json-jackson2/pom.xml index f25877cd3..d36762aa0 100644 --- a/mcp-json-jackson2/pom.xml +++ b/mcp-json-jackson2/pom.xml @@ -13,11 +13,13 @@ Java MCP SDK JSON Jackson 2 Java MCP SDK JSON implementation based on Jackson 2 https://github.com/modelcontextprotocol/java-sdk + https://github.com/modelcontextprotocol/java-sdk - git://github.com/modelcontextprotocol/java-sdk.git - git@github.com/modelcontextprotocol/java-sdk.git + scm:git:git://github.com/modelcontextprotocol/java-sdk.git + scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git + @@ -62,21 +64,21 @@ - - com.fasterxml.jackson.core - jackson-databind - ${jackson2.version} - - - io.modelcontextprotocol.sdk - mcp-core - 1.1.0-SNAPSHOT - - - com.networknt - json-schema-validator - ${json-schema-validator-jackson2.version} - + + com.fasterxml.jackson.core + jackson-databind + ${jackson2.version} + + + io.modelcontextprotocol.sdk + mcp-core + 1.1.0-SNAPSHOT + + + com.networknt + json-schema-validator + ${json-schema-validator-jackson2.version} + org.assertj @@ -104,4 +106,4 @@ - + \ No newline at end of file diff --git a/mcp-json-jackson3/pom.xml b/mcp-json-jackson3/pom.xml index 99baf14e1..cd6ecaa3a 100644 --- a/mcp-json-jackson3/pom.xml +++ b/mcp-json-jackson3/pom.xml @@ -13,11 +13,13 @@ Java MCP SDK JSON Jackson 3 Java MCP SDK JSON implementation based on Jackson 3 https://github.com/modelcontextprotocol/java-sdk + https://github.com/modelcontextprotocol/java-sdk - git://github.com/modelcontextprotocol/java-sdk.git - git@github.com/modelcontextprotocol/java-sdk.git + scm:git:git://github.com/modelcontextprotocol/java-sdk.git + scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git + @@ -61,21 +63,21 @@ - - io.modelcontextprotocol.sdk - mcp-core - 1.1.0-SNAPSHOT - - - tools.jackson.core - jackson-databind - ${jackson3.version} - - - com.networknt - json-schema-validator - ${json-schema-validator-jackson3.version} - + + io.modelcontextprotocol.sdk + mcp-core + 1.1.0-SNAPSHOT + + + tools.jackson.core + jackson-databind + ${jackson3.version} + + + com.networknt + json-schema-validator + ${json-schema-validator-jackson3.version} + org.assertj @@ -103,4 +105,4 @@ - + \ No newline at end of file diff --git a/mcp-test/pom.xml b/mcp-test/pom.xml index 531c0bbc5..53fb84941 100644 --- a/mcp-test/pom.xml +++ b/mcp-test/pom.xml @@ -1,7 +1,7 @@ + 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"> 4.0.0 io.modelcontextprotocol.sdk @@ -16,8 +16,8 @@ https://github.com/modelcontextprotocol/java-sdk - git://github.com/modelcontextprotocol/java-sdk.git - git@github.com/modelcontextprotocol/java-sdk.git + scm:git:git://github.com/modelcontextprotocol/java-sdk.git + scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git diff --git a/mcp/pom.xml b/mcp/pom.xml index 937974228..5dc80163d 100644 --- a/mcp/pom.xml +++ b/mcp/pom.xml @@ -16,8 +16,8 @@ https://github.com/modelcontextprotocol/java-sdk - git://github.com/modelcontextprotocol/java-sdk.git - git@github.com/modelcontextprotocol/java-sdk.git + scm:git:git://github.com/modelcontextprotocol/java-sdk.git + scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git @@ -36,4 +36,4 @@ - + \ No newline at end of file diff --git a/pom.xml b/pom.xml index b1eedd38e..cdbeb25f2 100644 --- a/pom.xml +++ b/pom.xml @@ -13,8 +13,8 @@ https://github.com/modelcontextprotocol/java-sdk - git://github.com/modelcontextprotocol/java-sdk.git - git@github.com/modelcontextprotocol/java-sdk.git + scm:git:git://github.com/modelcontextprotocol/java-sdk.git + scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git Java SDK MCP Parent @@ -29,7 +29,7 @@ MIT License - http://www.opensource.org/licenses/mit-license.php + https://www.opensource.org/licenses/mit-license.php @@ -57,7 +57,7 @@ 17 17 17 - + 3.27.6 6.0.2 @@ -105,11 +105,11 @@ mcp-bom mcp - mcp-core - mcp-json-jackson2 - mcp-json-jackson3 + mcp-core + mcp-json-jackson2 + mcp-json-jackson3 mcp-test - conformance-tests + conformance-tests @@ -329,9 +329,9 @@ true central - - mcp-parent,conformance-tests,client-jdk-http-client,client-spring-http-client,server-servlet - + + mcp-parent,conformance-tests,client-jdk-http-client,client-spring-http-client,server-servlet + true @@ -387,4 +387,4 @@ - + \ No newline at end of file From cbb235fd32ea29c93f07642a7a6f83893672c8ec Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Fri, 13 Mar 2026 13:31:03 +0100 Subject: [PATCH 11/15] Next development version Signed-off-by: Christian Tzolov --- conformance-tests/client-jdk-http-client/pom.xml | 4 ++-- conformance-tests/client-spring-http-client/pom.xml | 2 +- conformance-tests/pom.xml | 2 +- conformance-tests/server-servlet/pom.xml | 4 ++-- mcp-bom/pom.xml | 2 +- mcp-core/pom.xml | 2 +- mcp-json-jackson2/pom.xml | 4 ++-- mcp-json-jackson3/pom.xml | 4 ++-- mcp-test/pom.xml | 8 ++++---- mcp/pom.xml | 6 +++--- pom.xml | 2 +- 11 files changed, 20 insertions(+), 20 deletions(-) diff --git a/conformance-tests/client-jdk-http-client/pom.xml b/conformance-tests/client-jdk-http-client/pom.xml index 54618f15c..f939cfa6c 100644 --- a/conformance-tests/client-jdk-http-client/pom.xml +++ b/conformance-tests/client-jdk-http-client/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk conformance-tests - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT client-jdk-http-client jar @@ -28,7 +28,7 @@ io.modelcontextprotocol.sdk mcp - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT diff --git a/conformance-tests/client-spring-http-client/pom.xml b/conformance-tests/client-spring-http-client/pom.xml index 90ed576cf..06b53887d 100644 --- a/conformance-tests/client-spring-http-client/pom.xml +++ b/conformance-tests/client-spring-http-client/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk conformance-tests - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT client-spring-http-client jar diff --git a/conformance-tests/pom.xml b/conformance-tests/pom.xml index 7329fe849..88ab7c4b0 100644 --- a/conformance-tests/pom.xml +++ b/conformance-tests/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT conformance-tests pom diff --git a/conformance-tests/server-servlet/pom.xml b/conformance-tests/server-servlet/pom.xml index 289599a5e..a80c7c4ec 100644 --- a/conformance-tests/server-servlet/pom.xml +++ b/conformance-tests/server-servlet/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk conformance-tests - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT server-servlet jar @@ -28,7 +28,7 @@ io.modelcontextprotocol.sdk mcp - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT diff --git a/mcp-bom/pom.xml b/mcp-bom/pom.xml index aa6cc7914..303520517 100644 --- a/mcp-bom/pom.xml +++ b/mcp-bom/pom.xml @@ -7,7 +7,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT mcp-bom diff --git a/mcp-core/pom.xml b/mcp-core/pom.xml index 3f7fa0b83..d622df0d1 100644 --- a/mcp-core/pom.xml +++ b/mcp-core/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT mcp-core jar diff --git a/mcp-json-jackson2/pom.xml b/mcp-json-jackson2/pom.xml index d36762aa0..5dd9a5ac1 100644 --- a/mcp-json-jackson2/pom.xml +++ b/mcp-json-jackson2/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT mcp-json-jackson2 jar @@ -72,7 +72,7 @@ io.modelcontextprotocol.sdk mcp-core - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT com.networknt diff --git a/mcp-json-jackson3/pom.xml b/mcp-json-jackson3/pom.xml index cd6ecaa3a..2afd474f6 100644 --- a/mcp-json-jackson3/pom.xml +++ b/mcp-json-jackson3/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT mcp-json-jackson3 jar @@ -66,7 +66,7 @@ io.modelcontextprotocol.sdk mcp-core - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT tools.jackson.core diff --git a/mcp-test/pom.xml b/mcp-test/pom.xml index 53fb84941..45e74717c 100644 --- a/mcp-test/pom.xml +++ b/mcp-test/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT mcp-test jar @@ -24,7 +24,7 @@ io.modelcontextprotocol.sdk mcp-core - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT @@ -159,7 +159,7 @@ io.modelcontextprotocol.sdk mcp-json-jackson3 - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT test @@ -170,7 +170,7 @@ io.modelcontextprotocol.sdk mcp-json-jackson2 - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT test diff --git a/mcp/pom.xml b/mcp/pom.xml index 5dc80163d..16fca0ba4 100644 --- a/mcp/pom.xml +++ b/mcp/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT mcp jar @@ -25,13 +25,13 @@ io.modelcontextprotocol.sdk mcp-json-jackson3 - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT io.modelcontextprotocol.sdk mcp-core - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT diff --git a/pom.xml b/pom.xml index cdbeb25f2..d738e26e6 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.1.0-SNAPSHOT + 2.0.0-SNAPSHOT pom https://github.com/modelcontextprotocol/java-sdk From 665ed51b9cde8d0d7b8a9785930acdf78d46f159 Mon Sep 17 00:00:00 2001 From: Prachi Pandey Date: Mon, 2 Mar 2026 11:08:20 +0530 Subject: [PATCH 12/15] feat: Add Enterprise Managed Authorization (SEP-990) support --- .../client/auth/AuthServerMetadata.java | 66 +++ ...DiscoverAndRequestJwtAuthGrantOptions.java | 165 ++++++ .../client/auth/EnterpriseAuth.java | 341 +++++++++++ .../auth/EnterpriseAuthAssertionContext.java | 52 ++ .../client/auth/EnterpriseAuthException.java | 32 + .../client/auth/EnterpriseAuthProvider.java | 205 +++++++ .../auth/EnterpriseAuthProviderOptions.java | 115 ++++ .../auth/ExchangeJwtBearerGrantOptions.java | 115 ++++ .../client/auth/JagTokenExchangeResponse.java | 87 +++ .../auth/JwtBearerAccessTokenResponse.java | 106 ++++ .../auth/RequestJwtAuthGrantOptions.java | 143 +++++ .../client/auth/EnterpriseAuthTest.java | 557 ++++++++++++++++++ .../json/gson/GsonMcpJsonMapperSupplier.java | 51 ++ ...contextprotocol.json.McpJsonMapperSupplier | 1 + 14 files changed, 2036 insertions(+) create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/auth/AuthServerMetadata.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/auth/DiscoverAndRequestJwtAuthGrantOptions.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuth.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthAssertionContext.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthException.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProvider.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProviderOptions.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/auth/ExchangeJwtBearerGrantOptions.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JagTokenExchangeResponse.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JwtBearerAccessTokenResponse.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/auth/RequestJwtAuthGrantOptions.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/spec/json/gson/GsonMcpJsonMapperSupplier.java create mode 100644 mcp-core/src/test/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/AuthServerMetadata.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/AuthServerMetadata.java new file mode 100644 index 000000000..321943a41 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/AuthServerMetadata.java @@ -0,0 +1,66 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * OAuth 2.0 Authorization Server Metadata as defined by RFC 8414. + *

+ * Used during Enterprise Managed Authorization (SEP-990) to discover the token endpoint + * of the enterprise Identity Provider and the MCP authorization server. + * + * @author MCP SDK Contributors + * @see RFC 8414 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class AuthServerMetadata { + + @JsonProperty("issuer") + private String issuer; + + @JsonProperty("token_endpoint") + private String tokenEndpoint; + + @JsonProperty("authorization_endpoint") + private String authorizationEndpoint; + + @JsonProperty("jwks_uri") + private String jwksUri; + + public String getIssuer() { + return issuer; + } + + public void setIssuer(String issuer) { + this.issuer = issuer; + } + + public String getTokenEndpoint() { + return tokenEndpoint; + } + + public void setTokenEndpoint(String tokenEndpoint) { + this.tokenEndpoint = tokenEndpoint; + } + + public String getAuthorizationEndpoint() { + return authorizationEndpoint; + } + + public void setAuthorizationEndpoint(String authorizationEndpoint) { + this.authorizationEndpoint = authorizationEndpoint; + } + + public String getJwksUri() { + return jwksUri; + } + + public void setJwksUri(String jwksUri) { + this.jwksUri = jwksUri; + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/DiscoverAndRequestJwtAuthGrantOptions.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/DiscoverAndRequestJwtAuthGrantOptions.java new file mode 100644 index 000000000..bb693dba4 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/DiscoverAndRequestJwtAuthGrantOptions.java @@ -0,0 +1,165 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import java.util.Objects; + +/** + * Options for {@link EnterpriseAuth#discoverAndRequestJwtAuthorizationGrant} — performs + * step 1 of the Enterprise Managed Authorization (SEP-990) flow by first discovering the + * IdP token endpoint via RFC 8414 metadata discovery, then requesting the JAG. + *

+ * If {@link #idpTokenEndpoint} is provided it is used directly and discovery is skipped. + * + * @author MCP SDK Contributors + */ +public class DiscoverAndRequestJwtAuthGrantOptions { + + /** + * The base URL of the enterprise IdP. Used as the root URL for RFC 8414 discovery + * ({@code /.well-known/oauth-authorization-server} or + * {@code /.well-known/openid-configuration}). + */ + private final String idpUrl; + + /** + * Optional override for the IdP's token endpoint. When provided, RFC 8414 discovery + * is skipped. + */ + private final String idpTokenEndpoint; + + /** The ID token (assertion) issued by the enterprise IdP. */ + private final String idToken; + + /** The OAuth 2.0 client ID registered at the enterprise IdP. */ + private final String clientId; + + /** The OAuth 2.0 client secret (may be {@code null} for public clients). */ + private final String clientSecret; + + /** The {@code audience} parameter for the token exchange request (optional). */ + private final String audience; + + /** The {@code resource} parameter for the token exchange request (optional). */ + private final String resource; + + /** The {@code scope} parameter for the token exchange request (optional). */ + private final String scope; + + private DiscoverAndRequestJwtAuthGrantOptions(Builder builder) { + this.idpUrl = Objects.requireNonNull(builder.idpUrl, "idpUrl must not be null"); + this.idpTokenEndpoint = builder.idpTokenEndpoint; + this.idToken = Objects.requireNonNull(builder.idToken, "idToken must not be null"); + this.clientId = Objects.requireNonNull(builder.clientId, "clientId must not be null"); + this.clientSecret = builder.clientSecret; + this.audience = builder.audience; + this.resource = builder.resource; + this.scope = builder.scope; + } + + public String getIdpUrl() { + return idpUrl; + } + + public String getIdpTokenEndpoint() { + return idpTokenEndpoint; + } + + public String getIdToken() { + return idToken; + } + + public String getClientId() { + return clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public String getAudience() { + return audience; + } + + public String getResource() { + return resource; + } + + public String getScope() { + return scope; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private String idpUrl; + + private String idpTokenEndpoint; + + private String idToken; + + private String clientId; + + private String clientSecret; + + private String audience; + + private String resource; + + private String scope; + + private Builder() { + } + + public Builder idpUrl(String idpUrl) { + this.idpUrl = idpUrl; + return this; + } + + public Builder idpTokenEndpoint(String idpTokenEndpoint) { + this.idpTokenEndpoint = idpTokenEndpoint; + return this; + } + + public Builder idToken(String idToken) { + this.idToken = idToken; + return this; + } + + public Builder clientId(String clientId) { + this.clientId = clientId; + return this; + } + + public Builder clientSecret(String clientSecret) { + this.clientSecret = clientSecret; + return this; + } + + public Builder audience(String audience) { + this.audience = audience; + return this; + } + + public Builder resource(String resource) { + this.resource = resource; + return this; + } + + public Builder scope(String scope) { + this.scope = scope; + return this; + } + + public DiscoverAndRequestJwtAuthGrantOptions build() { + return new DiscoverAndRequestJwtAuthGrantOptions(this); + } + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuth.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuth.java new file mode 100644 index 000000000..fe18dd0c1 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuth.java @@ -0,0 +1,341 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.McpJsonMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * Layer 2 utility class for the Enterprise Managed Authorization (SEP-990) flow. + *

+ * Provides static async methods for each discrete step of the two-step enterprise auth + * protocol: + *

    + *
  1. Step 1 — JAG request: Exchange an enterprise OIDC ID token for a JWT + * Authorization Grant (ID-JAG) at the enterprise IdP via RFC 8693 token exchange. + * Methods: {@link #requestJwtAuthorizationGrant} / + * {@link #discoverAndRequestJwtAuthorizationGrant}.
  2. + *
  3. Step 2 — access token exchange: Exchange the JAG for an OAuth 2.0 access + * token at the MCP authorization server via RFC 7523 JWT Bearer grant. Method: + * {@link #exchangeJwtBearerGrant}.
  4. + *
+ *

+ * For a higher-level, stateful integration that handles both steps and caches the + * resulting access token, use {@link EnterpriseAuthProvider} instead. + *

+ * All methods return {@link Mono} and require a {@link java.net.http.HttpClient} to be + * provided by the caller. They do not manage the lifecycle of the client. + * + * @author MCP SDK Contributors + * @see EnterpriseAuthProvider + * @see RFC 8414 — Authorization + * Server Metadata + * @see RFC 8693 — Token + * Exchange + * @see RFC 7523 — JWT Bearer + * Grant + */ +public final class EnterpriseAuth { + + private static final Logger logger = LoggerFactory.getLogger(EnterpriseAuth.class); + + /** + * Token type URI for OIDC ID tokens, used as the {@code subject_token_type} in the + * RFC 8693 token exchange request. + */ + public static final String TOKEN_TYPE_ID_TOKEN = "urn:ietf:params:oauth:token-type:id_token"; + + /** + * Token type URI for JWT Authorization Grants (ID-JAG), used as the + * {@code requested_token_type} in the token exchange request and validated as the + * {@code issued_token_type} in the response. + */ + public static final String TOKEN_TYPE_ID_JAG = "urn:ietf:params:oauth:token-type:id-jag"; + + /** + * Grant type URI for RFC 8693 token exchange requests. + */ + public static final String GRANT_TYPE_TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange"; + + /** + * Grant type URI for RFC 7523 JWT Bearer grant requests. + */ + public static final String GRANT_TYPE_JWT_BEARER = "urn:ietf:params:oauth:grant-type:jwt-bearer"; + + private static final String WELL_KNOWN_OAUTH = "/.well-known/oauth-authorization-server"; + + private static final String WELL_KNOWN_OPENID = "/.well-known/openid-configuration"; + + private EnterpriseAuth() { + } + + // ----------------------------------------------------------------------- + // Authorization server discovery (RFC 8414) + // ----------------------------------------------------------------------- + + /** + * Discovers the OAuth 2.0 authorization server metadata for the given base URL using + * RFC 8414. + *

+ * First attempts to retrieve metadata from + * {@code {url}/.well-known/oauth-authorization-server}. If that fails (non-200 + * response or network error), falls back to + * {@code {url}/.well-known/openid-configuration}. + * @param url the base URL of the authorization server or resource server + * @param httpClient the HTTP client to use for the discovery request + * @return a {@link Mono} emitting the parsed {@link AuthServerMetadata}, or an error + * of type {@link EnterpriseAuthException} if discovery fails + */ + public static Mono discoverAuthServerMetadata(String url, HttpClient httpClient) { + String baseUrl = url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + String oauthDiscoveryUrl = baseUrl + WELL_KNOWN_OAUTH; + String openIdDiscoveryUrl = baseUrl + WELL_KNOWN_OPENID; + logger.debug("Discovering authorization server metadata for {}", baseUrl); + return fetchAuthServerMetadata(oauthDiscoveryUrl, httpClient) + .onErrorResume(e -> fetchAuthServerMetadata(openIdDiscoveryUrl, httpClient)); + } + + private static Mono fetchAuthServerMetadata(String url, HttpClient httpClient) { + return Mono.fromFuture(() -> { + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .GET() + .header("Accept", "application/json") + .build(); + return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()); + }).flatMap(response -> { + if (response.statusCode() != 200) { + return Mono.error(new EnterpriseAuthException("Failed to discover authorization server metadata from " + + url + ": HTTP " + response.statusCode())); + } + try { + McpJsonMapper mapper = McpJsonDefaults.getMapper(); + AuthServerMetadata metadata = mapper.readValue(response.body(), AuthServerMetadata.class); + logger.debug("Discovered authorization server metadata from {}: issuer={}, tokenEndpoint={}", url, + metadata.getIssuer(), metadata.getTokenEndpoint()); + return Mono.just(metadata); + } + catch (Exception e) { + return Mono + .error(new EnterpriseAuthException("Failed to parse authorization server metadata from " + url, e)); + } + }); + } + + // ----------------------------------------------------------------------- + // Step 1 — JAG request (RFC 8693 token exchange) + // ----------------------------------------------------------------------- + + /** + * Requests a JWT Authorization Grant (ID-JAG) by performing an RFC 8693 token + * exchange at the specified token endpoint. + *

+ * Exchanges the enterprise OIDC ID token for an ID-JAG that can subsequently be + * presented to the MCP authorization server via {@link #exchangeJwtBearerGrant}. + *

+ * Validates that the response {@code issued_token_type} equals + * {@link #TOKEN_TYPE_ID_JAG} and that {@code token_type} is {@code N_A} + * (case-insensitive) per RFC 8693 §2.2.1. + * @param options request parameters including the IdP token endpoint, ID token, and + * client credentials + * @param httpClient the HTTP client to use + * @return a {@link Mono} emitting the JAG (the {@code access_token} value from the + * exchange response), or an error of type {@link EnterpriseAuthException} + */ + public static Mono requestJwtAuthorizationGrant(RequestJwtAuthGrantOptions options, HttpClient httpClient) { + return Mono.defer(() -> { + List params = new ArrayList<>(); + params.add(encode("grant_type") + "=" + encode(GRANT_TYPE_TOKEN_EXCHANGE)); + params.add(encode("subject_token") + "=" + encode(options.getIdToken())); + params.add(encode("subject_token_type") + "=" + encode(TOKEN_TYPE_ID_TOKEN)); + params.add(encode("requested_token_type") + "=" + encode(TOKEN_TYPE_ID_JAG)); + params.add(encode("client_id") + "=" + encode(options.getClientId())); + if (options.getClientSecret() != null) { + params.add(encode("client_secret") + "=" + encode(options.getClientSecret())); + } + if (options.getAudience() != null) { + params.add(encode("audience") + "=" + encode(options.getAudience())); + } + if (options.getResource() != null) { + params.add(encode("resource") + "=" + encode(options.getResource())); + } + if (options.getScope() != null) { + params.add(encode("scope") + "=" + encode(options.getScope())); + } + String body = String.join("&", params); + logger.debug("Requesting JAG token exchange at {}", options.getTokenEndpoint()); + HttpRequest request = HttpRequest.newBuilder(URI.create(options.getTokenEndpoint())) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .build(); + return Mono.fromFuture(() -> httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())); + }).flatMap(response -> { + if (response.statusCode() != 200) { + return Mono.error(new EnterpriseAuthException( + "JAG token exchange failed: HTTP " + response.statusCode() + " - " + response.body())); + } + try { + McpJsonMapper mapper = McpJsonDefaults.getMapper(); + JagTokenExchangeResponse tokenResponse = mapper.readValue(response.body(), + JagTokenExchangeResponse.class); + + // Validate per RFC 8693 §2.2.1 + if (!TOKEN_TYPE_ID_JAG.equalsIgnoreCase(tokenResponse.getIssuedTokenType())) { + return Mono.error(new EnterpriseAuthException("Unexpected issued_token_type in JAG response: " + + tokenResponse.getIssuedTokenType() + " (expected " + TOKEN_TYPE_ID_JAG + ")")); + } + if (!"N_A".equalsIgnoreCase(tokenResponse.getTokenType())) { + return Mono.error(new EnterpriseAuthException("Unexpected token_type in JAG response: " + + tokenResponse.getTokenType() + " (expected N_A per RFC 8693 §2.2.1)")); + } + if (tokenResponse.getAccessToken() == null || tokenResponse.getAccessToken().isBlank()) { + return Mono + .error(new EnterpriseAuthException("JAG token exchange response is missing access_token")); + } + logger.debug("JAG token exchange successful"); + return Mono.just(tokenResponse.getAccessToken()); + } + catch (EnterpriseAuthException e) { + return Mono.error(e); + } + catch (Exception e) { + return Mono.error(new EnterpriseAuthException("Failed to parse JAG token exchange response", e)); + } + }); + } + + /** + * Discovers the enterprise IdP's token endpoint via RFC 8414, then requests a JAG via + * RFC 8693 token exchange. + *

+ * If {@link DiscoverAndRequestJwtAuthGrantOptions#getIdpTokenEndpoint()} is set, the + * discovery step is skipped and the provided endpoint is used directly. + * @param options request parameters including the IdP base URL (for discovery), ID + * token, and client credentials + * @param httpClient the HTTP client to use + * @return a {@link Mono} emitting the JAG string, or an error of type + * {@link EnterpriseAuthException} + */ + public static Mono discoverAndRequestJwtAuthorizationGrant(DiscoverAndRequestJwtAuthGrantOptions options, + HttpClient httpClient) { + Mono tokenEndpointMono; + if (options.getIdpTokenEndpoint() != null) { + tokenEndpointMono = Mono.just(options.getIdpTokenEndpoint()); + } + else { + tokenEndpointMono = discoverAuthServerMetadata(options.getIdpUrl(), httpClient).flatMap(metadata -> { + if (metadata.getTokenEndpoint() == null) { + return Mono.error(new EnterpriseAuthException("No token_endpoint in IdP metadata at " + + options.getIdpUrl() + ". Ensure the IdP supports RFC 8414.")); + } + return Mono.just(metadata.getTokenEndpoint()); + }); + } + + return tokenEndpointMono.flatMap(tokenEndpoint -> { + RequestJwtAuthGrantOptions grantOptions = RequestJwtAuthGrantOptions.builder() + .tokenEndpoint(tokenEndpoint) + .idToken(options.getIdToken()) + .clientId(options.getClientId()) + .clientSecret(options.getClientSecret()) + .audience(options.getAudience()) + .resource(options.getResource()) + .scope(options.getScope()) + .build(); + return requestJwtAuthorizationGrant(grantOptions, httpClient); + }); + } + + // ----------------------------------------------------------------------- + // Step 2 — JWT Bearer grant exchange (RFC 7523) + // ----------------------------------------------------------------------- + + /** + * Exchanges a JWT Authorization Grant (ID-JAG) for an OAuth 2.0 access token at the + * MCP authorization server's token endpoint using RFC 7523. + *

+ * The returned {@link JwtBearerAccessTokenResponse} includes the access token and, if + * the server provided an {@code expires_in} value, an absolute + * {@link JwtBearerAccessTokenResponse#getExpiresAt() expiresAt} timestamp computed + * from the current system time. + * @param options request parameters including the MCP auth server token endpoint, JAG + * assertion, and client credentials + * @param httpClient the HTTP client to use + * @return a {@link Mono} emitting the {@link JwtBearerAccessTokenResponse}, or an + * error of type {@link EnterpriseAuthException} + */ + public static Mono exchangeJwtBearerGrant(ExchangeJwtBearerGrantOptions options, + HttpClient httpClient) { + return Mono.defer(() -> { + List params = new ArrayList<>(); + params.add(encode("grant_type") + "=" + encode(GRANT_TYPE_JWT_BEARER)); + params.add(encode("assertion") + "=" + encode(options.getAssertion())); + params.add(encode("client_id") + "=" + encode(options.getClientId())); + if (options.getClientSecret() != null) { + params.add(encode("client_secret") + "=" + encode(options.getClientSecret())); + } + if (options.getScope() != null) { + params.add(encode("scope") + "=" + encode(options.getScope())); + } + String body = String.join("&", params); + logger.debug("Exchanging JWT bearer grant at {}", options.getTokenEndpoint()); + HttpRequest request = HttpRequest.newBuilder(URI.create(options.getTokenEndpoint())) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .build(); + return Mono.fromFuture(() -> httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())); + }).flatMap(response -> { + if (response.statusCode() != 200) { + return Mono.error(new EnterpriseAuthException( + "JWT bearer grant exchange failed: HTTP " + response.statusCode() + " - " + response.body())); + } + try { + McpJsonMapper mapper = McpJsonDefaults.getMapper(); + JwtBearerAccessTokenResponse tokenResponse = mapper.readValue(response.body(), + JwtBearerAccessTokenResponse.class); + + if (tokenResponse.getAccessToken() == null || tokenResponse.getAccessToken().isBlank()) { + return Mono.error( + new EnterpriseAuthException("JWT bearer grant exchange response is missing access_token")); + } + // Compute absolute expiry from relative expires_in + if (tokenResponse.getExpiresIn() != null) { + tokenResponse.setExpiresAt(Instant.now().plusSeconds(tokenResponse.getExpiresIn())); + } + logger.debug("JWT bearer grant exchange successful; expires_in={}", tokenResponse.getExpiresIn()); + return Mono.just(tokenResponse); + } + catch (EnterpriseAuthException e) { + return Mono.error(e); + } + catch (Exception e) { + return Mono.error(new EnterpriseAuthException("Failed to parse JWT bearer grant exchange response", e)); + } + }); + } + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthAssertionContext.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthAssertionContext.java new file mode 100644 index 000000000..6726f6d00 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthAssertionContext.java @@ -0,0 +1,52 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import java.net.URI; +import java.util.Objects; + +/** + * Context passed to the assertion callback in {@link EnterpriseAuthProvider}. + *

+ * Contains the resource URL of the MCP server and the URL of the authorization server + * that was discovered for that resource. The callback uses this context to obtain a + * suitable assertion (e.g., an OIDC ID token) from the enterprise IdP. + * + * @author MCP SDK Contributors + */ +public class EnterpriseAuthAssertionContext { + + private final URI resourceUrl; + + private final URI authorizationServerUrl; + + /** + * Creates a new {@link EnterpriseAuthAssertionContext}. + * @param resourceUrl the URL of the MCP resource being accessed (must not be + * {@code null}) + * @param authorizationServerUrl the URL of the MCP authorization server discovered + * for the resource (must not be {@code null}) + */ + public EnterpriseAuthAssertionContext(URI resourceUrl, URI authorizationServerUrl) { + this.resourceUrl = Objects.requireNonNull(resourceUrl, "resourceUrl must not be null"); + this.authorizationServerUrl = Objects.requireNonNull(authorizationServerUrl, + "authorizationServerUrl must not be null"); + } + + /** + * Returns the URL of the MCP resource being accessed. + */ + public URI getResourceUrl() { + return resourceUrl; + } + + /** + * Returns the URL of the MCP authorization server for the resource. + */ + public URI getAuthorizationServerUrl() { + return authorizationServerUrl; + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthException.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthException.java new file mode 100644 index 000000000..26d4e87dd --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthException.java @@ -0,0 +1,32 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +/** + * Exception thrown when an error occurs during the Enterprise Managed Authorization + * (SEP-990) flow. + * + * @author MCP SDK Contributors + */ +public class EnterpriseAuthException extends RuntimeException { + + /** + * Creates a new {@code EnterpriseAuthException} with the given message. + * @param message the error message + */ + public EnterpriseAuthException(String message) { + super(message); + } + + /** + * Creates a new {@code EnterpriseAuthException} with the given message and cause. + * @param message the error message + * @param cause the underlying cause + */ + public EnterpriseAuthException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProvider.java new file mode 100644 index 000000000..ecba04694 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProvider.java @@ -0,0 +1,205 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; + +import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer; +import io.modelcontextprotocol.common.McpTransportContext; +import org.reactivestreams.Publisher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * Layer 3 implementation of Enterprise Managed Authorization (SEP-990). + *

+ * Implements {@link McpAsyncHttpClientRequestCustomizer} so that it can be registered + * directly with any HTTP transport. On each request it: + *

    + *
  1. Checks an in-memory access token cache.
  2. + *
  3. If the cache is empty or the token is expired (within a 30-second buffer), it + * performs the full enterprise auth flow: + *
      + *
    1. Discovers the MCP authorization server metadata via RFC 8414.
    2. + *
    3. Invokes the {@link EnterpriseAuthProviderOptions#getAssertionCallback() assertion + * callback} to obtain a JWT Authorization Grant (ID-JAG) from the enterprise IdP.
    4. + *
    5. Exchanges the JAG for an OAuth 2.0 access token via RFC 7523 at the MCP + * authorization server's token endpoint.
    6. + *
    7. Caches the access token.
    8. + *
    + *
  4. + *
  5. Adds an {@code Authorization: Bearer {token}} header to the outgoing request.
  6. + *
+ * + *

Usage

+ * + *
{@code
+ * EnterpriseAuthProvider provider = new EnterpriseAuthProvider(
+ *     EnterpriseAuthProviderOptions.builder()
+ *         .clientId("my-client-id")
+ *         .clientSecret("my-client-secret")
+ *         .assertionCallback(ctx -> {
+ *             // Step 1: exchange your enterprise ID token for a JAG
+ *             return EnterpriseAuth.discoverAndRequestJwtAuthorizationGrant(
+ *                 DiscoverAndRequestJwtAuthGrantOptions.builder()
+ *                     .idpUrl(ctx.getAuthorizationServerUrl().toString())
+ *                     .idToken(myIdTokenSupplier.get())
+ *                     .clientId("idp-client-id")
+ *                     .build(),
+ *                 httpClient);
+ *         })
+ *         .build());
+ *
+ * // Register with an HTTP transport
+ * HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(serverUrl)
+ *     .httpRequestCustomizer(provider)
+ *     .build();
+ * }
+ * + * @author MCP SDK Contributors + * @see EnterpriseAuth + * @see EnterpriseAuthProviderOptions + */ +public class EnterpriseAuthProvider implements McpAsyncHttpClientRequestCustomizer { + + private static final Logger logger = LoggerFactory.getLogger(EnterpriseAuthProvider.class); + + /** + * Proactive refresh buffer: treat a token as expired this many seconds before its + * actual expiry to avoid using a token that expires mid-flight. + */ + private static final Duration EXPIRY_BUFFER = Duration.ofSeconds(30); + + private final EnterpriseAuthProviderOptions options; + + private final HttpClient httpClient; + + private final AtomicReference cachedTokenRef = new AtomicReference<>(); + + /** + * Creates a new {@link EnterpriseAuthProvider} using the default {@link HttpClient}. + * @param options provider options including client credentials and the assertion + * callback (must not be {@code null}) + */ + public EnterpriseAuthProvider(EnterpriseAuthProviderOptions options) { + this(options, HttpClient.newHttpClient()); + } + + /** + * Creates a new {@link EnterpriseAuthProvider} with a custom {@link HttpClient}. + *

+ * Use this constructor when you need to configure TLS, proxies, or other HTTP client + * settings. + * @param options provider options (must not be {@code null}) + * @param httpClient the HTTP client to use for token discovery and exchange requests + * (must not be {@code null}) + */ + public EnterpriseAuthProvider(EnterpriseAuthProviderOptions options, HttpClient httpClient) { + this.options = Objects.requireNonNull(options, "options must not be null"); + this.httpClient = Objects.requireNonNull(httpClient, "httpClient must not be null"); + } + + /** + * Injects an {@code Authorization: Bearer} header into the outgoing HTTP request, + * obtaining or refreshing the access token as needed. + */ + @Override + public Publisher customize(HttpRequest.Builder builder, String method, URI endpoint, + String body, McpTransportContext context) { + return getAccessToken(endpoint).map(token -> builder.header("Authorization", "Bearer " + token)); + } + + /** + * Invalidates the cached access token, forcing the next request to perform a full + * enterprise auth flow. + *

+ * Useful after receiving a {@code 401 Unauthorized} response from the MCP server. + */ + public void invalidateCache() { + logger.debug("Invalidating cached enterprise auth token"); + cachedTokenRef.set(null); + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + private Mono getAccessToken(URI endpoint) { + JwtBearerAccessTokenResponse cached = cachedTokenRef.get(); + if (cached != null && !isExpiredOrNearlyExpired(cached)) { + logger.debug("Using cached enterprise auth token"); + return Mono.just(cached.getAccessToken()); + } + logger.debug("Cached enterprise auth token is absent or expired; fetching new token"); + return fetchNewToken(endpoint).doOnNext(response -> { + cachedTokenRef.set(response); + logger.debug("Cached new enterprise auth token; expires_in={}", + response.getExpiresIn() != null ? response.getExpiresIn() + "s" : "unknown"); + }).map(JwtBearerAccessTokenResponse::getAccessToken); + } + + private boolean isExpiredOrNearlyExpired(JwtBearerAccessTokenResponse token) { + Instant expiresAt = token.getExpiresAt(); + if (expiresAt == null) { + return false; + } + return Instant.now().isAfter(expiresAt.minus(EXPIRY_BUFFER)); + } + + private Mono fetchNewToken(URI endpoint) { + URI resourceBaseUri = deriveBaseUri(endpoint); + logger.debug("Discovering MCP authorization server for resource {}", resourceBaseUri); + + return EnterpriseAuth.discoverAuthServerMetadata(resourceBaseUri.toString(), httpClient).flatMap(metadata -> { + if (metadata.getTokenEndpoint() == null) { + return Mono.error(new EnterpriseAuthException("No token_endpoint in authorization server metadata for " + + resourceBaseUri + ". Ensure the MCP server supports RFC 8414.")); + } + + // Resolve the authorization server URL: prefer issuer, fall back to base URI + URI authServerUri; + if (metadata.getIssuer() != null && !metadata.getIssuer().isBlank()) { + authServerUri = URI.create(metadata.getIssuer()); + } + else { + authServerUri = resourceBaseUri; + } + + EnterpriseAuthAssertionContext assertionContext = new EnterpriseAuthAssertionContext(resourceBaseUri, + authServerUri); + logger.debug("Invoking assertion callback for resourceUrl={}, authServerUrl={}", resourceBaseUri, + authServerUri); + + return options.getAssertionCallback().apply(assertionContext).flatMap(assertion -> { + ExchangeJwtBearerGrantOptions exchangeOptions = ExchangeJwtBearerGrantOptions.builder() + .tokenEndpoint(metadata.getTokenEndpoint()) + .assertion(assertion) + .clientId(options.getClientId()) + .clientSecret(options.getClientSecret()) + .scope(options.getScope()) + .build(); + return EnterpriseAuth.exchangeJwtBearerGrant(exchangeOptions, httpClient); + }); + }); + } + + /** + * Extracts the scheme+host+port from the given URI, dropping any path, query, or + * fragment. This is the URL against which RFC 8414 discovery is performed. + */ + private static URI deriveBaseUri(URI uri) { + int port = uri.getPort(); + String base = uri.getScheme() + "://" + uri.getHost() + (port != -1 ? ":" + port : ""); + return URI.create(base); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProviderOptions.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProviderOptions.java new file mode 100644 index 000000000..2f696f1c0 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProviderOptions.java @@ -0,0 +1,115 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import java.util.Objects; +import java.util.function.Function; + +import reactor.core.publisher.Mono; + +/** + * Configuration options for {@link EnterpriseAuthProvider}. + *

+ * At minimum, {@link #clientId} and {@link #assertionCallback} are required. + * + * @author MCP SDK Contributors + */ +public class EnterpriseAuthProviderOptions { + + /** + * The OAuth 2.0 client ID registered at the MCP authorization server. Required. + */ + private final String clientId; + + /** + * The OAuth 2.0 client secret. Optional for public clients. + */ + private final String clientSecret; + + /** + * The {@code scope} parameter to request when exchanging the JWT bearer grant. + * Optional. + */ + private final String scope; + + /** + * Callback that obtains an assertion (ID token / JAG) for the given context. + *

+ * The callback receives an {@link EnterpriseAuthAssertionContext} describing the MCP + * resource and its authorization server, and must return a {@link Mono} that emits + * the assertion string (e.g., an OIDC ID token from the enterprise IdP). + *

+ * Required. + */ + private final Function> assertionCallback; + + private EnterpriseAuthProviderOptions(Builder builder) { + this.clientId = Objects.requireNonNull(builder.clientId, "clientId must not be null"); + this.clientSecret = builder.clientSecret; + this.scope = builder.scope; + this.assertionCallback = Objects.requireNonNull(builder.assertionCallback, + "assertionCallback must not be null"); + } + + public String getClientId() { + return clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public String getScope() { + return scope; + } + + public Function> getAssertionCallback() { + return assertionCallback; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private String clientId; + + private String clientSecret; + + private String scope; + + private Function> assertionCallback; + + private Builder() { + } + + public Builder clientId(String clientId) { + this.clientId = clientId; + return this; + } + + public Builder clientSecret(String clientSecret) { + this.clientSecret = clientSecret; + return this; + } + + public Builder scope(String scope) { + this.scope = scope; + return this; + } + + public Builder assertionCallback(Function> assertionCallback) { + this.assertionCallback = assertionCallback; + return this; + } + + public EnterpriseAuthProviderOptions build() { + return new EnterpriseAuthProviderOptions(this); + } + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/ExchangeJwtBearerGrantOptions.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/ExchangeJwtBearerGrantOptions.java new file mode 100644 index 000000000..0d7e8bfa3 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/ExchangeJwtBearerGrantOptions.java @@ -0,0 +1,115 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import java.util.Objects; + +/** + * Options for {@link EnterpriseAuth#exchangeJwtBearerGrant} — performs step 2 of the + * Enterprise Managed Authorization (SEP-990) flow. + *

+ * Posts an RFC 7523 JWT Bearer grant exchange to the MCP authorization server's token + * endpoint, exchanging the JAG (JWT Authorization Grant / ID-JAG) for a standard OAuth + * 2.0 access token that can be used to call the MCP server. + * + * @author MCP SDK Contributors + * @see RFC 7523 + */ +public class ExchangeJwtBearerGrantOptions { + + /** The full URL of the MCP authorization server's token endpoint. */ + private final String tokenEndpoint; + + /** The JWT Authorization Grant (ID-JAG) obtained from step 1. */ + private final String assertion; + + /** The OAuth 2.0 client ID registered at the MCP authorization server. */ + private final String clientId; + + /** The OAuth 2.0 client secret (may be {@code null} for public clients). */ + private final String clientSecret; + + /** The {@code scope} parameter for the token request (optional). */ + private final String scope; + + private ExchangeJwtBearerGrantOptions(Builder builder) { + this.tokenEndpoint = Objects.requireNonNull(builder.tokenEndpoint, "tokenEndpoint must not be null"); + this.assertion = Objects.requireNonNull(builder.assertion, "assertion must not be null"); + this.clientId = Objects.requireNonNull(builder.clientId, "clientId must not be null"); + this.clientSecret = builder.clientSecret; + this.scope = builder.scope; + } + + public String getTokenEndpoint() { + return tokenEndpoint; + } + + public String getAssertion() { + return assertion; + } + + public String getClientId() { + return clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public String getScope() { + return scope; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private String tokenEndpoint; + + private String assertion; + + private String clientId; + + private String clientSecret; + + private String scope; + + private Builder() { + } + + public Builder tokenEndpoint(String tokenEndpoint) { + this.tokenEndpoint = tokenEndpoint; + return this; + } + + public Builder assertion(String assertion) { + this.assertion = assertion; + return this; + } + + public Builder clientId(String clientId) { + this.clientId = clientId; + return this; + } + + public Builder clientSecret(String clientSecret) { + this.clientSecret = clientSecret; + return this; + } + + public Builder scope(String scope) { + this.scope = scope; + return this; + } + + public ExchangeJwtBearerGrantOptions build() { + return new ExchangeJwtBearerGrantOptions(this); + } + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JagTokenExchangeResponse.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JagTokenExchangeResponse.java new file mode 100644 index 000000000..35f1d6532 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JagTokenExchangeResponse.java @@ -0,0 +1,87 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * RFC 8693 Token Exchange response for the JAG (JWT Authorization Grant) flow. + *

+ * Returned by the enterprise IdP when exchanging an ID Token for a JWT Authorization + * Grant (ID-JAG) during Enterprise Managed Authorization (SEP-990). + *

+ * The three key fields are: + *

    + *
  • {@code access_token} — the issued JAG (despite the name, not an OAuth access + * token)
  • + *
  • {@code issued_token_type} — must be + * {@code urn:ietf:params:oauth:token-type:id-jag}
  • + *
  • {@code token_type} — must be {@code N_A} (case-insensitive, per RFC 8693 + * §2.2.1)
  • + *
+ * + * @author MCP SDK Contributors + * @see RFC 8693 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class JagTokenExchangeResponse { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("issued_token_type") + private String issuedTokenType; + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("scope") + private String scope; + + @JsonProperty("expires_in") + private Integer expiresIn; + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getIssuedTokenType() { + return issuedTokenType; + } + + public void setIssuedTokenType(String issuedTokenType) { + this.issuedTokenType = issuedTokenType; + } + + public String getTokenType() { + return tokenType; + } + + public void setTokenType(String tokenType) { + this.tokenType = tokenType; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public Integer getExpiresIn() { + return expiresIn; + } + + public void setExpiresIn(Integer expiresIn) { + this.expiresIn = expiresIn; + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JwtBearerAccessTokenResponse.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JwtBearerAccessTokenResponse.java new file mode 100644 index 000000000..bba6daa0b --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JwtBearerAccessTokenResponse.java @@ -0,0 +1,106 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import java.time.Instant; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * OAuth 2.0 access token response returned by the MCP authorization server after a + * successful RFC 7523 JWT Bearer grant exchange. + *

+ * This is the result of step 2 in the Enterprise Managed Authorization (SEP-990) flow: + * exchanging the JWT Authorization Grant (ID-JAG) for an access token at the MCP Server's + * authorization server. + * + * @author MCP SDK Contributors + * @see RFC 7523 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class JwtBearerAccessTokenResponse { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("expires_in") + private Integer expiresIn; + + @JsonProperty("scope") + private String scope; + + @JsonProperty("refresh_token") + private String refreshToken; + + /** + * The absolute time at which this token expires. Computed from {@code expires_in} + * upon deserialization by {@link EnterpriseAuth}. Marked {@code transient} so that + * JSON mappers skip this field during deserialization. + */ + private transient Instant expiresAt; + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getTokenType() { + return tokenType; + } + + public void setTokenType(String tokenType) { + this.tokenType = tokenType; + } + + public Integer getExpiresIn() { + return expiresIn; + } + + public void setExpiresIn(Integer expiresIn) { + this.expiresIn = expiresIn; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } + + /** + * Returns {@code true} if this token has expired (or has no expiry information). + */ + public boolean isExpired() { + if (expiresAt == null) { + return false; + } + return Instant.now().isAfter(expiresAt); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/RequestJwtAuthGrantOptions.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/RequestJwtAuthGrantOptions.java new file mode 100644 index 000000000..913c4fdd5 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/RequestJwtAuthGrantOptions.java @@ -0,0 +1,143 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import java.util.Objects; + +/** + * Options for {@link EnterpriseAuth#requestJwtAuthorizationGrant} — performs step 1 of + * the Enterprise Managed Authorization (SEP-990) flow using a known token endpoint. + *

+ * Posts an RFC 8693 token exchange request to the enterprise IdP's token endpoint and + * returns the JAG (JWT Authorization Grant / ID-JAG token). + * + * @author MCP SDK Contributors + */ +public class RequestJwtAuthGrantOptions { + + /** The full URL of the enterprise IdP's token endpoint. */ + private final String tokenEndpoint; + + /** The ID token (assertion) issued by the enterprise IdP. */ + private final String idToken; + + /** The OAuth 2.0 client ID registered at the enterprise IdP. */ + private final String clientId; + + /** The OAuth 2.0 client secret (may be {@code null} for public clients). */ + private final String clientSecret; + + /** The {@code audience} parameter for the token exchange request (optional). */ + private final String audience; + + /** The {@code resource} parameter for the token exchange request (optional). */ + private final String resource; + + /** The {@code scope} parameter for the token exchange request (optional). */ + private final String scope; + + private RequestJwtAuthGrantOptions(Builder builder) { + this.tokenEndpoint = Objects.requireNonNull(builder.tokenEndpoint, "tokenEndpoint must not be null"); + this.idToken = Objects.requireNonNull(builder.idToken, "idToken must not be null"); + this.clientId = Objects.requireNonNull(builder.clientId, "clientId must not be null"); + this.clientSecret = builder.clientSecret; + this.audience = builder.audience; + this.resource = builder.resource; + this.scope = builder.scope; + } + + public String getTokenEndpoint() { + return tokenEndpoint; + } + + public String getIdToken() { + return idToken; + } + + public String getClientId() { + return clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public String getAudience() { + return audience; + } + + public String getResource() { + return resource; + } + + public String getScope() { + return scope; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private String tokenEndpoint; + + private String idToken; + + private String clientId; + + private String clientSecret; + + private String audience; + + private String resource; + + private String scope; + + private Builder() { + } + + public Builder tokenEndpoint(String tokenEndpoint) { + this.tokenEndpoint = tokenEndpoint; + return this; + } + + public Builder idToken(String idToken) { + this.idToken = idToken; + return this; + } + + public Builder clientId(String clientId) { + this.clientId = clientId; + return this; + } + + public Builder clientSecret(String clientSecret) { + this.clientSecret = clientSecret; + return this; + } + + public Builder audience(String audience) { + this.audience = audience; + return this; + } + + public Builder resource(String resource) { + this.resource = resource; + return this; + } + + public Builder scope(String scope) { + this.scope = scope; + return this; + } + + public RequestJwtAuthGrantOptions build() { + return new RequestJwtAuthGrantOptions(this); + } + + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java new file mode 100644 index 000000000..f7be577cd --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java @@ -0,0 +1,557 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import io.modelcontextprotocol.common.McpTransportContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for {@link EnterpriseAuth} and {@link EnterpriseAuthProvider}. + * + * @author MCP SDK Contributors + */ +class EnterpriseAuthTest { + + private HttpServer server; + + private String baseUrl; + + private HttpClient httpClient; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress(0), 0); + server.start(); + int port = server.getAddress().getPort(); + baseUrl = "http://localhost:" + port; + httpClient = HttpClient.newHttpClient(); + } + + @AfterEach + void stopServer() { + if (server != null) { + server.stop(0); + } + } + + // ----------------------------------------------------------------------- + // discoverAuthServerMetadata — success paths + // ----------------------------------------------------------------------- + + @Test + void discoverAuthServerMetadata_oauthWellKnown_success() { + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, """ + { + "issuer": "https://auth.example.com", + "token_endpoint": "https://auth.example.com/token", + "authorization_endpoint": "https://auth.example.com/authorize" + }""")); + + StepVerifier.create(EnterpriseAuth.discoverAuthServerMetadata(baseUrl, httpClient)).assertNext(metadata -> { + assertThat(metadata.getIssuer()).isEqualTo("https://auth.example.com"); + assertThat(metadata.getTokenEndpoint()).isEqualTo("https://auth.example.com/token"); + assertThat(metadata.getAuthorizationEndpoint()).isEqualTo("https://auth.example.com/authorize"); + }).verifyComplete(); + } + + @Test + void discoverAuthServerMetadata_fallsBackToOpenIdConfiguration() { + // Primary endpoint returns 404 + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 404, "")); + // Fallback endpoint succeeds + server.createContext("/.well-known/openid-configuration", exchange -> sendJson(exchange, 200, """ + { + "issuer": "https://idp.example.com", + "token_endpoint": "https://idp.example.com/token" + }""")); + + StepVerifier.create(EnterpriseAuth.discoverAuthServerMetadata(baseUrl, httpClient)) + .assertNext(metadata -> assertThat(metadata.getTokenEndpoint()).isEqualTo("https://idp.example.com/token")) + .verifyComplete(); + } + + @Test + void discoverAuthServerMetadata_bothFail_emitsError() { + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 500, "")); + server.createContext("/.well-known/openid-configuration", exchange -> sendJson(exchange, 500, "")); + + StepVerifier.create(EnterpriseAuth.discoverAuthServerMetadata(baseUrl, httpClient)) + .expectErrorMatches(e -> e instanceof EnterpriseAuthException && e.getMessage().contains("HTTP 500")) + .verify(); + } + + @Test + void discoverAuthServerMetadata_stripsTrailingSlash() { + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, """ + {"issuer":"https://auth.example.com","token_endpoint":"https://auth.example.com/token"}""")); + + // Provide URL with trailing slash — should still work + StepVerifier.create(EnterpriseAuth.discoverAuthServerMetadata(baseUrl + "/", httpClient)) + .assertNext(metadata -> assertThat(metadata.getIssuer()).isEqualTo("https://auth.example.com")) + .verifyComplete(); + } + + // ----------------------------------------------------------------------- + // requestJwtAuthorizationGrant — success and validation + // ----------------------------------------------------------------------- + + @Test + void requestJwtAuthorizationGrant_success() { + server.createContext("/token", exchange -> { + String body = new String(exchange.getRequestBody().readAllBytes()); + assertThat(body).contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange"); + assertThat(body).contains("subject_token=my-id-token"); + assertThat(body).contains("subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid_token"); + assertThat(body).contains("requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid-jag"); + assertThat(body).contains("client_id=my-client"); + + sendJson(exchange, 200, """ + { + "access_token": "my-jag-token", + "issued_token_type": "urn:ietf:params:oauth:token-type:id-jag", + "token_type": "N_A" + }"""); + }); + + RequestJwtAuthGrantOptions options = RequestJwtAuthGrantOptions.builder() + .tokenEndpoint(baseUrl + "/token") + .idToken("my-id-token") + .clientId("my-client") + .build(); + + StepVerifier.create(EnterpriseAuth.requestJwtAuthorizationGrant(options, httpClient)) + .expectNext("my-jag-token") + .verifyComplete(); + } + + @Test + void requestJwtAuthorizationGrant_includesOptionalParams() { + server.createContext("/token", exchange -> { + String body = new String(exchange.getRequestBody().readAllBytes()); + assertThat(body).contains("client_secret=s3cr3t"); + assertThat(body).contains("audience=my-audience"); + assertThat(body).contains("resource=https%3A%2F%2Fmcp.example.com"); + assertThat(body).contains("scope=openid+profile"); + + sendJson(exchange, 200, """ + { + "access_token": "the-jag", + "issued_token_type": "urn:ietf:params:oauth:token-type:id-jag", + "token_type": "N_A" + }"""); + }); + + RequestJwtAuthGrantOptions options = RequestJwtAuthGrantOptions.builder() + .tokenEndpoint(baseUrl + "/token") + .idToken("tok") + .clientId("cid") + .clientSecret("s3cr3t") + .audience("my-audience") + .resource("https://mcp.example.com") + .scope("openid profile") + .build(); + + StepVerifier.create(EnterpriseAuth.requestJwtAuthorizationGrant(options, httpClient)) + .expectNext("the-jag") + .verifyComplete(); + } + + @Test + void requestJwtAuthorizationGrant_wrongIssuedTokenType_emitsError() { + server.createContext("/token", exchange -> sendJson(exchange, 200, """ + { + "access_token": "tok", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer" + }""")); + + RequestJwtAuthGrantOptions options = RequestJwtAuthGrantOptions.builder() + .tokenEndpoint(baseUrl + "/token") + .idToken("id-tok") + .clientId("cid") + .build(); + + StepVerifier.create(EnterpriseAuth.requestJwtAuthorizationGrant(options, httpClient)) + .expectErrorMatches( + e -> e instanceof EnterpriseAuthException && e.getMessage().contains("issued_token_type")) + .verify(); + } + + @Test + void requestJwtAuthorizationGrant_wrongTokenType_emitsError() { + server.createContext("/token", exchange -> sendJson(exchange, 200, """ + { + "access_token": "tok", + "issued_token_type": "urn:ietf:params:oauth:token-type:id-jag", + "token_type": "Bearer" + }""")); + + RequestJwtAuthGrantOptions options = RequestJwtAuthGrantOptions.builder() + .tokenEndpoint(baseUrl + "/token") + .idToken("id-tok") + .clientId("cid") + .build(); + + StepVerifier.create(EnterpriseAuth.requestJwtAuthorizationGrant(options, httpClient)) + .expectErrorMatches(e -> e instanceof EnterpriseAuthException && e.getMessage().contains("token_type")) + .verify(); + } + + @Test + void requestJwtAuthorizationGrant_httpError_emitsError() { + server.createContext("/token", exchange -> sendJson(exchange, 400, "{\"error\":\"invalid_client\"}")); + + RequestJwtAuthGrantOptions options = RequestJwtAuthGrantOptions.builder() + .tokenEndpoint(baseUrl + "/token") + .idToken("id-tok") + .clientId("cid") + .build(); + + StepVerifier.create(EnterpriseAuth.requestJwtAuthorizationGrant(options, httpClient)) + .expectErrorMatches(e -> e instanceof EnterpriseAuthException && e.getMessage().contains("HTTP 400")) + .verify(); + } + + // ----------------------------------------------------------------------- + // discoverAndRequestJwtAuthorizationGrant + // ----------------------------------------------------------------------- + + @Test + void discoverAndRequestJwtAuthorizationGrant_discoversAndExchanges() { + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/token\"}")); + server.createContext("/token", exchange -> sendJson(exchange, 200, """ + { + "access_token": "discovered-jag", + "issued_token_type": "urn:ietf:params:oauth:token-type:id-jag", + "token_type": "N_A" + }""")); + + DiscoverAndRequestJwtAuthGrantOptions options = DiscoverAndRequestJwtAuthGrantOptions.builder() + .idpUrl(baseUrl) + .idToken("my-id-tok") + .clientId("cid") + .build(); + + StepVerifier.create(EnterpriseAuth.discoverAndRequestJwtAuthorizationGrant(options, httpClient)) + .expectNext("discovered-jag") + .verifyComplete(); + } + + @Test + void discoverAndRequestJwtAuthorizationGrant_overriddenTokenEndpoint_skipsDiscovery() { + // No well-known handler registered — if discovery were attempted, connection + // would fail + server.createContext("/direct-token", exchange -> sendJson(exchange, 200, """ + { + "access_token": "direct-jag", + "issued_token_type": "urn:ietf:params:oauth:token-type:id-jag", + "token_type": "N_A" + }""")); + + DiscoverAndRequestJwtAuthGrantOptions options = DiscoverAndRequestJwtAuthGrantOptions.builder() + .idpUrl(baseUrl) + .idpTokenEndpoint(baseUrl + "/direct-token") + .idToken("my-id-tok") + .clientId("cid") + .build(); + + StepVerifier.create(EnterpriseAuth.discoverAndRequestJwtAuthorizationGrant(options, httpClient)) + .expectNext("direct-jag") + .verifyComplete(); + } + + // ----------------------------------------------------------------------- + // exchangeJwtBearerGrant + // ----------------------------------------------------------------------- + + @Test + void exchangeJwtBearerGrant_success() { + server.createContext("/token", exchange -> { + String body = new String(exchange.getRequestBody().readAllBytes()); + assertThat(body).contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer"); + assertThat(body).contains("assertion=my-jag"); + assertThat(body).contains("client_id=cid"); + + sendJson(exchange, 200, """ + { + "access_token": "the-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "mcp" + }"""); + }); + + ExchangeJwtBearerGrantOptions options = ExchangeJwtBearerGrantOptions.builder() + .tokenEndpoint(baseUrl + "/token") + .assertion("my-jag") + .clientId("cid") + .build(); + + StepVerifier.create(EnterpriseAuth.exchangeJwtBearerGrant(options, httpClient)).assertNext(response -> { + assertThat(response.getAccessToken()).isEqualTo("the-access-token"); + assertThat(response.getTokenType()).isEqualTo("Bearer"); + assertThat(response.getExpiresIn()).isEqualTo(3600); + assertThat(response.getScope()).isEqualTo("mcp"); + assertThat(response.getExpiresAt()).isNotNull(); + assertThat(response.isExpired()).isFalse(); + }).verifyComplete(); + } + + @Test + void exchangeJwtBearerGrant_missingAccessToken_emitsError() { + server.createContext("/token", exchange -> sendJson(exchange, 200, """ + {"token_type": "Bearer"}""")); + + ExchangeJwtBearerGrantOptions options = ExchangeJwtBearerGrantOptions.builder() + .tokenEndpoint(baseUrl + "/token") + .assertion("jag") + .clientId("cid") + .build(); + + StepVerifier.create(EnterpriseAuth.exchangeJwtBearerGrant(options, httpClient)) + .expectErrorMatches(e -> e instanceof EnterpriseAuthException && e.getMessage().contains("access_token")) + .verify(); + } + + @Test + void exchangeJwtBearerGrant_httpError_emitsError() { + server.createContext("/token", exchange -> sendJson(exchange, 401, "{\"error\":\"invalid_client\"}")); + + ExchangeJwtBearerGrantOptions options = ExchangeJwtBearerGrantOptions.builder() + .tokenEndpoint(baseUrl + "/token") + .assertion("jag") + .clientId("cid") + .build(); + + StepVerifier.create(EnterpriseAuth.exchangeJwtBearerGrant(options, httpClient)) + .expectErrorMatches(e -> e instanceof EnterpriseAuthException && e.getMessage().contains("HTTP 401")) + .verify(); + } + + // ----------------------------------------------------------------------- + // EnterpriseAuthProvider + // ----------------------------------------------------------------------- + + @Test + void enterpriseAuthProvider_injectsAuthorizationHeader() { + // Auth server discovery + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); + // JWT bearer grant exchange + server.createContext("/mcp-token", exchange -> sendJson(exchange, 200, """ + { + "access_token": "final-access-token", + "token_type": "Bearer", + "expires_in": 3600 + }""")); + + // The assertion callback simulates having already obtained a JAG from the IdP + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("client-id") + .assertionCallback(ctx -> Mono.just("pre-obtained-jag")) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + + URI endpoint = URI.create(baseUrl + "/mcp"); + HttpRequest.Builder builder = HttpRequest.newBuilder(endpoint); + + StepVerifier + .create(Mono.from(provider.customize(builder, "POST", endpoint, "{}", McpTransportContext.EMPTY)) + .map(HttpRequest.Builder::build) + .map(req -> req.headers().firstValue("Authorization").orElse(null))) + .expectNext("Bearer final-access-token") + .verifyComplete(); + } + + @Test + void enterpriseAuthProvider_cachesPreviousToken() { + int[] callCount = { 0 }; + + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); + server.createContext("/mcp-token", exchange -> { + callCount[0]++; + sendJson(exchange, 200, """ + { + "access_token": "cached-token", + "token_type": "Bearer", + "expires_in": 3600 + }"""); + }); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("client-id") + .assertionCallback(ctx -> Mono.just("jag")) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + + URI endpoint = URI.create(baseUrl + "/mcp"); + HttpRequest.Builder builder1 = HttpRequest.newBuilder(endpoint); + HttpRequest.Builder builder2 = HttpRequest.newBuilder(endpoint); + + // First request — fetches token + Mono.from(provider.customize(builder1, "POST", endpoint, null, McpTransportContext.EMPTY)).block(); + // Second request — should use cache + Mono.from(provider.customize(builder2, "POST", endpoint, null, McpTransportContext.EMPTY)).block(); + + assertThat(callCount[0]).isEqualTo(1); + } + + @Test + void enterpriseAuthProvider_invalidateCache_forcesRefetch() { + int[] callCount = { 0 }; + + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); + server.createContext("/mcp-token", exchange -> { + callCount[0]++; + sendJson(exchange, 200, """ + { + "access_token": "refreshed-token", + "token_type": "Bearer", + "expires_in": 3600 + }"""); + }); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("client-id") + .assertionCallback(ctx -> Mono.just("jag")) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + + URI endpoint = URI.create(baseUrl + "/mcp"); + + // First request + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + assertThat(callCount[0]).isEqualTo(1); + + // Invalidate + provider.invalidateCache(); + + // Second request — cache cleared, must fetch again + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + assertThat(callCount[0]).isEqualTo(2); + } + + @Test + void enterpriseAuthProvider_discoveryFails_emitsError() { + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 500, "")); + server.createContext("/.well-known/openid-configuration", exchange -> sendJson(exchange, 500, "")); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("cid") + .assertionCallback(ctx -> Mono.just("jag")) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + URI endpoint = URI.create(baseUrl + "/mcp"); + + StepVerifier.create(Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY))) + .expectErrorMatches(e -> e instanceof EnterpriseAuthException) + .verify(); + } + + @Test + void enterpriseAuthProvider_assertionCallbackError_emitsError() { + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("cid") + .assertionCallback(ctx -> Mono.error(new RuntimeException("IdP unreachable"))) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + URI endpoint = URI.create(baseUrl + "/mcp"); + + StepVerifier + .create(Mono.from(provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, + McpTransportContext.EMPTY))) + .expectErrorMatches(e -> e instanceof RuntimeException && e.getMessage().contains("IdP unreachable")) + .verify(); + } + + // ----------------------------------------------------------------------- + // EnterpriseAuthProviderOptions — validation + // ----------------------------------------------------------------------- + + @Test + void providerOptions_nullClientId_throws() { + assertThatThrownBy( + () -> EnterpriseAuthProviderOptions.builder().assertionCallback(ctx -> Mono.just("j")).build()) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("clientId"); + } + + @Test + void providerOptions_nullCallback_throws() { + assertThatThrownBy(() -> EnterpriseAuthProviderOptions.builder().clientId("cid").build()) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("assertionCallback"); + } + + // ----------------------------------------------------------------------- + // JwtBearerAccessTokenResponse helpers + // ----------------------------------------------------------------------- + + @Test + void jwtBearerAccessTokenResponse_isExpired_whenPastExpiresAt() { + JwtBearerAccessTokenResponse response = new JwtBearerAccessTokenResponse(); + response.setAccessToken("tok"); + response.setExpiresAt(java.time.Instant.now().minusSeconds(10)); + assertThat(response.isExpired()).isTrue(); + } + + @Test + void jwtBearerAccessTokenResponse_notExpired_whenNoExpiresAt() { + JwtBearerAccessTokenResponse response = new JwtBearerAccessTokenResponse(); + response.setAccessToken("tok"); + assertThat(response.isExpired()).isFalse(); + } + + // ----------------------------------------------------------------------- + // Helper + // ----------------------------------------------------------------------- + + private static void sendJson(HttpExchange exchange, int statusCode, String body) { + try { + byte[] bytes = body.getBytes(java.nio.charset.StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(statusCode, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } + catch (IOException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/spec/json/gson/GsonMcpJsonMapperSupplier.java b/mcp-core/src/test/java/io/modelcontextprotocol/spec/json/gson/GsonMcpJsonMapperSupplier.java new file mode 100644 index 000000000..c0e1baedd --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/spec/json/gson/GsonMcpJsonMapperSupplier.java @@ -0,0 +1,51 @@ +package io.modelcontextprotocol.spec.json.gson; + +import java.lang.reflect.Field; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.gson.FieldNamingStrategy; +import com.google.gson.GsonBuilder; +import com.google.gson.ToNumberPolicy; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.McpJsonMapperSupplier; + +/** + * Test-only {@link McpJsonMapperSupplier} backed by Gson. Registered via + * {@code META-INF/services} so that {@code McpJsonDefaults.getMapper()} works in unit + * tests without requiring a Jackson module on the classpath. + *

+ * The Gson instance is configured with a {@link FieldNamingStrategy} that reads Jackson's + * {@link JsonProperty} annotation so that snake_case JSON fields (e.g. + * {@code token_endpoint}) map correctly to camelCase Java fields annotated with + * {@code @JsonProperty("token_endpoint")}. + */ +public class GsonMcpJsonMapperSupplier implements McpJsonMapperSupplier { + + @Override + public McpJsonMapper get() { + var gson = new GsonBuilder().serializeNulls() + .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) + .setNumberToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) + .setFieldNamingStrategy(new JacksonPropertyFieldNamingStrategy()) + .create(); + return new GsonMcpJsonMapper(gson); + } + + /** + * Resolves a field name using the value of a {@link JsonProperty} annotation if + * present, otherwise falls back to the Java field name. + */ + private static final class JacksonPropertyFieldNamingStrategy implements FieldNamingStrategy { + + @Override + public String translateName(Field field) { + JsonProperty annotation = field.getAnnotation(JsonProperty.class); + if (annotation != null && annotation.value() != null && !annotation.value().isEmpty()) { + return annotation.value(); + } + return field.getName(); + } + + } + +} diff --git a/mcp-core/src/test/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier b/mcp-core/src/test/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier new file mode 100644 index 000000000..5bf822b4a --- /dev/null +++ b/mcp-core/src/test/resources/META-INF/services/io.modelcontextprotocol.json.McpJsonMapperSupplier @@ -0,0 +1 @@ +io.modelcontextprotocol.spec.json.gson.GsonMcpJsonMapperSupplier From 443e6e589f03ac1d6afca9e0e52f67e445e0a028 Mon Sep 17 00:00:00 2001 From: Prachi Pandey Date: Mon, 16 Mar 2026 11:58:36 +0530 Subject: [PATCH 13/15] Address PR review comments for enterprise managed authorization --- ...DiscoverAndRequestJwtAuthGrantOptions.java | 127 ++++++------------ .../client/auth/EnterpriseAuth.java | 96 ++++++++----- .../client/auth/EnterpriseAuthProvider.java | 16 ++- .../auth/ExchangeJwtBearerGrantOptions.java | 6 + .../client/auth/JagTokenExchangeResponse.java | 7 +- .../auth/RequestJwtAuthGrantOptions.java | 9 +- .../client/auth/EnterpriseAuthTest.java | 98 +++++++++++++- 7 files changed, 231 insertions(+), 128 deletions(-) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/DiscoverAndRequestJwtAuthGrantOptions.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/DiscoverAndRequestJwtAuthGrantOptions.java index bb693dba4..22f5921b3 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/DiscoverAndRequestJwtAuthGrantOptions.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/DiscoverAndRequestJwtAuthGrantOptions.java @@ -7,15 +7,19 @@ import java.util.Objects; /** - * Options for {@link EnterpriseAuth#discoverAndRequestJwtAuthorizationGrant} — performs - * step 1 of the Enterprise Managed Authorization (SEP-990) flow by first discovering the - * IdP token endpoint via RFC 8414 metadata discovery, then requesting the JAG. + * Options for {@link EnterpriseAuth#discoverAndRequestJwtAuthorizationGrant} — extends + * {@link RequestJwtAuthGrantOptions} with IdP discovery support. *

- * If {@link #idpTokenEndpoint} is provided it is used directly and discovery is skipped. + * Performs step 1 of the Enterprise Managed Authorization (SEP-990) flow by first + * discovering the IdP token endpoint via RFC 8414 metadata discovery, then requesting the + * JAG. + *

+ * If {@link #getIdpTokenEndpoint()} is provided it is used directly and discovery is + * skipped. * * @author MCP SDK Contributors */ -public class DiscoverAndRequestJwtAuthGrantOptions { +public class DiscoverAndRequestJwtAuthGrantOptions extends RequestJwtAuthGrantOptions { /** * The base URL of the enterprise IdP. Used as the root URL for RFC 8414 discovery @@ -24,95 +28,33 @@ public class DiscoverAndRequestJwtAuthGrantOptions { */ private final String idpUrl; - /** - * Optional override for the IdP's token endpoint. When provided, RFC 8414 discovery - * is skipped. - */ - private final String idpTokenEndpoint; - - /** The ID token (assertion) issued by the enterprise IdP. */ - private final String idToken; - - /** The OAuth 2.0 client ID registered at the enterprise IdP. */ - private final String clientId; - - /** The OAuth 2.0 client secret (may be {@code null} for public clients). */ - private final String clientSecret; - - /** The {@code audience} parameter for the token exchange request (optional). */ - private final String audience; - - /** The {@code resource} parameter for the token exchange request (optional). */ - private final String resource; - - /** The {@code scope} parameter for the token exchange request (optional). */ - private final String scope; - private DiscoverAndRequestJwtAuthGrantOptions(Builder builder) { + super(builder); this.idpUrl = Objects.requireNonNull(builder.idpUrl, "idpUrl must not be null"); - this.idpTokenEndpoint = builder.idpTokenEndpoint; - this.idToken = Objects.requireNonNull(builder.idToken, "idToken must not be null"); - this.clientId = Objects.requireNonNull(builder.clientId, "clientId must not be null"); - this.clientSecret = builder.clientSecret; - this.audience = builder.audience; - this.resource = builder.resource; - this.scope = builder.scope; } public String getIdpUrl() { return idpUrl; } + /** + * Returns the optional pre-configured IdP token endpoint. When non-null, RFC 8414 + * discovery is skipped and this endpoint is used directly. + *

+ * This is a convenience method equivalent to {@link #getTokenEndpoint()}. + */ public String getIdpTokenEndpoint() { - return idpTokenEndpoint; - } - - public String getIdToken() { - return idToken; - } - - public String getClientId() { - return clientId; - } - - public String getClientSecret() { - return clientSecret; - } - - public String getAudience() { - return audience; - } - - public String getResource() { - return resource; - } - - public String getScope() { - return scope; + return getTokenEndpoint(); } public static Builder builder() { return new Builder(); } - public static final class Builder { + public static final class Builder extends RequestJwtAuthGrantOptions.Builder { private String idpUrl; - private String idpTokenEndpoint; - - private String idToken; - - private String clientId; - - private String clientSecret; - - private String audience; - - private String resource; - - private String scope; - private Builder() { } @@ -121,41 +63,60 @@ public Builder idpUrl(String idpUrl) { return this; } + /** + * Optional override for the IdP's token endpoint. When set, RFC 8414 discovery is + * skipped and this endpoint is used directly. + *

+ * Equivalent to calling {@link #tokenEndpoint(String)}. + */ public Builder idpTokenEndpoint(String idpTokenEndpoint) { - this.idpTokenEndpoint = idpTokenEndpoint; + super.tokenEndpoint(idpTokenEndpoint); + return this; + } + + @Override + public Builder tokenEndpoint(String tokenEndpoint) { + super.tokenEndpoint(tokenEndpoint); return this; } + @Override public Builder idToken(String idToken) { - this.idToken = idToken; + super.idToken(idToken); return this; } + @Override public Builder clientId(String clientId) { - this.clientId = clientId; + super.clientId(clientId); return this; } + @Override public Builder clientSecret(String clientSecret) { - this.clientSecret = clientSecret; + super.clientSecret(clientSecret); return this; } + @Override public Builder audience(String audience) { - this.audience = audience; + super.audience(audience); return this; } + @Override public Builder resource(String resource) { - this.resource = resource; + super.resource(resource); return this; } + @Override public Builder scope(String scope) { - this.scope = scope; + super.scope(scope); return this; } + @Override public DiscoverAndRequestJwtAuthGrantOptions build() { return new DiscoverAndRequestJwtAuthGrantOptions(this); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuth.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuth.java index fe18dd0c1..fe17eba16 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuth.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuth.java @@ -12,6 +12,7 @@ import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.ArrayList; +import java.util.Base64; import java.util.List; import io.modelcontextprotocol.json.McpJsonDefaults; @@ -159,22 +160,22 @@ private static Mono fetchAuthServerMetadata(String url, Http public static Mono requestJwtAuthorizationGrant(RequestJwtAuthGrantOptions options, HttpClient httpClient) { return Mono.defer(() -> { List params = new ArrayList<>(); - params.add(encode("grant_type") + "=" + encode(GRANT_TYPE_TOKEN_EXCHANGE)); - params.add(encode("subject_token") + "=" + encode(options.getIdToken())); - params.add(encode("subject_token_type") + "=" + encode(TOKEN_TYPE_ID_TOKEN)); - params.add(encode("requested_token_type") + "=" + encode(TOKEN_TYPE_ID_JAG)); - params.add(encode("client_id") + "=" + encode(options.getClientId())); + params.add(encodeParam("grant_type", GRANT_TYPE_TOKEN_EXCHANGE)); + params.add(encodeParam("subject_token", options.getIdToken())); + params.add(encodeParam("subject_token_type", TOKEN_TYPE_ID_TOKEN)); + params.add(encodeParam("requested_token_type", TOKEN_TYPE_ID_JAG)); + params.add(encodeParam("client_id", options.getClientId())); if (options.getClientSecret() != null) { - params.add(encode("client_secret") + "=" + encode(options.getClientSecret())); + params.add(encodeParam("client_secret", options.getClientSecret())); } if (options.getAudience() != null) { - params.add(encode("audience") + "=" + encode(options.getAudience())); + params.add(encodeParam("audience", options.getAudience())); } if (options.getResource() != null) { - params.add(encode("resource") + "=" + encode(options.getResource())); + params.add(encodeParam("resource", options.getResource())); } if (options.getScope() != null) { - params.add(encode("scope") + "=" + encode(options.getScope())); + params.add(encodeParam("scope", options.getScope())); } String body = String.join("&", params); logger.debug("Requesting JAG token exchange at {}", options.getTokenEndpoint()); @@ -195,20 +196,8 @@ public static Mono requestJwtAuthorizationGrant(RequestJwtAuthGrantOptio JagTokenExchangeResponse.class); // Validate per RFC 8693 §2.2.1 - if (!TOKEN_TYPE_ID_JAG.equalsIgnoreCase(tokenResponse.getIssuedTokenType())) { - return Mono.error(new EnterpriseAuthException("Unexpected issued_token_type in JAG response: " - + tokenResponse.getIssuedTokenType() + " (expected " + TOKEN_TYPE_ID_JAG + ")")); - } - if (!"N_A".equalsIgnoreCase(tokenResponse.getTokenType())) { - return Mono.error(new EnterpriseAuthException("Unexpected token_type in JAG response: " - + tokenResponse.getTokenType() + " (expected N_A per RFC 8693 §2.2.1)")); - } - if (tokenResponse.getAccessToken() == null || tokenResponse.getAccessToken().isBlank()) { - return Mono - .error(new EnterpriseAuthException("JAG token exchange response is missing access_token")); - } - logger.debug("JAG token exchange successful"); - return Mono.just(tokenResponse.getAccessToken()); + return validateJAGTokenExchangeResponse(tokenResponse) + .doOnNext(token -> logger.debug("JAG token exchange successful")); } catch (EnterpriseAuthException e) { return Mono.error(e); @@ -234,6 +223,8 @@ public static Mono requestJwtAuthorizationGrant(RequestJwtAuthGrantOptio public static Mono discoverAndRequestJwtAuthorizationGrant(DiscoverAndRequestJwtAuthGrantOptions options, HttpClient httpClient) { Mono tokenEndpointMono; + // If the caller already discovered (or otherwise knows) the IdP token endpoint, + // skip RFC 8414 metadata discovery and use the pre-configured value directly. if (options.getIdpTokenEndpoint() != null) { tokenEndpointMono = Mono.just(options.getIdpTokenEndpoint()); } @@ -283,21 +274,25 @@ public static Mono exchangeJwtBearerGrant(Exchange HttpClient httpClient) { return Mono.defer(() -> { List params = new ArrayList<>(); - params.add(encode("grant_type") + "=" + encode(GRANT_TYPE_JWT_BEARER)); - params.add(encode("assertion") + "=" + encode(options.getAssertion())); - params.add(encode("client_id") + "=" + encode(options.getClientId())); - if (options.getClientSecret() != null) { - params.add(encode("client_secret") + "=" + encode(options.getClientSecret())); - } + params.add(encodeParam("grant_type", GRANT_TYPE_JWT_BEARER)); + params.add(encodeParam("assertion", options.getAssertion())); if (options.getScope() != null) { - params.add(encode("scope") + "=" + encode(options.getScope())); + params.add(encodeParam("scope", options.getScope())); } String body = String.join("&", params); + // Use client_secret_basic (RFC 6749 §2.3.1): send credentials in the + // Authorization header rather than the request body. This matches the + // token_endpoint_auth_method declared by the provider and is required by + // SEP-990 conformance tests. + String secret = options.getClientSecret() != null ? options.getClientSecret() : ""; + String credentials = Base64.getEncoder() + .encodeToString((options.getClientId() + ":" + secret).getBytes(StandardCharsets.UTF_8)); logger.debug("Exchanging JWT bearer grant at {}", options.getTokenEndpoint()); HttpRequest request = HttpRequest.newBuilder(URI.create(options.getTokenEndpoint())) .POST(HttpRequest.BodyPublishers.ofString(body)) .header("Content-Type", "application/x-www-form-urlencoded") .header("Accept", "application/json") + .header("Authorization", "Basic " + credentials) .build(); return Mono.fromFuture(() -> httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())); }).flatMap(response -> { @@ -318,6 +313,14 @@ public static Mono exchangeJwtBearerGrant(Exchange if (tokenResponse.getExpiresIn() != null) { tokenResponse.setExpiresAt(Instant.now().plusSeconds(tokenResponse.getExpiresIn())); } + // RFC 7523 (JWT Bearer Grant) is a stateless grant: the client presents a + // signed JWT assertion directly to obtain an access token, with no + // authorization code or refresh token involved. If the AS returns a + // refresh_token anyway, it is intentionally ignored — using it would + // allow the client to obtain new access tokens without re-validating the + // enterprise identity via the IdP, bypassing IdP session and revocation + // policies. When the access token expires, repeat the full enterprise + // auth flow to obtain a fresh token. logger.debug("JWT bearer grant exchange successful; expires_in={}", tokenResponse.getExpiresIn()); return Mono.just(tokenResponse); } @@ -334,6 +337,39 @@ public static Mono exchangeJwtBearerGrant(Exchange // Internal helpers // ----------------------------------------------------------------------- + /** + * Validates the RFC 8693 token exchange response for a JAG request. + * @param tokenResponse the parsed response + * @return a {@link Mono} emitting the {@code access_token} value, or an error of type + * {@link EnterpriseAuthException} if any validation check fails + */ + /** + * Validates the RFC 8693 token exchange response for a JAG request. + *

+ * Validates {@code issued_token_type} and the presence of {@code access_token}. + * {@code token_type} is intentionally not validated: per RFC 8693 §2.2.1 it is + * informational when the issued token is not an access token, and per RFC 6749 §5.1 + * it is case-insensitive — strict {@code N_A} checking would reject conformant IdPs + * that omit or capitalise the field differently. + */ + private static Mono validateJAGTokenExchangeResponse(JagTokenExchangeResponse tokenResponse) { + if (!TOKEN_TYPE_ID_JAG.equalsIgnoreCase(tokenResponse.getIssuedTokenType())) { + return Mono.error(new EnterpriseAuthException("Unexpected issued_token_type in JAG response: " + + tokenResponse.getIssuedTokenType() + " (expected " + TOKEN_TYPE_ID_JAG + ")")); + } + if (tokenResponse.getAccessToken() == null || tokenResponse.getAccessToken().isBlank()) { + return Mono.error(new EnterpriseAuthException("JAG token exchange response is missing access_token")); + } + return Mono.just(tokenResponse.getAccessToken()); + } + + /** + * URL-encodes a form parameter key-value pair as {@code key=value}. + */ + private static String encodeParam(String key, String value) { + return encode(key) + "=" + encode(value); + } + private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProvider.java index ecba04694..d9c5e0172 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProvider.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProvider.java @@ -26,8 +26,8 @@ * directly with any HTTP transport. On each request it: *

    *
  1. Checks an in-memory access token cache.
  2. - *
  3. If the cache is empty or the token is expired (within a 30-second buffer), it - * performs the full enterprise auth flow: + *
  4. If the cache is empty or the token is expired (within the + * {@code TOKEN_EXPIRY_BUFFER}), it performs the full enterprise auth flow: *
      *
    1. Discovers the MCP authorization server metadata via RFC 8414.
    2. *
    3. Invokes the {@link EnterpriseAuthProviderOptions#getAssertionCallback() assertion @@ -54,6 +54,7 @@ * .idpUrl(ctx.getAuthorizationServerUrl().toString()) * .idToken(myIdTokenSupplier.get()) * .clientId("idp-client-id") + * .clientSecret("idp-client-secret") * .build(), * httpClient); * }) @@ -77,7 +78,7 @@ public class EnterpriseAuthProvider implements McpAsyncHttpClientRequestCustomiz * Proactive refresh buffer: treat a token as expired this many seconds before its * actual expiry to avoid using a token that expires mid-flight. */ - private static final Duration EXPIRY_BUFFER = Duration.ofSeconds(30); + private static final Duration TOKEN_EXPIRY_BUFFER = Duration.ofSeconds(30); private final EnterpriseAuthProviderOptions options; @@ -152,7 +153,7 @@ private boolean isExpiredOrNearlyExpired(JwtBearerAccessTokenResponse token) { if (expiresAt == null) { return false; } - return Instant.now().isAfter(expiresAt.minus(EXPIRY_BUFFER)); + return Instant.now().isAfter(expiresAt.minus(TOKEN_EXPIRY_BUFFER)); } private Mono fetchNewToken(URI endpoint) { @@ -180,6 +181,13 @@ private Mono fetchNewToken(URI endpoint) { authServerUri); return options.getAssertionCallback().apply(assertionContext).flatMap(assertion -> { + // Note: the ID-JAG obtained from the assertionCallback is used + // immediately + // for a single access-token exchange and is not cached. If the access + // token + // is short-lived, caching the ID-JAG at the callback level can reduce IdP + // round-trips, as the JAG may still be valid when the access token + // expires. ExchangeJwtBearerGrantOptions exchangeOptions = ExchangeJwtBearerGrantOptions.builder() .tokenEndpoint(metadata.getTokenEndpoint()) .assertion(assertion) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/ExchangeJwtBearerGrantOptions.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/ExchangeJwtBearerGrantOptions.java index 0d7e8bfa3..a3fae07ff 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/ExchangeJwtBearerGrantOptions.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/ExchangeJwtBearerGrantOptions.java @@ -13,6 +13,12 @@ * Posts an RFC 7523 JWT Bearer grant exchange to the MCP authorization server's token * endpoint, exchanging the JAG (JWT Authorization Grant / ID-JAG) for a standard OAuth * 2.0 access token that can be used to call the MCP server. + *

      + * Client credentials are sent using {@code client_secret_basic} (RFC 6749 §2.3.1): the + * {@code client_id} and {@code client_secret} are Base64-encoded and sent in the + * {@code Authorization: Basic} header. This matches the + * {@code token_endpoint_auth_method} declared by {@code EnterpriseAuthProvider} and is + * required by SEP-990 conformance tests. * * @author MCP SDK Contributors * @see RFC 7523 diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JagTokenExchangeResponse.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JagTokenExchangeResponse.java index 35f1d6532..c6c7935b8 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JagTokenExchangeResponse.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/JagTokenExchangeResponse.java @@ -13,14 +13,15 @@ * Returned by the enterprise IdP when exchanging an ID Token for a JWT Authorization * Grant (ID-JAG) during Enterprise Managed Authorization (SEP-990). *

      - * The three key fields are: + * The key fields are: *

        *
      • {@code access_token} — the issued JAG (despite the name, not an OAuth access * token)
      • *
      • {@code issued_token_type} — must be * {@code urn:ietf:params:oauth:token-type:id-jag}
      • - *
      • {@code token_type} — must be {@code N_A} (case-insensitive, per RFC 8693 - * §2.2.1)
      • + *
      • {@code token_type} — informational; per RFC 8693 §2.2.1 it SHOULD be {@code N_A} + * when the issued token is not an access token, but this is not strictly enforced as some + * conformant IdPs may omit or vary the casing
      • *
      * * @author MCP SDK Contributors diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/RequestJwtAuthGrantOptions.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/RequestJwtAuthGrantOptions.java index 913c4fdd5..b16d3a0f8 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/RequestJwtAuthGrantOptions.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/auth/RequestJwtAuthGrantOptions.java @@ -38,8 +38,8 @@ public class RequestJwtAuthGrantOptions { /** The {@code scope} parameter for the token exchange request (optional). */ private final String scope; - private RequestJwtAuthGrantOptions(Builder builder) { - this.tokenEndpoint = Objects.requireNonNull(builder.tokenEndpoint, "tokenEndpoint must not be null"); + protected RequestJwtAuthGrantOptions(Builder builder) { + this.tokenEndpoint = builder.tokenEndpoint; this.idToken = Objects.requireNonNull(builder.idToken, "idToken must not be null"); this.clientId = Objects.requireNonNull(builder.clientId, "clientId must not be null"); this.clientSecret = builder.clientSecret; @@ -80,7 +80,7 @@ public static Builder builder() { return new Builder(); } - public static final class Builder { + public static class Builder { private String tokenEndpoint; @@ -96,7 +96,7 @@ public static final class Builder { private String scope; - private Builder() { + protected Builder() { } public Builder tokenEndpoint(String tokenEndpoint) { @@ -135,6 +135,7 @@ public Builder scope(String scope) { } public RequestJwtAuthGrantOptions build() { + Objects.requireNonNull(tokenEndpoint, "tokenEndpoint must not be null"); return new RequestJwtAuthGrantOptions(this); } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java index f7be577cd..1c96b85a5 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java @@ -196,7 +196,9 @@ void requestJwtAuthorizationGrant_wrongIssuedTokenType_emitsError() { } @Test - void requestJwtAuthorizationGrant_wrongTokenType_emitsError() { + void requestJwtAuthorizationGrant_nonStandardTokenType_succeeds() { + // token_type is informational per RFC 8693 §2.2.1; non-N_A values must not be + // rejected so that conformant IdPs that omit or vary the field are accepted. server.createContext("/token", exchange -> sendJson(exchange, 200, """ { "access_token": "tok", @@ -211,8 +213,8 @@ void requestJwtAuthorizationGrant_wrongTokenType_emitsError() { .build(); StepVerifier.create(EnterpriseAuth.requestJwtAuthorizationGrant(options, httpClient)) - .expectErrorMatches(e -> e instanceof EnterpriseAuthException && e.getMessage().contains("token_type")) - .verify(); + .expectNext("tok") + .verifyComplete(); } @Test @@ -289,7 +291,15 @@ void exchangeJwtBearerGrant_success() { String body = new String(exchange.getRequestBody().readAllBytes()); assertThat(body).contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer"); assertThat(body).contains("assertion=my-jag"); - assertThat(body).contains("client_id=cid"); + // client credentials must be sent via Basic auth header + // (client_secret_basic), + // not in the request body (client_secret_post) + assertThat(body).doesNotContain("client_id"); + String authHeader = exchange.getRequestHeaders().getFirst("Authorization"); + assertThat(authHeader).isNotNull(); + assertThat(authHeader).startsWith("Basic "); + String decoded = new String(java.util.Base64.getDecoder().decode(authHeader.substring(6))); + assertThat(decoded).isEqualTo("cid:"); sendJson(exchange, 200, """ { @@ -498,6 +508,86 @@ void enterpriseAuthProvider_assertionCallbackError_emitsError() { .verify(); } + @Test + void enterpriseAuthProvider_nearlyExpiredToken_fetchesNewToken() { + // expires_in=0 means the token expires immediately; with the 30-second + // TOKEN_EXPIRY_BUFFER it is considered expired on every call, forcing a re-fetch. + int[] callCount = { 0 }; + + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); + server.createContext("/mcp-token", exchange -> { + callCount[0]++; + sendJson(exchange, 200, """ + { + "access_token": "expiring-token", + "token_type": "Bearer", + "expires_in": 0 + }"""); + }); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("client-id") + .assertionCallback(ctx -> Mono.just("jag")) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + URI endpoint = URI.create(baseUrl + "/mcp"); + + // First request — fetches a token that expires within the buffer window + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + assertThat(callCount[0]).isEqualTo(1); + + // Second request — cached token is already within the expiry buffer, must + // re-fetch + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + assertThat(callCount[0]).isEqualTo(2); + } + + @Test + void enterpriseAuthProvider_tokenWithoutExpiresIn_usesCache() { + // When the server omits expires_in the token has no expiry and is kept in cache + // indefinitely (until invalidated). + int[] callCount = { 0 }; + + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); + server.createContext("/mcp-token", exchange -> { + callCount[0]++; + sendJson(exchange, 200, """ + { + "access_token": "no-expiry-token", + "token_type": "Bearer" + }"""); + }); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("client-id") + .assertionCallback(ctx -> Mono.just("jag")) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + URI endpoint = URI.create(baseUrl + "/mcp"); + + // First request fetches and caches the token + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + // Subsequent requests must reuse the cached token without re-fetching + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + + assertThat(callCount[0]).isEqualTo(1); + } + // ----------------------------------------------------------------------- // EnterpriseAuthProviderOptions — validation // ----------------------------------------------------------------------- From 649a4261ee2633bfa94798fa1281195ed337455c Mon Sep 17 00:00:00 2001 From: Prachi Pandey Date: Tue, 17 Mar 2026 00:29:18 +0530 Subject: [PATCH 14/15] Split EnterpriseAuthProvider tests into separate test class --- .../auth/EnterpriseAuthProviderTest.java | 342 ++++++++++++++++++ .../client/auth/EnterpriseAuthTest.java | 272 +------------- 2 files changed, 343 insertions(+), 271 deletions(-) create mode 100644 mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProviderTest.java diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProviderTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProviderTest.java new file mode 100644 index 000000000..4d2f32593 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthProviderTest.java @@ -0,0 +1,342 @@ +/* + * Copyright 2024-2025 the original author or authors. + */ + +package io.modelcontextprotocol.client.auth; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import io.modelcontextprotocol.common.McpTransportContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for {@link EnterpriseAuthProvider}. + * + * @author MCP SDK Contributors + */ +class EnterpriseAuthProviderTest { + + private HttpServer server; + + private String baseUrl; + + private HttpClient httpClient; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress(0), 0); + server.start(); + int port = server.getAddress().getPort(); + baseUrl = "http://localhost:" + port; + httpClient = HttpClient.newHttpClient(); + } + + @AfterEach + void stopServer() { + if (server != null) { + server.stop(0); + } + } + + // ----------------------------------------------------------------------- + // EnterpriseAuthProvider + // ----------------------------------------------------------------------- + + @Test + void enterpriseAuthProvider_injectsAuthorizationHeader() { + // Auth server discovery + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); + // JWT bearer grant exchange + server.createContext("/mcp-token", exchange -> sendJson(exchange, 200, """ + { + "access_token": "final-access-token", + "token_type": "Bearer", + "expires_in": 3600 + }""")); + + // The assertion callback simulates having already obtained a JAG from the IdP + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("client-id") + .assertionCallback(ctx -> Mono.just("pre-obtained-jag")) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + + URI endpoint = URI.create(baseUrl + "/mcp"); + HttpRequest.Builder builder = HttpRequest.newBuilder(endpoint); + + StepVerifier + .create(Mono.from(provider.customize(builder, "POST", endpoint, "{}", McpTransportContext.EMPTY)) + .map(HttpRequest.Builder::build) + .map(req -> req.headers().firstValue("Authorization").orElse(null))) + .expectNext("Bearer final-access-token") + .verifyComplete(); + } + + @Test + void enterpriseAuthProvider_cachesPreviousToken() { + int[] callCount = { 0 }; + + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); + server.createContext("/mcp-token", exchange -> { + callCount[0]++; + sendJson(exchange, 200, """ + { + "access_token": "cached-token", + "token_type": "Bearer", + "expires_in": 3600 + }"""); + }); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("client-id") + .assertionCallback(ctx -> Mono.just("jag")) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + + URI endpoint = URI.create(baseUrl + "/mcp"); + HttpRequest.Builder builder1 = HttpRequest.newBuilder(endpoint); + HttpRequest.Builder builder2 = HttpRequest.newBuilder(endpoint); + + // First request — fetches token + Mono.from(provider.customize(builder1, "POST", endpoint, null, McpTransportContext.EMPTY)).block(); + // Second request — should use cache + Mono.from(provider.customize(builder2, "POST", endpoint, null, McpTransportContext.EMPTY)).block(); + + assertThat(callCount[0]).isEqualTo(1); + } + + @Test + void enterpriseAuthProvider_invalidateCache_forcesRefetch() { + int[] callCount = { 0 }; + + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); + server.createContext("/mcp-token", exchange -> { + callCount[0]++; + sendJson(exchange, 200, """ + { + "access_token": "refreshed-token", + "token_type": "Bearer", + "expires_in": 3600 + }"""); + }); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("client-id") + .assertionCallback(ctx -> Mono.just("jag")) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + + URI endpoint = URI.create(baseUrl + "/mcp"); + + // First request + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + assertThat(callCount[0]).isEqualTo(1); + + // Invalidate + provider.invalidateCache(); + + // Second request — cache cleared, must fetch again + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + assertThat(callCount[0]).isEqualTo(2); + } + + @Test + void enterpriseAuthProvider_discoveryFails_emitsError() { + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 500, "")); + server.createContext("/.well-known/openid-configuration", exchange -> sendJson(exchange, 500, "")); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("cid") + .assertionCallback(ctx -> Mono.just("jag")) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + URI endpoint = URI.create(baseUrl + "/mcp"); + + StepVerifier.create(Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY))) + .expectErrorMatches(e -> e instanceof EnterpriseAuthException) + .verify(); + } + + @Test + void enterpriseAuthProvider_assertionCallbackError_emitsError() { + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("cid") + .assertionCallback(ctx -> Mono.error(new RuntimeException("IdP unreachable"))) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + URI endpoint = URI.create(baseUrl + "/mcp"); + + StepVerifier + .create(Mono.from(provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, + McpTransportContext.EMPTY))) + .expectErrorMatches(e -> e instanceof RuntimeException && e.getMessage().contains("IdP unreachable")) + .verify(); + } + + @Test + void enterpriseAuthProvider_nearlyExpiredToken_fetchesNewToken() { + // expires_in=0 means the token expires immediately; with the 30-second + // TOKEN_EXPIRY_BUFFER it is considered expired on every call, forcing a re-fetch. + int[] callCount = { 0 }; + + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); + server.createContext("/mcp-token", exchange -> { + callCount[0]++; + sendJson(exchange, 200, """ + { + "access_token": "expiring-token", + "token_type": "Bearer", + "expires_in": 0 + }"""); + }); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("client-id") + .assertionCallback(ctx -> Mono.just("jag")) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + URI endpoint = URI.create(baseUrl + "/mcp"); + + // First request — fetches a token that expires within the buffer window + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + assertThat(callCount[0]).isEqualTo(1); + + // Second request — cached token is already within the expiry buffer, must + // re-fetch + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + assertThat(callCount[0]).isEqualTo(2); + } + + @Test + void enterpriseAuthProvider_tokenWithoutExpiresIn_usesCache() { + // When the server omits expires_in the token has no expiry and is kept in cache + // indefinitely (until invalidated). + int[] callCount = { 0 }; + + server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, + "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); + server.createContext("/mcp-token", exchange -> { + callCount[0]++; + sendJson(exchange, 200, """ + { + "access_token": "no-expiry-token", + "token_type": "Bearer" + }"""); + }); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId("client-id") + .assertionCallback(ctx -> Mono.just("jag")) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + URI endpoint = URI.create(baseUrl + "/mcp"); + + // First request fetches and caches the token + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + // Subsequent requests must reuse the cached token without re-fetching + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + Mono.from( + provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) + .block(); + + assertThat(callCount[0]).isEqualTo(1); + } + + // ----------------------------------------------------------------------- + // EnterpriseAuthProviderOptions — validation + // ----------------------------------------------------------------------- + + @Test + void providerOptions_nullClientId_throws() { + assertThatThrownBy( + () -> EnterpriseAuthProviderOptions.builder().assertionCallback(ctx -> Mono.just("j")).build()) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("clientId"); + } + + @Test + void providerOptions_nullCallback_throws() { + assertThatThrownBy(() -> EnterpriseAuthProviderOptions.builder().clientId("cid").build()) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("assertionCallback"); + } + + // ----------------------------------------------------------------------- + // JwtBearerAccessTokenResponse helpers + // ----------------------------------------------------------------------- + + @Test + void jwtBearerAccessTokenResponse_isExpired_whenPastExpiresAt() { + JwtBearerAccessTokenResponse response = new JwtBearerAccessTokenResponse(); + response.setAccessToken("tok"); + response.setExpiresAt(java.time.Instant.now().minusSeconds(10)); + assertThat(response.isExpired()).isTrue(); + } + + @Test + void jwtBearerAccessTokenResponse_notExpired_whenNoExpiresAt() { + JwtBearerAccessTokenResponse response = new JwtBearerAccessTokenResponse(); + response.setAccessToken("tok"); + assertThat(response.isExpired()).isFalse(); + } + + // ----------------------------------------------------------------------- + // Helper + // ----------------------------------------------------------------------- + + private static void sendJson(HttpExchange exchange, int statusCode, String body) { + try { + byte[] bytes = body.getBytes(java.nio.charset.StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(statusCode, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } + catch (IOException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java index 1c96b85a5..619e42c16 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/auth/EnterpriseAuthTest.java @@ -21,10 +21,9 @@ import reactor.test.StepVerifier; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; /** - * Tests for {@link EnterpriseAuth} and {@link EnterpriseAuthProvider}. + * Tests for {@link EnterpriseAuth}. * * @author MCP SDK Contributors */ @@ -357,275 +356,6 @@ void exchangeJwtBearerGrant_httpError_emitsError() { .verify(); } - // ----------------------------------------------------------------------- - // EnterpriseAuthProvider - // ----------------------------------------------------------------------- - - @Test - void enterpriseAuthProvider_injectsAuthorizationHeader() { - // Auth server discovery - server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, - "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); - // JWT bearer grant exchange - server.createContext("/mcp-token", exchange -> sendJson(exchange, 200, """ - { - "access_token": "final-access-token", - "token_type": "Bearer", - "expires_in": 3600 - }""")); - - // The assertion callback simulates having already obtained a JAG from the IdP - EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() - .clientId("client-id") - .assertionCallback(ctx -> Mono.just("pre-obtained-jag")) - .build(); - - EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); - - URI endpoint = URI.create(baseUrl + "/mcp"); - HttpRequest.Builder builder = HttpRequest.newBuilder(endpoint); - - StepVerifier - .create(Mono.from(provider.customize(builder, "POST", endpoint, "{}", McpTransportContext.EMPTY)) - .map(HttpRequest.Builder::build) - .map(req -> req.headers().firstValue("Authorization").orElse(null))) - .expectNext("Bearer final-access-token") - .verifyComplete(); - } - - @Test - void enterpriseAuthProvider_cachesPreviousToken() { - int[] callCount = { 0 }; - - server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, - "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); - server.createContext("/mcp-token", exchange -> { - callCount[0]++; - sendJson(exchange, 200, """ - { - "access_token": "cached-token", - "token_type": "Bearer", - "expires_in": 3600 - }"""); - }); - - EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() - .clientId("client-id") - .assertionCallback(ctx -> Mono.just("jag")) - .build(); - - EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); - - URI endpoint = URI.create(baseUrl + "/mcp"); - HttpRequest.Builder builder1 = HttpRequest.newBuilder(endpoint); - HttpRequest.Builder builder2 = HttpRequest.newBuilder(endpoint); - - // First request — fetches token - Mono.from(provider.customize(builder1, "POST", endpoint, null, McpTransportContext.EMPTY)).block(); - // Second request — should use cache - Mono.from(provider.customize(builder2, "POST", endpoint, null, McpTransportContext.EMPTY)).block(); - - assertThat(callCount[0]).isEqualTo(1); - } - - @Test - void enterpriseAuthProvider_invalidateCache_forcesRefetch() { - int[] callCount = { 0 }; - - server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, - "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); - server.createContext("/mcp-token", exchange -> { - callCount[0]++; - sendJson(exchange, 200, """ - { - "access_token": "refreshed-token", - "token_type": "Bearer", - "expires_in": 3600 - }"""); - }); - - EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() - .clientId("client-id") - .assertionCallback(ctx -> Mono.just("jag")) - .build(); - - EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); - - URI endpoint = URI.create(baseUrl + "/mcp"); - - // First request - Mono.from( - provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) - .block(); - assertThat(callCount[0]).isEqualTo(1); - - // Invalidate - provider.invalidateCache(); - - // Second request — cache cleared, must fetch again - Mono.from( - provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) - .block(); - assertThat(callCount[0]).isEqualTo(2); - } - - @Test - void enterpriseAuthProvider_discoveryFails_emitsError() { - server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 500, "")); - server.createContext("/.well-known/openid-configuration", exchange -> sendJson(exchange, 500, "")); - - EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() - .clientId("cid") - .assertionCallback(ctx -> Mono.just("jag")) - .build(); - - EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); - URI endpoint = URI.create(baseUrl + "/mcp"); - - StepVerifier.create(Mono.from( - provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY))) - .expectErrorMatches(e -> e instanceof EnterpriseAuthException) - .verify(); - } - - @Test - void enterpriseAuthProvider_assertionCallbackError_emitsError() { - server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, - "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); - - EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() - .clientId("cid") - .assertionCallback(ctx -> Mono.error(new RuntimeException("IdP unreachable"))) - .build(); - - EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); - URI endpoint = URI.create(baseUrl + "/mcp"); - - StepVerifier - .create(Mono.from(provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, - McpTransportContext.EMPTY))) - .expectErrorMatches(e -> e instanceof RuntimeException && e.getMessage().contains("IdP unreachable")) - .verify(); - } - - @Test - void enterpriseAuthProvider_nearlyExpiredToken_fetchesNewToken() { - // expires_in=0 means the token expires immediately; with the 30-second - // TOKEN_EXPIRY_BUFFER it is considered expired on every call, forcing a re-fetch. - int[] callCount = { 0 }; - - server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, - "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); - server.createContext("/mcp-token", exchange -> { - callCount[0]++; - sendJson(exchange, 200, """ - { - "access_token": "expiring-token", - "token_type": "Bearer", - "expires_in": 0 - }"""); - }); - - EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() - .clientId("client-id") - .assertionCallback(ctx -> Mono.just("jag")) - .build(); - - EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); - URI endpoint = URI.create(baseUrl + "/mcp"); - - // First request — fetches a token that expires within the buffer window - Mono.from( - provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) - .block(); - assertThat(callCount[0]).isEqualTo(1); - - // Second request — cached token is already within the expiry buffer, must - // re-fetch - Mono.from( - provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) - .block(); - assertThat(callCount[0]).isEqualTo(2); - } - - @Test - void enterpriseAuthProvider_tokenWithoutExpiresIn_usesCache() { - // When the server omits expires_in the token has no expiry and is kept in cache - // indefinitely (until invalidated). - int[] callCount = { 0 }; - - server.createContext("/.well-known/oauth-authorization-server", exchange -> sendJson(exchange, 200, - "{\"issuer\":\"" + baseUrl + "\",\"token_endpoint\":\"" + baseUrl + "/mcp-token\"}")); - server.createContext("/mcp-token", exchange -> { - callCount[0]++; - sendJson(exchange, 200, """ - { - "access_token": "no-expiry-token", - "token_type": "Bearer" - }"""); - }); - - EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() - .clientId("client-id") - .assertionCallback(ctx -> Mono.just("jag")) - .build(); - - EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); - URI endpoint = URI.create(baseUrl + "/mcp"); - - // First request fetches and caches the token - Mono.from( - provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) - .block(); - // Subsequent requests must reuse the cached token without re-fetching - Mono.from( - provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) - .block(); - Mono.from( - provider.customize(HttpRequest.newBuilder(endpoint), "GET", endpoint, null, McpTransportContext.EMPTY)) - .block(); - - assertThat(callCount[0]).isEqualTo(1); - } - - // ----------------------------------------------------------------------- - // EnterpriseAuthProviderOptions — validation - // ----------------------------------------------------------------------- - - @Test - void providerOptions_nullClientId_throws() { - assertThatThrownBy( - () -> EnterpriseAuthProviderOptions.builder().assertionCallback(ctx -> Mono.just("j")).build()) - .isInstanceOf(NullPointerException.class) - .hasMessageContaining("clientId"); - } - - @Test - void providerOptions_nullCallback_throws() { - assertThatThrownBy(() -> EnterpriseAuthProviderOptions.builder().clientId("cid").build()) - .isInstanceOf(NullPointerException.class) - .hasMessageContaining("assertionCallback"); - } - - // ----------------------------------------------------------------------- - // JwtBearerAccessTokenResponse helpers - // ----------------------------------------------------------------------- - - @Test - void jwtBearerAccessTokenResponse_isExpired_whenPastExpiresAt() { - JwtBearerAccessTokenResponse response = new JwtBearerAccessTokenResponse(); - response.setAccessToken("tok"); - response.setExpiresAt(java.time.Instant.now().minusSeconds(10)); - assertThat(response.isExpired()).isTrue(); - } - - @Test - void jwtBearerAccessTokenResponse_notExpired_whenNoExpiresAt() { - JwtBearerAccessTokenResponse response = new JwtBearerAccessTokenResponse(); - response.setAccessToken("tok"); - assertThat(response.isExpired()).isFalse(); - } - // ----------------------------------------------------------------------- // Helper // ----------------------------------------------------------------------- From 20c5628aec5587a9ffdb9f813a1edf14041cff85 Mon Sep 17 00:00:00 2001 From: Prachi Pandey Date: Wed, 18 Mar 2026 11:13:55 +0530 Subject: [PATCH 15/15] Add auth/cross-app-access-complete-flow to JDK conformance client (SEP-990) --- .../client-jdk-http-client/pom.xml | 7 ++ .../client/ConformanceJdkClientMcpClient.java | 89 +++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/conformance-tests/client-jdk-http-client/pom.xml b/conformance-tests/client-jdk-http-client/pom.xml index f939cfa6c..c30c0f608 100644 --- a/conformance-tests/client-jdk-http-client/pom.xml +++ b/conformance-tests/client-jdk-http-client/pom.xml @@ -31,6 +31,13 @@ 2.0.0-SNAPSHOT + + + io.modelcontextprotocol.sdk + mcp-core + 2.0.0-SNAPSHOT + + ch.qos.logback diff --git a/conformance-tests/client-jdk-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceJdkClientMcpClient.java b/conformance-tests/client-jdk-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceJdkClientMcpClient.java index 570c4614e..41485cd3e 100644 --- a/conformance-tests/client-jdk-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceJdkClientMcpClient.java +++ b/conformance-tests/client-jdk-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceJdkClientMcpClient.java @@ -2,8 +2,15 @@ import java.time.Duration; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.client.McpClient; import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.auth.DiscoverAndRequestJwtAuthGrantOptions; +import io.modelcontextprotocol.client.auth.EnterpriseAuth; +import io.modelcontextprotocol.client.auth.EnterpriseAuthProvider; +import io.modelcontextprotocol.client.auth.EnterpriseAuthProviderOptions; import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; import io.modelcontextprotocol.spec.McpSchema; @@ -53,6 +60,9 @@ public static void main(String[] args) { case "sse-retry": runSSERetryScenario(serverUrl); break; + case "auth/cross-app-access-complete-flow": + runCrossAppAccessCompleteFlowScenario(serverUrl); + break; default: System.err.println("Unknown scenario: " + scenario); System.err.println("Available scenarios:"); @@ -60,6 +70,7 @@ public static void main(String[] args) { System.err.println(" - tools_call"); System.err.println(" - elicitation-sep1034-client-defaults"); System.err.println(" - sse-retry"); + System.err.println(" - auth/cross-app-access-complete-flow"); System.exit(1); } System.exit(0); @@ -283,4 +294,82 @@ private static void runSSERetryScenario(String serverUrl) throws Exception { } } + /** + * Cross-App Access scenario: Tests SEP-990 Enterprise Managed Authorization flow. + *

      + * Reads context from {@code MCP_CONFORMANCE_CONTEXT} (JSON) containing: + * {@code client_id}, {@code client_secret}, {@code idp_client_id}, + * {@code idp_id_token}, {@code idp_issuer}, {@code idp_token_endpoint}. + *

      + * Uses {@link EnterpriseAuthProvider} with an assertion callback that performs RFC + * 8693 token exchange at the IdP, then exchanges the ID-JAG for an access token at + * the MCP authorization server via RFC 7523 JWT Bearer grant. + * @param serverUrl the URL of the MCP server + * @throws Exception if any error occurs during execution + */ + private static void runCrossAppAccessCompleteFlowScenario(String serverUrl) throws Exception { + String contextEnv = System.getenv("MCP_CONFORMANCE_CONTEXT"); + if (contextEnv == null || contextEnv.isEmpty()) { + System.err.println("Error: MCP_CONFORMANCE_CONTEXT environment variable is not set"); + System.exit(1); + } + + CrossAppAccessContext ctx = new ObjectMapper().readValue(contextEnv, CrossAppAccessContext.class); + + java.net.http.HttpClient httpClient = java.net.http.HttpClient.newHttpClient(); + + EnterpriseAuthProviderOptions options = EnterpriseAuthProviderOptions.builder() + .clientId(ctx.clientId()) + .clientSecret(ctx.clientSecret()) + .assertionCallback(assertionCtx -> { + // RFC 8693 token exchange at the IdP: ID Token → ID-JAG + DiscoverAndRequestJwtAuthGrantOptions jagOptions = DiscoverAndRequestJwtAuthGrantOptions + .builder() + .idpUrl(ctx.idpIssuer()) + .idpTokenEndpoint(ctx.idpTokenEndpoint()) + .idToken(ctx.idpIdToken()) + .clientId(ctx.idpClientId()) + .audience(assertionCtx.getAuthorizationServerUrl().toString()) + .resource(assertionCtx.getResourceUrl().toString()) + .build(); + return EnterpriseAuth.discoverAndRequestJwtAuthorizationGrant(jagOptions, httpClient); + }) + .build(); + + EnterpriseAuthProvider provider = new EnterpriseAuthProvider(options, httpClient); + + HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(serverUrl) + .httpRequestCustomizer(provider) + .build(); + + McpSyncClient client = McpClient.sync(transport) + .clientInfo(new McpSchema.Implementation("test-client", "1.0.0")) + .requestTimeout(Duration.ofSeconds(30)) + .build(); + + try { + client.initialize(); + System.out.println("Successfully connected to MCP server"); + + client.listTools(); + System.out.println("Successfully listed tools"); + } + finally { + client.close(); + System.out.println("Connection closed successfully"); + } + } + + /** + * Context provided by the conformance suite for the cross-app-access-complete-flow + * scenario via the {@code MCP_CONFORMANCE_CONTEXT} environment variable. + */ + @JsonIgnoreProperties(ignoreUnknown = true) + private record CrossAppAccessContext(@JsonProperty("client_id") String clientId, + @JsonProperty("client_secret") String clientSecret, + @JsonProperty("idp_client_id") String idpClientId, + @JsonProperty("idp_id_token") String idpIdToken, @JsonProperty("idp_issuer") String idpIssuer, + @JsonProperty("idp_token_endpoint") String idpTokenEndpoint) { + } + }