Skip to content

Commit 7b00aec

Browse files
committed
Refuse a _meta that is not an object as invalid params
## Motivation and Context `_meta` is an object on every request (`MetaObject` in the schema). After #650, a String, Array, or number there no longer raises from `dig`, and the modern lifecycle answers -32602 because the envelope keys are missing; the legacy lifecycle, though, went on to run the handler. That left two gaps. The value still reached `server_context[:_meta]`, where the `server_context.dig(:_meta, ...)` the server context page shows raises `TypeError` on a String or an Array, so a handler written as documented still turned the request into -32603. And the TypeScript and Python servers refuse such a request as invalid params on both lifecycles, through schema validation of `params` and their inbound checks, so the Ruby server answered differently from both. The request is now refused with -32602 "Invalid params: _meta must be an object" before the envelope is read and before any handler runs, on both lifecycles. A notification handed to a handler is dropped the same way, without a response, as the TypeScript server drops a notification with invalid params; `notifications/cancelled` is served before the check and reads no `_meta`. A `null` `_meta` reads as absent, as it does for the Python server's request model. The server context page states the rule, since it tells handlers to read `_meta` from `server_context`. ## How Has This Been Tested? test/mcp/server_test.rb and test/mcp/server/transports/streamable_http_transport_test.rb. Before the fix, legacy `tools/call` with `_meta: "not-an-object"` ran the tool, and a tool reading `server_context.dig(:_meta, :key)` answered -32603 "Internal error" with "Internal error calling tool meta_reader" as the data. ## Breaking Changes A legacy-lifecycle request whose `_meta` is not an object now answers -32602. Released versions served such a request for methods that never read `_meta`, such as `tools/list`, and answered -32603 for `tools/call` and `prompts/get`.
1 parent 51db52a commit 7b00aec

4 files changed

Lines changed: 91 additions & 10 deletions

File tree

