diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 0f77af3c..586d0def 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -745,11 +745,15 @@ def call_tool(request, session: nil, related_request_id: nil, cancellation: nil) progress_token = request.dig(:_meta, :progressToken) - result = call_tool_with_args( + response = call_tool_with_args( tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation ) + result = response.to_h validate_tool_call_result!(tool, result) - serialize_structured_content_fallback(result) + serialize_structured_content_fallback( + result, + content_provided: response.respond_to?(:content_provided?) && response.content_provided?, + ) rescue RequestHandlerError, CancelledError # CancelledError is intentionally not wrapped so `handle_request` can turn it into # `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec. @@ -986,14 +990,18 @@ def validate_tool_call_result!(tool, result) # Per SEP-2106, `structuredContent` may be any JSON value, not only an object. # Clients on older protocol versions may only read `content`, - # so when a tool returns non-object structured content without providing - # any content blocks, mirror the value into `content` as serialized JSON text. - def serialize_structured_content_fallback(result) + # so when a tool returns non-object structured content without explicitly + # providing `content`, mirror the value into serialized JSON text. + def serialize_structured_content_fallback(result, content_provided: false) structured = result[:structuredContent] return result if structured.nil? || structured.is_a?(Hash) + return result if content_provided return result unless result[:content].nil? || result[:content].empty? - result.merge(content: [{ type: "text", text: JSON.generate(structured) }]) + serialized = JSON.generate(structured) + return result if JSON.parse(serialized).is_a?(Hash) + + result.merge(content: [{ type: "text", text: serialized }]) end # Whether a tool/prompt handler opts in to receiving an `MCP::ServerContext`. @@ -1026,9 +1034,9 @@ def call_tool_with_args(tool, arguments, context, progress_token: nil, session: related_request_id: related_request_id, cancellation: cancellation, ) - tool.call(**args, server_context: server_context).to_h + tool.call(**args, server_context: server_context) else - tool.call(**args).to_h + tool.call(**args) end end diff --git a/lib/mcp/tool/response.rb b/lib/mcp/tool/response.rb index 2532ea83..9e4b2ecd 100644 --- a/lib/mcp/tool/response.rb +++ b/lib/mcp/tool/response.rb @@ -7,18 +7,24 @@ class Response attr_reader :content, :structured_content, :meta - def initialize(content = nil, deprecated_error = NOT_GIVEN, error: false, structured_content: nil, meta: nil) + def initialize(content = NOT_GIVEN, deprecated_error = NOT_GIVEN, error: false, structured_content: nil, meta: nil) if deprecated_error != NOT_GIVEN warn("Passing `error` with the 2nd argument of `Response.new` is deprecated. Use keyword argument like `Response.new(content, error: error)` instead.", uplevel: 1) error = deprecated_error end - @content = content || [] + content_given = !content.equal?(NOT_GIVEN) + @content_provided = content_given && !content.nil? + @content = content_given ? (content || []) : [] @error = error @structured_content = structured_content @meta = meta end + def content_provided? + @content_provided + end + def error? !!@error end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 18240b0e..7281a434 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -3156,6 +3156,16 @@ def call(numbers:, strings:, objects:, server_context: nil) end end + class JsonObjectValue + def initialize(value) + @value = value + end + + def to_json(*args) + @value.to_json(*args) + end + end + test "server_context_with_meta uses accessor method, not ivar directly" do subclass = Class.new(Server) do def server_context @@ -3247,23 +3257,28 @@ def server_context end end - test "#handle tools/call mirrors non-object structuredContent into serialized text content" do + test "#handle tools/call mirrors non-object structuredContent when content is omitted or nil" do # Per SEP-2106, `structuredContent` may be any JSON value. Older clients may only read `content`, # so the server adds a serialized fallback when the tool provided no content blocks. server = Server.new(name: "structured_test", tools: []) - server.define_tool(name: "array_tool") do + server.define_tool(name: "nil_content_tool") do Tool::Response.new(nil, structured_content: [1, 2]) end + server.define_tool(name: "omitted_content_tool") do + Tool::Response.new(structured_content: [1, 2]) + end - response = server.handle({ - jsonrpc: "2.0", - method: "tools/call", - id: 1, - params: { name: "array_tool", arguments: {} }, - }) + ["nil_content_tool", "omitted_content_tool"].each_with_index do |tool_name, index| + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: index + 1, + params: { name: tool_name, arguments: {} }, + }) - assert_equal [1, 2], response.dig(:result, :structuredContent) - assert_equal [{ type: "text", text: "[1,2]" }], response.dig(:result, :content) + assert_equal [1, 2], response.dig(:result, :structuredContent) + assert_equal [{ type: "text", text: "[1,2]" }], response.dig(:result, :content) + end end test "#handle tools/call does not overwrite explicit content when structuredContent is non-object" do @@ -3299,6 +3314,60 @@ def server_context assert_empty response.dig(:result, :content) end + test "#handle tools/call recognizes object-like structuredContent by its JSON shape" do + structured_content = JsonObjectValue.new(answer: 42) + server = Server.new(name: "structured_test", tools: []) + server.define_tool(name: "object_tool") do + Tool::Response.new(nil, structured_content: structured_content) + end + + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { name: "object_tool", arguments: {} }, + }) + + assert_equal structured_content, response.dig(:result, :structuredContent) + assert_empty response.dig(:result, :content) + end + + test "#handle tools/call preserves explicit empty content for non-object structuredContent" do + server = Server.new(name: "structured_test", tools: []) + server.define_tool(name: "array_tool") do + Tool::Response.new([], structured_content: [1, 2]) + end + + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { name: "array_tool", arguments: {} }, + }) + + assert_equal [1, 2], response.dig(:result, :structuredContent) + assert_empty response.dig(:result, :content) + end + + test "#handle tools/call preserves explicit empty content for object-like structuredContent" do + structured_content = JsonObjectValue.new(answer: 42) + + server = Server.new(name: "structured_test", tools: []) + server.define_tool(name: "object_tool") do + Tool::Response.new([], structured_content: structured_content) + end + + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { name: "object_tool", arguments: {} }, + }) + + assert_equal structured_content, response.dig(:result, :structuredContent) + assert_empty response.dig(:result, :content) + end + test "list results carry ttlMs and cacheScope when ttl_ms is configured" do # SEP-2549 cache hints. The cacheScope default is "public", matching # the spec default and the Python SDK. diff --git a/test/mcp/tool/response_test.rb b/test/mcp/tool/response_test.rb index 122dbb07..afa7d3b5 100644 --- a/test/mcp/tool/response_test.rb +++ b/test/mcp/tool/response_test.rb @@ -16,6 +16,12 @@ class ResponseTest < ActiveSupport::TestCase refute response.error? end + test "#content_provided? distinguishes empty content from omitted or nil content" do + assert Response.new([]).content_provided? + refute Response.new.content_provided? + refute Response.new(nil).content_provided? + end + test "#initialize with content and error set to true" do content = [{ type: "text",