From 66dd9eece2dccd10b3dda954f342be37a92bebfd Mon Sep 17 00:00:00 2001 From: eeaters <870905780@qq.com> Date: Mon, 25 Aug 2025 17:45:58 +0800 Subject: [PATCH] feat: client support for setting ToolAnnotations - Added a function in Client to set tool annotations received from server - Updated Client builder to support configuring ToolAnnotations - Enables downstream clients to handle server-provided tool metadata --- .../client/McpAsyncClient.java | 56 +++++++++++++++++-- .../client/McpClient.java | 41 +++++++++++++- .../io/modelcontextprotocol/util/Utils.java | 28 ++++++++++ 3 files changed, 117 insertions(+), 8 deletions(-) diff --git a/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java b/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java index eb6d42f68..6268ad40d 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -14,6 +14,7 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -22,7 +23,6 @@ import io.modelcontextprotocol.spec.McpClientSession; import io.modelcontextprotocol.spec.McpClientTransport; -import io.modelcontextprotocol.spec.McpError; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities; import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest; @@ -153,6 +153,15 @@ public class McpAsyncClient { */ private final LifecycleInitializer initializer; + /** + * MCP provides a standardized way for servers to request tool invocation from + * clients. This flow allows clients to maintain control over tool access, selection, + * and permissions while enabling servers to leverage AI capabilities—with no server + * API keys necessary. Servers can request tool invocation with optional tool + * annotations to provide additional context to the tool. + */ + private Function toolAnnotationsHandler; + /** * Create a new McpAsyncClient with the given transport and session request-response * timeout. @@ -162,8 +171,7 @@ public class McpAsyncClient { * @param features the MCP Client supported features. */ McpAsyncClient(McpClientTransport transport, Duration requestTimeout, Duration initializationTimeout, - McpClientFeatures.Async features) { - + McpClientFeatures.Async features, Function toolAnnotationsHandler) { Assert.notNull(transport, "Transport must not be null"); Assert.notNull(requestTimeout, "Request timeout must not be null"); Assert.notNull(initializationTimeout, "Initialization timeout must not be null"); @@ -172,7 +180,7 @@ public class McpAsyncClient { this.clientCapabilities = features.clientCapabilities(); this.transport = transport; this.roots = new ConcurrentHashMap<>(features.roots()); - + this.toolAnnotationsHandler = toolAnnotationsHandler != null ? toolAnnotationsHandler : tool -> null; // Request Handlers Map> requestHandlers = new HashMap<>(); @@ -575,10 +583,48 @@ public Mono listTools(String cursor) { } return init.mcpSession() .sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor), - LIST_TOOLS_RESULT_TYPE_REF); + LIST_TOOLS_RESULT_TYPE_REF) + .map(this::mergeToolsAnnotations); }); } + private McpSchema.ListToolsResult mergeToolsAnnotations(McpSchema.ListToolsResult listToolsResult) { + if (listToolsResult == null || listToolsResult.tools() == null || listToolsResult.tools().isEmpty()) { + return listToolsResult; + } + + List mergedTools = listToolsResult.tools().stream().map(tool -> { + McpSchema.ToolAnnotations clientAnnoProperties = this.toolAnnotationsHandler.apply(tool.name()); + if (clientAnnoProperties == null) { + return tool; // no update needed + } + McpSchema.ToolAnnotations mergedAnno = mergeAnnotations(tool.annotations(), clientAnnoProperties); + return McpSchema.Tool.builder() + .name(tool.name()) + .title(tool.title()) + .description(tool.description()) + .inputSchema(tool.inputSchema()) + .outputSchema(tool.outputSchema()) + .annotations(mergedAnno) + .meta(tool.meta()) + .build(); + }).collect(Collectors.toList()); + return new McpSchema.ListToolsResult(mergedTools, listToolsResult.nextCursor(), listToolsResult.meta()); + } + + private McpSchema.ToolAnnotations mergeAnnotations(McpSchema.ToolAnnotations remoteAnnotations, + McpSchema.ToolAnnotations clientAnnotations) { + if (remoteAnnotations == null) { + return clientAnnotations; + } + return new McpSchema.ToolAnnotations(Utils.preferFirst(clientAnnotations.title(), remoteAnnotations.title()), + Utils.mergeBoolean(clientAnnotations.readOnlyHint(), remoteAnnotations.readOnlyHint()), + Utils.mergeBoolean(clientAnnotations.destructiveHint(), remoteAnnotations.destructiveHint()), + Utils.mergeBoolean(clientAnnotations.idempotentHint(), remoteAnnotations.idempotentHint()), + Utils.mergeBoolean(clientAnnotations.openWorldHint(), remoteAnnotations.openWorldHint()), + Utils.mergeBoolean(clientAnnotations.returnDirect(), remoteAnnotations.returnDirect())); + } + private NotificationHandler asyncToolsChangeNotificationHandler( List, Mono>> toolsChangeConsumers) { // TODO: params are not used yet diff --git a/mcp/src/main/java/io/modelcontextprotocol/client/McpClient.java b/mcp/src/main/java/io/modelcontextprotocol/client/McpClient.java index c8af28ac1..3bca88b43 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/client/McpClient.java +++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpClient.java @@ -183,6 +183,8 @@ class SyncSpec { private Function elicitationHandler; + private Function toolAnnotationsHandler; + private SyncSpec(McpClientTransport transport) { Assert.notNull(transport, "Transport must not be null"); this.transport = transport; @@ -409,6 +411,21 @@ public SyncSpec progressConsumers(List> return this; } + /** + * Adds a handler to be invoked when tool annotations are requested. This allows + * the client to provide additional information about tools, such as their + * capabilities and usage instructions. + * @param toolAnnotationsHandler A handler that receives the tool name and returns + * the tool annotations. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if toolAnnotationsHandler is null + */ + public SyncSpec toolAnnotationsHandler(Function toolAnnotationsHandler) { + Assert.notNull(progressConsumers, "tool annotations handler must not be null"); + this.toolAnnotationsHandler = toolAnnotationsHandler; + return this; + } + /** * Create an instance of {@link McpSyncClient} with the provided configurations or * sensible defaults. @@ -422,8 +439,8 @@ public McpSyncClient build() { McpClientFeatures.Async asyncFeatures = McpClientFeatures.Async.fromSync(syncFeatures); - return new McpSyncClient( - new McpAsyncClient(transport, this.requestTimeout, this.initializationTimeout, asyncFeatures)); + return new McpSyncClient(new McpAsyncClient(transport, this.requestTimeout, this.initializationTimeout, + asyncFeatures, toolAnnotationsHandler)); } } @@ -474,6 +491,8 @@ class AsyncSpec { private Function> elicitationHandler; + private Function toolAnnotationsHandler; + private AsyncSpec(McpClientTransport transport) { Assert.notNull(transport, "Transport must not be null"); this.transport = transport; @@ -720,6 +739,21 @@ public AsyncSpec progressConsumers( return this; } + /** + * Adds a handler to be invoked when tool annotations are requested. This allows + * the client to provide additional information about tools, such as their + * capabilities and usage instructions. + * @param toolAnnotationsHandler A handler that receives the tool name and returns + * the tool annotations. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if toolAnnotationsHandler is null + */ + public AsyncSpec toolAnnotationsHandler(Function toolAnnotationsHandler) { + Assert.notNull(toolAnnotationsHandler, "tool annotations handler must not be null"); + this.toolAnnotationsHandler = toolAnnotationsHandler; + return this; + } + /** * Create an instance of {@link McpAsyncClient} with the provided configurations * or sensible defaults. @@ -730,7 +764,8 @@ public McpAsyncClient build() { new McpClientFeatures.Async(this.clientInfo, this.capabilities, this.roots, this.toolsChangeConsumers, this.resourcesChangeConsumers, this.resourcesUpdateConsumers, this.promptsChangeConsumers, this.loggingConsumers, this.progressConsumers, - this.samplingHandler, this.elicitationHandler)); + this.samplingHandler, this.elicitationHandler), + toolAnnotationsHandler); } } diff --git a/mcp/src/main/java/io/modelcontextprotocol/util/Utils.java b/mcp/src/main/java/io/modelcontextprotocol/util/Utils.java index 039b0d68e..1e430d6fb 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/util/Utils.java +++ b/mcp/src/main/java/io/modelcontextprotocol/util/Utils.java @@ -107,4 +107,32 @@ private static boolean isUnderBaseUri(URI baseUri, URI endpointUri) { return endpointPath.startsWith(basePath); } + /** + * Returns the first non-null value from the given arguments. + * @param first The first argument + * @param second The second argument + * @return The first non-null value + */ + public static T preferFirst(T first, T second) { + return second != null ? second : first; + } + + + /** + * Merges two boolean values. If the first value is null, the second value is returned. + * If the second value is null, false is returned. + * @param first The first boolean value + * @param second The second boolean value + * @return The merged boolean value + */ + public static Boolean mergeBoolean(Boolean first, Boolean second) { + if (first == null) { + return second; + } + if (second == null) { + return false; + } + return first && second; + } + }