|
| 1 | +"""`docs/advanced/header-parameters.md`: every claim the page makes, proved against the real SDK.""" |
| 2 | + |
| 3 | +from collections.abc import AsyncIterator |
| 4 | +from contextlib import asynccontextmanager |
| 5 | +from typing import Annotated, Literal |
| 6 | + |
| 7 | +import httpx2 |
| 8 | +import pytest |
| 9 | +from mcp_types import HEADER_MISMATCH, ListToolsResult, PaginatedRequestParams |
| 10 | +from pydantic import Field, WithJsonSchema |
| 11 | +from starlette.applications import Starlette |
| 12 | + |
| 13 | +from docs_src.header_parameters import tutorial001, tutorial002 |
| 14 | +from mcp import Client |
| 15 | +from mcp.client.streamable_http import streamable_http_client |
| 16 | +from mcp.server import MCPServer, Server, ServerRequestContext |
| 17 | +from mcp.server.mcpserver.exceptions import InvalidSignature |
| 18 | + |
| 19 | +# See test_index.py for why this is a per-module mark and not a conftest hook. |
| 20 | +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] |
| 21 | + |
| 22 | +URL = "http://localhost:8000/mcp" |
| 23 | +ARGUMENTS = {"title": "Dune", "region": "eu"} |
| 24 | + |
| 25 | + |
| 26 | +@asynccontextmanager |
| 27 | +async def check_stock_over_http( |
| 28 | + app: Starlette, mode: Literal["auto", "legacy"] = "auto" |
| 29 | +) -> AsyncIterator[tuple[httpx2.AsyncClient, httpx2.Request]]: |
| 30 | + """List the tools and call `check_stock` over in-process HTTP; yield the HTTP client and the call's request.""" |
| 31 | + requests: list[httpx2.Request] = [] |
| 32 | + |
| 33 | + async def record(request: httpx2.Request) -> None: |
| 34 | + requests.append(request) |
| 35 | + |
| 36 | + async with ( |
| 37 | + app.router.lifespan_context(app), |
| 38 | + httpx2.ASGITransport(app) as transport, |
| 39 | + httpx2.AsyncClient(transport=transport, event_hooks={"request": [record]}) as http, |
| 40 | + Client(streamable_http_client(URL, http_client=http), mode=mode) as client, |
| 41 | + ): |
| 42 | + await client.list_tools() |
| 43 | + result = await client.call_tool("check_stock", ARGUMENTS) |
| 44 | + assert not result.is_error |
| 45 | + yield http, next(request for request in requests if b'"tools/call"' in request.content) |
| 46 | + |
| 47 | + |
| 48 | +@pytest.mark.parametrize( |
| 49 | + "app", [tutorial001.mcp.streamable_http_app(), tutorial002.app], ids=["tutorial001", "tutorial002"] |
| 50 | +) |
| 51 | +async def test_a_2026_http_client_sends_the_marked_argument_as_a_header_as_well(app: Starlette) -> None: |
| 52 | + """Both tutorials: `region` travels as `Mcp-Param-Region` and stays in the body; `title` is body only.""" |
| 53 | + async with check_stock_over_http(app) as (_, call): |
| 54 | + assert {k: v for k, v in call.headers.items() if k.startswith("mcp-param-")} == {"mcp-param-region": "eu"} |
| 55 | + assert b'"region":"eu"' in call.content |
| 56 | + |
| 57 | + |
| 58 | +async def test_a_call_whose_header_and_body_disagree_is_rejected() -> None: |
| 59 | + """tutorial001: the client's own request, replayed with a different `Mcp-Param-Region`, is a 400.""" |
| 60 | + async with check_stock_over_http(tutorial001.mcp.streamable_http_app()) as (http, call): |
| 61 | + tampered = await http.post(URL, content=call.content, headers={**call.headers, "mcp-param-region": "us"}) |
| 62 | + assert tampered.status_code == 400 |
| 63 | + assert tampered.json()["error"]["code"] == HEADER_MISMATCH |
| 64 | + |
| 65 | + |
| 66 | +async def test_a_legacy_http_connection_ignores_the_annotation() -> None: |
| 67 | + """tutorial001: before 2026-07-28 the same call succeeds and carries no `Mcp-Param-*` header.""" |
| 68 | + async with check_stock_over_http(tutorial001.mcp.streamable_http_app(), mode="legacy") as (_, call): |
| 69 | + assert not [name for name in call.headers if name.startswith("mcp-param-")] |
| 70 | + |
| 71 | + |
| 72 | +async def test_a_connection_that_is_not_http_ignores_the_annotation() -> None: |
| 73 | + """tutorial001: in memory there are no headers to send, and the call succeeds all the same.""" |
| 74 | + async with Client(tutorial001.mcp) as client: |
| 75 | + assert client.protocol_version == "2026-07-28" |
| 76 | + result = await client.call_tool("check_stock", ARGUMENTS) |
| 77 | + assert result.structured_content == {"result": "Dune: 3 copies in eu."} |
| 78 | + |
| 79 | + |
| 80 | +async def test_int_and_bool_arguments_can_be_marked() -> None: |
| 81 | + """`str` is tutorial001; `int` and `bool` register too, and a 2026-07-28 client keeps the tool.""" |
| 82 | + mcp = MCPServer("Bookshop") |
| 83 | + |
| 84 | + @mcp.tool() |
| 85 | + def reserve( |
| 86 | + copies: Annotated[int, Field(json_schema_extra={"x-mcp-header": "Copies"})], |
| 87 | + gift: Annotated[bool, Field(json_schema_extra={"x-mcp-header": "Gift"})], |
| 88 | + ) -> None: |
| 89 | + """Never called: registering and listing it is the claim.""" |
| 90 | + |
| 91 | + async with Client(mcp) as client: |
| 92 | + assert [tool.name for tool in (await client.list_tools()).tools] == ["reserve"] |
| 93 | + |
| 94 | + |
| 95 | +async def test_any_other_type_is_refused_when_the_tool_is_registered() -> None: |
| 96 | + """A marked `list[str]` raises `InvalidSignature` from the decorator, before any client connects.""" |
| 97 | + mcp = MCPServer("Bookshop") |
| 98 | + with pytest.raises(InvalidSignature): |
| 99 | + |
| 100 | + @mcp.tool() |
| 101 | + def check_stock(regions: Annotated[list[str], Field(json_schema_extra={"x-mcp-header": "Regions"})]) -> None: |
| 102 | + """Never called: the decoration itself is what raises.""" |
| 103 | + |
| 104 | + assert await mcp.list_tools() == [] |
| 105 | + |
| 106 | + |
| 107 | +async def test_a_plain_optional_argument_is_refused_and_the_spelled_out_schema_registers() -> None: |
| 108 | + """`str | None` has no single `type`, so it is refused; the page's `WithJsonSchema` spelling is kept.""" |
| 109 | + mcp = MCPServer("Bookshop") |
| 110 | + with pytest.raises(InvalidSignature): |
| 111 | + |
| 112 | + @mcp.tool() |
| 113 | + def refused(region: Annotated[str | None, Field(json_schema_extra={"x-mcp-header": "Region"})] = None) -> None: |
| 114 | + """Never called: the decoration itself is what raises.""" |
| 115 | + |
| 116 | + @mcp.tool() |
| 117 | + def check_stock( |
| 118 | + region: Annotated[str | None, WithJsonSchema({"type": "string", "x-mcp-header": "Region"})] = None, |
| 119 | + ) -> str: |
| 120 | + """Count the copies of a book in one region's warehouses.""" |
| 121 | + return f"3 copies in {region}." |
| 122 | + |
| 123 | + async with Client(mcp) as client: |
| 124 | + assert [tool.name for tool in (await client.list_tools()).tools] == ["check_stock"] |
| 125 | + result = await client.call_tool("check_stock", {}) |
| 126 | + assert result.structured_content == {"result": "3 copies in None."} |
| 127 | + |
| 128 | + |
| 129 | +async def test_the_low_level_server_serves_an_invalid_annotation_and_a_2026_client_leaves_the_tool_out() -> None: |
| 130 | + """tutorial002's tool with `region` turned into an array: a legacy client is shown it, a 2026-07-28 one is not.""" |
| 131 | + properties = {"region": {"type": "array", "x-mcp-header": "Region"}} |
| 132 | + invalid = tutorial002.CHECK_STOCK.model_copy(update={"input_schema": {"type": "object", "properties": properties}}) |
| 133 | + |
| 134 | + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: |
| 135 | + return ListToolsResult(tools=[invalid]) |
| 136 | + |
| 137 | + server = Server("Bookshop", on_list_tools=list_tools) |
| 138 | + async with Client(server, mode="legacy") as legacy: |
| 139 | + assert [tool.name for tool in (await legacy.list_tools()).tools] == ["check_stock"] |
| 140 | + async with Client(server) as modern: |
| 141 | + assert modern.protocol_version == "2026-07-28" |
| 142 | + assert (await modern.list_tools()).tools == [] |
0 commit comments