From f7a8fda110c97adf7f73cedcbb1c4f59554f0493 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Fri, 31 Jan 2025 14:33:32 +0100 Subject: [PATCH 1/2] feat: Add Spring AI MCP Integration Adds comprehensive Model Context Protocol (MCP) integration to Spring AI, including: Core Features: - MCP client implementation with Spring AI tool calling capabilities - Spring-friendly abstractions for MCP clients and servers - Both synchronous and asynchronous MCP server operation modes - Add MCP client autoconfiguration with support for STDIO, WebMVC and WebFlux transports - Auto-configuration for MCP server components - Spring Boot starter (spring-ai-starter-mcp) with WebFlux and WebMVC support - MCP dependency management with BOM - Add close() method to McpToolCallback for proper resource cleanup - Add initialize flag to control MCP client initialization - Add comprehensive integration tests and documentation for MCP client configuration Technical Improvements: - Split WebMvc and WebFlux configurations into separate auto-configuration classes - Server type configurable via 'spring.ai.mcp.server.type' property (SYNC/ASYNC) - Comprehensive test coverage including McpServerAutoConfigurationIT - Utility classes for converting between Spring AI tools and MCP tools - MCP SDK version management in parent pom refactor(mcp): reorganize MCP tool utilities and client configuration - Rename ToolUtils to McpToolUtils for better MCP-specific naming - Rename McpToolCallbackProvider to SyncMcpToolCallbackProvider - Add utility methods for handling tool callbacks in McpToolUtils - Extract client configuration logic into new McpClientDefinitions class - Add tool callback support to ChatClient interface and implementations - Remove redundant integration test refactor: introduce MCP client customization support Add McpSyncClientCustomizer interface for customizing MCP sync clients Replace McpClientDefinitions with McpSyncClientConfigurer Refactor MCP client initialization to support customization Remove redundant close() method from McpToolCallback Fix conditional class dependencies in WebMvc/Flux configurations Signed-off-by: Christian Tzolov --- mcp/common/pom.xml | 87 +++++++ .../ai/mcp/McpSyncClientCustomizer.java | 28 ++ .../ai/mcp/McpToolCallback.java | 115 +++++++++ .../springframework/ai/mcp/McpToolUtils.java | 201 +++++++++++++++ .../ai/mcp/SyncMcpToolCallbackProvider.java | 121 +++++++++ .../ai/mcp/McpToolCallbackProviderTests.java | 99 ++++++++ .../ai/mcp/McpToolCallbackTests.java | 74 ++++++ .../ai/mcp/ToolUtilsTests.java | 173 +++++++++++++ pom.xml | 20 +- spring-ai-bom/pom.xml | 12 + .../ai/chat/client/ChatClient.java | 5 + .../ai/chat/client/DefaultChatClient.java | 10 + .../chat/client/DefaultChatClientBuilder.java | 7 + spring-ai-docs/src/main/asciidoc/mcp.md | 202 +++++++++++++++ spring-ai-spring-boot-autoconfigure/pom.xml | 23 ++ .../stdio/McpStdioClientProperties.java | 209 +++++++++++++++ .../mcp/client/stdio/McpStdioConnection.java | 61 +++++ .../client/stdio/McpSyncClientConfigurer.java | 46 ++++ .../MpcStdioClientAutoConfiguration.java | 135 ++++++++++ .../mcp/server/McpServerProperties.java | 240 ++++++++++++++++++ .../server/MpcServerAutoConfiguration.java | 229 +++++++++++++++++ .../MpcWebFluxServerAutoConfiguration.java | 82 ++++++ .../MpcWebMvcServerAutoConfiguration.java | 82 ++++++ ...ot.autoconfigure.AutoConfiguration.imports | 5 + .../server/McpServerAutoConfigurationIT.java | 93 +++++++ .../resources/application-test.properties | 10 + .../spring-ai-starter-mcp/pom.xml | 58 +++++ 27 files changed, 2426 insertions(+), 1 deletion(-) create mode 100644 mcp/common/pom.xml create mode 100644 mcp/common/src/main/java/org/springframework/ai/mcp/McpSyncClientCustomizer.java create mode 100644 mcp/common/src/main/java/org/springframework/ai/mcp/McpToolCallback.java create mode 100644 mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java create mode 100644 mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallbackProvider.java create mode 100644 mcp/common/src/test/java/org/springframework/ai/mcp/McpToolCallbackProviderTests.java create mode 100644 mcp/common/src/test/java/org/springframework/ai/mcp/McpToolCallbackTests.java create mode 100644 mcp/common/src/test/java/org/springframework/ai/mcp/ToolUtilsTests.java create mode 100644 spring-ai-docs/src/main/asciidoc/mcp.md create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/client/stdio/McpStdioClientProperties.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/client/stdio/McpStdioConnection.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/client/stdio/McpSyncClientConfigurer.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/client/stdio/MpcStdioClientAutoConfiguration.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/server/McpServerProperties.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/server/MpcServerAutoConfiguration.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/server/MpcWebFluxServerAutoConfiguration.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/server/MpcWebMvcServerAutoConfiguration.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mcp/server/McpServerAutoConfigurationIT.java create mode 100644 spring-ai-spring-boot-autoconfigure/src/test/resources/application-test.properties create mode 100644 spring-ai-spring-boot-starters/spring-ai-starter-mcp/pom.xml diff --git a/mcp/common/pom.xml b/mcp/common/pom.xml new file mode 100644 index 00000000000..ba4ecf4f702 --- /dev/null +++ b/mcp/common/pom.xml @@ -0,0 +1,87 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../pom.xml + + spring-ai-mcp + Spring AI MCP Client + Spring Framework integration for Model Context Protocol (MCP), providing Spring AI function calling capabilities and Spring-friendly abstractions for MCP clients and MCP servers + + 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 + + + + + + io.modelcontextprotocol.sdk + mcp-bom + ${mcp.sdk.version} + pom + import + + + + + + + io.modelcontextprotocol.sdk + mcp + + + + + org.junit.jupiter + junit-jupiter + test + + + + org.mockito + mockito-junit-jupiter + test + + + + org.assertj + assertj-core + test + + + + io.projectreactor + reactor-test + test + + + + io.modelcontextprotocol.sdk + mcp-spring-webflux + true + + + + io.modelcontextprotocol.sdk + mcp-spring-webmvc + true + + + + org.springframework.ai + spring-ai-core + ${project.parent.version} + + + + + diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/McpSyncClientCustomizer.java b/mcp/common/src/main/java/org/springframework/ai/mcp/McpSyncClientCustomizer.java new file mode 100644 index 00000000000..40c35cb2fcd --- /dev/null +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/McpSyncClientCustomizer.java @@ -0,0 +1,28 @@ +/* +* Copyright 2024 - 2024 the original author or authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +package org.springframework.ai.mcp; + +import io.modelcontextprotocol.client.McpClient; + +/** + * @author Christian Tzolov + * @since 1.0.0 + */ +public interface McpSyncClientCustomizer { + + void customize(String name, McpClient.SyncSpec sync); + +} diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolCallback.java b/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolCallback.java new file mode 100644 index 00000000000..b4e3ada139f --- /dev/null +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolCallback.java @@ -0,0 +1,115 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.mcp; + +import java.util.Map; + +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.Tool; + +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; + +/** + * Implementation of {@link ToolCallback} that adapts MCP tools to Spring AI's tool + * interface. + *

+ * This class acts as a bridge between the Model Context Protocol (MCP) and Spring AI's + * tool system, allowing MCP tools to be used seamlessly within Spring AI applications. + * It: + *

+ *

+ * Example usage:

{@code
+ * McpSyncClient mcpClient = // obtain MCP client
+ * Tool mcpTool = // obtain MCP tool definition
+ * ToolCallback callback = new McpToolCallback(mcpClient, mcpTool);
+ *
+ * // Use the tool through Spring AI's interfaces
+ * ToolDefinition definition = callback.getToolDefinition();
+ * String result = callback.call("{\"param\": \"value\"}");
+ * }
+ * + * @author Christian Tzolov + * @see ToolCallback + * @see McpSyncClient + * @see Tool + */ +public class McpToolCallback implements ToolCallback { + + private final McpSyncClient mcpClient; + + private final Tool tool; + + /** + * Creates a new {@code McpToolCallback} instance. + * @param mcpClient the MCP client to use for tool execution + * @param tool the MCP tool definition to adapt + */ + + public McpToolCallback(McpSyncClient mcpClient, Tool tool) { + this.mcpClient = mcpClient; + this.tool = tool; + } + + /** + * Returns a Spring AI tool definition adapted from the MCP tool. + *

+ * The tool definition includes: + *

+ * @return the Spring AI tool definition + */ + @Override + public ToolDefinition getToolDefinition() { + return ToolDefinition.builder() + .name(this.tool.name()) + .description(this.tool.description()) + .inputSchema(ModelOptionsUtils.toJsonString(this.tool.inputSchema())) + .build(); + } + + /** + * Executes the tool with the provided input. + *

+ * This method: + *

    + *
  1. Converts the JSON input string to a map of arguments
  2. + *
  3. Calls the tool through the MCP client
  4. + *
  5. Converts the tool's response content to a JSON string
  6. + *
+ * @param functionInput the tool input as a JSON string + * @return the tool's response as a JSON string + */ + @Override + public String call(String functionInput) { + Map arguments = ModelOptionsUtils.jsonToMap(functionInput); + CallToolResult response = this.mcpClient + .callTool(new CallToolRequest(this.getToolDefinition().name(), arguments)); + return ModelOptionsUtils.toJsonString(response.content()); + } + +} 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 new file mode 100644 index 00000000000..e7f3b2319c0 --- /dev/null +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java @@ -0,0 +1,201 @@ +/* +* Copyright 2025 - 2025 the original author or authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +package org.springframework.ai.mcp; + +import java.util.List; + +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolRegistration; +import io.modelcontextprotocol.spec.McpSchema; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.util.CollectionUtils; + +/** + * Utility class that provides helper methods for working with Model Context Protocol + * (MCP) tools in a Spring AI environment. This class facilitates the integration between + * Spring AI's tool callbacks and MCP's tool system. + * + *

+ * The MCP tool system enables servers to expose executable functionality to language + * models, allowing them to interact with external systems, perform computations, and take + * actions in the real world. Each tool is uniquely identified by a name and includes + * metadata describing its schema. + * + *

+ * This helper class provides methods to: + *

    + *
  • Convert Spring AI's {@link ToolCallback} instances to MCP tool registrations
  • + *
  • Generate JSON schemas for tool input validation
  • + *
+ * + * @author Christian Tzolov + */ +public final class McpToolUtils { + + private McpToolUtils() { + } + + /** + * Converts a list of Spring AI tool callbacks to MCP synchronous tool registrations. + *

+ * This method processes multiple tool callbacks in bulk, converting each one to its + * corresponding MCP tool registration while maintaining synchronous execution + * semantics. + * @param toolCallbacks the list of tool callbacks to convert + * @return a list of MCP synchronous tool registrations + * @see #toSyncToolRegistration(ToolCallback) + */ + public static List toSyncToolRegistration( + List toolCallbacks) { + return toolCallbacks.stream().map(McpToolUtils::toSyncToolRegistration).toList(); + } + + /** + * Convenience method to convert a variable number of tool callbacks to MCP + * synchronous tool registrations. + *

+ * This is a varargs wrapper around {@link #toSyncToolRegistration(List)} for easier + * usage when working with individual callbacks. + * @param toolCallbacks the tool callbacks to convert + * @return a list of MCP synchronous tool registrations + * @see #toSyncToolRegistration(List) + */ + public static List toSyncToolRegistration(ToolCallback... toolCallbacks) { + return toSyncToolRegistration(List.of(toolCallbacks)); + } + + /** + * Converts a Spring AI FunctionCallback to an MCP SyncToolRegistration. This enables + * Spring AI functions to be exposed as MCP tools that can be discovered and invoked + * by language models. + * + *

+ * The conversion process: + *

    + *
  • Creates an MCP Tool with the function's name and input schema
  • + *
  • Wraps the function's execution in a SyncToolRegistration that handles the MCP + * protocol
  • + *
  • Provides error handling and result formatting according to MCP + * specifications
  • + *
+ * + * You can use the FunctionCallback builder to create a new instance of + * FunctionCallback using either java.util.function.Function or Method reference. + * @param toolCallback the Spring AI function callback to convert + * @return an MCP SyncToolRegistration that wraps the function callback + * @throws RuntimeException if there's an error during the function execution + */ + public static McpServerFeatures.SyncToolRegistration toSyncToolRegistration(ToolCallback toolCallback) { + var tool = new McpSchema.Tool(toolCallback.getToolDefinition().name(), + toolCallback.getToolDefinition().description(), toolCallback.getToolDefinition().inputSchema()); + + return new McpServerFeatures.SyncToolRegistration(tool, request -> { + try { + String callResult = toolCallback.call(ModelOptionsUtils.toJsonString(request)); + return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(callResult)), false); + } + catch (Exception e) { + return new McpSchema.CallToolResult(List.of(new McpSchema.TextContent(e.getMessage())), true); + } + }); + } + + /** + * Converts a list of Spring AI tool callbacks to MCP asynchronous tool registrations. + *

+ * This method processes multiple tool callbacks in bulk, converting each one to its + * corresponding MCP tool registration while adding asynchronous execution + * capabilities. The resulting registrations will execute their tools on a bounded + * elastic scheduler. + * @param toolCallbacks the list of tool callbacks to convert + * @return a list of MCP asynchronous tool registrations + * @see #toAsyncToolRegistration(ToolCallback) + */ + public static List toAsyncToolRegistration( + List toolCallbacks) { + return toolCallbacks.stream().map(McpToolUtils::toAsyncToolRegistration).toList(); + } + + /** + * Convenience method to convert a variable number of tool callbacks to MCP + * asynchronous tool registrations. + *

+ * This is a varargs wrapper around {@link #toAsyncToolRegistration(List)} for easier + * usage when working with individual callbacks. + * @param toolCallbacks the tool callbacks to convert + * @return a list of MCP asynchronous tool registrations + * @see #toAsyncToolRegistration(List) + */ + public static List toAsyncToolRegistration(ToolCallback... toolCallbacks) { + return toAsyncToolRegistration(List.of(toolCallbacks)); + } + + /** + * Converts a Spring AI tool callback to an MCP asynchronous tool registration. + *

+ * This method enables Spring AI tools to be exposed as asynchronous MCP tools that + * can be discovered and invoked by language models. The conversion process: + *

    + *
  • First converts the callback to a synchronous registration
  • + *
  • Wraps the synchronous execution in a reactive Mono
  • + *
  • Configures execution on a bounded elastic scheduler for non-blocking + * operation
  • + *
+ *

+ * The resulting async registration will: + *

    + *
  • Execute the tool without blocking the calling thread
  • + *
  • Handle errors and results asynchronously
  • + *
  • Provide backpressure through Project Reactor
  • + *
+ * @param toolCallback the Spring AI tool callback to convert + * @return an MCP asynchronous tool registration that wraps the tool callback + * @see McpServerFeatures.AsyncToolRegistration + * @see Mono + * @see Schedulers#boundedElastic() + */ + public static McpServerFeatures.AsyncToolRegistration toAsyncToolRegistration(ToolCallback toolCallback) { + + McpServerFeatures.SyncToolRegistration syncToolRegistration = toSyncToolRegistration(toolCallback); + if (syncToolRegistration == null) { + return null; + } + return new AsyncToolRegistration(syncToolRegistration.tool(), + map -> Mono.fromCallable(() -> syncToolRegistration.call().apply(map)) + .subscribeOn(Schedulers.boundedElastic())); + } + + public static List getToolCallbacks(McpSyncClient... mcpClients) { + return getToolCallbacks(List.of(mcpClients)); + } + + public static List getToolCallbacks(List mcpClients) { + + if (CollectionUtils.isEmpty(mcpClients)) { + return List.of(); + } + return mcpClients.stream() + .map(mcpClient -> List.of((new SyncMcpToolCallbackProvider(mcpClient).getToolCallbacks()))) + .flatMap(List::stream) + .toList(); + } + +} diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallbackProvider.java b/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallbackProvider.java new file mode 100644 index 00000000000..4ac9e2daab0 --- /dev/null +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallbackProvider.java @@ -0,0 +1,121 @@ +/* +* Copyright 2025 - 2025 the original author or authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +package org.springframework.ai.mcp; + +import java.util.List; + +import io.modelcontextprotocol.client.McpAsyncClient; +import io.modelcontextprotocol.client.McpSyncClient; + +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.ToolCallbackProvider; +import org.springframework.ai.tool.util.ToolUtils; +import org.springframework.util.CollectionUtils; + +/** + * Implementation of {@link ToolCallbackProvider} that discovers and provides MCP tools. + *

+ * This class acts as a tool provider for Spring AI, automatically discovering tools from + * an MCP server and making them available as Spring AI tools. It: + *

    + *
  • Connects to an MCP server through a sync client
  • + *
  • Lists and retrieves available tools from the server
  • + *
  • Creates {@link McpToolCallback} instances for each discovered tool
  • + *
  • Validates tool names to prevent duplicates
  • + *
+ *

+ * Example usage:

{@code
+ * McpSyncClient mcpClient = // obtain MCP client
+ * ToolCallbackProvider provider = new McpToolCallbackProvider(mcpClient);
+ *
+ * // Get all available tools
+ * ToolCallback[] tools = provider.getToolCallbacks();
+ * }
+ * + * @author Christian Tzolov + * @since 1.0.0 + * @see ToolCallbackProvider + * @see McpToolCallback + * @see McpSyncClient + */ + +public class SyncMcpToolCallbackProvider implements ToolCallbackProvider { + + private final McpSyncClient mcpClient; + + /** + * Creates a new {@code McpToolCallbackProvider} instance. + * @param mcpClient the MCP client to use for discovering tools + */ + public SyncMcpToolCallbackProvider(McpSyncClient mcpClient) { + this.mcpClient = mcpClient; + } + + /** + * Discovers and returns all available tools from the MCP server. + *

+ * This method: + *

    + *
  1. Retrieves the list of tools from the MCP server
  2. + *
  3. Creates a {@link McpToolCallback} for each tool
  4. + *
  5. Validates that there are no duplicate tool names
  6. + *
+ * @return an array of tool callbacks, one for each discovered tool + * @throws IllegalStateException if duplicate tool names are found + */ + @Override + public ToolCallback[] getToolCallbacks() { + + var toolCallbacks = this.mcpClient.listTools() + .tools() + .stream() + .map(tool -> new McpToolCallback(this.mcpClient, tool)) + .toArray(ToolCallback[]::new); + + validateToolCallbacks(toolCallbacks); + + return toolCallbacks; + + } + + /** + * Validates that there are no duplicate tool names in the provided callbacks. + *

+ * This method ensures that each tool has a unique name, which is required for proper + * tool resolution and execution. + * @param toolCallbacks the tool callbacks to validate + * @throws IllegalStateException if duplicate tool names are found + */ + private void validateToolCallbacks(ToolCallback[] toolCallbacks) { + List duplicateToolNames = ToolUtils.getDuplicateToolNames(toolCallbacks); + if (!duplicateToolNames.isEmpty()) { + throw new IllegalStateException( + "Multiple tools with the same name (%s)".formatted(String.join(", ", duplicateToolNames))); + } + } + + public static List syncToolCallbacks(List mcpClients) { + + if (CollectionUtils.isEmpty(mcpClients)) { + return List.of(); + } + return mcpClients.stream() + .map(mcpClient -> List.of((new SyncMcpToolCallbackProvider(mcpClient).getToolCallbacks()))) + .flatMap(List::stream) + .toList(); + } + +} diff --git a/mcp/common/src/test/java/org/springframework/ai/mcp/McpToolCallbackProviderTests.java b/mcp/common/src/test/java/org/springframework/ai/mcp/McpToolCallbackProviderTests.java new file mode 100644 index 00000000000..0fc86888df7 --- /dev/null +++ b/mcp/common/src/test/java/org/springframework/ai/mcp/McpToolCallbackProviderTests.java @@ -0,0 +1,99 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.mcp; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.spec.McpSchema.ListToolsResult; +import io.modelcontextprotocol.spec.McpSchema.Tool; + +@ExtendWith(MockitoExtension.class) +class McpToolCallbackProviderTests { + + @Mock + private McpSyncClient mcpClient; + + @Test + void getToolCallbacksShouldReturnEmptyArrayWhenNoTools() { + // Arrange + ListToolsResult listToolsResult = mock(ListToolsResult.class); + when(listToolsResult.tools()).thenReturn(List.of()); + when(mcpClient.listTools()).thenReturn(listToolsResult); + + SyncMcpToolCallbackProvider provider = new SyncMcpToolCallbackProvider(mcpClient); + + // Act + var callbacks = provider.getToolCallbacks(); + + // Assert + assertThat(callbacks).isEmpty(); + } + + @Test + void getToolCallbacksShouldReturnCallbacksForEachTool() { + // Arrange + Tool tool1 = mock(Tool.class); + when(tool1.name()).thenReturn("tool1"); + + Tool tool2 = mock(Tool.class); + when(tool2.name()).thenReturn("tool2"); + + ListToolsResult listToolsResult = mock(ListToolsResult.class); + when(listToolsResult.tools()).thenReturn(List.of(tool1, tool2)); + when(mcpClient.listTools()).thenReturn(listToolsResult); + + SyncMcpToolCallbackProvider provider = new SyncMcpToolCallbackProvider(mcpClient); + + // Act + var callbacks = provider.getToolCallbacks(); + + // Assert + assertThat(callbacks).hasSize(2); + } + + @Test + void getToolCallbacksShouldThrowExceptionForDuplicateToolNames() { + // Arrange + Tool tool1 = mock(Tool.class); + when(tool1.name()).thenReturn("sameName"); + + Tool tool2 = mock(Tool.class); + when(tool2.name()).thenReturn("sameName"); + + ListToolsResult listToolsResult = mock(ListToolsResult.class); + when(listToolsResult.tools()).thenReturn(List.of(tool1, tool2)); + when(mcpClient.listTools()).thenReturn(listToolsResult); + + SyncMcpToolCallbackProvider provider = new SyncMcpToolCallbackProvider(mcpClient); + + // Act & Assert + assertThatThrownBy(() -> provider.getToolCallbacks()).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Multiple tools with the same name"); + } + +} diff --git a/mcp/common/src/test/java/org/springframework/ai/mcp/McpToolCallbackTests.java b/mcp/common/src/test/java/org/springframework/ai/mcp/McpToolCallbackTests.java new file mode 100644 index 00000000000..d0eefaf75e5 --- /dev/null +++ b/mcp/common/src/test/java/org/springframework/ai/mcp/McpToolCallbackTests.java @@ -0,0 +1,74 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.mcp; + +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.Tool; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class McpToolCallbackTests { + + @Mock + private McpSyncClient mcpClient; + + @Mock + private Tool tool; + + @Test + void getToolDefinitionShouldReturnCorrectDefinition() { + // Arrange + when(tool.name()).thenReturn("testTool"); + when(tool.description()).thenReturn("Test tool description"); + + McpToolCallback callback = new McpToolCallback(mcpClient, tool); + + // Act + var toolDefinition = callback.getToolDefinition(); + + // Assert + assertThat(toolDefinition.name()).isEqualTo("testTool"); + assertThat(toolDefinition.description()).isEqualTo("Test tool description"); + } + + @Test + void callShouldHandleJsonInputAndOutput() { + // Arrange + when(tool.name()).thenReturn("testTool"); + CallToolResult callResult = mock(CallToolResult.class); + when(mcpClient.callTool(any(CallToolRequest.class))).thenReturn(callResult); + + McpToolCallback callback = new McpToolCallback(mcpClient, tool); + + // Act + String response = callback.call("{\"param\":\"value\"}"); + + // Assert + assertThat(response).isNotNull(); + } + +} diff --git a/mcp/common/src/test/java/org/springframework/ai/mcp/ToolUtilsTests.java b/mcp/common/src/test/java/org/springframework/ai/mcp/ToolUtilsTests.java new file mode 100644 index 00000000000..18af850ad1f --- /dev/null +++ b/mcp/common/src/test/java/org/springframework/ai/mcp/ToolUtilsTests.java @@ -0,0 +1,173 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.mcp; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Modifier; +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolRegistration; +import io.modelcontextprotocol.server.McpServerFeatures.SyncToolRegistration; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ToolUtilsTests { + + @Test + void constructorShouldBePrivate() throws Exception { + Constructor constructor = McpToolUtils.class.getDeclaredConstructor(); + assertThat(Modifier.isPrivate(constructor.getModifiers())).isTrue(); + constructor.setAccessible(true); + constructor.newInstance(); + } + + @Test + void toSyncToolRegistrationShouldConvertSingleCallback() { + // Arrange + ToolCallback callback = createMockToolCallback("test", "success"); + + // Act + SyncToolRegistration registration = McpToolUtils.toSyncToolRegistration(callback); + + // Assert + assertThat(registration).isNotNull(); + assertThat(registration.tool().name()).isEqualTo("test"); + + CallToolResult result = registration.call().apply(Map.of()); + TextContent content = (TextContent) result.content().get(0); + assertThat(content.text()).isEqualTo("success"); + assertThat(result.isError()).isFalse(); + } + + @Test + void toSyncToolRegistrationShouldHandleError() { + // Arrange + ToolCallback callback = createMockToolCallback("test", new RuntimeException("error")); + + // Act + SyncToolRegistration registration = McpToolUtils.toSyncToolRegistration(callback); + + // Assert + assertThat(registration).isNotNull(); + CallToolResult result = registration.call().apply(Map.of()); + TextContent content = (TextContent) result.content().get(0); + assertThat(content.text()).isEqualTo("error"); + assertThat(result.isError()).isTrue(); + } + + @Test + void toSyncToolRegistrationShouldConvertMultipleCallbacks() { + // Arrange + ToolCallback callback1 = createMockToolCallback("test1", "success1"); + ToolCallback callback2 = createMockToolCallback("test2", "success2"); + + // Act + List registrations = McpToolUtils.toSyncToolRegistration(callback1, callback2); + + // Assert + assertThat(registrations).hasSize(2); + assertThat(registrations.get(0).tool().name()).isEqualTo("test1"); + assertThat(registrations.get(1).tool().name()).isEqualTo("test2"); + } + + @Test + void toAsyncToolRegistrationShouldConvertSingleCallback() { + // Arrange + ToolCallback callback = createMockToolCallback("test", "success"); + + // Act + AsyncToolRegistration registration = McpToolUtils.toAsyncToolRegistration(callback); + + // Assert + assertThat(registration).isNotNull(); + assertThat(registration.tool().name()).isEqualTo("test"); + + StepVerifier.create(registration.call().apply(Map.of())).assertNext(result -> { + TextContent content = (TextContent) result.content().get(0); + assertThat(content.text()).isEqualTo("success"); + assertThat(result.isError()).isFalse(); + }).verifyComplete(); + } + + @Test + void toAsyncToolRegistrationShouldHandleError() { + // Arrange + ToolCallback callback = createMockToolCallback("test", new RuntimeException("error")); + + // Act + AsyncToolRegistration registration = McpToolUtils.toAsyncToolRegistration(callback); + + // Assert + assertThat(registration).isNotNull(); + StepVerifier.create(registration.call().apply(Map.of())).assertNext(result -> { + TextContent content = (TextContent) result.content().get(0); + assertThat(content.text()).isEqualTo("error"); + assertThat(result.isError()).isTrue(); + }).verifyComplete(); + } + + @Test + void toAsyncToolRegistrationShouldConvertMultipleCallbacks() { + // Arrange + ToolCallback callback1 = createMockToolCallback("test1", "success1"); + ToolCallback callback2 = createMockToolCallback("test2", "success2"); + + // Act + List registrations = McpToolUtils.toAsyncToolRegistration(callback1, callback2); + + // Assert + assertThat(registrations).hasSize(2); + assertThat(registrations.get(0).tool().name()).isEqualTo("test1"); + assertThat(registrations.get(1).tool().name()).isEqualTo("test2"); + } + + private ToolCallback createMockToolCallback(String name, String result) { + ToolCallback callback = mock(ToolCallback.class); + ToolDefinition definition = ToolDefinition.builder() + .name(name) + .description("Test tool") + .inputSchema("{}") + .build(); + when(callback.getToolDefinition()).thenReturn(definition); + when(callback.call(anyString())).thenReturn(result); + return callback; + } + + private ToolCallback createMockToolCallback(String name, RuntimeException error) { + ToolCallback callback = mock(ToolCallback.class); + ToolDefinition definition = ToolDefinition.builder() + .name(name) + .description("Test tool") + .inputSchema("{}") + .build(); + when(callback.getToolDefinition()).thenReturn(definition); + when(callback.call(anyString())).thenThrow(error); + return callback; + } + +} diff --git a/pom.xml b/pom.xml index c8b36b70257..3fb66c04f42 100644 --- a/pom.xml +++ b/pom.xml @@ -127,7 +127,11 @@ spring-ai-spring-boot-starters/spring-ai-starter-zhipuai spring-ai-spring-boot-starters/spring-ai-starter-moonshot + spring-ai-spring-boot-starters/spring-ai-starter-mcp + spring-ai-integration-tests + + mcp/common @@ -249,6 +253,9 @@ 1.5.1 0.0.6 + + 0.7.0-SNAPSHOT + 3.11.0 3.1.2 @@ -850,11 +857,22 @@ + + Central Portal Snapshots + central-portal-snapshots + https://central.sonatype.com/repository/maven-snapshots/ + + false + + + true + + maven-central https://repo.maven.apache.org/maven2/ - false + true true diff --git a/spring-ai-bom/pom.xml b/spring-ai-bom/pom.xml index fb8fee83f4e..5979f150857 100644 --- a/spring-ai-bom/pom.xml +++ b/spring-ai-bom/pom.xml @@ -42,6 +42,12 @@ + + org.springframework.ai + spring-ai-mcp + ${project.version} + + org.springframework.ai spring-ai-core @@ -581,6 +587,12 @@ ${project.version} + + org.springframework.ai + spring-ai-mcp-spring-boot-starter + ${project.version} + + diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/ChatClient.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/ChatClient.java index fad1ff7c521..07bae3c30cc 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/ChatClient.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/ChatClient.java @@ -35,6 +35,7 @@ import org.springframework.ai.converter.StructuredOutputConverter; import org.springframework.ai.model.Media; import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.tool.ToolCallback; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.io.Resource; import org.springframework.lang.Nullable; @@ -218,6 +219,8 @@ interface ChatClientRequestSpec { ChatClientRequestSpec tools(FunctionCallback... toolCallbacks); + ChatClientRequestSpec tools(List toolCallbacks); + ChatClientRequestSpec tools(Object... toolObjects); @Deprecated @@ -283,6 +286,8 @@ interface Builder { Builder defaultTools(FunctionCallback... toolCallbacks); + Builder defaultTools(List toolCallbacks); + Builder defaultTools(Object... toolObjects); /** diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java index d6086ad9121..cb7599f0b45 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java @@ -33,6 +33,8 @@ import io.micrometer.observation.Observation; import io.micrometer.observation.ObservationRegistry; import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor; + +import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.ToolCallbacks; import reactor.core.publisher.Flux; import reactor.core.scheduler.Schedulers; @@ -853,6 +855,14 @@ public ChatClientRequestSpec tools(FunctionCallback... toolCallbacks) { return this; } + @Override + public ChatClientRequestSpec tools(List toolCallbacks) { + Assert.notNull(toolCallbacks, "toolCallbacks cannot be null"); + Assert.noNullElements(toolCallbacks, "toolCallbacks cannot contain null elements"); + this.functionCallbacks.addAll(toolCallbacks); + return this; + } + @Override public ChatClientRequestSpec tools(Object... toolObjects) { Assert.notNull(toolObjects, "toolObjects cannot be null"); diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java index 676e8f2243e..2349cd61945 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java @@ -35,6 +35,7 @@ import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.tool.ToolCallback; import org.springframework.core.io.Resource; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -160,6 +161,12 @@ public Builder defaultTools(FunctionCallback... toolCallbacks) { return this; } + @Override + public Builder defaultTools(List toolCallbacks) { + this.defaultRequest.tools(toolCallbacks); + return this; + } + @Override public Builder defaultTools(Object... toolObjects) { this.defaultRequest.tools(toolObjects); diff --git a/spring-ai-docs/src/main/asciidoc/mcp.md b/spring-ai-docs/src/main/asciidoc/mcp.md new file mode 100644 index 00000000000..5e41a616337 --- /dev/null +++ b/spring-ai-docs/src/main/asciidoc/mcp.md @@ -0,0 +1,202 @@ +# Model Context Protocol (MCP) Server + +The Spring AI MCP module provides integration with the Model Context Protocol, allowing you to expose your AI tools and resources through a standardized protocol. This module is particularly useful when you want to make your Spring AI tools and resources available to MCP-compatible clients. + +## Dependencies + +To use the MCP server functionality, add the following dependency to your project: + +```xml + + org.springframework.ai + spring-ai-mcp-spring-boot-starter + ${spring-ai.version} + +``` + +## Configuration Properties + +The MCP server can be configured using the following properties under the `spring.ai.mcp.server` prefix: + +| Property | Default | Description | +|----------|---------|-------------| +| `enabled` | `false` | Enable/disable the MCP server | +| `name` | `"mcp-server"` | Name of the MCP server | +| `version` | `"1.0.0"` | Version of the MCP server | +| `type` | `SYNC` | Server type (`SYNC` or `ASYNC`) | +| `resource-change-notification` | `true` | Enable/disable resource change notifications | +| `tool-change-notification` | `true` | Enable/disable tool change notifications | +| `prompt-change-notification` | `true` | Enable/disable prompt change notifications | +| `transport` | `STDIO` | Transport type (`STDIO`, `WEBMVC`, or `WEBFLUX`) | +| `sse-message-endpoint` | `"/mcp/message"` | Server-Sent Events (SSE) message endpoint for web transports | + +## Server Types + +The MCP server supports two operation modes: + +### 1. Synchronous Mode (Default) + +The synchronous mode is the default option, suitable for most use cases where tools and resources are accessed sequentially: + +```yaml +spring: + ai: + mcp: + server: + type: SYNC +``` + +### 2. Asynchronous Mode + +The asynchronous mode is designed for reactive applications and scenarios requiring non-blocking operations: + +```yaml +spring: + ai: + mcp: + server: + type: ASYNC +``` + +## Transport Options + +The MCP server supports three transport types: + +### 1. STDIO Transport (Default) + +The Standard Input/Output transport is the default option, suitable for command-line tools and local development: + +```yaml +spring: + ai: + mcp: + server: + transport: STDIO +``` + +### 2. WebMvc Transport + +The WebMvc transport uses Spring MVC's Server-Sent Events (SSE) for communication: + +```yaml +spring: + ai: + mcp: + server: + transport: WEBMVC + sse-message-endpoint: /mcp/message # Optional, defaults to /mcp/message +``` + +Required dependencies: +```xml + + io.modelcontextprotocol.sdk + mcp-spring-webmvc + ${mcp.version} + + + org.springframework.boot + spring-boot-starter-web + +``` + +### 3. WebFlux Transport + +The WebFlux transport uses Spring WebFlux's Server-Sent Events for reactive communication: + +```yaml +spring: + ai: + mcp: + server: + transport: WEBFLUX + sse-message-endpoint: /mcp/message # Optional, defaults to /mcp/message +``` + +Required dependencies: +```xml + + io.modelcontextprotocol.sdk + mcp-spring-webflux + ${mcp.version} + + + org.springframework.boot + spring-boot-starter-webflux + +``` + +## Core Features + +The MCP server provides several core features: + +### Tools + +- Extensible tool registration system supporting both sync and async execution +- Automatic tool discovery and registration through Spring's component scanning +- Change notification support for tool updates + +### Resources + +- Static and dynamic resource management +- Optional change notifications for resource updates +- Support for both sync and async resource access + +### Prompts + +- Configurable prompt templates +- Change notification support for template updates +- Integration with Spring AI's prompt system + +## Usage Example + +Here's an example of configuring the MCP server with WebMvc transport and custom settings: + +```yaml +spring: + ai: + mcp: + server: + enabled: true + name: "My AI Tools Server" + version: "1.0.0" + type: SYNC + transport: WEBMVC + sse-message-endpoint: /ai/mcp/events + resource-change-notification: true + tool-change-notification: true + prompt-change-notification: false +``` + +## Auto-configuration + +The MCP server auto-configuration is provided through: + +1. `MpcServerAutoConfiguration`: Core server configuration supporting both sync and async modes +2. `MpcWebMvcServerAutoConfiguration`: WebMvc transport configuration (activated when WebMvc dependencies are present) +3. `MpcWebFluxServerAutoConfiguration`: WebFlux transport configuration (activated when WebFlux dependencies are present) + +The auto-configuration will automatically set up the appropriate server type and transport based on your configuration and available dependencies. + +## Implementing Tools and Resources + +To expose your Spring AI tools and resources through the MCP server: + +1. Implement the `ToolCallback` interface for your AI tools: +```java +@Component +public class MyAiTool implements ToolCallback { + // Implementation +} +``` + +2. The auto-configuration will automatically discover and register your tools with the MCP server, converting them to either sync or async implementations based on your server type configuration. + +## Monitoring + +The MCP server provides notifications for changes in: +- Tools (when tools are added or removed) +- Resources (when resources are updated) +- Prompts (when prompt templates change) + +You can enable/disable these notifications using the configuration properties. The notification system works with both sync and async server types, providing consistent change tracking regardless of the chosen operation mode. diff --git a/spring-ai-spring-boot-autoconfigure/pom.xml b/spring-ai-spring-boot-autoconfigure/pom.xml index 1ed0b73b640..94cbf519ff7 100644 --- a/spring-ai-spring-boot-autoconfigure/pom.xml +++ b/spring-ai-spring-boot-autoconfigure/pom.xml @@ -45,6 +45,29 @@ true + + + org.springframework.ai + spring-ai-mcp + ${project.parent.version} + true + + + + io.modelcontextprotocol.sdk + mcp-spring-webflux + 0.7.0-SNAPSHOT + true + + + + io.modelcontextprotocol.sdk + mcp-spring-webmvc + 0.7.0-SNAPSHOT + true + + + org.springframework.ai spring-ai-openai diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/client/stdio/McpStdioClientProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/client/stdio/McpStdioClientProperties.java new file mode 100644 index 00000000000..ccfc0e985c3 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/client/stdio/McpStdioClientProperties.java @@ -0,0 +1,209 @@ +/* + * 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.autoconfigure.mcp.client.stdio; + +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.client.transport.ServerParameters; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.core.io.Resource; +import org.springframework.util.Assert; + +/** + * Configuration properties for the Model Context Protocol (MCP) stdio client. + *

+ * This class manages configuration settings for MCP stdio client connections, including + * server parameters, timeouts, and connection details. It supports both direct + * configuration through properties and configuration through external resource files. + * + * @author Christian Tzolov + * @since 1.0.0 + */ +@ConfigurationProperties(McpStdioClientProperties.CONFIG_PREFIX) +public class McpStdioClientProperties { + + public static final String CONFIG_PREFIX = "spring.ai.mcp.client.stdio"; + + /** + * Enable/disable the MCP client. + *

+ * When set to false, the MCP client and all its components will not be initialized. + */ + private boolean enabled = false; + + /** + * The version of the MCP client instance. + *

+ * This version is reported to clients and used for compatibility checks. + */ + private String version = "1.0.0"; + + /** + * The timeout duration for MCP client requests. + *

+ * Defaults to 20 seconds. + */ + private Duration requestTimeout = Duration.ofSeconds(20); + + /** + * Flag to enable/disable root change notifications. + *

+ * When enabled, the client will be notified of changes to the root configuration. + * Defaults to true. + */ + private boolean rootChangeNotification = true; + + /** + * Resource containing the MCP servers configuration. + *

+ * This resource should contain a JSON configuration defining the MCP servers and + * their parameters. + */ + private Resource serversConfiguration; + + /** + * Map of MCP stdio connections configurations. + *

+ * Each entry represents a named connection with its specific configuration + * parameters. + */ + private Map stdioConnections = new HashMap<>(); + + /** + * Flag to indicate if the MCP client has to be initialized. + */ + private boolean initialize = true; + + public Resource getServersConfiguration() { + return this.serversConfiguration; + } + + public void setServersConfiguration(Resource stdioConnectionResources) { + this.serversConfiguration = stdioConnectionResources; + } + + public Map getStdioConnections() { + return this.stdioConnections; + } + + public boolean isRootChangeNotification() { + return this.rootChangeNotification; + } + + public void setRootChangeNotification(boolean rootChangeNotification) { + this.rootChangeNotification = rootChangeNotification; + } + + public Duration getRequestTimeout() { + return this.requestTimeout; + } + + public void setRequestTimeout(Duration requestTimeout) { + Assert.notNull(requestTimeout, "Request timeout must not be null"); + this.requestTimeout = requestTimeout; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getVersion() { + return this.version; + } + + public void setVersion(String version) { + Assert.hasText(version, "Version must not be empty"); + this.version = version; + } + + public boolean isInitialize() { + return this.initialize; + } + + public void setInitialize(boolean initialize) { + this.initialize = initialize; + } + + /** + * Record representing the parameters for an MCP server connection. + *

+ * Includes the command to execute, command arguments, and environment variables. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public static record Parameters( + /** + * The command to execute for the MCP server. + */ + @JsonProperty("command") String command, + /** + * List of command arguments. + */ + @JsonProperty("args") List args, + /** + * Map of environment variables for the server process. + */ + @JsonProperty("env") Map env) { + } + + private Map resourceToServerParameters() { + try { + Map> stdioConnection = new ObjectMapper().readValue( + this.serversConfiguration.getInputStream(), + new TypeReference>>() { + }); + + Map mcpServerJsonConfig = stdioConnection.entrySet().iterator().next().getValue(); + + return mcpServerJsonConfig.entrySet().stream().collect(Collectors.toMap(kv -> kv.getKey(), kv -> { + Parameters parameters = kv.getValue(); + return ServerParameters.builder(parameters.command()) + .args(parameters.args()) + .env(parameters.env()) + .build(); + })); + } + catch (Exception e) { + throw new RuntimeException("Failed to read stdio connection resource", e); + } + } + + public Map toServerParameters() { + Map serverParameters = new HashMap<>(); + if (this.serversConfiguration != null) { + serverParameters.putAll(resourceToServerParameters()); + } + + for (Map.Entry entry : this.stdioConnections.entrySet()) { + serverParameters.put(entry.getKey(), entry.getValue().toServerParameters()); + } + return serverParameters; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/client/stdio/McpStdioConnection.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/client/stdio/McpStdioConnection.java new file mode 100644 index 00000000000..77920d90f61 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/client/stdio/McpStdioConnection.java @@ -0,0 +1,61 @@ +/* + * 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.autoconfigure.mcp.client.stdio; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.client.transport.ServerParameters; + +public class McpStdioConnection { + + private String command; + + private List args = new ArrayList<>(); + + private Map env; + + public String getCommand() { + return command; + } + + public void setCommand(String command) { + this.command = command; + } + + public List getArgs() { + return args; + } + + public void setArgs(List args) { + this.args = args; + } + + public Map getEnv() { + return env; + } + + public void setEnv(Map env) { + this.env = env; + } + + public ServerParameters toServerParameters() { + return ServerParameters.builder(this.command).args(this.args).env(this.env).build(); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/client/stdio/McpSyncClientConfigurer.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/client/stdio/McpSyncClientConfigurer.java new file mode 100644 index 00000000000..159a38057b0 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/client/stdio/McpSyncClientConfigurer.java @@ -0,0 +1,46 @@ +/* + * 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.autoconfigure.mcp.client.stdio; + +import java.util.List; + +import io.modelcontextprotocol.client.McpClient; + +import org.springframework.ai.mcp.McpSyncClientCustomizer; + +public class McpSyncClientConfigurer { + + private List customizers; + + void setCustomizers(List customizers) { + this.customizers = customizers; + } + + public McpClient.SyncSpec configure(String name, McpClient.SyncSpec spec) { + applyCustomizers(name, spec); + return spec; + } + + private void applyCustomizers(String name, McpClient.SyncSpec spec) { + if (this.customizers != null) { + for (McpSyncClientCustomizer customizer : this.customizers) { + customizer.customize(name, spec); + } + } + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/client/stdio/MpcStdioClientAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/client/stdio/MpcStdioClientAutoConfiguration.java new file mode 100644 index 00000000000..b5d81b4321a --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/client/stdio/MpcStdioClientAutoConfiguration.java @@ -0,0 +1,135 @@ +/* + * 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.autoconfigure.mcp.client.stdio; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.ServerParameters; +import io.modelcontextprotocol.client.transport.StdioClientTransport; +import io.modelcontextprotocol.spec.McpSchema; + +import org.springframework.ai.mcp.McpSyncClientCustomizer; +import org.springframework.ai.mcp.McpToolCallback; +import org.springframework.ai.mcp.McpToolUtils; +import org.springframework.ai.tool.ToolCallback; +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; + +/** + * Auto-configuration for Model Context Protocol (MCP) STDIO clients. + * + *

+ * This configuration is responsible for setting up MCP clients that communicate with MCP + * servers through standard input/output (STDIO). It creates and configures + * {@link McpSyncClient} instances based on the provided configuration properties. + * + *

+ * The configuration is conditionally enabled when: + *

    + *
  • Required classes ({@link McpSchema} and {@link McpSyncClient}) are present on the + * classpath
  • + *
  • The 'spring.ai.mcp.client.stdio.enabled' property is set to 'true'
  • + *
+ * + *

+ * This auto-configuration provides: + *

    + *
  • A list of {@link McpSyncClient} instances configured for STDIO communication
  • + *
  • An {@link McpToolAdapter} that manages tool callbacks for the configured + * clients
  • + *
+ * + * @author Christian Tzolov + * @since 1.0.0 + * @see McpStdioClientProperties + * @see McpSyncClient + * @see McpToolCallback + */ +@AutoConfiguration +@ConditionalOnClass({ McpSchema.class, McpSyncClient.class }) +@EnableConfigurationProperties(McpStdioClientProperties.class) +@ConditionalOnProperty(prefix = McpStdioClientProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true") +public class MpcStdioClientAutoConfiguration { + + @Bean + public List mcpSyncClients(McpSyncClientConfigurer mcpSyncClientConfigurer, + McpStdioClientProperties clientProperties) { + + List clients = new ArrayList<>(); + + for (Map.Entry serverParameters : clientProperties.toServerParameters().entrySet()) { + + var transport = new StdioClientTransport(serverParameters.getValue()); + + McpSchema.Implementation clientInfo = new McpSchema.Implementation(serverParameters.getKey(), + clientProperties.getVersion()); + + McpClient.SyncSpec syncSpec = McpClient.sync(transport) + .clientInfo(clientInfo) + .requestTimeout(clientProperties.getRequestTimeout()); + + syncSpec = mcpSyncClientConfigurer.configure(serverParameters.getKey(), syncSpec); + + var syncClient = syncSpec.build(); + + if (clientProperties.isInitialize()) { + syncClient.initialize(); + } + + clients.add(syncClient); + + } + + return clients; + } + + @Bean + public List toolCallbacks(List mcpClients) { + return McpToolUtils.getToolCallbacks(mcpClients); + } + + public record ClosebleMcpSyncClients(List clients) implements AutoCloseable { + + @Override + public void close() { + clients.forEach(McpSyncClient::close); + } + } + + @Bean + public ClosebleMcpSyncClients makeThemClosable(List clients) { + return new ClosebleMcpSyncClients(clients); + } + + @Bean + @ConditionalOnMissingBean + McpSyncClientConfigurer mcpSyncClientConfigurer(ObjectProvider customizerProvider) { + McpSyncClientConfigurer configurer = new McpSyncClientConfigurer(); + configurer.setCustomizers(customizerProvider.orderedStream().toList()); + return configurer; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/server/McpServerProperties.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/server/McpServerProperties.java new file mode 100644 index 00000000000..5be80b131c6 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/server/McpServerProperties.java @@ -0,0 +1,240 @@ +/* + * 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.autoconfigure.mcp.server; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.Assert; + +/** + * Configuration properties for the Model Context Protocol (MCP) server. + *

+ * These properties control the behavior and configuration of the MCP server, including: + *

    + *
  • Server identification (name and version)
  • + *
  • Transport type (STDIO, WEBMVC, or WEBFLUX)
  • + *
  • Change notification settings for tools, resources, and prompts
  • + *
  • Web transport endpoint configuration
  • + *
+ *

+ * All properties are prefixed with {@code spring.ai.mcp.server}. + * + * @author Christian Tzolov + * @since 1.0.0 + * @see org.springframework.ai.autoconfigure.mcp.server.MpcServerAutoConfiguration + */ +@ConfigurationProperties(McpServerProperties.CONFIG_PREFIX) +public class McpServerProperties { + + public static final String CONFIG_PREFIX = "spring.ai.mcp.server"; + + /** + * Enable/disable the MCP server. + *

+ * When set to false, the MCP server and all its components will not be initialized. + */ + private boolean enabled = false; + + /** + * 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. + *

+ * This version is reported to clients and used for compatibility checks. + */ + private String version = "1.0.0"; + + /** + * 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; + + /** + * The transport type to use for MCP server communication. + *

+ * Supported types are: + *

    + *
  • STDIO - Standard input/output transport (default)
  • + *
  • WEBMVC - Spring MVC Server-Sent Events transport
  • + *
  • WEBFLUX - Spring WebFlux Server-Sent Events transport
  • + *
+ */ + private Transport transport = Transport.STDIO; + + /** + * The endpoint path for Server-Sent Events (SSE) when using web transports. + *

+ * This property is only used when transport is set to WEBMVC or WEBFLUX. + */ + private String sseMessageEndpoint = "/mcp/message"; + + /** + * The type of server to use for MCP server communication. + *

+ * Supported types are: + *

    + *
  • SYNC - Standard synchronous server (default)
  • + *
  • ASYNC - Asynchronous server
  • + *
+ */ + private ServerType type = ServerType.SYNC; + + /** + * Transport types supported by the MCP server. + */ + public enum Transport { + + /** + * Standard input/output transport, suitable for command-line tools and local + * development. + */ + STDIO, + + /** + * Spring MVC Server-Sent Events transport, requires spring-boot-starter-web and + * mcp-spring-webmvc. + */ + WEBMVC, + + /** + * Spring WebFlux Server-Sent Events transport, requires + * spring-boot-starter-webflux and mcp-spring-webflux. + */ + WEBFLUX + + } + + /** + * Server types supported by the MCP server. + */ + public enum ServerType { + + /** + * Synchronous (McpSyncServer) server + */ + SYNC, + + /** + * Asynchronous (McpAsyncServer) server + */ + ASYNC + + } + + 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 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 Transport getTransport() { + return this.transport; + } + + public void setTransport(Transport transport) { + Assert.notNull(transport, "Transport must not be null"); + this.transport = transport; + } + + public String getSseMessageEndpoint() { + return this.sseMessageEndpoint; + } + + public void setSseMessageEndpoint(String sseMessageEndpoint) { + Assert.hasText(sseMessageEndpoint, "SSE message endpoint must not be empty"); + this.sseMessageEndpoint = sseMessageEndpoint; + } + + public ServerType getType() { + return this.type; + } + + public void setType(ServerType serverType) { + Assert.notNull(serverType, "Server type must not be null"); + this.type = serverType; + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/server/MpcServerAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/server/MpcServerAutoConfiguration.java new file mode 100644 index 00000000000..1293f3d97a8 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/server/MpcServerAutoConfiguration.java @@ -0,0 +1,229 @@ +/* + * 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.autoconfigure.mcp.server; + +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; + +import io.modelcontextprotocol.server.McpServer; +import reactor.core.publisher.Mono; +import io.modelcontextprotocol.server.McpServer.SyncSpec; +import io.modelcontextprotocol.server.McpServer.AsyncSpec; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpServerFeatures.SyncToolRegistration; +import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolRegistration; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.McpAsyncServer; +import io.modelcontextprotocol.server.transport.StdioServerTransport; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.Implementation; +import io.modelcontextprotocol.spec.ServerMcpTransport; + +import org.springframework.ai.mcp.McpToolUtils; +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.log.LogAccessor; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for the Model Context Protocol (MCP) + * Server. + *

+ * This configuration class sets up the core MCP server components with support for both + * synchronous and asynchronous operation modes. The server type is controlled through the + * {@code spring.ai.mcp.server.type} property, defaulting to SYNC mode. + *

+ * Core features and capabilities include: + *

    + *
  • Tools: Extensible tool registration system supporting both sync and async + * execution
  • + *
  • Resources: Static and dynamic resource management with optional change + * notifications
  • + *
  • Prompts: Configurable prompt templates with change notification support
  • + *
  • Transport: Flexible transport layer with built-in support for: + *
      + *
    • STDIO (default): Standard input/output based communication
    • + *
    • WebMvc: HTTP-based transport when Spring MVC is available
    • + *
    • WebFlux: Reactive transport when Spring WebFlux is available
    • + *
    + *
  • + *
+ *

+ * The configuration is activated when: + *

    + *
  • The required MCP classes ({@link McpSchema} and {@link McpSyncServer}) are on the + * classpath
  • + *
  • The {@code spring.ai.mcp.server.enabled} property is true (default)
  • + *
+ *

+ * Server configuration is managed through {@link McpServerProperties} with support for: + *

    + *
  • Server identification (name, version)
  • + *
  • Transport selection
  • + *
  • Change notification settings for tools, resources, and prompts
  • + *
  • Sync/Async operation mode selection
  • + *
+ *

+ * WebMvc transport support is provided separately by + * {@link MpcWebMvcServerAutoConfiguration}. + * + * @author Christian Tzolov + * @since 1.0.0 + * @see McpServerProperties + * @see MpcWebMvcServerAutoConfiguration + * @see org.springframework.ai.mcp.ToolCallback + */ +@AutoConfiguration +@ConditionalOnClass({ McpSchema.class, McpSyncServer.class }) +@EnableConfigurationProperties(McpServerProperties.class) +@ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true") +public class MpcServerAutoConfiguration { + + private static final LogAccessor logger = new LogAccessor(MpcServerAutoConfiguration.class); + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "transport", havingValue = "STDIO", + matchIfMissing = true) + public ServerMcpTransport stdioServerTransport() { + return new StdioServerTransport(); + } + + @Bean + @ConditionalOnMissingBean + public McpSchema.ServerCapabilities.Builder capabilitiesBuilder() { + return McpSchema.ServerCapabilities.builder(); + } + + @Bean + @ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "type", havingValue = "SYNC", + matchIfMissing = true) + public List syncTools(List toolCalls) { + return McpToolUtils.toSyncToolRegistration(toolCalls); + } + + @Bean + @ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "type", havingValue = "SYNC", + matchIfMissing = true) + public McpSyncServer mcpSyncServer(ServerMcpTransport transport, + McpSchema.ServerCapabilities.Builder capabilitiesBuilder, McpServerProperties serverProperties, + ObjectProvider> tools, + ObjectProvider> resources, + ObjectProvider> prompts, + ObjectProvider>> rootsChangeConsumers) { + + McpSchema.Implementation serverInfo = new Implementation(serverProperties.getName(), + serverProperties.getVersion()); + + // Create the server with both tool and resource capabilities + SyncSpec serverBuilder = McpServer.sync(transport).serverInfo(serverInfo); + + tools.ifAvailable(toolsList -> { + serverBuilder.tools(toolsList); + capabilitiesBuilder.tools(serverProperties.isToolChangeNotification()); + logger.info("Registered tools" + toolsList.size() + " notification: " + + serverProperties.isToolChangeNotification()); + }); + + resources.ifAvailable(resourceList -> { + serverBuilder.resources(resourceList); + capabilitiesBuilder.resources(false, serverProperties.isResourceChangeNotification()); + logger.info("Registered resources" + resourceList.size() + " notification: " + + serverProperties.isResourceChangeNotification()); + }); + + prompts.ifAvailable(promptList -> { + serverBuilder.prompts(promptList); + capabilitiesBuilder.prompts(serverProperties.isPromptChangeNotification()); + logger.info("Registered prompts" + promptList.size() + " notification: " + + serverProperties.isPromptChangeNotification()); + }); + + rootsChangeConsumers.ifAvailable(consumer -> { + serverBuilder.rootsChangeConsumer(consumer); + logger.info("Registered roots change consumer"); + }); + + serverBuilder.capabilities(capabilitiesBuilder.build()); + + return serverBuilder.build(); + } + + @Bean + @ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "type", havingValue = "ASYNC") + public List asyncTools(List toolCalls) { + return McpToolUtils.toAsyncToolRegistration(toolCalls); + } + + @Bean + @ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "type", havingValue = "ASYNC") + public McpAsyncServer mcpAsyncServer(ServerMcpTransport transport, + McpSchema.ServerCapabilities.Builder capabilitiesBuilder, McpServerProperties serverProperties, + ObjectProvider> tools, + ObjectProvider> resources, + ObjectProvider> prompts, + ObjectProvider>> rootsChangeConsumer) { + + McpSchema.Implementation serverInfo = new Implementation(serverProperties.getName(), + serverProperties.getVersion()); + + // Create the server with both tool and resource capabilities + AsyncSpec serverBilder = McpServer.async(transport).serverInfo(serverInfo); + + tools.ifAvailable(toolsList -> { + serverBilder.tools(toolsList); + capabilitiesBuilder.tools(serverProperties.isToolChangeNotification()); + logger.info("Registered tools" + toolsList.size() + " notification: " + + serverProperties.isToolChangeNotification()); + }); + + resources.ifAvailable(resourceList -> { + serverBilder.resources(resourceList); + capabilitiesBuilder.resources(false, serverProperties.isResourceChangeNotification()); + logger.info("Registered resources" + resourceList.size() + " notification: " + + serverProperties.isResourceChangeNotification()); + }); + + prompts.ifAvailable(promptList -> { + serverBilder.prompts(promptList); + capabilitiesBuilder.prompts(serverProperties.isPromptChangeNotification()); + logger.info("Registered prompts" + promptList.size() + " notification: " + + serverProperties.isPromptChangeNotification()); + }); + + rootsChangeConsumer.ifAvailable(consumer -> { + Function, Mono> asyncConsumer = roots -> { + consumer.accept(roots); + return Mono.empty(); + }; + serverBilder.rootsChangeConsumer(asyncConsumer); + logger.info("Registered roots change consumer"); + }); + + serverBilder.capabilities(capabilitiesBuilder.build()); + + return serverBilder.build(); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/server/MpcWebFluxServerAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/server/MpcWebFluxServerAutoConfiguration.java new file mode 100644 index 00000000000..a1ce31bca5b --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/server/MpcWebFluxServerAutoConfiguration.java @@ -0,0 +1,82 @@ +/* + * 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.autoconfigure.mcp.server; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.server.transport.WebFluxSseServerTransport; + +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.context.annotation.Bean; +import org.springframework.web.reactive.function.server.RouterFunction; + +/** + * {@link AutoConfiguration Auto-configuration} for MCP WebFlux Server Transport. + *

+ * This configuration class sets up the WebFlux-specific transport components for the MCP + * server, providing reactive Server-Sent Events (SSE) communication through Spring + * WebFlux. It is activated when: + *

    + *
  • The WebFluxSseServerTransport class is on the classpath (from mcp-spring-webflux + * dependency)
  • + *
  • Spring WebFlux's RouterFunction class is available (from + * spring-boot-starter-webflux)
  • + *
  • The {@code spring.ai.mcp.server.transport} property is set to {@code WEBFLUX}
  • + *
+ *

+ * The configuration provides: + *

    + *
  • A WebFluxSseServerTransport bean for handling reactive SSE communication
  • + *
  • A RouterFunction bean that sets up the reactive SSE endpoint
  • + *
+ *

+ * Required dependencies:

{@code
+ * 
+ *     io.modelcontextprotocol.sdk
+ *     mcp-spring-webflux
+ * 
+ * 
+ *     org.springframework.boot
+ *     spring-boot-starter-webflux
+ * 
+ * }
+ * + * @author Christian Tzolov + * @since 1.0.0 + * @see McpServerProperties + * @see WebFluxSseServerTransport + */ +@AutoConfiguration +@ConditionalOnClass({ WebFluxSseServerTransport.class }) +@ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "transport", havingValue = "WEBFLUX") +public class MpcWebFluxServerAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public WebFluxSseServerTransport webFluxTransport(McpServerProperties serverProperties) { + return new WebFluxSseServerTransport(new ObjectMapper(), serverProperties.getSseMessageEndpoint()); + } + + // Router function for SSE transport used by Spring WebFlux to start an HTTP server. + @Bean + public RouterFunction webfluxMcpRouterFunction(WebFluxSseServerTransport webFluxTransport) { + return webFluxTransport.getRouterFunction(); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/server/MpcWebMvcServerAutoConfiguration.java b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/server/MpcWebMvcServerAutoConfiguration.java new file mode 100644 index 00000000000..5759cc4f68c --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/main/java/org/springframework/ai/autoconfigure/mcp/server/MpcWebMvcServerAutoConfiguration.java @@ -0,0 +1,82 @@ +/* + * 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.autoconfigure.mcp.server; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.server.transport.WebMvcSseServerTransport; + +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.context.annotation.Bean; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.ServerResponse; + +/** + * {@link AutoConfiguration Auto-configuration} for MCP WebMvc Server Transport. + *

+ * This configuration class sets up the WebMvc-specific transport components for the MCP + * server, providing Server-Sent Events (SSE) communication through Spring MVC. It is + * activated when: + *

    + *
  • The WebMvcSseServerTransport class is on the classpath (from mcp-spring-webmvc + * dependency)
  • + *
  • Spring MVC's RouterFunction class is available (from spring-boot-starter-web)
  • + *
  • The {@code spring.ai.mcp.server.transport} property is set to {@code WEBMVC}
  • + *
+ *

+ * The configuration provides: + *

    + *
  • A WebMvcSseServerTransport bean for handling SSE communication
  • + *
  • A RouterFunction bean that sets up the SSE endpoint
  • + *
+ *

+ * Required dependencies:

{@code
+ * 
+ *     io.modelcontextprotocol.sdk
+ *     mcp-spring-webmvc
+ * 
+ * 
+ *     org.springframework.boot
+ *     spring-boot-starter-web
+ * 
+ * }
+ * + * @author Christian Tzolov + * @since 1.0.0 + * @see McpServerProperties + * @see WebMvcSseServerTransport + */ +@AutoConfiguration +@ConditionalOnClass({ WebMvcSseServerTransport.class }) +@ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "transport", havingValue = "WEBMVC") +public class MpcWebMvcServerAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public WebMvcSseServerTransport webMvcSseServerTransport(ObjectMapper objectMapper, + McpServerProperties serverProperties) { + return new WebMvcSseServerTransport(objectMapper, serverProperties.getSseMessageEndpoint()); + } + + @Bean + public RouterFunction mvcMcpRouterFunction(WebMvcSseServerTransport transport) { + return transport.getRouterFunction(); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 022b4a8b5e7..aa9a96d02b7 100644 --- a/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-ai-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -60,3 +60,8 @@ org.springframework.ai.autoconfigure.minimax.MiniMaxAutoConfiguration org.springframework.ai.autoconfigure.vertexai.embedding.VertexAiEmbeddingAutoConfiguration org.springframework.ai.autoconfigure.chat.memory.cassandra.CassandraChatMemoryAutoConfiguration org.springframework.ai.autoconfigure.vectorstore.observation.VectorStoreObservationAutoConfiguration +org.springframework.ai.autoconfigure.mcp.server.MpcServerAutoConfiguration +org.springframework.ai.autoconfigure.mcp.server.MpcWebMvcServerAutoConfiguration +org.springframework.ai.autoconfigure.mcp.server.MpcWebFluxServerAutoConfiguration +org.springframework.ai.autoconfigure.mcp.client.stdio.MpcStdioClientAutoConfiguration + diff --git a/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mcp/server/McpServerAutoConfigurationIT.java b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mcp/server/McpServerAutoConfigurationIT.java new file mode 100644 index 00000000000..c2580bb82c5 --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/java/org/springframework/ai/autoconfigure/mcp/server/McpServerAutoConfigurationIT.java @@ -0,0 +1,93 @@ +/* + * 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.autoconfigure.mcp.server; + +import io.modelcontextprotocol.server.McpAsyncServer; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.transport.StdioServerTransport; +import io.modelcontextprotocol.spec.ServerMcpTransport; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +public class McpServerAutoConfigurationIT { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.ai.mcp.server.enabled=true") + .withConfiguration(AutoConfigurations.of(MpcServerAutoConfiguration.class)); + + @Test + void defaultConfiguration() { + this.contextRunner.run(context -> { + assertThat(context).hasSingleBean(McpSyncServer.class); + assertThat(context).hasSingleBean(ServerMcpTransport.class); + assertThat(context.getBean(ServerMcpTransport.class)).isInstanceOf(StdioServerTransport.class); + + McpServerProperties properties = context.getBean(McpServerProperties.class); + assertThat(properties.getName()).isEqualTo("mcp-server"); + assertThat(properties.getVersion()).isEqualTo("1.0.0"); + assertThat(properties.getTransport()).isEqualTo(McpServerProperties.Transport.STDIO); + assertThat(properties.getType()).isEqualTo(McpServerProperties.ServerType.SYNC); + assertThat(properties.isToolChangeNotification()).isTrue(); + assertThat(properties.isResourceChangeNotification()).isTrue(); + assertThat(properties.isPromptChangeNotification()).isTrue(); + }); + } + + @Test + void asyncConfiguration() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.type=ASYNC", "spring.ai.mcp.server.name=test-server", + "spring.ai.mcp.server.version=2.0.0") + .run(context -> { + assertThat(context).hasSingleBean(McpAsyncServer.class); + assertThat(context).doesNotHaveBean(McpSyncServer.class); + + McpServerProperties properties = context.getBean(McpServerProperties.class); + assertThat(properties.getName()).isEqualTo("test-server"); + assertThat(properties.getVersion()).isEqualTo("2.0.0"); + assertThat(properties.getType()).isEqualTo(McpServerProperties.ServerType.ASYNC); + }); + } + + @Test + void disabledConfiguration() { + this.contextRunner.withPropertyValues("spring.ai.mcp.server.enabled=false").run(context -> { + assertThat(context).doesNotHaveBean(McpSyncServer.class); + assertThat(context).doesNotHaveBean(McpAsyncServer.class); + assertThat(context).doesNotHaveBean(ServerMcpTransport.class); + }); + } + + @Test + void notificationConfiguration() { + this.contextRunner + .withPropertyValues("spring.ai.mcp.server.tool-change-notification=false", + "spring.ai.mcp.server.resource-change-notification=false", + "spring.ai.mcp.server.prompt-change-notification=false") + .run(context -> { + McpServerProperties properties = context.getBean(McpServerProperties.class); + assertThat(properties.isToolChangeNotification()).isFalse(); + assertThat(properties.isResourceChangeNotification()).isFalse(); + assertThat(properties.isPromptChangeNotification()).isFalse(); + }); + } + +} diff --git a/spring-ai-spring-boot-autoconfigure/src/test/resources/application-test.properties b/spring-ai-spring-boot-autoconfigure/src/test/resources/application-test.properties new file mode 100644 index 00000000000..9107b9e407a --- /dev/null +++ b/spring-ai-spring-boot-autoconfigure/src/test/resources/application-test.properties @@ -0,0 +1,10 @@ +# Test MCP STDIO client configuration +spring.ai.mcp.client.stdio.enabled=true +spring.ai.mcp.client.stdio.version=test-version +spring.ai.mcp.client.stdio.request-timeout=15s +spring.ai.mcp.client.stdio.root-change-notification=false + +# Test server configuration +spring.ai.mcp.client.stdio.stdio-connections.test-server.command=echo +spring.ai.mcp.client.stdio.stdio-connections.test-server.args[0]=test +spring.ai.mcp.client.stdio.stdio-connections.test-server.env.TEST_ENV=test-value diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-mcp/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-mcp/pom.xml new file mode 100644 index 00000000000..e7e54a69b22 --- /dev/null +++ b/spring-ai-spring-boot-starters/spring-ai-starter-mcp/pom.xml @@ -0,0 +1,58 @@ + + + + + 4.0.0 + + org.springframework.ai + spring-ai + 1.0.0-SNAPSHOT + ../../pom.xml + + spring-ai-mcp-spring-boot-starter + jar + Spring AI Starter - MCP + Spring AI MCP 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-spring-boot-autoconfigure + ${project.parent.version} + + + + org.springframework.ai + spring-ai-mcp + ${project.parent.version} + + + + From bae5bbbae086a93302a7207e0c367b77f83dc7be Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Wed, 5 Feb 2025 19:31:51 +0100 Subject: [PATCH 2/2] Add MCP AOT hints Signed-off-by: Christian Tzolov --- .../org/springframework/ai/mcp/McpHints.java | 63 +++++++++++++++++++ .../resources/META-INF/spring/aot.factories | 2 + 2 files changed, 65 insertions(+) create mode 100644 mcp/common/src/main/java/org/springframework/ai/mcp/McpHints.java create mode 100644 mcp/common/src/main/resources/META-INF/spring/aot.factories diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/McpHints.java b/mcp/common/src/main/java/org/springframework/ai/mcp/McpHints.java new file mode 100644 index 00000000000..3239c0a62dd --- /dev/null +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/McpHints.java @@ -0,0 +1,63 @@ +/* +* Copyright 2025 - 2025 the original author or authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +package org.springframework.ai.mcp; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; + +import io.modelcontextprotocol.spec.McpSchema; + +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.aot.hint.TypeReference; + +/** + * @author Josh Long + * @since 1.0.0 + */ +@SuppressWarnings("unused") +public class McpHints implements RuntimeHintsRegistrar { + + @Override + public void registerHints(RuntimeHints hints, ClassLoader classLoader) { + var mcs = MemberCategory.values(); + + for (var tr : innerClasses(McpSchema.class)) { + hints.reflection().registerType(tr, mcs); + } + } + + private Set innerClasses(Class clzz) { + var indent = new HashSet(); + this.findNestedClasses(clzz, indent); + return indent.stream().map(TypeReference::of).collect(Collectors.toSet()); + } + + private void findNestedClasses(Class clazz, Set indent) { + var classes = new ArrayList>(); + classes.addAll(Arrays.asList(clazz.getDeclaredClasses())); + classes.addAll(Arrays.asList(clazz.getClasses())); + for (var nestedClass : classes) { + this.findNestedClasses(nestedClass, indent); + } + indent.addAll(classes.stream().map(Class::getName).toList()); + } + +} diff --git a/mcp/common/src/main/resources/META-INF/spring/aot.factories b/mcp/common/src/main/resources/META-INF/spring/aot.factories new file mode 100644 index 00000000000..dbf904981fb --- /dev/null +++ b/mcp/common/src/main/resources/META-INF/spring/aot.factories @@ -0,0 +1,2 @@ +org.springframework.aot.hint.RuntimeHintsRegistrar=\ + org.springframework.ai.mcp.McpHints \ No newline at end of file