diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/McpClientAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/McpClientAutoConfiguration.java index 28c628e5019..ae85e47b70d 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/McpClientAutoConfiguration.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/McpClientAutoConfiguration.java @@ -104,8 +104,6 @@ "org.springframework.ai.mcp.client.httpclient.autoconfigure.StreamableHttpHttpClientTransportAutoConfiguration", "org.springframework.ai.mcp.client.webflux.autoconfigure.SseWebFluxTransportAutoConfiguration", "org.springframework.ai.mcp.client.webflux.autoconfigure.StreamableHttpWebFluxTransportAutoConfiguration" }) - -// @AutoConfiguration @ConditionalOnClass({ McpSchema.class }) @EnableConfigurationProperties(McpClientCommonProperties.class) @ConditionalOnProperty(prefix = McpClientCommonProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/properties/McpStreamableHttpClientProperties.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/properties/McpStreamableHttpClientProperties.java index afa74ded003..312c5af4e2f 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/properties/McpStreamableHttpClientProperties.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/properties/McpStreamableHttpClientProperties.java @@ -31,7 +31,7 @@ *
* Example configuration:
* spring.ai.mcp.client.streamable-http: - * connections-http: + * connections: * server1: * url: http://localhost:8080/events * server2: diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/pom.xml b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/pom.xml index f8d1d3509b5..8b2ab1d2a7c 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/pom.xml +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/pom.xml @@ -68,6 +68,25 @@test ++ + +net.javacrumbs.json-unit +json-unit-assertj +${json-unit-assertj.version} +test ++ + +org.springframework.ai +spring-ai-autoconfigure-mcp-client-webflux +${project.parent.version} +test ++ diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfiguration.java index 96cbba5a18b..e8fa61a8ba6 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfiguration.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfiguration.java @@ -20,14 +20,26 @@ import java.util.List; import java.util.function.BiConsumer; import java.util.function.BiFunction; -import java.util.stream.Collectors; + +import org.springframework.ai.tool.ToolCallback; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +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.core.env.Environment; +import org.springframework.core.log.LogAccessor; +import org.springframework.util.CollectionUtils; +import org.springframework.web.context.support.StandardServletEnvironment; import io.modelcontextprotocol.server.McpAsyncServer; import io.modelcontextprotocol.server.McpAsyncServerExchange; import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpServer.AsyncSpecification; import io.modelcontextprotocol.server.McpServer.SyncSpecification; -import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.McpServerFeatures.AsyncCompletionSpecification; import io.modelcontextprotocol.server.McpServerFeatures.AsyncPromptSpecification; import io.modelcontextprotocol.server.McpServerFeatures.AsyncResourceSpecification; @@ -44,23 +56,6 @@ import io.modelcontextprotocol.spec.McpServerTransportProvider; import reactor.core.publisher.Mono; -import org.springframework.ai.mcp.McpToolUtils; -import org.springframework.ai.tool.ToolCallback; -import org.springframework.ai.tool.ToolCallbackProvider; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.EnableAutoConfiguration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; -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.core.env.Environment; -import org.springframework.core.log.LogAccessor; -import org.springframework.util.CollectionUtils; -import org.springframework.util.MimeType; -import org.springframework.web.context.support.StandardServletEnvironment; - /** * {@link EnableAutoConfiguration Auto-configuration} for the Model Context Protocol (MCP) * Server. @@ -110,7 +105,8 @@ * @see McpWebFluxServerAutoConfiguration * @see ToolCallback */ -@AutoConfiguration(after = { McpWebMvcServerAutoConfiguration.class, McpWebFluxServerAutoConfiguration.class }) +@AutoConfiguration(after = { ToolCallbackConverterAutoConfiguration.class, McpWebMvcServerAutoConfiguration.class, + McpWebFluxServerAutoConfiguration.class }) @ConditionalOnClass({ McpSchema.class, McpSyncServer.class }) @EnableConfigurationProperties(McpServerProperties.class) @ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", @@ -131,44 +127,6 @@ public McpSchema.ServerCapabilities.Builder capabilitiesBuilder() { return McpSchema.ServerCapabilities.builder(); } - @Bean - @ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "type", havingValue = "SYNC", - matchIfMissing = true) - public Listorg.springframework.boot +spring-boot-starter-webflux +test +syncTools(ObjectProvider > toolCalls, - List
toolCallbacksList, McpServerProperties serverProperties) { - - List tools = new ArrayList<>(toolCalls.stream().flatMap(List::stream).toList()); - - if (!CollectionUtils.isEmpty(toolCallbacksList)) { - tools.addAll(toolCallbacksList); - } - - return this.toSyncToolSpecifications(tools, serverProperties); - } - - private List toSyncToolSpecifications(List tools, - McpServerProperties serverProperties) { - - // De-duplicate tools by their name, keeping the first occurrence of each tool - // name - return tools.stream() // Key: tool name - .collect(Collectors.toMap(tool -> tool.getToolDefinition().name(), tool -> tool, // Value: - // the - // tool - // itself - (existing, replacement) -> existing)) // On duplicate key, keep the - // existing tool - .values() - .stream() - .map(tool -> { - String toolName = tool.getToolDefinition().name(); - MimeType mimeType = (serverProperties.getToolResponseMimeType().containsKey(toolName)) - ? MimeType.valueOf(serverProperties.getToolResponseMimeType().get(toolName)) : null; - return McpToolUtils.toSyncToolSpecification(tool, mimeType); - }) - .toList(); - } - @Bean @ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "type", havingValue = "SYNC", matchIfMissing = true) @@ -179,7 +137,7 @@ public McpSyncServer mcpSyncServer(McpServerTransportProvider transportProvider, ObjectProvider > prompts, ObjectProvider
> completions, ObjectProvider
>> rootsChangeConsumers, - List toolCallbackProvider, Environment environment) { + Environment environment) { McpSchema.Implementation serverInfo = new Implementation(serverProperties.getName(), serverProperties.getVersion()); @@ -195,15 +153,6 @@ public McpSyncServer mcpSyncServer(McpServerTransportProvider transportProvider, List toolSpecifications = new ArrayList<>( tools.stream().flatMap(List::stream).toList()); - List providerToolCallbacks = toolCallbackProvider.stream() - .map(pr -> List.of(pr.getToolCallbacks())) - .flatMap(List::stream) - .filter(fc -> fc instanceof ToolCallback) - .map(fc -> (ToolCallback) fc) - .toList(); - - toolSpecifications.addAll(this.toSyncToolSpecifications(providerToolCallbacks, serverProperties)); - if (!CollectionUtils.isEmpty(toolSpecifications)) { serverBuilder.tools(toolSpecifications); logger.info("Registered tools: " + toolSpecifications.size()); @@ -268,41 +217,6 @@ public McpSyncServer mcpSyncServer(McpServerTransportProvider transportProvider, return serverBuilder.build(); } - @Bean - @ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "type", havingValue = "ASYNC") - public List asyncTools(ObjectProvider > toolCalls, - List
toolCallbackList, McpServerProperties serverProperties) { - - List tools = new ArrayList<>(toolCalls.stream().flatMap(List::stream).toList()); - if (!CollectionUtils.isEmpty(toolCallbackList)) { - tools.addAll(toolCallbackList); - } - - return this.toAsyncToolSpecification(tools, serverProperties); - } - - private List toAsyncToolSpecification(List tools, - McpServerProperties serverProperties) { - // De-duplicate tools by their name, keeping the first occurrence of each tool - // name - return tools.stream() // Key: tool name - .collect(Collectors.toMap(tool -> tool.getToolDefinition().name(), tool -> tool, // Value: - // the - // tool - // itself - (existing, replacement) -> existing)) // On duplicate key, keep the - // existing tool - .values() - .stream() - .map(tool -> { - String toolName = tool.getToolDefinition().name(); - MimeType mimeType = (serverProperties.getToolResponseMimeType().containsKey(toolName)) - ? MimeType.valueOf(serverProperties.getToolResponseMimeType().get(toolName)) : null; - return McpToolUtils.toAsyncToolSpecification(tool, mimeType); - }) - .toList(); - } - @Bean @ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "type", havingValue = "ASYNC") public McpAsyncServer mcpAsyncServer(McpServerTransportProvider transportProvider, @@ -311,8 +225,7 @@ public McpAsyncServer mcpAsyncServer(McpServerTransportProvider transportProvide ObjectProvider > resources, ObjectProvider
> prompts, ObjectProvider
> completions, - ObjectProvider
>> rootsChangeConsumer, - List toolCallbackProvider) { + ObjectProvider >> rootsChangeConsumer) { McpSchema.Implementation serverInfo = new Implementation(serverProperties.getName(), serverProperties.getVersion()); @@ -324,14 +237,6 @@ public McpAsyncServer mcpAsyncServer(McpServerTransportProvider transportProvide if (serverProperties.getCapabilities().isTool()) { List toolSpecifications = new ArrayList<>( tools.stream().flatMap(List::stream).toList()); - List providerToolCallbacks = toolCallbackProvider.stream() - .map(pr -> List.of(pr.getToolCallbacks())) - .flatMap(List::stream) - .filter(fc -> fc instanceof ToolCallback) - .map(fc -> (ToolCallback) fc) - .toList(); - - toolSpecifications.addAll(this.toAsyncToolSpecification(providerToolCallbacks, serverProperties)); logger.info("Enable tools capabilities, notification: " + serverProperties.isToolChangeNotification()); capabilitiesBuilder.tools(serverProperties.isToolChangeNotification()); diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerProperties.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerProperties.java index ebde2ecfff3..b9a8284275b 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerProperties.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerProperties.java @@ -140,6 +140,11 @@ public class McpServerProperties { */ private Duration requestTimeout = Duration.ofSeconds(20); + /** + * The duration to keep the connection alive. Disabled by default. + */ + private Duration keepAliveInterval; + public Duration getRequestTimeout() { return this.requestTimeout; } @@ -281,6 +286,14 @@ public Map getToolResponseMimeType() { return this.toolResponseMimeType; } + public void setKeepAliveInterval(Duration keepAliveInterval) { + this.keepAliveInterval = keepAliveInterval; + } + + public Duration getKeepAliveInterval() { + return this.keepAliveInterval; + } + public static class Capabilities { private boolean resource = true; diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpWebFluxServerAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpWebFluxServerAutoConfiguration.java index 3a68fa1b910..565bf0d6920 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpWebFluxServerAutoConfiguration.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpWebFluxServerAutoConfiguration.java @@ -49,7 +49,9 @@ * A RouterFunction bean that sets up the reactive SSE endpoint * *- * Required dependencies:
{@code + * Required dependencies: + * + *{@code ** - +io.modelcontextprotocol.sdk *mcp-spring-webflux @@ -76,12 +78,20 @@ public class McpWebFluxServerAutoConfiguration { @ConditionalOnMissingBean public WebFluxSseServerTransportProvider webFluxTransport(ObjectProviderobjectMapperProvider, McpServerProperties serverProperties) { + ObjectMapper objectMapper = objectMapperProvider.getIfAvailable(ObjectMapper::new); - return new WebFluxSseServerTransportProvider(objectMapper, serverProperties.getBaseUrl(), - serverProperties.getSseMessageEndpoint(), serverProperties.getSseEndpoint()); + + return WebFluxSseServerTransportProvider.builder() + .objectMapper(objectMapper) + .basePath(serverProperties.getBaseUrl()) + .messageEndpoint(serverProperties.getSseMessageEndpoint()) + .sseEndpoint(serverProperties.getSseEndpoint()) + .keepAliveInterval(serverProperties.getKeepAliveInterval()) + .build(); } - // Router function for SSE transport used by Spring WebFlux to start an HTTP server. + // Router function for SSE transport used by Spring WebFlux to start an HTTP + // server. @Bean public RouterFunction> webfluxMcpRouterFunction(WebFluxSseServerTransportProvider webFluxProvider) { return webFluxProvider.getRouterFunction(); diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpWebMvcServerAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpWebMvcServerAutoConfiguration.java index b0f24861dff..1b2c7479a54 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpWebMvcServerAutoConfiguration.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpWebMvcServerAutoConfiguration.java @@ -71,9 +71,16 @@ public class McpWebMvcServerAutoConfiguration { @ConditionalOnMissingBean public WebMvcSseServerTransportProvider webMvcSseServerTransportProvider( ObjectProvider objectMapperProvider, McpServerProperties serverProperties) { + ObjectMapper objectMapper = objectMapperProvider.getIfAvailable(ObjectMapper::new); - return new WebMvcSseServerTransportProvider(objectMapper, serverProperties.getBaseUrl(), - serverProperties.getSseMessageEndpoint(), serverProperties.getSseEndpoint()); + + return WebMvcSseServerTransportProvider.builder() + .objectMapper(objectMapper) + .baseUrl(serverProperties.getBaseUrl()) + .sseEndpoint(serverProperties.getSseEndpoint()) + .messageEndpoint(serverProperties.getSseMessageEndpoint()) + .keepAliveInterval(serverProperties.getKeepAliveInterval()) + .build(); } @Bean diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/ToolCallbackConverterAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/ToolCallbackConverterAutoConfiguration.java new file mode 100644 index 00000000000..2eace3e61b3 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/ToolCallbackConverterAutoConfiguration.java @@ -0,0 +1,131 @@ +/* + * 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.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.ai.mcp.McpToolUtils; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.ToolCallbackProvider; +import org.springframework.beans.factory.ObjectProvider; +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.Conditional; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MimeType; + +import io.modelcontextprotocol.server.McpServerFeatures; + +/** + * @author Christian Tzolov + */ +@EnableConfigurationProperties(McpServerProperties.class) +@Conditional(ToolCallbackConverterCondition.class) +public class ToolCallbackConverterAutoConfiguration { + + @Bean + @ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "type", havingValue = "SYNC", + matchIfMissing = true) + public List syncTools(ObjectProvider > toolCalls, + List
toolCallbacksList, List toolCallbackProvider, + McpServerProperties serverProperties) { + + List tools = this.aggregateToolCallbacks(toolCalls, toolCallbacksList, toolCallbackProvider); + + return this.toSyncToolSpecifications(tools, serverProperties); + } + + private List toSyncToolSpecifications(List tools, + McpServerProperties serverProperties) { + + // De-duplicate tools by their name, keeping the first occurrence of each tool + // name + return tools.stream() // Key: tool name + .collect(Collectors.toMap(tool -> tool.getToolDefinition().name(), tool -> tool, // Value: + // the + // tool + // itself + (existing, replacement) -> existing)) // On duplicate key, keep the + // existing tool + .values() + .stream() + .map(tool -> { + String toolName = tool.getToolDefinition().name(); + MimeType mimeType = (serverProperties.getToolResponseMimeType().containsKey(toolName)) + ? MimeType.valueOf(serverProperties.getToolResponseMimeType().get(toolName)) : null; + return McpToolUtils.toSyncToolSpecification(tool, mimeType); + }) + .toList(); + } + + @Bean + @ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "type", havingValue = "ASYNC") + public List asyncTools(ObjectProvider > toolCalls, + List
toolCallbacksList, List toolCallbackProvider, + McpServerProperties serverProperties) { + + List tools = this.aggregateToolCallbacks(toolCalls, toolCallbacksList, toolCallbackProvider); + + return this.toAsyncToolSpecification(tools, serverProperties); + } + + private List toAsyncToolSpecification(List tools, + McpServerProperties serverProperties) { + // De-duplicate tools by their name, keeping the first occurrence of each tool + // name + return tools.stream() // Key: tool name + .collect(Collectors.toMap(tool -> tool.getToolDefinition().name(), tool -> tool, // Value: + // the + // tool + // itself + (existing, replacement) -> existing)) // On duplicate key, keep the + // existing tool + .values() + .stream() + .map(tool -> { + String toolName = tool.getToolDefinition().name(); + MimeType mimeType = (serverProperties.getToolResponseMimeType().containsKey(toolName)) + ? MimeType.valueOf(serverProperties.getToolResponseMimeType().get(toolName)) : null; + return McpToolUtils.toAsyncToolSpecification(tool, mimeType); + }) + .toList(); + } + + private List aggregateToolCallbacks(ObjectProvider > toolCalls, + List
toolCallbacksList, List toolCallbackProvider) { + + List tools = new ArrayList<>(toolCalls.stream().flatMap(List::stream).toList()); + + if (!CollectionUtils.isEmpty(toolCallbacksList)) { + tools.addAll(toolCallbacksList); + } + + List providerToolCallbacks = toolCallbackProvider.stream() + .map(pr -> List.of(pr.getToolCallbacks())) + .flatMap(List::stream) + .filter(fc -> fc instanceof ToolCallback) + .map(fc -> (ToolCallback) fc) + .toList(); + + tools.addAll(providerToolCallbacks); + return tools; + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/ToolCallbackConverterCondition.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/ToolCallbackConverterCondition.java new file mode 100644 index 00000000000..c2b74c04931 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/java/org/springframework/ai/mcp/server/autoconfigure/ToolCallbackConverterCondition.java @@ -0,0 +1,43 @@ +/* + * 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 org.springframework.boot.autoconfigure.condition.AllNestedConditions; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +/** + * @author Christian Tzolov + */ +public class ToolCallbackConverterCondition extends AllNestedConditions { + + public ToolCallbackConverterCondition() { + super(ConfigurationPhase.PARSE_CONFIGURATION); + } + + @ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + static class McpServerEnabledCondition { + + } + + @ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "tool-callback-converter", + havingValue = "true", matchIfMissing = true) + static class ToolCallbackConvertCondition { + + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index d2faa1cbfe5..73251ac2a3d 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -16,3 +16,4 @@ org.springframework.ai.mcp.server.autoconfigure.McpServerAutoConfiguration org.springframework.ai.mcp.server.autoconfigure.McpWebFluxServerAutoConfiguration org.springframework.ai.mcp.server.autoconfigure.McpWebMvcServerAutoConfiguration +org.springframework.ai.mcp.server.autoconfigure.ToolCallbackConverterAutoConfiguration diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/test/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfigurationIT.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/test/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfigurationIT.java index 07fd683403e..4d6198b3aea 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/test/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfigurationIT.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/test/java/org/springframework/ai/mcp/server/autoconfigure/McpServerAutoConfigurationIT.java @@ -54,8 +54,8 @@ public class McpServerAutoConfigurationIT { - private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(McpServerAutoConfiguration.class)); + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withConfiguration( + AutoConfigurations.of(McpServerAutoConfiguration.class, ToolCallbackConverterAutoConfiguration.class)); @Test void defaultConfiguration() { @@ -194,6 +194,35 @@ void toolSpecificationConfiguration() { }); } + @Test + void syncToolCallbackRegistrationControl() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server..type=SYNC", "spring.ai.mcp.server..tool-callback-converter=true") + .run(context -> { + assertThat(context).hasBean("syncTools"); + }); + + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.type=SYNC", "spring.ai.mcp.server.tool-callback-converter=false") + .run(context -> { + assertThat(context).doesNotHaveBean("syncTools"); + }); + } + + @Test + void asyncToolCallbackRegistrationControl() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.type=ASYNC", "spring.ai.mcp.server.tool-callback-converter=true") + .run(context -> { + assertThat(context).hasBean("asyncTools"); + }); + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.type=ASYNC", "spring.ai.mcp.server.tool-callback-converter=false") + .run(context -> { + assertThat(context).doesNotHaveBean("asyncTools"); + }); + } + @Test void resourceSpecificationConfiguration() { this.contextRunner.withUserConfiguration(TestResourceConfiguration.class).run(context -> { diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/test/java/org/springframework/ai/mcp/server/autoconfigure/SseWebClientAndWebFluxServerIT.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/test/java/org/springframework/ai/mcp/server/autoconfigure/SseWebClientAndWebFluxServerIT.java new file mode 100644 index 00000000000..4255cbd73e2 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server/src/test/java/org/springframework/ai/mcp/server/autoconfigure/SseWebClientAndWebFluxServerIT.java @@ -0,0 +1,513 @@ +/* + * 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 static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration; +import org.springframework.ai.mcp.client.common.autoconfigure.McpToolCallbackAutoConfiguration; +import org.springframework.ai.mcp.client.webflux.autoconfigure.SseWebFluxTransportAutoConfiguration; +import org.springframework.ai.mcp.customizer.McpSyncClientCustomizer; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.core.ResolvableType; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; +import org.springframework.test.util.TestSocketUtils; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.CompleteRequest; +import io.modelcontextprotocol.spec.McpSchema.CompleteResult; +import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult; +import io.modelcontextprotocol.spec.McpSchema.ElicitRequest; +import io.modelcontextprotocol.spec.McpSchema.ElicitResult; +import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; +import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; +import io.modelcontextprotocol.spec.McpSchema.LoggingLevel; +import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification; +import io.modelcontextprotocol.spec.McpSchema.ModelHint; +import io.modelcontextprotocol.spec.McpSchema.ModelPreferences; +import io.modelcontextprotocol.spec.McpSchema.ProgressNotification; +import io.modelcontextprotocol.spec.McpSchema.PromptArgument; +import io.modelcontextprotocol.spec.McpSchema.PromptMessage; +import io.modelcontextprotocol.spec.McpSchema.PromptReference; +import io.modelcontextprotocol.spec.McpSchema.Resource; +import io.modelcontextprotocol.spec.McpSchema.Role; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import io.modelcontextprotocol.spec.McpSchema.Tool; +import net.javacrumbs.jsonunit.core.Option; +import reactor.netty.DisposableServer; +import reactor.netty.http.server.HttpServer; + +public class SseWebClientAndWebFluxServerIT { + + private static final Logger logger = LoggerFactory.getLogger(SseWebClientAndWebFluxServerIT.class); + + private final ApplicationContextRunner serverContextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(McpServerAutoConfiguration.class, + ToolCallbackConverterAutoConfiguration.class, McpWebFluxServerAutoConfiguration.class)); + + private final ApplicationContextRunner clientApplicationContext = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(McpToolCallbackAutoConfiguration.class, + McpClientAutoConfiguration.class, SseWebFluxTransportAutoConfiguration.class)); + + @Test + void clientServerCapabilities() { + + int serverPort = TestSocketUtils.findAvailableTcpPort(); + + this.serverContextRunner.withUserConfiguration(TestMcpServerConfiguration.class) + .withPropertyValues(// @formatter:off + "spring.ai.mcp.server.sse-endpoint=/sse", + "spring.ai.mcp.server.base-url=http://localhost:" + serverPort, + "spring.ai.mcp.server.name=test-mcp-server", + "spring.ai.mcp.server.keep-alive-interval=1s", + "spring.ai.mcp.server.version=1.0.0") // @formatter:on + .run(serverContext -> { + // Verify all required beans are present + assertThat(serverContext).hasSingleBean(WebFluxSseServerTransportProvider.class); + assertThat(serverContext).hasSingleBean(RouterFunction.class); + assertThat(serverContext).hasSingleBean(McpSyncServer.class); + + // Verify server properties are configured correctly + McpServerProperties properties = serverContext.getBean(McpServerProperties.class); + assertThat(properties.getName()).isEqualTo("test-mcp-server"); + assertThat(properties.getVersion()).isEqualTo("1.0.0"); + // assertThat(properties.getMcpEndpoint()).isEqualTo("/mcp"); + + var httpServer = startHttpServer(serverContext, serverPort); + + clientApplicationContext.withUserConfiguration(TestMcpClientConfiguration.class) + .withPropertyValues(// @formatter:off + "spring.ai.mcp.client.sse.connections.server1.url=http://localhost:" + serverPort, + "spring.ai.mcp.client.initialized=false") // @formatter:on + .run(clientContext -> { + McpSyncClient mcpClient = getMcpSyncClient(clientContext); + assertThat(mcpClient).isNotNull(); + var initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + // TOOLS / SAMPLING / ELICITATION + + // tool list + assertThat(mcpClient.listTools().tools()).hasSize(2); + assertThat(mcpClient.listTools().tools()) + .contains(Tool.builder().name("tool1").description("tool1 description").inputSchema(""" + { + "": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + } + """).build()); + + // Call a tool that sends progress notifications + CallToolRequest toolRequest = CallToolRequest.builder() + .name("tool1") + .arguments(Map.of()) + .progressToken("test-progress-token") + .build(); + + CallToolResult response = mcpClient.callTool(toolRequest); + + assertThat(response).isNotNull(); + assertThat(response.isError()).isNull(); + String responseText = ((TextContent) response.content().get(0)).text(); + assertThat(responseText).contains("CALL RESPONSE"); + assertThat(responseText).contains("Response Test Sampling Message with model hint OpenAi"); + assertThat(responseText).contains("ElicitResult"); + + // TOOL STRUCTURED OUTPUT + // Call tool with valid structured output + CallToolResult calculatorToolResponse = mcpClient + .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3"))); + + assertThat(calculatorToolResponse).isNotNull(); + assertThat(calculatorToolResponse.isError()).isFalse(); + + assertThat(calculatorToolResponse.structuredContent()).isNotNull(); + + assertThat(calculatorToolResponse.structuredContent()).containsEntry("result", 5.0) + .containsEntry("operation", "2 + 3") + .containsEntry("timestamp", "2024-01-01T10:00:00Z"); + + assertThatJson(calculatorToolResponse.structuredContent()).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + {"result":5.0,"operation":"2 + 3","timestamp":"2024-01-01T10:00:00Z"}""")); + + // PROGRESS + TestContext testContext = clientContext.getBean(TestContext.class); + assertThat(testContext.progressLatch.await(5, TimeUnit.SECONDS)) + .as("Should receive progress notifications in reasonable time") + .isTrue(); + assertThat(testContext.progressNotifications).hasSize(3); + + Map notificationMap = testContext.progressNotifications + .stream() + .collect(Collectors.toMap(n -> n.message(), n -> n)); + + // First notification should be 0.0/1.0 progress + assertThat(notificationMap.get("tool call start").progressToken()) + .isEqualTo("test-progress-token"); + assertThat(notificationMap.get("tool call start").progress()).isEqualTo(0.0); + assertThat(notificationMap.get("tool call start").total()).isEqualTo(1.0); + assertThat(notificationMap.get("tool call start").message()).isEqualTo("tool call start"); + + // Second notification should be 1.0/1.0 progress + assertThat(notificationMap.get("elicitation completed").progressToken()) + .isEqualTo("test-progress-token"); + assertThat(notificationMap.get("elicitation completed").progress()).isEqualTo(0.5); + assertThat(notificationMap.get("elicitation completed").total()).isEqualTo(1.0); + assertThat(notificationMap.get("elicitation completed").message()) + .isEqualTo("elicitation completed"); + + // Third notification should be 0.5/1.0 progress + assertThat(notificationMap.get("sampling completed").progressToken()) + .isEqualTo("test-progress-token"); + assertThat(notificationMap.get("sampling completed").progress()).isEqualTo(1.0); + assertThat(notificationMap.get("sampling completed").total()).isEqualTo(1.0); + assertThat(notificationMap.get("sampling completed").message()).isEqualTo("sampling completed"); + + // PROMPT / COMPLETION + + // list prompts + assertThat(mcpClient.listPrompts()).isNotNull(); + assertThat(mcpClient.listPrompts().prompts()).hasSize(1); + + // get prompt + GetPromptResult promptResult = mcpClient + .getPrompt(new GetPromptRequest("code-completion", Map.of("language", "java"))); + assertThat(promptResult).isNotNull(); + + // completion + CompleteRequest completeRequest = new CompleteRequest( + new PromptReference("ref/prompt", "code-completion", "Code completion"), + new CompleteRequest.CompleteArgument("language", "py")); + + CompleteResult completeResult = mcpClient.completeCompletion(completeRequest); + + assertThat(completeResult).isNotNull(); + assertThat(completeResult.completion().total()).isEqualTo(10); + assertThat(completeResult.completion().values()).containsExactly("python", "pytorch", "pyside"); + assertThat(completeResult.meta()).isNull(); + + // logging message + var logMessage = testContext.loggingNotificationRef.get(); + assertThat(logMessage).isNotNull(); + assertThat(logMessage.level()).isEqualTo(LoggingLevel.INFO); + assertThat(logMessage.logger()).isEqualTo("test-logger"); + assertThat(logMessage.data()).contains("User prompt"); + + // RESOURCES + assertThat(mcpClient.listResources()).isNotNull(); + assertThat(mcpClient.listResources().resources()).hasSize(1); + assertThat(mcpClient.listResources().resources().get(0)) + .isEqualToComparingFieldByFieldRecursively(Resource.builder() + .uri("file://resource") + .name("Test Resource") + .mimeType("text/plain") + .description("Test resource description") + .build()); + + }); + + stopHttpServer(httpServer); + }); + } + + private static class TestContext { + + final AtomicReference loggingNotificationRef = new AtomicReference<>(); + + final CountDownLatch progressLatch = new CountDownLatch(3); + + final List progressNotifications = new CopyOnWriteArrayList<>(); + + } + + public static class TestMcpServerConfiguration { + + @Bean + public List myTools() { + + // Tool 1 + McpServerFeatures.SyncToolSpecification tool1 = McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(""" + { + "": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + } + """).build()) + .callHandler((exchange, request) -> { + + exchange.progressNotification( + new ProgressNotification("test-progress-token", 0.0, 1.0, "tool call start")); + + exchange.ping(); // call client ping + + // call elicitation + var elicitationRequest = McpSchema.ElicitRequest.builder() + .message("Test message") + .requestedSchema( + Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) + .build(); + + ElicitResult elicitationResult = exchange.createElicitation(elicitationRequest); + + exchange.progressNotification( + new ProgressNotification("test-progress-token", 0.50, 1.0, "elicitation completed")); + + // call sampling + var createMessageRequest = McpSchema.CreateMessageRequest.builder() + .messages(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, + new McpSchema.TextContent("Test Sampling Message")))) + .modelPreferences(ModelPreferences.builder() + .hints(List.of(ModelHint.of("OpenAi"), ModelHint.of("Ollama"))) + .costPriority(1.0) + .speedPriority(1.0) + .intelligencePriority(1.0) + .build()) + .build(); + + CreateMessageResult samplingResponse = exchange.createMessage(createMessageRequest); + + exchange.progressNotification( + new ProgressNotification("test-progress-token", 1.0, 1.0, "sampling completed")); + + return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent( + "CALL RESPONSE: " + samplingResponse.toString() + ", " + elicitationResult.toString())), + null); + }) + .build(); + + // Tool 2 + + // Create a tool with output schema + Map outputSchema = Map.of( + "type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation", + Map.of("type", "string"), "timestamp", Map.of("type", "string")), + "required", List.of("result", "operation")); + + Tool calculatorTool = Tool.builder() + .name("calculator") + .description("Performs mathematical calculations") + .outputSchema(outputSchema) + .build(); + + McpServerFeatures.SyncToolSpecification tool2 = McpServerFeatures.SyncToolSpecification.builder() + .tool(calculatorTool) + .callHandler((exchange, request) -> { + String expression = (String) request.arguments().getOrDefault("expression", "2 + 3"); + double result = this.evaluateExpression(expression); + return CallToolResult.builder() + .structuredContent( + Map.of("result", result, "operation", expression, "timestamp", "2024-01-01T10:00:00Z")) + .build(); + }) + .build(); + + return List.of(tool1, tool2); + } + + @Bean + public List myPrompts() { + + var prompt = new McpSchema.Prompt("code-completion", "Code completion", "this is code review prompt", + List.of(new PromptArgument("language", "Language", "string", false))); + + var promptSpecification = new McpServerFeatures.SyncPromptSpecification(prompt, + (exchange, getPromptRequest) -> { + String languageArgument = (String) getPromptRequest.arguments().get("language"); + if (languageArgument == null) { + languageArgument = "java"; + } + + // send logging notification + exchange.loggingNotification(LoggingMessageNotification.builder() + // .level(LoggingLevel.DEBUG) + .logger("test-logger") + .data("User prompt: Hello " + languageArgument + "! How can I assist you today?") + .build()); + + var userMessage = new PromptMessage(Role.USER, + new TextContent("Hello " + languageArgument + "! How can I assist you today?")); + return new GetPromptResult("A personalized greeting message", List.of(userMessage)); + }); + + return List.of(promptSpecification); + } + + @Bean + public List myCompletions() { + var completion = new McpServerFeatures.SyncCompletionSpecification( + new McpSchema.PromptReference("ref/prompt", "code-completion", "Code completion"), + (exchange, request) -> { + var expectedValues = List.of("python", "pytorch", "pyside"); + return new McpSchema.CompleteResult(new CompleteResult.CompleteCompletion(expectedValues, 10, // total + true // hasMore + )); + }); + + return List.of(completion); + } + + @Bean + public List myResources() { + + var systemInfoResource = Resource.builder() + .uri("file://resource") + .name("Test Resource") + .mimeType("text/plain") + .description("Test resource description") + .build(); + + var resourceSpecification = new McpServerFeatures.SyncResourceSpecification(systemInfoResource, + (exchange, request) -> { + try { + var systemInfo = Map.of("os", System.getProperty("os.name"), "os_version", + System.getProperty("os.version"), "java_version", + System.getProperty("java.version")); + String jsonContent = new ObjectMapper().writeValueAsString(systemInfo); + return new McpSchema.ReadResourceResult(List.of(new McpSchema.TextResourceContents( + request.uri(), "application/json", jsonContent))); + } + catch (Exception e) { + throw new RuntimeException("Failed to generate system info", e); + } + }); + + return List.of(resourceSpecification); + } + + private double evaluateExpression(String expression) { + // Simple expression evaluator for testing + return switch (expression) { + case "2 + 3" -> 5.0; + case "10 * 2" -> 20.0; + case "7 + 8" -> 15.0; + case "5 + 3" -> 8.0; + default -> 0.0; + }; + } + + } + + public static class TestMcpClientConfiguration { + + @Bean + public TestContext testContext() { + return new TestContext(); + } + + @Bean + McpSyncClientCustomizer clientCustomizer(TestContext testContext) { + + return (name, mcpClientSpec) -> { + + // Add logging handler + mcpClientSpec = mcpClientSpec.loggingConsumer(logingMessage -> { + testContext.loggingNotificationRef.set(logingMessage); + logger.info("MCP LOGGING: [{}] {}", logingMessage.level(), logingMessage.data()); + }); + + // Add sampling handler + Function samplingHandler = llmRequest -> { + String userPrompt = ((McpSchema.TextContent) llmRequest.messages().get(0).content()).text(); + String modelHint = llmRequest.modelPreferences().hints().get(0).name(); + return CreateMessageResult.builder() + .content(new McpSchema.TextContent("Response " + userPrompt + " with model hint " + modelHint)) + .build(); + }; + + mcpClientSpec.sampling(samplingHandler); + + // Add elicitation handler + Function elicitationHandler = request -> { + assertThat(request.message()).isNotEmpty(); + assertThat(request.requestedSchema()).isNotNull(); + return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("message", request.message())); + }; + + mcpClientSpec.elicitation(elicitationHandler); + + // Progress notification + mcpClientSpec.progressConsumer(progressNotification -> { + testContext.progressNotifications.add(progressNotification); + testContext.progressLatch.countDown(); + + assertThat(progressNotification.progressToken()).isEqualTo("test-progress-token"); + // assertThat(progressNotification.progress()).isEqualTo(0.0); + assertThat(progressNotification.total()).isEqualTo(1.0); + // assertThat(progressNotification.message()).isEqualTo("processing"); + }); + }; + } + + } + + // Helper methods to start and stop the HTTP server + private static DisposableServer startHttpServer(ApplicationContext serverContext, int port) { + WebFluxSseServerTransportProvider mcpSseServerTransport = serverContext + .getBean(WebFluxSseServerTransportProvider.class); + HttpHandler httpHandler = RouterFunctions.toHttpHandler(mcpSseServerTransport.getRouterFunction()); + ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler); + return HttpServer.create().port(port).handle(adapter).bindNow(); + } + + private static void stopHttpServer(DisposableServer server) { + if (server != null) { + server.disposeNow(); + } + } + + // Helper method to get the MCP sync client + private static McpSyncClient getMcpSyncClient(ApplicationContext clientContext) { + ObjectProvider > mcpClients = clientContext + .getBeanProvider(ResolvableType.forClassWithGenerics(List.class, McpSyncClient.class)); + return mcpClients.getIfAvailable().get(0); + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common/pom.xml b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common/pom.xml new file mode 100644 index 00000000000..9df6cf26b74 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common/pom.xml @@ -0,0 +1,68 @@ + +
+ diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common/src/main/java/org/springframework/ai/mcp/server/stateless/autoconfigure/McpStatelessServerAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common/src/main/java/org/springframework/ai/mcp/server/stateless/autoconfigure/McpStatelessServerAutoConfiguration.java new file mode 100644 index 00000000000..9692f1191dc --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common/src/main/java/org/springframework/ai/mcp/server/stateless/autoconfigure/McpStatelessServerAutoConfiguration.java @@ -0,0 +1,223 @@ +/* + * 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.stateless.autoconfigure; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +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.core.env.Environment; +import org.springframework.core.log.LogAccessor; +import org.springframework.util.CollectionUtils; +import org.springframework.web.context.support.StandardServletEnvironment; + +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpServer.StatelessAsyncSpecification; +import io.modelcontextprotocol.server.McpServer.StatelessSyncSpecification; +import io.modelcontextprotocol.server.McpStatelessAsyncServer; +import io.modelcontextprotocol.server.McpStatelessServerFeatures.AsyncCompletionSpecification; +import io.modelcontextprotocol.server.McpStatelessServerFeatures.AsyncPromptSpecification; +import io.modelcontextprotocol.server.McpStatelessServerFeatures.AsyncResourceSpecification; +import io.modelcontextprotocol.server.McpStatelessServerFeatures.AsyncToolSpecification; +import io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification; +import io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncPromptSpecification; +import io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceSpecification; +import io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification; +import io.modelcontextprotocol.server.McpStatelessSyncServer; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.Implementation; +import io.modelcontextprotocol.spec.McpStatelessServerTransport; + +/** + * @author Christian Tzolov + */ +@AutoConfiguration(afterName = { + "org.springframework.ai.mcp.server.stateless.autoconfigure.ToolCallbackConverterAutoConfiguration", + "org.springframework.ai.mcp.server.stateless.webflux.autoconfigure.McpStatelessServerWebFluxAutoConfiguration", + "org.springframework.ai.mcp.server.stateless.webmvc.autoconfigure.McpStatelessServerWebMvcAutoConfiguration" }) +@ConditionalOnClass({ McpSchema.class, McpSyncServer.class }) +@EnableConfigurationProperties(McpStatelessServerProperties.class) +@ConditionalOnProperty(prefix = McpStatelessServerProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) +public class McpStatelessServerAutoConfiguration { + + private static final LogAccessor logger = new LogAccessor(McpStatelessServerAutoConfiguration.class); + + @Bean + @ConditionalOnMissingBean + public McpSchema.ServerCapabilities.Builder capabilitiesBuilder() { + return McpSchema.ServerCapabilities.builder(); + } + + @Bean + @ConditionalOnProperty(prefix = McpStatelessServerProperties.CONFIG_PREFIX, name = "type", havingValue = "SYNC", + matchIfMissing = true) + public McpStatelessSyncServer mcpStatelessSyncServer(McpStatelessServerTransport statelessTransport, + McpSchema.ServerCapabilities.Builder capabilitiesBuilder, McpStatelessServerProperties serverProperties, + ObjectProvider4.0.0 ++ +org.springframework.ai +spring-ai-parent +1.1.0-SNAPSHOT +../../../pom.xml +spring-ai-autoconfigure-mcp-stateless-server-common +jar +Spring AI MCP Stateless Server Common Auto Configuration +Spring AI MCP Stateless Server Common Auto Configuration +https://github.com/spring-projects/spring-ai + ++ + +https://github.com/spring-projects/spring-ai +git://github.com/spring-projects/spring-ai.git +git@github.com:spring-projects/spring-ai.git ++ + + ++ + +org.springframework.boot +spring-boot-starter ++ + +org.springframework.ai +spring-ai-mcp +${project.parent.version} +true ++ + +io.modelcontextprotocol.sdk +mcp-spring-webmvc +true ++ + +org.springframework.boot +spring-boot-configuration-processor +true ++ + + + +org.springframework.boot +spring-boot-autoconfigure-processor +true ++ + + +org.springframework.ai +spring-ai-test +${project.parent.version} +test +> tools, + ObjectProvider
> resources, + ObjectProvider
> prompts, + ObjectProvider
> completions, Environment environment) { + + McpSchema.Implementation serverInfo = new Implementation(serverProperties.getName(), + serverProperties.getVersion()); + + // Create the server with both tool and resource capabilities + StatelessSyncSpecification serverBuilder = McpServer.sync(statelessTransport).serverInfo(serverInfo); + + // Tools + if (serverProperties.getCapabilities().isTool()) { + capabilitiesBuilder.tools(false); + + List
toolSpecifications = new ArrayList<>( + tools.stream().flatMap(List::stream).toList()); + + if (!CollectionUtils.isEmpty(toolSpecifications)) { + serverBuilder.tools(toolSpecifications); + logger.info("Registered tools: " + toolSpecifications.size()); + } + } + + // Resources + if (serverProperties.getCapabilities().isResource()) { + capabilitiesBuilder.resources(false, false); + + List resourceSpecifications = resources.stream().flatMap(List::stream).toList(); + if (!CollectionUtils.isEmpty(resourceSpecifications)) { + serverBuilder.resources(resourceSpecifications); + logger.info("Registered resources: " + resourceSpecifications.size()); + } + } + + // Prompts + if (serverProperties.getCapabilities().isPrompt()) { + capabilitiesBuilder.prompts(false); + + List promptSpecifications = prompts.stream().flatMap(List::stream).toList(); + if (!CollectionUtils.isEmpty(promptSpecifications)) { + serverBuilder.prompts(promptSpecifications); + logger.info("Registered prompts: " + promptSpecifications.size()); + } + } + + // Completions + if (serverProperties.getCapabilities().isCompletion()) { + logger.info("Enable completions capabilities"); + capabilitiesBuilder.completions(); + + List completionSpecifications = completions.stream() + .flatMap(List::stream) + .toList(); + if (!CollectionUtils.isEmpty(completionSpecifications)) { + serverBuilder.completions(completionSpecifications); + logger.info("Registered completions: " + completionSpecifications.size()); + } + } + + serverBuilder.capabilities(capabilitiesBuilder.build()); + + serverBuilder.instructions(serverProperties.getInstructions()); + + serverBuilder.requestTimeout(serverProperties.getRequestTimeout()); + if (environment instanceof StandardServletEnvironment) { + serverBuilder.immediateExecution(true); + } + + return serverBuilder.build(); + } + + @Bean + @ConditionalOnProperty(prefix = McpStatelessServerProperties.CONFIG_PREFIX, name = "type", havingValue = "ASYNC") + public McpStatelessAsyncServer mcpStatelessAsyncServer(McpStatelessServerTransport statelessTransport, + McpSchema.ServerCapabilities.Builder capabilitiesBuilder, McpStatelessServerProperties serverProperties, + ObjectProvider > tools, + ObjectProvider
> resources, + ObjectProvider
> prompts, + ObjectProvider
> completions) { + + McpSchema.Implementation serverInfo = new Implementation(serverProperties.getName(), + serverProperties.getVersion()); + + // Create the server with both tool and resource capabilities + StatelessAsyncSpecification serverBuilder = McpServer.async(statelessTransport).serverInfo(serverInfo); + + // Tools + if (serverProperties.getCapabilities().isTool()) { + List
toolSpecifications = new ArrayList<>( + tools.stream().flatMap(List::stream).toList()); + + capabilitiesBuilder.tools(false); + + if (!CollectionUtils.isEmpty(toolSpecifications)) { + serverBuilder.tools(toolSpecifications); + logger.info("Registered tools: " + toolSpecifications.size()); + } + } + + // Resources + if (serverProperties.getCapabilities().isResource()) { + capabilitiesBuilder.resources(false, false); + + List resourceSpecifications = resources.stream().flatMap(List::stream).toList(); + if (!CollectionUtils.isEmpty(resourceSpecifications)) { + serverBuilder.resources(resourceSpecifications); + logger.info("Registered resources: " + resourceSpecifications.size()); + } + } + + // Prompts + if (serverProperties.getCapabilities().isPrompt()) { + capabilitiesBuilder.prompts(false); + List promptSpecifications = prompts.stream().flatMap(List::stream).toList(); + + if (!CollectionUtils.isEmpty(promptSpecifications)) { + serverBuilder.prompts(promptSpecifications); + logger.info("Registered prompts: " + promptSpecifications.size()); + } + } + + // Completions + if (serverProperties.getCapabilities().isCompletion()) { + logger.info("Enable completions capabilities"); + capabilitiesBuilder.completions(); + List completionSpecifications = completions.stream() + .flatMap(List::stream) + .toList(); + + if (!CollectionUtils.isEmpty(completionSpecifications)) { + serverBuilder.completions(completionSpecifications); + logger.info("Registered completions: " + completionSpecifications.size()); + } + } + + serverBuilder.capabilities(capabilitiesBuilder.build()); + + serverBuilder.instructions(serverProperties.getInstructions()); + + serverBuilder.requestTimeout(serverProperties.getRequestTimeout()); + + return serverBuilder.build(); + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common/src/main/java/org/springframework/ai/mcp/server/stateless/autoconfigure/McpStatelessServerProperties.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common/src/main/java/org/springframework/ai/mcp/server/stateless/autoconfigure/McpStatelessServerProperties.java new file mode 100644 index 00000000000..f78e88995cc --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common/src/main/java/org/springframework/ai/mcp/server/stateless/autoconfigure/McpStatelessServerProperties.java @@ -0,0 +1,230 @@ +/* + * 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.stateless.autoconfigure; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.Assert; + +/** + * @author Christian Tzolov + */ +@ConfigurationProperties(McpStatelessServerProperties.CONFIG_PREFIX) +public class McpStatelessServerProperties { + + public static final String CONFIG_PREFIX = "spring.ai.mcp.server.stateless"; + + /** + * Enable/disable the MCP server. + * + * When set to false, the MCP server and all its components will not be initialized. + */ + private boolean enabled = true; + + /** + * The name of the MCP server instance. + *
+ * This name is used to identify the server in logs and monitoring. + */ + private String name = "mcp-server"; + + /** + * The version of the MCP server instance. + */ + private String version = "1.0.0"; + + /** + * The instructions of the MCP server instance. + *
+ * These instructions are used to provide guidance to the client on how to interact + * with this server. + */ + private String instructions = null; + + /** + */ + private String mcpEndpoint = "/mcp"; + + /** + * The type of server to use for MCP server communication. + *
+ * Supported types are: + *
+ *
+ */ + private ServerType type = ServerType.SYNC; + + private Capabilities capabilities = new Capabilities(); + + /** + * Sets the duration to wait for server responses before timing out requests. This + * timeout applies to all requests made through the client, including tool calls, + * resource access, and prompt operations. + */ + private Duration requestTimeout = Duration.ofSeconds(20); + + private boolean disallowDelete; + + public Duration getRequestTimeout() { + return this.requestTimeout; + } + + public void setRequestTimeout(Duration requestTimeout) { + Assert.notNull(requestTimeout, "Request timeout must not be null"); + this.requestTimeout = requestTimeout; + } + + public Capabilities getCapabilities() { + return this.capabilities; + } + + /** + * Server types supported by the MCP server. + */ + public enum ServerType { + + /** + * Synchronous (McpSyncServer) server + */ + SYNC, + + /** + * Asynchronous (McpAsyncServer) server + */ + ASYNC + + } + + /** + * (Optional) response MIME type per tool name. + */ + private Map- SYNC - Standard synchronous server (default)
+ *- ASYNC - Asynchronous server
+ *toolResponseMimeType = new HashMap<>(); + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + Assert.hasText(name, "Name must not be empty"); + this.name = name; + } + + public String getVersion() { + return this.version; + } + + public void setVersion(String version) { + Assert.hasText(version, "Version must not be empty"); + this.version = version; + } + + public String getInstructions() { + return this.instructions; + } + + public void setInstructions(String instructions) { + this.instructions = instructions; + } + + public String getMcpEndpoint() { + return this.mcpEndpoint; + } + + public void setMcpEndpoint(String mcpEndpoint) { + Assert.hasText(mcpEndpoint, "MCP endpoint must not be empty"); + this.mcpEndpoint = mcpEndpoint; + } + + public ServerType getType() { + return this.type; + } + + public void setType(ServerType serverType) { + Assert.notNull(serverType, "Server type must not be null"); + this.type = serverType; + } + + public Map getToolResponseMimeType() { + return this.toolResponseMimeType; + } + + public boolean isDisallowDelete() { + return this.disallowDelete; + } + + public void setDisallowDelete(boolean disallowDelete) { + this.disallowDelete = disallowDelete; + } + + public static class Capabilities { + + private boolean resource = true; + + private boolean tool = true; + + private boolean prompt = true; + + private boolean completion = true; + + public boolean isResource() { + return this.resource; + } + + public void setResource(boolean resource) { + this.resource = resource; + } + + public boolean isTool() { + return this.tool; + } + + public void setTool(boolean tool) { + this.tool = tool; + } + + public boolean isPrompt() { + return this.prompt; + } + + public void setPrompt(boolean prompt) { + this.prompt = prompt; + } + + public boolean isCompletion() { + return this.completion; + } + + public void setCompletion(boolean completion) { + this.completion = completion; + } + + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common/src/main/java/org/springframework/ai/mcp/server/stateless/autoconfigure/ToolCallbackConverterAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common/src/main/java/org/springframework/ai/mcp/server/stateless/autoconfigure/ToolCallbackConverterAutoConfiguration.java new file mode 100644 index 00000000000..dd0bf3271a3 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common/src/main/java/org/springframework/ai/mcp/server/stateless/autoconfigure/ToolCallbackConverterAutoConfiguration.java @@ -0,0 +1,123 @@ +/* + * 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.stateless.autoconfigure; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.ai.mcp.McpToolUtils; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.ToolCallbackProvider; +import org.springframework.beans.factory.ObjectProvider; +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.Conditional; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MimeType; + +import io.modelcontextprotocol.server.McpStatelessServerFeatures; + +/** + * @author Christian Tzolov + */ +@EnableConfigurationProperties(McpStatelessServerProperties.class) +@Conditional(ToolCallbackConverterCondition.class) +public class ToolCallbackConverterAutoConfiguration { + + @Bean + @ConditionalOnProperty(prefix = McpStatelessServerProperties.CONFIG_PREFIX, name = "type", havingValue = "SYNC", + matchIfMissing = true) + public List syncTools( + ObjectProvider > toolCalls, List
toolCallbackList, + List toolCallbackProvider, McpStatelessServerProperties serverProperties) { + + List tools = this.aggregateToolCallbacks(toolCalls, toolCallbackList, toolCallbackProvider); + + return this.toSyncToolSpecifications(tools, serverProperties); + } + + private List toSyncToolSpecifications(List tools, + McpStatelessServerProperties serverProperties) { + + // De-duplicate tools by their name, keeping the first occurrence of each tool + // name + return tools.stream() // Key: tool name + .collect(Collectors.toMap(tool -> tool.getToolDefinition().name(), tool -> tool, + (existing, replacement) -> existing)) + .values() + .stream() + .map(tool -> { + String toolName = tool.getToolDefinition().name(); + MimeType mimeType = (serverProperties.getToolResponseMimeType().containsKey(toolName)) + ? MimeType.valueOf(serverProperties.getToolResponseMimeType().get(toolName)) : null; + return McpToolUtils.toStatelessSyncToolSpecification(tool, mimeType); + }) + .toList(); + } + + @Bean + @ConditionalOnProperty(prefix = McpStatelessServerProperties.CONFIG_PREFIX, name = "type", havingValue = "ASYNC") + public List asyncTools( + ObjectProvider > toolCalls, List
toolCallbackList, + List toolCallbackProvider, McpStatelessServerProperties serverProperties) { + + List tools = this.aggregateToolCallbacks(toolCalls, toolCallbackList, toolCallbackProvider); + + return this.toAsyncToolSpecification(tools, serverProperties); + } + + private List toAsyncToolSpecification(List tools, + McpStatelessServerProperties serverProperties) { + // De-duplicate tools by their name, keeping the first occurrence of each tool + // name + return tools.stream() // Key: tool name + .collect(Collectors.toMap(tool -> tool.getToolDefinition().name(), tool -> tool, + (existing, replacement) -> existing)) + .values() + .stream() + .map(tool -> { + String toolName = tool.getToolDefinition().name(); + MimeType mimeType = (serverProperties.getToolResponseMimeType().containsKey(toolName)) + ? MimeType.valueOf(serverProperties.getToolResponseMimeType().get(toolName)) : null; + return McpToolUtils.toStatelessAsyncToolSpecification(tool, mimeType); + }) + .toList(); + } + + private List aggregateToolCallbacks(ObjectProvider > toolCalls, + List
toolCallbacksList, List toolCallbackProvider) { + + List tools = new ArrayList<>(toolCalls.stream().flatMap(List::stream).toList()); + + if (!CollectionUtils.isEmpty(toolCallbacksList)) { + tools.addAll(toolCallbacksList); + } + + List providerToolCallbacks = toolCallbackProvider.stream() + .map(pr -> List.of(pr.getToolCallbacks())) + .flatMap(List::stream) + .filter(fc -> fc instanceof ToolCallback) + .map(fc -> (ToolCallback) fc) + .toList(); + + tools.addAll(providerToolCallbacks); + return tools; + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common/src/main/java/org/springframework/ai/mcp/server/stateless/autoconfigure/ToolCallbackConverterCondition.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common/src/main/java/org/springframework/ai/mcp/server/stateless/autoconfigure/ToolCallbackConverterCondition.java new file mode 100644 index 00000000000..660d5f904ff --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common/src/main/java/org/springframework/ai/mcp/server/stateless/autoconfigure/ToolCallbackConverterCondition.java @@ -0,0 +1,43 @@ +/* + * 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.stateless.autoconfigure; + +import org.springframework.boot.autoconfigure.condition.AllNestedConditions; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +/** + * @author Christian Tzolov + */ +public class ToolCallbackConverterCondition extends AllNestedConditions { + + public ToolCallbackConverterCondition() { + super(ConfigurationPhase.PARSE_CONFIGURATION); + } + + @ConditionalOnProperty(prefix = McpStatelessServerProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + static class McpServerEnabledCondition { + + } + + @ConditionalOnProperty(prefix = McpStatelessServerProperties.CONFIG_PREFIX, name = "tool-callback-converter", + havingValue = "true", matchIfMissing = true) + static class ToolCallbackConvertCondition { + + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..12f3fd0dc59 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,18 @@ +# +# 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. +# +org.springframework.ai.mcp.server.stateless.autoconfigure.McpStatelessServerAutoConfiguration +org.springframework.ai.mcp.server.stateless.autoconfigure.ToolCallbackConverterAutoConfiguration + diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common/src/test/java/org/springframework/ai/mcp/server/stateless/autoconfigure/McpStatelessServerAutoConfigurationIT.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common/src/test/java/org/springframework/ai/mcp/server/stateless/autoconfigure/McpStatelessServerAutoConfigurationIT.java new file mode 100644 index 00000000000..5a1c57dc26e --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common/src/test/java/org/springframework/ai/mcp/server/stateless/autoconfigure/McpStatelessServerAutoConfigurationIT.java @@ -0,0 +1,453 @@ +/* + * 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.stateless.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.ai.mcp.SyncMcpToolCallback; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.ToolCallbackProvider; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.server.McpStatelessAsyncServer; +import io.modelcontextprotocol.server.McpStatelessServerFeatures; +import io.modelcontextprotocol.server.McpStatelessServerFeatures.AsyncCompletionSpecification; +import io.modelcontextprotocol.server.McpStatelessServerFeatures.AsyncToolSpecification; +import io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncCompletionSpecification; +import io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncPromptSpecification; +import io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncResourceSpecification; +import io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification; +import io.modelcontextprotocol.server.McpStatelessSyncServer; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.server.McpTransportContext; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpStatelessServerTransport; +import reactor.core.publisher.Mono; + +public class McpStatelessServerAutoConfigurationIT { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(McpStatelessServerAutoConfiguration.class, + ToolCallbackConverterAutoConfiguration.class)) + .withUserConfiguration(TestStatelessTransportConfiguration.class); + + @Test + void defaultConfiguration() { + this.contextRunner.run(context -> { + assertThat(context).hasSingleBean(McpStatelessSyncServer.class); + assertThat(context).hasSingleBean(McpStatelessServerTransport.class); + + McpStatelessServerProperties properties = context.getBean(McpStatelessServerProperties.class); + assertThat(properties.getName()).isEqualTo("mcp-server"); + assertThat(properties.getVersion()).isEqualTo("1.0.0"); + assertThat(properties.getType()).isEqualTo(McpStatelessServerProperties.ServerType.SYNC); + assertThat(properties.getRequestTimeout().getSeconds()).isEqualTo(20); + assertThat(properties.getMcpEndpoint()).isEqualTo("/mcp"); + + // Check capabilities + assertThat(properties.getCapabilities().isTool()).isTrue(); + assertThat(properties.getCapabilities().isResource()).isTrue(); + assertThat(properties.getCapabilities().isPrompt()).isTrue(); + assertThat(properties.getCapabilities().isCompletion()).isTrue(); + }); + } + + @Test + void asyncConfiguration() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.stateless.type=ASYNC", + "spring.ai.mcp.server.stateless.name=test-server", "spring.ai.mcp.server.stateless.version=2.0.0", + "spring.ai.mcp.server.stateless.instructions=My MCP Server", + "spring.ai.mcp.server.stateless.request-timeout=30s") + .run(context -> { + assertThat(context).hasSingleBean(McpStatelessAsyncServer.class); + assertThat(context).doesNotHaveBean(McpStatelessSyncServer.class); + + McpStatelessServerProperties properties = context.getBean(McpStatelessServerProperties.class); + assertThat(properties.getName()).isEqualTo("test-server"); + assertThat(properties.getVersion()).isEqualTo("2.0.0"); + assertThat(properties.getInstructions()).isEqualTo("My MCP Server"); + assertThat(properties.getType()).isEqualTo(McpStatelessServerProperties.ServerType.ASYNC); + assertThat(properties.getRequestTimeout().getSeconds()).isEqualTo(30); + }); + } + + @Test + void syncToolCallbackRegistrationControl() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.stateless.type=SYNC", + "spring.ai.mcp.server.stateless.tool-callback-converter=true") + .run(context -> { + assertThat(context).hasBean("syncTools"); + }); + + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.stateless.type=SYNC", + "spring.ai.mcp.server.stateless.tool-callback-converter=false") + .run(context -> { + assertThat(context).doesNotHaveBean("syncTools"); + }); + } + + @Test + void asyncToolCallbackRegistrationControl() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.stateless.type=ASYNC", + "spring.ai.mcp.server.stateless.tool-callback-converter=true") + .run(context -> { + assertThat(context).hasBean("asyncTools"); + }); + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.stateless.type=ASYNC", + "spring.ai.mcp.server.stateless.tool-callback-converter=false") + .run(context -> { + assertThat(context).doesNotHaveBean("asyncTools"); + }); + } + + @Test + void syncServerInstructionsConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.stateless.instructions=Sync Server Instructions") + .run(context -> { + McpStatelessServerProperties properties = context.getBean(McpStatelessServerProperties.class); + assertThat(properties.getInstructions()).isEqualTo("Sync Server Instructions"); + + McpStatelessSyncServer server = context.getBean(McpStatelessSyncServer.class); + assertThat(server).isNotNull(); + }); + } + + @Test + void disabledConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.stateless.enabled=false").run(context -> { + assertThat(context).doesNotHaveBean(McpStatelessSyncServer.class); + assertThat(context).doesNotHaveBean(McpStatelessAsyncServer.class); + assertThat(context).doesNotHaveBean(McpStatelessServerTransport.class); + }); + } + + @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 toolSpecificationConfiguration() { + this.contextRunner.withUserConfiguration(TestToolConfiguration.class).run(context -> { + List tools = context.getBean("syncTools", List.class); + assertThat(tools).hasSize(1); + }); + } + + @Test + void resourceSpecificationConfiguration() { + this.contextRunner.withUserConfiguration(TestResourceConfiguration.class).run(context -> { + McpStatelessSyncServer server = context.getBean(McpStatelessSyncServer.class); + assertThat(server).isNotNull(); + }); + } + + @Test + void promptSpecificationConfiguration() { + this.contextRunner.withUserConfiguration(TestPromptConfiguration.class).run(context -> { + McpStatelessSyncServer server = context.getBean(McpStatelessSyncServer.class); + assertThat(server).isNotNull(); + }); + } + + @Test + void asyncToolSpecificationConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.stateless.type=ASYNC") + .withUserConfiguration(TestToolConfiguration.class) + .run(context -> { + List tools = context.getBean("asyncTools", List.class); + assertThat(tools).hasSize(1); + }); + } + + @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 rootsChangeHandlerConfiguration() { + this.contextRunner.withUserConfiguration(TestRootsHandlerConfiguration.class).run(context -> { + McpStatelessSyncServer server = context.getBean(McpStatelessSyncServer.class); + assertThat(server).isNotNull(); + }); + } + + @Test + void asyncRootsChangeHandlerConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.stateless.type=ASYNC") + .withUserConfiguration(TestAsyncRootsHandlerConfiguration.class) + .run(context -> { + McpStatelessAsyncServer server = context.getBean(McpStatelessAsyncServer.class); + assertThat(server).isNotNull(); + }); + } + + @Test + void capabilitiesConfiguration() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.stateless.capabilities.tool=false", + "spring.ai.mcp.server.stateless.capabilities.resource=false", + "spring.ai.mcp.server.stateless.capabilities.prompt=false", + "spring.ai.mcp.server.stateless.capabilities.completion=false") + .run(context -> { + McpStatelessServerProperties properties = context.getBean(McpStatelessServerProperties.class); + assertThat(properties.getCapabilities().isTool()).isFalse(); + assertThat(properties.getCapabilities().isResource()).isFalse(); + assertThat(properties.getCapabilities().isPrompt()).isFalse(); + assertThat(properties.getCapabilities().isCompletion()).isFalse(); + + // Verify the server is configured with the disabled capabilities + McpStatelessSyncServer server = context.getBean(McpStatelessSyncServer.class); + assertThat(server).isNotNull(); + }); + } + + @Test + void toolResponseMimeTypeConfiguration() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.stateless.tool-response-mime-type.test-tool=application/json") + .withUserConfiguration(TestToolConfiguration.class) + .run(context -> { + McpStatelessServerProperties properties = context.getBean(McpStatelessServerProperties.class); + assertThat(properties.getToolResponseMimeType()).containsEntry("test-tool", "application/json"); + + // Verify the MIME type is applied to the tool specifications + List tools = context.getBean("syncTools", List.class); + assertThat(tools).hasSize(1); + + // The server should be properly configured with the tool + McpStatelessSyncServer server = context.getBean(McpStatelessSyncServer.class); + assertThat(server).isNotNull(); + }); + } + + @Test + void requestTimeoutConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.stateless.request-timeout=45s").run(context -> { + McpStatelessServerProperties properties = context.getBean(McpStatelessServerProperties.class); + assertThat(properties.getRequestTimeout().getSeconds()).isEqualTo(45); + + // Verify the server is configured with the timeout + McpStatelessSyncServer server = context.getBean(McpStatelessSyncServer.class); + assertThat(server).isNotNull(); + }); + } + + @Test + void endpointConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.stateless.mcp-endpoint=/my-mcp").run(context -> { + McpStatelessServerProperties properties = context.getBean(McpStatelessServerProperties.class); + assertThat(properties.getMcpEndpoint()).isEqualTo("/my-mcp"); + + // Verify the server is configured with the endpoints + McpStatelessSyncServer server = context.getBean(McpStatelessSyncServer.class); + assertThat(server).isNotNull(); + }); + } + + @Test + void completionSpecificationConfiguration() { + this.contextRunner.withUserConfiguration(TestCompletionConfiguration.class).run(context -> { + List completions = context.getBean("testCompletions", List.class); + assertThat(completions).hasSize(1); + }); + } + + @Test + void asyncCompletionSpecificationConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.type=ASYNC") + .withUserConfiguration(TestAsyncCompletionConfiguration.class) + .run(context -> { + List completions = context.getBean("testAsyncCompletions", List.class); + assertThat(completions).hasSize(1); + }); + } + + @Test + void toolCallbackProviderConfiguration() { + this.contextRunner.withUserConfiguration(TestToolCallbackProviderConfiguration.class) + .run(context -> assertThat(context).hasSingleBean(ToolCallbackProvider.class)); + } + + @Configuration + static class TestResourceConfiguration { + + @Bean + List testResources() { + return List.of(); + } + + } + + @Configuration + static class TestPromptConfiguration { + + @Bean + List 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 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 TestToolCallbackProviderConfiguration { + + @Bean + ToolCallbackProvider testToolCallbackProvider() { + return () -> { + McpSyncClient mockClient = Mockito.mock(McpSyncClient.class); + McpSchema.Tool mockTool = Mockito.mock(McpSchema.Tool.class); + + Mockito.when(mockTool.name()).thenReturn("provider-tool"); + Mockito.when(mockTool.description()).thenReturn("Provider Tool"); + when(mockClient.getClientInfo()).thenReturn(new McpSchema.Implementation("testClient", "1.0.0")); + + return new ToolCallback[] { new SyncMcpToolCallback(mockClient, mockTool) }; + }; + } + + } + + @Configuration + static class TestCompletionConfiguration { + + @Bean + List testCompletions() { + + BiFunction completionHandler = ( + context, request) -> new McpSchema.CompleteResult( + new McpSchema.CompleteResult.CompleteCompletion(List.of(), 0, false)); + + return List.of(new McpStatelessServerFeatures.SyncCompletionSpecification( + new McpSchema.PromptReference("ref/prompt", "code_review", "Code review"), completionHandler)); + } + + } + + @Configuration + static class TestAsyncCompletionConfiguration { + + @Bean + List testAsyncCompletions() { + BiFunction > completionHandler = ( + context, request) -> Mono.just(new McpSchema.CompleteResult( + new McpSchema.CompleteResult.CompleteCompletion(List.of(), 0, false))); + + return List.of(new McpStatelessServerFeatures.AsyncCompletionSpecification( + new McpSchema.PromptReference("ref/prompt", "code_review", "Code review"), completionHandler)); + } + + } + + @Configuration + static class TestRootsHandlerConfiguration { + + @Bean + BiConsumer > rootsChangeHandler() { + return (context, roots) -> { + // Test implementation + }; + } + + } + + @Configuration + static class TestAsyncRootsHandlerConfiguration { + + @Bean + BiConsumer > rootsChangeHandler() { + return (context, roots) -> { + // Test implementation + }; + } + + } + + @Configuration + static class TestStatelessTransportConfiguration { + + @Bean + @ConditionalOnProperty(prefix = McpStatelessServerProperties.CONFIG_PREFIX, name = "enabled", + havingValue = "true", matchIfMissing = true) + public McpStatelessServerTransport statelessTransport() { + return Mockito.mock(McpStatelessServerTransport.class); + } + + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common/src/test/java/org/springframework/ai/mcp/server/stateless/autoconfigure/ToolCallbackConverterAutoConfigurationIT.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common/src/test/java/org/springframework/ai/mcp/server/stateless/autoconfigure/ToolCallbackConverterAutoConfigurationIT.java new file mode 100644 index 00000000000..f3f14a245ca --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common/src/test/java/org/springframework/ai/mcp/server/stateless/autoconfigure/ToolCallbackConverterAutoConfigurationIT.java @@ -0,0 +1,302 @@ +/* + * 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.stateless.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.ai.mcp.SyncMcpToolCallback; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.ToolCallbackProvider; +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 io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.server.McpStatelessServerFeatures.AsyncToolSpecification; +import io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification; +import io.modelcontextprotocol.spec.McpSchema; + +/** + * Integration tests for {@link ToolCallbackConverterAutoConfiguration} and + * {@link ToolCallbackConverterCondition}. + * + * @author Christian Tzolov + */ +public class ToolCallbackConverterAutoConfigurationIT { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ToolCallbackConverterAutoConfiguration.class)) + .withPropertyValues("spring.ai.mcp.server.stateless.enabled=true"); + + @Test + void defaultSyncToolsConfiguration() { + this.contextRunner.withUserConfiguration(TestToolConfiguration.class).run(context -> { + assertThat(context).hasSingleBean(ToolCallbackConverterAutoConfiguration.class); + assertThat(context).hasBean("syncTools"); + + @SuppressWarnings("unchecked") + List syncTools = (List ) context.getBean("syncTools"); + assertThat(syncTools).hasSize(1); + assertThat(syncTools.get(0)).isNotNull(); + }); + } + + @Test + void asyncToolsConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.stateless.type=ASYNC") + .withUserConfiguration(TestToolConfiguration.class) + .run(context -> { + assertThat(context).hasSingleBean(ToolCallbackConverterAutoConfiguration.class); + assertThat(context).hasBean("asyncTools"); + assertThat(context).doesNotHaveBean("syncTools"); + + @SuppressWarnings("unchecked") + List asyncTools = (List ) context.getBean("asyncTools"); + assertThat(asyncTools).hasSize(1); + assertThat(asyncTools.get(0)).isNotNull(); + }); + } + + @Test + void toolCallbackProviderConfiguration() { + this.contextRunner.withUserConfiguration(TestToolCallbackProviderConfiguration.class).run(context -> { + assertThat(context).hasSingleBean(ToolCallbackConverterAutoConfiguration.class); + assertThat(context).hasBean("syncTools"); + + @SuppressWarnings("unchecked") + List syncTools = (List ) context.getBean("syncTools"); + assertThat(syncTools).hasSize(1); + }); + } + + @Test + void multipleToolCallbacksConfiguration() { + this.contextRunner.withUserConfiguration(TestMultipleToolsConfiguration.class).run(context -> { + assertThat(context).hasSingleBean(ToolCallbackConverterAutoConfiguration.class); + assertThat(context).hasBean("syncTools"); + + @SuppressWarnings("unchecked") + List syncTools = (List ) context.getBean("syncTools"); + assertThat(syncTools).hasSize(2); + }); + } + + @Test + void toolResponseMimeTypeConfiguration() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.stateless.tool-response-mime-type.test-tool=application/json") + .withUserConfiguration(TestToolConfiguration.class) + .run(context -> { + assertThat(context).hasSingleBean(ToolCallbackConverterAutoConfiguration.class); + assertThat(context).hasBean("syncTools"); + + @SuppressWarnings("unchecked") + List syncTools = (List ) context.getBean("syncTools"); + assertThat(syncTools).hasSize(1); + + McpStatelessServerProperties properties = context.getBean(McpStatelessServerProperties.class); + assertThat(properties.getToolResponseMimeType()).containsEntry("test-tool", "application/json"); + }); + } + + @Test + void duplicateToolNamesDeduplication() { + this.contextRunner.withUserConfiguration(TestDuplicateToolsConfiguration.class).run(context -> { + assertThat(context).hasSingleBean(ToolCallbackConverterAutoConfiguration.class); + assertThat(context).hasBean("syncTools"); + + @SuppressWarnings("unchecked") + List syncTools = (List ) context.getBean("syncTools"); + // Tools have different client prefixes, so both should be present + assertThat(syncTools).hasSize(2); + }); + } + + @Test + void conditionDisabledWhenServerDisabled() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.stateless.enabled=false") + .withUserConfiguration(TestToolConfiguration.class) + .run(context -> { + assertThat(context).doesNotHaveBean(ToolCallbackConverterAutoConfiguration.class); + assertThat(context).doesNotHaveBean("syncTools"); + assertThat(context).doesNotHaveBean("asyncTools"); + }); + } + + @Test + void conditionDisabledWhenToolCallbackConvertDisabled() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.stateless.tool-callback-converter=false") + .withUserConfiguration(TestToolConfiguration.class) + .run(context -> { + assertThat(context).doesNotHaveBean(ToolCallbackConverterAutoConfiguration.class); + assertThat(context).doesNotHaveBean("syncTools"); + assertThat(context).doesNotHaveBean("asyncTools"); + }); + } + + @Test + void conditionEnabledByDefault() { + this.contextRunner.withUserConfiguration(TestToolConfiguration.class).run(context -> { + assertThat(context).hasSingleBean(ToolCallbackConverterAutoConfiguration.class); + assertThat(context).hasBean("syncTools"); + }); + } + + @Test + void conditionEnabledExplicitly() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.stateless.enabled=true", + "spring.ai.mcp.server.stateless.tool-callback-converter=true") + .withUserConfiguration(TestToolConfiguration.class) + .run(context -> { + assertThat(context).hasSingleBean(ToolCallbackConverterAutoConfiguration.class); + assertThat(context).hasBean("syncTools"); + }); + } + + @Test + void emptyToolCallbacksConfiguration() { + this.contextRunner.run(context -> { + assertThat(context).hasSingleBean(ToolCallbackConverterAutoConfiguration.class); + assertThat(context).hasBean("syncTools"); + + @SuppressWarnings("unchecked") + List syncTools = (List ) context.getBean("syncTools"); + assertThat(syncTools).isEmpty(); + }); + } + + @Test + void mixedToolCallbacksAndProvidersConfiguration() { + this.contextRunner + .withUserConfiguration(TestToolConfiguration.class, TestToolCallbackProviderConfiguration.class) + .run(context -> { + assertThat(context).hasSingleBean(ToolCallbackConverterAutoConfiguration.class); + assertThat(context).hasBean("syncTools"); + + @SuppressWarnings("unchecked") + List syncTools = (List ) context.getBean("syncTools"); + assertThat(syncTools).hasSize(2); // One from direct callback, one from + // provider + }); + } + + @Configuration + static class TestToolConfiguration { + + @Bean + List testToolCallbacks() { + 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 TestMultipleToolsConfiguration { + + @Bean + List testMultipleToolCallbacks() { + McpSyncClient mockClient1 = Mockito.mock(McpSyncClient.class); + McpSchema.Tool mockTool1 = Mockito.mock(McpSchema.Tool.class); + McpSchema.CallToolResult mockResult1 = Mockito.mock(McpSchema.CallToolResult.class); + + Mockito.when(mockTool1.name()).thenReturn("test-tool-1"); + Mockito.when(mockTool1.description()).thenReturn("Test Tool 1"); + Mockito.when(mockClient1.callTool(Mockito.any(McpSchema.CallToolRequest.class))).thenReturn(mockResult1); + when(mockClient1.getClientInfo()).thenReturn(new McpSchema.Implementation("testClient1", "1.0.0")); + + McpSyncClient mockClient2 = Mockito.mock(McpSyncClient.class); + McpSchema.Tool mockTool2 = Mockito.mock(McpSchema.Tool.class); + McpSchema.CallToolResult mockResult2 = Mockito.mock(McpSchema.CallToolResult.class); + + Mockito.when(mockTool2.name()).thenReturn("test-tool-2"); + Mockito.when(mockTool2.description()).thenReturn("Test Tool 2"); + Mockito.when(mockClient2.callTool(Mockito.any(McpSchema.CallToolRequest.class))).thenReturn(mockResult2); + when(mockClient2.getClientInfo()).thenReturn(new McpSchema.Implementation("testClient2", "1.0.0")); + + return List.of(new SyncMcpToolCallback(mockClient1, mockTool1), + new SyncMcpToolCallback(mockClient2, mockTool2)); + } + + } + + @Configuration + static class TestDuplicateToolsConfiguration { + + @Bean + List testDuplicateToolCallbacks() { + McpSyncClient mockClient1 = Mockito.mock(McpSyncClient.class); + McpSchema.Tool mockTool1 = Mockito.mock(McpSchema.Tool.class); + McpSchema.CallToolResult mockResult1 = Mockito.mock(McpSchema.CallToolResult.class); + + Mockito.when(mockTool1.name()).thenReturn("duplicate-tool"); + Mockito.when(mockTool1.description()).thenReturn("First Tool"); + Mockito.when(mockClient1.callTool(Mockito.any(McpSchema.CallToolRequest.class))).thenReturn(mockResult1); + when(mockClient1.getClientInfo()).thenReturn(new McpSchema.Implementation("testClient1", "1.0.0")); + + McpSyncClient mockClient2 = Mockito.mock(McpSyncClient.class); + McpSchema.Tool mockTool2 = Mockito.mock(McpSchema.Tool.class); + McpSchema.CallToolResult mockResult2 = Mockito.mock(McpSchema.CallToolResult.class); + + Mockito.when(mockTool2.name()).thenReturn("duplicate-tool"); + Mockito.when(mockTool2.description()).thenReturn("Second Tool"); + Mockito.when(mockClient2.callTool(Mockito.any(McpSchema.CallToolRequest.class))).thenReturn(mockResult2); + when(mockClient2.getClientInfo()).thenReturn(new McpSchema.Implementation("testClient2", "1.0.0")); + + return List.of(new SyncMcpToolCallback(mockClient1, mockTool1), + new SyncMcpToolCallback(mockClient2, mockTool2)); + } + + } + + @Configuration + static class TestToolCallbackProviderConfiguration { + + @Bean + ToolCallbackProvider testToolCallbackProvider() { + return () -> { + 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("provider-tool"); + Mockito.when(mockTool.description()).thenReturn("Provider Tool"); + Mockito.when(mockClient.callTool(Mockito.any(McpSchema.CallToolRequest.class))).thenReturn(mockResult); + when(mockClient.getClientInfo()).thenReturn(new McpSchema.Implementation("testClient", "1.0.0")); + + return new ToolCallback[] { new SyncMcpToolCallback(mockClient, mockTool) }; + }; + } + + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webflux/pom.xml b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webflux/pom.xml new file mode 100644 index 00000000000..b9bd3fe530a --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webflux/pom.xml @@ -0,0 +1,100 @@ + + + diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webflux/src/main/java/org/springframework/ai/mcp/server/stateless/webflux/autoconfigure/McpStatelessServerWebFluxAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webflux/src/main/java/org/springframework/ai/mcp/server/stateless/webflux/autoconfigure/McpStatelessServerWebFluxAutoConfiguration.java new file mode 100644 index 00000000000..7d10b2bf937 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webflux/src/main/java/org/springframework/ai/mcp/server/stateless/webflux/autoconfigure/McpStatelessServerWebFluxAutoConfiguration.java @@ -0,0 +1,68 @@ +/* + * 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.stateless.webflux.autoconfigure; + +import org.springframework.ai.mcp.server.stateless.autoconfigure.McpStatelessServerProperties; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +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.core.log.LogAccessor; +import org.springframework.web.reactive.function.server.RouterFunction; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.modelcontextprotocol.server.McpStatelessSyncServer; +import io.modelcontextprotocol.server.transport.WebFluxStatelessServerTransport; +import io.modelcontextprotocol.spec.McpSchema; + +/** + * @author Christian Tzolov + */ +@ConditionalOnClass({ McpSchema.class, McpStatelessSyncServer.class }) +@EnableConfigurationProperties(McpStatelessServerProperties.class) +@ConditionalOnProperty(prefix = McpStatelessServerProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) +public class McpStatelessServerWebFluxAutoConfiguration { + + private static final LogAccessor logger = new LogAccessor(McpStatelessServerWebFluxAutoConfiguration.class); + + @Bean + @ConditionalOnMissingBean + public WebFluxStatelessServerTransport webFluxStatelessServerTransport( + ObjectProvider4.0.0 ++ +org.springframework.ai +spring-ai-parent +1.1.0-SNAPSHOT +../../../pom.xml +spring-ai-autoconfigure-mcp-stateless-server-webflux +jar +Spring AI MCP Stateless Server WebFlux Auto Configuration +Spring AI MCP Stateless Server WebFlux Auto Configuration +https://github.com/spring-projects/spring-ai + ++ + +https://github.com/spring-projects/spring-ai +git://github.com/spring-projects/spring-ai.git +git@github.com:spring-projects/spring-ai.git ++ + + ++ + +org.springframework.boot +spring-boot-starter ++ + +org.springframework.ai +spring-ai-mcp +${project.parent.version} +true ++ + +org.springframework.ai +spring-ai-autoconfigure-mcp-stateless-server-common +${project.parent.version} ++ + +io.modelcontextprotocol.sdk +mcp-spring-webflux +true ++ + +org.springframework.boot +spring-boot-configuration-processor +true ++ + + + +org.springframework.boot +spring-boot-autoconfigure-processor +true ++ + +org.springframework.ai +spring-ai-test +${project.parent.version} +test ++ + +org.springframework.ai +spring-ai-autoconfigure-mcp-client-webflux +${project.parent.version} +test ++ + +org.springframework.boot +spring-boot-starter-webflux +test ++ + +org.springframework.boot +spring-boot-starter-test +test ++ + + +net.javacrumbs.json-unit +json-unit-assertj +${json-unit-assertj.version} +test +objectMapperProvider, McpStatelessServerProperties serverProperties) { + + ObjectMapper objectMapper = objectMapperProvider.getIfAvailable(ObjectMapper::new); + + return WebFluxStatelessServerTransport.builder() + .objectMapper(objectMapper) + .messageEndpoint(serverProperties.getMcpEndpoint()) + // .disallowDelete(serverProperties.isDisallowDelete()) + .build(); + } + + // Router function for stateless http transport used by Spring WebFlux to start an + // HTTP server. + @Bean + public RouterFunction> webFluxStatelessServerRouterFunction( + WebFluxStatelessServerTransport webFluxStatelessTransport) { + return webFluxStatelessTransport.getRouterFunction(); + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webflux/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webflux/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..d2650c31550 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webflux/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,17 @@ +# +# 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. +# +org.springframework.ai.mcp.server.stateless.webflux.autoconfigure.McpStatelessServerWebFluxAutoConfiguration + diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webflux/src/test/java/org/springframework/ai/mcp/server/stateless/webflux/autoconfigure/McpStatelessServerWebFluxAutoConfigurationIT.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webflux/src/test/java/org/springframework/ai/mcp/server/stateless/webflux/autoconfigure/McpStatelessServerWebFluxAutoConfigurationIT.java new file mode 100644 index 00000000000..d28f07bafdb --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webflux/src/test/java/org/springframework/ai/mcp/server/stateless/webflux/autoconfigure/McpStatelessServerWebFluxAutoConfigurationIT.java @@ -0,0 +1,175 @@ +/* + * 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.stateless.webflux.autoconfigure; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.modelcontextprotocol.server.transport.WebFluxStatelessServerTransport; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.web.reactive.function.server.RouterFunction; + +import static org.assertj.core.api.Assertions.assertThat; + +class McpStatelessServerWebFluxAutoConfigurationIT { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(McpStatelessServerWebFluxAutoConfiguration.class)); + + @Test + void defaultConfiguration() { + this.contextRunner.run(context -> { + assertThat(context).hasSingleBean(WebFluxStatelessServerTransport.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void objectMapperConfiguration() { + this.contextRunner.withBean(ObjectMapper.class, ObjectMapper::new).run(context -> { + assertThat(context).hasSingleBean(WebFluxStatelessServerTransport.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void serverDisableConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.stateless.enabled=false").run(context -> { + assertThat(context).doesNotHaveBean(WebFluxStatelessServerTransport.class); + assertThat(context).doesNotHaveBean(RouterFunction.class); + }); + } + + @Test + void serverBaseUrlConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.stateless.mcpEndpoint=/test") + .run(context -> assertThat(context.getBean(WebFluxStatelessServerTransport.class)).extracting("mcpEndpoint") + .isEqualTo("/test")); + } + + @Test + void keepAliveIntervalConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.keep-alive-interval=PT30S") + .run(context -> { + assertThat(context).hasSingleBean(WebFluxStatelessServerTransport.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void disallowDeleteConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.disallow-delete=true") + .run(context -> { + assertThat(context).hasSingleBean(WebFluxStatelessServerTransport.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void disallowDeleteFalseConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.disallow-delete=false") + .run(context -> { + assertThat(context).hasSingleBean(WebFluxStatelessServerTransport.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void customObjectMapperIsUsed() { + ObjectMapper customObjectMapper = new ObjectMapper(); + this.contextRunner.withBean("customObjectMapper", ObjectMapper.class, () -> customObjectMapper).run(context -> { + assertThat(context).hasSingleBean(WebFluxStatelessServerTransport.class); + assertThat(context).hasSingleBean(RouterFunction.class); + // Verify the custom ObjectMapper is used + assertThat(context.getBean(ObjectMapper.class)).isSameAs(customObjectMapper); + }); + } + + @Test + void conditionalOnClassPresent() { + this.contextRunner.run(context -> { + // Verify that the configuration is loaded when required classes are present + assertThat(context).hasSingleBean(WebFluxStatelessServerTransport.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void conditionalOnMissingBeanWorks() { + // Test that @ConditionalOnMissingBean works by providing a custom bean + this.contextRunner + .withBean("customWebFluxProvider", WebFluxStatelessServerTransport.class, + () -> WebFluxStatelessServerTransport.builder() + .objectMapper(new ObjectMapper()) + .messageEndpoint("/custom") + .build()) + .run(context -> { + assertThat(context).hasSingleBean(WebFluxStatelessServerTransport.class); + // Should use the custom bean, not create a new one + WebFluxStatelessServerTransport provider = context.getBean(WebFluxStatelessServerTransport.class); + assertThat(provider).extracting("mcpEndpoint").isEqualTo("/custom"); + }); + } + + @Test + void routerFunctionIsCreatedFromProvider() { + this.contextRunner.run(context -> { + assertThat(context).hasSingleBean(RouterFunction.class); + assertThat(context).hasSingleBean(WebFluxStatelessServerTransport.class); + + // Verify that the RouterFunction is created from the provider + RouterFunction> routerFunction = context.getBean(RouterFunction.class); + assertThat(routerFunction).isNotNull(); + }); + } + + @Test + void allPropertiesConfiguration() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.stateless.mcpEndpoint=/custom-endpoint", + "spring.ai.mcp.server.stateless.disallow-delete=true") + .run(context -> { + WebFluxStatelessServerTransport provider = context.getBean(WebFluxStatelessServerTransport.class); + assertThat(provider).extracting("mcpEndpoint").isEqualTo("/custom-endpoint"); + // Verify beans are created successfully with all properties + assertThat(context).hasSingleBean(WebFluxStatelessServerTransport.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void enabledPropertyDefaultsToTrue() { + // Test that when enabled property is not set, it defaults to true (matchIfMissing + // = true) + this.contextRunner.run(context -> { + assertThat(context).hasSingleBean(WebFluxStatelessServerTransport.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void enabledPropertyExplicitlyTrue() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.enabled=true").run(context -> { + assertThat(context).hasSingleBean(WebFluxStatelessServerTransport.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webflux/src/test/java/org/springframework/ai/mcp/server/stateless/webflux/autoconfigure/StatelessWebClientAndWebFluxServerIT.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webflux/src/test/java/org/springframework/ai/mcp/server/stateless/webflux/autoconfigure/StatelessWebClientAndWebFluxServerIT.java new file mode 100644 index 00000000000..465b56e441c --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webflux/src/test/java/org/springframework/ai/mcp/server/stateless/webflux/autoconfigure/StatelessWebClientAndWebFluxServerIT.java @@ -0,0 +1,364 @@ +/* + * 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.stateless.webflux.autoconfigure; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration; +import org.springframework.ai.mcp.client.common.autoconfigure.McpToolCallbackAutoConfiguration; +import org.springframework.ai.mcp.client.webflux.autoconfigure.StreamableHttpWebFluxTransportAutoConfiguration; +import org.springframework.ai.mcp.customizer.McpSyncClientCustomizer; +import org.springframework.ai.mcp.server.stateless.autoconfigure.McpStatelessServerAutoConfiguration; +import org.springframework.ai.mcp.server.stateless.autoconfigure.McpStatelessServerProperties; +import org.springframework.ai.mcp.server.stateless.autoconfigure.ToolCallbackConverterAutoConfiguration; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.core.ResolvableType; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; +import org.springframework.test.util.TestSocketUtils; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.server.McpStatelessServerFeatures; +import io.modelcontextprotocol.server.McpStatelessSyncServer; +import io.modelcontextprotocol.server.transport.WebFluxStatelessServerTransport; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.CompleteRequest; +import io.modelcontextprotocol.spec.McpSchema.CompleteResult; +import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; +import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; +import io.modelcontextprotocol.spec.McpSchema.PromptArgument; +import io.modelcontextprotocol.spec.McpSchema.PromptMessage; +import io.modelcontextprotocol.spec.McpSchema.PromptReference; +import io.modelcontextprotocol.spec.McpSchema.Resource; +import io.modelcontextprotocol.spec.McpSchema.Role; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import io.modelcontextprotocol.spec.McpSchema.Tool; +import net.javacrumbs.jsonunit.core.Option; +import reactor.netty.DisposableServer; +import reactor.netty.http.server.HttpServer; + +public class StatelessWebClientAndWebFluxServerIT { + + private final ApplicationContextRunner serverContextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(McpStatelessServerAutoConfiguration.class, + ToolCallbackConverterAutoConfiguration.class, McpStatelessServerWebFluxAutoConfiguration.class)); + + private final ApplicationContextRunner clientApplicationContext = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(McpToolCallbackAutoConfiguration.class, + McpClientAutoConfiguration.class, StreamableHttpWebFluxTransportAutoConfiguration.class)); + + @Test + void clientServerCapabilities() { + + int serverPort = TestSocketUtils.findAvailableTcpPort(); + + this.serverContextRunner.withUserConfiguration(TestMcpServerConfiguration.class) + .withPropertyValues(// @formatter:off + "spring.ai.mcp.server.stateless.mcp-endpoint=/mcp", + "spring.ai.mcp.server.stateless.name=test-mcp-server", + "spring.ai.mcp.server.stateless.keep-alive-interval=1s", + "spring.ai.mcp.server.stateless.version=1.0.0") // @formatter:on + .run(serverContext -> { + // Verify all required beans are present + assertThat(serverContext).hasSingleBean(WebFluxStatelessServerTransport.class); + assertThat(serverContext).hasSingleBean(RouterFunction.class); + assertThat(serverContext).hasSingleBean(McpStatelessSyncServer.class); + + // Verify server properties are configured correctly + McpStatelessServerProperties properties = serverContext.getBean(McpStatelessServerProperties.class); + assertThat(properties.getName()).isEqualTo("test-mcp-server"); + assertThat(properties.getVersion()).isEqualTo("1.0.0"); + assertThat(properties.getMcpEndpoint()).isEqualTo("/mcp"); + + var httpServer = startHttpServer(serverContext, serverPort); + + clientApplicationContext.withUserConfiguration(TestMcpClientConfiguration.class) + .withPropertyValues(// @formatter:off + "spring.ai.mcp.client.streamable-http.connections.server1.url=http://localhost:" + serverPort, + "spring.ai.mcp.client.initialized=false") // @formatter:on + .run(clientContext -> { + McpSyncClient mcpClient = getMcpSyncClient(clientContext); + assertThat(mcpClient).isNotNull(); + var initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + // TOOLS / SAMPLING / ELICITATION + + // tool list + assertThat(mcpClient.listTools().tools()).hasSize(2); + assertThat(mcpClient.listTools().tools()) + .contains(Tool.builder().name("tool1").description("tool1 description").inputSchema(""" + { + "": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + } + """).build()); + + // Call a tool that sends progress notifications + CallToolRequest toolRequest = CallToolRequest.builder() + .name("tool1") + .arguments(Map.of()) + .progressToken("test-progress-token") + .build(); + + CallToolResult response = mcpClient.callTool(toolRequest); + + assertThat(response).isNotNull(); + assertThat(response.isError()).isNull(); + String responseText = ((TextContent) response.content().get(0)).text(); + assertThat(responseText).contains("CALL RESPONSE"); + + // TOOL STRUCTURED OUTPUT + // Call tool with valid structured output + CallToolResult calculatorToolResponse = mcpClient + .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3"))); + + assertThat(calculatorToolResponse).isNotNull(); + assertThat(calculatorToolResponse.isError()).isFalse(); + + assertThat(calculatorToolResponse.structuredContent()).isNotNull(); + + assertThat(calculatorToolResponse.structuredContent()).containsEntry("result", 5.0) + .containsEntry("operation", "2 + 3") + .containsEntry("timestamp", "2024-01-01T10:00:00Z"); + + assertThatJson(calculatorToolResponse.structuredContent()).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + {"result":5.0,"operation":"2 + 3","timestamp":"2024-01-01T10:00:00Z"}""")); + + // PROMPT / COMPLETION + + // list prompts + assertThat(mcpClient.listPrompts()).isNotNull(); + assertThat(mcpClient.listPrompts().prompts()).hasSize(1); + + // get prompt + GetPromptResult promptResult = mcpClient + .getPrompt(new GetPromptRequest("code-completion", Map.of("language", "java"))); + assertThat(promptResult).isNotNull(); + + // completion + CompleteRequest completeRequest = new CompleteRequest( + new PromptReference("ref/prompt", "code-completion", "Code completion"), + new CompleteRequest.CompleteArgument("language", "py")); + + CompleteResult completeResult = mcpClient.completeCompletion(completeRequest); + + assertThat(completeResult).isNotNull(); + assertThat(completeResult.completion().total()).isEqualTo(10); + assertThat(completeResult.completion().values()).containsExactly("python", "pytorch", "pyside"); + assertThat(completeResult.meta()).isNull(); + + // RESOURCES + assertThat(mcpClient.listResources()).isNotNull(); + assertThat(mcpClient.listResources().resources()).hasSize(1); + assertThat(mcpClient.listResources().resources().get(0)) + .isEqualToComparingFieldByFieldRecursively(Resource.builder() + .uri("file://resource") + .name("Test Resource") + .mimeType("text/plain") + .description("Test resource description") + .build()); + + }); + + stopHttpServer(httpServer); + }); + } + + public static class TestMcpServerConfiguration { + + @Bean + public List myTools() { + + // Tool 1 + McpStatelessServerFeatures.SyncToolSpecification tool1 = McpStatelessServerFeatures.SyncToolSpecification + .builder() + .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(""" + { + "": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + } + """).build()) + .callHandler((exchange, request) -> { + + return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null); + }) + .build(); + + // Tool 2 + + // Create a tool with output schema + Map outputSchema = Map.of( + "type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation", + Map.of("type", "string"), "timestamp", Map.of("type", "string")), + "required", List.of("result", "operation")); + + Tool calculatorTool = Tool.builder() + .name("calculator") + .description("Performs mathematical calculations") + .outputSchema(outputSchema) + .build(); + + McpStatelessServerFeatures.SyncToolSpecification tool2 = McpStatelessServerFeatures.SyncToolSpecification + .builder() + .tool(calculatorTool) + .callHandler((exchange, request) -> { + String expression = (String) request.arguments().getOrDefault("expression", "2 + 3"); + double result = this.evaluateExpression(expression); + return CallToolResult.builder() + .structuredContent( + Map.of("result", result, "operation", expression, "timestamp", "2024-01-01T10:00:00Z")) + .build(); + }) + .build(); + + return List.of(tool1, tool2); + } + + @Bean + public List myPrompts() { + + var prompt = new McpSchema.Prompt("code-completion", "Code completion", "this is code review prompt", + List.of(new PromptArgument("language", "Language", "string", false))); + + var promptSpecification = new McpStatelessServerFeatures.SyncPromptSpecification(prompt, + (exchange, getPromptRequest) -> { + String languageArgument = (String) getPromptRequest.arguments().get("language"); + if (languageArgument == null) { + languageArgument = "java"; + } + + var userMessage = new PromptMessage(Role.USER, + new TextContent("Hello " + languageArgument + "! How can I assist you today?")); + return new GetPromptResult("A personalized greeting message", List.of(userMessage)); + }); + + return List.of(promptSpecification); + } + + @Bean + public List myCompletions() { + var completion = new McpStatelessServerFeatures.SyncCompletionSpecification( + new McpSchema.PromptReference("ref/prompt", "code-completion", "Code completion"), + (exchange, request) -> { + var expectedValues = List.of("python", "pytorch", "pyside"); + return new McpSchema.CompleteResult(new CompleteResult.CompleteCompletion(expectedValues, 10, // total + true // hasMore + )); + }); + + return List.of(completion); + } + + @Bean + public List myResources() { + + var systemInfoResource = Resource.builder() + .uri("file://resource") + .name("Test Resource") + .mimeType("text/plain") + .description("Test resource description") + .build(); + + var resourceSpecification = new McpStatelessServerFeatures.SyncResourceSpecification(systemInfoResource, + (exchange, request) -> { + try { + var systemInfo = Map.of("os", System.getProperty("os.name"), "os_version", + System.getProperty("os.version"), "java_version", + System.getProperty("java.version")); + String jsonContent = new ObjectMapper().writeValueAsString(systemInfo); + return new McpSchema.ReadResourceResult(List.of(new McpSchema.TextResourceContents( + request.uri(), "application/json", jsonContent))); + } + catch (Exception e) { + throw new RuntimeException("Failed to generate system info", e); + } + }); + + return List.of(resourceSpecification); + } + + private double evaluateExpression(String expression) { + // Simple expression evaluator for testing + return switch (expression) { + case "2 + 3" -> 5.0; + case "10 * 2" -> 20.0; + case "7 + 8" -> 15.0; + case "5 + 3" -> 8.0; + default -> 0.0; + }; + } + + } + + public static class TestMcpClientConfiguration { + + @Bean + McpSyncClientCustomizer clientCustomizer() { + + return (name, mcpClientSpec) -> { + // stateless server clients won't receive message notifications or + // requests from the server + }; + } + + } + + // Helper methods to start and stop the HTTP server + private static DisposableServer startHttpServer(ApplicationContext serverContext, int port) { + WebFluxStatelessServerTransport mcpStatelessServerTransport = serverContext + .getBean(WebFluxStatelessServerTransport.class); + HttpHandler httpHandler = RouterFunctions.toHttpHandler(mcpStatelessServerTransport.getRouterFunction()); + ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler); + return HttpServer.create().port(port).handle(adapter).bindNow(); + } + + private static void stopHttpServer(DisposableServer server) { + if (server != null) { + server.disposeNow(); + } + } + + // Helper method to get the MCP sync client + private static McpSyncClient getMcpSyncClient(ApplicationContext clientContext) { + ObjectProvider > mcpClients = clientContext + .getBeanProvider(ResolvableType.forClassWithGenerics(List.class, McpSyncClient.class)); + return mcpClients.getIfAvailable().get(0); + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webmvc/pom.xml b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webmvc/pom.xml new file mode 100644 index 00000000000..0ca778f0b38 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webmvc/pom.xml @@ -0,0 +1,74 @@ + +
+ diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webmvc/src/main/java/org/springframework/ai/mcp/server/stateless/webmvc/autoconfigure/McpStatelessServerWebMvcAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webmvc/src/main/java/org/springframework/ai/mcp/server/stateless/webmvc/autoconfigure/McpStatelessServerWebMvcAutoConfiguration.java new file mode 100644 index 00000000000..caa7480ad9f --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webmvc/src/main/java/org/springframework/ai/mcp/server/stateless/webmvc/autoconfigure/McpStatelessServerWebMvcAutoConfiguration.java @@ -0,0 +1,68 @@ +/* + * 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.stateless.webmvc.autoconfigure; + +import org.springframework.ai.mcp.server.stateless.autoconfigure.McpStatelessServerProperties; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +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.core.log.LogAccessor; +import org.springframework.web.servlet.function.RouterFunction; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.modelcontextprotocol.server.McpStatelessSyncServer; +import io.modelcontextprotocol.server.transport.WebMvcStatelessServerTransport; +import io.modelcontextprotocol.spec.McpSchema; + +/** + * @author Christian Tzolov + */ +@ConditionalOnClass({ McpSchema.class, McpStatelessSyncServer.class }) +@EnableConfigurationProperties(McpStatelessServerProperties.class) +@ConditionalOnProperty(prefix = McpStatelessServerProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) +public class McpStatelessServerWebMvcAutoConfiguration { + + private static final LogAccessor logger = new LogAccessor(McpStatelessServerWebMvcAutoConfiguration.class); + + @Bean + @ConditionalOnMissingBean + public WebMvcStatelessServerTransport webMvcStatelessServerTransport( + ObjectProvider4.0.0 ++ +org.springframework.ai +spring-ai-parent +1.1.0-SNAPSHOT +../../../pom.xml +spring-ai-autoconfigure-mcp-stateless-server-webmvc +jar +Spring AI MCP Stateless Server WebMVC Auto Configuration +Spring AI MCP Stateless Server WebMVC Auto Configuration +https://github.com/spring-projects/spring-ai + ++ + +https://github.com/spring-projects/spring-ai +git://github.com/spring-projects/spring-ai.git +git@github.com:spring-projects/spring-ai.git ++ + + ++ + +org.springframework.boot +spring-boot-starter ++ + +org.springframework.ai +spring-ai-mcp +${project.parent.version} +true ++ + +org.springframework.ai +spring-ai-autoconfigure-mcp-stateless-server-common +${project.parent.version} ++ + +io.modelcontextprotocol.sdk +mcp-spring-webmvc +true ++ + +org.springframework.boot +spring-boot-configuration-processor +true ++ + + + +org.springframework.boot +spring-boot-autoconfigure-processor +true ++ + + +org.springframework.ai +spring-ai-test +${project.parent.version} +test +objectMapperProvider, McpStatelessServerProperties serverProperties) { + + ObjectMapper objectMapper = objectMapperProvider.getIfAvailable(ObjectMapper::new); + + return WebMvcStatelessServerTransport.builder() + .objectMapper(objectMapper) + .messageEndpoint(serverProperties.getMcpEndpoint()) + // .disallowDelete(serverProperties.isDisallowDelete()) + .build(); + } + + // Router function for stateless http transport used by Spring WebFlux to start an + // HTTP server. + @Bean + public RouterFunction> webMvcStatelessServerRouterFunction( + WebMvcStatelessServerTransport webMvcStatelessTransport) { + return webMvcStatelessTransport.getRouterFunction(); + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webmvc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webmvc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..5c74a631c1d --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webmvc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,16 @@ +# +# 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.stateless.webmvc.autoconfigure.McpStatelessServerWebFluxAutoConfiguration \ No newline at end of file diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webmvc/src/test/java/org/springframework/ai/mcp/server/stateless/webmvc/autoconfigure/McpStatelessServerWebMvcAutoConfigurationIT.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webmvc/src/test/java/org/springframework/ai/mcp/server/stateless/webmvc/autoconfigure/McpStatelessServerWebMvcAutoConfigurationIT.java new file mode 100644 index 00000000000..62df66412a5 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webmvc/src/test/java/org/springframework/ai/mcp/server/stateless/webmvc/autoconfigure/McpStatelessServerWebMvcAutoConfigurationIT.java @@ -0,0 +1,175 @@ +/* + * 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.stateless.webmvc.autoconfigure; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.modelcontextprotocol.server.transport.WebMvcStatelessServerTransport; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.web.servlet.function.RouterFunction; + +import static org.assertj.core.api.Assertions.assertThat; + +class McpStatelessServerWebMvcAutoConfigurationIT { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(McpStatelessServerWebMvcAutoConfiguration.class)); + + @Test + void defaultConfiguration() { + this.contextRunner.run(context -> { + assertThat(context).hasSingleBean(WebMvcStatelessServerTransport.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void objectMapperConfiguration() { + this.contextRunner.withBean(ObjectMapper.class, ObjectMapper::new).run(context -> { + assertThat(context).hasSingleBean(WebMvcStatelessServerTransport.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void serverDisableConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.stateless.enabled=false").run(context -> { + assertThat(context).doesNotHaveBean(WebMvcStatelessServerTransport.class); + assertThat(context).doesNotHaveBean(RouterFunction.class); + }); + } + + @Test + void serverBaseUrlConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.stateless.mcpEndpoint=/test") + .run(context -> assertThat(context.getBean(WebMvcStatelessServerTransport.class)).extracting("mcpEndpoint") + .isEqualTo("/test")); + } + + @Test + void keepAliveIntervalConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.keep-alive-interval=PT30S") + .run(context -> { + assertThat(context).hasSingleBean(WebMvcStatelessServerTransport.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void disallowDeleteConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.disallow-delete=true") + .run(context -> { + assertThat(context).hasSingleBean(WebMvcStatelessServerTransport.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void disallowDeleteFalseConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.disallow-delete=false") + .run(context -> { + assertThat(context).hasSingleBean(WebMvcStatelessServerTransport.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void customObjectMapperIsUsed() { + ObjectMapper customObjectMapper = new ObjectMapper(); + this.contextRunner.withBean("customObjectMapper", ObjectMapper.class, () -> customObjectMapper).run(context -> { + assertThat(context).hasSingleBean(WebMvcStatelessServerTransport.class); + assertThat(context).hasSingleBean(RouterFunction.class); + // Verify the custom ObjectMapper is used + assertThat(context.getBean(ObjectMapper.class)).isSameAs(customObjectMapper); + }); + } + + @Test + void conditionalOnClassPresent() { + this.contextRunner.run(context -> { + // Verify that the configuration is loaded when required classes are present + assertThat(context).hasSingleBean(WebMvcStatelessServerTransport.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void conditionalOnMissingBeanWorks() { + // Test that @ConditionalOnMissingBean works by providing a custom bean + this.contextRunner + .withBean("customWebMvcProvider", WebMvcStatelessServerTransport.class, + () -> WebMvcStatelessServerTransport.builder() + .objectMapper(new ObjectMapper()) + .messageEndpoint("/custom") + .build()) + .run(context -> { + assertThat(context).hasSingleBean(WebMvcStatelessServerTransport.class); + // Should use the custom bean, not create a new one + WebMvcStatelessServerTransport provider = context.getBean(WebMvcStatelessServerTransport.class); + assertThat(provider).extracting("mcpEndpoint").isEqualTo("/custom"); + }); + } + + @Test + void routerFunctionIsCreatedFromProvider() { + this.contextRunner.run(context -> { + assertThat(context).hasSingleBean(RouterFunction.class); + assertThat(context).hasSingleBean(WebMvcStatelessServerTransport.class); + + // Verify that the RouterFunction is created from the provider + RouterFunction> routerFunction = context.getBean(RouterFunction.class); + assertThat(routerFunction).isNotNull(); + }); + } + + @Test + void allPropertiesConfiguration() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.stateless.mcpEndpoint=/custom-endpoint", + "spring.ai.mcp.server.stateless.disallow-delete=true") + .run(context -> { + WebMvcStatelessServerTransport provider = context.getBean(WebMvcStatelessServerTransport.class); + assertThat(provider).extracting("mcpEndpoint").isEqualTo("/custom-endpoint"); + // Verify beans are created successfully with all properties + assertThat(context).hasSingleBean(WebMvcStatelessServerTransport.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void enabledPropertyDefaultsToTrue() { + // Test that when enabled property is not set, it defaults to true (matchIfMissing + // = true) + this.contextRunner.run(context -> { + assertThat(context).hasSingleBean(WebMvcStatelessServerTransport.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void enabledPropertyExplicitlyTrue() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.enabled=true").run(context -> { + assertThat(context).hasSingleBean(WebMvcStatelessServerTransport.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common/pom.xml b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common/pom.xml new file mode 100644 index 00000000000..c3f72903269 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common/pom.xml @@ -0,0 +1,68 @@ + + + diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common/src/main/java/org/springframework/ai/mcp/server/streamable/autoconfigure/McpStreamableServerAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common/src/main/java/org/springframework/ai/mcp/server/streamable/autoconfigure/McpStreamableServerAutoConfiguration.java new file mode 100644 index 00000000000..6267c166706 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common/src/main/java/org/springframework/ai/mcp/server/streamable/autoconfigure/McpStreamableServerAutoConfiguration.java @@ -0,0 +1,254 @@ +/* + * 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.streamable.autoconfigure; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +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.core.env.Environment; +import org.springframework.core.log.LogAccessor; +import org.springframework.util.CollectionUtils; +import org.springframework.web.context.support.StandardServletEnvironment; + +import io.modelcontextprotocol.server.McpAsyncServer; +import io.modelcontextprotocol.server.McpAsyncServerExchange; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpServer.AsyncSpecification; +import io.modelcontextprotocol.server.McpServer.SyncSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.AsyncCompletionSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.AsyncPromptSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.AsyncResourceSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.Implementation; +import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider; +import reactor.core.publisher.Mono; + +/** + * @author Christian Tzolov + */ +@AutoConfiguration(afterName = { + "org.springframework.ai.mcp.server.streamable-http.autoconfigure.ToolCallbackConverterAutoConfiguration", + "org.springframework.ai.mcp.server.streamable-http.webflux.autoconfigure.McpStreamableServerWebFluxAutoConfiguration", + "org.springframework.ai.mcp.server.streamable-http.webmvc.autoconfigure.McpStreamableServerWebMvcAutoConfiguration" }) +@ConditionalOnClass({ McpSchema.class, McpSyncServer.class }) +@EnableConfigurationProperties(McpStreamableServerProperties.class) +@ConditionalOnProperty(prefix = McpStreamableServerProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) +public class McpStreamableServerAutoConfiguration { + + private static final LogAccessor logger = new LogAccessor(McpStreamableServerAutoConfiguration.class); + + @Bean + @ConditionalOnMissingBean + public McpSchema.ServerCapabilities.Builder capabilitiesBuilder() { + return McpSchema.ServerCapabilities.builder(); + } + + @Bean + @ConditionalOnProperty(prefix = McpStreamableServerProperties.CONFIG_PREFIX, name = "type", havingValue = "SYNC", + matchIfMissing = true) + public McpSyncServer mcpStreamableSyncServer(McpStreamableServerTransportProvider transportProvider, + McpSchema.ServerCapabilities.Builder capabilitiesBuilder, McpStreamableServerProperties serverProperties, + ObjectProvider4.0.0 ++ +org.springframework.ai +spring-ai-parent +1.1.0-SNAPSHOT +../../../pom.xml +spring-ai-autoconfigure-mcp-streamable-server-common +jar +Spring AI MCP Streamable Server Common Auto Configuration +Spring AI MCP Streamable Server Common Auto Configuration +https://github.com/spring-projects/spring-ai + ++ + +https://github.com/spring-projects/spring-ai +git://github.com/spring-projects/spring-ai.git +git@github.com:spring-projects/spring-ai.git ++ + + ++ + +org.springframework.boot +spring-boot-starter ++ + +org.springframework.ai +spring-ai-mcp +${project.parent.version} +true ++ + +io.modelcontextprotocol.sdk +mcp-spring-webmvc +true ++ + +org.springframework.boot +spring-boot-configuration-processor +true ++ + + + +org.springframework.boot +spring-boot-autoconfigure-processor +true ++ + + +org.springframework.ai +spring-ai-test +${project.parent.version} +test +> tools, + ObjectProvider
> resources, + ObjectProvider
> prompts, + ObjectProvider
> completions, + ObjectProvider
>> rootsChangeConsumers, + Environment environment) { + + McpSchema.Implementation serverInfo = new Implementation(serverProperties.getName(), + serverProperties.getVersion()); + + // Create the server with both tool and resource capabilities + SyncSpecification serverBuilder = McpServer.sync(transportProvider).serverInfo(serverInfo); + + // Tools + if (serverProperties.getCapabilities().isTool()) { + logger.info("Enable tools capabilities, notification: " + serverProperties.isToolChangeNotification()); + capabilitiesBuilder.tools(serverProperties.isToolChangeNotification()); + + List toolSpecifications = new ArrayList<>( + tools.stream().flatMap(List::stream).toList()); + + if (!CollectionUtils.isEmpty(toolSpecifications)) { + serverBuilder.tools(toolSpecifications); + logger.info("Registered tools: " + toolSpecifications.size()); + } + } + + // Resources + if (serverProperties.getCapabilities().isResource()) { + logger.info( + "Enable resources capabilities, notification: " + serverProperties.isResourceChangeNotification()); + capabilitiesBuilder.resources(false, serverProperties.isResourceChangeNotification()); + + List resourceSpecifications = resources.stream().flatMap(List::stream).toList(); + if (!CollectionUtils.isEmpty(resourceSpecifications)) { + serverBuilder.resources(resourceSpecifications); + logger.info("Registered resources: " + resourceSpecifications.size()); + } + } + + // Prompts + if (serverProperties.getCapabilities().isPrompt()) { + logger.info("Enable prompts capabilities, notification: " + serverProperties.isPromptChangeNotification()); + capabilitiesBuilder.prompts(serverProperties.isPromptChangeNotification()); + + List promptSpecifications = prompts.stream().flatMap(List::stream).toList(); + if (!CollectionUtils.isEmpty(promptSpecifications)) { + serverBuilder.prompts(promptSpecifications); + logger.info("Registered prompts: " + promptSpecifications.size()); + } + } + + // Completions + if (serverProperties.getCapabilities().isCompletion()) { + logger.info("Enable completions capabilities"); + capabilitiesBuilder.completions(); + + List completionSpecifications = completions.stream() + .flatMap(List::stream) + .toList(); + if (!CollectionUtils.isEmpty(completionSpecifications)) { + serverBuilder.completions(completionSpecifications); + logger.info("Registered completions: " + completionSpecifications.size()); + } + } + + rootsChangeConsumers.ifAvailable(consumer -> { + BiConsumer > syncConsumer = (exchange, roots) -> consumer + .accept(exchange, roots); + serverBuilder.rootsChangeHandler(syncConsumer); + logger.info("Registered roots change consumer"); + }); + + serverBuilder.capabilities(capabilitiesBuilder.build()); + + serverBuilder.instructions(serverProperties.getInstructions()); + + serverBuilder.requestTimeout(serverProperties.getRequestTimeout()); + if (environment instanceof StandardServletEnvironment) { + serverBuilder.immediateExecution(true); + } + + return serverBuilder.build(); + } + + @Bean + @ConditionalOnProperty(prefix = McpStreamableServerProperties.CONFIG_PREFIX, name = "type", havingValue = "ASYNC") + public McpAsyncServer mcpStreamableAsyncServer(McpStreamableServerTransportProvider transportProvider, + McpSchema.ServerCapabilities.Builder capabilitiesBuilder, McpStreamableServerProperties serverProperties, + ObjectProvider > tools, + ObjectProvider
> resources, + ObjectProvider
> prompts, + ObjectProvider
> completions, + ObjectProvider
>> rootsChangeConsumer) { + + McpSchema.Implementation serverInfo = new Implementation(serverProperties.getName(), + serverProperties.getVersion()); + + // Create the server with both tool and resource capabilities + AsyncSpecification serverBuilder = McpServer.async(transportProvider).serverInfo(serverInfo); + + // Tools + if (serverProperties.getCapabilities().isTool()) { + List toolSpecifications = new ArrayList<>( + tools.stream().flatMap(List::stream).toList()); + + logger.info("Enable tools capabilities, notification: " + serverProperties.isToolChangeNotification()); + capabilitiesBuilder.tools(serverProperties.isToolChangeNotification()); + + if (!CollectionUtils.isEmpty(toolSpecifications)) { + serverBuilder.tools(toolSpecifications); + logger.info("Registered tools: " + toolSpecifications.size()); + } + } + + // Resources + if (serverProperties.getCapabilities().isResource()) { + logger.info( + "Enable resources capabilities, notification: " + serverProperties.isResourceChangeNotification()); + capabilitiesBuilder.resources(false, serverProperties.isResourceChangeNotification()); + + List resourceSpecifications = resources.stream().flatMap(List::stream).toList(); + if (!CollectionUtils.isEmpty(resourceSpecifications)) { + serverBuilder.resources(resourceSpecifications); + logger.info("Registered resources: " + resourceSpecifications.size()); + } + } + + // Prompts + if (serverProperties.getCapabilities().isPrompt()) { + logger.info("Enable prompts capabilities, notification: " + serverProperties.isPromptChangeNotification()); + capabilitiesBuilder.prompts(serverProperties.isPromptChangeNotification()); + List promptSpecifications = prompts.stream().flatMap(List::stream).toList(); + + if (!CollectionUtils.isEmpty(promptSpecifications)) { + serverBuilder.prompts(promptSpecifications); + logger.info("Registered prompts: " + promptSpecifications.size()); + } + } + + // Completions + if (serverProperties.getCapabilities().isCompletion()) { + logger.info("Enable completions capabilities"); + capabilitiesBuilder.completions(); + List completionSpecifications = completions.stream() + .flatMap(List::stream) + .toList(); + + if (!CollectionUtils.isEmpty(completionSpecifications)) { + serverBuilder.completions(completionSpecifications); + logger.info("Registered completions: " + completionSpecifications.size()); + } + } + + rootsChangeConsumer.ifAvailable(consumer -> { + BiFunction , Mono > asyncConsumer = (exchange, roots) -> { + consumer.accept(exchange, roots); + return Mono.empty(); + }; + serverBuilder.rootsChangeHandler(asyncConsumer); + logger.info("Registered roots change consumer"); + }); + + serverBuilder.capabilities(capabilitiesBuilder.build()); + + serverBuilder.instructions(serverProperties.getInstructions()); + + serverBuilder.requestTimeout(serverProperties.getRequestTimeout()); + + return serverBuilder.build(); + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common/src/main/java/org/springframework/ai/mcp/server/streamable/autoconfigure/McpStreamableServerProperties.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common/src/main/java/org/springframework/ai/mcp/server/streamable/autoconfigure/McpStreamableServerProperties.java new file mode 100644 index 00000000000..7d66b2a47df --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common/src/main/java/org/springframework/ai/mcp/server/streamable/autoconfigure/McpStreamableServerProperties.java @@ -0,0 +1,294 @@ +/* + * 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.streamable.autoconfigure; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.Assert; + +/** + * @author Christian Tzolov + */ +@ConfigurationProperties(McpStreamableServerProperties.CONFIG_PREFIX) +public class McpStreamableServerProperties { + + public static final String CONFIG_PREFIX = "spring.ai.mcp.server.streamable-http"; + + /** + * Enable/disable the MCP server. + * + * When set to false, the MCP server and all its components will not be initialized. + */ + private boolean enabled = true; + + /** + * The name of the MCP server instance. + *
+ * This name is used to identify the server in logs and monitoring. + */ + private String name = "mcp-server"; + + /** + * The version of the MCP server instance. + */ + private String version = "1.0.0"; + + /** + * The instructions of the MCP server instance. + *
+ * These instructions are used to provide guidance to the client on how to interact + * with this server. + */ + private String instructions = null; + + /** + * Enable/disable notifications for resource changes. Only relevant for MCP servers + * with resource capabilities. + *
+ * When enabled, the server will notify clients when resources are added, updated, or + * removed. + */ + private boolean resourceChangeNotification = true; + + /** + * Enable/disable notifications for tool changes. Only relevant for MCP servers with + * tool capabilities. + *
+ * When enabled, the server will notify clients when tools are registered or + * unregistered. + */ + private boolean toolChangeNotification = true; + + /** + * Enable/disable notifications for prompt changes. Only relevant for MCP servers with + * prompt capabilities. + *
+ * When enabled, the server will notify clients when prompt templates are modified. + */ + private boolean promptChangeNotification = true; + + /** + */ + private String mcpEndpoint = "/mcp"; + + /** + * The type of server to use for MCP server communication. + *
+ * Supported types are: + *
+ *
+ */ + private ServerType type = ServerType.SYNC; + + private Capabilities capabilities = new Capabilities(); + + /** + * Sets the duration to wait for server responses before timing out requests. This + * timeout applies to all requests made through the client, including tool calls, + * resource access, and prompt operations. + */ + private Duration requestTimeout = Duration.ofSeconds(20); + + /** + * The duration to keep the connection alive. + */ + private Duration keepAliveInterval; + + private boolean disallowDelete; + + public Duration getRequestTimeout() { + return this.requestTimeout; + } + + public void setRequestTimeout(Duration requestTimeout) { + Assert.notNull(requestTimeout, "Request timeout must not be null"); + this.requestTimeout = requestTimeout; + } + + public Capabilities getCapabilities() { + return this.capabilities; + } + + /** + * Server types supported by the MCP server. + */ + public enum ServerType { + + /** + * Synchronous (McpSyncServer) server + */ + SYNC, + + /** + * Asynchronous (McpAsyncServer) server + */ + ASYNC + + } + + /** + * (Optional) response MIME type per tool name. + */ + private Map- SYNC - Standard synchronous server (default)
+ *- ASYNC - Asynchronous server
+ *toolResponseMimeType = new HashMap<>(); + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + Assert.hasText(name, "Name must not be empty"); + this.name = name; + } + + public String getVersion() { + return this.version; + } + + public void setVersion(String version) { + Assert.hasText(version, "Version must not be empty"); + this.version = version; + } + + public String getInstructions() { + return this.instructions; + } + + public void setInstructions(String instructions) { + this.instructions = instructions; + } + + public boolean isResourceChangeNotification() { + return this.resourceChangeNotification; + } + + public void setResourceChangeNotification(boolean resourceChangeNotification) { + this.resourceChangeNotification = resourceChangeNotification; + } + + public boolean isToolChangeNotification() { + return this.toolChangeNotification; + } + + public void setToolChangeNotification(boolean toolChangeNotification) { + this.toolChangeNotification = toolChangeNotification; + } + + public boolean isPromptChangeNotification() { + return this.promptChangeNotification; + } + + public void setPromptChangeNotification(boolean promptChangeNotification) { + this.promptChangeNotification = promptChangeNotification; + } + + public String getMcpEndpoint() { + return this.mcpEndpoint; + } + + public void setMcpEndpoint(String mcpEndpoint) { + Assert.hasText(mcpEndpoint, "MCP endpoint must not be empty"); + this.mcpEndpoint = mcpEndpoint; + } + + public ServerType getType() { + return this.type; + } + + public void setType(ServerType serverType) { + Assert.notNull(serverType, "Server type must not be null"); + this.type = serverType; + } + + public Map getToolResponseMimeType() { + return this.toolResponseMimeType; + } + + public void setKeepAliveInterval(Duration keepAliveInterval) { + Assert.notNull(keepAliveInterval, "Keep-alive interval must not be null"); + this.keepAliveInterval = keepAliveInterval; + } + + public Duration getKeepAliveInterval() { + return this.keepAliveInterval; + } + + public boolean isDisallowDelete() { + return this.disallowDelete; + } + + public void setDisallowDelete(boolean disallowDelete) { + this.disallowDelete = disallowDelete; + } + + public static class Capabilities { + + private boolean resource = true; + + private boolean tool = true; + + private boolean prompt = true; + + private boolean completion = true; + + public boolean isResource() { + return this.resource; + } + + public void setResource(boolean resource) { + this.resource = resource; + } + + public boolean isTool() { + return this.tool; + } + + public void setTool(boolean tool) { + this.tool = tool; + } + + public boolean isPrompt() { + return this.prompt; + } + + public void setPrompt(boolean prompt) { + this.prompt = prompt; + } + + public boolean isCompletion() { + return this.completion; + } + + public void setCompletion(boolean completion) { + this.completion = completion; + } + + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common/src/main/java/org/springframework/ai/mcp/server/streamable/autoconfigure/ToolCallbackConverterAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common/src/main/java/org/springframework/ai/mcp/server/streamable/autoconfigure/ToolCallbackConverterAutoConfiguration.java new file mode 100644 index 00000000000..3d44fd4f0b7 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common/src/main/java/org/springframework/ai/mcp/server/streamable/autoconfigure/ToolCallbackConverterAutoConfiguration.java @@ -0,0 +1,131 @@ +/* + * 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.streamable.autoconfigure; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.ai.mcp.McpToolUtils; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.ToolCallbackProvider; +import org.springframework.beans.factory.ObjectProvider; +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.Conditional; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MimeType; + +import io.modelcontextprotocol.server.McpServerFeatures; + +/** + * @author Christian Tzolov + */ +@EnableConfigurationProperties(McpStreamableServerProperties.class) +@Conditional(ToolCallbackConverterCondition.class) +public class ToolCallbackConverterAutoConfiguration { + + @Bean + @ConditionalOnProperty(prefix = McpStreamableServerProperties.CONFIG_PREFIX, name = "type", havingValue = "SYNC", + matchIfMissing = true) + public List syncTools(ObjectProvider > toolCalls, + List
toolCallbacksList, List toolCallbackProvider, + McpStreamableServerProperties serverProperties) { + + List tools = this.aggregateToolCallbacks(toolCalls, toolCallbacksList, toolCallbackProvider); + + return this.toSyncToolSpecifications(tools, serverProperties); + } + + private List toSyncToolSpecifications(List tools, + McpStreamableServerProperties serverProperties) { + + // De-duplicate tools by their name, keeping the first occurrence of each tool + // name + return tools.stream() // Key: tool name + .collect(Collectors.toMap(tool -> tool.getToolDefinition().name(), tool -> tool, // Value: + // the + // tool + // itself + (existing, replacement) -> existing)) // On duplicate key, keep the + // existing tool + .values() + .stream() + .map(tool -> { + String toolName = tool.getToolDefinition().name(); + MimeType mimeType = (serverProperties.getToolResponseMimeType().containsKey(toolName)) + ? MimeType.valueOf(serverProperties.getToolResponseMimeType().get(toolName)) : null; + return McpToolUtils.toSyncToolSpecification(tool, mimeType); + }) + .toList(); + } + + @Bean + @ConditionalOnProperty(prefix = McpStreamableServerProperties.CONFIG_PREFIX, name = "type", havingValue = "ASYNC") + public List asyncTools(ObjectProvider > toolCalls, + List
toolCallbackList, List toolCallbackProvider, + McpStreamableServerProperties serverProperties) { + + List tools = this.aggregateToolCallbacks(toolCalls, toolCallbackList, toolCallbackProvider); + + return this.toAsyncToolSpecification(tools, serverProperties); + } + + private List toAsyncToolSpecification(List tools, + McpStreamableServerProperties serverProperties) { + // De-duplicate tools by their name, keeping the first occurrence of each tool + // name + return tools.stream() // Key: tool name + .collect(Collectors.toMap(tool -> tool.getToolDefinition().name(), tool -> tool, // Value: + // the + // tool + // itself + (existing, replacement) -> existing)) // On duplicate key, keep the + // existing tool + .values() + .stream() + .map(tool -> { + String toolName = tool.getToolDefinition().name(); + MimeType mimeType = (serverProperties.getToolResponseMimeType().containsKey(toolName)) + ? MimeType.valueOf(serverProperties.getToolResponseMimeType().get(toolName)) : null; + return McpToolUtils.toAsyncToolSpecification(tool, mimeType); + }) + .toList(); + } + + private List aggregateToolCallbacks(ObjectProvider > toolCalls, + List
toolCallbacksList, List toolCallbackProvider) { + + List tools = new ArrayList<>(toolCalls.stream().flatMap(List::stream).toList()); + + if (!CollectionUtils.isEmpty(toolCallbacksList)) { + tools.addAll(toolCallbacksList); + } + + List providerToolCallbacks = toolCallbackProvider.stream() + .map(pr -> List.of(pr.getToolCallbacks())) + .flatMap(List::stream) + .filter(fc -> fc instanceof ToolCallback) + .map(fc -> (ToolCallback) fc) + .toList(); + + tools.addAll(providerToolCallbacks); + return tools; + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common/src/main/java/org/springframework/ai/mcp/server/streamable/autoconfigure/ToolCallbackConverterCondition.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common/src/main/java/org/springframework/ai/mcp/server/streamable/autoconfigure/ToolCallbackConverterCondition.java new file mode 100644 index 00000000000..d46ad8927c5 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common/src/main/java/org/springframework/ai/mcp/server/streamable/autoconfigure/ToolCallbackConverterCondition.java @@ -0,0 +1,43 @@ +/* + * 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.streamable.autoconfigure; + +import org.springframework.boot.autoconfigure.condition.AllNestedConditions; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +/** + * @author Christian Tzolov + */ +public class ToolCallbackConverterCondition extends AllNestedConditions { + + public ToolCallbackConverterCondition() { + super(ConfigurationPhase.PARSE_CONFIGURATION); + } + + @ConditionalOnProperty(prefix = McpStreamableServerProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) + static class McpServerEnabledCondition { + + } + + @ConditionalOnProperty(prefix = McpStreamableServerProperties.CONFIG_PREFIX, name = "tool-callback-converter", + havingValue = "true", matchIfMissing = true) + static class ToolCallbackConvertCondition { + + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..cad50691305 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,17 @@ +# +# 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. +# +org.springframework.ai.mcp.server.streamable.autoconfigure.McpStreamableServerAutoConfiguration +org.springframework.ai.mcp.server.streamable.autoconfigure.ToolCallbackConverterAutoConfiguration diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common/src/test/java/org/springframework/ai/mcp/server/streamable/autoconfigure/McpStreamableServerAutoConfigurationIT.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common/src/test/java/org/springframework/ai/mcp/server/streamable/autoconfigure/McpStreamableServerAutoConfigurationIT.java new file mode 100644 index 00000000000..477a36771b7 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common/src/test/java/org/springframework/ai/mcp/server/streamable/autoconfigure/McpStreamableServerAutoConfigurationIT.java @@ -0,0 +1,485 @@ +/* + * 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.streamable.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.ai.mcp.SyncMcpToolCallback; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.ToolCallbackProvider; +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 io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.server.McpAsyncServer; +import io.modelcontextprotocol.server.McpAsyncServerExchange; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpServerFeatures.AsyncCompletionSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpServerTransportProvider; +import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider; +import reactor.core.publisher.Mono; + +public class McpStreamableServerAutoConfigurationIT { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(McpStreamableServerAutoConfiguration.class, + ToolCallbackConverterAutoConfiguration.class)) + .withUserConfiguration(TestStreamableTransportProviderConfiguration.class); + + @Test + void defaultConfiguration() { + this.contextRunner.run(context -> { + assertThat(context).hasSingleBean(McpSyncServer.class); + assertThat(context).hasSingleBean(McpStreamableServerTransportProvider.class); + + McpStreamableServerProperties properties = context.getBean(McpStreamableServerProperties.class); + assertThat(properties.getName()).isEqualTo("mcp-server"); + assertThat(properties.getVersion()).isEqualTo("1.0.0"); + assertThat(properties.getType()).isEqualTo(McpStreamableServerProperties.ServerType.SYNC); + assertThat(properties.isToolChangeNotification()).isTrue(); + assertThat(properties.isResourceChangeNotification()).isTrue(); + assertThat(properties.isPromptChangeNotification()).isTrue(); + assertThat(properties.getRequestTimeout().getSeconds()).isEqualTo(20); + assertThat(properties.getMcpEndpoint()).isEqualTo("/mcp"); + + // Check capabilities + assertThat(properties.getCapabilities().isTool()).isTrue(); + assertThat(properties.getCapabilities().isResource()).isTrue(); + assertThat(properties.getCapabilities().isPrompt()).isTrue(); + assertThat(properties.getCapabilities().isCompletion()).isTrue(); + }); + } + + @Test + void asyncConfiguration() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.streamable-http.type=ASYNC", + "spring.ai.mcp.server.streamable-http.name=test-server", + "spring.ai.mcp.server.streamable-http.version=2.0.0", + "spring.ai.mcp.server.streamable-http.instructions=My MCP Server", + "spring.ai.mcp.server.streamable-http.request-timeout=30s") + .run(context -> { + assertThat(context).hasSingleBean(McpAsyncServer.class); + assertThat(context).doesNotHaveBean(McpSyncServer.class); + + McpStreamableServerProperties properties = context.getBean(McpStreamableServerProperties.class); + assertThat(properties.getName()).isEqualTo("test-server"); + assertThat(properties.getVersion()).isEqualTo("2.0.0"); + assertThat(properties.getInstructions()).isEqualTo("My MCP Server"); + assertThat(properties.getType()).isEqualTo(McpStreamableServerProperties.ServerType.ASYNC); + assertThat(properties.getRequestTimeout().getSeconds()).isEqualTo(30); + }); + } + + @Test + void syncToolCallbackRegistrationControl() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.streamable-http.type=SYNC", + "spring.ai.mcp.server.streamable-http.tool-callback-converter=true") + .run(context -> { + assertThat(context).hasBean("syncTools"); + }); + + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.streamable-http.type=SYNC", + "spring.ai.mcp.server.streamable-http.tool-callback-converter=false") + .run(context -> { + assertThat(context).doesNotHaveBean("syncTools"); + }); + } + + @Test + void asyncToolCallbackRegistrationControl() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.streamable-http.type=ASYNC", + "spring.ai.mcp.server.streamable-http.tool-callback-converter=true") + .run(context -> { + assertThat(context).hasBean("asyncTools"); + }); + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.streamable-http.type=ASYNC", + "spring.ai.mcp.server.streamable-http.tool-callback-converter=false") + .run(context -> { + assertThat(context).doesNotHaveBean("asyncTools"); + }); + } + + @Test + void syncServerInstructionsConfiguration() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.streamable-http.instructions=Sync Server Instructions") + .run(context -> { + McpStreamableServerProperties properties = context.getBean(McpStreamableServerProperties.class); + assertThat(properties.getInstructions()).isEqualTo("Sync Server Instructions"); + + McpSyncServer server = context.getBean(McpSyncServer.class); + assertThat(server).isNotNull(); + }); + } + + @Test + void serverNotificationConfiguration() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.streamable-http.tool-change-notification=false", + "spring.ai.mcp.server.streamable-http.resource-change-notification=false") + .run(context -> { + McpStreamableServerProperties properties = context.getBean(McpStreamableServerProperties.class); + assertThat(properties.isToolChangeNotification()).isFalse(); + assertThat(properties.isResourceChangeNotification()).isFalse(); + }); + } + + @Test + void disabledConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.enabled=false").run(context -> { + assertThat(context).doesNotHaveBean(McpSyncServer.class); + assertThat(context).doesNotHaveBean(McpAsyncServer.class); + assertThat(context).doesNotHaveBean(McpServerTransportProvider.class); + }); + } + + @Test + void notificationConfiguration() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.streamable-http.tool-change-notification=false", + "spring.ai.mcp.server.streamable-http.resource-change-notification=false", + "spring.ai.mcp.server.streamable-http.prompt-change-notification=false") + .run(context -> { + McpStreamableServerProperties properties = context.getBean(McpStreamableServerProperties.class); + assertThat(properties.isToolChangeNotification()).isFalse(); + assertThat(properties.isResourceChangeNotification()).isFalse(); + assertThat(properties.isPromptChangeNotification()).isFalse(); + }); + } + + @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 toolSpecificationConfiguration() { + this.contextRunner.withUserConfiguration(TestToolConfiguration.class).run(context -> { + List tools = context.getBean("syncTools", List.class); + assertThat(tools).hasSize(1); + }); + } + + @Test + void resourceSpecificationConfiguration() { + this.contextRunner.withUserConfiguration(TestResourceConfiguration.class).run(context -> { + McpSyncServer server = context.getBean(McpSyncServer.class); + assertThat(server).isNotNull(); + }); + } + + @Test + void promptSpecificationConfiguration() { + this.contextRunner.withUserConfiguration(TestPromptConfiguration.class).run(context -> { + McpSyncServer server = context.getBean(McpSyncServer.class); + assertThat(server).isNotNull(); + }); + } + + @Test + void asyncToolSpecificationConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.type=ASYNC") + .withUserConfiguration(TestToolConfiguration.class) + .run(context -> { + List tools = context.getBean("asyncTools", List.class); + assertThat(tools).hasSize(1); + }); + } + + @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 rootsChangeHandlerConfiguration() { + this.contextRunner.withUserConfiguration(TestRootsHandlerConfiguration.class).run(context -> { + McpSyncServer server = context.getBean(McpSyncServer.class); + assertThat(server).isNotNull(); + }); + } + + @Test + void asyncRootsChangeHandlerConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.type=ASYNC") + .withUserConfiguration(TestAsyncRootsHandlerConfiguration.class) + .run(context -> { + McpAsyncServer server = context.getBean(McpAsyncServer.class); + assertThat(server).isNotNull(); + }); + } + + @Test + void capabilitiesConfiguration() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.streamable-http.capabilities.tool=false", + "spring.ai.mcp.server.streamable-http.capabilities.resource=false", + "spring.ai.mcp.server.streamable-http.capabilities.prompt=false", + "spring.ai.mcp.server.streamable-http.capabilities.completion=false") + .run(context -> { + McpStreamableServerProperties properties = context.getBean(McpStreamableServerProperties.class); + assertThat(properties.getCapabilities().isTool()).isFalse(); + assertThat(properties.getCapabilities().isResource()).isFalse(); + assertThat(properties.getCapabilities().isPrompt()).isFalse(); + assertThat(properties.getCapabilities().isCompletion()).isFalse(); + + // Verify the server is configured with the disabled capabilities + McpSyncServer server = context.getBean(McpSyncServer.class); + assertThat(server).isNotNull(); + }); + } + + @Test + void toolResponseMimeTypeConfiguration() { + this.contextRunner + .withPropertyValues( + "spring.ai.mcp.server.streamable-http.tool-response-mime-type.test-tool=application/json") + .withUserConfiguration(TestToolConfiguration.class) + .run(context -> { + McpStreamableServerProperties properties = context.getBean(McpStreamableServerProperties.class); + assertThat(properties.getToolResponseMimeType()).containsEntry("test-tool", "application/json"); + + // Verify the MIME type is applied to the tool specifications + List tools = context.getBean("syncTools", List.class); + assertThat(tools).hasSize(1); + + // The server should be properly configured with the tool + McpSyncServer server = context.getBean(McpSyncServer.class); + assertThat(server).isNotNull(); + }); + } + + @Test + void requestTimeoutConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.request-timeout=45s") + .run(context -> { + McpStreamableServerProperties properties = context.getBean(McpStreamableServerProperties.class); + assertThat(properties.getRequestTimeout().getSeconds()).isEqualTo(45); + + // Verify the server is configured with the timeout + McpSyncServer server = context.getBean(McpSyncServer.class); + assertThat(server).isNotNull(); + }); + } + + @Test + void endpointConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.mcp-endpoint=/my-mcp") + .run(context -> { + McpStreamableServerProperties properties = context.getBean(McpStreamableServerProperties.class); + assertThat(properties.getMcpEndpoint()).isEqualTo("/my-mcp"); + + // Verify the server is configured with the endpoints + McpSyncServer server = context.getBean(McpSyncServer.class); + assertThat(server).isNotNull(); + }); + } + + @Test + void completionSpecificationConfiguration() { + this.contextRunner.withUserConfiguration(TestCompletionConfiguration.class).run(context -> { + List completions = context.getBean("testCompletions", List.class); + assertThat(completions).hasSize(1); + }); + } + + @Test + void asyncCompletionSpecificationConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.type=ASYNC") + .withUserConfiguration(TestAsyncCompletionConfiguration.class) + .run(context -> { + List completions = context.getBean("testAsyncCompletions", List.class); + assertThat(completions).hasSize(1); + }); + } + + @Test + void toolCallbackProviderConfiguration() { + this.contextRunner.withUserConfiguration(TestToolCallbackProviderConfiguration.class) + .run(context -> assertThat(context).hasSingleBean(ToolCallbackProvider.class)); + } + + @Configuration + static class TestResourceConfiguration { + + @Bean + List testResources() { + return List.of(); + } + + } + + @Configuration + static class TestPromptConfiguration { + + @Bean + List 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 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 TestToolCallbackProviderConfiguration { + + @Bean + ToolCallbackProvider testToolCallbackProvider() { + return () -> { + McpSyncClient mockClient = Mockito.mock(McpSyncClient.class); + McpSchema.Tool mockTool = Mockito.mock(McpSchema.Tool.class); + + Mockito.when(mockTool.name()).thenReturn("provider-tool"); + Mockito.when(mockTool.description()).thenReturn("Provider Tool"); + when(mockClient.getClientInfo()).thenReturn(new McpSchema.Implementation("testClient", "1.0.0")); + + return new ToolCallback[] { new SyncMcpToolCallback(mockClient, mockTool) }; + }; + } + + } + + @Configuration + static class TestCompletionConfiguration { + + @Bean + List testCompletions() { + + BiFunction completionHandler = ( + exchange, request) -> new McpSchema.CompleteResult( + new McpSchema.CompleteResult.CompleteCompletion(List.of(), 0, false)); + + return List.of(new McpServerFeatures.SyncCompletionSpecification( + new McpSchema.PromptReference("ref/prompt", "code_review", "Code review"), completionHandler)); + } + + } + + @Configuration + static class TestAsyncCompletionConfiguration { + + @Bean + List testAsyncCompletions() { + BiFunction > completionHandler = ( + exchange, request) -> Mono.just(new McpSchema.CompleteResult( + new McpSchema.CompleteResult.CompleteCompletion(List.of(), 0, false))); + + return List.of(new McpServerFeatures.AsyncCompletionSpecification( + new McpSchema.PromptReference("ref/prompt", "code_review", "Code review"), completionHandler)); + } + + } + + @Configuration + static class TestRootsHandlerConfiguration { + + @Bean + BiConsumer > rootsChangeHandler() { + return (exchange, roots) -> { + // Test implementation + }; + } + + } + + @Configuration + static class TestAsyncRootsHandlerConfiguration { + + @Bean + BiConsumer > rootsChangeHandler() { + return (exchange, roots) -> { + // Test implementation + }; + } + + } + + @Configuration + static class TestStreamableTransportProviderConfiguration { + + @Bean + public McpStreamableServerTransportProvider transportProvider() { + return Mockito.mock(McpStreamableServerTransportProvider.class); + } + + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common/src/test/java/org/springframework/ai/mcp/server/streamable/autoconfigure/ToolCallbackConverterAutoConfigurationIT.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common/src/test/java/org/springframework/ai/mcp/server/streamable/autoconfigure/ToolCallbackConverterAutoConfigurationIT.java new file mode 100644 index 00000000000..e76b9edfbaa --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common/src/test/java/org/springframework/ai/mcp/server/streamable/autoconfigure/ToolCallbackConverterAutoConfigurationIT.java @@ -0,0 +1,303 @@ +/* + * 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.streamable.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.ai.mcp.SyncMcpToolCallback; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.ToolCallbackProvider; +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 io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification; +import io.modelcontextprotocol.spec.McpSchema; + +/** + * Integration tests for {@link ToolCallbackConverterAutoConfiguration} and + * {@link ToolCallbackConverterCondition}. + * + * @author Christian Tzolov + */ +public class ToolCallbackConverterAutoConfigurationIT { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ToolCallbackConverterAutoConfiguration.class)) + .withPropertyValues("spring.ai.mcp.server.streamable-http.enabled=true"); + + @Test + void defaultSyncToolsConfiguration() { + this.contextRunner.withUserConfiguration(TestToolConfiguration.class).run(context -> { + assertThat(context).hasSingleBean(ToolCallbackConverterAutoConfiguration.class); + assertThat(context).hasBean("syncTools"); + + @SuppressWarnings("unchecked") + List syncTools = (List ) context.getBean("syncTools"); + assertThat(syncTools).hasSize(1); + assertThat(syncTools.get(0)).isNotNull(); + }); + } + + @Test + void asyncToolsConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.type=ASYNC") + .withUserConfiguration(TestToolConfiguration.class) + .run(context -> { + assertThat(context).hasSingleBean(ToolCallbackConverterAutoConfiguration.class); + assertThat(context).hasBean("asyncTools"); + assertThat(context).doesNotHaveBean("syncTools"); + + @SuppressWarnings("unchecked") + List asyncTools = (List ) context.getBean("asyncTools"); + assertThat(asyncTools).hasSize(1); + assertThat(asyncTools.get(0)).isNotNull(); + }); + } + + @Test + void toolCallbackProviderConfiguration() { + this.contextRunner.withUserConfiguration(TestToolCallbackProviderConfiguration.class).run(context -> { + assertThat(context).hasSingleBean(ToolCallbackConverterAutoConfiguration.class); + assertThat(context).hasBean("syncTools"); + + @SuppressWarnings("unchecked") + List syncTools = (List ) context.getBean("syncTools"); + assertThat(syncTools).hasSize(1); + }); + } + + @Test + void multipleToolCallbacksConfiguration() { + this.contextRunner.withUserConfiguration(TestMultipleToolsConfiguration.class).run(context -> { + assertThat(context).hasSingleBean(ToolCallbackConverterAutoConfiguration.class); + assertThat(context).hasBean("syncTools"); + + @SuppressWarnings("unchecked") + List syncTools = (List ) context.getBean("syncTools"); + assertThat(syncTools).hasSize(2); + }); + } + + @Test + void toolResponseMimeTypeConfiguration() { + this.contextRunner + .withPropertyValues( + "spring.ai.mcp.server.streamable-http.tool-response-mime-type.test-tool=application/json") + .withUserConfiguration(TestToolConfiguration.class) + .run(context -> { + assertThat(context).hasSingleBean(ToolCallbackConverterAutoConfiguration.class); + assertThat(context).hasBean("syncTools"); + + @SuppressWarnings("unchecked") + List syncTools = (List ) context.getBean("syncTools"); + assertThat(syncTools).hasSize(1); + + McpStreamableServerProperties properties = context.getBean(McpStreamableServerProperties.class); + assertThat(properties.getToolResponseMimeType()).containsEntry("test-tool", "application/json"); + }); + } + + @Test + void duplicateToolNamesDeduplication() { + this.contextRunner.withUserConfiguration(TestDuplicateToolsConfiguration.class).run(context -> { + assertThat(context).hasSingleBean(ToolCallbackConverterAutoConfiguration.class); + assertThat(context).hasBean("syncTools"); + + @SuppressWarnings("unchecked") + List syncTools = (List ) context.getBean("syncTools"); + // Tools have different client prefixes, so both should be present + assertThat(syncTools).hasSize(2); + }); + } + + @Test + void conditionDisabledWhenServerDisabled() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.enabled=false") + .withUserConfiguration(TestToolConfiguration.class) + .run(context -> { + assertThat(context).doesNotHaveBean(ToolCallbackConverterAutoConfiguration.class); + assertThat(context).doesNotHaveBean("syncTools"); + assertThat(context).doesNotHaveBean("asyncTools"); + }); + } + + @Test + void conditionDisabledWhenToolCallbackConvertDisabled() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.tool-callback-converter=false") + .withUserConfiguration(TestToolConfiguration.class) + .run(context -> { + assertThat(context).doesNotHaveBean(ToolCallbackConverterAutoConfiguration.class); + assertThat(context).doesNotHaveBean("syncTools"); + assertThat(context).doesNotHaveBean("asyncTools"); + }); + } + + @Test + void conditionEnabledByDefault() { + this.contextRunner.withUserConfiguration(TestToolConfiguration.class).run(context -> { + assertThat(context).hasSingleBean(ToolCallbackConverterAutoConfiguration.class); + assertThat(context).hasBean("syncTools"); + }); + } + + @Test + void conditionEnabledExplicitly() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.streamable-http.enabled=true", + "spring.ai.mcp.server.streamable-http.tool-callback-converter=true") + .withUserConfiguration(TestToolConfiguration.class) + .run(context -> { + assertThat(context).hasSingleBean(ToolCallbackConverterAutoConfiguration.class); + assertThat(context).hasBean("syncTools"); + }); + } + + @Test + void emptyToolCallbacksConfiguration() { + this.contextRunner.run(context -> { + assertThat(context).hasSingleBean(ToolCallbackConverterAutoConfiguration.class); + assertThat(context).hasBean("syncTools"); + + @SuppressWarnings("unchecked") + List syncTools = (List ) context.getBean("syncTools"); + assertThat(syncTools).isEmpty(); + }); + } + + @Test + void mixedToolCallbacksAndProvidersConfiguration() { + this.contextRunner + .withUserConfiguration(TestToolConfiguration.class, TestToolCallbackProviderConfiguration.class) + .run(context -> { + assertThat(context).hasSingleBean(ToolCallbackConverterAutoConfiguration.class); + assertThat(context).hasBean("syncTools"); + + @SuppressWarnings("unchecked") + List syncTools = (List ) context.getBean("syncTools"); + assertThat(syncTools).hasSize(2); // One from direct callback, one from + // provider + }); + } + + @Configuration + static class TestToolConfiguration { + + @Bean + List testToolCallbacks() { + 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 TestMultipleToolsConfiguration { + + @Bean + List testMultipleToolCallbacks() { + McpSyncClient mockClient1 = Mockito.mock(McpSyncClient.class); + McpSchema.Tool mockTool1 = Mockito.mock(McpSchema.Tool.class); + McpSchema.CallToolResult mockResult1 = Mockito.mock(McpSchema.CallToolResult.class); + + Mockito.when(mockTool1.name()).thenReturn("test-tool-1"); + Mockito.when(mockTool1.description()).thenReturn("Test Tool 1"); + Mockito.when(mockClient1.callTool(Mockito.any(McpSchema.CallToolRequest.class))).thenReturn(mockResult1); + when(mockClient1.getClientInfo()).thenReturn(new McpSchema.Implementation("testClient1", "1.0.0")); + + McpSyncClient mockClient2 = Mockito.mock(McpSyncClient.class); + McpSchema.Tool mockTool2 = Mockito.mock(McpSchema.Tool.class); + McpSchema.CallToolResult mockResult2 = Mockito.mock(McpSchema.CallToolResult.class); + + Mockito.when(mockTool2.name()).thenReturn("test-tool-2"); + Mockito.when(mockTool2.description()).thenReturn("Test Tool 2"); + Mockito.when(mockClient2.callTool(Mockito.any(McpSchema.CallToolRequest.class))).thenReturn(mockResult2); + when(mockClient2.getClientInfo()).thenReturn(new McpSchema.Implementation("testClient2", "1.0.0")); + + return List.of(new SyncMcpToolCallback(mockClient1, mockTool1), + new SyncMcpToolCallback(mockClient2, mockTool2)); + } + + } + + @Configuration + static class TestDuplicateToolsConfiguration { + + @Bean + List testDuplicateToolCallbacks() { + McpSyncClient mockClient1 = Mockito.mock(McpSyncClient.class); + McpSchema.Tool mockTool1 = Mockito.mock(McpSchema.Tool.class); + McpSchema.CallToolResult mockResult1 = Mockito.mock(McpSchema.CallToolResult.class); + + Mockito.when(mockTool1.name()).thenReturn("duplicate-tool"); + Mockito.when(mockTool1.description()).thenReturn("First Tool"); + Mockito.when(mockClient1.callTool(Mockito.any(McpSchema.CallToolRequest.class))).thenReturn(mockResult1); + when(mockClient1.getClientInfo()).thenReturn(new McpSchema.Implementation("testClient1", "1.0.0")); + + McpSyncClient mockClient2 = Mockito.mock(McpSyncClient.class); + McpSchema.Tool mockTool2 = Mockito.mock(McpSchema.Tool.class); + McpSchema.CallToolResult mockResult2 = Mockito.mock(McpSchema.CallToolResult.class); + + Mockito.when(mockTool2.name()).thenReturn("duplicate-tool"); + Mockito.when(mockTool2.description()).thenReturn("Second Tool"); + Mockito.when(mockClient2.callTool(Mockito.any(McpSchema.CallToolRequest.class))).thenReturn(mockResult2); + when(mockClient2.getClientInfo()).thenReturn(new McpSchema.Implementation("testClient2", "1.0.0")); + + return List.of(new SyncMcpToolCallback(mockClient1, mockTool1), + new SyncMcpToolCallback(mockClient2, mockTool2)); + } + + } + + @Configuration + static class TestToolCallbackProviderConfiguration { + + @Bean + ToolCallbackProvider testToolCallbackProvider() { + return () -> { + 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("provider-tool"); + Mockito.when(mockTool.description()).thenReturn("Provider Tool"); + Mockito.when(mockClient.callTool(Mockito.any(McpSchema.CallToolRequest.class))).thenReturn(mockResult); + when(mockClient.getClientInfo()).thenReturn(new McpSchema.Implementation("testClient", "1.0.0")); + + return new ToolCallback[] { new SyncMcpToolCallback(mockClient, mockTool) }; + }; + } + + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webflux/pom.xml b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webflux/pom.xml new file mode 100644 index 00000000000..a9b77576d82 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webflux/pom.xml @@ -0,0 +1,100 @@ + + + diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webflux/src/main/java/org/springframework/ai/mcp/server/streamable/webflux/autoconfigure/McpStreamableServerWebFluxAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webflux/src/main/java/org/springframework/ai/mcp/server/streamable/webflux/autoconfigure/McpStreamableServerWebFluxAutoConfiguration.java new file mode 100644 index 00000000000..ccca513d4c7 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webflux/src/main/java/org/springframework/ai/mcp/server/streamable/webflux/autoconfigure/McpStreamableServerWebFluxAutoConfiguration.java @@ -0,0 +1,69 @@ +/* + * 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.streamable.webflux.autoconfigure; + +import org.springframework.ai.mcp.server.streamable.autoconfigure.McpStreamableServerProperties; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +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.core.log.LogAccessor; +import org.springframework.web.reactive.function.server.RouterFunction; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.transport.WebFluxStreamableServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema; + +/** + * @author Christian Tzolov + */ +@ConditionalOnClass({ McpSchema.class, McpSyncServer.class }) +@EnableConfigurationProperties(McpStreamableServerProperties.class) +@ConditionalOnProperty(prefix = McpStreamableServerProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) +public class McpStreamableServerWebFluxAutoConfiguration { + + private static final LogAccessor logger = new LogAccessor(McpStreamableServerWebFluxAutoConfiguration.class); + + @Bean + @ConditionalOnMissingBean + public WebFluxStreamableServerTransportProvider webFluxStreamableServerTransportProvider( + ObjectProvider4.0.0 ++ +org.springframework.ai +spring-ai-parent +1.1.0-SNAPSHOT +../../../pom.xml +spring-ai-autoconfigure-mcp-streamable-server-webflux +jar +Spring AI MCP Streamable Server WebFlux Auto Configuration +Spring AI MCP Streamable Server WebFlux Auto Configuration +https://github.com/spring-projects/spring-ai + ++ + +https://github.com/spring-projects/spring-ai +git://github.com/spring-projects/spring-ai.git +git@github.com:spring-projects/spring-ai.git ++ + + ++ + +org.springframework.boot +spring-boot-starter ++ + +org.springframework.ai +spring-ai-mcp +${project.parent.version} +true ++ + +org.springframework.ai +spring-ai-autoconfigure-mcp-streamable-server-common +${project.parent.version} ++ + +io.modelcontextprotocol.sdk +mcp-spring-webflux +true ++ + +org.springframework.boot +spring-boot-configuration-processor +true ++ + + + +org.springframework.boot +spring-boot-autoconfigure-processor +true ++ + + +org.springframework.ai +spring-ai-test +${project.parent.version} +test ++ + +org.springframework.ai +spring-ai-autoconfigure-mcp-client-webflux +${project.parent.version} +test ++ + +org.springframework.boot +spring-boot-starter-webflux +test ++ + +org.springframework.boot +spring-boot-starter-test +test ++ + +net.javacrumbs.json-unit +json-unit-assertj +${json-unit-assertj.version} +test +objectMapperProvider, McpStreamableServerProperties serverProperties) { + + ObjectMapper objectMapper = objectMapperProvider.getIfAvailable(ObjectMapper::new); + + return WebFluxStreamableServerTransportProvider.builder() + .objectMapper(objectMapper) + .messageEndpoint(serverProperties.getMcpEndpoint()) + .keepAliveInterval(serverProperties.getKeepAliveInterval()) + .disallowDelete(serverProperties.isDisallowDelete()) + .build(); + } + + // Router function for streamable http transport used by Spring WebFlux to start an + // HTTP server. + @Bean + public RouterFunction> webFluxStreamableServerRouterFunction( + WebFluxStreamableServerTransportProvider webFluxProvider) { + return webFluxProvider.getRouterFunction(); + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webflux/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webflux/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..aa950fe41b3 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webflux/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,17 @@ +# +# 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. +# +org.springframework.ai.mcp.server.streamable.webflux.autoconfigure.McpStreamableServerWebFluxAutoConfiguration + diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webflux/src/test/java/org/springframework/ai/mcp/server/streamable/webflux/autoconfigure/McpStreamableServerWebFluxAutoConfigurationIT.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webflux/src/test/java/org/springframework/ai/mcp/server/streamable/webflux/autoconfigure/McpStreamableServerWebFluxAutoConfigurationIT.java new file mode 100644 index 00000000000..59d1e24fe5f --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webflux/src/test/java/org/springframework/ai/mcp/server/streamable/webflux/autoconfigure/McpStreamableServerWebFluxAutoConfigurationIT.java @@ -0,0 +1,178 @@ +/* + * 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.streamable.webflux.autoconfigure; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.server.transport.WebFluxStreamableServerTransportProvider; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.web.reactive.function.server.RouterFunction; + +import static org.assertj.core.api.Assertions.assertThat; + +class McpStreamableServerWebFluxAutoConfigurationIT { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(McpStreamableServerWebFluxAutoConfiguration.class)); + + @Test + void defaultConfiguration() { + this.contextRunner.run(context -> { + assertThat(context).hasSingleBean(WebFluxStreamableServerTransportProvider.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void objectMapperConfiguration() { + this.contextRunner.withBean(ObjectMapper.class, ObjectMapper::new).run(context -> { + assertThat(context).hasSingleBean(WebFluxStreamableServerTransportProvider.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void serverDisableConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.enabled=false").run(context -> { + assertThat(context).doesNotHaveBean(WebFluxStreamableServerTransportProvider.class); + assertThat(context).doesNotHaveBean(RouterFunction.class); + }); + } + + @Test + void serverBaseUrlConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.mcpEndpoint=/test") + .run(context -> assertThat(context.getBean(WebFluxStreamableServerTransportProvider.class)) + .extracting("mcpEndpoint") + .isEqualTo("/test")); + } + + @Test + void keepAliveIntervalConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.keep-alive-interval=PT30S") + .run(context -> { + assertThat(context).hasSingleBean(WebFluxStreamableServerTransportProvider.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void disallowDeleteConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.disallow-delete=true") + .run(context -> { + assertThat(context).hasSingleBean(WebFluxStreamableServerTransportProvider.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void disallowDeleteFalseConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.disallow-delete=false") + .run(context -> { + assertThat(context).hasSingleBean(WebFluxStreamableServerTransportProvider.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void customObjectMapperIsUsed() { + ObjectMapper customObjectMapper = new ObjectMapper(); + this.contextRunner.withBean("customObjectMapper", ObjectMapper.class, () -> customObjectMapper).run(context -> { + assertThat(context).hasSingleBean(WebFluxStreamableServerTransportProvider.class); + assertThat(context).hasSingleBean(RouterFunction.class); + // Verify the custom ObjectMapper is used + assertThat(context.getBean(ObjectMapper.class)).isSameAs(customObjectMapper); + }); + } + + @Test + void conditionalOnClassPresent() { + this.contextRunner.run(context -> { + // Verify that the configuration is loaded when required classes are present + assertThat(context).hasSingleBean(WebFluxStreamableServerTransportProvider.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void conditionalOnMissingBeanWorks() { + // Test that @ConditionalOnMissingBean works by providing a custom bean + this.contextRunner + .withBean("customWebFluxProvider", WebFluxStreamableServerTransportProvider.class, + () -> WebFluxStreamableServerTransportProvider.builder() + .objectMapper(new ObjectMapper()) + .messageEndpoint("/custom") + .build()) + .run(context -> { + assertThat(context).hasSingleBean(WebFluxStreamableServerTransportProvider.class); + // Should use the custom bean, not create a new one + WebFluxStreamableServerTransportProvider provider = context + .getBean(WebFluxStreamableServerTransportProvider.class); + assertThat(provider).extracting("mcpEndpoint").isEqualTo("/custom"); + }); + } + + @Test + void routerFunctionIsCreatedFromProvider() { + this.contextRunner.run(context -> { + assertThat(context).hasSingleBean(RouterFunction.class); + assertThat(context).hasSingleBean(WebFluxStreamableServerTransportProvider.class); + + // Verify that the RouterFunction is created from the provider + RouterFunction> routerFunction = context.getBean(RouterFunction.class); + assertThat(routerFunction).isNotNull(); + }); + } + + @Test + void allPropertiesConfiguration() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.streamable-http.mcpEndpoint=/custom-endpoint", + "spring.ai.mcp.server.streamable-http.keep-alive-interval=PT45S", + "spring.ai.mcp.server.streamable-http.disallow-delete=true") + .run(context -> { + WebFluxStreamableServerTransportProvider provider = context + .getBean(WebFluxStreamableServerTransportProvider.class); + assertThat(provider).extracting("mcpEndpoint").isEqualTo("/custom-endpoint"); + // Verify beans are created successfully with all properties + assertThat(context).hasSingleBean(WebFluxStreamableServerTransportProvider.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void enabledPropertyDefaultsToTrue() { + // Test that when enabled property is not set, it defaults to true (matchIfMissing + // = true) + this.contextRunner.run(context -> { + assertThat(context).hasSingleBean(WebFluxStreamableServerTransportProvider.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void enabledPropertyExplicitlyTrue() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.enabled=true").run(context -> { + assertThat(context).hasSingleBean(WebFluxStreamableServerTransportProvider.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webflux/src/test/java/org/springframework/ai/mcp/server/streamable/webflux/autoconfigure/StreamableWebClientAndWebFluxServerIT.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webflux/src/test/java/org/springframework/ai/mcp/server/streamable/webflux/autoconfigure/StreamableWebClientAndWebFluxServerIT.java new file mode 100644 index 00000000000..fde56a70fc0 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webflux/src/test/java/org/springframework/ai/mcp/server/streamable/webflux/autoconfigure/StreamableWebClientAndWebFluxServerIT.java @@ -0,0 +1,510 @@ +/* + * 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.streamable.webflux.autoconfigure; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration; +import org.springframework.ai.mcp.client.common.autoconfigure.McpToolCallbackAutoConfiguration; +import org.springframework.ai.mcp.client.webflux.autoconfigure.StreamableHttpWebFluxTransportAutoConfiguration; +import org.springframework.ai.mcp.customizer.McpSyncClientCustomizer; +import org.springframework.ai.mcp.server.streamable.autoconfigure.McpStreamableServerAutoConfiguration; +import org.springframework.ai.mcp.server.streamable.autoconfigure.McpStreamableServerProperties; +import org.springframework.ai.mcp.server.streamable.autoconfigure.ToolCallbackConverterAutoConfiguration; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.core.ResolvableType; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; +import org.springframework.test.util.TestSocketUtils; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.transport.WebFluxStreamableServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.CompleteRequest; +import io.modelcontextprotocol.spec.McpSchema.CompleteResult; +import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult; +import io.modelcontextprotocol.spec.McpSchema.ElicitRequest; +import io.modelcontextprotocol.spec.McpSchema.ElicitResult; +import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; +import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; +import io.modelcontextprotocol.spec.McpSchema.LoggingLevel; +import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification; +import io.modelcontextprotocol.spec.McpSchema.ModelHint; +import io.modelcontextprotocol.spec.McpSchema.ModelPreferences; +import io.modelcontextprotocol.spec.McpSchema.ProgressNotification; +import io.modelcontextprotocol.spec.McpSchema.PromptArgument; +import io.modelcontextprotocol.spec.McpSchema.PromptMessage; +import io.modelcontextprotocol.spec.McpSchema.PromptReference; +import io.modelcontextprotocol.spec.McpSchema.Resource; +import io.modelcontextprotocol.spec.McpSchema.Role; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import io.modelcontextprotocol.spec.McpSchema.Tool; +import net.javacrumbs.jsonunit.core.Option; +import reactor.netty.DisposableServer; +import reactor.netty.http.server.HttpServer; + +public class StreamableWebClientAndWebFluxServerIT { + + private static final Logger logger = LoggerFactory.getLogger(StreamableWebClientAndWebFluxServerIT.class); + + private final ApplicationContextRunner serverContextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(McpStreamableServerAutoConfiguration.class, + ToolCallbackConverterAutoConfiguration.class, McpStreamableServerWebFluxAutoConfiguration.class)); + + private final ApplicationContextRunner clientApplicationContext = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(McpToolCallbackAutoConfiguration.class, + McpClientAutoConfiguration.class, StreamableHttpWebFluxTransportAutoConfiguration.class)); + + @Test + void clientServerCapabilities() { + + int serverPort = TestSocketUtils.findAvailableTcpPort(); + + this.serverContextRunner.withUserConfiguration(TestMcpServerConfiguration.class) + .withPropertyValues(// @formatter:off + "spring.ai.mcp.server.streamable-http.mcp-endpoint=/mcp", + "spring.ai.mcp.server.streamable-http.name=test-mcp-server", + "spring.ai.mcp.server.streamable-http.keep-alive-interval=1s", + "spring.ai.mcp.server.streamable-http.version=1.0.0") // @formatter:on + .run(serverContext -> { + // Verify all required beans are present + assertThat(serverContext).hasSingleBean(WebFluxStreamableServerTransportProvider.class); + assertThat(serverContext).hasSingleBean(RouterFunction.class); + assertThat(serverContext).hasSingleBean(McpSyncServer.class); + + // Verify server properties are configured correctly + McpStreamableServerProperties properties = serverContext.getBean(McpStreamableServerProperties.class); + assertThat(properties.getName()).isEqualTo("test-mcp-server"); + assertThat(properties.getVersion()).isEqualTo("1.0.0"); + assertThat(properties.getMcpEndpoint()).isEqualTo("/mcp"); + + var httpServer = startHttpServer(serverContext, serverPort); + + clientApplicationContext.withUserConfiguration(TestMcpClientConfiguration.class) + .withPropertyValues(// @formatter:off + "spring.ai.mcp.client.streamable-http.connections.server1.url=http://localhost:" + serverPort, + "spring.ai.mcp.client.initialized=false") // @formatter:on + .run(clientContext -> { + McpSyncClient mcpClient = getMcpSyncClient(clientContext); + assertThat(mcpClient).isNotNull(); + var initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + // TOOLS / SAMPLING / ELICITATION + + // tool list + assertThat(mcpClient.listTools().tools()).hasSize(2); + assertThat(mcpClient.listTools().tools()) + .contains(Tool.builder().name("tool1").description("tool1 description").inputSchema(""" + { + "": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + } + """).build()); + + // Call a tool that sends progress notifications + CallToolRequest toolRequest = CallToolRequest.builder() + .name("tool1") + .arguments(Map.of()) + .progressToken("test-progress-token") + .build(); + + CallToolResult response = mcpClient.callTool(toolRequest); + + assertThat(response).isNotNull(); + assertThat(response.isError()).isNull(); + String responseText = ((TextContent) response.content().get(0)).text(); + assertThat(responseText).contains("CALL RESPONSE"); + assertThat(responseText).contains("Response Test Sampling Message with model hint OpenAi"); + assertThat(responseText).contains("ElicitResult"); + + // TOOL STRUCTURED OUTPUT + // Call tool with valid structured output + CallToolResult calculatorToolResponse = mcpClient + .callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3"))); + + assertThat(calculatorToolResponse).isNotNull(); + assertThat(calculatorToolResponse.isError()).isFalse(); + + assertThat(calculatorToolResponse.structuredContent()).isNotNull(); + + assertThat(calculatorToolResponse.structuredContent()).containsEntry("result", 5.0) + .containsEntry("operation", "2 + 3") + .containsEntry("timestamp", "2024-01-01T10:00:00Z"); + + assertThatJson(calculatorToolResponse.structuredContent()).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + {"result":5.0,"operation":"2 + 3","timestamp":"2024-01-01T10:00:00Z"}""")); + + // PROGRESS + TestContext testContext = clientContext.getBean(TestContext.class); + assertThat(testContext.progressLatch.await(5, TimeUnit.SECONDS)) + .as("Should receive progress notifications in reasonable time") + .isTrue(); + assertThat(testContext.progressNotifications).hasSize(3); + + Map notificationMap = testContext.progressNotifications + .stream() + .collect(Collectors.toMap(n -> n.message(), n -> n)); + + // First notification should be 0.0/1.0 progress + assertThat(notificationMap.get("tool call start").progressToken()) + .isEqualTo("test-progress-token"); + assertThat(notificationMap.get("tool call start").progress()).isEqualTo(0.0); + assertThat(notificationMap.get("tool call start").total()).isEqualTo(1.0); + assertThat(notificationMap.get("tool call start").message()).isEqualTo("tool call start"); + + // Second notification should be 1.0/1.0 progress + assertThat(notificationMap.get("elicitation completed").progressToken()) + .isEqualTo("test-progress-token"); + assertThat(notificationMap.get("elicitation completed").progress()).isEqualTo(0.5); + assertThat(notificationMap.get("elicitation completed").total()).isEqualTo(1.0); + assertThat(notificationMap.get("elicitation completed").message()) + .isEqualTo("elicitation completed"); + + // Third notification should be 0.5/1.0 progress + assertThat(notificationMap.get("sampling completed").progressToken()) + .isEqualTo("test-progress-token"); + assertThat(notificationMap.get("sampling completed").progress()).isEqualTo(1.0); + assertThat(notificationMap.get("sampling completed").total()).isEqualTo(1.0); + assertThat(notificationMap.get("sampling completed").message()).isEqualTo("sampling completed"); + + // PROMPT / COMPLETION + + // list prompts + assertThat(mcpClient.listPrompts()).isNotNull(); + assertThat(mcpClient.listPrompts().prompts()).hasSize(1); + + // get prompt + GetPromptResult promptResult = mcpClient + .getPrompt(new GetPromptRequest("code-completion", Map.of("language", "java"))); + assertThat(promptResult).isNotNull(); + + // completion + CompleteRequest completeRequest = new CompleteRequest( + new PromptReference("ref/prompt", "code-completion", "Code completion"), + new CompleteRequest.CompleteArgument("language", "py")); + + CompleteResult completeResult = mcpClient.completeCompletion(completeRequest); + + assertThat(completeResult).isNotNull(); + assertThat(completeResult.completion().total()).isEqualTo(10); + assertThat(completeResult.completion().values()).containsExactly("python", "pytorch", "pyside"); + assertThat(completeResult.meta()).isNull(); + + // logging message + var logMessage = testContext.loggingNotificationRef.get(); + assertThat(logMessage).isNotNull(); + assertThat(logMessage.level()).isEqualTo(LoggingLevel.INFO); + assertThat(logMessage.logger()).isEqualTo("test-logger"); + assertThat(logMessage.data()).contains("User prompt"); + + // RESOURCES + assertThat(mcpClient.listResources()).isNotNull(); + assertThat(mcpClient.listResources().resources()).hasSize(1); + assertThat(mcpClient.listResources().resources().get(0)) + .isEqualToComparingFieldByFieldRecursively(Resource.builder() + .uri("file://resource") + .name("Test Resource") + .mimeType("text/plain") + .description("Test resource description") + .build()); + + }); + + stopHttpServer(httpServer); + }); + } + + public static class TestMcpServerConfiguration { + + @Bean + public List myTools() { + + // Tool 1 + McpServerFeatures.SyncToolSpecification tool1 = McpServerFeatures.SyncToolSpecification.builder() + .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(""" + { + "": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + } + """).build()) + .callHandler((exchange, request) -> { + + exchange.progressNotification( + new ProgressNotification("test-progress-token", 0.0, 1.0, "tool call start")); + + exchange.ping(); // call client ping + + // call elicitation + var elicitationRequest = McpSchema.ElicitRequest.builder() + .message("Test message") + .requestedSchema( + Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) + .build(); + + ElicitResult elicitationResult = exchange.createElicitation(elicitationRequest); + + exchange.progressNotification( + new ProgressNotification("test-progress-token", 0.50, 1.0, "elicitation completed")); + + // call sampling + var createMessageRequest = McpSchema.CreateMessageRequest.builder() + .messages(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, + new McpSchema.TextContent("Test Sampling Message")))) + .modelPreferences(ModelPreferences.builder() + .hints(List.of(ModelHint.of("OpenAi"), ModelHint.of("Ollama"))) + .costPriority(1.0) + .speedPriority(1.0) + .intelligencePriority(1.0) + .build()) + .build(); + + CreateMessageResult samplingResponse = exchange.createMessage(createMessageRequest); + + exchange.progressNotification( + new ProgressNotification("test-progress-token", 1.0, 1.0, "sampling completed")); + + return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent( + "CALL RESPONSE: " + samplingResponse.toString() + ", " + elicitationResult.toString())), + null); + }) + .build(); + + // Tool 2 + + // Create a tool with output schema + Map outputSchema = Map.of( + "type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation", + Map.of("type", "string"), "timestamp", Map.of("type", "string")), + "required", List.of("result", "operation")); + + Tool calculatorTool = Tool.builder() + .name("calculator") + .description("Performs mathematical calculations") + .outputSchema(outputSchema) + .build(); + + McpServerFeatures.SyncToolSpecification tool2 = McpServerFeatures.SyncToolSpecification.builder() + .tool(calculatorTool) + .callHandler((exchange, request) -> { + String expression = (String) request.arguments().getOrDefault("expression", "2 + 3"); + double result = this.evaluateExpression(expression); + return CallToolResult.builder() + .structuredContent( + Map.of("result", result, "operation", expression, "timestamp", "2024-01-01T10:00:00Z")) + .build(); + }) + .build(); + + return List.of(tool1, tool2); + } + + @Bean + public List myPrompts() { + + var prompt = new McpSchema.Prompt("code-completion", "Code completion", "this is code review prompt", + List.of(new PromptArgument("language", "Language", "string", false))); + + var promptSpecification = new McpServerFeatures.SyncPromptSpecification(prompt, + (exchange, getPromptRequest) -> { + String languageArgument = (String) getPromptRequest.arguments().get("language"); + if (languageArgument == null) { + languageArgument = "java"; + } + + // send logging notification + exchange.loggingNotification(LoggingMessageNotification.builder() + // .level(LoggingLevel.DEBUG) + .logger("test-logger") + .data("User prompt: Hello " + languageArgument + "! How can I assist you today?") + .build()); + + var userMessage = new PromptMessage(Role.USER, + new TextContent("Hello " + languageArgument + "! How can I assist you today?")); + return new GetPromptResult("A personalized greeting message", List.of(userMessage)); + }); + + return List.of(promptSpecification); + } + + @Bean + public List myCompletions() { + var completion = new McpServerFeatures.SyncCompletionSpecification( + new McpSchema.PromptReference("ref/prompt", "code-completion", "Code completion"), + (exchange, request) -> { + var expectedValues = List.of("python", "pytorch", "pyside"); + return new McpSchema.CompleteResult(new CompleteResult.CompleteCompletion(expectedValues, 10, // total + true // hasMore + )); + }); + + return List.of(completion); + } + + @Bean + public List myResources() { + + var systemInfoResource = Resource.builder() + .uri("file://resource") + .name("Test Resource") + .mimeType("text/plain") + .description("Test resource description") + .build(); + + var resourceSpecification = new McpServerFeatures.SyncResourceSpecification(systemInfoResource, + (exchange, request) -> { + try { + var systemInfo = Map.of("os", System.getProperty("os.name"), "os_version", + System.getProperty("os.version"), "java_version", + System.getProperty("java.version")); + String jsonContent = new ObjectMapper().writeValueAsString(systemInfo); + return new McpSchema.ReadResourceResult(List.of(new McpSchema.TextResourceContents( + request.uri(), "application/json", jsonContent))); + } + catch (Exception e) { + throw new RuntimeException("Failed to generate system info", e); + } + }); + + return List.of(resourceSpecification); + } + + private double evaluateExpression(String expression) { + // Simple expression evaluator for testing + return switch (expression) { + case "2 + 3" -> 5.0; + case "10 * 2" -> 20.0; + case "7 + 8" -> 15.0; + case "5 + 3" -> 8.0; + default -> 0.0; + }; + } + + } + + private static class TestContext { + + final AtomicReference loggingNotificationRef = new AtomicReference<>(); + + final CountDownLatch progressLatch = new CountDownLatch(3); + + final List progressNotifications = new CopyOnWriteArrayList<>(); + + } + + public static class TestMcpClientConfiguration { + + @Bean + public TestContext testContext() { + return new TestContext(); + } + + @Bean + McpSyncClientCustomizer clientCustomizer(TestContext testContext) { + + return (name, mcpClientSpec) -> { + + // Add logging handler + mcpClientSpec = mcpClientSpec.loggingConsumer(logingMessage -> { + testContext.loggingNotificationRef.set(logingMessage); + logger.info("MCP LOGGING: [{}] {}", logingMessage.level(), logingMessage.data()); + }); + + // Add sampling handler + Function samplingHandler = llmRequest -> { + String userPrompt = ((McpSchema.TextContent) llmRequest.messages().get(0).content()).text(); + String modelHint = llmRequest.modelPreferences().hints().get(0).name(); + return CreateMessageResult.builder() + .content(new McpSchema.TextContent("Response " + userPrompt + " with model hint " + modelHint)) + .build(); + }; + + mcpClientSpec.sampling(samplingHandler); + + // Add elicitation handler + Function elicitationHandler = request -> { + assertThat(request.message()).isNotEmpty(); + assertThat(request.requestedSchema()).isNotNull(); + return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("message", request.message())); + }; + + mcpClientSpec.elicitation(elicitationHandler); + + // Progress notification + mcpClientSpec.progressConsumer(progressNotification -> { + testContext.progressNotifications.add(progressNotification); + testContext.progressLatch.countDown(); + }); + }; + } + + } + + // Helper methods to start and stop the HTTP server + private static DisposableServer startHttpServer(ApplicationContext serverContext, int port) { + WebFluxStreamableServerTransportProvider mcpStreamableServerTransport = serverContext + .getBean(WebFluxStreamableServerTransportProvider.class); + HttpHandler httpHandler = RouterFunctions.toHttpHandler(mcpStreamableServerTransport.getRouterFunction()); + ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler); + return HttpServer.create().port(port).handle(adapter).bindNow(); + } + + private static void stopHttpServer(DisposableServer server) { + if (server != null) { + server.disposeNow(); + } + } + + // Helper method to get the MCP sync client + private static McpSyncClient getMcpSyncClient(ApplicationContext clientContext) { + ObjectProvider > mcpClients = clientContext + .getBeanProvider(ResolvableType.forClassWithGenerics(List.class, McpSyncClient.class)); + return mcpClients.getIfAvailable().get(0); + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webmvc/pom.xml b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webmvc/pom.xml new file mode 100644 index 00000000000..43a10044f79 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webmvc/pom.xml @@ -0,0 +1,80 @@ + +
+ diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webmvc/src/main/java/org/springframework/ai/mcp/server/streamable/webmvc/autoconfigure/McpStreamableServerWebMvcAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webmvc/src/main/java/org/springframework/ai/mcp/server/streamable/webmvc/autoconfigure/McpStreamableServerWebMvcAutoConfiguration.java new file mode 100644 index 00000000000..147e392d949 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webmvc/src/main/java/org/springframework/ai/mcp/server/streamable/webmvc/autoconfigure/McpStreamableServerWebMvcAutoConfiguration.java @@ -0,0 +1,69 @@ +/* + * 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.streamable.webmvc.autoconfigure; + +import org.springframework.ai.mcp.server.streamable.autoconfigure.McpStreamableServerProperties; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +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.core.log.LogAccessor; +import org.springframework.web.servlet.function.RouterFunction; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.transport.WebMvcStreamableServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema; + +/** + * @author Christian Tzolov + */ +@ConditionalOnClass({ McpSchema.class, McpSyncServer.class }) +@EnableConfigurationProperties(McpStreamableServerProperties.class) +@ConditionalOnProperty(prefix = McpStreamableServerProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true", + matchIfMissing = true) +public class McpStreamableServerWebMvcAutoConfiguration { + + private static final LogAccessor logger = new LogAccessor(McpStreamableServerWebMvcAutoConfiguration.class); + + @Bean + @ConditionalOnMissingBean + public WebMvcStreamableServerTransportProvider webMvcStreamableServerTransportProvider( + ObjectProvider4.0.0 ++ +org.springframework.ai +spring-ai-parent +1.1.0-SNAPSHOT +../../../pom.xml +spring-ai-autoconfigure-mcp-streamable-server-webmvc +jar +Spring AI MCP Streamable Server WebMvc Auto Configuration +Spring AI MCP Streamable Server WebMvc Auto Configuration +https://github.com/spring-projects/spring-ai + ++ + +https://github.com/spring-projects/spring-ai +git://github.com/spring-projects/spring-ai.git +git@github.com:spring-projects/spring-ai.git ++ + + ++ + +org.springframework.boot +spring-boot-starter ++ + +org.springframework.ai +spring-ai-mcp +${project.parent.version} +true ++ + +org.springframework.ai +spring-ai-autoconfigure-mcp-streamable-server-common +${project.parent.version} ++ + +io.modelcontextprotocol.sdk +mcp-spring-webmvc +true ++ + +org.springframework.boot +spring-boot-configuration-processor +true ++ + + + +org.springframework.boot +spring-boot-autoconfigure-processor +true ++ + +org.springframework.ai +spring-ai-test +${project.parent.version} +test ++ + +net.javacrumbs.json-unit +json-unit-assertj +${json-unit-assertj.version} +test +objectMapperProvider, McpStreamableServerProperties serverProperties) { + + ObjectMapper objectMapper = objectMapperProvider.getIfAvailable(ObjectMapper::new); + + return WebMvcStreamableServerTransportProvider.builder() + .objectMapper(objectMapper) + .mcpEndpoint(serverProperties.getMcpEndpoint()) + .keepAliveInterval(serverProperties.getKeepAliveInterval()) + .disallowDelete(serverProperties.isDisallowDelete()) + .build(); + } + + // Router function for streamable http transport used by Spring WebFlux to start an + // HTTP server. + @Bean + public RouterFunction> webMvcStreamableServerRouterFunction( + WebMvcStreamableServerTransportProvider webMvcProvider) { + return webMvcProvider.getRouterFunction(); + } + +} diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webmvc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webmvc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..b9e0b6255d4 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webmvc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,17 @@ +# +# 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. +# +org.springframework.ai.mcp.server.streamable.webmvc.autoconfigure.McpStreamableServerWebMvcAutoConfiguration + diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webmvc/src/test/java/org/springframework/ai/mcp/server/streamable/webmvc/autoconfigure/McpStreamableServerWebMvcAutoConfigurationIT.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webmvc/src/test/java/org/springframework/ai/mcp/server/streamable/webmvc/autoconfigure/McpStreamableServerWebMvcAutoConfigurationIT.java new file mode 100644 index 00000000000..bee802e3bf6 --- /dev/null +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webmvc/src/test/java/org/springframework/ai/mcp/server/streamable/webmvc/autoconfigure/McpStreamableServerWebMvcAutoConfigurationIT.java @@ -0,0 +1,178 @@ +/* + * 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.streamable.webmvc.autoconfigure; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.server.transport.WebMvcStreamableServerTransportProvider; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.web.servlet.function.RouterFunction; + +import static org.assertj.core.api.Assertions.assertThat; + +class McpStreamableServerWebMvcAutoConfigurationIT { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(McpStreamableServerWebMvcAutoConfiguration.class)); + + @Test + void defaultConfiguration() { + this.contextRunner.run(context -> { + assertThat(context).hasSingleBean(WebMvcStreamableServerTransportProvider.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void objectMapperConfiguration() { + this.contextRunner.withBean(ObjectMapper.class, ObjectMapper::new).run(context -> { + assertThat(context).hasSingleBean(WebMvcStreamableServerTransportProvider.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void serverDisableConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.enabled=false").run(context -> { + assertThat(context).doesNotHaveBean(WebMvcStreamableServerTransportProvider.class); + assertThat(context).doesNotHaveBean(RouterFunction.class); + }); + } + + @Test + void serverBaseUrlConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.mcpEndpoint=/test") + .run(context -> assertThat(context.getBean(WebMvcStreamableServerTransportProvider.class)) + .extracting("mcpEndpoint") + .isEqualTo("/test")); + } + + @Test + void keepAliveIntervalConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.keep-alive-interval=PT30S") + .run(context -> { + assertThat(context).hasSingleBean(WebMvcStreamableServerTransportProvider.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void disallowDeleteConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.disallow-delete=true") + .run(context -> { + assertThat(context).hasSingleBean(WebMvcStreamableServerTransportProvider.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void disallowDeleteFalseConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.disallow-delete=false") + .run(context -> { + assertThat(context).hasSingleBean(WebMvcStreamableServerTransportProvider.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void customObjectMapperIsUsed() { + ObjectMapper customObjectMapper = new ObjectMapper(); + this.contextRunner.withBean("customObjectMapper", ObjectMapper.class, () -> customObjectMapper).run(context -> { + assertThat(context).hasSingleBean(WebMvcStreamableServerTransportProvider.class); + assertThat(context).hasSingleBean(RouterFunction.class); + // Verify the custom ObjectMapper is used + assertThat(context.getBean(ObjectMapper.class)).isSameAs(customObjectMapper); + }); + } + + @Test + void conditionalOnClassPresent() { + this.contextRunner.run(context -> { + // Verify that the configuration is loaded when required classes are present + assertThat(context).hasSingleBean(WebMvcStreamableServerTransportProvider.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void conditionalOnMissingBeanWorks() { + // Test that @ConditionalOnMissingBean works by providing a custom bean + this.contextRunner + .withBean("customWebFluxProvider", WebMvcStreamableServerTransportProvider.class, + () -> WebMvcStreamableServerTransportProvider.builder() + .objectMapper(new ObjectMapper()) + .mcpEndpoint("/custom") + .build()) + .run(context -> { + assertThat(context).hasSingleBean(WebMvcStreamableServerTransportProvider.class); + // Should use the custom bean, not create a new one + WebMvcStreamableServerTransportProvider provider = context + .getBean(WebMvcStreamableServerTransportProvider.class); + assertThat(provider).extracting("mcpEndpoint").isEqualTo("/custom"); + }); + } + + @Test + void routerFunctionIsCreatedFromProvider() { + this.contextRunner.run(context -> { + assertThat(context).hasSingleBean(RouterFunction.class); + assertThat(context).hasSingleBean(WebMvcStreamableServerTransportProvider.class); + + // Verify that the RouterFunction is created from the provider + RouterFunction> routerFunction = context.getBean(RouterFunction.class); + assertThat(routerFunction).isNotNull(); + }); + } + + @Test + void allPropertiesConfiguration() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.streamable-http.mcpEndpoint=/custom-endpoint", + "spring.ai.mcp.server.streamable-http.keep-alive-interval=PT45S", + "spring.ai.mcp.server.streamable-http.disallow-delete=true") + .run(context -> { + WebMvcStreamableServerTransportProvider provider = context + .getBean(WebMvcStreamableServerTransportProvider.class); + assertThat(provider).extracting("mcpEndpoint").isEqualTo("/custom-endpoint"); + // Verify beans are created successfully with all properties + assertThat(context).hasSingleBean(WebMvcStreamableServerTransportProvider.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void enabledPropertyDefaultsToTrue() { + // Test that when enabled property is not set, it defaults to true (matchIfMissing + // = true) + this.contextRunner.run(context -> { + assertThat(context).hasSingleBean(WebMvcStreamableServerTransportProvider.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + + @Test + void enabledPropertyExplicitlyTrue() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.streamable-http.enabled=true").run(context -> { + assertThat(context).hasSingleBean(WebMvcStreamableServerTransportProvider.class); + assertThat(context).hasSingleBean(RouterFunction.class); + }); + } + +} diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java b/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java index 2f8f366d076..c7ab81072ec 100644 --- a/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java @@ -27,6 +27,7 @@ import io.modelcontextprotocol.client.McpSyncClient; import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolSpecification; +import io.modelcontextprotocol.server.McpStatelessServerFeatures; import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.Role; @@ -187,6 +188,32 @@ public static McpServerFeatures.SyncToolSpecification toSyncToolSpecification(To }); } + public static McpStatelessServerFeatures.SyncToolSpecification toStatelessSyncToolSpecification( + ToolCallback toolCallback, MimeType mimeType) { + + var tool = McpSchema.Tool.builder() + .name(toolCallback.getToolDefinition().name()) + .description(toolCallback.getToolDefinition().description()) + .inputSchema(toolCallback.getToolDefinition().inputSchema()) + .build(); + + return new McpStatelessServerFeatures.SyncToolSpecification(tool, (mcpTransportContext, request) -> { + try { + String callResult = toolCallback.call(ModelOptionsUtils.toJsonString(request), + new ToolContext(Map.of(TOOL_CONTEXT_MCP_EXCHANGE_KEY, mcpTransportContext))); + if (mimeType != null && mimeType.toString().startsWith("image")) { + return new McpSchema.CallToolResult(List + .of(new McpSchema.ImageContent(List.of(Role.ASSISTANT), null, callResult, mimeType.toString())), + 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); + } + }); + } + /** * 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 @@ -293,6 +320,18 @@ public static McpServerFeatures.AsyncToolSpecification toAsyncToolSpecification( .subscribeOn(Schedulers.boundedElastic())); } + public static McpStatelessServerFeatures.AsyncToolSpecification toStatelessAsyncToolSpecification( + ToolCallback toolCallback, MimeType mimeType) { + + McpStatelessServerFeatures.SyncToolSpecification statelessSyncToolSpecification = toStatelessSyncToolSpecification( + toolCallback, mimeType); + + return new McpStatelessServerFeatures.AsyncToolSpecification(statelessSyncToolSpecification.tool(), + (context, map) -> Mono + .fromCallable(() -> statelessSyncToolSpecification.callHandler().apply(context, map)) + .subscribeOn(Schedulers.boundedElastic())); + } + /** * Convenience method to get tool callbacks from multiple synchronous MCP clients. * diff --git a/pom.xml b/pom.xml index 79483271a2a..fd9d6424320 100644 --- a/pom.xml +++ b/pom.xml @@ -120,6 +120,12 @@
auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-webflux auto-configurations/mcp/spring-ai-autoconfigure-mcp-server +auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-common +auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webflux +auto-configurations/mcp/spring-ai-autoconfigure-mcp-streamable-server-webmvc +auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-common +auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webflux +auto-configurations/mcp/spring-ai-autoconfigure-mcp-stateless-server-webmvc auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-azure auto-configurations/vector-stores/spring-ai-autoconfigure-vector-store-azure-cosmos-db @@ -213,6 +219,11 @@spring-ai-spring-boot-starters/spring-ai-starter-mcp-client-webflux spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-webflux spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-webmvc + +spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-streamable-webflux +spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-streamable-webmvc +spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-stateless-webflux +spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-stateless-webmvc spring-ai-integration-tests @@ -325,7 +336,7 @@4.1.0 -0.11.0 +0.12.0-SNAPSHOT 4.13.1 diff --git a/spring-ai-bom/pom.xml b/spring-ai-bom/pom.xml index 9a8d4bad80f..82caafdf3e3 100644 --- a/spring-ai-bom/pom.xml +++ b/spring-ai-bom/pom.xml @@ -547,7 +547,7 @@${project.version} + org.springframework.ai @@ -555,6 +555,42 @@${project.version} + + +org.springframework.ai +spring-ai-autoconfigure-mcp-streamable-server-common +${project.version} ++ + +org.springframework.ai +spring-ai-autoconfigure-mcp-streamable-server-webflux +${project.version} ++ + +org.springframework.ai +spring-ai-autoconfigure-mcp-streamable-server-webmvc +${project.version} ++ + +org.springframework.ai +spring-ai-autoconfigure-mcp-stateless-server-common +${project.version} ++ + +org.springframework.ai +spring-ai-autoconfigure-mcp-stateless-server-webflux +${project.version} ++ +org.springframework.ai +spring-ai-autoconfigure-mcp-stateless-server-webmvc +${project.version} +@@ -1050,6 +1086,33 @@ +${project.version} + + + +org.springframework.ai +spring-ai-starter-mcp-server-streamable-webflux +${project.version} ++ + +org.springframework.ai +spring-ai-starter-mcp-server-streamable-webmvc +${project.version} ++ + + +org.springframework.ai +spring-ai-starter-mcp-server-stateless-webflux +${project.version} ++ + +org.springframework.ai +spring-ai-starter-mcp-server-stateless-webmvc +${project.version} +diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc index 8e7ccdd6258..7ae5ab6c6e7 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/nav.adoc @@ -79,6 +79,9 @@ ** xref:api/mcp/mcp-overview.adoc[Model Context Protocol (MCP)] *** xref:api/mcp/mcp-client-boot-starter-docs.adoc[MCP Client Boot Starters] *** xref:api/mcp/mcp-server-boot-starter-docs.adoc[MCP Server Boot Starters] +**** xref:api/mcp/mcp-stdio-sse-server-boot-starter-docs.adoc[STDIO and SSE MCP Servers] +**** xref:api/mcp/mcp-streamable-http-server-boot-starter-docs.adoc[Streamable-HTTP MCP Servers] +**** xref:api/mcp/mcp-stateless-server-boot-starter-docs.adoc[Stateless MCP Servers] *** xref:api/mcp/mcp-helpers.adoc[MCP Utilities] ** xref:api/retrieval-augmented-generation.adoc[Retrieval Augmented Generation (RAG)] diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-client-boot-starter-docs.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-client-boot-starter-docs.adoc index 4b731cbaa79..c7bc730832c 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-client-boot-starter-docs.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-client-boot-starter-docs.adoc @@ -269,7 +269,8 @@ Servers can request the list of roots from supporting clients and receive notifi - Tools change notifications - when the list of available server tools changes - Resources change notifications - when the list of available server resources changes. - Prompts change notifications - when the list of available server prompts changes. -* link:https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging[*Logging Handlers*] - standardized way for servers to send structured log messages to clients. + - link:https://modelcontextprotocol.io/specification/2025-06-18/server/utilities/logging[*Logging Handlers*] - standardized way for servers to send structured log messages to clients. + Clients can control logging verbosity by setting minimum log levels @@ -299,6 +300,17 @@ public class CustomMcpSyncClientCustomizer implements McpSyncClientCustomizer { return result; }); + // Sets a custom elicitation handler for processing elicitation requests. + spec.elicitation((ElicitRequest request) -> { + // handle elicitation + return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("message", request.message())); + }); + + // Adds a consumer to be notified when progress notifications are received. + spec.progressConsumer((ProgressNotification progress) -> { + // Handle progress notifications + }); + // Adds a consumer to be notified when the available tools change, such as tools // being added or removed. spec.toolsChangeConsumer((List tools) -> { diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-overview.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-overview.adoc index 430ac219565..a15a82d0f3c 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-overview.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-overview.adoc @@ -3,18 +3,11 @@ The link:https://modelcontextprotocol.org/docs/concepts/architecture[Model Context Protocol] (MCP) is a standardized protocol that enables AI models to interact with external tools and resources in a structured way. It supports multiple transport mechanisms to provide flexibility across different environments. -The link:https://modelcontextprotocol.io/sdk/java[MCP Java SDK] provides a Java implementation of the Model Context Protocol, enabling standardized interaction with AI models and tools through both synchronous and asynchronous communication patterns. +The link:https://modelcontextprotocol.io/sdk/java/mcp-overview[MCP Java SDK] provides a Java implementation of the Model Context Protocol, enabling standardized interaction with AI models and tools through both synchronous and asynchronous communication patterns. `**Spring AI MCP**` extends the MCP Java SDK with Spring Boot integration, providing both xref:api/mcp/mcp-client-boot-starter-docs.adoc[client] and xref:api/mcp/mcp-server-boot-starter-docs.adoc[server] starters. Bootstrap your AI applications with MCP support using link:https://start.spring.io[Spring Initializer]. -[NOTE] -==== -Breaking Changes in MCP Java SDK 0.8.0 ⚠️ - -MCP Java SDK version 0.8.0 introduces several breaking changes including a new session-based architecture. If you're upgrading from Java SDK 0.7.0, please refer to the https://github.com/modelcontextprotocol/java-sdk/blob/main/migration-0.8.0.md[Migration Guide] for detailed instructions. -==== - == MCP Java SDK Architecture TIP: This section provides an overview for the link:https://modelcontextprotocol.io/sdk/java[MCP Java SDK architecture]. @@ -68,9 +61,9 @@ a| The MCP Server is a foundational component in the Model Context Protocol (MCP * Synchronous and Asynchronous API support * Transport implementations: ** Stdio-based transport for process-based communication -** Servlet-based SSE server transport -** WebFlux SSE server transport for reactive HTTP streaming -** WebMVC SSE server transport for servlet-based HTTP streaming +** Servlet-based SSE and Streamable-HTTP server transports +** WebFlux SSE and Streamable-HTTP server transports for reactive HTTP streaming +** WebMVC SSE and Streamable-HTTP server transports for servlet-based HTTP streaming ^a| image::mcp/java-mcp-server-architecture.jpg[Java MCP Server Architecture, width=600] |=== @@ -83,13 +76,24 @@ For simplified setup using Spring Boot, use the MCP Boot Starters described belo Spring AI provides MCP integration through the following Spring Boot starters: === link:mcp-client-boot-starter-docs.html[Client Starters] -* `spring-ai-starter-mcp-client` - Core starter providing STDIO and HTTP-based SSE support -* `spring-ai-starter-mcp-client-webflux` - WebFlux-based SSE transport implementation + +* `spring-ai-starter-mcp-client` - Core starter providing `STDIO` and HTTP-based `SSE` and `Streamable-HTTP` support +* `spring-ai-starter-mcp-client-webflux` - WebFlux-based `SSE` and `Streamable-HTTP` transport implementation === link:mcp-server-boot-starter-docs.html[Server Starters] -* `spring-ai-starter-mcp-server` - Core server with STDIO transport support -* `spring-ai-starter-mcp-server-webmvc` - Spring MVC-based SSE transport implementation -* `spring-ai-starter-mcp-server-webflux` - WebFlux-based SSE transport implementation + +==== STDIO and SSE MCP Servers +* `spring-ai-starter-mcp-server` - Core server with `STDIO` transport support +* `spring-ai-starter-mcp-server-webmvc` - Spring MVC-based `SSE` transport implementation +* `spring-ai-starter-mcp-server-webflux` - WebFlux-based `SSE` transport implementation + +==== Streamable MCP Servers +* `spring-ai-starter-mcp-server-streamable-webmvc` - Spring MVC-based `Streamable-HTTP` server with change notifications +* `spring-ai-starter-mcp-server-streamable-webflux` - WebFlux-based `Streamable-HTTP` server with change notifications + +==== Stateless MCP Servers +* `spring-ai-starter-mcp-server-stateless-webmvc` - Spring MVC-based `Stateless` server for simplified deployments +* `spring-ai-starter-mcp-server-stateless-webflux` - WebFlux-based `Stateless` server for simplified deployments == Additional Resources diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-server-boot-starter-docs.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-server-boot-starter-docs.adoc index 6d19626961c..f1120f7e55d 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-server-boot-starter-docs.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-server-boot-starter-docs.adoc @@ -1,118 +1,21 @@ = MCP Server Boot Starter -The Spring AI MCP (Model Context Protocol) Server Boot Starter provides auto-configuration for setting up an MCP server in Spring Boot applications. It enables seamless integration of MCP server capabilities with Spring Boot's auto-configuration system. +link:https://modelcontextprotocol.io/docs/learn/server-concepts[Model Context Protocol (MCP) Servers] are programs that expose specific capabilities to AI applications through standardized protocol interfaces. +Each server provides focused functionality for a particular domain. -The MCP Server Boot Starter offers: +The Spring AI MCP Server Boot Starters provide auto-configuration for setting up link:https://modelcontextprotocol.io/docs/learn/server-concepts[MCP Servers] in Spring Boot applications. +They enable seamless integration of MCP server capabilities with Spring Boot's auto-configuration system. -* Automatic configuration of MCP server components +The MCP Server Boot Starters offer: + +* Automatic configuration of MCP server components, including tools, resources, and prompts +* Support for different MCP protocol versions, including STDIO, SSE, Streamable-HTTP, and stateless servers * Support for both synchronous and asynchronous operation modes * Multiple transport layer options * Flexible tool, resource, and prompt specification * Change notification capabilities -== Starters - -[NOTE] -==== -There has been a significant change in the Spring AI auto-configuration, starter modules' artifact names. -Please refer to the https://docs.spring.io/spring-ai/reference/upgrade-notes.html[upgrade notes] for more information. -==== - -Choose one of the following starters based on your transport requirements: - -=== Standard MCP Server - -Full MCP Server features support with `STDIO` server transport. - -[source,xml] ----- - - ----- - -* Suitable for command-line and desktop tools -* No additional web dependencies required - -The starter activates the `McpServerAutoConfiguration` auto-configuration responsible for: - -* Configuring the basic server components -* Handling tool, resource, and prompt specifications -* Managing server capabilities and change notifications -* Providing both sync and async server implementations - -=== WebMVC Server Transport - -Full MCP Server features support with `SSE` (Server-Sent Events) server transport based on Spring MVC and an optional `STDIO` transport. - -[source,xml] ----- -org.springframework.ai -spring-ai-starter-mcp-server -- ----- - -The starter activates the `McpWebMvcServerAutoConfiguration` and `McpServerAutoConfiguration` auto-configurations to provide: - -* HTTP-based transport using Spring MVC (`WebMvcSseServerTransportProvider`) -* Automatically configured SSE endpoints -* Optional `STDIO` transport (enabled by setting `spring.ai.mcp.server.stdio=true`) -* Included `spring-boot-starter-web` and `mcp-spring-webmvc` dependencies - -=== WebFlux Server Transport - -Full MCP Server features support with `SSE` (Server-Sent Events) server transport based on Spring WebFlux and an optional `STDIO` transport. - -[source,xml] ----- -org.springframework.ai -spring-ai-starter-mcp-server-webmvc -- ----- - -The starter activates the `McpWebFluxServerAutoConfiguration` and `McpServerAutoConfiguration` auto-configurations to provide: - -* Reactive transport using Spring WebFlux (`WebFluxSseServerTransportProvider`) -* Automatically configured reactive SSE endpoints -* Optional `STDIO` transport (enabled by setting `spring.ai.mcp.server.stdio=true`) -* Included `spring-boot-starter-webflux` and `mcp-spring-webflux` dependencies - -[NOTE] -==== -Due to Spring Boot's default behavior, when both `org.springframework.web.servlet.DispatcherServlet` and `org.springframework.web.reactive.DispatcherHandler` are present on the classpath, Spring Boot will prioritize `DispatcherServlet`. As a result, if your project uses `spring-boot-starter-web`, it is recommended to use `spring-ai-starter-mcp-server-webmvc` instead of `spring-ai-starter-mcp-server-webflux`. -==== - -Configuration Properties - -All properties are prefixed with `spring.ai.mcp.server`: - -[options="header"] -|=== -|Property |Description |Default -|`enabled` |Enable/disable the MCP server |`true` -|`stdio` |Enable/disable stdio transport |`false` -|`name` |Server name for identification |`mcp-server` -|`version` |Server version |`1.0.0` -|`instructions` |Optional instructions to provide guidance to the client on how to interact with this server |`null` -|`type` |Server type (SYNC/ASYNC) |`SYNC` -|`capabilities.resource` |Enable/disable resource capabilities |`true` -|`capabilities.tool` |Enable/disable tool capabilities |`true` -|`capabilities.prompt` |Enable/disable prompt capabilities |`true` -|`capabilities.completion` |Enable/disable completion capabilities |`true` -|`resource-change-notification` |Enable resource change notifications |`true` -|`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` | 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 endpoint at `/api/v1` + `sse-endpoint` and the message endpoint is `/api/v1` + `sse-message-endpoint` | - -|`request-timeout` | Duration to wait for server responses before timing out requests. Applies to all requests made through the client, including tool calls, resource access, and prompt operations. | `20` seconds -|=== - -== Sync/Async Server Types +== Sync/Async Server API Options * **Synchronous Server** - The default server type implemented using `McpSyncServer`. It is designed for straightforward request-response patterns in your applications. @@ -125,260 +28,44 @@ This server type automatically sets up asynchronous tool specifications with bui == Server Capabilities -The MCP Server supports four main capability types that can be individually enabled or disabled: +Depending on the server and transport types, MCP Servers can support various capabilities, such as: -* **Tools** - Enable/disable tool capabilities with `spring.ai.mcp.server.capabilities.tool=true|false` -* **Resources** - Enable/disable resource capabilities with `spring.ai.mcp.server.capabilities.resource=true|false` -* **Prompts** - Enable/disable prompt capabilities with `spring.ai.mcp.server.capabilities.prompt=true|false` -* **Completions** - Enable/disable completion capabilities with `spring.ai.mcp.server.capabilities.completion=true|false` +* **Tools** - Allows servers to expose tools that can be invoked by language models +* **Resources** - Provides a standardized way for servers to expose resources to clients +* **Prompts** - Provides a standardized way for servers to expose prompt templates to clients +* **Utility/Completions** - Provides a standardized way for servers to offer argument autocompletion suggestions for prompts and resource URIs +* **Utility/Logging** - Provides a standardized way for servers to send structured log messages to clients +* **Utility/Progress** - Optional progress tracking for long-running operations through notification messages +* **Utility/Ping** - Optional health check mechanism for the server to report its status All capabilities are enabled by default. Disabling a capability will prevent the server from registering and exposing the corresponding features to clients. == Transport Options -The MCP Server supports three transport mechanisms, each with its dedicated starter: +MCP Servers support multiple transport mechanisms, each with its dedicated starter: * Standard Input/Output (STDIO) - `spring-ai-starter-mcp-server` -* Spring MVC (Server-Sent Events) - `spring-ai-starter-mcp-server-webmvc` -* Spring WebFlux (Reactive SSE) - `spring-ai-starter-mcp-server-webflux` - -== Features and Capabilities - -The MCP Server Boot Starter allows servers to expose tools, resources, and prompts to clients. -It automatically converts custom capability handlers registered as Spring beans to sync/async specifications based on server type: - -=== link:https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/[Tools] -Allows servers to expose tools that can be invoked by language models. The MCP Server Boot Starter provides: - -* Change notification support -* xref:api/tools.adoc[Spring AI Tools] are automatically converted to sync/async specifications based on server type -* Automatic tool specification through Spring beans: - -[source,java] ----- -@Bean -public ToolCallbackProvider myTools(...) { - Listorg.springframework.ai -spring-ai-starter-mcp-server-webflux -tools = ... - return ToolCallbackProvider.from(tools); -} ----- - -or using the low-level API: - -[source,java] ----- -@Bean -public List myTools(...) { - List tools = ... - return tools; -} ----- - -The auto-configuration will automatically detect and register all tool callbacks from: -* Individual `ToolCallback` beans -* Lists of `ToolCallback` beans -* `ToolCallbackProvider` beans - -Tools are de-duplicated by name, with the first occurrence of each tool name being used. +* SSE Spring MVC - `spring-ai-starter-mcp-server-webmvc` +* SSE Spring WebFlux (Reactive) - `spring-ai-starter-mcp-server-webflux` +* Streamable-HTTP Spring MVC - `spring-ai-starter-mcp-server-streamable-webmvc` +* Streamable-HTTP Spring WebFlux (Reactive) - `spring-ai-starter-mcp-server-streamable-webflux` +* Stateless Spring MVC - `spring-ai-starter-mcp-server-stateless-webmvc` +* Stateless Spring WebFlux (Reactive) - `spring-ai-starter-mcp-server-stateless-webflux` -==== Tool Context Support +Choose one of the following starters based on your transport and feature requirements: -The xref:api/tools.adoc#_tool_context[ToolContext] is supported, allowing contextual information to be passed to tool calls. It contains an `McpSyncServerExchange` instance under the `exchange` key, accessible via `McpToolUtils.getMcpExchange(toolContext)`. See this https://github.com/spring-projects/spring-ai-examples/blob/3fab8483b8deddc241b1e16b8b049616604b7767/model-context-protocol/sampling/mcp-weather-webmvc-server/src/main/java/org/springframework/ai/mcp/sample/server/WeatherService.java#L59-L126[example] demonstrating `exchange.loggingNotification(...)` and `exchange.createMessage(...)`. +- xref:api/mcp/mcp-stdio-sse-server-boot-starter-docs.adoc[STDIO and SSE MCP Servers] +- xref:api/mcp/mcp-streamable-http-server-boot-starter-docs.adoc[Streamable-HTTP MCP Servers] +- xref:api/mcp/mcp-stateless-server-boot-starter-docs.adoc[Stateless MCP Servers] -=== link:https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/[Resource Management] - -Provides a standardized way for servers to expose resources to clients. - -* Static and dynamic resource specifications -* Optional change notifications -* Support for resource templates -* Automatic conversion between sync/async resource specifications -* Automatic resource specification through Spring beans: - -[source,java] ----- -@Bean -public List myResources(...) { - var systemInfoResource = new McpSchema.Resource(...); - var resourceSpecification = new McpServerFeatures.SyncResourceSpecification(systemInfoResource, (exchange, request) -> { - try { - var systemInfo = Map.of(...); - String jsonContent = new ObjectMapper().writeValueAsString(systemInfo); - return new McpSchema.ReadResourceResult( - List.of(new McpSchema.TextResourceContents(request.uri(), "application/json", jsonContent))); - } - catch (Exception e) { - throw new RuntimeException("Failed to generate system info", e); - } - }); - - return List.of(resourceSpecification); -} ----- - -=== link:https://spec.modelcontextprotocol.io/specification/2024-11-05/server/prompts/[Prompt Management] - -Provides a standardized way for servers to expose prompt templates to clients. - -* Change notification support -* Template versioning -* Automatic conversion between sync/async prompt specifications -* Automatic prompt specification through Spring beans: - -[source,java] ----- -@Bean -public List myPrompts() { - var prompt = new McpSchema.Prompt("greeting", "A friendly greeting prompt", - List.of(new McpSchema.PromptArgument("name", "The name to greet", true))); - - var promptSpecification = new McpServerFeatures.SyncPromptSpecification(prompt, (exchange, getPromptRequest) -> { - String nameArgument = (String) getPromptRequest.arguments().get("name"); - if (nameArgument == null) { nameArgument = "friend"; } - var userMessage = new PromptMessage(Role.USER, new TextContent("Hello " + nameArgument + "! How can I assist you today?")); - return new GetPromptResult("A personalized greeting message", List.of(userMessage)); - }); - - return List.of(promptSpecification); -} ----- - -=== link:https://spec.modelcontextprotocol.io/specification/2024-11-05/server/completions/[Completion Management] - -Provides a standardized way for servers to expose completion capabilities to clients. - -* Support for both sync and async completion specifications -* Automatic registration through Spring beans: - -[source,java] ----- -@Bean -public List myCompletions() { - var completion = new McpServerFeatures.SyncCompletionSpecification( - "code-completion", - "Provides code completion suggestions", - (exchange, request) -> { - // Implementation that returns completion suggestions - return new McpSchema.CompletionResult(List.of( - new McpSchema.Completion("suggestion1", "First suggestion"), - new McpSchema.Completion("suggestion2", "Second suggestion") - )); - } - ); - - return List.of(completion); -} ----- - -=== link:https://spec.modelcontextprotocol.io/specification/2024-11-05/client/roots/#root-list-changes[Root Change Consumers] - -When roots change, clients that support `listChanged` send a Root Change notification. - -* Support for monitoring root changes -* Automatic conversion to async consumers for reactive applications -* Optional registration through Spring beans - -[source,java] ----- -@Bean -public BiConsumer > rootsChangeHandler() { - return (exchange, roots) -> { - logger.info("Registering root resources: {}", roots); - }; -} ----- - -== Usage Examples - -=== Standard STDIO Server Configuration -[source,yaml] ----- -# Using spring-ai-starter-mcp-server -spring: - ai: - mcp: - server: - name: stdio-mcp-server - version: 1.0.0 - type: SYNC ----- - -=== WebMVC Server Configuration -[source,yaml] ----- -# Using spring-ai-starter-mcp-server-webmvc -spring: - ai: - mcp: - server: - name: webmvc-mcp-server - version: 1.0.0 - type: SYNC - instructions: "This server provides weather information tools and resources" - sse-message-endpoint: /mcp/messages - capabilities: - tool: true - resource: true - prompt: true - completion: true ----- - -=== WebFlux Server Configuration -[source,yaml] ----- -# Using spring-ai-starter-mcp-server-webflux -spring: - ai: - mcp: - server: - name: webflux-mcp-server - version: 1.0.0 - type: ASYNC # Recommended for reactive applications - instructions: "This reactive server provides weather information tools and resources" - sse-message-endpoint: /mcp/messages - capabilities: - tool: true - resource: true - prompt: true - completion: true ----- - -=== Creating a Spring Boot Application with MCP Server - -[source,java] ----- -@Service -public class WeatherService { - - @Tool(description = "Get weather information by city name") - public String getWeather(String cityName) { - // Implementation - } -} - -@SpringBootApplication -public class McpServerApplication { - - private static final Logger logger = LoggerFactory.getLogger(McpServerApplication.class); - - public static void main(String[] args) { - SpringApplication.run(McpServerApplication.class, args); - } - - @Bean - public ToolCallbackProvider weatherTools(WeatherService weatherService) { - return MethodToolCallbackProvider.builder().toolObjects(weatherService).build(); - } -} ----- - -The auto-configuration will automatically register the tool callbacks as MCP tools. -You can have multiple beans producing ToolCallbacks. The auto-configuration will merge them. == Example Applications -* link:https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol/weather/starter-webflux-server[Weather Server (WebFlux)] - Spring AI MCP Server Boot Starter with WebFlux transport. -* link:https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol/weather/starter-stdio-server[Weather Server (STDIO)] - Spring AI MCP Server Boot Starter with STDIO transport. -* link:https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol/weather/manual-webflux-server[Weather Server Manual Configuration] - Spring AI MCP Server Boot Starter that doesn't use auto-configuration but the Java SDK to configure the server manually. + +* link:https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol/weather/starter-webflux-server[Weather Server (SSE WebFlux)] - Spring AI MCP Server Boot Starter with WebFlux transport +* link:https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol/weather/starter-stdio-server[Weather Server (STDIO)] - Spring AI MCP Server Boot Starter with STDIO transport +* link:https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol/weather/manual-webflux-server[Weather Server Manual Configuration] - Spring AI MCP Server Boot Starter that doesn't use auto-configuration but uses the Java SDK to configure the server manually +* Streamable-HTTP WebFlux/WebMVC Example - TODO +* Stateless WebFlux/WebMVC Example - TODO == Additional Resources diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-stateless-server-boot-starter-docs.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-stateless-server-boot-starter-docs.adoc new file mode 100644 index 00000000000..6b22c17c419 --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-stateless-server-boot-starter-docs.adoc @@ -0,0 +1,239 @@ + +== Stateless MCP Servers + +Stateless MCP servers are designed for simplified deployments where session state is not maintained between requests. +These servers are ideal for microservices architectures and cloud-native deployments. + +NOTE: They require xref:api/mcp/mcp-client-boot-starter-docs#_streamable_http_transport_properties[streamable-HTTP clients] to support the necessary protocols for handling these features. + +NOTE: Stateless servers don't support message requests to the MCP client (e.g., elicitation, sampling, ping). + +=== Stateless WebMVC Server + +[source,xml] +---- + + +---- + +- Stateless operation with Spring MVC transport +- No session state management +- Simplified deployment model +- Optimized for cloud-native environments + +=== Stateless WebFlux Server + +[source,xml] +---- +org.springframework.ai +spring-ai-starter-mcp-server-stateless-webmvc ++ +---- + +- Reactive stateless operation with WebFlux transport +- No session state management +- Non-blocking request processing +- Optimized for high-throughput scenarios + +== Configuration Properties + +All properties are prefixed with `spring.ai.mcp.server.stateless`: + +[options="header"] +|=== +|Property |Description |Default +|`enabled` |Enable/disable the stateless MCP server |`true` +|`tool-callback-converter` |Enable/disable the conversion of Spring AI ToolCallbacks into MCP Tool specs |`true` +|`name` |Server name for identification |`mcp-server` +|`version` |Server version |`1.0.0` +|`instructions` |Optional instructions for client interaction |`null` +|`type` |Server type (SYNC/ASYNC) |`SYNC` +|`capabilities.resource` |Enable/disable resource capabilities |`true` +|`capabilities.tool` |Enable/disable tool capabilities |`true` +|`capabilities.prompt` |Enable/disable prompt capabilities |`true` +|`capabilities.completion` |Enable/disable completion capabilities |`true` +|`tool-response-mime-type` |Response MIME type per tool name |`-` +|`mcp-endpoint` |Custom MCP endpoint path |`/mcp` +|`request-timeout` |Request timeout duration |`20 seconds` +|`disallow-delete` |Disallow delete operations |`false` +|=== + + +== Features and Capabilities + +The MCP Server Boot Starter allows servers to expose tools, resources, and prompts to clients. +It automatically converts custom capability handlers registered as Spring beans to sync/async specifications based on the server type: + +=== link:https://modelcontextprotocol.io/specification/2025-03-26/server/tools[Tools] +Allows servers to expose tools that can be invoked by language models. The MCP Server Boot Starter provides: + +* Change notification support +* xref:api/tools.adoc[Spring AI Tools] are automatically converted to sync/async specifications based on the server type +* Automatic tool specification through Spring beans: + +[source,java] +---- +@Bean +public ToolCallbackProvider myTools(...) { + Listorg.springframework.ai +spring-ai-starter-mcp-server-stateless-webflux +tools = ... + return ToolCallbackProvider.from(tools); +} +---- + +or using the low-level API: + +[source,java] +---- +@Bean +public List myTools(...) { + List tools = ... + return tools; +} +---- + +The auto-configuration will automatically detect and register all tool callbacks from: + +- Individual `ToolCallback` beans +- Lists of `ToolCallback` beans +- `ToolCallbackProvider` beans + +Tools are de-duplicated by name, with the first occurrence of each tool name being used. + +TIP: You can disable the automatic detection and registration of all tool callbacks by setting the `tool-callback-converter` to `false`. + +NOTE: Tool Context Support is not applicable for stateless servers. + +=== link:https://modelcontextprotocol.io/specification/2025-03-26/server/resources/[Resources] + +Provides a standardized way for servers to expose resources to clients. + +* Static and dynamic resource specifications +* Optional change notifications +* Support for resource templates +* Automatic conversion between sync/async resource specifications +* Automatic resource specification through Spring beans: + +[source,java] +---- +@Bean +public List myResources(...) { + var systemInfoResource = new McpSchema.Resource(...); + var resourceSpecification = new McpStatelessServerFeatures.SyncResourceSpecification(systemInfoResource, (context, request) -> { + try { + var systemInfo = Map.of(...); + String jsonContent = new ObjectMapper().writeValueAsString(systemInfo); + return new McpSchema.ReadResourceResult( + List.of(new McpSchema.TextResourceContents(request.uri(), "application/json", jsonContent))); + } + catch (Exception e) { + throw new RuntimeException("Failed to generate system info", e); + } + }); + + return List.of(resourceSpecification); +} +---- + +=== link:https://modelcontextprotocol.io/specification/2025-03-26/server/prompts/[Prompts] + +Provides a standardized way for servers to expose prompt templates to clients. + +* Change notification support +* Template versioning +* Automatic conversion between sync/async prompt specifications +* Automatic prompt specification through Spring beans: + +[source,java] +---- +@Bean +public List myPrompts() { + var prompt = new McpSchema.Prompt("greeting", "A friendly greeting prompt", + List.of(new McpSchema.PromptArgument("name", "The name to greet", true))); + + var promptSpecification = new McpStatelessServerFeatures.SyncPromptSpecification(prompt, (context, getPromptRequest) -> { + String nameArgument = (String) getPromptRequest.arguments().get("name"); + if (nameArgument == null) { nameArgument = "friend"; } + var userMessage = new PromptMessage(Role.USER, new TextContent("Hello " + nameArgument + "! How can I assist you today?")); + return new GetPromptResult("A personalized greeting message", List.of(userMessage)); + }); + + return List.of(promptSpecification); +} +---- + +=== link:https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/completion/[Completion] + +Provides a standardized way for servers to expose completion capabilities to clients. + +* Support for both sync and async completion specifications +* Automatic registration through Spring beans: + +[source,java] +---- +@Bean +public List myCompletions() { + var completion = new McpStatelessServerFeatures.SyncCompletionSpecification( + new McpSchema.PromptReference( + "ref/prompt", "code-completion", "Provides code completion suggestions"), + (exchange, request) -> { + // Implementation that returns completion suggestions + return new McpSchema.CompleteResult(List.of("python", "pytorch", "pyside"), 10, true); + } + ); + + return List.of(completion); +} +---- + +== Usage Examples + +=== Stateless Server Configuration +[source,yaml] +---- +# Using spring-ai-starter-mcp-server-stateless-webflux +spring: + ai: + mcp: + server: + stateless: + name: stateless-mcp-server + version: 1.0.0 + type: ASYNC + instructions: "This stateless server is optimized for cloud deployments" + mcp-endpoint: /api/mcp +---- + +=== Creating a Spring Boot Application with MCP Server + +[source,java] +---- +@Service +public class WeatherService { + + @Tool(description = "Get weather information by city name") + public String getWeather(String cityName) { + // Implementation + } +} + +@SpringBootApplication +public class McpServerApplication { + + private static final Logger logger = LoggerFactory.getLogger(McpServerApplication.class); + + public static void main(String[] args) { + SpringApplication.run(McpServerApplication.class, args); + } + + @Bean + public ToolCallbackProvider weatherTools(WeatherService weatherService) { + return MethodToolCallbackProvider.builder().toolObjects(weatherService).build(); + } +} +---- + +The auto-configuration will automatically register the tool callbacks as MCP tools. +You can have multiple beans producing ToolCallbacks, and the auto-configuration will merge them. diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-stdio-sse-server-boot-starter-docs.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-stdio-sse-server-boot-starter-docs.adoc new file mode 100644 index 00000000000..b31f0ecca95 --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-stdio-sse-server-boot-starter-docs.adoc @@ -0,0 +1,411 @@ + +== STDIO and SSE MCP Servers + +The STDIO and SSE MCP Servers support multiple transport mechanisms, each with its dedicated starter. + +=== STDIO MCP Server + +Full MCP Server feature support with `STDIO` server transport. + +[source,xml] +---- + + +---- + +* Suitable for command-line and desktop tools +* No additional web dependencies required +* Configuration of basic server components +* Handling of tool, resource, and prompt specifications +* Management of server capabilities and change notifications +* Support for both sync and async server implementations + +=== SSE WebMVC Server + +Full MCP Server feature support with `SSE` (Server-Sent Events) server transport based on Spring MVC and an optional `STDIO` transport. + +[source,xml] +---- +org.springframework.ai +spring-ai-starter-mcp-server ++ +---- + +* HTTP-based transport using Spring MVC (`WebMvcSseServerTransportProvider`) +* Automatically configured SSE endpoints +* Optional `STDIO` transport (enabled by setting `spring.ai.mcp.server.stdio=true`) +* Includes `spring-boot-starter-web` and `mcp-spring-webmvc` dependencies + +=== SSE WebFlux Server + +Full MCP Server feature support with `SSE` (Server-Sent Events) server transport based on Spring WebFlux and an optional `STDIO` transport. + +[source,xml] +---- +org.springframework.ai +spring-ai-starter-mcp-server-webmvc ++ +---- + +The starter activates the `McpWebFluxServerAutoConfiguration` and `McpServerAutoConfiguration` auto-configurations to provide: + +* Reactive transport using Spring WebFlux (`WebFluxSseServerTransportProvider`) +* Automatically configured reactive SSE endpoints +* Optional `STDIO` transport (enabled by setting `spring.ai.mcp.server.stdio=true`) +* Includes `spring-boot-starter-webflux` and `mcp-spring-webflux` dependencies + +[NOTE] +==== +Due to Spring Boot's default behavior, when both `org.springframework.web.servlet.DispatcherServlet` and `org.springframework.web.reactive.DispatcherHandler` are present on the classpath, Spring Boot will prioritize `DispatcherServlet`. As a result, if your project uses `spring-boot-starter-web`, it is recommended to use `spring-ai-starter-mcp-server-webmvc` instead of `spring-ai-starter-mcp-server-webflux`. +==== + +== Configuration Properties + +All properties are prefixed with `spring.ai.mcp.server`: + +[options="header"] +|=== +|Property |Description |Default +|`enabled` |Enable/disable the MCP server |`true` +|`tool-callback-converter` |Enable/disable the conversion of Spring AI ToolCallbacks into MCP Tool specs |`true` +|`stdio` |Enable/disable STDIO transport |`false` +|`name` |Server name for identification |`mcp-server` +|`version` |Server version |`1.0.0` +|`instructions` |Optional instructions to provide guidance to the client on how to interact with this server |`null` +|`type` |Server type (SYNC/ASYNC) |`SYNC` +|`capabilities.resource` |Enable/disable resource capabilities |`true` +|`capabilities.tool` |Enable/disable tool capabilities |`true` +|`capabilities.prompt` |Enable/disable prompt capabilities |`true` +|`capabilities.completion` |Enable/disable completion capabilities |`true` +|`resource-change-notification` |Enable resource change notifications |`true` +|`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` |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 endpoint at `/api/v1` + `sse-endpoint` and the message endpoint is `/api/v1` + `sse-message-endpoint` |`-` +|`request-timeout` |Duration to wait for server responses before timing out requests. Applies to all requests made through the client, including tool calls, resource access, and prompt operations |`20 seconds` +|`keep-alive-interval` |Connection keep-alive interval |`null` (disabled) +|=== + +== Features and Capabilities + +The MCP Server Boot Starter allows servers to expose tools, resources, and prompts to clients. +It automatically converts custom capability handlers registered as Spring beans to sync/async specifications based on the server type: + +=== link:https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/[Tools] +Allows servers to expose tools that can be invoked by language models. The MCP Server Boot Starter provides: + +* Change notification support +* xref:api/tools.adoc[Spring AI Tools] are automatically converted to sync/async specifications based on the server type +* Automatic tool specification through Spring beans: + +[source,java] +---- +@Bean +public ToolCallbackProvider myTools(...) { + Listorg.springframework.ai +spring-ai-starter-mcp-server-webflux +tools = ... + return ToolCallbackProvider.from(tools); +} +---- + +or using the low-level API: + +[source,java] +---- +@Bean +public List myTools(...) { + List tools = ... + return tools; +} +---- + + +The auto-configuration will automatically detect and register all tool callbacks from: + +- Individual `ToolCallback` beans +- Lists of `ToolCallback` beans +- `ToolCallbackProvider` beans + +Tools are de-duplicated by name, with the first occurrence of each tool name being used. + +TIP: You can disable the automatic detection and registration of all tool callbacks by setting the `tool-callback-converter` to `false`. + +==== Tool Context Support + +The xref:api/tools.adoc#_tool_context[ToolContext] is supported, allowing contextual information to be passed to tool calls. It contains an `McpSyncServerExchange` instance under the `exchange` key, accessible via `McpToolUtils.getMcpExchange(toolContext)`. See this https://github.com/spring-projects/spring-ai-examples/blob/3fab8483b8deddc241b1e16b8b049616604b7767/model-context-protocol/sampling/mcp-weather-webmvc-server/src/main/java/org/springframework/ai/mcp/sample/server/WeatherService.java#L59-L126[example] demonstrating `exchange.loggingNotification(...)` and `exchange.createMessage(...)`. + +=== link:https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/[Resources] + +Provides a standardized way for servers to expose resources to clients. + +* Static and dynamic resource specifications +* Optional change notifications +* Support for resource templates +* Automatic conversion between sync/async resource specifications +* Automatic resource specification through Spring beans: + +[source,java] +---- +@Bean +public List myResources(...) { + var systemInfoResource = new McpSchema.Resource(...); + var resourceSpecification = new McpServerFeatures.SyncResourceSpecification(systemInfoResource, (exchange, request) -> { + try { + var systemInfo = Map.of(...); + String jsonContent = new ObjectMapper().writeValueAsString(systemInfo); + return new McpSchema.ReadResourceResult( + List.of(new McpSchema.TextResourceContents(request.uri(), "application/json", jsonContent))); + } + catch (Exception e) { + throw new RuntimeException("Failed to generate system info", e); + } + }); + + return List.of(resourceSpecification); +} +---- + +=== link:https://spec.modelcontextprotocol.io/specification/2024-11-05/server/prompts/[Prompts] + +Provides a standardized way for servers to expose prompt templates to clients. + +* Change notification support +* Template versioning +* Automatic conversion between sync/async prompt specifications +* Automatic prompt specification through Spring beans: + +[source,java] +---- +@Bean +public List myPrompts() { + var prompt = new McpSchema.Prompt("greeting", "A friendly greeting prompt", + List.of(new McpSchema.PromptArgument("name", "The name to greet", true))); + + var promptSpecification = new McpServerFeatures.SyncPromptSpecification(prompt, (exchange, getPromptRequest) -> { + String nameArgument = (String) getPromptRequest.arguments().get("name"); + if (nameArgument == null) { nameArgument = "friend"; } + var userMessage = new PromptMessage(Role.USER, new TextContent("Hello " + nameArgument + "! How can I assist you today?")); + return new GetPromptResult("A personalized greeting message", List.of(userMessage)); + }); + + return List.of(promptSpecification); +} +---- + +=== link:https://spec.modelcontextprotocol.io/specification/2024-11-05/server/completions/[Completions] + +Provides a standardized way for servers to expose completion capabilities to clients. + +* Support for both sync and async completion specifications +* Automatic registration through Spring beans: + +[source,java] +---- +@Bean +public List myCompletions() { + var completion = new McpServerFeatures.SyncCompletionSpecification( + new McpSchema.PromptReference( + "ref/prompt", "code-completion", "Provides code completion suggestions"), + (exchange, request) -> { + // Implementation that returns completion suggestions + return new McpSchema.CompleteResult(List.of("python", "pytorch", "pyside"), 10, true); + } + ); + + return List.of(completion); +} +---- + +=== link:https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/logging/[Logging] + +Provides a standardized way for servers to send structured log messages to clients. +From within the tool, resource, prompt or completion call handler use the provided `McpSyncServerExchange`/`McpAsyncServerExchange` `exchange` object to send logging messages: + +[source,java] +---- +(exchange, request) -> { + exchange.loggingNotification(LoggingMessageNotification.builder() + .level(LoggingLevel.INFO) + .logger("test-logger") + .data("This is a test log message") + .build()); +} +---- + +On the MCP client you can register xref::api/mcp/mcp-client-boot-starter-docs#_customization_types[logging consumers] to handle these messages: + +[source,java] +---- +mcpClientSpec.loggingConsumer((McpSchema.LoggingMessageNotification log) -> { + // Handle log messages +}); +---- + +=== link:https://modelcontextprotocol.io/specification/2025-03-26/basic/utilities/progress[Progress] + +Provides a standardized way for servers to send progress updates to clients. +From within the tool, resource, prompt or completion call handler use the provided `McpSyncServerExchange`/`McpAsyncServerExchange` `exchange` object to send progress notifications: + +[source,java] +---- +(exchange, request) -> { + exchange.progressNotification(ProgressNotification.builder() + .progressToken("test-progress-token") + .progress(0.25) + .total(1.0) + .message("tool call in progress") + .build()); +} +---- + +The Mcp Client can receive progress notifications and update its UI accordingly. +For this it needs to register a progress consumer. + +[source,java] +---- +mcpClientSpec.progressConsumer((McpSchema.ProgressNotification progress) -> { + // Handle progress notifications +}); +---- + +=== link:https://spec.modelcontextprotocol.io/specification/2024-11-05/client/roots/#root-list-changes[Root List Changes] + +When roots change, clients that support `listChanged` send a root change notification. + +* Support for monitoring root changes +* Automatic conversion to async consumers for reactive applications +* Optional registration through Spring beans + +[source,java] +---- +@Bean +public BiConsumer > rootsChangeHandler() { + return (exchange, roots) -> { + logger.info("Registering root resources: {}", roots); + }; +} +---- + +=== link:https://modelcontextprotocol.io/specification/2025-03-26/basic/utilities/ping/[Ping] + +Ping mechanism for the server to verify that its clients are still alive. +From within the tool, resource, prompt or completion call handler use the provided `McpSyncServerExchange`/`McpAsyncServerExchange` `exchange` object to send ping messages: + +[source,java] +---- +(exchange, request) -> { + exchange.ping(); +} +---- + +=== Keep Alive + +Server can optionally, periodically issue pings to connected clients to verify connection health. + +By default, keep-alive is disabled. +To enable keep-alive, set the `keep-alive-interval` property in your configuration: + +```yaml +spring: + ai: + mcp: + server: + keep-alive-interval: 30s +``` + +== Usage Examples + +=== Standard STDIO Server Configuration +[source,yaml] +---- +# Using spring-ai-starter-mcp-server +spring: + ai: + mcp: + server: + name: stdio-mcp-server + version: 1.0.0 + type: SYNC +---- + +=== WebMVC Server Configuration +[source,yaml] +---- +# Using spring-ai-starter-mcp-server-webmvc +spring: + ai: + mcp: + server: + name: webmvc-mcp-server + version: 1.0.0 + type: SYNC + instructions: "This server provides weather information tools and resources" + sse-message-endpoint: /mcp/messages + capabilities: + tool: true + resource: true + prompt: true + completion: true +---- + +=== WebFlux Server Configuration +[source,yaml] +---- +# Using spring-ai-starter-mcp-server-webflux +spring: + ai: + mcp: + server: + name: webflux-mcp-server + version: 1.0.0 + type: ASYNC # Recommended for reactive applications + instructions: "This reactive server provides weather information tools and resources" + sse-message-endpoint: /mcp/messages + capabilities: + tool: true + resource: true + prompt: true + completion: true +---- + +=== Creating a Spring Boot Application with MCP Server + +[source,java] +---- +@Service +public class WeatherService { + + @Tool(description = "Get weather information by city name") + public String getWeather(String cityName) { + // Implementation + } +} + +@SpringBootApplication +public class McpServerApplication { + + private static final Logger logger = LoggerFactory.getLogger(McpServerApplication.class); + + public static void main(String[] args) { + SpringApplication.run(McpServerApplication.class, args); + } + + @Bean + public ToolCallbackProvider weatherTools(WeatherService weatherService) { + return MethodToolCallbackProvider.builder().toolObjects(weatherService).build(); + } +} +---- + +The auto-configuration will automatically register the tool callbacks as MCP tools. +You can have multiple beans producing ToolCallbacks, and the auto-configuration will merge them. + +== Example Applications +* link:https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol/weather/starter-webflux-server[Weather Server (WebFlux)] - Spring AI MCP Server Boot Starter with WebFlux transport +* link:https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol/weather/starter-stdio-server[Weather Server (STDIO)] - Spring AI MCP Server Boot Starter with STDIO transport +* link:https://github.com/spring-projects/spring-ai-examples/tree/main/model-context-protocol/weather/manual-webflux-server[Weather Server Manual Configuration] - Spring AI MCP Server Boot Starter that doesn't use auto-configuration but uses the Java SDK to configure the server manually diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-streamable-http-server-boot-starter-docs.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-streamable-http-server-boot-starter-docs.adoc new file mode 100644 index 00000000000..0ef335c4855 --- /dev/null +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-streamable-http-server-boot-starter-docs.adoc @@ -0,0 +1,352 @@ + +== Streamable-HTTP MCP Servers + +link:https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http[Streamable-HTTP] MCP servers provide enhanced functionality with support for change notifications and persistent connections. +These servers, introduced with spec version link:https://modelcontextprotocol.io/specification/2025-03-26[2025-03-26], are ideal for applications that need to notify clients about dynamic changes to tools, resources, or prompts. + +NOTE: They require xref:api/mcp/mcp-client-boot-starter-docs#_streamable_http_transport_properties[streamable-HTTP clients] to support the necessary protocols for handling these features. + +=== Streamable-HTTP WebMVC Server + +[source,xml] +---- + + +---- + +* Full MCP server capabilities with Spring MVC Streamable transport +* Suppport for tools, resources, prompts, completion, logging, progression, ping, root-changes capabilities +* Persistent connection management + +=== Streamable-HTTP WebFlux Server + +[source,xml] +---- +org.springframework.ai +spring-ai-starter-mcp-server-streamable-webmvc ++ +---- + +* Reactive MCP server with WebFlux Streamable transport +* Suppport for tools, resources, prompts, completion, logging, progression, ping, root-changes capabilities +* Non-blocking, persistent connection management + +== Configuration Properties + +All properties are prefixed with `spring.ai.mcp.server.streamable-http`: + +[options="header"] +|=== +|Property |Description |Default +|`enabled` |Enable/disable the streamable MCP server |`true` +|`tool-callback-converter` |Enable/disable the conversion of Spring AI ToolCallbacks into MCP Tool specs |`true` +|`name` |Server name for identification |`mcp-server` +|`version` |Server version |`1.0.0` +|`instructions` |Optional instructions for client interaction |`null` +|`type` |Server type (SYNC/ASYNC) |`SYNC` +|`capabilities.resource` |Enable/disable resource capabilities |`true` +|`capabilities.tool` |Enable/disable tool capabilities |`true` +|`capabilities.prompt` |Enable/disable prompt capabilities |`true` +|`capabilities.completion` |Enable/disable completion capabilities |`true` +|`resource-change-notification` |Enable resource change notifications |`true` +|`prompt-change-notification` |Enable prompt change notifications |`true` +|`tool-change-notification` |Enable tool change notifications |`true` +|`tool-response-mime-type` |Response MIME type per tool name |`-` +|`mcp-endpoint` |Custom MCP endpoint path |`/mcp` +|`request-timeout` |Request timeout duration |`20 seconds` +|`keep-alive-interval` |Connection keep-alive interval |`null` (disabled) +|=== + +== Features and Capabilities + +The MCP Server supports four main capability types that can be individually enabled or disabled: + +- **Tools** - Enable/disable tool capabilities with `spring.ai.mcp.server.streamable-http.capabilities.tool=true|false` +- **Resources** - Enable/disable resource capabilities with `spring.ai.mcp.server.streamable-http.capabilities.resource=true|false` +- **Prompts** - Enable/disable prompt capabilities with `spring.ai.mcp.server.streamable-http.capabilities.prompt=true|false` +- **Completions** - Enable/disable completion capabilities with `spring.ai.mcp.server.streamable-http.capabilities.completion=true|false` + +All capabilities are enabled by default. Disabling a capability will prevent the server from registering and exposing the corresponding features to clients. + +The MCP Server Boot Starter allows servers to expose tools, resources, and prompts to clients. +It automatically converts custom capability handlers registered as Spring beans to sync/async specifications based on the server type: + +=== link:https://modelcontextprotocol.io/specification/2025-03-26/server/tools[Tools] +Allows servers to expose tools that can be invoked by language models. The MCP Server Boot Starter provides: + +* Change notification support +* xref:api/tools.adoc[Spring AI Tools] are automatically converted to sync/async specifications based on the server type +* Automatic tool specification through Spring beans: + +[source,java] +---- +@Bean +public ToolCallbackProvider myTools(...) { + Listorg.springframework.ai +spring-ai-starter-mcp-server-streamable-webflux +tools = ... + return ToolCallbackProvider.from(tools); +} +---- + +or using the low-level API: + +[source,java] +---- +@Bean +public List myTools(...) { + List tools = ... + return tools; +} +---- + +The auto-configuration will automatically detect and register all tool callbacks from: + +- Individual `ToolCallback` beans +- Lists of `ToolCallback` beans +- `ToolCallbackProvider` beans + +Tools are de-duplicated by name, with the first occurrence of each tool name being used. + +TIP: You can disable the automatic detection and registration of all tool callbacks by setting the `tool-callback-converter` to `false`. + +==== Tool Context Support + +The xref:api/tools.adoc#_tool_context[ToolContext] is supported, allowing contextual information to be passed to tool calls. It contains an `McpSyncServerExchange` instance under the `exchange` key, accessible via `McpToolUtils.getMcpExchange(toolContext)`. See this https://github.com/spring-projects/spring-ai-examples/blob/3fab8483b8deddc241b1e16b8b049616604b7767/model-context-protocol/sampling/mcp-weather-webmvc-server/src/main/java/org/springframework/ai/mcp/sample/server/WeatherService.java#L59-L126[example] demonstrating `exchange.loggingNotification(...)` and `exchange.createMessage(...)`. + +=== link:https://modelcontextprotocol.io/specification/2025-03-26/server/resources/[Resources] + +Provides a standardized way for servers to expose resources to clients. + +* Static and dynamic resource specifications +* Optional change notifications +* Support for resource templates +* Automatic conversion between sync/async resource specifications +* Automatic resource specification through Spring beans: + +[source,java] +---- +@Bean +public List myResources(...) { + var systemInfoResource = new McpSchema.Resource(...); + var resourceSpecification = new McpServerFeatures.SyncResourceSpecification(systemInfoResource, (exchange, request) -> { + try { + var systemInfo = Map.of(...); + String jsonContent = new ObjectMapper().writeValueAsString(systemInfo); + return new McpSchema.ReadResourceResult( + List.of(new McpSchema.TextResourceContents(request.uri(), "application/json", jsonContent))); + } + catch (Exception e) { + throw new RuntimeException("Failed to generate system info", e); + } + }); + + return List.of(resourceSpecification); +} +---- + +=== link:https://modelcontextprotocol.io/specification/2025-03-26/server/prompts/[Prompts] + +Provides a standardized way for servers to expose prompt templates to clients. + +* Change notification support +* Template versioning +* Automatic conversion between sync/async prompt specifications +* Automatic prompt specification through Spring beans: + +[source,java] +---- +@Bean +public List myPrompts() { + var prompt = new McpSchema.Prompt("greeting", "A friendly greeting prompt", + List.of(new McpSchema.PromptArgument("name", "The name to greet", true))); + + var promptSpecification = new McpServerFeatures.SyncPromptSpecification(prompt, (exchange, getPromptRequest) -> { + String nameArgument = (String) getPromptRequest.arguments().get("name"); + if (nameArgument == null) { nameArgument = "friend"; } + var userMessage = new PromptMessage(Role.USER, new TextContent("Hello " + nameArgument + "! How can I assist you today?")); + return new GetPromptResult("A personalized greeting message", List.of(userMessage)); + }); + + return List.of(promptSpecification); +} +---- + +=== link:https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/completion/[Completions] + +Provides a standardized way for servers to expose completion capabilities to clients. + +* Support for both sync and async completion specifications +* Automatic registration through Spring beans: + +[source,java] +---- +@Bean +public List myCompletions() { + var completion = new McpServerFeatures.SyncCompletionSpecification( + new McpSchema.PromptReference( + "ref/prompt", "code-completion", "Provides code completion suggestions"), + (exchange, request) -> { + // Implementation that returns completion suggestions + return new McpSchema.CompleteResult(List.of("python", "pytorch", "pyside"), 10, true); + } + ); + + return List.of(completion); +} +---- + +=== link:https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/logging/[Logging] + +Provides a standardized way for servers to send structured log messages to clients. +From within the tool, resource, prompt or completion call handler use the provided `McpSyncServerExchange`/`McpAsyncServerExchange` `exchange` object to send logging messages: + +[source,java] +---- +(exchange, request) -> { + exchange.loggingNotification(LoggingMessageNotification.builder() + .level(LoggingLevel.INFO) + .logger("test-logger") + .data("This is a test log message") + .build()); +} +---- + +On the MCP client you can register xref::api/mcp/mcp-client-boot-starter-docs#_customization_types[logging consumers] to handle these messages: + +[source,java] +---- +mcpClientSpec.loggingConsumer((McpSchema.LoggingMessageNotification log) -> { + // Handle log messages +}); +---- + +=== link:https://modelcontextprotocol.io/specification/2025-03-26/basic/utilities/progress[Progress] + +Provides a standardized way for servers to send progress updates to clients. +From within the tool, resource, prompt or completion call handler use the provided `McpSyncServerExchange`/`McpAsyncServerExchange` `exchange` object to send progress notifications: + +[source,java] +---- +(exchange, request) -> { + exchange.progressNotification(ProgressNotification.builder() + .progressToken("test-progress-token") + .progress(0.25) + .total(1.0) + .message("tool call in progress") + .build()); +} +---- + +The Mcp Client can receive progress notifications and update its UI accordingly. +For this it needs to register a progress consumer. + +[source,java] +---- +mcpClientSpec.progressConsumer((McpSchema.ProgressNotification progress) -> { + // Handle progress notifications +}); +---- + +=== link:https://modelcontextprotocol.io/specification/2025-03-26/client/roots#root-list-changes[Root List Changes] + +When roots change, clients that support `listChanged` send a root change notification. + +* Support for monitoring root changes +* Automatic conversion to async consumers for reactive applications +* Optional registration through Spring beans + +[source,java] +---- +@Bean +public BiConsumer > rootsChangeHandler() { + return (exchange, roots) -> { + logger.info("Registering root resources: {}", roots); + }; +} +---- + +=== link:https://modelcontextprotocol.io/specification/2025-03-26/basic/utilities/ping/[Ping] + +Ping mechanism for the server to verify that its clients are still alive. +From within the tool, resource, prompt or completion call handler use the provided `McpSyncServerExchange`/`McpAsyncServerExchange` `exchange` object to send ping messages: + +[source,java] +---- +(exchange, request) -> { + exchange.ping(); +} +---- + +=== Keep Alive + +Server can optionally, periodically issue pings to connected clients to verify connection health. + +By default, keep-alive is disabled. +To enable keep-alive, set the `keep-alive-interval` property in your configuration: + +```yaml +spring: + ai: + mcp: + server: + streamable-http: + keep-alive-interval: 30s +``` + +NOTE: Currently, for streamable-http servers, the keep-alive mechanism is available only for the link:https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#listening-for-messages-from-the-server[Listening for Messages from the Server (SSE)] connection. + + +== Usage Examples + +=== Streamable HTTP Server Configuration +[source,yaml] +---- +# Using spring-ai-starter-mcp-server-streamable-webmvc +spring: + ai: + mcp: + server: + streamable-http: + name: streamable-mcp-server + version: 1.0.0 + type: SYNC + instructions: "This streamable server provides real-time notifications" + resource-change-notification: true + tool-change-notification: true + prompt-change-notification: true +---- + + +=== Creating a Spring Boot Application with MCP Server + +[source,java] +---- +@Service +public class WeatherService { + + @Tool(description = "Get weather information by city name") + public String getWeather(String cityName) { + // Implementation + } +} + +@SpringBootApplication +public class McpServerApplication { + + private static final Logger logger = LoggerFactory.getLogger(McpServerApplication.class); + + public static void main(String[] args) { + SpringApplication.run(McpServerApplication.class, args); + } + + @Bean + public ToolCallbackProvider weatherTools(WeatherService weatherService) { + return MethodToolCallbackProvider.builder().toolObjects(weatherService).build(); + } +} +---- + +The auto-configuration will automatically register the tool callbacks as MCP tools. +You can have multiple beans producing ToolCallbacks, and the auto-configuration will merge them. diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-stateless-webflux/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-stateless-webflux/pom.xml new file mode 100644 index 00000000000..3b064124c61 --- /dev/null +++ b/spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-stateless-webflux/pom.xml @@ -0,0 +1,72 @@ + + + + + diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-stateless-webmvc/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-stateless-webmvc/pom.xml new file mode 100644 index 00000000000..052d136fb85 --- /dev/null +++ b/spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-stateless-webmvc/pom.xml @@ -0,0 +1,66 @@ + + + +4.0.0 ++ +org.springframework.ai +spring-ai-parent +1.1.0-SNAPSHOT +../../pom.xml +spring-ai-starter-mcp-server-stateless-webflux +jar +Spring AI Starter - MCP Server Stateless Webflux +Spring AI MCP Server Stateless WebFlux Spring Boot Starter +https://github.com/spring-projects/spring-ai + ++ + +https://github.com/spring-projects/spring-ai +git://github.com/spring-projects/spring-ai.git +git@github.com:spring-projects/spring-ai.git ++ + + ++ + +org.springframework.boot +spring-boot-starter ++ + +org.springframework.ai +spring-ai-autoconfigure-mcp-stateless-server-webflux +${project.parent.version} ++ + +org.springframework.ai +spring-ai-mcp +${project.parent.version} ++ + +io.modelcontextprotocol.sdk +mcp-spring-webflux ++ + + +org.springframework.boot +spring-boot-starter-webflux ++ diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-streamable-webflux/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-streamable-webflux/pom.xml new file mode 100644 index 00000000000..9724041f24c --- /dev/null +++ b/spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-streamable-webflux/pom.xml @@ -0,0 +1,72 @@ + + + +4.0.0 ++ +org.springframework.ai +spring-ai-parent +1.1.0-SNAPSHOT +../../pom.xml +spring-ai-starter-mcp-server-stateless-webmvc +jar +Spring AI Starter - MCP Server Stateless WebMVC +Spring AI MCP Server Stateless WebMVC Spring Boot Starter +https://github.com/spring-projects/spring-ai + ++ + +https://github.com/spring-projects/spring-ai +git://github.com/spring-projects/spring-ai.git +git@github.com:spring-projects/spring-ai.git ++ + + ++ + +org.springframework.boot +spring-boot-starter ++ + +org.springframework.ai +spring-ai-autoconfigure-mcp-stateless-server-webmvc +${project.parent.version} ++ + +org.springframework.ai +spring-ai-mcp +${project.parent.version} ++ + +io.modelcontextprotocol.sdk +mcp-spring-webmvc ++ diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-streamable-webmvc/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-streamable-webmvc/pom.xml new file mode 100644 index 00000000000..d85fdbdf855 --- /dev/null +++ b/spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-streamable-webmvc/pom.xml @@ -0,0 +1,66 @@ + + + +4.0.0 ++ +org.springframework.ai +spring-ai-parent +1.1.0-SNAPSHOT +../../pom.xml +spring-ai-starter-mcp-server-streamable-webflux +jar +Spring AI Starter - MCP Server Streamable Webflux +Spring AI MCP Server Streamable WebFlux Spring Boot Starter +https://github.com/spring-projects/spring-ai + ++ + +https://github.com/spring-projects/spring-ai +git://github.com/spring-projects/spring-ai.git +git@github.com:spring-projects/spring-ai.git ++ + + ++ + +org.springframework.boot +spring-boot-starter ++ + +org.springframework.ai +spring-ai-autoconfigure-mcp-streamable-server-webflux +${project.parent.version} ++ + +org.springframework.ai +spring-ai-mcp +${project.parent.version} ++ + +io.modelcontextprotocol.sdk +mcp-spring-webflux ++ + + +org.springframework.boot +spring-boot-starter-webflux ++ 4.0.0 ++ +org.springframework.ai +spring-ai-parent +1.1.0-SNAPSHOT +../../pom.xml +spring-ai-starter-mcp-server-streamable-webmvc +jar +Spring AI Starter - MCP Server Streamable WebMVC +Spring AI MCP Server Streamable WebMVC Spring Boot Starter +https://github.com/spring-projects/spring-ai + ++ + +https://github.com/spring-projects/spring-ai +git://github.com/spring-projects/spring-ai.git +git@github.com:spring-projects/spring-ai.git ++ + + ++ + +org.springframework.boot +spring-boot-starter ++ + +org.springframework.ai +spring-ai-autoconfigure-mcp-streamable-server-webmvc +${project.parent.version} ++ + +org.springframework.ai +spring-ai-mcp +${project.parent.version} ++ + +io.modelcontextprotocol.sdk +mcp-spring-webmvc +