diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 82db97f76..2e655d6ce 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -60,3 +60,45 @@ jobs: command: 'java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-1.0.0-SNAPSHOT.jar' scenario: ${{ matrix.scenario }} expected-failures: ./conformance-tests/conformance-baseline.yml + + auth: + name: Auth Conformance + runs-on: ubuntu-latest + strategy: + matrix: + scenario: + - auth/metadata-default + - auth/metadata-var1 + - auth/metadata-var2 + - auth/metadata-var3 + - auth/basic-cimd + - auth/scope-from-www-authenticate + - auth/scope-from-scopes-supported + - auth/scope-omitted-when-undefined + - auth/scope-step-up + - auth/scope-retry-limit + - auth/token-endpoint-auth-basic + - auth/token-endpoint-auth-post + - auth/token-endpoint-auth-none + - auth/pre-registration + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'maven' + + - name: Build client + run: mvn clean install -DskipTests + + - name: Run conformance test + uses: modelcontextprotocol/conformance@v0.1.15 + with: + node-version: '22' # see https://github.com/modelcontextprotocol/conformance/pull/162 + mode: client + command: 'java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.0.0-SNAPSHOT.jar' + scenario: ${{ matrix.scenario }} + expected-failures: ./conformance-tests/conformance-baseline.yml \ No newline at end of file diff --git a/conformance-tests/VALIDATION_RESULTS.md b/conformance-tests/VALIDATION_RESULTS.md index 7be75e6e5..19e74330c 100644 --- a/conformance-tests/VALIDATION_RESULTS.md +++ b/conformance-tests/VALIDATION_RESULTS.md @@ -2,8 +2,9 @@ ## Summary -**Server Tests:** 37/40 passed (92.5%) +**Server Tests:** 37/40 passed (92.5%) **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 @@ -20,7 +21,7 @@ ### Failing (3/40) 1. **resources-subscribe** - Not implemented in SDK -2. **resources-unsubscribe** - Not implemented in SDK +2. **resources-unsubscribe** - Not implemented in SDK ## Client Test Results @@ -32,17 +33,45 @@ ### Partially Passing (1/4 scenarios, 1/2 checks) -- **sse-retry (1/2 + 1 warning):** +- **sse-retry (1/2 + 1 warning):** - ✅ Reconnects after stream closure - ❌ Does not respect retry timing - ⚠️ Does not send Last-Event-ID header (SHOULD requirement) **Issue:** Client treats `retry:` SSE field as invalid instead of parsing it for reconnection timing. +## Auth Test Results (Spring HTTP Client) + +**Status: 178 passed, 1 failed, 1 warning across 14 scenarios** + +Uses the `client-spring-http-client` module with Spring Security OAuth2 and the [mcp-client-security](https://github.com/springaicommunity/mcp-client-security) library. + +### Fully Passing (12/14 scenarios) + +- **auth/metadata-default (12/12):** Default metadata discovery +- **auth/metadata-var1 (12/12):** Metadata discovery variant 1 +- **auth/metadata-var2 (12/12):** Metadata discovery variant 2 +- **auth/metadata-var3 (12/12):** Metadata discovery variant 3 +- **auth/scope-from-www-authenticate (13/13):** Scope extraction from WWW-Authenticate header +- **auth/scope-from-scopes-supported (13/13):** Scope extraction from scopes_supported +- **auth/scope-omitted-when-undefined (13/13):** Scope omitted when not defined +- **auth/scope-retry-limit (11/11):** Scope retry limit handling +- **auth/token-endpoint-auth-basic (17/17):** Token endpoint with HTTP Basic auth +- **auth/token-endpoint-auth-post (17/17):** Token endpoint with POST body auth +- **auth/token-endpoint-auth-none (17/17):** Token endpoint with no client auth +- **auth/pre-registration (6/6):** Pre-registered client credentials flow + +### Partially Passing (2/14 scenarios) + +- **auth/basic-cimd (12/12 + 1 warning):** Basic Client-Initiated Metadata Discovery — all checks pass, minor warning +- **auth/scope-step-up (11/12):** Scope step-up challenge — 1 failure, client does not fully handle scope escalation after initial authorization + ## 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 ## Running Tests @@ -70,11 +99,26 @@ for scenario in initialize tools_call elicitation-sep1034-client-defaults sse-re done ``` +### Auth (Spring HTTP Client) + +Ensure you run with the conformance testing suite `0.1.15` or higher. + +```bash +# Build +cd conformance-tests/client-spring-http-client +../../mvnw clean package -DskipTests + +# 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" \ + --suite auth +``` + ## Recommendations ### High Priority 1. Fix client SSE retry field handling in `HttpClientStreamableHttpTransport` 2. Implement resource subscription handlers in `McpStatelessAsyncServer` - -### Medium Priority -3. Add Host/Origin validation in `HttpServletStreamableServerTransportProvider` for DNS rebinding protection +3. Implement CIMD +4. Implement scope step up diff --git a/conformance-tests/client-spring-http-client/README.md b/conformance-tests/client-spring-http-client/README.md new file mode 100644 index 000000000..876a86e1d --- /dev/null +++ b/conformance-tests/client-spring-http-client/README.md @@ -0,0 +1,124 @@ +# MCP Conformance Tests - Spring HTTP Client (Auth Suite) + +This module provides a conformance test client implementation for the Java MCP SDK's **auth** suite. + +OAuth2 support is not implemented in the SDK itself, but we provide hooks to implement the Authorization section of the specification. One such implementation is done in Spring, with Sprign AI and the [mcp-client-security](https://github.com/springaicommunity/mcp-client-security) library. + +This is a Spring web application, we interact with it through a normal HTTP-client that follows redirects and performs OAuth2 authorization flows. + +## Overview + +The conformance test client is designed to work with the [MCP Conformance Test Framework](https://github.com/modelcontextprotocol/conformance). It validates that the Java MCP SDK client, combined with Spring Security's OAuth2 support, properly implements the MCP authorization specification. + +Test with @modelcontextprotocol/conformance@0.1.15. + +## Conformance Test Results + +**Status: 178 passed, 1 failed, 1 warning across 14 scenarios** + +| Scenario | Result | Details | +|---|---|---| +| auth/metadata-default | ✅ Pass | 12/12 | +| auth/metadata-var1 | ✅ Pass | 12/12 | +| auth/metadata-var2 | ✅ Pass | 12/12 | +| auth/metadata-var3 | ✅ Pass | 12/12 | +| auth/basic-cimd | ⚠️ Warning | 12/12 passed, 1 warning | +| auth/scope-from-www-authenticate | ✅ Pass | 13/13 | +| auth/scope-from-scopes-supported | ✅ Pass | 13/13 | +| auth/scope-omitted-when-undefined | ✅ Pass | 13/13 | +| auth/scope-step-up | ❌ Fail | 11/12 (1 failed) | +| auth/scope-retry-limit | ✅ Pass | 11/11 | +| auth/token-endpoint-auth-basic | ✅ Pass | 17/17 | +| auth/token-endpoint-auth-post | ✅ Pass | 17/17 | +| auth/token-endpoint-auth-none | ✅ Pass | 17/17 | +| auth/pre-registration | ✅ Pass | 6/6 | + +See [VALIDATION_RESULTS.md](../VALIDATION_RESULTS.md) for the full project validation results. + +## Architecture + +The client is a Spring Boot application that reads test scenarios from environment variables and accepts the server URL as a command-line argument, following the conformance framework's conventions: + +- **MCP_CONFORMANCE_SCENARIO**: Environment variable specifying which test scenario to run +- **MCP_CONFORMANCE_CONTEXT**: Environment variable with JSON context (used by `auth/pre-registration`) +- **Server URL**: Passed as the last command-line argument + +### Scenario Routing + +The application uses Spring's conditional configuration to select the appropriate scenario at startup: + +- **`DefaultConfiguration`** — Activated for all scenarios except `auth/pre-registration`. Uses the OAuth2 Authorization Code flow with dynamic client registration via `McpClientOAuth2Configurer`. +- **`PreRegistrationConfiguration`** — Activated only for `auth/pre-registration`. Uses the Client Credentials flow with pre-registered client credentials read from `MCP_CONFORMANCE_CONTEXT`. + +### Key Dependencies + +- **Spring Boot 4.0** with Spring Security OAuth2 Client +- **Spring AI MCP Client** (`spring-ai-starter-mcp-client`) +- **mcp-client-security** — Community library providing MCP-specific OAuth2 integration (metadata discovery, dynamic client registration, transport context) + +## Building + +Build the executable JAR: + +```bash +cd conformance-tests/client-spring-http-client +../../mvnw clean package -DskipTests +``` + +This creates an executable JAR at: +``` +target/client-spring-http-client-0.18.0-SNAPSHOT.jar +``` + +## Running Tests + +### Using the Conformance Framework + +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" \ + --suite auth +``` + +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" \ + --scenario auth/metadata-default +``` + +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" \ + --scenario auth/metadata-default \ + --verbose +``` + +### Manual Testing + +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 +``` + +## Known Issues + +1. **auth/scope-step-up** (1 failure) — The client does not fully handle scope step-up challenges where the server requests additional scopes after initial authorization. +2. **auth/basic-cimd** (1 warning) — Minor conformance warning in the basic Client-Initiated Metadata Discovery flow. + +## References + +- [MCP Specification — Authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) +- [MCP Conformance Tests](https://github.com/modelcontextprotocol/conformance) +- [mcp-client-security Library](https://github.com/springaicommunity/mcp-client-security) +- [SDK Integration Guide](https://github.com/modelcontextprotocol/conformance/blob/main/SDK_INTEGRATION.md) diff --git a/conformance-tests/client-spring-http-client/pom.xml b/conformance-tests/client-spring-http-client/pom.xml new file mode 100644 index 000000000..9537eed96 --- /dev/null +++ b/conformance-tests/client-spring-http-client/pom.xml @@ -0,0 +1,90 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 4.0.2 + + + 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 + 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 + + + + 17 + 2.0.0-M2 + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + + org.springframework.boot + spring-boot-starter-restclient + + + + org.springframework.ai + spring-ai-starter-mcp-client + ${spring-ai.version} + + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + + org.springaicommunity + mcp-client-security + 0.1.2 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + + + maven-central + https://repo.maven.apache.org/maven2/ + + false + + + true + + + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone + + false + + + + + diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceSpringClientApplication.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceSpringClientApplication.java new file mode 100644 index 000000000..00582c9f2 --- /dev/null +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceSpringClientApplication.java @@ -0,0 +1,99 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.conformance.client; + +import java.util.Optional; + +import io.modelcontextprotocol.conformance.client.scenario.Scenario; +import org.springaicommunity.mcp.security.client.sync.oauth2.metadata.McpMetadataDiscoveryService; +import org.springaicommunity.mcp.security.client.sync.oauth2.registration.DynamicClientRegistrationService; +import org.springaicommunity.mcp.security.client.sync.oauth2.registration.InMemoryMcpClientRegistrationRepository; + +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; + +/** + * MCP Conformance Test Client - Spring HTTP Client Implementation. + * + *

