Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/_server/server-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ class MyTool < MCP::Tool
end
```

A `_meta` that is not an object is refused with `-32602` before any handler runs, on both lifecycles,
and a `null` `_meta` reads as absent, so `server_context[:_meta]`, when present, is always a `Hash`.

**Client Request Example:**

A `tools/call` request carrying `_meta`, as read by the tool above:
Expand Down
21 changes: 21 additions & 0 deletions lib/mcp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,7 @@ def handle_request(request, method, session: nil, related_request_id: nil, auth_
server_context: { request: request },
exception_already_reported: ->(e) { reported_exception.equal?(e) },
) do
refuse_non_object_meta!(params)
envelope = lift_request_envelope(params, method: method, session: session)

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

# `_meta` is an object on every request (`MetaObject`); a String, Array, or number there carries no envelope,
# no progress token, and no trace context, and the TypeScript and Python servers refuse it as invalid params on
# both lifecycles. Refused before the envelope is read and before any handler can meet it in `server_context`.
# `null` reads as absent, as it does for the Python server.
def refuse_non_object_meta!(params)
return unless params.is_a?(Hash)

# Read as `RequestEnvelope` reads it, so the two cannot disagree on a Hash holding both key spellings.
meta = params[:_meta]
meta = params["_meta"] if meta.nil?
return if meta.nil? || meta.is_a?(Hash)

raise RequestHandlerError.new(
"Invalid params: _meta must be an object",
params,
error_type: :invalid_params,
error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
)
end

# Lifts the SEP-2575 per-request `_meta` envelope for modern requests. Only a request whose `_meta` carries
# the full required triple is classified as modern; a partial triple keeps flowing through the legacy path untouched.
# Notifications carry no envelope (their `_meta` is a `NotificationMetaObject`), and `server/discover` is
Expand Down
31 changes: 31 additions & 0 deletions test/mcp/server/transports/streamable_http_transport_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6516,6 +6516,37 @@ def string
end
end

test "legacy POST refuses a _meta that is not an object with -32602 before the tool runs" do
calls = 0
@server.define_tool(name: "meta_reader") do |server_context:|
calls += 1
server_context.dig(:_meta, :key)
Tool::Response.new([])
end
session_id = initialize_test_session

["not-an-object", [], 42].each do |meta|
response = @transport.handle_request(create_rack_request(
"POST",
"/",
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
{ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "meta_reader", arguments: {}, _meta: meta } }.to_json,
))
body = if response[1]["content-type"].to_s.start_with?("text/event-stream")
io = StringIO.new
response[2].call(io)
JSON.parse(io.string[/^data: (.+)$/, 1])
else
JSON.parse(response[2][0])
end

assert_equal 200, response[0]
assert_equal(-32602, body.dig("error", "code"), meta.inspect)
assert_equal "Invalid params: _meta must be an object", body.dig("error", "message")
end
assert_equal 0, calls
end

test "modern POST rejects an envelope missing clientCapabilities with 400 and -32602" do
body = {
jsonrpc: "2.0",
Expand Down
46 changes: 36 additions & 10 deletions test/mcp/server_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4350,18 +4350,44 @@ def server_context
end
end

test "#handle tools/call and prompts/get ignore a _meta that is not an object" do
@server.define_tool(name: "echo_tool") { Tool::Response.new([]) }
test "#handle refuses a _meta that is not an object with -32602 before any handler runs" do
calls = 0
server = Server.new(name: "meta_server", server_context: { user: "u" })
# Reads `_meta` as the server context page shows (`dig`), which raises TypeError on a String or an Array.
server.define_tool(name: "meta_reader") do |server_context:|
calls += 1
server_context.dig(:_meta, :key)
Tool::Response.new([])
end

["not-an-object", [], 42].each do |meta|
[
[server, { jsonrpc: "2.0", method: "tools/call", id: 1, params: { name: "meta_reader", _meta: meta } }],
[server, { jsonrpc: "2.0", method: "tools/call", id: 1, params: { name: "meta_reader", "_meta" => meta } }],
[server, { jsonrpc: "2.0", method: "tools/list", id: 1, params: { _meta: meta } }],
[@server, { jsonrpc: "2.0", method: "prompts/get", id: 1, params: { name: "test_prompt", arguments: { test_argument: "x" }, _meta: meta } }],
].each do |target, request|
response = target.handle(request)
assert_equal(-32602, response[:error][:code], request.inspect)
assert_equal "Invalid params: _meta must be an object", response[:error][:message]
end
end
assert_equal 0, calls

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

assert_nil tool[:error]
assert_nil prompt[:error]
end
test "#handle drops a notification whose _meta is not an object without running its handler" do
calls = 0
@server.define_custom_method(method_name: "custom/notify") { |_params| calls += 1 }

assert_nil @server.handle({ jsonrpc: "2.0", method: "custom/notify", params: { _meta: "not-an-object" } })
assert_equal 0, calls
assert_nil @server.handle({ jsonrpc: "2.0", method: "custom/notify", params: { _meta: { key: "v" } } })
assert_equal 1, calls
end

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