Skip to content

Commit 565f4de

Browse files
authored
Merge pull request #469 from jstar0/fix/preserve-explicit-tool-content
Preserve explicit tool response content
2 parents fa05845 + 43a0ba6 commit 565f4de

4 files changed

Lines changed: 109 additions & 20 deletions

File tree

‎lib/mcp/server.rb‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -745,11 +745,15 @@ def call_tool(request, session: nil, related_request_id: nil, cancellation: nil)
745745

746746
progress_token = request.dig(:_meta, :progressToken)
747747

748-
result = call_tool_with_args(
748+
response = call_tool_with_args(
749749
tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation
750750
)
751+
result = response.to_h
751752
validate_tool_call_result!(tool, result)
752-
serialize_structured_content_fallback(result)
753+
serialize_structured_content_fallback(
754+
result,
755+
content_provided: response.respond_to?(:content_provided?) && response.content_provided?,
756+
)
753757
rescue RequestHandlerError, CancelledError
754758
# CancelledError is intentionally not wrapped so `handle_request` can turn it into
755759
# `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec.
@@ -986,14 +990,18 @@ def validate_tool_call_result!(tool, result)
986990

987991
# Per SEP-2106, `structuredContent` may be any JSON value, not only an object.
988992
# Clients on older protocol versions may only read `content`,
989-
# so when a tool returns non-object structured content without providing
990-
# any content blocks, mirror the value into `content` as serialized JSON text.
991-
def serialize_structured_content_fallback(result)
993+
# so when a tool returns non-object structured content without explicitly
994+
# providing `content`, mirror the value into serialized JSON text.
995+
def serialize_structured_content_fallback(result, content_provided: false)
992996
structured = result[:structuredContent]
993997
return result if structured.nil? || structured.is_a?(Hash)
998+
return result if content_provided
994999
return result unless result[:content].nil? || result[:content].empty?
9951000

996-
result.merge(content: [{ type: "text", text: JSON.generate(structured) }])
1001+
serialized = JSON.generate(structured)
1002+
return result if JSON.parse(serialized).is_a?(Hash)
1003+
1004+
result.merge(content: [{ type: "text", text: serialized }])
9971005
end
9981006

9991007
# 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:
10261034
related_request_id: related_request_id,
10271035
cancellation: cancellation,
10281036
)
1029-
tool.call(**args, server_context: server_context).to_h
1037+
tool.call(**args, server_context: server_context)
10301038
else
1031-
tool.call(**args).to_h
1039+
tool.call(**args)
10321040
end
10331041
end
10341042

‎lib/mcp/tool/response.rb‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,24 @@ class Response
77

88
attr_reader :content, :structured_content, :meta
99

10-
def initialize(content = nil, deprecated_error = NOT_GIVEN, error: false, structured_content: nil, meta: nil)
10+
def initialize(content = NOT_GIVEN, deprecated_error = NOT_GIVEN, error: false, structured_content: nil, meta: nil)
1111
if deprecated_error != NOT_GIVEN
1212
warn("Passing `error` with the 2nd argument of `Response.new` is deprecated. Use keyword argument like `Response.new(content, error: error)` instead.", uplevel: 1)
1313
error = deprecated_error
1414
end
1515

16-
@content = content || []
16+
content_given = !content.equal?(NOT_GIVEN)
17+
@content_provided = content_given && !content.nil?
18+
@content = content_given ? (content || []) : []
1719
@error = error
1820
@structured_content = structured_content
1921
@meta = meta
2022
end
2123

24+
def content_provided?
25+
@content_provided
26+
end
27+
2228
def error?
2329
!!@error
2430
end

‎test/mcp/server_test.rb‎

Lines changed: 79 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3156,6 +3156,16 @@ def call(numbers:, strings:, objects:, server_context: nil)
31563156
end
31573157
end
31583158

3159+
class JsonObjectValue
3160+
def initialize(value)
3161+
@value = value
3162+
end
3163+
3164+
def to_json(*args)
3165+
@value.to_json(*args)
3166+
end
3167+
end
3168+
31593169
test "server_context_with_meta uses accessor method, not ivar directly" do
31603170
subclass = Class.new(Server) do
31613171
def server_context
@@ -3247,23 +3257,28 @@ def server_context
32473257
end
32483258
end
32493259