+ * This client is designed to work with the MCP conformance test framework. It reads the + * test scenario from the MCP_CONFORMANCE_SCENARIO environment variable and the server URL + * from command-line arguments. + * + *

+ * It specifically tests the {@code auth} conformance suite. It requires Spring to work. + * + *

+ * Usage: java -jar client-spring-http-client.jar <server-url> + * + * @see MCP Conformance + * Test Framework + */ +@SpringBootApplication +public class ConformanceSpringClientApplication { + + public static final String REGISTRATION_ID = "default_registration"; + + public static void main(String[] args) { + SpringApplication.run(ConformanceSpringClientApplication.class, args); + } + + @Bean + McpMetadataDiscoveryService discovery() { + return new McpMetadataDiscoveryService(); + } + + @Bean + InMemoryMcpClientRegistrationRepository clientRegistrationRepository(McpMetadataDiscoveryService discovery) { + return new InMemoryMcpClientRegistrationRepository(new DynamicClientRegistrationService(), discovery); + } + + @Bean + ApplicationRunner conformanceRunner(Optional scenario, ServerUrl serverUrl) { + return args -> { + String scenarioName = System.getenv("MCP_CONFORMANCE_SCENARIO"); + if (scenarioName == null || scenarioName.isEmpty()) { + System.err.println("Error: MCP_CONFORMANCE_SCENARIO environment variable is not set"); + System.exit(1); + } + + if (scenario.isEmpty()) { + System.err.println("Unsupported scenario type"); + System.exit(1); + } + + try { + System.out.println("Executing " + scenarioName); + scenario.get().execute(serverUrl.value()); + System.exit(0); + } + catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + e.printStackTrace(); + System.exit(1); + } + }; + } + + public record ServerUrl(String value) { + } + + @Bean + ServerUrl serverUrl(ApplicationArguments args) { + var nonOptionArgs = args.getNonOptionArgs(); + if (nonOptionArgs.isEmpty()) { + System.err.println("Usage: ConformanceSpringClientApplication "); + System.err.println("The server URL must be provided as a command-line argument."); + System.err.println("The MCP_CONFORMANCE_SCENARIO environment variable must be set."); + System.exit(1); + } + + return new ServerUrl(nonOptionArgs.get(nonOptionArgs.size() - 1)); + } + +} diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/McpClientController.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/McpClientController.java new file mode 100644 index 000000000..e02cfd416 --- /dev/null +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/McpClientController.java @@ -0,0 +1,30 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.conformance.client; + +import io.modelcontextprotocol.conformance.client.scenario.Scenario; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Expose MCP client in a web environment. + */ +@RestController +class McpClientController { + + private final Scenario scenario; + + McpClientController(Scenario scenario) { + this.scenario = scenario; + } + + @GetMapping("/initialize-mcp-client") + public String execute() { + this.scenario.getMcpClient().initialize(); + return "OK"; + } + +} 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 new file mode 100644 index 000000000..acf26d94e --- /dev/null +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/DefaultConfiguration.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.conformance.client.configuration; + +import io.modelcontextprotocol.conformance.client.ConformanceSpringClientApplication; +import io.modelcontextprotocol.conformance.client.scenario.DefaultScenario; +import org.springaicommunity.mcp.security.client.sync.config.McpClientOAuth2Configurer; +import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpClientRegistrationRepository; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.security.web.SecurityFilterChain; +import static io.modelcontextprotocol.conformance.client.ConformanceSpringClientApplication.REGISTRATION_ID; + +@Configuration +@ConditionalOnExpression("#{environment['MCP_CONFORMANCE_SCENARIO'] != 'auth/pre-registration'}") +public class DefaultConfiguration { + + @Bean + DefaultScenario defaultScenario(McpClientRegistrationRepository clientRegistrationRepository, + ServletWebServerApplicationContext serverCtx, + OAuth2AuthorizedClientRepository oAuth2AuthorizedClientRepository) { + return new DefaultScenario(clientRegistrationRepository, serverCtx, oAuth2AuthorizedClientRepository); + } + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http, ConformanceSpringClientApplication.ServerUrl serverUrl) { + return http.authorizeHttpRequests(authz -> authz.anyRequest().permitAll()) + .with(new McpClientOAuth2Configurer(), + mcp -> mcp.registerMcpOAuth2Client(REGISTRATION_ID, serverUrl.value())) + .build(); + } + +} diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/PreRegistrationConfiguration.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/PreRegistrationConfiguration.java new file mode 100644 index 000000000..afe03f85a --- /dev/null +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/PreRegistrationConfiguration.java @@ -0,0 +1,39 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.conformance.client.configuration; + +import io.modelcontextprotocol.conformance.client.scenario.PreRegistrationScenario; +import org.springaicommunity.mcp.security.client.sync.config.McpClientOAuth2Configurer; +import org.springaicommunity.mcp.security.client.sync.oauth2.metadata.McpMetadataDiscoveryService; +import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpClientRegistrationRepository; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@ConditionalOnProperty(name = "mcp.conformance.scenario", havingValue = "auth/pre-registration") +public class PreRegistrationConfiguration { + + @Bean + PreRegistrationScenario defaultScenario(McpClientRegistrationRepository clientRegistrationRepository, + McpMetadataDiscoveryService mcpMetadataDiscovery, + OAuth2AuthorizedClientService oAuth2AuthorizedClientService) { + return new PreRegistrationScenario(clientRegistrationRepository, mcpMetadataDiscovery, + oAuth2AuthorizedClientService); + } + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) { + return http.authorizeHttpRequests(authz -> authz.anyRequest().permitAll()) + .with(new McpClientOAuth2Configurer(), Customizer.withDefaults()) + .build(); + } + +} 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 new file mode 100644 index 000000000..d82637de9 --- /dev/null +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java @@ -0,0 +1,100 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.conformance.client.scenario; + +import java.net.CookieManager; +import java.net.CookiePolicy; +import java.net.http.HttpClient; +import java.time.Duration; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.spec.McpSchema; +import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springaicommunity.mcp.security.client.sync.AuthenticationMcpTransportContextProvider; +import org.springaicommunity.mcp.security.client.sync.oauth2.http.client.OAuth2AuthorizationCodeSyncHttpRequestCustomizer; +import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpClientRegistrationRepository; + +import org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.web.client.RestClient; +import static io.modelcontextprotocol.conformance.client.ConformanceSpringClientApplication.REGISTRATION_ID; + +public class DefaultScenario implements Scenario { + + private static final Logger log = LoggerFactory + .getLogger(DefaultScenario.class); + + private final ServletWebServerApplicationContext serverCtx; + + private final DefaultOAuth2AuthorizedClientManager authorizedClientManager; + + private McpSyncClient client; + + public DefaultScenario(McpClientRegistrationRepository clientRegistrationRepository, + ServletWebServerApplicationContext serverCtx, + OAuth2AuthorizedClientRepository oAuth2AuthorizedClientRepository) { + this.serverCtx = serverCtx; + this.authorizedClientManager = new DefaultOAuth2AuthorizedClientManager(clientRegistrationRepository, + oAuth2AuthorizedClientRepository); + } + + @Override + public void execute(String serverUrl) { + log.info("Executing DefaultScenario"); + var testServerUrl = "http://localhost:" + serverCtx.getWebServer().getPort(); + var testClient = buildTestClient(testServerUrl); + + var customizer = new OAuth2AuthorizationCodeSyncHttpRequestCustomizer(authorizedClientManager, REGISTRATION_ID); + HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(serverUrl) + .httpRequestCustomizer(customizer) + .build(); + + this.client = McpClient.sync(transport) + .transportContextProvider(new AuthenticationMcpTransportContextProvider()) + .clientInfo(new McpSchema.Implementation("test-client", "1.0.0")) + .requestTimeout(Duration.ofSeconds(30)) + .build(); + + try { + testClient.get().uri("/initialize-mcp-client").retrieve().toBodilessEntity(); + } + finally { + // Close the client (which will close the transport) + this.client.close(); + + System.out.println("Connection closed successfully"); + } + } + + private static @NonNull RestClient buildTestClient(String testServerUrl) { + var cookieManager = new CookieManager(); + cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL); + var httpClient = HttpClient.newBuilder() + .cookieHandler(cookieManager) + .followRedirects(HttpClient.Redirect.ALWAYS) + .build(); + var testClient = RestClient.builder() + .baseUrl(testServerUrl) + .requestFactory(new JdkClientHttpRequestFactory(httpClient)) + .build(); + return testClient; + } + + @Override + public McpSyncClient getMcpClient() { + if (this.client == null) { + return Scenario.super.getMcpClient(); + } + + return this.client; + } + +} diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/PreRegistrationScenario.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/PreRegistrationScenario.java new file mode 100644 index 000000000..8e6bbe228 --- /dev/null +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/PreRegistrationScenario.java @@ -0,0 +1,110 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.conformance.client.scenario; + +import java.time.Duration; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.spec.McpSchema; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springaicommunity.mcp.security.client.sync.AuthenticationMcpTransportContextProvider; +import org.springaicommunity.mcp.security.client.sync.oauth2.http.client.OAuth2ClientCredentialsSyncHttpRequestCustomizer; +import org.springaicommunity.mcp.security.client.sync.oauth2.metadata.McpMetadataDiscoveryService; +import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpClientRegistrationRepository; +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.annotation.JsonNaming; +import tools.jackson.databind.json.JsonMapper; + +import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.registration.ClientRegistrations; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import static io.modelcontextprotocol.conformance.client.ConformanceSpringClientApplication.REGISTRATION_ID; + +public class PreRegistrationScenario implements Scenario { + + private static final Logger log = LoggerFactory.getLogger(PreRegistrationScenario.class); + + private final JsonMapper mapper; + + private final McpClientRegistrationRepository clientRegistrationRepository; + + private final AuthorizedClientServiceOAuth2AuthorizedClientManager authorizedClientManager; + + private final McpMetadataDiscoveryService mcpMetadataDiscovery; + + public PreRegistrationScenario(McpClientRegistrationRepository clientRegistrationRepository, + McpMetadataDiscoveryService mcpMetadataDiscovery, OAuth2AuthorizedClientService authorizedClientService) { + this.mapper = JsonMapper.shared(); + this.clientRegistrationRepository = clientRegistrationRepository; + this.authorizedClientManager = new AuthorizedClientServiceOAuth2AuthorizedClientManager( + clientRegistrationRepository, authorizedClientService); + this.mcpMetadataDiscovery = mcpMetadataDiscovery; + } + + @Override + public void execute(String serverUrl) { + log.info("Executing PreRegistrationScenario"); + + var oauthCredentials = extractCredentialsFromContext(); + setClientRegistration(serverUrl, oauthCredentials); + + var customizer = new OAuth2ClientCredentialsSyncHttpRequestCustomizer(authorizedClientManager, REGISTRATION_ID); + HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(serverUrl) + .httpRequestCustomizer(customizer) + .build(); + + var client = McpClient.sync(transport) + .transportContextProvider(new AuthenticationMcpTransportContextProvider()) + .clientInfo(new McpSchema.Implementation("test-client", "1.0.0")) + .requestTimeout(Duration.ofSeconds(30)) + .build(); + + try { + // Initialize client + client.initialize(); + + System.out.println("Successfully connected to MCP server"); + } + finally { + // Close the client (which will close the transport) + client.close(); + + System.out.println("Connection closed successfully"); + } + } + + private void setClientRegistration(String mcpServerUrl, PreRegistrationContext oauthCredentials) { + var metadata = this.mcpMetadataDiscovery.getMcpMetadata(mcpServerUrl); + var registration = ClientRegistrations + .fromIssuerLocation(metadata.protectedResourceMetadata().authorizationServers().get(0)) + .registrationId(REGISTRATION_ID) + .clientId(oauthCredentials.clientId()) + .clientSecret(oauthCredentials.clientSecret()) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .build(); + clientRegistrationRepository.addPreRegisteredClient(registration, + metadata.protectedResourceMetadata().resource()); + } + + private PreRegistrationContext extractCredentialsFromContext() { + String contextEnv = System.getenv("MCP_CONFORMANCE_CONTEXT"); + if (contextEnv == null || contextEnv.isEmpty()) { + var errorMessage = "Error: MCP_CONFORMANCE_CONTEXT environment variable is not set"; + System.err.println(errorMessage); + throw new RuntimeException(errorMessage); + } + + return mapper.readValue(contextEnv, PreRegistrationContext.class); + } + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + private record PreRegistrationContext(String clientId, String clientSecret) { + + } + +} diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/Scenario.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/Scenario.java new file mode 100644 index 000000000..9054db83b --- /dev/null +++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/Scenario.java @@ -0,0 +1,17 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.conformance.client.scenario; + +import io.modelcontextprotocol.client.McpSyncClient; + +public interface Scenario { + + default McpSyncClient getMcpClient() { + throw new IllegalStateException("Client not set"); + } + + void execute(String serverUrl); + +} diff --git a/conformance-tests/client-spring-http-client/src/main/resources/application.properties b/conformance-tests/client-spring-http-client/src/main/resources/application.properties new file mode 100644 index 000000000..0c4a77438 --- /dev/null +++ b/conformance-tests/client-spring-http-client/src/main/resources/application.properties @@ -0,0 +1,4 @@ +# Server runs on random port +server.port=0 +# Disable Spring AI MCP client auto-configuration (we configure the client manually) +spring.ai.mcp.client.enabled=false diff --git a/conformance-tests/conformance-baseline.yml b/conformance-tests/conformance-baseline.yml index 920e8401c..4ab144063 100644 --- a/conformance-tests/conformance-baseline.yml +++ b/conformance-tests/conformance-baseline.yml @@ -12,3 +12,7 @@ client: # - Client does not parse or respect retry: field timing # - Client does not send Last-Event-ID header - sse-retry + # CIMD not implemented yet + - auth/basic-cimd + # Scope step up beyond initial authorization request not implemented + - auth/scope-step-up diff --git a/conformance-tests/pom.xml b/conformance-tests/pom.xml index 141ac6299..432fde03f 100644 --- a/conformance-tests/pom.xml +++ b/conformance-tests/pom.xml @@ -22,6 +22,7 @@ client-jdk-http-client + client-spring-http-client server-servlet