Skip to content

Commit c4cfebd

Browse files
committed
Support vendor request types in ClientSession.send_request
Request gains a name_param ClassVar naming the wire-params key to mirror into the Mcp-Name header; send_request emits it whenever the stamp has not already set the header, so core NAME_BEARING_METHODS rows win by ordering and a missing value fails loud instead of silently omitting the header. send_request's typing widens to accept any Request[Any, Any] (runtime was always duck-typed), retiring the cast ceremony in the extension and custom-method stories. dispatch_input_request and validate_tool_result are promoted to public ClientSession surface.
1 parent c2cf4ed commit c4cfebd

13 files changed

Lines changed: 408 additions & 36 deletions

File tree

‎docs/advanced/extensions.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,8 @@ The same file's `main()` is the whole client story, both halves of it:
119119
which: `require_client_extension(ctx, ...)` and
120120
`ctx.session.check_client_capability(...)` read the right source on both paths.
121121
* Vendor methods drop one layer to `client.session.send_request(...)`; `Client`
122-
only grows first-class methods for spec verbs. The `cast` is there because
123-
`send_request` is typed against the spec's closed request union.
122+
only grows first-class methods for spec verbs. `send_request` accepts any
123+
`Request` subclass, so the vendor request passes as-is.
124124

125125
### Intercepting `tools/call`
126126

‎docs_src/extensions/tutorial004.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from collections.abc import Sequence
2-
from typing import Any, Literal, cast
2+
from typing import Any, Literal
33

44
import mcp_types as types
55
from pydantic import Field
@@ -53,6 +53,6 @@ def methods(self) -> Sequence[MethodBinding]:
5353
async def main() -> None:
5454
async with Client(mcp, extensions={EXTENSION_ID: {}}) as client:
5555
request = SearchRequest(params=SearchParams(query="mcp", limit=3))
56-
result = await client.session.send_request(cast("types.ClientRequest", request), SearchResult)
56+
result = await client.session.send_request(request, SearchResult)
5757
print(result.items)
5858
# ['mcp-0', 'mcp-1', 'mcp-2']

‎examples/stories/custom_methods/README.md‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,9 @@ uv run python -m stories.custom_methods.client --http
2828
method string is the wire `method`; use a vendor prefix so it can never
2929
collide with a future spec method.
3030
- `client.py` `client.session.send_request(...)` — `Client` only exposes spec
31-
verbs, so vendor methods go through the underlying `ClientSession`. The
32-
`cast("types.ClientRequest", ...)` is needed because `send_request`'s
33-
`request` parameter is currently typed as the closed spec union; widening it
34-
(or adding `Client.send_request`) is tracked for beta.
31+
verbs, so vendor methods go through the underlying `ClientSession`.
32+
`send_request` accepts any `types.Request` subclass, so the vendor request
33+
passes as-is, no cast.
3534

3635
## Caveats
3736

‎examples/stories/custom_methods/client.py‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Send a vendor-prefixed request via the `client.session` escape hatch."""
22

3-
from typing import Literal, cast
3+
from typing import Literal
44

55
import mcp_types as types
66

@@ -26,12 +26,11 @@ async def main(target: Target, *, mode: str = "auto") -> None:
2626
async with Client(target, mode=mode) as client:
2727
# `Client` only exposes spec-defined verbs, so vendor methods have to drop one
2828
# layer to `client.session` today — there is no `Client`-level API for them
29-
# yet, and whether `.session` stays public is undecided. `send_request` is
30-
# typed against the closed `ClientRequest` union, hence the cast; at runtime
31-
# the body only calls `.model_dump()` and the unknown method skips the
32-
# per-spec result-validation registry.
29+
# yet, and whether `.session` stays public is undecided. `send_request`
30+
# accepts any `Request` subclass; the unknown method skips the per-spec
31+
# result-validation registry.
3332
request = SearchRequest(params=SearchParams(query="mcp", limit=3))
34-
result = await client.session.send_request(cast("types.ClientRequest", request), SearchResult)
33+
result = await client.session.send_request(request, SearchResult)
3534
assert result.items == ["mcp-0", "mcp-1", "mcp-2"], result
3635

3736

‎examples/stories/extensions/client.py‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Discover an extension's capability entry, call its tool, then send its vendor method."""
22

3-
from typing import Literal, cast
3+
from typing import Literal
44

55
import mcp_types as types
66
from mcp_types import TextContent
@@ -43,10 +43,9 @@ async def main(target: Target, *, mode: str = "auto") -> None:
4343
assert isinstance(result.content[0], TextContent)
4444
assert result.content[0].text == "mcp-suggestion", result.content[0].text
4545

46-
# Vendor methods drop one layer to `client.session` (see custom_methods/);
47-
# the cast is needed because `send_request` is typed against the spec union.
46+
# Vendor methods drop one layer to `client.session` (see custom_methods/).
4847
request = SearchRequest(params=SearchParams(query="mcp", limit=3))
49-
found = await client.session.send_request(cast("types.ClientRequest", request), SearchResult)
48+
found = await client.session.send_request(request, SearchResult)
5049
assert found.items == ["mcp-0", "mcp-1", "mcp-2"], found
5150

5251

‎src/mcp-types/mcp_types/_types.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from __future__ import annotations
1010

11-
from typing import Annotated, Any, Final, Generic, Literal, TypeAlias, TypeVar
11+
from typing import Annotated, Any, ClassVar, Final, Generic, Literal, TypeAlias, TypeVar
1212

