Conformance testing: add app to test auth suite (#806)

Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
This commit is contained in:
Daniel Garnier-Moiroux 2026-02-20 17:53:57 +01:00 • committed by GitHub
commit 5b99cd600e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 750 additions and 6 deletions

View file

@ -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.
*
* <p>
* 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.
*
* <p>
* It specifically tests the {@code auth} conformance suite. It requires Spring to work.
*
* <p>
* Usage: java -jar client-spring-http-client.jar &lt;server-url&gt;
*
* @see <a href= "https://github.com/modelcontextprotocol/conformance">MCP Conformance
* Test Framework</a>
*/
@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> 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 <server-url>");
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));
}
}

View file

@ -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";
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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;
}
}

View file

@ -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) {
}
}

View file

@ -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);
}

View file

@ -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