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/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/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: + *

    + *
  • Converts MCP tool definitions to Spring AI tool definitions
  • + *
  • Handles the execution of tool calls through the MCP client
  • + *
  • Manages JSON serialization/deserialization of tool inputs and outputs
  • + *
+ *

+ * 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: + *

    + *
  • The tool's name from the MCP definition
  • + *
  • The tool's description from the MCP definition
  • + *
  • The input schema converted to JSON format
  • + *
+ * @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/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 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} + + + +