From 96b50f24beeb949b215e42e796ae7d7579394966 Mon Sep 17 00:00:00 2001 From: Emirn Date: Mon, 2 Nov 2026 17:33:07 +0400 Subject: [PATCH] Handle a `_meta` that is not an object without raising MCP requires `_meta` to be an object. When a request's `_meta` is a string, an array or a number, three `dig(:_meta, ...)` calls raised TypeError instead of answering a client error: - `validate_modern_headers` (Streamable HTTP, 2026-07-28): every method answered HTTP 500 -32603. `RequestEnvelope.parse!` already treats a non-object `_meta` as empty to answer -32602, but the header check runs first. - `call_tool`: legacy `tools/call` answered -32603 Internal error. - `build_server_context`: legacy `prompts/get` answered -32603. A non-object `_meta` carries no protocol version and no progress token, so these reads now skip it. The 2026-07-28 path answers HTTP 400 -32602, and legacy `tools/call` and `prompts/get` ignore the value, as legacy `tools/list` and `RequestEnvelope.modern?` already do. --- lib/mcp/server.rb | 4 ++-- .../server/transports/streamable_http_transport.rb | 2 +- .../transports/streamable_http_transport_test.rb | 11 +++++++++++ test/mcp/server_test.rb | 14 ++++++++++++++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index 9987ceb6..a1a2e87a 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -1403,7 +1403,7 @@ def call_tool(request, session: nil, related_request_id: nil, cancellation: nil, end end - progress_token = request.dig(:_meta, :progressToken) + progress_token = request.dig(:_meta, :progressToken) if request[:_meta].is_a?(Hash) response = call_tool_with_args( tool, @@ -1915,7 +1915,7 @@ def handler_declares_server_context?(handler) # (progress, cancellation, nested server-to-client requests). def build_server_context(request:, session:, related_request_id:, cancellation:, envelope: nil, auth_info: nil) meta_source = request.is_a?(Hash) ? request : {} - progress_token = meta_source.dig(:_meta, :progressToken) + progress_token = meta_source.dig(:_meta, :progressToken) if meta_source[:_meta].is_a?(Hash) progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id) retry_fields = mrtr_retry_fields(meta_source) ServerContext.new( diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index ce20a012..86e442a0 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -980,7 +980,7 @@ def handle_modern(request, header_version, body_string: nil) # converge on enforcement. def validate_modern_headers(request, body, header_version) params = body[:params] - meta_version = params.is_a?(Hash) ? params.dig(:_meta, :"io.modelcontextprotocol/protocolVersion") : nil + meta_version = params.is_a?(Hash) && params[:_meta].is_a?(Hash) ? params.dig(:_meta, :"io.modelcontextprotocol/protocolVersion") : nil if meta_version && meta_version != header_version return header_mismatch_response( "MCP-Protocol-Version header value '#{header_version}' does not match body value '#{meta_version}'", diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 8a6eca2c..d08c4d8d 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -6505,6 +6505,17 @@ def string assert_equal(-32602, JSON.parse(response[2][0]).dig("error", "code")) end + test "modern POST rejects a _meta that is not an object with 400 and -32602" do + ["not-an-object", []].each do |meta| + response = @transport.handle_request(modern_rack_request( + { jsonrpc: "2.0", method: "tools/list", id: 1, params: { _meta: meta } }.to_json, + )) + + assert_equal 400, response[0] + assert_equal(-32602, JSON.parse(response[2][0]).dig("error", "code")) + end + end + test "modern POST rejects an envelope missing clientCapabilities with 400 and -32602" do body = { jsonrpc: "2.0", diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 4cfa6456..9fc298d2 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -4321,6 +4321,20 @@ 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([]) } + + ["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 }, + }) + + assert_nil tool[:error] + assert_nil prompt[:error] + end + end + 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.