‎docs/_server/server-context.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ class MyTool < MCP::Tool
6868
end
6969
```
7070

71+
A `_meta` that is not an object is refused with `-32602` before any handler runs, on both lifecycles,
72+
and a `null` `_meta` reads as absent, so `server_context[:_meta]`, when present, is always a `Hash`.
73+
7174
**Client Request Example:**
7275

7376
A `tools/call` request carrying `_meta`, as read by the tool above:

‎lib/mcp/server.rb‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -799,6 +799,7 @@ def handle_request(request, method, session: nil, related_request_id: nil, auth_
799799
server_context: { request: request },
800800
exception_already_reported: ->(e) { reported_exception.equal?(e) },
801801
) do
802+
refuse_non_object_meta!(params)
802803
envelope = lift_request_envelope(params, method: method, session: session)
803804

804805
# The envelope's `logLevel` member replaces `logging/setLevel` in the modern lifecycle:
@@ -931,6 +932,26 @@ def modern_request?(request, session)
931932
RequestEnvelope.modern?(request.is_a?(Hash) ? request[:params] : nil)
932933
end
933934

935+
# `_meta` is an object on every request (`MetaObject`); a String, Array, or number there carries no envelope,
936+
# no progress token, and no trace context, and the TypeScript and Python servers refuse it as invalid params on
937+
# both lifecycles. Refused before the envelope is read and before any handler can meet it in `server_context`.
938+
# `null` reads as absent, as it does for the Python server.
939+
def refuse_non_object_meta!(params)
940+
return unless params.is_a?(Hash)
941+
942+
# Read as `RequestEnvelope` reads it, so the two cannot disagree on a Hash holding both key spellings.
943+
meta = params[:_meta]
944+
meta = params["_meta"] if meta.nil?
945+
return if meta.nil? || meta.is_a?(Hash)
946+
947+
raise RequestHandlerError.new(
948+
"Invalid params: _meta must be an object",
949+
params,
950+
error_type: :invalid_params,
951+
error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
952+
)
953+
end
954+
934955
# Lifts the SEP-2575 per-request `_meta` envelope for modern requests. Only a request whose `_meta` carries
935956
# the full required triple is classified as modern; a partial triple keeps flowing through the legacy path untouched.
936957
# Notifications carry no envelope (their `_meta` is a `NotificationMetaObject`), and `server/discover` is

‎test/mcp/server/transports/streamable_http_transport_test.rb‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6516,6 +6516,37 @@ def string
65166516
end
65176517
end
65186518

6519+
test "legacy POST refuses a _meta that is not an object with -32602 before the tool runs" do
6520+
calls = 0
6521+
@server.define_tool(name: "meta_reader") do |server_context:|
6522+
calls += 1
6523+
server_context.dig(:_meta, :key)
6524+
Tool::Response.new([])
6525+
end
6526+
session_id = initialize_test_session
6527+
6528+
["not-an-object", [], 42].each do |meta|
6529+
response = @transport.handle_request(create_rack_request(
6530+
"POST",
6531+
"/",
6532+
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
6533+
{ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "meta_reader", arguments: {}, _meta: meta } }.to_json,
6534+
))
6535+
body = if response[1]["content-type"].to_s.start_with?("text/event-stream")
6536+
io = StringIO.new
6537+
response[2].call(io)
6538+
JSON.parse(io.string[/^data: (.+)$/, 1])
6539+
else
6540+
JSON.parse(response[2][0])
6541+
end
6542+
6543+
assert_equal 200, response[0]
6544+
assert_equal(-32602, body.dig("error", "code"), meta.inspect)
6545+
assert_equal "Invalid params: _meta must be an object", body.dig("error", "message")
6546+
end
6547+
assert_equal 0, calls
6548+
end
6549+
65196550
test "modern POST rejects an envelope missing clientCapabilities with 400 and -32602" do
65206551
body = {
65216552
jsonrpc: "2.0",

‎test/mcp/server_test.rb‎

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4350,18 +4350,44 @@ def server_context
43504350
end
43514351
end
43524352

4353-
test "#handle tools/call and prompts/get ignore a _meta that is not an object" do
4354-
@server.define_tool(name: "echo_tool") { Tool::Response.new([]) }
4353+
test "#handle refuses a _meta that is not an object with -32602 before any handler runs" do
4354+
calls = 0
4355+
server = Server.new(name: "meta_server", server_context: { user: "u" })
4356+
# Reads `_meta` as the server context page shows (`dig`), which raises TypeError on a String or an Array.
4357+
server.define_tool(name: "meta_reader") do |server_context:|
4358+
calls += 1
4359+
server_context.dig(:_meta, :key)
4360+
Tool::Response.new([])
4361+
end
4362+
4363+
["not-an-object", [], 42].each do |meta|
4364+
[
4365+
[server, { jsonrpc: "2.0", method: "tools/call", id: 1, params: { name: "meta_reader", _meta: meta } }],
4366+
[server, { jsonrpc: "2.0", method: "tools/call", id: 1, params: { name: "meta_reader", "_meta" => meta } }],
4367+
[server, { jsonrpc: "2.0", method: "tools/list", id: 1, params: { _meta: meta } }],
4368+
[@server, { jsonrpc: "2.0", method: "prompts/get", id: 1, params: { name: "test_prompt", arguments: { test_argument: "x" }, _meta: meta } }],
4369+
].each do |target, request|
4370+
response = target.handle(request)
4371+
assert_equal(-32602, response[:error][:code], request.inspect)
4372+
assert_equal "Invalid params: _meta must be an object", response[:error][:message]
4373+
end
4374+
end
4375+
assert_equal 0, calls
43554376

4356-
["not-an-object", []].each do |meta|
4357-
tool = @server.handle({ jsonrpc: "2.0", method: "tools/call", id: 1, params: { name: "echo_tool", _meta: meta } })
4358-
prompt = @server.handle({
4359-
jsonrpc: "2.0", method: "prompts/get", id: 1, params: { name: "test_prompt", arguments: { test_argument: "x" }, _meta: meta },
4360-
})
4377+
# `null` reads as absent and an object reaches the handler, where the documented `dig` works on both.
4378+
assert_nil server.handle({ jsonrpc: "2.0", method: "tools/call", id: 1, params: { name: "meta_reader", _meta: nil } })[:error]
4379+
assert_nil server.handle({ jsonrpc: "2.0", method: "tools/call", id: 1, params: { name: "meta_reader", _meta: { key: "v" } } })[:error]
4380+
assert_equal 2, calls
4381+
end
43614382

4362-
assert_nil tool[:error]
4363-
assert_nil prompt[:error]
4364-
end
4383+
test "#handle drops a notification whose _meta is not an object without running its handler" do
4384+
calls = 0
4385+
@server.define_custom_method(method_name: "custom/notify") { |_params| calls += 1 }
4386+
4387+
assert_nil @server.handle({ jsonrpc: "2.0", method: "custom/notify", params: { _meta: "not-an-object" } })
4388+
assert_equal 0, calls
4389+
assert_nil @server.handle({ jsonrpc: "2.0", method: "custom/notify", params: { _meta: { key: "v" } } })
4390+
assert_equal 1, calls
43654391
end
43664392

43674393
test "#handle tools/call mirrors non-object structuredContent when content is omitted or nil" do

0 commit comments

Comments
 (0)