From c8847ac883e6d7a749ea9c0fdb6e5b0fcaaccf98 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 31 Oct 2026 01:36:00 +0900 Subject: [PATCH] Keep a tool's `_meta`, `title`, `icons`, and `execution` on the client ## Motivation and Context `MCP::Client#list_tools` built each `MCP::Client::Tool` from `name`, `description`, `inputSchema`, `outputSchema`, and `annotations`, and dropped the other members of the specification's `Tool` object: `title` and `_meta` (2025-06-18) and `icons` and `execution` (2025-11-25). Extensions put what a host needs under `_meta`: MCP Apps (SEP-1865) names a tool's app under `_meta.ui`, so a host on this client could not tell which tools have an app without a second raw `tools/list` of its own (#640). The TypeScript and Python clients keep all four on their `Tool` types, and this SDK's own `MCP::Tool` already serves `title`, `icons`, and `_meta`. `MCP::Client::Tool` now keeps the four as the server sent them, the way `annotations` is kept, behind `title`, `icons`, `execution`, and `meta` readers; `list_tools` passes them through, and a member a tool omits reads as nil. The SDK gives none of them a meaning of its own: `execution` carries the task-support hint of SEP-1686, which this SDK does not act on yet, and `_meta` keys belong to the extensions that define them. The client page notes that all four are untrusted input from the server, which the SDK does not act on. Closes #640. ## How Has This Been Tested? test/mcp/client/tool_test.rb and test/mcp/client_test.rb, which read the four members back from a `tools/list` answer carrying the MCP Apps `_meta.ui` shape and see nil when a tool omits them. At d5180252 the readers raise `NoMethodError` and `MCP::Client::Tool.new` refuses the keywords. ## Breaking Changes None. The new keywords of `MCP::Client::Tool#initialize` default to nil. --- docs/_client/index.md | 4 ++++ lib/mcp/client.rb | 4 ++++ lib/mcp/client/tool.rb | 10 ++++++++-- test/mcp/client/tool_test.rb | 15 +++++++++++++++ test/mcp/client_test.rb | 13 +++++++++++++ 5 files changed, 44 insertions(+), 2 deletions(-) diff --git a/docs/_client/index.md b/docs/_client/index.md index c43b4b5e..3ddf8677 100644 --- a/docs/_client/index.md +++ b/docs/_client/index.md @@ -46,3 +46,7 @@ The client provides a wrapper class for tools returned by the server: - `MCP::Client::Tool` - Represents a single tool with its metadata This class provides easy access to tool properties like name, description, input schema, and output schema. +`title`, `annotations`, `icons`, `execution`, and `meta` (the tool's `_meta`) are kept as the server sent them, +so what an extension puts under `_meta` is readable: an MCP Apps host finds a tool's app under `tool.meta&.dig("ui", "resourceUri")`. +These values are untrusted input from the server: the SDK passes them through without acting on them, +and a host that renders `icons` or loads what `_meta` names treats them as it would any server-supplied URL or content. diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index b6f1df1d..550e25df 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -315,10 +315,14 @@ def list_tools(cursor: nil, meta: nil, cancellation: nil) Tool.new( name: tool["name"], + title: tool["title"], description: tool["description"], input_schema: tool["inputSchema"], output_schema: tool["outputSchema"], annotations: tool["annotations"], + icons: tool["icons"], + execution: tool["execution"], + meta: tool["_meta"], ) end diff --git a/lib/mcp/client/tool.rb b/lib/mcp/client/tool.rb index bfefda85..d487daac 100644 --- a/lib/mcp/client/tool.rb +++ b/lib/mcp/client/tool.rb @@ -2,15 +2,21 @@ module MCP class Client + # A tool as `tools/list` described it. Every member is kept as the server sent it, so what an extension + # puts under `_meta` (MCP Apps names a tool's app under `_meta.ui`) reaches the host unchanged. class Tool - attr_reader :name, :description, :input_schema, :output_schema, :annotations + attr_reader :name, :title, :description, :input_schema, :output_schema, :annotations, :icons, :execution, :meta - def initialize(name:, description:, input_schema:, output_schema: nil, annotations: nil) + def initialize(name:, description:, input_schema:, output_schema: nil, annotations: nil, title: nil, icons: nil, execution: nil, meta: nil) @name = name + @title = title @description = description @input_schema = input_schema @output_schema = output_schema @annotations = annotations + @icons = icons + @execution = execution + @meta = meta end end end diff --git a/test/mcp/client/tool_test.rb b/test/mcp/client/tool_test.rb index f2025abf..934160b8 100644 --- a/test/mcp/client/tool_test.rb +++ b/test/mcp/client/tool_test.rb @@ -37,6 +37,13 @@ def test_annotations_returns_nil_when_not_provided assert_nil(@tool.annotations) end + def test_title_icons_execution_and_meta_return_nil_when_not_provided + assert_nil(@tool.title) + assert_nil(@tool.icons) + assert_nil(@tool.execution) + assert_nil(@tool.meta) + end + def test_annotations_returns_annotations_when_provided tool_with_annotations = Tool.new( name: "test_tool_with_annotations", @@ -68,17 +75,25 @@ def test_output_schema_returns_output_schema_when_provided def test_initialization_with_all_parameters tool = Tool.new( name: "full_tool", + title: "Full Tool", description: "A tool with all parameters", input_schema: { "type" => "object" }, output_schema: { "type" => "object", "properties" => { "status" => { "type" => "boolean" } } }, annotations: { "readOnlyHint" => true }, + icons: [{ "src" => "https://example.com/tool.png", "mimeType" => "image/png" }], + execution: { "taskSupport" => "optional" }, + meta: { "ui" => { "resourceUri" => "ui://shop/plans" } }, ) assert_equal("full_tool", tool.name) + assert_equal("Full Tool", tool.title) assert_equal("A tool with all parameters", tool.description) assert_equal({ "type" => "object" }, tool.input_schema) assert_equal({ "type" => "object", "properties" => { "status" => { "type" => "boolean" } } }, tool.output_schema) assert_equal({ "readOnlyHint" => true }, tool.annotations) + assert_equal([{ "src" => "https://example.com/tool.png", "mimeType" => "image/png" }], tool.icons) + assert_equal({ "taskSupport" => "optional" }, tool.execution) + assert_equal({ "ui" => { "resourceUri" => "ui://shop/plans" } }, tool.meta) end end end diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index bbd06520..c5f6b40c 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -336,10 +336,14 @@ def test_tools_sends_request_to_transport_and_returns_tools_array "tools" => [ { "name" => "tool1", + "title" => "First tool", "description" => "tool1", "inputSchema" => {}, "outputSchema" => { "type" => "object", "properties" => { "result" => { "type" => "string" } } }, "annotations" => { "readOnlyHint" => true, "title" => "Tool One" }, + "icons" => [{ "src" => "https://example.com/tool1.png", "mimeType" => "image/png" }], + "execution" => { "taskSupport" => "optional" }, + "_meta" => { "ui" => { "resourceUri" => "ui://shop/plans", "visibility" => ["model", "app"] } }, }, { "name" => "tool2", "description" => "tool2", "inputSchema" => {} }, ], @@ -358,9 +362,18 @@ def test_tools_sends_request_to_transport_and_returns_tools_array assert_equal("tool1", tools.first.name) assert_equal({ "type" => "object", "properties" => { "result" => { "type" => "string" } } }, tools.first.output_schema) assert_equal({ "readOnlyHint" => true, "title" => "Tool One" }, tools.first.annotations) + # Kept as sent: an MCP Apps host reads the tool's app from `_meta.ui`. + assert_equal("First tool", tools.first.title) + assert_equal([{ "src" => "https://example.com/tool1.png", "mimeType" => "image/png" }], tools.first.icons) + assert_equal({ "taskSupport" => "optional" }, tools.first.execution) + assert_equal({ "ui" => { "resourceUri" => "ui://shop/plans", "visibility" => ["model", "app"] } }, tools.first.meta) assert_equal("tool2", tools.last.name) assert_nil(tools.last.output_schema) assert_nil(tools.last.annotations) + assert_nil(tools.last.title) + assert_nil(tools.last.icons) + assert_nil(tools.last.execution) + assert_nil(tools.last.meta) end def test_tools_returns_empty_array_when_no_tools