Mirror x-mcp-header tool arguments into Mcp-Param-* request headers (SEP-2243) (#2990)
This commit is contained in:
parent
3a8da8c0c3
commit
5b2713d40c
10 changed files with 393 additions and 14 deletions
30
.github/actions/conformance/client.py
vendored
30
.github/actions/conformance/client.py
vendored
|
|
@ -335,6 +335,34 @@ async def run_http_invalid_tool_headers(server_url: str) -> None:
|
||||||
logger.exception(f"call_tool({tool.name!r}) failed")
|
logger.exception(f"call_tool({tool.name!r}) failed")
|
||||||
|
|
||||||
|
|
||||||
|
@register("http-custom-headers")
|
||||||
|
async def run_http_custom_headers(server_url: str) -> None:
|
||||||
|
"""List tools, then replay the harness's `toolCalls` so x-mcp-header args mirror into headers (SEP-2243).
|
||||||
|
|
||||||
|
The scenario supplies the exact arguments to send (including the null/edge-case values that
|
||||||
|
exercise omission and Base64 encoding) via the context `toolCalls`; using them verbatim is
|
||||||
|
what drives every per-parameter check. `list_tools` first so the SDK caches each tool's
|
||||||
|
annotations; a tool the SDK dropped (invalid annotations) is skipped. Per-call failures are
|
||||||
|
logged and skipped rather than aborting the run.
|
||||||
|
"""
|
||||||
|
tool_calls: list[dict[str, Any]] = []
|
||||||
|
if os.environ.get("MCP_CONFORMANCE_CONTEXT"):
|
||||||
|
tool_calls = get_conformance_context().get("toolCalls", [])
|
||||||
|
async with Client(server_url, mode=client_mode()) as client:
|
||||||
|
listed = await client.list_tools()
|
||||||
|
surfaced = {tool.name for tool in listed.tools}
|
||||||
|
logger.debug(f"Surfaced tools: {sorted(surfaced)}")
|
||||||
|
for call in tool_calls:
|
||||||
|
name = call["name"]
|
||||||
|
if name not in surfaced:
|
||||||
|
logger.debug(f"skipping {name!r}: not surfaced by list_tools")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await client.call_tool(name, call.get("arguments") or {})
|
||||||
|
except Exception:
|
||||||
|
logger.exception(f"call_tool({name!r}) failed")
|
||||||
|
|
||||||
|
|
||||||
@register("elicitation-sep1034-client-defaults")
|
@register("elicitation-sep1034-client-defaults")
|
||||||
async def run_elicitation_defaults(server_url: str) -> None:
|
async def run_elicitation_defaults(server_url: str) -> None:
|
||||||
"""Connect with elicitation callback that applies schema defaults."""
|
"""Connect with elicitation callback that applies schema defaults."""
|
||||||
|
|
@ -526,8 +554,6 @@ def main() -> None:
|
||||||
elif scenario.startswith("auth/"):
|
elif scenario.startswith("auth/"):
|
||||||
asyncio.run(run_auth_code_client(server_url))
|
asyncio.run(run_auth_code_client(server_url))
|
||||||
else:
|
else:
|
||||||
# Unhandled scenarios:
|
|
||||||
# - http-custom-headers (SEP-2243 / S8: Mcp-Param-* emission)
|
|
||||||
print(f"Unknown scenario: {scenario}", file=sys.stderr)
|
print(f"Unknown scenario: {scenario}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,7 @@
|
||||||
# milestone.
|
# milestone.
|
||||||
|
|
||||||
client:
|
client:
|
||||||
# SEP-2243 (HTTP standardization): no client Mcp-Param-* support yet — needs the
|
[]
|
||||||
# tool-schema-cache vs per-call tool_definition design (S8).
|
|
||||||
- http-custom-headers
|
|
||||||
# auth/enterprise-managed-authorization (SEP-990) is in the 2025 baseline but
|
# auth/enterprise-managed-authorization (SEP-990) is in the 2025 baseline but
|
||||||
# NOT here: the harness skips it as inapplicable at --spec-version 2026-07-28
|
# NOT here: the harness skips it as inapplicable at --spec-version 2026-07-28
|
||||||
# (it is an extension scenario not carried into the 2026 wire), so it is
|
# (it is an extension scenario not carried into the 2026 wire), so it is
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,6 @@
|
||||||
# on stale entries), so the baseline burns down per milestone.
|
# on stale entries), so the baseline burns down per milestone.
|
||||||
|
|
||||||
client:
|
client:
|
||||||
# --- Draft-spec scenarios (in `--suite draft`, also part of `--suite all`) ---
|
|
||||||
# SEP-2243 (HTTP standardization): no client Mcp-Param-* support yet — needs the
|
|
||||||
# tool-schema-cache vs per-call tool_definition design (S8).
|
|
||||||
- http-custom-headers
|
|
||||||
|
|
||||||
# --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 ---
|
# --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 ---
|
||||||
# SEP-990 (enterprise-managed authorization extension): no fixture handler /
|
# SEP-990 (enterprise-managed authorization extension): no fixture handler /
|
||||||
# client support for the token-exchange + JWT bearer flow.
|
# client support for the token-exchange + JWT bearer flow.
|
||||||
|
|
|
||||||
37
.github/workflows/conformance.yml
vendored
37
.github/workflows/conformance.yml
vendored
|
|
@ -18,7 +18,16 @@ env:
|
||||||
# Use a published version, e.g. @modelcontextprotocol/conformance@0.2.0-alpha.7.
|
# Use a published version, e.g. @modelcontextprotocol/conformance@0.2.0-alpha.7.
|
||||||
# Bump deliberately and reconcile both
|
# Bump deliberately and reconcile both
|
||||||
# .github/actions/conformance/expected-failures*.yml files in the same change.
|
# .github/actions/conformance/expected-failures*.yml files in the same change.
|
||||||
CONFORMANCE_PKG: "@modelcontextprotocol/conformance@0.2.0-alpha.7"
|
#
|
||||||
|
# Temporarily pinned to the pkg.pr.new preview build of conformance#371, which
|
||||||
|
# fixes the http-custom-headers fixture's spec-forbidden `number`-typed
|
||||||
|
# x-mcp-header annotations. Because this is a mutable URL (not a registry
|
||||||
|
# spec), CONFORMANCE_PKG_SHA256 pins the tarball and the fetch-and-verify step
|
||||||
|
# below downloads, checks the digest, and repoints CONFORMANCE_PKG at the
|
||||||
|
# verified local copy. Repin to the published release that includes #371 once
|
||||||
|
# it ships, then drop CONFORMANCE_PKG_SHA256 and the fetch-and-verify steps.
|
||||||
|
CONFORMANCE_PKG: "https://pkg.pr.new/@modelcontextprotocol/conformance@371"
|
||||||
|
CONFORMANCE_PKG_SHA256: "9d8b25874d55e304b006cbaa066571773582f5828143c53a2b8a6830f203ca1d"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
server-conformance:
|
server-conformance:
|
||||||
|
|
@ -34,6 +43,19 @@ jobs:
|
||||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
|
- name: Fetch and verify conformance harness
|
||||||
|
# Only when CONFORMANCE_PKG is a URL: download, check the recorded
|
||||||
|
# sha256, and re-point CONFORMANCE_PKG at the verified local tarball.
|
||||||
|
# When CONFORMANCE_PKG is a registry spec, this step is a no-op (npm's
|
||||||
|
# own integrity check applies).
|
||||||
|
run: |
|
||||||
|
case "$CONFORMANCE_PKG" in
|
||||||
|
https://*)
|
||||||
|
curl -fsSL "$CONFORMANCE_PKG" -o /tmp/conformance.tgz
|
||||||
|
echo "$CONFORMANCE_PKG_SHA256 /tmp/conformance.tgz" | sha256sum -c -
|
||||||
|
echo "CONFORMANCE_PKG=file:/tmp/conformance.tgz" >> "$GITHUB_ENV"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
- run: uv sync --frozen --all-extras --package mcp-everything-server
|
- run: uv sync --frozen --all-extras --package mcp-everything-server
|
||||||
- name: Run server conformance (active suite)
|
- name: Run server conformance (active suite)
|
||||||
run: >-
|
run: >-
|
||||||
|
|
@ -65,6 +87,19 @@ jobs:
|
||||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
|
- name: Fetch and verify conformance harness
|
||||||
|
# Only when CONFORMANCE_PKG is a URL: download, check the recorded
|
||||||
|
# sha256, and re-point CONFORMANCE_PKG at the verified local tarball.
|
||||||
|
# When CONFORMANCE_PKG is a registry spec, this step is a no-op (npm's
|
||||||
|
# own integrity check applies).
|
||||||
|
run: |
|
||||||
|
case "$CONFORMANCE_PKG" in
|
||||||
|
https://*)
|
||||||
|
curl -fsSL "$CONFORMANCE_PKG" -o /tmp/conformance.tgz
|
||||||
|
echo "$CONFORMANCE_PKG_SHA256 /tmp/conformance.tgz" | sha256sum -c -
|
||||||
|
echo "CONFORMANCE_PKG=file:/tmp/conformance.tgz" >> "$GITHUB_ENV"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
- run: uv sync --frozen --all-extras --package mcp
|
- run: uv sync --frozen --all-extras --package mcp
|
||||||
- name: Run client conformance (all suite)
|
- name: Run client conformance (all suite)
|
||||||
# The harness runs all scenarios via unbounded Promise.all; with 40
|
# The harness runs all scenarios via unbounded Promise.all; with 40
|
||||||
|
|
|
||||||
|
|
@ -399,6 +399,10 @@ For an in-process `Client(server)` (where `server` is a `Server` or `MCPServer`
|
||||||
|
|
||||||
For protocol 2026-07-28, a `tools/call` request may return an `InputRequiredResult` asking the client to supply additional input and retry. By default `call_tool` (on `ClientSession`, `Client`, and `ClientSessionGroup`) still returns `CallToolResult` and raises `RuntimeError` if the server requests input. Pass `allow_input_required=True` to receive the `InputRequiredResult` instead, then retry with `input_responses=` / `request_state=`.
|
For protocol 2026-07-28, a `tools/call` request may return an `InputRequiredResult` asking the client to supply additional input and retry. By default `call_tool` (on `ClientSession`, `Client`, and `ClientSessionGroup`) still returns `CallToolResult` and raises `RuntimeError` if the server requests input. Pass `allow_input_required=True` to receive the `InputRequiredResult` instead, then retry with `input_responses=` / `request_state=`.
|
||||||
|
|
||||||
|
### `call_tool` mirrors `x-mcp-header` arguments into `Mcp-Param-*` headers (SEP-2243)
|
||||||
|
|
||||||
|
For protocol 2026-07-28 over Streamable HTTP, a tool's input-schema property may carry an `x-mcp-header` annotation. When a tool the client has listed is called, each annotated argument is mirrored into an `Mcp-Param-<name>` request header (string verbatim, integer as decimal, boolean as `true`/`false`, base64-sentinel-wrapped when not header-safe; `null`/absent arguments are omitted). The argument is also left in the request body. `list_tools` caches a tool's annotations, so list a tool before calling it to enable mirroring; a tool the client never listed emits no `Mcp-Param-*` headers. Other transports ignore the annotation.
|
||||||
|
|
||||||
### `McpError` renamed to `MCPError`
|
### `McpError` renamed to `MCPError`
|
||||||
|
|
||||||
The `McpError` exception class has been renamed to `MCPError` for consistent naming with the MCP acronym style used throughout the SDK.
|
The `McpError` exception class has been renamed to `MCPError` for consistent naming with the MCP acronym style used throughout the SDK.
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ from mcp.shared.inbound import (
|
||||||
NAME_BEARING_METHODS,
|
NAME_BEARING_METHODS,
|
||||||
encode_header_value,
|
encode_header_value,
|
||||||
find_invalid_x_mcp_header,
|
find_invalid_x_mcp_header,
|
||||||
|
mcp_param_headers,
|
||||||
|
x_mcp_header_map,
|
||||||
)
|
)
|
||||||
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
|
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
|
||||||
from mcp.shared.message import ClientMessageMetadata, SessionMessage
|
from mcp.shared.message import ClientMessageMetadata, SessionMessage
|
||||||
|
|
@ -68,7 +70,10 @@ def _make_handshake_stamp(protocol_version: str) -> Callable[[dict[str, Any], Ca
|
||||||
|
|
||||||
|
|
||||||
def _make_modern_stamp(
|
def _make_modern_stamp(
|
||||||
protocol_version: str, client_info: dict[str, Any], capabilities: dict[str, Any]
|
protocol_version: str,
|
||||||
|
client_info: dict[str, Any],
|
||||||
|
capabilities: dict[str, Any],
|
||||||
|
resolve_param_headers: Callable[[str, Mapping[str, Any]], dict[str, str]],
|
||||||
) -> Callable[[dict[str, Any], CallOptions], None]:
|
) -> Callable[[dict[str, Any], CallOptions], None]:
|
||||||
def stamp(data: dict[str, Any], opts: CallOptions) -> None:
|
def stamp(data: dict[str, Any], opts: CallOptions) -> None:
|
||||||
params = data.setdefault("params", {})
|
params = data.setdefault("params", {})
|
||||||
|
|
@ -83,6 +88,8 @@ def _make_modern_stamp(
|
||||||
name_key = NAME_BEARING_METHODS.get(data["method"])
|
name_key = NAME_BEARING_METHODS.get(data["method"])
|
||||||
if name_key is not None and isinstance(name := params.get(name_key), str):
|
if name_key is not None and isinstance(name := params.get(name_key), str):
|
||||||
headers[MCP_NAME_HEADER] = encode_header_value(name)
|
headers[MCP_NAME_HEADER] = encode_header_value(name)
|
||||||
|
if data["method"] == "tools/call" and isinstance(name := params.get("name"), str):
|
||||||
|
headers.update(resolve_param_headers(name, params.get("arguments") or {}))
|
||||||
|
|
||||||
return stamp
|
return stamp
|
||||||
|
|
||||||
|
|
@ -215,6 +222,7 @@ class ClientSession:
|
||||||
self._logging_callback = logging_callback or _default_logging_callback
|
self._logging_callback = logging_callback or _default_logging_callback
|
||||||
self._message_handler = message_handler or _default_message_handler
|
self._message_handler = message_handler or _default_message_handler
|
||||||
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
|
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
|
||||||
|
self._x_mcp_header_maps: dict[str, dict[tuple[str, ...], str]] = {}
|
||||||
self._initialize_result: types.InitializeResult | None = None
|
self._initialize_result: types.InitializeResult | None = None
|
||||||
self._discover_result: types.DiscoverResult | None = None
|
self._discover_result: types.DiscoverResult | None = None
|
||||||
self._negotiated_version: str | None = None
|
self._negotiated_version: str | None = None
|
||||||
|
|
@ -393,7 +401,7 @@ class ClientSession:
|
||||||
)
|
)
|
||||||
client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True)
|
client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True)
|
||||||
capabilities = self._build_capabilities().model_dump(by_alias=True, mode="json", exclude_none=True)
|
capabilities = self._build_capabilities().model_dump(by_alias=True, mode="json", exclude_none=True)
|
||||||
self._stamp = _make_modern_stamp(mutual[-1], client_info, capabilities)
|
self._stamp = _make_modern_stamp(mutual[-1], client_info, capabilities, self._resolve_param_headers)
|
||||||
self._discover_result = result
|
self._discover_result = result
|
||||||
self._initialize_result = None
|
self._initialize_result = None
|
||||||
self._negotiated_version = mutual[-1]
|
self._negotiated_version = mutual[-1]
|
||||||
|
|
@ -646,6 +654,11 @@ class ClientSession:
|
||||||
) -> types.CallToolResult | types.InputRequiredResult:
|
) -> types.CallToolResult | types.InputRequiredResult:
|
||||||
"""Send a tools/call request with optional progress callback support.
|
"""Send a tools/call request with optional progress callback support.
|
||||||
|
|
||||||
|
On a modern (2026-07-28) connection, arguments annotated with `x-mcp-header`
|
||||||
|
in the tool's input schema are mirrored into `Mcp-Param-*` request headers.
|
||||||
|
The annotations are read from the tool's last `list_tools` entry, so list
|
||||||
|
the tool before calling it to enable header emission.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
input_responses: Responses to a prior `InputRequiredResult.input_requests`.
|
input_responses: Responses to a prior `InputRequiredResult.input_requests`.
|
||||||
request_state: Opaque state echoed from a prior `InputRequiredResult`.
|
request_state: Opaque state echoed from a prior `InputRequiredResult`.
|
||||||
|
|
@ -657,7 +670,6 @@ class ClientSession:
|
||||||
RuntimeError: If the server returns an `InputRequiredResult` and
|
RuntimeError: If the server returns an `InputRequiredResult` and
|
||||||
``allow_input_required`` is ``False``.
|
``allow_input_required`` is ``False``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
result = await self.send_request(
|
result = await self.send_request(
|
||||||
types.CallToolRequest(
|
types.CallToolRequest(
|
||||||
params=types.CallToolRequestParams(
|
params=types.CallToolRequestParams(
|
||||||
|
|
@ -683,6 +695,13 @@ class ClientSession:
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def _resolve_param_headers(self, name: str, arguments: Mapping[str, Any]) -> dict[str, str]:
|
||||||
|
"""`Mcp-Param-*` headers for a `tools/call`, or empty when the tool was never listed."""
|
||||||
|
header_map = self._x_mcp_header_maps.get(name)
|
||||||
|
if header_map is None:
|
||||||
|
return {}
|
||||||
|
return mcp_param_headers(header_map, arguments)
|
||||||
|
|
||||||
async def _validate_tool_result(self, name: str, result: types.CallToolResult) -> None:
|
async def _validate_tool_result(self, name: str, result: types.CallToolResult) -> None:
|
||||||
"""Validate the structured content of a tool result against its output schema."""
|
"""Validate the structured content of a tool result against its output schema."""
|
||||||
if name not in self._tool_output_schemas:
|
if name not in self._tool_output_schemas:
|
||||||
|
|
@ -767,7 +786,12 @@ class ClientSession:
|
||||||
for tool in result.tools:
|
for tool in result.tools:
|
||||||
if (reason := find_invalid_x_mcp_header(tool.input_schema)) is not None:
|
if (reason := find_invalid_x_mcp_header(tool.input_schema)) is not None:
|
||||||
logger.warning("dropping tool %r: invalid x-mcp-header (%s)", tool.name, reason)
|
logger.warning("dropping tool %r: invalid x-mcp-header (%s)", tool.name, reason)
|
||||||
|
# Evict any map cached from a prior valid listing so a stale entry can't
|
||||||
|
# mirror headers for a tool this listing dropped.
|
||||||
|
self._x_mcp_header_maps.pop(tool.name, None)
|
||||||
continue
|
continue
|
||||||
|
# Cache the arg→header map so a later tools/call mirrors it into Mcp-Param-* headers.
|
||||||
|
self._x_mcp_header_maps[tool.name] = x_mcp_header_map(tool.input_schema)
|
||||||
kept.append(tool)
|
kept.append(tool)
|
||||||
result.tools = kept
|
result.tools = kept
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ __all__ = [
|
||||||
"InboundModernRoute",
|
"InboundModernRoute",
|
||||||
"MCP_METHOD_HEADER",
|
"MCP_METHOD_HEADER",
|
||||||
"MCP_NAME_HEADER",
|
"MCP_NAME_HEADER",
|
||||||
|
"MCP_PARAM_HEADER_PREFIX",
|
||||||
"MCP_PROTOCOL_VERSION_HEADER",
|
"MCP_PROTOCOL_VERSION_HEADER",
|
||||||
"NAME_BEARING_METHODS",
|
"NAME_BEARING_METHODS",
|
||||||
"X_MCP_HEADER_KEY",
|
"X_MCP_HEADER_KEY",
|
||||||
|
|
@ -49,6 +50,8 @@ __all__ = [
|
||||||
"decode_header_value",
|
"decode_header_value",
|
||||||
"encode_header_value",
|
"encode_header_value",
|
||||||
"find_invalid_x_mcp_header",
|
"find_invalid_x_mcp_header",
|
||||||
|
"mcp_param_headers",
|
||||||
|
"x_mcp_header_map",
|
||||||
]
|
]
|
||||||
|
|
||||||
MCP_PROTOCOL_VERSION_HEADER: Final = "mcp-protocol-version"
|
MCP_PROTOCOL_VERSION_HEADER: Final = "mcp-protocol-version"
|
||||||
|
|
@ -216,6 +219,56 @@ def find_invalid_x_mcp_header(input_schema: Any) -> str | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
MCP_PARAM_HEADER_PREFIX: Final = "Mcp-Param-"
|
||||||
|
"""Prefix the `x-mcp-header` token is joined to, forming the per-parameter HTTP header name."""
|
||||||
|
|
||||||
|
|
||||||
|
def x_mcp_header_map(input_schema: Any) -> dict[tuple[str, ...], str]:
|
||||||
|
"""Map each property carrying a valid `x-mcp-header` to its annotation token, keyed by property path.
|
||||||
|
|
||||||
|
The key is the chain of `properties` keys from the schema root to the
|
||||||
|
annotated property; a top-level property has a one-element path, a nested
|
||||||
|
one a longer path. Call only on a schema that
|
||||||
|
:func:`find_invalid_x_mcp_header` accepts; an invalid schema yields an
|
||||||
|
undefined subset.
|
||||||
|
"""
|
||||||
|
mapping: dict[tuple[str, ...], str] = {}
|
||||||
|
for path, schema in _walk_schema_positions(input_schema):
|
||||||
|
if path and isinstance(header := schema.get(X_MCP_HEADER_KEY), str):
|
||||||
|
mapping[path] = header
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
def mcp_param_headers(header_map: Mapping[tuple[str, ...], str], arguments: Mapping[str, Any]) -> dict[str, str]:
|
||||||
|
"""Build the `Mcp-Param-*` headers a `tools/call` mirrors from its arguments.
|
||||||
|
|
||||||
|
For each `(path, token)` in `header_map`, read the value at that property
|
||||||
|
path in `arguments` and, when it is present and not `None`, emit
|
||||||
|
`Mcp-Param-<token>` carrying it: `bool` as `true`/`false`, other scalars via
|
||||||
|
`str`, each passed through :func:`encode_header_value` so a non-token value
|
||||||
|
is base64-wrapped. A path that hits a missing key or a non-mapping node is
|
||||||
|
skipped, matching the spec's "omit the header when no value is present".
|
||||||
|
"""
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
for path, token in header_map.items():
|
||||||
|
value = _value_at_path(arguments, path)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
rendered = ("true" if value else "false") if isinstance(value, bool) else str(value)
|
||||||
|
headers[f"{MCP_PARAM_HEADER_PREFIX}{token}"] = encode_header_value(rendered)
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _value_at_path(arguments: Mapping[str, Any], path: tuple[str, ...]) -> Any:
|
||||||
|
"""Read the value at a `properties`-key path in `arguments`, or `None` if any step is missing or non-mapping."""
|
||||||
|
node: Any = arguments
|
||||||
|
for key in path:
|
||||||
|
if not isinstance(node, Mapping):
|
||||||
|
return None
|
||||||
|
node = cast("Mapping[str, Any]", node).get(key)
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
# INTERNAL_ERROR is deliberately unmapped (→ HTTP 200): the spec assigns no status to
|
# INTERNAL_ERROR is deliberately unmapped (→ HTTP 200): the spec assigns no status to
|
||||||
# -32603, and whether handler-origin errors get 5xx is an open S4 question — see TODO(L66).
|
# -32603, and whether handler-origin errors get 5xx is an open S4 question — see TODO(L66).
|
||||||
ERROR_CODE_HTTP_STATUS: Final[Mapping[int, int]] = MappingProxyType(
|
ERROR_CODE_HTTP_STATUS: Final[Mapping[int, int]] = MappingProxyType(
|
||||||
|
|
|
||||||
|
|
@ -3372,6 +3372,19 @@ REQUIREMENTS: dict[str, Requirement] = {
|
||||||
transports=("streamable-http",),
|
transports=("streamable-http",),
|
||||||
note="Only observable over streamable HTTP: headers are derived from the body envelope at the transport seam.",
|
note="Only observable over streamable HTTP: headers are derived from the body envelope at the transport seam.",
|
||||||
),
|
),
|
||||||
|
"client-transport:http:custom-param-headers": Requirement(
|
||||||
|
source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#custom-headers-from-tool-parameters",
|
||||||
|
behavior=(
|
||||||
|
"On a tools/call, a client mirrors each argument annotated with x-mcp-header in the tool's "
|
||||||
|
"inputSchema into an Mcp-Param-<name> header -- string as-is, integer as decimal, boolean as "
|
||||||
|
"true/false, base64-sentinel-wrapped when not header-safe -- omitting null or absent arguments and "
|
||||||
|
"never mirroring unannotated parameters. The schema is taken from the tool's last list_tools entry; "
|
||||||
|
"a tool the client never listed emits no Mcp-Param-* headers."
|
||||||
|
),
|
||||||
|
added_in="2026-07-28",
|
||||||
|
transports=("streamable-http",),
|
||||||
|
note="Only observable over streamable HTTP: headers are derived from the cached tool schema at the seam.",
|
||||||
|
),
|
||||||
"client-transport:http:stateless-ignores-session-id": Requirement(
|
"client-transport:http:stateless-ignores-session-id": Requirement(
|
||||||
source=f"{SPEC_2026_BASE_URL}/basic/transports#stateless-request-headers",
|
source=f"{SPEC_2026_BASE_URL}/basic/transports#stateless-request-headers",
|
||||||
behavior=(
|
behavior=(
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ from mcp_types import (
|
||||||
from mcp_types.version import LATEST_MODERN_VERSION
|
from mcp_types.version import LATEST_MODERN_VERSION
|
||||||
|
|
||||||
from mcp import MCPError
|
from mcp import MCPError
|
||||||
|
from mcp.client.client import Client
|
||||||
from mcp.client.session import ClientSession
|
from mcp.client.session import ClientSession
|
||||||
from mcp.client.streamable_http import streamable_http_client
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
from mcp.server import Server, ServerRequestContext
|
from mcp.server import Server, ServerRequestContext
|
||||||
|
|
@ -388,3 +389,164 @@ async def test_pinned_client_stateless_tools_call_round_trips_against_the_modern
|
||||||
assert len(responses) == len(requests)
|
assert len(responses) == len(requests)
|
||||||
assert all("mcp-session-id" not in r.headers for r in requests)
|
assert all("mcp-session-id" not in r.headers for r in requests)
|
||||||
assert all("mcp-session-id" not in r.headers for r in responses)
|
assert all("mcp-session-id" not in r.headers for r in responses)
|
||||||
|
|
||||||
|
|
||||||
|
_CUSTOM_HEADER_TOOL = Tool(
|
||||||
|
name="run",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"region": {"type": "string", "x-mcp-header": "Region"},
|
||||||
|
"priority": {"type": "integer", "x-mcp-header": "Priority"},
|
||||||
|
"verbose": {"type": "boolean", "x-mcp-header": "Verbose"},
|
||||||
|
"note": {"type": "string", "x-mcp-header": "Note"},
|
||||||
|
"query": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["region"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _custom_header_server() -> Server:
|
||||||
|
"""A server with one tool whose schema annotates four args with `x-mcp-header` and leaves `query` plain."""
|
||||||
|
|
||||||
|
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
|
||||||
|
return ListToolsResult(tools=[_CUSTOM_HEADER_TOOL], ttl_ms=0, cache_scope="public")
|
||||||
|
|
||||||
|
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
|
||||||
|
return CallToolResult(content=[TextContent(text="ok")])
|
||||||
|
|
||||||
|
return Server("custom-headers", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||||
|
|
||||||
|
|
||||||
|
@requirement("client-transport:http:custom-param-headers")
|
||||||
|
async def test_modern_client_mirrors_x_mcp_header_args_into_mcp_param_headers() -> None:
|
||||||
|
"""A tools/call mirrors the tool's `x-mcp-header` arguments into `Mcp-Param-*` headers.
|
||||||
|
|
||||||
|
After `list_tools` caches the tool's annotations, the client renders each annotated argument into
|
||||||
|
its header per the spec's Value Encoding rules: `region` verbatim, `priority` as a decimal, `verbose`
|
||||||
|
as `false`, and the non-ASCII `note` base64-sentinel-wrapped. The unannotated `query` and the omitted
|
||||||
|
`verbose`-sibling stay out of the headers, and every mirrored value remains in the request body. Asserted
|
||||||
|
at the wire because the client never surfaces the outgoing headers.
|
||||||
|
"""
|
||||||
|
requests: list[httpx.Request] = []
|
||||||
|
|
||||||
|
async def on_request(request: httpx.Request) -> None:
|
||||||
|
requests.append(request)
|
||||||
|
|
||||||
|
discover = DiscoverResult(
|
||||||
|
supported_versions=[LATEST_MODERN_VERSION],
|
||||||
|
capabilities=ServerCapabilities(),
|
||||||
|
server_info=Implementation(name="srv", version="0"),
|
||||||
|
)
|
||||||
|
with anyio.fail_after(5):
|
||||||
|
async with (
|
||||||
|
mounted_app(_custom_header_server(), on_request=on_request) as (http, _),
|
||||||
|
Client(
|
||||||
|
streamable_http_client(f"{BASE_URL}/mcp", http_client=http),
|
||||||
|
mode=LATEST_MODERN_VERSION,
|
||||||
|
prior_discover=discover,
|
||||||
|
) as client,
|
||||||
|
):
|
||||||
|
await client.list_tools()
|
||||||
|
await client.call_tool("run", {"region": "us-west1", "priority": 42, "verbose": False, "note": "héllo"})
|
||||||
|
|
||||||
|
call = next(r for r in requests if json.loads(r.content)["method"] == "tools/call")
|
||||||
|
assert {k: v for k, v in call.headers.items() if k.startswith("mcp-param-")} == snapshot(
|
||||||
|
{
|
||||||
|
"mcp-param-region": "us-west1",
|
||||||
|
"mcp-param-priority": "42",
|
||||||
|
"mcp-param-verbose": "false",
|
||||||
|
"mcp-param-note": "=?base64?aMOpbGxv?=",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Mirroring is additive: the arguments are unchanged in the body.
|
||||||
|
assert json.loads(call.content)["params"]["arguments"] == snapshot(
|
||||||
|
{"region": "us-west1", "priority": 42, "verbose": False, "note": "héllo"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@requirement("client-transport:http:custom-param-headers")
|
||||||
|
async def test_modern_client_emits_no_param_headers_for_an_unlisted_tool() -> None:
|
||||||
|
"""A `tools/call` for a tool the client never listed carries no `Mcp-Param-*` headers.
|
||||||
|
|
||||||
|
The spec lets a client that lacks the tool's `inputSchema` send the request without custom headers.
|
||||||
|
The call is made with no prior `list_tools`, so the first `tools/call` POST -- captured before the
|
||||||
|
implicit output-schema `list_tools` runs -- has no cached annotations and emits no `Mcp-Param-*` header.
|
||||||
|
"""
|
||||||
|
requests: list[httpx.Request] = []
|
||||||
|
|
||||||
|
async def on_request(request: httpx.Request) -> None:
|
||||||
|
if json.loads(request.content)["method"] == "tools/call":
|
||||||
|
requests.append(request)
|
||||||
|
|
||||||
|
discover = DiscoverResult(
|
||||||
|
supported_versions=[LATEST_MODERN_VERSION],
|
||||||
|
capabilities=ServerCapabilities(),
|
||||||
|
server_info=Implementation(name="srv", version="0"),
|
||||||
|
)
|
||||||
|
with anyio.fail_after(5):
|
||||||
|
async with (
|
||||||
|
mounted_app(_custom_header_server(), on_request=on_request) as (http, _),
|
||||||
|
Client(
|
||||||
|
streamable_http_client(f"{BASE_URL}/mcp", http_client=http),
|
||||||
|
mode=LATEST_MODERN_VERSION,
|
||||||
|
prior_discover=discover,
|
||||||
|
) as client,
|
||||||
|
):
|
||||||
|
await client.call_tool("run", {"region": "us-west1"})
|
||||||
|
|
||||||
|
assert not any(k.startswith("mcp-param-") for k in requests[0].headers)
|
||||||
|
|
||||||
|
|
||||||
|
@requirement("client-transport:http:custom-param-headers")
|
||||||
|
async def test_modern_client_stops_mirroring_after_a_re_list_drops_the_tool() -> None:
|
||||||
|
"""A re-list that drops a previously valid tool stops mirroring its `x-mcp-header` args.
|
||||||
|
|
||||||
|
The tool is first listed with a valid annotation (so a call mirrors `Mcp-Param-Region`), then re-listed
|
||||||
|
with an invalid annotation -- the modern client drops it and evicts the cached map, so a later `tools/call`
|
||||||
|
by name carries no `Mcp-Param-*` header. Asserted at the wire, where the eviction is observable.
|
||||||
|
"""
|
||||||
|
schema = {"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "Region"}}}
|
||||||
|
bad_schema = {"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}}
|
||||||
|
valid = Tool(name="run", input_schema=schema)
|
||||||
|
invalid = Tool(name="run", input_schema=bad_schema)
|
||||||
|
listings = iter([valid, invalid])
|
||||||
|
|
||||||
|
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
|
||||||
|
return ListToolsResult(tools=[next(listings)], ttl_ms=0, cache_scope="public")
|
||||||
|
|
||||||
|
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
|
||||||
|
return CallToolResult(content=[TextContent(text="ok")])
|
||||||
|
|
||||||
|
server = Server("evict", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||||
|
|
||||||
|
tool_calls: list[httpx.Request] = []
|
||||||
|
|
||||||
|
async def on_request(request: httpx.Request) -> None:
|
||||||
|
if json.loads(request.content)["method"] == "tools/call":
|
||||||
|
tool_calls.append(request)
|
||||||
|
|
||||||
|
discover = DiscoverResult(
|
||||||
|
supported_versions=[LATEST_MODERN_VERSION],
|
||||||
|
capabilities=ServerCapabilities(),
|
||||||
|
server_info=Implementation(name="srv", version="0"),
|
||||||
|
)
|
||||||
|
with anyio.fail_after(5):
|
||||||
|
async with (
|
||||||
|
mounted_app(server, on_request=on_request) as (http, _),
|
||||||
|
Client(
|
||||||
|
streamable_http_client(f"{BASE_URL}/mcp", http_client=http),
|
||||||
|
mode=LATEST_MODERN_VERSION,
|
||||||
|
prior_discover=discover,
|
||||||
|
) as client,
|
||||||
|
):
|
||||||
|
assert [t.name for t in (await client.list_tools()).tools] == ["run"]
|
||||||
|
await client.call_tool("run", {"a": "x"})
|
||||||
|
|
||||||
|
assert [t.name for t in (await client.list_tools()).tools] == []
|
||||||
|
await client.call_tool("run", {"a": "x"})
|
||||||
|
|
||||||
|
before, after = tool_calls
|
||||||
|
assert before.headers.get("mcp-param-region") == "x"
|
||||||
|
assert not any(k.startswith("mcp-param-") for k in after.headers)
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ from mcp.shared.inbound import (
|
||||||
decode_header_value,
|
decode_header_value,
|
||||||
encode_header_value,
|
encode_header_value,
|
||||||
find_invalid_x_mcp_header,
|
find_invalid_x_mcp_header,
|
||||||
|
mcp_param_headers,
|
||||||
|
x_mcp_header_map,
|
||||||
)
|
)
|
||||||
|
|
||||||
CLIENT_INFO = {"name": "t", "version": "0"}
|
CLIENT_INFO = {"name": "t", "version": "0"}
|
||||||
|
|
@ -505,3 +507,70 @@ def test_find_invalid_x_mcp_header_reports_dotted_path_for_nested_property() ->
|
||||||
schema = _schema(outer={"type": "object", "properties": {"r": {"type": "object", "x-mcp-header": "R"}}})
|
schema = _schema(outer={"type": "object", "properties": {"r": {"type": "object", "x-mcp-header": "R"}}})
|
||||||
reason = find_invalid_x_mcp_header(schema)
|
reason = find_invalid_x_mcp_header(schema)
|
||||||
assert reason is not None and "'outer.r'" in reason
|
assert reason is not None and "'outer.r'" in reason
|
||||||
|
|
||||||
|
|
||||||
|
# --- x_mcp_header_map ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_x_mcp_header_map_keys_top_level_and_nested_properties_by_path() -> None:
|
||||||
|
"""Each annotated property maps to its token under its full `properties` path; unannotated props are absent."""
|
||||||
|
schema = _schema(
|
||||||
|
region={"type": "string", "x-mcp-header": "Region"},
|
||||||
|
query={"type": "string"},
|
||||||
|
outer={"type": "object", "properties": {"inner": {"type": "string", "x-mcp-header": "Inner"}}},
|
||||||
|
)
|
||||||
|
assert x_mcp_header_map(schema) == {("region",): "Region", ("outer", "inner"): "Inner"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("input_schema", [None, "not-a-mapping", {"type": "object"}])
|
||||||
|
def test_x_mcp_header_map_empty_for_schemas_without_annotations(input_schema: Any) -> None:
|
||||||
|
assert x_mcp_header_map(input_schema) == {}
|
||||||
|
|
||||||
|
|
||||||
|
# --- mcp_param_headers ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_param_headers_renders_primitive_types_per_spec() -> None:
|
||||||
|
"""String verbatim, integer as decimal, boolean as lowercase `true`/`false`, header named `Mcp-Param-<token>`."""
|
||||||
|
header_map = {("region",): "Region", ("priority",): "Priority", ("verbose",): "Verbose", ("debug",): "Debug"}
|
||||||
|
arguments = {"region": "us-west1", "priority": 42, "verbose": False, "debug": True}
|
||||||
|
assert mcp_param_headers(header_map, arguments) == {
|
||||||
|
"Mcp-Param-Region": "us-west1",
|
||||||
|
"Mcp-Param-Priority": "42",
|
||||||
|
"Mcp-Param-Verbose": "false",
|
||||||
|
"Mcp-Param-Debug": "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("value", "encoded"),
|
||||||
|
[
|
||||||
|
pytest.param("us-west1", "us-west1", id="plain-ascii"),
|
||||||
|
pytest.param("Hello, 世界", "=?base64?SGVsbG8sIOS4lueVjA==?=", id="non-ascii"),
|
||||||
|
pytest.param(" padded ", "=?base64?IHBhZGRlZCA=?=", id="edge-whitespace"),
|
||||||
|
pytest.param("line1\nline2", "=?base64?bGluZTEKbGluZTI=?=", id="control-char"),
|
||||||
|
pytest.param("=?base64?literal?=", "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?=", id="sentinel-lookalike"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_mcp_param_headers_base64_wraps_header_unsafe_strings(value: str, encoded: str) -> None:
|
||||||
|
"""Matches the spec's Value Encoding table: a non-header-safe string is base64-sentinel wrapped."""
|
||||||
|
assert mcp_param_headers({("v",): "Val"}, {"v": value}) == {"Mcp-Param-Val": encoded}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_param_headers_omits_absent_or_null_arguments() -> None:
|
||||||
|
"""A path that hits a missing key or a `None` value emits no header (spec: omit when no value is present)."""
|
||||||
|
header_map = {("present",): "Present", ("missing",): "Missing", ("nulled",): "Nulled"}
|
||||||
|
assert mcp_param_headers(header_map, {"present": "x", "nulled": None}) == {"Mcp-Param-Present": "x"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_param_headers_reads_nested_argument_path() -> None:
|
||||||
|
"""A nested annotated property reads its value at the matching nested `arguments` path."""
|
||||||
|
headers = mcp_param_headers({("outer", "inner"): "Inner"}, {"outer": {"inner": "deep"}})
|
||||||
|
assert headers == {"Mcp-Param-Inner": "deep"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_param_headers_omits_when_nested_path_is_broken() -> None:
|
||||||
|
"""A nested path through a non-mapping or missing intermediate node yields no header."""
|
||||||
|
header_map = {("outer", "inner"): "Inner"}
|
||||||
|
assert mcp_param_headers(header_map, {"outer": "not-a-mapping"}) == {}
|
||||||
|
assert mcp_param_headers(header_map, {}) == {}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue