Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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,
Expand All @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
*
* @author Christian Tzolov
* @author YunKui Lu
* @author Dimitar Proynov
*/
public interface ToolContextToMcpMetaConverter {

Expand All @@ -43,6 +44,30 @@ public interface ToolContextToMcpMetaConverter {
*/
Map<String, Object> convert(ToolContext toolContext);

/**
* Convert the given {@link ToolContext} to MCP tool call metadata for a call bound to
* a specific MCP server.
* <p>
* 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}.
* <p>
* 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<String, Object> convert(ToolContext toolContext, McpConnectionInfo connectionInfo) {
return convert(toolContext);
}

static ToolContextToMcpMetaConverter defaultConverter() {

return toolContext -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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()
Expand All @@ -303,7 +310,7 @@ void builderShouldAcceptCustomToolContextConverter() {
callback.call("{}", toolContext);

// Assert
verify(customConverter).convert(toolContext);
verify(customConverter).convert(eq(toolContext), any(McpConnectionInfo.class));
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,6 +36,7 @@
*
* @author Christian Tzolov
* @author YunKui Lu
* @author Dimitar Proynov
*/
@ExtendWith(MockitoExtension.class)
class ToolContextToMcpMetaConverterTest {
Expand Down Expand Up @@ -277,6 +280,67 @@ void defaultConverterShouldHandleSpecialCharactersInKeys() {
assertThat(result).containsEntry("key@with#special$chars", "value5");
}

@Test
void defaultConverterIgnoresConnectionInfoAndBehavesLikeSingleArgConvert() {
ToolContextToMcpMetaConverter converter = ToolContextToMcpMetaConverter.defaultConverter();
Map<String, Object> contextMap = new HashMap<>();
contextMap.put("key1", "value1");
ToolContext toolContext = new ToolContext(contextMap);
McpConnectionInfo connectionInfo = connectionInfo("trusted-server");

Map<String, Object> 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<String, Object> 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<String, Object> convert(ToolContext toolContext) {
return convert(toolContext, null);
}

@Override
public Map<String, Object> 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<String, Object> forwardedToTrustedServer = scopedConverter.convert(toolContext,
connectionInfo("trusted-server"));
Map<String, Object> 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> 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<String, Object> 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:
Expand Down
Loading