diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpClientBuilder.java b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpClientBuilder.java index 75879b0493..46fc786729 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpClientBuilder.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpClientBuilder.java @@ -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; @@ -473,7 +474,9 @@ public Mono 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 = @@ -492,7 +495,8 @@ public Mono buildAsync() { .requestTimeout(requestTimeout) .initializationTimeout(initializationTimeout) .clientInfo(clientInfo) - .capabilities(clientCapabilities); + .capabilities(clientCapabilities) + .jsonSchemaValidator(jsonSchemaValidator); if (asyncElicitationHandler != null) { clientBuilder = clientBuilder.elicitation(asyncElicitationHandler); @@ -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); @@ -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); @@ -560,7 +567,7 @@ private McpSchema.ClientCapabilities buildCapabilities(boolean withElicitation) // ==================== Internal Transport Configuration Classes ==================== private interface TransportConfig { - McpClientTransport createTransport(); + McpClientTransport createTransport(McpJsonMapper jsonMapper); } /** @@ -637,7 +644,7 @@ public StdioTransportConfig(String command, List args, Map customizer) { } @Override - public McpClientTransport createTransport() { + public McpClientTransport createTransport(McpJsonMapper jsonMapper) { if (clientTransportBuilder == null) { clientTransportBuilder = HttpClientSseClientTransport.builder(url); } @@ -787,7 +794,7 @@ public McpClientTransport createTransport() { clientTransportBuilder.customizeClient(httpClientCustomizer); } - clientTransportBuilder.sseEndpoint(extractEndpoint()); + clientTransportBuilder.jsonMapper(jsonMapper).sseEndpoint(extractEndpoint()); if (!headers.isEmpty()) { clientTransportBuilder.customizeRequest( @@ -823,7 +830,7 @@ public void customizeHttpClient(Consumer customizer) { } @Override - public McpClientTransport createTransport() { + public McpClientTransport createTransport(McpJsonMapper jsonMapper) { if (clientTransportBuilder == null) { clientTransportBuilder = HttpClientStreamableHttpTransport.builder(url); } @@ -833,7 +840,7 @@ public McpClientTransport createTransport() { clientTransportBuilder.customizeClient(httpClientCustomizer); } - clientTransportBuilder.endpoint(extractEndpoint()); + clientTransportBuilder.jsonMapper(jsonMapper).endpoint(extractEndpoint()); if (!headers.isEmpty()) { clientTransportBuilder.customizeRequest( diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpJsonDefaults.java b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpJsonDefaults.java new file mode 100644 index 0000000000..ac2e8f6712 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpJsonDefaults.java @@ -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. + * + *

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() { + return loadSupplier(McpJsonMapperSupplier.class, "McpJsonMapper").get(); + } + + static JsonSchemaValidator jsonSchemaValidator() { + return loadSupplier(JsonSchemaValidatorSupplier.class, "JsonSchemaValidator").get(); + } + + private static > T loadSupplier( + Class serviceType, String implementationType) { + ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); + Optional 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 Optional loadFirst(Class 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(); + } + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/McpJsonDefaultsTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/McpJsonDefaultsTest.java new file mode 100644 index 0000000000..d099553c04 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/mcp/McpJsonDefaultsTest.java @@ -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 {} +}