Conformance testing: add app to test auth suite (#806)
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
This commit is contained in:
parent
4c1c3d8d5c
commit
5b99cd600e
8 changed files with 750 additions and 6 deletions
|
|
@ -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 <server-url>
|
||||
*
|
||||
* @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));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
Loading…
Reference in a new issue