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
30 changes: 28 additions & 2 deletions .github/actions/conformance/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,34 @@ async def run_http_invalid_tool_headers(server_url: str) -> None:
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")
async def run_elicitation_defaults(server_url: str) -> None:
"""Connect with elicitation callback that applies schema defaults."""
Expand Down Expand Up @@ -526,8 +554,6 @@ def main() -> None:
elif scenario.startswith("auth/"):
asyncio.run(run_auth_code_client(server_url))
else:
# Unhandled scenarios:
# - http-custom-headers (SEP-2243 / S8: Mcp-Param-* emission)
print(f"Unknown scenario: {scenario}", file=sys.stderr)
sys.exit(1)
else:
Expand Down
4 changes: 1 addition & 3 deletions .github/actions/conformance/expected-failures.2026-07-28.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@
# milestone.

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
# 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
Expand Down
5 changes: 0 additions & 5 deletions .github/actions/conformance/expected-failures.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,6 @@
# on stale entries), so the baseline burns down per milestone.

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 ---
# SEP-990 (enterprise-managed authorization extension): no fixture handler /
# client support for the token-exchange + JWT bearer flow.
Expand Down
37 changes: 36 additions & 1 deletion .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,16 @@ env:
# Use a published version, e.g. @modelcontextprotocol/conformance@0.2.0-alpha.7.
# Bump deliberately and reconcile both
# .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"
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Comment on lines +21 to +29

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] URL-based CONFORMANCE_PKG reintroduced without the SHA256 fetch-and-verify step

This repins CONFORMANCE_PKG to a pkg.pr.new URL but doesn't restore the CONFORMANCE_PKG_SHA256 env var and "Fetch and verify conformance harness" step that accompanied the previous URL-based pin (removed in #2974 only because it switched to a registry spec). npx --yes "$CONFORMANCE_PKG" now executes an unverified, mutable remote tarball in CI — a regression from the repo's own supply-chain posture for this exact pattern.

Either restore the SHA256 + fetch-and-verify step from pre-#2974 (curl → sha256sum -c → repoint to file:/tmp/conformance.tgz), or keep the @0.2.0-alpha.7 registry pin and leave http-custom-headers in expected-failures until conformance#371 publishes.

AI Disclaimer

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch - restored the SHA256 + fetch-and-verify step from pre-#2974 (curl → sha256sum -c → repoint to file:/tmp/conformance.tgz) and pinned CONFORMANCE_PKG_SHA256 to the #371 tarball digest. So CI verifies the mutable URL rather than trusting it, and a re-push to #371 fails the digest check loudly. Kept the preview pin (not the alpha.7 + expected-failures option) so the scenario runs green now; will repin to the published release once #371 ships and drop the SHA + verify step then.

CONFORMANCE_PKG_SHA256: "9d8b25874d55e304b006cbaa066571773582f5828143c53a2b8a6830f203ca1d"

jobs:
server-conformance:
Expand All @@ -34,6 +43,19 @@ jobs:
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
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
- name: Run server conformance (active suite)
run: >-
Expand Down Expand Up @@ -65,6 +87,19 @@ jobs:
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
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
- name: Run client conformance (all suite)
# The harness runs all scenarios via unbounded Promise.all; with 40
Expand Down
4 changes: 4 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,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=`.

### `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`

The `McpError` exception class has been renamed to `MCPError` for consistent naming with the MCP acronym style used throughout the SDK.
Expand Down
30 changes: 27 additions & 3 deletions src/mcp/client/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
NAME_BEARING_METHODS,
encode_header_value,
find_invalid_x_mcp_header,
mcp_param_headers,
x_mcp_header_map,
)
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.message import ClientMessageMetadata, SessionMessage
Expand Down Expand Up @@ -68,7 +70,10 @@ def stamp(data: dict[str, Any], opts: CallOptions) -> None:


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]:
def stamp(data: dict[str, Any], opts: CallOptions) -> None:
params = data.setdefault("params", {})
Expand All @@ -83,6 +88,8 @@ def stamp(data: dict[str, Any], opts: CallOptions) -> None:
name_key = NAME_BEARING_METHODS.get(data["method"])
if name_key is not None and isinstance(name := params.get(name_key), str):
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

Expand Down Expand Up @@ -215,6 +222,7 @@ def __init__(
self._logging_callback = logging_callback or _default_logging_callback
self._message_handler = message_handler or _default_message_handler
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._discover_result: types.DiscoverResult | None = None
self._negotiated_version: str | None = None
Expand Down Expand Up @@ -393,7 +401,7 @@ def adopt(self, result: types.InitializeResult | types.DiscoverResult) -> None:
)
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)
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._initialize_result = None
self._negotiated_version = mutual[-1]
Expand Down Expand Up @@ -646,6 +654,11 @@ async def call_tool(
) -> types.CallToolResult | types.InputRequiredResult:
"""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:
input_responses: Responses to a prior `InputRequiredResult.input_requests`.
request_state: Opaque state echoed from a prior `InputRequiredResult`.
Expand All @@ -657,7 +670,6 @@ async def call_tool(
RuntimeError: If the server returns an `InputRequiredResult` and
``allow_input_required`` is ``False``.
"""

result = await self.send_request(
types.CallToolRequest(
params=types.CallToolRequestParams(
Expand All @@ -683,6 +695,13 @@ async def call_tool(
)
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:
"""Validate the structured content of a tool result against its output schema."""
if name not in self._tool_output_schemas:
Expand Down Expand Up @@ -767,7 +786,12 @@ async def list_tools(self, *, params: types.PaginatedRequestParams | None = None
for tool in result.tools:
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)
# 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
# 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)
result.tools = kept

Expand Down
53 changes: 53 additions & 0 deletions src/mcp/shared/inbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,16 @@
"InboundModernRoute",
"MCP_METHOD_HEADER",
"MCP_NAME_HEADER",
"MCP_PARAM_HEADER_PREFIX",
"MCP_PROTOCOL_VERSION_HEADER",
"NAME_BEARING_METHODS",
"X_MCP_HEADER_KEY",
"classify_inbound_request",
"decode_header_value",
"encode_header_value",
"find_invalid_x_mcp_header",
"mcp_param_headers",
"x_mcp_header_map",
]

MCP_PROTOCOL_VERSION_HEADER: Final = "mcp-protocol-version"
Expand Down Expand Up @@ -206,6 +209,56 @@ def find_invalid_x_mcp_header(input_schema: Any) -> str | 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
# -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(
Expand Down
13 changes: 13 additions & 0 deletions tests/interaction/_requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -3372,6 +3372,19 @@ def __post_init__(self) -> None:
transports=("streamable-http",),
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(
source=f"{SPEC_2026_BASE_URL}/basic/transports#stateless-request-headers",
behavior=(
Expand Down
Loading
Loading