Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.TypeRef;
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
Expand Down Expand Up @@ -473,7 +474,9 @@ public Mono<McpClientWrapper> buildAsync() {

return Mono.fromCallable(
() -> {
McpClientTransport transport = transportConfig.createTransport();
McpJsonMapper jsonMapper = McpJsonDefaults.jsonMapper();
JsonSchemaValidator jsonSchemaValidator = McpJsonDefaults.jsonSchemaValidator();
McpClientTransport transport = transportConfig.createTransport(jsonMapper);

if (protocolVersions != null) {
transport =
Expand All @@ -492,7 +495,8 @@ public Mono<McpClientWrapper> buildAsync() {
.requestTimeout(requestTimeout)
.initializationTimeout(initializationTimeout)
.clientInfo(clientInfo)
.capabilities(clientCapabilities);
.capabilities(clientCapabilities)
.jsonSchemaValidator(jsonSchemaValidator);

if (asyncElicitationHandler != null) {
clientBuilder = clientBuilder.elicitation(asyncElicitationHandler);
Expand All @@ -514,7 +518,9 @@ public McpClientWrapper buildSync() {
throw new IllegalStateException("Transport must be configured");
}

McpClientTransport transport = transportConfig.createTransport();
McpJsonMapper jsonMapper = McpJsonDefaults.jsonMapper();
JsonSchemaValidator jsonSchemaValidator = McpJsonDefaults.jsonSchemaValidator();
McpClientTransport transport = transportConfig.createTransport(jsonMapper);

if (protocolVersions != null) {
transport = new ProtocolVersionOverrideTransport(transport, protocolVersions);
Expand All @@ -532,7 +538,8 @@ public McpClientWrapper buildSync() {
.requestTimeout(requestTimeout)
.initializationTimeout(initializationTimeout)
.clientInfo(clientInfo)
.capabilities(clientCapabilities);
.capabilities(clientCapabilities)
.jsonSchemaValidator(jsonSchemaValidator);

if (syncElicitationHandler != null) {
clientBuilder = clientBuilder.elicitation(syncElicitationHandler);
Expand Down Expand Up @@ -560,7 +567,7 @@ private McpSchema.ClientCapabilities buildCapabilities(boolean withElicitation)
// ==================== Internal Transport Configuration Classes ====================

private interface TransportConfig {
McpClientTransport createTransport();
McpClientTransport createTransport(McpJsonMapper jsonMapper);
}

/**
Expand Down Expand Up @@ -637,7 +644,7 @@ public StdioTransportConfig(String command, List<String> args, Map<String, Strin
}

@Override
public McpClientTransport createTransport() {
public McpClientTransport createTransport(McpJsonMapper jsonMapper) {
ServerParameters.Builder paramsBuilder = ServerParameters.builder(command);

if (!args.isEmpty()) {
Expand All @@ -649,7 +656,7 @@ public McpClientTransport createTransport() {
}

ServerParameters params = paramsBuilder.build();
return new StdioClientTransport(params, McpJsonMapper.getDefault());
return new StdioClientTransport(params, jsonMapper);
}
}

Expand Down Expand Up @@ -777,7 +784,7 @@ public void customizeHttpClient(Consumer<HttpClient.Builder> customizer) {
}

@Override
public McpClientTransport createTransport() {
public McpClientTransport createTransport(McpJsonMapper jsonMapper) {
if (clientTransportBuilder == null) {
clientTransportBuilder = HttpClientSseClientTransport.builder(url);
}
Expand All @@ -787,7 +794,7 @@ public McpClientTransport createTransport() {
clientTransportBuilder.customizeClient(httpClientCustomizer);
}

clientTransportBuilder.sseEndpoint(extractEndpoint());
clientTransportBuilder.jsonMapper(jsonMapper).sseEndpoint(extractEndpoint());

if (!headers.isEmpty()) {
clientTransportBuilder.customizeRequest(
Expand Down Expand Up @@ -823,7 +830,7 @@ public void customizeHttpClient(Consumer<HttpClient.Builder> customizer) {
}

@Override
public McpClientTransport createTransport() {
public McpClientTransport createTransport(McpJsonMapper jsonMapper) {
if (clientTransportBuilder == null) {
clientTransportBuilder = HttpClientStreamableHttpTransport.builder(url);
}
Expand All @@ -833,7 +840,7 @@ public McpClientTransport createTransport() {
clientTransportBuilder.customizeClient(httpClientCustomizer);
}

clientTransportBuilder.endpoint(extractEndpoint());
clientTransportBuilder.jsonMapper(jsonMapper).endpoint(extractEndpoint());

if (!headers.isEmpty()) {
clientTransportBuilder.customizeRequest(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* Copyright 2024-2026 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.agentscope.core.tool.mcp;

import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.McpJsonMapperSupplier;
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
import io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier;
import java.util.Optional;
import java.util.ServiceLoader;
import java.util.function.Supplier;

/**
* Loads the default MCP JSON components using a class loader that can see their providers.
*
* <p>Worker threads in executable JARs may use a context class loader that cannot access nested
* dependencies. In that case, fall back to the AgentScope class loader.
*/
final class McpJsonDefaults {

private McpJsonDefaults() {}

static McpJsonMapper jsonMapper() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] jsonMapper() and jsonSchemaValidator() invoke ServiceLoader.load() on every call. Since builder methods are typically called once per client and ServiceLoader.load() is lightweight, this is acceptable. However, if clients are created frequently, consider caching the resolved supplier with a lazy-holder or static final field to avoid repeated classpath scanning. Note: ServiceLoader.load() + findFirst() should be deterministic for a given classloader, so caching is safe.

return loadSupplier(McpJsonMapperSupplier.class, "McpJsonMapper").get();
}

static JsonSchemaValidator jsonSchemaValidator() {
return loadSupplier(JsonSchemaValidatorSupplier.class, "JsonSchemaValidator").get();
}

private static <T extends Supplier<?>> T loadSupplier(
Class<T> serviceType, String implementationType) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The parameter name implementationType is slightly misleading — it is used as a display name in the error message (e.g. "McpJsonMapper", "JsonSchemaValidator"), not as a type reference. Consider renaming to displayName or providerName for clarity.

ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
Optional<T> supplier = loadFirst(serviceType, contextClassLoader);

ClassLoader agentScopeClassLoader = McpJsonDefaults.class.getClassLoader();
if (supplier.isEmpty() && agentScopeClassLoader != contextClassLoader) {
supplier = loadFirst(serviceType, agentScopeClassLoader);
}

return supplier.orElseThrow(
() ->
new IllegalStateException(
"No default " + implementationType + " implementation found"));
}

private static <T> Optional<T> loadFirst(Class<T> serviceType, ClassLoader classLoader) {
if (classLoader == null) {
return Optional.empty();
}
try {
return ServiceLoader.load(serviceType, classLoader).findFirst();
} catch (java.util.ServiceConfigurationError | LinkageError e) {
return Optional.empty();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
* Copyright 2024-2026 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.agentscope.core.tool.mcp;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import io.modelcontextprotocol.json.McpJsonMapperSupplier;
import io.modelcontextprotocol.json.schema.JsonSchemaValidatorSupplier;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ServiceLoader;
import java.util.function.Supplier;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

class McpJsonDefaultsTest {

@Test
void shouldResolveProvidersFromContextClassLoader() {
assertNotNull(McpJsonDefaults.jsonMapper());
assertNotNull(McpJsonDefaults.jsonSchemaValidator());
}

@Test
void shouldFallbackWhenContextClassLoaderCannotSeeMcpProviders() {
ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader();
ClassLoader isolatedClassLoader = new ClassLoader(null) {};

assertTrue(
ServiceLoader.load(McpJsonMapperSupplier.class, isolatedClassLoader)
.findFirst()
.isEmpty());
assertTrue(
ServiceLoader.load(JsonSchemaValidatorSupplier.class, isolatedClassLoader)
.findFirst()
.isEmpty());

try {
Thread.currentThread().setContextClassLoader(isolatedClassLoader);

assertNotNull(McpJsonDefaults.jsonMapper());
assertNotNull(McpJsonDefaults.jsonSchemaValidator());
assertNotNull(
McpClientBuilder.create("isolated-loader")
.stdioTransport("echo", "test")
.buildSync());
} finally {
Thread.currentThread().setContextClassLoader(originalClassLoader);
}
}

@Test
void shouldFallbackWhenContextClassLoaderIsNull() {
ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader();
try {
Thread.currentThread().setContextClassLoader(null);

assertNotNull(McpJsonDefaults.jsonMapper());
assertNotNull(McpJsonDefaults.jsonSchemaValidator());
} finally {
Thread.currentThread().setContextClassLoader(originalClassLoader);
}
}

@Test
void shouldFallbackWhenContextClassLoaderHasBrokenServiceDeclaration(@TempDir Path tempDir)
throws Exception {
writeServiceFile(tempDir, McpJsonMapperSupplier.class.getName(), "missing.DoesNotExist");

ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader();
try (URLClassLoader brokenClassLoader =
new URLClassLoader(new URL[] {tempDir.toUri().toURL()}, null)) {
Thread.currentThread().setContextClassLoader(brokenClassLoader);

assertNotNull(McpJsonDefaults.jsonMapper());
} finally {
Thread.currentThread().setContextClassLoader(originalClassLoader);
}
}

@Test
void shouldThrowWhenNoDefaultSupplierIsAvailable() throws Exception {
Method loadSupplier =
McpJsonDefaults.class.getDeclaredMethod("loadSupplier", Class.class, String.class);
loadSupplier.setAccessible(true);

ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader();
try {
Thread.currentThread().setContextClassLoader(McpJsonDefaults.class.getClassLoader());

InvocationTargetException exception =
assertThrows(
InvocationTargetException.class,
() ->
loadSupplier.invoke(
null, MissingSupplier.class, "MissingSupplier"));

IllegalStateException cause =
assertInstanceOf(IllegalStateException.class, exception.getCause());
assertEquals("No default MissingSupplier implementation found", cause.getMessage());
} finally {
Thread.currentThread().setContextClassLoader(originalClassLoader);
}
}

private static void writeServiceFile(Path root, String serviceName, String providerName)
throws Exception {
Path serviceDirectory = root.resolve("META-INF/services");
Files.createDirectories(serviceDirectory);
Files.writeString(
serviceDirectory.resolve(serviceName), providerName + System.lineSeparator());
}

private interface MissingSupplier extends Supplier<Object> {}
}
Loading