3250-
test "#handle tools/call mirrors non-object structuredContent into serialized text content" do
3260+
test "#handle tools/call mirrors non-object structuredContent when content is omitted or nil" do
32513261
# Per SEP-2106, `structuredContent` may be any JSON value. Older clients may only read `content`,
32523262
# so the server adds a serialized fallback when the tool provided no content blocks.
32533263
server = Server.new(name: "structured_test", tools: [])
3254-
server.define_tool(name: "array_tool") do
3264+
server.define_tool(name: "nil_content_tool") do
32553265
Tool::Response.new(nil, structured_content: [1, 2])
32563266
end
3267+
server.define_tool(name: "omitted_content_tool") do
3268+
Tool::Response.new(structured_content: [1, 2])
3269+
end
32573270

3258-
response = server.handle({
3259-
jsonrpc: "2.0",
3260-
method: "tools/call",
3261-
id: 1,
3262-
params: { name: "array_tool", arguments: {} },
3263-
})
3271+
["nil_content_tool", "omitted_content_tool"].each_with_index do |tool_name, index|
3272+
response = server.handle({
3273+
jsonrpc: "2.0",
3274+
method: "tools/call",
3275+
id: index + 1,
3276+
params: { name: tool_name, arguments: {} },
3277+
})
32643278

3265-
assert_equal [1, 2], response.dig(:result, :structuredContent)
3266-
assert_equal [{ type: "text", text: "[1,2]" }], response.dig(:result, :content)
3279+
assert_equal [1, 2], response.dig(:result, :structuredContent)
3280+
assert_equal [{ type: "text", text: "[1,2]" }], response.dig(:result, :content)
3281+
end
32673282
end
32683283

32693284
test "#handle tools/call does not overwrite explicit content when structuredContent is non-object" do
@@ -3299,6 +3314,60 @@ def server_context
32993314
assert_empty response.dig(:result, :content)
33003315
end
33013316

3317+
test "#handle tools/call recognizes object-like structuredContent by its JSON shape" do
3318+
structured_content = JsonObjectValue.new(answer: 42)
3319+
server = Server.new(name: "structured_test", tools: [])
3320+
server.define_tool(name: "object_tool") do
3321+
Tool::Response.new(nil, structured_content: structured_content)
3322+
end
3323+
3324+
response = server.handle({
3325+
jsonrpc: "2.0",
3326+
method: "tools/call",
3327+
id: 1,
3328+
params: { name: "object_tool", arguments: {} },
3329+
})
3330+
3331+
assert_equal structured_content, response.dig(:result, :structuredContent)
3332+
assert_empty response.dig(:result, :content)
3333+
end
3334+
3335+
test "#handle tools/call preserves explicit empty content for non-object structuredContent" do
3336+
server = Server.new(name: "structured_test", tools: [])
3337+
server.define_tool(name: "array_tool") do
3338+
Tool::Response.new([], structured_content: [1, 2])
3339+
end
3340+
3341+
response = server.handle({
3342+
jsonrpc: "2.0",
3343+
method: "tools/call",
3344+
id: 1,
3345+
params: { name: "array_tool", arguments: {} },
3346+
})
3347+
3348+
assert_equal [1, 2], response.dig(:result, :structuredContent)
3349+
assert_empty response.dig(:result, :content)
3350+
end
3351+
3352+
test "#handle tools/call preserves explicit empty content for object-like structuredContent" do
3353+
structured_content = JsonObjectValue.new(answer: 42)
3354+
3355+
server = Server.new(name: "structured_test", tools: [])
3356+
server.define_tool(name: "object_tool") do
3357+
Tool::Response.new([], structured_content: structured_content)
3358+
end
3359+
3360+
response = server.handle({
3361+
jsonrpc: "2.0",
3362+
method: "tools/call",
3363+
id: 1,
3364+
params: { name: "object_tool", arguments: {} },
3365+
})
3366+
3367+
assert_equal structured_content, response.dig(:result, :structuredContent)
3368+
assert_empty response.dig(:result, :content)
3369+
end
3370+
33023371
test "list results carry ttlMs and cacheScope when ttl_ms is configured" do
33033372
# SEP-2549 cache hints. The cacheScope default is "public", matching
33043373
# the spec default and the Python SDK.

‎test/mcp/tool/response_test.rb‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ class ResponseTest < ActiveSupport::TestCase
1616
refute response.error?
1717
end
1818

19+
test "#content_provided? distinguishes empty content from omitted or nil content" do
20+
assert Response.new([]).content_provided?
21+
refute Response.new.content_provided?
22+
refute Response.new(nil).content_provided?
23+
end
24+
1925
test "#initialize with content and error set to true" do
2026
content = [{
2127
type: "text",

0 commit comments

Comments
 (0)