Skip to content
Open
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
4 changes: 0 additions & 4 deletions conformance/expected_failures.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,6 @@ server:
# the referee warns about each of the two; the baseline counts a warning as a failure. The resume on
# `Last-Event-ID` is not exercised, since the server does not close the stream mid-call.
- server-sse-polling
# SEP-2243 custom headers: the server does not bind `Mcp-Param-*` headers to `x-mcp-header` tool parameters
# (the client side sends them), so the conformance server exposes no such tool and the referee reports
# the scenario's checks as untestable.
- http-custom-header-server-validation
# SEP-2663 (io.modelcontextprotocol/tasks): the server does not implement the tasks extension.
# `tasks-status-notifications` is intentionally absent: the referee runs no check in it, and a baseline entry
# for a scenario with no failing check is flagged stale.
Expand Down
19 changes: 19 additions & 0 deletions conformance/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,24 @@ def call(**_args)
end
end

class TestXMcpHeader < MCP::Tool
tool_name "test_x_mcp_header"
description "A tool whose region argument is mirrored into the Mcp-Param-Region header (SEP-2243)"
input_schema(
properties: {
region: { type: "string", description: "Mirrored into the Mcp-Param-Region header", "x-mcp-header": "Region" },
},
)

class << self
# Arms the `http-custom-header-server-validation` server scenario, which the referee skips when no tool declares
# an `x-mcp-header` parameter, as the TypeScript and Python fixtures do.
def call(region: "<none>", **_args)
MCP::Tool::Response.new([MCP::Content::Text.new("region=#{region}").to_h])
end
end
end

class TestMissingCapability < MCP::Tool
tool_name "test_missing_capability"
description "A tool that requires the sampling client capability (SEP-2575)"
Expand Down Expand Up @@ -924,6 +942,7 @@ def build_server
Tools::TestElicitationSep1034Defaults,
Tools::TestElicitationSep1330Enums,
Tools::TestReconnection,
Tools::TestXMcpHeader,
Tools::TestMissingCapability,
Tools::TestInputRequiredResultElicitation,
Tools::TestInputRequiredResultSampling,
Expand Down
42 changes: 42 additions & 0 deletions docs/_server/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,48 @@ Annotations can be set either through the class definition using the `annotation
{: .note }
> This **Tool Annotations** feature is supported starting from `protocol_version: '2025-03-26'`.

## Custom Headers from Tool Parameters

On the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) (MCP 2026-07-28, SEP-2243), a tool can designate parameters
whose values the client mirrors into `Mcp-Param-{Name}` request headers, so intermediaries can route or apply policy on them
without parsing bodies. Declare the header name with an `x-mcp-header` annotation on the parameter's property:

```ruby
class RegionalTool < MCP::Tool
description "Runs in the region the request names"
input_schema(
properties: {
region: { type: "string", "x-mcp-header": "Region" },
},
required: ["region"]
)

class << self
def call(region:, server_context:)
MCP::Tool::Response.new([{ type: "text", text: "Running in #{region}" }])
end
end
end
```

The annotation must be a non-empty HTTP token, unique among the tool's annotations regardless of case,
and sit on a `string`, `integer`, or `boolean` property reachable from the schema root through `properties` keys only
(a `number` property is accepted as well, for compatibility with the conformance referee's fixtures, although the specification does not permit it).
Prefer `-` to `_` inside the name: proxies such as nginx drop header fields whose names hold an underscore by default,
and the request is then refused for its missing header.
Conforming clients drop a tool whose annotations break these rules and send no headers for it, and the transport validates nothing for such a tool.

`StreamableHTTPTransport` enforces the server side before the tool runs: on a `tools/call` of the modern lifecycle,
the `Mcp-Param-{Name}` header of each declared parameter must be present exactly when the body carries a value the client mirrors for it
(a string, a boolean, a finite float, or an integer within the safe JavaScript range; a value with no header rendering, such as an object, is left to argument validation)
and must agree with that value once a `=?base64?...?=` sentinel is decoded. A missing or disagreeing header,
a header whose argument is absent or `null` in the body, a header holding a byte outside the field-value set (visible ASCII, space, and tab)
without the sentinel, and a sentinel whose payload is not canonical Base64 or not UTF-8
are refused with HTTP 400 and JSON-RPC error `-32020` (`HeaderMismatch`) naming the header and, except in the two encoding refusals, the argument, never their values.
Integer and number parameters compare numerically when the header is a canonical decimal of at most 64 bytes, so a header of `42.0` agrees with a body value of `42`;
the comparison reads plain decimals only, so a number another SDK's client mirrors in exponent form is refused unless it repeats this SDK's own rendering byte for byte.
Headers for parameters without an annotation are ignored, and the handshake lifecycle (2025-11-25 and earlier) has no such headers.

## Tool Output Schemas

Tools can optionally define an `output_schema` to specify the expected structure of their results. This works similarly to how `input_schema` is defined and can be used in three ways.
Expand Down
1 change: 1 addition & 0 deletions lib/mcp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ module MCP
autoload :Elicitation, "mcp/elicitation"
autoload :ErrorCodes, "mcp/error_codes"
autoload :Icon, "mcp/icon"
autoload :McpParamHeaders, "mcp/mcp_param_headers"
autoload :Prompt, "mcp/prompt"
autoload :RequestEnvelope, "mcp/request_envelope"
autoload :Resource, "mcp/resource"
Expand Down
6 changes: 5 additions & 1 deletion lib/mcp/client.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# frozen_string_literal: true

require_relative "client/elicitation"
require_relative "client/mcp_param_headers"
require_relative "client/modern_envelope"
require_relative "client/oauth"
require_relative "client/stdio"
Expand All @@ -20,6 +19,11 @@ class Client
# every response would otherwise be followed indefinitely, growing the retained pages with it.
MAX_PAGES = 1_000

# The custom-header codec lived under this name until the server transport came to share it;
# the name stays for code that referenced it.
# @api private
McpParamHeaders = MCP::McpParamHeaders

class ServerError < StandardError
attr_reader :code, :data

Expand Down
2 changes: 1 addition & 1 deletion lib/mcp/client/http.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
require_relative "../methods"
require_relative "../protocol_deprecations"
require_relative "../version"
require_relative "mcp_param_headers"
require_relative "../mcp_param_headers"
require_relative "modern_envelope"

module MCP
Expand Down
242 changes: 0 additions & 242 deletions lib/mcp/client/mcp_param_headers.rb

This file was deleted.

Loading
Loading