1313
from pydantic import (
1414
BaseModel,
@@ -128,6 +128,12 @@ class Request(MCPModel, Generic[RequestParamsT, MethodT]):
128128
method: MethodT
129129
params: RequestParamsT
130130

131+
name_param: ClassVar[str | None] = None
132+
"""Wire-params key mirrored into the `Mcp-Name` header on sends (SEP-2243
133+
family; SEP-2663 requires it for tasks/*). The request type declares; the
134+
host emits. Subclasses override by bare assignment (`name_param = "taskId"`)
135+
— re-annotating as `ClassVar[str]` trips pyright's ClassVar invariance."""
136+
131137

132138
class PaginatedRequest(Request[PaginatedRequestParams | None, MethodT], Generic[MethodT]):
133139
"""Base class for paginated requests, matching the schema's PaginatedRequest interface."""

‎src/mcp/client/client.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -715,7 +715,7 @@ async def _drive_input_required(
715715

716716
async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData:
717717
ctx = ClientRequestContext(session=session, request_id=key, meta=req.params.meta if req.params else None)
718-
return await session._dispatch_input_request(ctx, req) # pyright: ignore[reportPrivateUsage]
718+
return await session.dispatch_input_request(ctx, req)
719719

720720
return await run_input_required_driver(
721721
first, dispatch=dispatch, retry=retry, max_rounds=self.input_required_max_rounds

‎src/mcp/client/session.py‎

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ async def __aexit__(
304304

305305
async def send_request(
306306
self,
307-
request: types.ClientRequest,
307+
request: types.ClientRequest | types.Request[Any, Any],
308308
result_type: type[ReceiveResultT] | TypeAdapter[ReceiveResultT],
309309
request_read_timeout_seconds: float | None = None,
310310
metadata: ClientMessageMetadata | None = None,
@@ -318,11 +318,22 @@ async def send_request(
318318
Raises:
319319
MCPError: Error response, read timeout, or connection closed.
320320
RuntimeError: Called before entering the context manager.
321+
ValueError: The request type declares `name_param` but the params
322+
carry no string value under that key for the `Mcp-Name` header.
321323
"""
322324
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
323325
method: str = data["method"]
324326
opts: CallOptions = {}
325327
self._stamp(data, opts)
328+
# Presence-keyed so the stamp's NAME_BEARING_METHODS rows win by ordering;
329+
# a missing/non-string name fails loud rather than omitting a MUST header.
330+
headers = opts.setdefault("headers", {})
331+
if (key := type(request).name_param) is not None and MCP_NAME_HEADER not in headers:
332+
params_data: dict[str, Any] = data.get("params") or {}
333+
name = params_data.get(key)
334+
if not isinstance(name, str):
335+
raise ValueError(f"{method} requires params[{key!r}] for Mcp-Name")
336+
headers[MCP_NAME_HEADER] = encode_header_value(name)
326337
timeout = (
327338
request_read_timeout_seconds
328339
if request_read_timeout_seconds is not None
@@ -766,7 +777,7 @@ async def call_tool(
766777
)
767778

768779
if isinstance(result, types.CallToolResult) and not result.is_error:
769-
await self._validate_tool_result(name, result)
780+
await self.validate_tool_result(name, result)
770781

771782
if isinstance(result, types.InputRequiredResult) and not allow_input_required:
772783
raise _input_required_unexpected("call_tool")
@@ -779,8 +790,17 @@ def _resolve_param_headers(self, name: str, arguments: Mapping[str, Any]) -> dic
779790
return {}
780791
return mcp_param_headers(header_map, arguments)
781792

782-
async def _validate_tool_result(self, name: str, result: types.CallToolResult) -> None:
783-
"""Validate the structured content of a tool result against its output schema."""
793+
async def validate_tool_result(self, name: str, result: types.CallToolResult) -> None:
794+
"""Revalidate a `CallToolResult` against the tool's declared output schema.
795+
796+
Fetches the tool listing first when `name` has no cached schema. Tools
797+
without an output schema (or not listed by the server) pass without
798+
validation.
799+
800+
Raises:
801+
RuntimeError: The result's structured content is missing or does
802+
not conform to the tool's output schema.
803+
"""
784804
if name not in self._tool_output_schemas:
785805
# refresh output schema cache
786806
await self.list_tools()
@@ -970,7 +990,7 @@ async def _on_request(
970990
ctx = ClientRequestContext(
971991
session=self, request_id=dctx.request_id, meta=request.params.meta if request.params else None
972992
)
973-
response = await self._dispatch_input_request(ctx, request)
993+
response = await self.dispatch_input_request(ctx, request)
974994
client_response = ClientResponse.validate_python(response)
975995
if isinstance(client_response, types.ErrorData):
976996
raise MCPError.from_error_data(client_response)
@@ -982,16 +1002,19 @@ async def _on_request(
9821002
raise MCPError(code=INTERNAL_ERROR, message="Client callback returned an invalid result") from None
9831003
return dumped
9841004

985-
async def _dispatch_input_request(
986-
self, ctx: ClientRequestContext, req: types.InputRequest
1005+
async def dispatch_input_request(
1006+
self, ctx: ClientRequestContext, request: types.InputRequest
9871007
) -> types.InputResponse | types.ErrorData:
988-
"""Route a server-initiated input request to the matching constructor callback.
1008+
"""Route an input request through the client's callback table.
9891009
9901010
Shared by the legacy server→client RPC path (`_on_request`) and the
9911011
2026-07-28 multi-round-trip driver, which dispatches the embedded
9921012
`InputRequiredResult.input_requests` through the same callbacks.
1013+
1014+
Returns the callback's `InputResponse`, or `ErrorData` when the
1015+
callback declines — the refusal path; callers must handle that arm.
9931016
"""
994-
match req:
1017+
match request:
9951018
case types.CreateMessageRequest(params=p):
9961019
return await self._sampling_callback(ctx, p)
9971020
case types.ElicitRequest(params=p):

0 commit comments

Comments
 (0)