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" }