From dee80d20ae3691dd67cb9d2ef3ebb856410fa9da Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 13 Dec 2025 00:51:22 +0900 Subject: [PATCH] Raise an error when duplicate tool names are registered This raises an exception when duplicate tool names are registered, instead of silently overwriting tools. Tool names are required to be unique within a server, so this behavior aligns with the MCP specification. > Tool names SHOULD be unique within a server. https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names Validation for tool names could be made more strictly spec-compliant in a separate effort. Fixes #197 --- lib/mcp/server.rb | 23 +++++++++++++++++- test/mcp/server_test.rb | 52 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index e37a785f..df6c4c6a 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -5,6 +5,15 @@ require_relative "methods" module MCP + class ToolNotUnique < StandardError + def initialize(duplicated_tool_names) + super(<<~MESSAGE) + Tool names should be unique. Use `tool_name` to assign unique names to: + #{duplicated_tool_names.join(", ")} + MESSAGE + end + end + class Server DEFAULT_VERSION = "0.1.0" @@ -53,6 +62,7 @@ def initialize( @title = title @version = version @instructions = instructions + @tool_names = tools.map(&:name_value) @tools = tools.to_h { |t| [t.name_value, t] } @prompts = prompts.to_h { |p| [p.name_value, p] } @resources = resources @@ -101,7 +111,10 @@ def handle_json(request) def define_tool(name: nil, title: nil, description: nil, input_schema: nil, annotations: nil, meta: nil, &block) tool = Tool.define(name:, title:, description:, input_schema:, annotations:, meta:, &block) - @tools[tool.name_value] = tool + tool_name = tool.name_value + + @tool_names << tool_name + @tools[tool_name] = tool validate! end @@ -176,6 +189,8 @@ def prompts_get_handler(&block) private def validate! + validate_tool_name! + # NOTE: The draft protocol version is the next version after 2025-11-25. if @configuration.protocol_version <= "2025-06-18" if server_info.key?(:description) @@ -216,6 +231,12 @@ def validate! end end + def validate_tool_name! + duplicated_tool_names = @tool_names.tally.filter_map { |name, count| name if count >= 2 } + + raise ToolNotUnique, duplicated_tool_names unless duplicated_tool_names.empty? + end + def handle_request(request, method) handler = @handlers[method] unless handler diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 91d4435d..e37afee8 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -374,6 +374,44 @@ def call(message:, server_context: nil) assert_instrumentation_data({ method: "tools/call", tool_name: "tool_that_raises" }) end + test "registers tools with the same class name in different namespaces" do + module Foo + class Example < Tool + end + end + + module Bar + class Example < Tool + end + end + + error = assert_raises(MCP::ToolNotUnique) { Server.new(tools: [Foo::Example, Bar::Example]) } + assert_equal(<<~MESSAGE, error.message) + Tool names should be unique. Use `tool_name` to assign unique names to: + example + MESSAGE + end + + test "registers tools with the same tool name" do + module Baz + class Example < Tool + tool_name "foo" + end + end + + module Qux + class Example < Tool + tool_name "foo" + end + end + + error = assert_raises(MCP::ToolNotUnique) { Server.new(tools: [Baz::Example, Qux::Example]) } + assert_equal(<<~MESSAGE, error.message) + Tool names should be unique. Use `tool_name` to assign unique names to: + foo + MESSAGE + end + test "#handle_json returns error response with isError true if the tool raises an error" do request = JSON.generate({ jsonrpc: "2.0", @@ -950,6 +988,20 @@ def call(message:, server_context: nil) assert_equal({ content: "success", isError: false }, response[:result]) end + test "#define_tool adds a tool with duplicated tool name to the server" do + error = assert_raises(MCP::ToolNotUnique) do + @server.define_tool( + name: "test_tool", # NOTE: Already registered tool name + description: "Defined tool", + input_schema: { type: "object", properties: { message: { type: "string" } }, required: ["message"] }, + meta: { foo: "bar" }, + ) do |message:| + Tool::Response.new(message) + end + end + assert_match(/\ATool names should be unique. Use `tool_name` to assign unique names to/, error.message) + end + test "#define_tool call definition allows tool arguments and server context" do @server.server_context = { user_id: "123" }