feat(mcp): update WebFlux and WebMvc server transport providers with URL configuration
- Update MCP SDK version to 0.9.0 - Add baseUrl and sseEndpoint properties to McpServerProperties - Update WebFlux and WebMvc server transport providers to use new URL configuration properties - Remove deprecated backward compatibility code and related tests - Remove deprecated methods from McpToolUtils - Update MCP SDK version to 0.9.0-SNAPSHOT - Add tool filtering capability to MCP Tool Callback Providers Introduces a BiPredicate-based filtering mechanism for both Sync and Async MCP Tool Callback Providers, allowing selective tool discovery based on custom criteria. This enables filtering tools by name, client, or any combination of properties. * Apply filter in getToolCallbacks() methods for both providers * Add tests for various filtering scenarios - Add utility method to retrieve MCP exchange from tool context * Add constant TOOL_CONTEXT_MCP_EXCHANGE_KEY to replace hardcoded exchange string * Implement getMcpExchange utility method to safely retrieve the MCP exchange object Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
@@ -1,112 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 - 2025 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
|
||||
*
|
||||
* https://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 org.springframework.ai.mcp.server.autoconfigure;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import io.modelcontextprotocol.server.McpServerFeatures;
|
||||
import io.modelcontextprotocol.server.McpSyncServerExchange;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
/**
|
||||
* @author Christian Tzolov
|
||||
*/
|
||||
@Deprecated
|
||||
@Configuration
|
||||
public class McpBackwardCompatibility {
|
||||
|
||||
@Bean
|
||||
public List<BiConsumer<McpSyncServerExchange, List<McpSchema.Root>>> syncRootsChangeConsumerToHandler(
|
||||
List<Consumer<List<McpSchema.Root>>> rootsChangeConsumers) {
|
||||
|
||||
if (CollectionUtils.isEmpty(rootsChangeConsumers)) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
return rootsChangeConsumers.stream()
|
||||
.map(c -> (BiConsumer<McpSyncServerExchange, List<McpSchema.Root>>) ((exchange, roots) -> c.accept(roots)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public List<McpServerFeatures.SyncToolSpecification> syncToolsRegistrationToSpecificaiton(
|
||||
ObjectProvider<List<McpServerFeatures.SyncToolRegistration>> toolRegistrations) {
|
||||
|
||||
return toolRegistrations.stream()
|
||||
.flatMap(List::stream)
|
||||
.map(McpServerFeatures.SyncToolRegistration::toSpecification)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public List<McpServerFeatures.SyncResourceSpecification> syncResourceRegistrationToSpecificaiton(
|
||||
ObjectProvider<List<McpServerFeatures.SyncResourceRegistration>> resourceRegistrations) {
|
||||
|
||||
return resourceRegistrations.stream()
|
||||
.flatMap(List::stream)
|
||||
.map(McpServerFeatures.SyncResourceRegistration::toSpecification)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public List<McpServerFeatures.SyncPromptSpecification> syncPromptRegistrationToSpecificaiton(
|
||||
ObjectProvider<List<McpServerFeatures.SyncPromptRegistration>> promptRegistrations) {
|
||||
|
||||
return promptRegistrations.stream()
|
||||
.flatMap(List::stream)
|
||||
.map(McpServerFeatures.SyncPromptRegistration::toSpecification)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Async
|
||||
@Bean
|
||||
public List<McpServerFeatures.AsyncToolSpecification> asyncToolsRegistrationToSpecificaiton(
|
||||
ObjectProvider<List<McpServerFeatures.AsyncToolRegistration>> toolRegistrations) {
|
||||
|
||||
return toolRegistrations.stream()
|
||||
.flatMap(List::stream)
|
||||
.map(McpServerFeatures.AsyncToolRegistration::toSpecification)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public List<McpServerFeatures.AsyncResourceSpecification> asyncResourceRegistrationToSpecificaiton(
|
||||
ObjectProvider<List<McpServerFeatures.AsyncResourceRegistration>> resourceRegistrations) {
|
||||
|
||||
return resourceRegistrations.stream()
|
||||
.flatMap(List::stream)
|
||||
.map(McpServerFeatures.AsyncResourceRegistration::toSpecification)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public List<McpServerFeatures.AsyncPromptSpecification> asyncPromptRegistrationToSpecificaiton(
|
||||
ObjectProvider<List<McpServerFeatures.AsyncPromptRegistration>> promptRegistrations) {
|
||||
|
||||
return promptRegistrations.stream()
|
||||
.flatMap(List::stream)
|
||||
.map(McpServerFeatures.AsyncPromptRegistration::toSpecification)
|
||||
.toList();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -53,7 +53,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.core.log.LogAccessor;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.MimeType;
|
||||
@@ -110,7 +109,6 @@ import org.springframework.util.MimeType;
|
||||
@AutoConfiguration(after = { McpWebMvcServerAutoConfiguration.class, McpWebFluxServerAutoConfiguration.class })
|
||||
@ConditionalOnClass({ McpSchema.class, McpSyncServer.class })
|
||||
@EnableConfigurationProperties(McpServerProperties.class)
|
||||
@Import(McpBackwardCompatibility.class)
|
||||
@ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true",
|
||||
matchIfMissing = true)
|
||||
public class McpServerAutoConfiguration {
|
||||
|
||||
@@ -98,6 +98,14 @@ public class McpServerProperties {
|
||||
*/
|
||||
private boolean promptChangeNotification = true;
|
||||
|
||||
/**
|
||||
*/
|
||||
private String baseUrl = "";
|
||||
|
||||
/**
|
||||
*/
|
||||
private String sseEndpoint = "/sse";
|
||||
|
||||
/**
|
||||
* The endpoint path for Server-Sent Events (SSE) when using web transports.
|
||||
* <p>
|
||||
@@ -196,6 +204,24 @@ public class McpServerProperties {
|
||||
this.promptChangeNotification = promptChangeNotification;
|
||||
}
|
||||
|
||||
public String getBaseUrl() {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
public void setBaseUrl(String baseUrl) {
|
||||
Assert.notNull(baseUrl, "Base URL must not be null");
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
public String getSseEndpoint() {
|
||||
return this.sseEndpoint;
|
||||
}
|
||||
|
||||
public void setSseEndpoint(String sseEndpoint) {
|
||||
Assert.hasText(sseEndpoint, "SSE endpoint must not be empty");
|
||||
this.sseEndpoint = sseEndpoint;
|
||||
}
|
||||
|
||||
public String getSseMessageEndpoint() {
|
||||
return this.sseMessageEndpoint;
|
||||
}
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025-2025 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
|
||||
*
|
||||
* https://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 org.springframework.ai.mcp.server.autoconfigure;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import io.modelcontextprotocol.client.McpSyncClient;
|
||||
import io.modelcontextprotocol.server.McpAsyncServer;
|
||||
import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolSpecification;
|
||||
import io.modelcontextprotocol.server.McpServerFeatures.SyncPromptRegistration;
|
||||
import io.modelcontextprotocol.server.McpServerFeatures.SyncResourceRegistration;
|
||||
import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification;
|
||||
import io.modelcontextprotocol.server.McpSyncServer;
|
||||
import io.modelcontextprotocol.server.transport.StdioServerTransportProvider;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpServerTransport;
|
||||
import io.modelcontextprotocol.spec.McpServerTransportProvider;
|
||||
import io.modelcontextprotocol.spec.ServerMcpTransport;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.ai.mcp.SyncMcpToolCallback;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@Disabled
|
||||
public class McpServerAutoConfigurationBackwardCompatibilityIT {
|
||||
|
||||
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(McpServerAutoConfiguration.class));
|
||||
|
||||
@Test
|
||||
void defaultConfiguration() {
|
||||
this.contextRunner.run(context -> {
|
||||
assertThat(context).hasSingleBean(McpSyncServer.class);
|
||||
assertThat(context).hasSingleBean(McpServerTransportProvider.class);
|
||||
assertThat(context.getBean(McpServerTransportProvider.class))
|
||||
.isInstanceOf(StdioServerTransportProvider.class);
|
||||
|
||||
McpServerProperties properties = context.getBean(McpServerProperties.class);
|
||||
assertThat(properties.getName()).isEqualTo("mcp-server");
|
||||
assertThat(properties.getVersion()).isEqualTo("1.0.0");
|
||||
assertThat(properties.getType()).isEqualTo(McpServerProperties.ServerType.SYNC);
|
||||
assertThat(properties.isToolChangeNotification()).isTrue();
|
||||
assertThat(properties.isResourceChangeNotification()).isTrue();
|
||||
assertThat(properties.isPromptChangeNotification()).isTrue();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void asyncConfiguration() {
|
||||
this.contextRunner
|
||||
.withPropertyValues("spring.ai.mcp.server.type=ASYNC", "spring.ai.mcp.server.name=test-server",
|
||||
"spring.ai.mcp.server.version=2.0.0")
|
||||
.run(context -> {
|
||||
assertThat(context).hasSingleBean(McpAsyncServer.class);
|
||||
assertThat(context).doesNotHaveBean(McpSyncServer.class);
|
||||
|
||||
McpServerProperties properties = context.getBean(McpServerProperties.class);
|
||||
assertThat(properties.getName()).isEqualTo("test-server");
|
||||
assertThat(properties.getVersion()).isEqualTo("2.0.0");
|
||||
assertThat(properties.getType()).isEqualTo(McpServerProperties.ServerType.ASYNC);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void transportConfiguration() {
|
||||
this.contextRunner.withUserConfiguration(CustomTransportConfiguration.class).run(context -> {
|
||||
assertThat(context).hasSingleBean(McpServerTransport.class);
|
||||
assertThat(context.getBean(McpServerTransport.class)).isInstanceOf(CustomServerTransport.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void serverNotificationConfiguration() {
|
||||
this.contextRunner
|
||||
.withPropertyValues("spring.ai.mcp.server.tool-change-notification=false",
|
||||
"spring.ai.mcp.server.resource-change-notification=false")
|
||||
.run(context -> {
|
||||
McpServerProperties properties = context.getBean(McpServerProperties.class);
|
||||
assertThat(properties.isToolChangeNotification()).isFalse();
|
||||
assertThat(properties.isResourceChangeNotification()).isFalse();
|
||||
});
|
||||
}
|
||||
|
||||
// @Test
|
||||
void invalidConfigurationThrowsException() {
|
||||
this.contextRunner.withPropertyValues("spring.ai.mcp.server.version=invalid-version").run(context -> {
|
||||
assertThat(context).hasFailed();
|
||||
assertThat(context).getFailure()
|
||||
.hasRootCauseInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Invalid version format");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledConfiguration() {
|
||||
this.contextRunner.withPropertyValues("spring.ai.mcp.server.enabled=false").run(context -> {
|
||||
assertThat(context).doesNotHaveBean(McpSyncServer.class);
|
||||
assertThat(context).doesNotHaveBean(McpAsyncServer.class);
|
||||
assertThat(context).doesNotHaveBean(ServerMcpTransport.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void notificationConfiguration() {
|
||||
this.contextRunner
|
||||
.withPropertyValues("spring.ai.mcp.server.tool-change-notification=false",
|
||||
"spring.ai.mcp.server.resource-change-notification=false",
|
||||
"spring.ai.mcp.server.prompt-change-notification=false")
|
||||
.run(context -> {
|
||||
McpServerProperties properties = context.getBean(McpServerProperties.class);
|
||||
assertThat(properties.isToolChangeNotification()).isFalse();
|
||||
assertThat(properties.isResourceChangeNotification()).isFalse();
|
||||
assertThat(properties.isPromptChangeNotification()).isFalse();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void stdioConfiguration() {
|
||||
this.contextRunner.withPropertyValues("spring.ai.mcp.server.stdio=true").run(context -> {
|
||||
McpServerProperties properties = context.getBean(McpServerProperties.class);
|
||||
assertThat(properties.isStdio()).isTrue();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void serverCapabilitiesConfiguration() {
|
||||
this.contextRunner.run(context -> {
|
||||
assertThat(context).hasSingleBean(McpSchema.ServerCapabilities.Builder.class);
|
||||
McpSchema.ServerCapabilities.Builder builder = context.getBean(McpSchema.ServerCapabilities.Builder.class);
|
||||
assertThat(builder).isNotNull();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolRegistrationConfiguration() {
|
||||
this.contextRunner.withUserConfiguration(TestToolConfiguration.class).run(context -> {
|
||||
List<SyncToolSpecification> tools = context.getBean("syncTools", List.class);
|
||||
assertThat(tools).hasSize(2);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void resourceRegistrationConfiguration() {
|
||||
this.contextRunner.withUserConfiguration(TestResourceConfiguration.class).run(context -> {
|
||||
McpSyncServer server = context.getBean(McpSyncServer.class);
|
||||
assertThat(server).isNotNull();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void promptRegistrationConfiguration() {
|
||||
this.contextRunner.withUserConfiguration(TestPromptConfiguration.class).run(context -> {
|
||||
McpSyncServer server = context.getBean(McpSyncServer.class);
|
||||
assertThat(server).isNotNull();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void asyncToolRegistrationConfiguration() {
|
||||
this.contextRunner.withPropertyValues("spring.ai.mcp.server.type=ASYNC")
|
||||
.withUserConfiguration(TestToolConfiguration.class)
|
||||
.run(context -> {
|
||||
List<AsyncToolSpecification> tools = context.getBean("asyncTools", List.class);
|
||||
assertThat(tools).hasSize(2);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void customCapabilitiesBuilder() {
|
||||
this.contextRunner.withUserConfiguration(CustomCapabilitiesConfiguration.class).run(context -> {
|
||||
assertThat(context).hasSingleBean(McpSchema.ServerCapabilities.Builder.class);
|
||||
assertThat(context.getBean(McpSchema.ServerCapabilities.Builder.class))
|
||||
.isInstanceOf(CustomCapabilitiesBuilder.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void rootsChangeConsumerConfiguration() {
|
||||
this.contextRunner.withUserConfiguration(TestRootsChangeConfiguration.class).run(context -> {
|
||||
McpSyncServer server = context.getBean(McpSyncServer.class);
|
||||
assertThat(server).isNotNull();
|
||||
});
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class TestResourceConfiguration {
|
||||
|
||||
@Bean
|
||||
List<SyncResourceRegistration> testResources() {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class TestPromptConfiguration {
|
||||
|
||||
@Bean
|
||||
List<SyncPromptRegistration> testPrompts() {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class CustomCapabilitiesConfiguration {
|
||||
|
||||
@Bean
|
||||
McpSchema.ServerCapabilities.Builder customCapabilitiesBuilder() {
|
||||
return new CustomCapabilitiesBuilder();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static class CustomCapabilitiesBuilder extends McpSchema.ServerCapabilities.Builder {
|
||||
|
||||
// Custom implementation for testing
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class TestToolConfiguration {
|
||||
|
||||
@Bean
|
||||
List<ToolCallback> testTool() {
|
||||
McpSyncClient mockClient = Mockito.mock(McpSyncClient.class);
|
||||
McpSchema.Tool mockTool = Mockito.mock(McpSchema.Tool.class);
|
||||
McpSchema.CallToolResult mockResult = Mockito.mock(McpSchema.CallToolResult.class);
|
||||
|
||||
Mockito.when(mockTool.name()).thenReturn("test-tool");
|
||||
Mockito.when(mockTool.description()).thenReturn("Test Tool");
|
||||
Mockito.when(mockClient.callTool(Mockito.any(McpSchema.CallToolRequest.class))).thenReturn(mockResult);
|
||||
when(mockClient.getClientInfo()).thenReturn(new McpSchema.Implementation("testClient", "1.0.0"));
|
||||
|
||||
return List.of(new SyncMcpToolCallback(mockClient, mockTool));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class TestRootsChangeConfiguration {
|
||||
|
||||
@Bean
|
||||
Consumer<List<McpSchema.Root>> rootsChangeConsumer() {
|
||||
return roots -> {
|
||||
// Test implementation
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static class CustomServerTransport implements McpServerTransport {
|
||||
|
||||
@Override
|
||||
public Mono<Void> connect(
|
||||
Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>> messageHandler) {
|
||||
return Mono.empty(); // Test implementation
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
|
||||
return Mono.empty(); // Test implementation
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T unmarshalFrom(Object value, TypeReference<T> type) {
|
||||
return null; // Test implementation
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// Test implementation
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> closeGracefully() {
|
||||
return Mono.empty(); // Test implementation
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class CustomTransportConfiguration {
|
||||
|
||||
@Bean
|
||||
McpServerTransport customTransport() {
|
||||
return new CustomServerTransport();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -18,7 +18,6 @@ package org.springframework.ai.mcp.server.autoconfigure;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import io.modelcontextprotocol.client.McpSyncClient;
|
||||
@@ -33,7 +32,6 @@ import io.modelcontextprotocol.server.transport.StdioServerTransportProvider;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpServerTransport;
|
||||
import io.modelcontextprotocol.spec.McpServerTransportProvider;
|
||||
import io.modelcontextprotocol.spec.ServerMcpTransport;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import reactor.core.publisher.Mono;
|
||||
@@ -122,7 +120,7 @@ public class McpServerAutoConfigurationIT {
|
||||
this.contextRunner.withPropertyValues("spring.ai.mcp.server.enabled=false").run(context -> {
|
||||
assertThat(context).doesNotHaveBean(McpSyncServer.class);
|
||||
assertThat(context).doesNotHaveBean(McpAsyncServer.class);
|
||||
assertThat(context).doesNotHaveBean(ServerMcpTransport.class);
|
||||
assertThat(context).doesNotHaveBean(McpServerTransport.class);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -277,12 +275,6 @@ public class McpServerAutoConfigurationIT {
|
||||
|
||||
static class CustomServerTransport implements McpServerTransport {
|
||||
|
||||
@Override
|
||||
public Mono<Void> connect(
|
||||
Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>> messageHandler) {
|
||||
return Mono.empty(); // Test implementation
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
|
||||
return Mono.empty(); // Test implementation
|
||||
|
||||
@@ -17,8 +17,10 @@ package org.springframework.ai.mcp;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.BiPredicate;
|
||||
|
||||
import io.modelcontextprotocol.client.McpAsyncClient;
|
||||
import io.modelcontextprotocol.spec.McpSchema.Tool;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
@@ -73,6 +75,21 @@ public class AsyncMcpToolCallbackProvider implements ToolCallbackProvider {
|
||||
|
||||
private final List<McpAsyncClient> mcpClients;
|
||||
|
||||
private final BiPredicate<McpAsyncClient, Tool> toolFilter;
|
||||
|
||||
/**
|
||||
* Creates a new {@code AsyncMcpToolCallbackProvider} instance with a list of MCP
|
||||
* clients.
|
||||
* @param mcpClients the list of MCP clients to use for discovering tools
|
||||
* @param toolFilter a filter to apply to each discovered tool
|
||||
*/
|
||||
public AsyncMcpToolCallbackProvider(BiPredicate<McpAsyncClient, Tool> toolFilter, List<McpAsyncClient> mcpClients) {
|
||||
Assert.notNull(mcpClients, "MCP clients must not be null");
|
||||
Assert.notNull(toolFilter, "Tool filter must not be null");
|
||||
this.mcpClients = mcpClients;
|
||||
this.toolFilter = toolFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new {@code AsyncMcpToolCallbackProvider} instance with a list of MCP
|
||||
* clients.
|
||||
@@ -82,13 +99,26 @@ public class AsyncMcpToolCallbackProvider implements ToolCallbackProvider {
|
||||
* @throws IllegalArgumentException if mcpClients is null
|
||||
*/
|
||||
public AsyncMcpToolCallbackProvider(List<McpAsyncClient> mcpClients) {
|
||||
Assert.notNull(mcpClients, "McpClients must not be null");
|
||||
this.mcpClients = mcpClients;
|
||||
this((mcpClient, tool) -> true, mcpClients);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new {@code AsyncMcpToolCallbackProvider} instance with one or more MCP
|
||||
* clients.
|
||||
* @param mcpClients the MCP clients to use for discovering tools
|
||||
* @param toolFilter a filter to apply to each discovered tool
|
||||
*/
|
||||
public AsyncMcpToolCallbackProvider(BiPredicate<McpAsyncClient, Tool> toolFilter, McpAsyncClient... mcpClients) {
|
||||
this(toolFilter, List.of(mcpClients));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new {@code AsyncMcpToolCallbackProvider} instance with one or more MCP
|
||||
* clients.
|
||||
* @param mcpClients the MCP clients to use for discovering tools
|
||||
*/
|
||||
public AsyncMcpToolCallbackProvider(McpAsyncClient... mcpClients) {
|
||||
Assert.notNull(mcpClients, "McpClients must not be null");
|
||||
this.mcpClients = List.of(mcpClients);
|
||||
this(List.of(mcpClients));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,6 +146,7 @@ public class AsyncMcpToolCallbackProvider implements ToolCallbackProvider {
|
||||
ToolCallback[] toolCallbacks = mcpClient.listTools()
|
||||
.map(response -> response.tools()
|
||||
.stream()
|
||||
.filter(tool -> toolFilter.test(mcpClient, tool))
|
||||
.map(tool -> new AsyncMcpToolCallback(mcpClient, tool))
|
||||
.toArray(ToolCallback[]::new))
|
||||
.block();
|
||||
|
||||
@@ -17,6 +17,7 @@ package org.springframework.ai.mcp;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
@@ -24,7 +25,6 @@ import io.micrometer.common.util.StringUtils;
|
||||
import io.modelcontextprotocol.client.McpAsyncClient;
|
||||
import io.modelcontextprotocol.client.McpSyncClient;
|
||||
import io.modelcontextprotocol.server.McpServerFeatures;
|
||||
import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolRegistration;
|
||||
import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolSpecification;
|
||||
import io.modelcontextprotocol.server.McpSyncServerExchange;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
@@ -62,6 +62,11 @@ import org.springframework.util.MimeType;
|
||||
*/
|
||||
public final class McpToolUtils {
|
||||
|
||||
/**
|
||||
* The name of tool context key used to store the MCP exchange object.
|
||||
*/
|
||||
public static final String TOOL_CONTEXT_MCP_EXCHANGE_KEY = "exchange";
|
||||
|
||||
private McpToolUtils() {
|
||||
}
|
||||
|
||||
@@ -87,23 +92,6 @@ public final class McpToolUtils {
|
||||
return formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a list of Spring AI tool callbacks to MCP synchronous tool registrations.
|
||||
* <p>
|
||||
* This method processes multiple tool callbacks in bulk, converting each one to its
|
||||
* corresponding MCP tool registration while maintaining synchronous execution
|
||||
* semantics.
|
||||
* @param toolCallbacks the list of tool callbacks to convert
|
||||
* @return a list of MCP synchronous tool registrations
|
||||
* @see #toSyncToolRegistration(ToolCallback)
|
||||
* @deprecated Use {@link #toSyncToolSpecification(List)} instead.
|
||||
*/
|
||||
@Deprecated
|
||||
public static List<McpServerFeatures.SyncToolRegistration> toSyncToolRegistration(
|
||||
List<ToolCallback> toolCallbacks) {
|
||||
return toolCallbacks.stream().map(McpToolUtils::toSyncToolRegistration).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a list of Spring AI tool callbacks to MCP synchronous tool specificaiton.
|
||||
* <p>
|
||||
@@ -118,27 +106,6 @@ public final class McpToolUtils {
|
||||
return toolCallbacks.stream().map(McpToolUtils::toSyncToolSpecification).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method to convert a variable number of tool callbacks to MCP
|
||||
* synchronous tool registrations.
|
||||
* <p>
|
||||
* This is a varargs wrapper around {@link #toSyncToolRegistration(List)} for easier
|
||||
* usage when working with individual callbacks.
|
||||
* @param toolCallbacks the tool callbacks to convert
|
||||
* @return a list of MCP synchronous tool registrations
|
||||
* @see #toSyncToolRegistration(List)
|
||||
* @deprecated Use {@link #toSyncToolSpecification(ToolCallback...)} instead.
|
||||
*/
|
||||
@Deprecated
|
||||
public static List<McpServerFeatures.SyncToolRegistration> toSyncToolRegistrations(ToolCallback... toolCallbacks) {
|
||||
return toSyncToolRegistration(List.of(toolCallbacks));
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
public static List<McpServerFeatures.SyncToolRegistration> toSyncToolRegistration(ToolCallback... toolCallbacks) {
|
||||
return toSyncToolRegistration(List.of(toolCallbacks));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method to convert a variable number of tool callbacks to MCP
|
||||
* synchronous tool specification.
|
||||
@@ -153,33 +120,6 @@ public final class McpToolUtils {
|
||||
return toSyncToolSpecification(List.of(toolCallbacks));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Spring AI ToolCallback to an MCP SyncToolRegistration. This enables
|
||||
* Spring AI functions to be exposed as MCP tools that can be discovered and invoked
|
||||
* by language models.
|
||||
*
|
||||
* <p>
|
||||
* The conversion process:
|
||||
* <ul>
|
||||
* <li>Creates an MCP Tool with the function's name and input schema</li>
|
||||
* <li>Wraps the function's execution in a SyncToolRegistration that handles the MCP
|
||||
* protocol</li>
|
||||
* <li>Provides error handling and result formatting according to MCP
|
||||
* specifications</li>
|
||||
* </ul>
|
||||
*
|
||||
* You can use the ToolCallback builder to create a new instance of ToolCallback using
|
||||
* either java.util.function.Function or Method reference.
|
||||
* @param toolCallback the Spring AI function callback to convert
|
||||
* @return an MCP SyncToolRegistration that wraps the function callback
|
||||
* @throws RuntimeException if there's an error during the function execution
|
||||
* @deprecated Use {@link #toSyncToolSpecification(ToolCallback)} instead.
|
||||
*/
|
||||
@Deprecated
|
||||
public static McpServerFeatures.SyncToolRegistration toSyncToolRegistration(ToolCallback toolCallback) {
|
||||
return toSyncToolRegistration(toolCallback, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Spring AI ToolCallback to an MCP SyncToolSpecification. This enables
|
||||
* Spring AI functions to be exposed as MCP tools that can be discovered and invoked
|
||||
@@ -205,65 +145,6 @@ public final class McpToolUtils {
|
||||
return toSyncToolSpecification(toolCallback, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Spring AI ToolCallback to an MCP SyncToolRegistration. This enables
|
||||
* Spring AI functions to be exposed as MCP tools that can be discovered and invoked
|
||||
* by language models.
|
||||
*
|
||||
* <p>
|
||||
* The conversion process:
|
||||
* <ul>
|
||||
* <li>Creates an MCP Tool with the function's name and input schema</li>
|
||||
* <li>Wraps the function's execution in a SyncToolRegistration that handles the MCP
|
||||
* protocol</li>
|
||||
* <li>Provides error handling and result formatting according to MCP
|
||||
* specifications</li>
|
||||
* </ul>
|
||||
*
|
||||
* You can use the ToolCallback builder to create a new instance of ToolCallback using
|
||||
* either java.util.function.Function or Method reference.
|
||||
* @param toolCallback the Spring AI function callback to convert
|
||||
* @param mimeType the MIME type of the output content
|
||||
* @return an MCP SyncToolRegistration that wraps the function callback
|
||||
* @throws RuntimeException if there's an error during the function execution
|
||||
* @deprecated Use {@link #toSyncToolSpecification(ToolCallback, MimeType)} instead.
|
||||
*/
|
||||
@Deprecated
|
||||
public static McpServerFeatures.SyncToolRegistration toSyncToolRegistration(ToolCallback toolCallback,
|
||||
MimeType mimeType) {
|
||||
|
||||
var tool = new McpSchema.Tool(toolCallback.getToolDefinition().name(),
|
||||
toolCallback.getToolDefinition().description(), toolCallback.getToolDefinition().inputSchema());
|
||||
|
||||
return new McpServerFeatures.SyncToolRegistration(tool, request -> {
|
||||
try {
|
||||
String callResult = toolCallback.call(ModelOptionsUtils.toJsonString(request));
|
||||
String imgData = callResult;
|
||||
if (mimeType != null && "image".equals(mimeType.getType())) {
|
||||
String imgType = mimeType.toString();
|
||||
if (callResult.startsWith("{") && callResult.endsWith("}")) {
|
||||
// This is most likely a JSON structure:
|
||||
// let's try to parse it as a base64 wrapper.
|
||||
var b64Struct = JsonParser.fromJson(callResult, Base64Wrapper.class);
|
||||
if (b64Struct.mimeType() != null && b64Struct.data() != null
|
||||
&& b64Struct.mimeType.getType().equals("image")) {
|
||||
// Get the base64 encoded image as is.
|
||||
imgType = b64Struct.mimeType().toString();
|
||||
imgData = b64Struct.data();
|
||||
}
|
||||
}
|
||||
return new McpSchema.CallToolResult(
|
||||
List.of(new McpSchema.ImageContent(List.of(Role.ASSISTANT), null, imgData, imgType)),
|
||||
false);
|
||||
}
|
||||
return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(callResult)), false);
|
||||
}
|
||||
catch (Exception e) {
|
||||
return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(e.getMessage())), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Spring AI ToolCallback to an MCP SyncToolSpecification. This enables
|
||||
* Spring AI functions to be exposed as MCP tools that can be discovered and invoked
|
||||
@@ -292,7 +173,7 @@ public final class McpToolUtils {
|
||||
return new McpServerFeatures.SyncToolSpecification(tool, (exchange, request) -> {
|
||||
try {
|
||||
String callResult = toolCallback.call(ModelOptionsUtils.toJsonString(request),
|
||||
new ToolContext(Map.of("exchange", exchange)));
|
||||
new ToolContext(Map.of(TOOL_CONTEXT_MCP_EXCHANGE_KEY, exchange)));
|
||||
if (mimeType != null && mimeType.toString().startsWith("image")) {
|
||||
return new McpSchema.CallToolResult(List
|
||||
.of(new McpSchema.ImageContent(List.of(Role.ASSISTANT), null, callResult, mimeType.toString())),
|
||||
@@ -307,21 +188,16 @@ public final class McpToolUtils {
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a list of Spring AI tool callbacks to MCP asynchronous tool registrations.
|
||||
* <p>
|
||||
* This method processes multiple tool callbacks in bulk, converting each one to its
|
||||
* corresponding MCP tool registration while adding asynchronous execution
|
||||
* capabilities. The resulting registrations will execute their tools on a bounded
|
||||
* elastic scheduler.
|
||||
* @param toolCallbacks the list of tool callbacks to convert
|
||||
* @return a list of MCP asynchronous tool registrations
|
||||
* @see #toAsyncToolRegistration(ToolCallback)
|
||||
* @deprecated Use {@link #toAsyncToolSpecification(List)} instead.
|
||||
* Retrieves the MCP exchange object from the provided tool context if it exists.
|
||||
* @param toolContext the tool context from which to retrieve the MCP exchange
|
||||
* @return the MCP exchange object, or null if not present in the context
|
||||
*/
|
||||
@Deprecated
|
||||
public static List<McpServerFeatures.AsyncToolRegistration> toAsyncToolRegistration(
|
||||
List<ToolCallback> toolCallbacks) {
|
||||
return toolCallbacks.stream().map(McpToolUtils::toAsyncToolRegistration).toList();
|
||||
public static Optional<McpSyncServerExchange> getMcpExchange(ToolContext toolContext) {
|
||||
if (toolContext != null && toolContext.getContext().containsKey(TOOL_CONTEXT_MCP_EXCHANGE_KEY)) {
|
||||
return Optional
|
||||
.ofNullable((McpSyncServerExchange) toolContext.getContext().get(TOOL_CONTEXT_MCP_EXCHANGE_KEY));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -339,20 +215,6 @@ public final class McpToolUtils {
|
||||
return toolCallbacks.stream().map(McpToolUtils::toAsyncToolSpecification).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method to convert a variable number of tool callbacks to MCP
|
||||
* asynchronous tool specifications.
|
||||
* <p>
|
||||
* This is a varargs wrapper around {@link #toAsyncToolRegistration(List)} for easier
|
||||
* usage when working with individual callbacks.
|
||||
* @param toolCallbacks the tool callbacks to convert
|
||||
* @return a list of MCP asynchronous tool registrations
|
||||
* @see #toAsyncToolRegistration(List)
|
||||
*/
|
||||
public static List<McpServerFeatures.AsyncToolRegistration> toAsyncToolRegistration(ToolCallback... toolCallbacks) {
|
||||
return toAsyncToolRegistration(List.of(toolCallbacks));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method to convert a variable number of tool callbacks to MCP
|
||||
* asynchronous tool specificaiton.
|
||||
@@ -368,36 +230,6 @@ public final class McpToolUtils {
|
||||
return toAsyncToolSpecifications(List.of(toolCallbacks));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Spring AI tool callback to an MCP asynchronous tool registration.
|
||||
* <p>
|
||||
* This method enables Spring AI tools to be exposed as asynchronous MCP tools that
|
||||
* can be discovered and invoked by language models. The conversion process:
|
||||
* <ul>
|
||||
* <li>First converts the callback to a synchronous registration</li>
|
||||
* <li>Wraps the synchronous execution in a reactive Mono</li>
|
||||
* <li>Configures execution on a bounded elastic scheduler for non-blocking
|
||||
* operation</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* The resulting async registration will:
|
||||
* <ul>
|
||||
* <li>Execute the tool without blocking the calling thread</li>
|
||||
* <li>Handle errors and results asynchronously</li>
|
||||
* <li>Provide backpressure through Project Reactor</li>
|
||||
* </ul>
|
||||
* @param toolCallback the Spring AI tool callback to convert
|
||||
* @return an MCP asynchronous tool registration that wraps the tool callback
|
||||
* @see McpServerFeatures.AsyncToolRegistration
|
||||
* @see Mono
|
||||
* @see Schedulers#boundedElastic()
|
||||
* @deprecated Use {@link #toAsyncToolSpecification(ToolCallback)} instead.
|
||||
*/
|
||||
@Deprecated
|
||||
public static McpServerFeatures.AsyncToolRegistration toAsyncToolRegistration(ToolCallback toolCallback) {
|
||||
return toAsyncToolRegistration(toolCallback, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Spring AI tool callback to an MCP asynchronous tool registration.
|
||||
* <p>
|
||||
@@ -426,43 +258,6 @@ public final class McpToolUtils {
|
||||
return toAsyncToolSpecification(toolCallback, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Spring AI tool callback to an MCP asynchronous tool registration.
|
||||
* <p>
|
||||
* This method enables Spring AI tools to be exposed as asynchronous MCP tools that
|
||||
* can be discovered and invoked by language models. The conversion process:
|
||||
* <ul>
|
||||
* <li>First converts the callback to a synchronous registration</li>
|
||||
* <li>Wraps the synchronous execution in a reactive Mono</li>
|
||||
* <li>Configures execution on a bounded elastic scheduler for non-blocking
|
||||
* operation</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* The resulting async registration will:
|
||||
* <ul>
|
||||
* <li>Execute the tool without blocking the calling thread</li>
|
||||
* <li>Handle errors and results asynchronously</li>
|
||||
* <li>Provide backpressure through Project Reactor</li>
|
||||
* </ul>
|
||||
* @param toolCallback the Spring AI tool callback to convert
|
||||
* @param mimeType the MIME type of the output content
|
||||
* @return an MCP asynchronous tool registration that wraps the tool callback
|
||||
* @see McpServerFeatures.AsyncToolRegistration
|
||||
* @see Mono
|
||||
* @see Schedulers#boundedElastic()
|
||||
* @deprecated Use {@link #toAsyncToolSpecification(ToolCallback, MimeType)} instead.
|
||||
*/
|
||||
@Deprecated
|
||||
public static McpServerFeatures.AsyncToolRegistration toAsyncToolRegistration(ToolCallback toolCallback,
|
||||
MimeType mimeType) {
|
||||
|
||||
McpServerFeatures.SyncToolRegistration syncToolRegistration = toSyncToolRegistration(toolCallback, mimeType);
|
||||
|
||||
return new AsyncToolRegistration(syncToolRegistration.tool(),
|
||||
map -> Mono.fromCallable(() -> syncToolRegistration.call().apply(map))
|
||||
.subscribeOn(Schedulers.boundedElastic()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Spring AI tool callback to an MCP asynchronous tool specification.
|
||||
* <p>
|
||||
|
||||
@@ -17,12 +17,15 @@ package org.springframework.ai.mcp;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.BiPredicate;
|
||||
|
||||
import io.modelcontextprotocol.client.McpSyncClient;
|
||||
import io.modelcontextprotocol.spec.McpSchema.Tool;
|
||||
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.tool.ToolCallbackProvider;
|
||||
import org.springframework.ai.tool.util.ToolUtils;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
/**
|
||||
@@ -69,17 +72,47 @@ public class SyncMcpToolCallbackProvider implements ToolCallbackProvider {
|
||||
|
||||
private final List<McpSyncClient> mcpClients;
|
||||
|
||||
private final BiPredicate<McpSyncClient, Tool> toolFilter;
|
||||
|
||||
/**
|
||||
* Creates a new {@code SyncMcpToolCallbackProvider} instance with a list of MCP
|
||||
* clients.
|
||||
* @param mcpClients the list of MCP clients to use for discovering tools
|
||||
* @param toolFilter a filter to apply to each discovered tool
|
||||
*/
|
||||
public SyncMcpToolCallbackProvider(BiPredicate<McpSyncClient, Tool> toolFilter, List<McpSyncClient> mcpClients) {
|
||||
Assert.notNull(mcpClients, "MCP clients must not be null");
|
||||
Assert.notNull(toolFilter, "Tool filter must not be null");
|
||||
this.mcpClients = mcpClients;
|
||||
this.toolFilter = toolFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new {@code SyncMcpToolCallbackProvider} instance with a list of MCP
|
||||
* clients.
|
||||
* @param mcpClients the list of MCP clients to use for discovering tools
|
||||
*/
|
||||
public SyncMcpToolCallbackProvider(List<McpSyncClient> mcpClients) {
|
||||
this.mcpClients = mcpClients;
|
||||
this((mcpClient, tool) -> true, mcpClients);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new {@code SyncMcpToolCallbackProvider} instance with one or more MCP
|
||||
* clients.
|
||||
* @param mcpClients the MCP clients to use for discovering tools
|
||||
* @param toolFilter a filter to apply to each discovered tool
|
||||
*/
|
||||
public SyncMcpToolCallbackProvider(BiPredicate<McpSyncClient, Tool> toolFilter, McpSyncClient... mcpClients) {
|
||||
this(toolFilter, List.of(mcpClients));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new {@code SyncMcpToolCallbackProvider} instance with one or more MCP
|
||||
* clients.
|
||||
* @param mcpClients the MCP clients to use for discovering tools
|
||||
*/
|
||||
public SyncMcpToolCallbackProvider(McpSyncClient... mcpClients) {
|
||||
this.mcpClients = List.of(mcpClients);
|
||||
this(List.of(mcpClients));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,10 +132,11 @@ public class SyncMcpToolCallbackProvider implements ToolCallbackProvider {
|
||||
|
||||
var toolCallbacks = new ArrayList<>();
|
||||
|
||||
mcpClients.stream().forEach(mcpClient -> {
|
||||
this.mcpClients.stream().forEach(mcpClient -> {
|
||||
toolCallbacks.addAll(mcpClient.listTools()
|
||||
.tools()
|
||||
.stream()
|
||||
.filter(tool -> toolFilter.test(mcpClient, tool))
|
||||
.map(tool -> new SyncMcpToolCallback(mcpClient, tool))
|
||||
.toList());
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.BiPredicate;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -129,4 +130,142 @@ class SyncMcpToolCallbackProviderTests {
|
||||
assertThat(callbacks).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolFilterShouldAcceptAllToolsByDefault() {
|
||||
var clientInfo = new Implementation("testClient", "1.0.0");
|
||||
when(mcpClient.getClientInfo()).thenReturn(clientInfo);
|
||||
|
||||
Tool tool1 = mock(Tool.class);
|
||||
when(tool1.name()).thenReturn("tool1");
|
||||
|
||||
Tool tool2 = mock(Tool.class);
|
||||
when(tool2.name()).thenReturn("tool2");
|
||||
|
||||
ListToolsResult listToolsResult = mock(ListToolsResult.class);
|
||||
when(listToolsResult.tools()).thenReturn(List.of(tool1, tool2));
|
||||
when(mcpClient.listTools()).thenReturn(listToolsResult);
|
||||
|
||||
// Using the constructor without explicit filter (should use default filter that
|
||||
// accepts all)
|
||||
SyncMcpToolCallbackProvider provider = new SyncMcpToolCallbackProvider(mcpClient);
|
||||
|
||||
var callbacks = provider.getToolCallbacks();
|
||||
|
||||
assertThat(callbacks).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolFilterShouldRejectAllToolsWhenConfigured() {
|
||||
|
||||
Tool tool1 = mock(Tool.class);
|
||||
Tool tool2 = mock(Tool.class);
|
||||
|
||||
ListToolsResult listToolsResult = mock(ListToolsResult.class);
|
||||
when(listToolsResult.tools()).thenReturn(List.of(tool1, tool2));
|
||||
when(mcpClient.listTools()).thenReturn(listToolsResult);
|
||||
|
||||
// Create a filter that rejects all tools
|
||||
BiPredicate<McpSyncClient, Tool> rejectAllFilter = (client, tool) -> false;
|
||||
|
||||
SyncMcpToolCallbackProvider provider = new SyncMcpToolCallbackProvider(rejectAllFilter, mcpClient);
|
||||
|
||||
var callbacks = provider.getToolCallbacks();
|
||||
|
||||
assertThat(callbacks).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolFilterShouldFilterToolsByNameWhenConfigured() {
|
||||
var clientInfo = new Implementation("testClient", "1.0.0");
|
||||
when(mcpClient.getClientInfo()).thenReturn(clientInfo);
|
||||
|
||||
Tool tool1 = mock(Tool.class);
|
||||
when(tool1.name()).thenReturn("tool1");
|
||||
|
||||
Tool tool2 = mock(Tool.class);
|
||||
when(tool2.name()).thenReturn("tool2");
|
||||
|
||||
Tool tool3 = mock(Tool.class);
|
||||
when(tool3.name()).thenReturn("tool3");
|
||||
|
||||
ListToolsResult listToolsResult = mock(ListToolsResult.class);
|
||||
when(listToolsResult.tools()).thenReturn(List.of(tool1, tool2, tool3));
|
||||
when(mcpClient.listTools()).thenReturn(listToolsResult);
|
||||
|
||||
// Create a filter that only accepts tools with names containing "2" or "3"
|
||||
BiPredicate<McpSyncClient, Tool> nameFilter = (client, tool) -> tool.name().contains("2")
|
||||
|| tool.name().contains("3");
|
||||
|
||||
SyncMcpToolCallbackProvider provider = new SyncMcpToolCallbackProvider(nameFilter, mcpClient);
|
||||
|
||||
var callbacks = provider.getToolCallbacks();
|
||||
|
||||
assertThat(callbacks).hasSize(2);
|
||||
assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("testClient_tool2");
|
||||
assertThat(callbacks[1].getToolDefinition().name()).isEqualTo("testClient_tool3");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolFilterShouldFilterToolsByClientWhenConfigured() {
|
||||
Tool tool1 = mock(Tool.class);
|
||||
when(tool1.name()).thenReturn("tool1");
|
||||
|
||||
Tool tool2 = mock(Tool.class);
|
||||
|
||||
McpSyncClient mcpClient1 = mock(McpSyncClient.class);
|
||||
ListToolsResult listToolsResult1 = mock(ListToolsResult.class);
|
||||
when(listToolsResult1.tools()).thenReturn(List.of(tool1));
|
||||
when(mcpClient1.listTools()).thenReturn(listToolsResult1);
|
||||
|
||||
var clientInfo1 = new Implementation("testClient1", "1.0.0");
|
||||
when(mcpClient1.getClientInfo()).thenReturn(clientInfo1);
|
||||
|
||||
McpSyncClient mcpClient2 = mock(McpSyncClient.class);
|
||||
ListToolsResult listToolsResult2 = mock(ListToolsResult.class);
|
||||
when(listToolsResult2.tools()).thenReturn(List.of(tool2));
|
||||
when(mcpClient2.listTools()).thenReturn(listToolsResult2);
|
||||
|
||||
var clientInfo2 = new Implementation("testClient2", "1.0.0");
|
||||
when(mcpClient2.getClientInfo()).thenReturn(clientInfo2);
|
||||
|
||||
// Create a filter that only accepts tools from client1
|
||||
BiPredicate<McpSyncClient, Tool> clientFilter = (client,
|
||||
tool) -> client.getClientInfo().name().equals("testClient1");
|
||||
|
||||
SyncMcpToolCallbackProvider provider = new SyncMcpToolCallbackProvider(clientFilter, mcpClient1, mcpClient2);
|
||||
|
||||
var callbacks = provider.getToolCallbacks();
|
||||
|
||||
assertThat(callbacks).hasSize(1);
|
||||
assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("testClient1_tool1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolFilterShouldCombineClientAndToolCriteriaWhenConfigured() {
|
||||
Tool tool1 = mock(Tool.class);
|
||||
when(tool1.name()).thenReturn("weather");
|
||||
|
||||
Tool tool2 = mock(Tool.class);
|
||||
when(tool2.name()).thenReturn("calculator");
|
||||
|
||||
McpSyncClient weatherClient = mock(McpSyncClient.class);
|
||||
ListToolsResult weatherResult = mock(ListToolsResult.class);
|
||||
when(weatherResult.tools()).thenReturn(List.of(tool1, tool2));
|
||||
when(weatherClient.listTools()).thenReturn(weatherResult);
|
||||
|
||||
var weatherClientInfo = new Implementation("weather-service", "1.0.0");
|
||||
when(weatherClient.getClientInfo()).thenReturn(weatherClientInfo);
|
||||
|
||||
// Create a filter that only accepts weather tools from the weather service
|
||||
BiPredicate<McpSyncClient, Tool> complexFilter = (client,
|
||||
tool) -> client.getClientInfo().name().equals("weather-service") && tool.name().equals("weather");
|
||||
|
||||
SyncMcpToolCallbackProvider provider = new SyncMcpToolCallbackProvider(complexFilter, weatherClient);
|
||||
|
||||
var callbacks = provider.getToolCallbacks();
|
||||
|
||||
assertThat(callbacks).hasSize(1);
|
||||
assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("weather_service_weather");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025-2025 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
|
||||
*
|
||||
* https://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 org.springframework.ai.mcp;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolRegistration;
|
||||
import io.modelcontextprotocol.server.McpServerFeatures.SyncToolRegistration;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
|
||||
import io.modelcontextprotocol.spec.McpSchema.TextContent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* @deprecated used to test backward compatbility. Replaced by the {@link ToolUtilsTests}
|
||||
* instead
|
||||
*/
|
||||
@Deprecated
|
||||
class ToolUtilsDeprecatedTests {
|
||||
|
||||
@Test
|
||||
void constructorShouldBePrivate() throws Exception {
|
||||
Constructor<McpToolUtils> constructor = McpToolUtils.class.getDeclaredConstructor();
|
||||
assertThat(Modifier.isPrivate(constructor.getModifiers())).isTrue();
|
||||
constructor.setAccessible(true);
|
||||
constructor.newInstance();
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSyncToolRegistrationShouldConvertSingleCallback() {
|
||||
// Arrange
|
||||
ToolCallback callback = createMockToolCallback("test", "success");
|
||||
|
||||
// Act
|
||||
SyncToolRegistration registration = McpToolUtils.toSyncToolRegistration(callback);
|
||||
|
||||
// Assert
|
||||
assertThat(registration).isNotNull();
|
||||
assertThat(registration.tool().name()).isEqualTo("test");
|
||||
|
||||
CallToolResult result = registration.call().apply(Map.of());
|
||||
TextContent content = (TextContent) result.content().get(0);
|
||||
assertThat(content.text()).isEqualTo("success");
|
||||
assertThat(result.isError()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSyncToolRegistrationShouldHandleError() {
|
||||
// Arrange
|
||||
ToolCallback callback = createMockToolCallback("test", new RuntimeException("error"));
|
||||
|
||||
// Act
|
||||
SyncToolRegistration registration = McpToolUtils.toSyncToolRegistration(callback);
|
||||
|
||||
// Assert
|
||||
assertThat(registration).isNotNull();
|
||||
CallToolResult result = registration.call().apply(Map.of());
|
||||
TextContent content = (TextContent) result.content().get(0);
|
||||
assertThat(content.text()).isEqualTo("error");
|
||||
assertThat(result.isError()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSyncToolRegistrationShouldConvertMultipleCallbacks() {
|
||||
// Arrange
|
||||
ToolCallback callback1 = createMockToolCallback("test1", "success1");
|
||||
ToolCallback callback2 = createMockToolCallback("test2", "success2");
|
||||
|
||||
// Act
|
||||
List<SyncToolRegistration> registrations = McpToolUtils.toSyncToolRegistrations(callback1, callback2);
|
||||
|
||||
// Assert
|
||||
assertThat(registrations).hasSize(2);
|
||||
assertThat(registrations.get(0).tool().name()).isEqualTo("test1");
|
||||
assertThat(registrations.get(1).tool().name()).isEqualTo("test2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toAsyncToolRegistrationShouldConvertSingleCallback() {
|
||||
// Arrange
|
||||
ToolCallback callback = createMockToolCallback("test", "success");
|
||||
|
||||
// Act
|
||||
AsyncToolRegistration registration = McpToolUtils.toAsyncToolRegistration(callback);
|
||||
|
||||
// Assert
|
||||
assertThat(registration).isNotNull();
|
||||
assertThat(registration.tool().name()).isEqualTo("test");
|
||||
|
||||
StepVerifier.create(registration.call().apply(Map.of())).assertNext(result -> {
|
||||
TextContent content = (TextContent) result.content().get(0);
|
||||
assertThat(content.text()).isEqualTo("success");
|
||||
assertThat(result.isError()).isFalse();
|
||||
}).verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void toAsyncToolRegistrationShouldHandleError() {
|
||||
// Arrange
|
||||
ToolCallback callback = createMockToolCallback("test", new RuntimeException("error"));
|
||||
|
||||
// Act
|
||||
AsyncToolRegistration registration = McpToolUtils.toAsyncToolRegistration(callback);
|
||||
|
||||
// Assert
|
||||
assertThat(registration).isNotNull();
|
||||
StepVerifier.create(registration.call().apply(Map.of())).assertNext(result -> {
|
||||
TextContent content = (TextContent) result.content().get(0);
|
||||
assertThat(content.text()).isEqualTo("error");
|
||||
assertThat(result.isError()).isTrue();
|
||||
}).verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void toAsyncToolRegistrationShouldConvertMultipleCallbacks() {
|
||||
// Arrange
|
||||
ToolCallback callback1 = createMockToolCallback("test1", "success1");
|
||||
ToolCallback callback2 = createMockToolCallback("test2", "success2");
|
||||
|
||||
// Act
|
||||
List<AsyncToolRegistration> registrations = McpToolUtils.toAsyncToolRegistration(callback1, callback2);
|
||||
|
||||
// Assert
|
||||
assertThat(registrations).hasSize(2);
|
||||
assertThat(registrations.get(0).tool().name()).isEqualTo("test1");
|
||||
assertThat(registrations.get(1).tool().name()).isEqualTo("test2");
|
||||
}
|
||||
|
||||
private ToolCallback createMockToolCallback(String name, String result) {
|
||||
ToolCallback callback = mock(ToolCallback.class);
|
||||
ToolDefinition definition = ToolDefinition.builder()
|
||||
.name(name)
|
||||
.description("Test tool")
|
||||
.inputSchema("{}")
|
||||
.build();
|
||||
when(callback.getToolDefinition()).thenReturn(definition);
|
||||
when(callback.call(anyString())).thenReturn(result);
|
||||
return callback;
|
||||
}
|
||||
|
||||
private ToolCallback createMockToolCallback(String name, RuntimeException error) {
|
||||
ToolCallback callback = mock(ToolCallback.class);
|
||||
ToolDefinition definition = ToolDefinition.builder()
|
||||
.name(name)
|
||||
.description("Test tool")
|
||||
.inputSchema("{}")
|
||||
.build();
|
||||
when(callback.getToolDefinition()).thenReturn(definition);
|
||||
when(callback.call(anyString())).thenThrow(error);
|
||||
return callback;
|
||||
}
|
||||
|
||||
}
|
||||
2
pom.xml
2
pom.xml
@@ -306,7 +306,7 @@
|
||||
<okhttp3.version>4.12.0</okhttp3.version>
|
||||
|
||||
<!-- MCP-->
|
||||
<mcp.sdk.version>0.8.1</mcp.sdk.version>
|
||||
<mcp.sdk.version>0.9.0</mcp.sdk.version>
|
||||
|
||||
<!-- plugin versions -->
|
||||
<antlr.version>4.13.1</antlr.version>
|
||||
|
||||
@@ -96,7 +96,9 @@ All properties are prefixed with `spring.ai.mcp.server`:
|
||||
|`prompt-change-notification` |Enable prompt change notifications |`true`
|
||||
|`tool-change-notification` |Enable tool change notifications |`true`
|
||||
|`tool-response-mime-type` |(optional) response MIME type per tool name. For example `spring.ai.mcp.server.tool-response-mime-type.generateImage=image/png` will associate the `image/png` mime type with the `generateImage()` tool name |`-`
|
||||
|`sse-message-endpoint` |SSE endpoint path for web transport |`/mcp/message`
|
||||
|`sse-message-endpoint` | Custom SSE Message endpoint path for web transport to be used by the client to send messages|`/mcp/message`
|
||||
|`sse-endpoint` |Custom SSE endpoint path for web transport |`/sse`
|
||||
|`base-url` | Optional URL prefix. For example `base-url=/api/v1` means that the client should access the sse endpont at `/api/v1` + `sse-endpoint` and the message endpoint is `/api/v1` + `sse-message-endpoint` | -
|
||||
|===
|
||||
|
||||
== Sync/Async Server Types
|
||||
|
||||
Reference in New Issue
Block a user