diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallback.java b/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallback.java index 3b326a7eb9..136887f1b0 100644 --- a/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallback.java +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallback.java @@ -123,7 +123,9 @@ public String call(String toolCallInput, @Nullable ToolContext toolContext) { CallToolResult response; try { - var mcpMeta = toolContext != null ? this.toolContextToMcpMetaConverter.convert(toolContext) : null; + var mcpMeta = toolContext != null + ? this.toolContextToMcpMetaConverter.convert(toolContext, McpConnectionInfo.from(this.mcpClient)) + : null; // Use the original tool name, not the prefixed one from getToolDefinition var request = CallToolRequest.builder(this.tool.name()).arguments(arguments).meta(mcpMeta).build(); diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallbackProvider.java b/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallbackProvider.java index 1707d82fe2..5a5aa11e77 100644 --- a/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallbackProvider.java +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallbackProvider.java @@ -185,11 +185,7 @@ public void onApplicationEvent(McpToolsChangedEvent event) { } private static McpConnectionInfo connectionInfo(McpAsyncClient mcpClient) { - return McpConnectionInfo.builder() - .clientCapabilities(mcpClient.getClientCapabilities()) - .clientInfo(mcpClient.getClientInfo()) - .initializeResult(mcpClient.getCurrentInitializationResult()) - .build(); + return McpConnectionInfo.from(mcpClient); } /** diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/McpConnectionInfo.java b/mcp/common/src/main/java/org/springframework/ai/mcp/McpConnectionInfo.java index 138369c7f3..c9de699ac3 100644 --- a/mcp/common/src/main/java/org/springframework/ai/mcp/McpConnectionInfo.java +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/McpConnectionInfo.java @@ -16,6 +16,8 @@ package org.springframework.ai.mcp; +import io.modelcontextprotocol.client.McpAsyncClient; +import io.modelcontextprotocol.client.McpSyncClient; import io.modelcontextprotocol.spec.McpSchema; import org.jspecify.annotations.Nullable; @@ -29,6 +31,7 @@ * @param initializeResult the MCP server initialization result * @author Ilayaperumal Gopinathan * @author Christian Tzolov + * @author Dimitar Proynov */ public record McpConnectionInfo(// @formatter:off McpSchema.ClientCapabilities clientCapabilities, @@ -43,6 +46,30 @@ public static Builder builder() { return new Builder(); } + /** + * Creates the connection info describing the given synchronous MCP client. + * @param mcpClient the MCP client to describe + * @return the connection info for {@code mcpClient} + */ + public static McpConnectionInfo from(McpSyncClient mcpClient) { + return builder().clientCapabilities(mcpClient.getClientCapabilities()) + .clientInfo(mcpClient.getClientInfo()) + .initializeResult(mcpClient.getCurrentInitializationResult()) + .build(); + } + + /** + * Creates the connection info describing the given asynchronous MCP client. + * @param mcpClient the MCP client to describe + * @return the connection info for {@code mcpClient} + */ + public static McpConnectionInfo from(McpAsyncClient mcpClient) { + return builder().clientCapabilities(mcpClient.getClientCapabilities()) + .clientInfo(mcpClient.getClientInfo()) + .initializeResult(mcpClient.getCurrentInitializationResult()) + .build(); + } + /** * Builder class for constructing McpConnectionInfo instances. */ diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallback.java b/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallback.java index 191eee99f5..85da8c932f 100644 --- a/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallback.java +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallback.java @@ -124,7 +124,9 @@ public String call(String toolCallInput, @Nullable ToolContext toolContext) { CallToolResult response; try { - var mcpMeta = toolContext != null ? this.toolContextToMcpMetaConverter.convert(toolContext) : null; + var mcpMeta = toolContext != null + ? this.toolContextToMcpMetaConverter.convert(toolContext, McpConnectionInfo.from(this.mcpClient)) + : null; // Use the original tool name, not the prefixed one from getToolDefinition var request = CallToolRequest.builder(this.tool.name()).arguments(arguments).meta(mcpMeta).build(); 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 index 047fda4dc0..d2b9b6b884 100644 --- a/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallbackProvider.java +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallbackProvider.java @@ -167,11 +167,7 @@ public void onApplicationEvent(McpToolsChangedEvent event) { } private static McpConnectionInfo connectionInfo(McpSyncClient mcpClient) { - return McpConnectionInfo.builder() - .clientCapabilities(mcpClient.getClientCapabilities()) - .clientInfo(mcpClient.getClientInfo()) - .initializeResult(mcpClient.getCurrentInitializationResult()) - .build(); + return McpConnectionInfo.from(mcpClient); } /** diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/ToolContextToMcpMetaConverter.java b/mcp/common/src/main/java/org/springframework/ai/mcp/ToolContextToMcpMetaConverter.java index 296e7ecc46..a41b8c1904 100644 --- a/mcp/common/src/main/java/org/springframework/ai/mcp/ToolContextToMcpMetaConverter.java +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/ToolContextToMcpMetaConverter.java @@ -28,6 +28,7 @@ * * @author Christian Tzolov * @author YunKui Lu + * @author Dimitar Proynov */ public interface ToolContextToMcpMetaConverter { @@ -43,6 +44,30 @@ public interface ToolContextToMcpMetaConverter { */ Map convert(ToolContext toolContext); + /** + * Convert the given {@link ToolContext} to MCP tool call metadata for a call bound to + * a specific MCP server. + *

+ * The same {@link ToolContext} is shared across every registered MCP server, so a + * converter that forwards sensitive entries (auth tokens, user identifiers, etc.) + * without regard to {@code connectionInfo} sends them to every server the application + * talks to, not just the ones that need them. Override this method instead of + * {@link #convert(ToolContext)} to scope what gets forwarded based on the destination + * server, mirroring how {@link McpToolFilter} scopes tool discovery per + * {@link McpConnectionInfo}. + *

+ * The default implementation ignores {@code connectionInfo} and delegates to + * {@link #convert(ToolContext)}, preserving existing behavior for converters that + * don't need per-server scoping. + * @param toolContext the tool context to convert + * @param connectionInfo the MCP server the resulting metadata will be sent to + * @return a map of metadata to be sent as part of the MCP tool call + * @since 2.0.2 + */ + default Map convert(ToolContext toolContext, McpConnectionInfo connectionInfo) { + return convert(toolContext); + } + static ToolContextToMcpMetaConverter defaultConverter() { return toolContext -> { diff --git a/mcp/common/src/test/java/org/springframework/ai/mcp/AsyncMcpToolCallbackTest.java b/mcp/common/src/test/java/org/springframework/ai/mcp/AsyncMcpToolCallbackTest.java index be1f842242..def01ec5ab 100644 --- a/mcp/common/src/test/java/org/springframework/ai/mcp/AsyncMcpToolCallbackTest.java +++ b/mcp/common/src/test/java/org/springframework/ai/mcp/AsyncMcpToolCallbackTest.java @@ -21,6 +21,7 @@ import io.modelcontextprotocol.client.McpAsyncClient; import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities; import io.modelcontextprotocol.spec.McpSchema.Implementation; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -36,6 +37,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -194,6 +196,8 @@ void callShouldIncludeToolContext() { .isError(false) .build(); when(this.mcpClient.callTool(any(McpSchema.CallToolRequest.class))).thenReturn(Mono.just(callToolResult)); + when(this.mcpClient.getClientCapabilities()).thenReturn(new ClientCapabilities(null, null, null, null)); + when(this.mcpClient.getClientInfo()).thenReturn(Implementation.builder("testClient", "1.0.0").build()); ToolContext toolContext = mock(ToolContext.class); when(toolContext.getContext()).thenReturn(Map.of("key", "value")); @@ -288,9 +292,12 @@ void builderShouldAcceptCustomToolContextConverter() { var callToolResult = McpSchema.CallToolResult.builder().addTextContent("Success").isError(false).build(); when(this.mcpClient.callTool(any(McpSchema.CallToolRequest.class))).thenReturn(Mono.just(callToolResult)); + when(this.mcpClient.getClientCapabilities()).thenReturn(new ClientCapabilities(null, null, null, null)); + when(this.mcpClient.getClientInfo()).thenReturn(Implementation.builder("testClient", "1.0.0").build()); ToolContext toolContext = mock(ToolContext.class); - when(customConverter.convert(toolContext)).thenReturn(Map.of("custom", "meta")); + when(customConverter.convert(eq(toolContext), any(McpConnectionInfo.class))) + .thenReturn(Map.of("custom", "meta")); // Act var callback = AsyncMcpToolCallback.builder() @@ -303,7 +310,7 @@ void builderShouldAcceptCustomToolContextConverter() { callback.call("{}", toolContext); // Assert - verify(customConverter).convert(toolContext); + verify(customConverter).convert(eq(toolContext), any(McpConnectionInfo.class)); } @Test diff --git a/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackTests.java b/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackTests.java index 5671d02bd2..5a03ce8801 100644 --- a/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackTests.java +++ b/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackTests.java @@ -24,6 +24,7 @@ import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities; import io.modelcontextprotocol.spec.McpSchema.Implementation; import io.modelcontextprotocol.spec.McpSchema.Tool; import org.junit.jupiter.api.Test; @@ -104,6 +105,8 @@ void callShouldHandleToolContext() { when(this.tool.name()).thenReturn("testTool"); CallToolResult callResult = mock(CallToolResult.class); when(this.mcpClient.callTool(any(CallToolRequest.class))).thenReturn(callResult); + when(this.mcpClient.getClientCapabilities()).thenReturn(new ClientCapabilities(null, null, null, null)); + when(this.mcpClient.getClientInfo()).thenReturn(Implementation.builder("testClient", "1.0.0").build()); SyncMcpToolCallback callback = SyncMcpToolCallback.builder() .mcpClient(this.mcpClient) diff --git a/mcp/common/src/test/java/org/springframework/ai/mcp/ToolContextToMcpMetaConverterTest.java b/mcp/common/src/test/java/org/springframework/ai/mcp/ToolContextToMcpMetaConverterTest.java index 79a1bb1e35..f7cfbd1597 100644 --- a/mcp/common/src/test/java/org/springframework/ai/mcp/ToolContextToMcpMetaConverterTest.java +++ b/mcp/common/src/test/java/org/springframework/ai/mcp/ToolContextToMcpMetaConverterTest.java @@ -20,6 +20,8 @@ import java.util.Map; import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities; +import io.modelcontextprotocol.spec.McpSchema.Implementation; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -34,6 +36,7 @@ * * @author Christian Tzolov * @author YunKui Lu + * @author Dimitar Proynov */ @ExtendWith(MockitoExtension.class) class ToolContextToMcpMetaConverterTest { @@ -277,6 +280,67 @@ void defaultConverterShouldHandleSpecialCharactersInKeys() { assertThat(result).containsEntry("key@with#special$chars", "value5"); } + @Test + void defaultConverterIgnoresConnectionInfoAndBehavesLikeSingleArgConvert() { + ToolContextToMcpMetaConverter converter = ToolContextToMcpMetaConverter.defaultConverter(); + Map contextMap = new HashMap<>(); + contextMap.put("key1", "value1"); + ToolContext toolContext = new ToolContext(contextMap); + McpConnectionInfo connectionInfo = connectionInfo("trusted-server"); + + Map result = converter.convert(toolContext, connectionInfo); + + assertThat(result).isEqualTo(converter.convert(toolContext)); + } + + @Test + void legacySingleArgImplementationStillWorksThroughTwoArgOverload() { + // A converter written against the pre-2.0.2 single-arg contract must keep + // working unmodified when invoked through the new connection-aware overload. + ToolContextToMcpMetaConverter legacyConverter = toolContext -> Map.of("legacy", "value"); + + Map result = legacyConverter.convert(new ToolContext(Map.of()), connectionInfo("any-server")); + + assertThat(result).containsEntry("legacy", "value"); + } + + @Test + void customConverterCanScopeMetadataByDestinationServer() { + // The whole point of the connection-aware overload: a converter can forward + // sensitive ToolContext entries only to the server(s) that should see them, + // instead of every registered MCP server getting the same metadata. + ToolContextToMcpMetaConverter scopedConverter = new ToolContextToMcpMetaConverter() { + @Override + public Map convert(ToolContext toolContext) { + return convert(toolContext, null); + } + + @Override + public Map convert(ToolContext toolContext, McpConnectionInfo connectionInfo) { + if (connectionInfo == null || !"trusted-server".equals(connectionInfo.clientInfo().name())) { + return Map.of(); + } + return toolContext.getContext(); + } + }; + ToolContext toolContext = new ToolContext(Map.of("sessionJwt", "secret-token")); + + Map forwardedToTrustedServer = scopedConverter.convert(toolContext, + connectionInfo("trusted-server")); + Map forwardedToUntrustedServer = scopedConverter.convert(toolContext, + connectionInfo("untrusted-server")); + + assertThat(forwardedToTrustedServer).containsEntry("sessionJwt", "secret-token"); + assertThat(forwardedToUntrustedServer).isEmpty(); + } + + private static McpConnectionInfo connectionInfo(String clientName) { + return McpConnectionInfo.builder() + .clientCapabilities(new ClientCapabilities(null, null, null, null)) + .clientInfo(Implementation.builder(clientName, "1.0.0").build()) + .build(); + } + @Test void defaultConverterShouldHandleEmptyStringValues() { ToolContextToMcpMetaConverter converter = ToolContextToMcpMetaConverter.defaultConverter(); diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-client-boot-starter-docs.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-client-boot-starter-docs.adoc index b63b255e40..ec29eb1368 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-client-boot-starter-docs.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-client-boot-starter-docs.adoc @@ -764,6 +764,38 @@ public class CustomToolContextToMcpMetaConverter implements ToolContextToMcpMeta } ---- +==== Scoping Metadata to Specific MCP Servers + +WARNING: The same `ToolContext` and converter are shared across every MCP server registered with the application. If you populate the tool context with sensitive data (auth tokens, user identifiers) for one server's benefit, the default converter forwards it to *every* server a tool call happens to target, not just the one that needs it. + +To scope what gets forwarded based on the destination server, override `convert(ToolContext, McpConnectionInfo)` instead of `convert(ToolContext)`. +The `McpConnectionInfo` identifies which MCP server the resulting metadata is about to be sent to, using the same connection info already used by `McpToolFilter` to scope tool discovery: + +[source,java] +---- +@Component +public class ScopedToolContextToMcpMetaConverter implements ToolContextToMcpMetaConverter { + + @Override + public Map convert(ToolContext toolContext) { + // Not used directly once convert(ToolContext, McpConnectionInfo) is overridden, + // but still required by the interface for callers that only have a ToolContext. + return Map.of(); + } + + @Override + public Map convert(ToolContext toolContext, McpConnectionInfo connectionInfo) { + // Only forward the session token to the internal, trusted MCP server. + if (!"internal-tools-server".equals(connectionInfo.clientInfo().name())) { + return Map.of(); + } + return Map.of("sessionJwt", toolContext.getContext().get("sessionJwt")); + } +} +---- + +`convert(ToolContext, McpConnectionInfo)` has a default implementation that delegates to `convert(ToolContext)`, so existing converters that don't override it keep working unchanged. + ==== Built-in Converters The framework provides built-in converters: