Skip to content

Commit debec20

Browse files
committed
Extensions intercept tools/call at the handler layer
A short-circuiting Extension.intercept_tool_call previously returned from the middleware chain, above the runner's outbound envelope pass, so its result skipped the per-version result sieve and the 2026-era serverInfo _meta stamp - and extension authors had no way to add the stamp themselves. Compose the interceptor chain around the tools/call handler instead of appending a middleware: a short-circuited result is now serialized exactly like a handler result, and call_next hands interceptors the handler's domain result rather than the wire dict. Also: pin version="2.0.0" on the lowlevel structured-output tutorial server so the page's fenced result is reproducible (and prove the fence verbatim in its test), document middleware envelope ownership in the middleware and migration pages, and update two stale docstrings from the envelope refactor.
1 parent 1bbc469 commit debec20

9 files changed

Lines changed: 85 additions & 61 deletions

File tree

docs/advanced/middleware.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ In increasing order of how much you should hesitate:
6363
`initialize`: the result the client gets back is built from your rewritten params, but the
6464
server commits its connection state from the original wire params. The two sides can finish
6565
the handshake disagreeing about what they negotiated.
66+
* **Answer.** Return a result without calling `call_next(ctx)` and it goes to the client as
67+
your response. `call_next` hands you the finished wire form, and the pipeline never patches
68+
what you return, so the whole envelope is yours: on a 2026-era connection that includes the
69+
`serverInfo` `_meta` stamp, which the SDK adds to handler results but not to yours.
6670

6771
!!! check
6872
`initialize` is one of the things middleware wraps, and it is the *only* hook you get

docs/migration.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,8 @@ What changed in the SDK:
486486
so on). Pass `include_server_info=False` to `Server(...)` or `MCPServer(...)`
487487
to turn it off. A `serverInfo` value your handler already set in `_meta` is
488488
never overwritten. Handshake-era responses are unchanged, and notifications
489-
and error responses are never stamped.
489+
and error responses are never stamped. A middleware that answers a request
490+
itself (without `call_next`) owns its result envelope, stamp included.
490491
- `client.server_info` and `session.server_info` are `Implementation | None`
491492
on 2026-era connections: identity is optional on the wire, so a server that
492493
does not stamp it reads as `None`. Handshake-era connections still always

docs_src/lowlevel/tutorial003.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,4 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) ->
3838
)
3939

4040

41-
server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool)
41+
server = Server("Bookshop", version="2.0.0", on_list_tools=list_tools, on_call_tool=call_tool)

src/mcp/server/extension.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from mcp_types.methods import SPEC_CLIENT_METHODS
2828
from pydantic import BaseModel
2929

30-
from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext
30+
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
3131

3232
# Re-exported from `mcp.shared.extension` (shared with the client surface) for existing importers.
3333
from mcp.shared.extension import validate_extension_identifier as validate_extension_identifier
@@ -144,30 +144,34 @@ async def intercept_tool_call(
144144
145145
Override to short-circuit (return a result without calling `call_next`)
146146
or to observe the call. `params` is the validated `tools/call` params;
147-
`call_next(ctx)` runs the rest of the chain and the real handler.
147+
`call_next(ctx)` runs the rest of the chain and the real handler, and
148+
returns the handler's domain result. Interceptors run at the handler
149+
layer: whatever they return is serialized like any handler result,
150+
including the 2026-era `serverInfo` `_meta` stamp.
148151
"""
149152
return await call_next(ctx)
150153

151154

152-
def compose_tool_call_interceptor(extensions: Sequence[Extension]) -> ServerMiddleware[Any]:
153-
"""Fold every extension's `intercept_tool_call` into one `ServerMiddleware`.
155+
def compose_tool_call_handler(extensions: Sequence[Extension], handler: RequestHandler) -> RequestHandler:
156+
"""Fold every extension's `intercept_tool_call` around the `tools/call` handler.
154157
155-
The returned middleware nests the interceptors (first extension outermost)
156-
and is a no-op for any method other than `tools/call`. It validates the
157-
`tools/call` params once and threads them to each interceptor.
158+
The returned handler nests the interceptors (first extension outermost) and
159+
replaces the plain `tools/call` registration. Interception happens at the
160+
handler layer, below the runner's outbound envelope pass, so a
161+
short-circuiting interceptor's result is sieved and stamped exactly like
162+
the wrapped handler's would be.
158163
"""
159164

160-
async def middleware(ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult:
161-
if ctx.method != "tools/call":
162-
return await call_next(ctx)
163-
params = CallToolRequestParams.model_validate({} if ctx.params is None else ctx.params, by_name=False)
165+
async def wrapped(ctx: ServerRequestContext[Any, Any], params: CallToolRequestParams) -> HandlerResult:
166+
async def innermost(inner_ctx: ServerRequestContext[Any, Any]) -> HandlerResult:
167+
return await handler(inner_ctx, params)
164168

165-
chain = call_next
169+
chain: CallNext = innermost
166170
for extension in reversed(extensions):
167171
chain = _bind_interceptor(extension, params, chain)
168172
return await chain(ctx)
169173

170-
return middleware
174+
return wrapped
171175

172176

173177
def _bind_interceptor(extension: Extension, params: CallToolRequestParams, call_next: CallNext) -> CallNext:

src/mcp/server/mcpserver/server.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
Extension,
6565
MethodBinding,
6666
RequestHandler,
67-
compose_tool_call_interceptor,
67+
compose_tool_call_handler,
6868
validate_extension_identifier,
6969
)
7070
from mcp.server.lowlevel.helper_types import ReadResourceContents
@@ -230,8 +230,9 @@ def __init__(
230230
# We need to create a Lifespan type that is a generic on the server type, like Starlette does.
231231
lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan), # type: ignore
232232
)
233-
# Ordering: inside OpenTelemetry (spans record the sealed wire form),
234-
# outside extension interceptors (extensions see plaintext).
233+
# Ordering: inside OpenTelemetry (spans record the sealed wire form).
234+
# Extension interceptors run at the handler layer, inside this
235+
# boundary, so they see plaintext.
235236
if request_state_security is None:
236237
security = RequestStateSecurity.ephemeral()
237238
else:
@@ -336,13 +337,20 @@ def _apply_extension(self, extension: Extension) -> None:
336337
self._lowlevel_server.extensions[extension.identifier] = extension.settings()
337338

338339
def _install_extension_interceptor(self) -> None:
339-
"""Compose every extension's `tools/call` interceptor into one middleware.
340+
"""Wrap the `tools/call` handler with every extension's interceptor.
340341
341342
Installed only when at least one extension overrides `intercept_tool_call`,
342-
so a server with purely additive extensions adds no middleware.
343+
so a server with purely additive extensions keeps the bare handler. The
344+
chain wraps the handler itself, below the runner's outbound envelope
345+
pass, so a short-circuiting interceptor's result is sieved and stamped
346+
exactly like a handler result.
343347
"""
344348
if any(type(e).intercept_tool_call is not Extension.intercept_tool_call for e in self._extensions):
345-
self._lowlevel_server.middleware.append(compose_tool_call_interceptor(self._extensions))
349+
self._lowlevel_server.add_request_handler(
350+
"tools/call",
351+
CallToolRequestParams,
352+
compose_tool_call_handler(self._extensions, self._handle_call_tool),
353+
)
346354

347355
@overload
348356
def run(self, transport: Literal["stdio"] = ...) -> None: ...

src/mcp/server/runner.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -370,8 +370,9 @@ def _stamp_server_info(self, version: str, result: dict[str, Any]) -> dict[str,
370370
371371
A handler-authored value wins, a non-mapping `_meta` is the handler's
372372
to own, and handshake-era results are never stamped. `result` is
373-
pipeline-owned (`_dump_result` copies), but `_meta` may still be the
374-
handler's object, so the stamp replaces it rather than writing into it.
373+
pipeline-owned (`_dump_result` copies dicts; the spec-method sieve
374+
re-dumps), but `_meta` may still be the handler's object, so the stamp
375+
replaces it rather than writing into it.
375376
"""
376377
if not self.server.include_server_info or version not in MODERN_PROTOCOL_VERSIONS:
377378
return result

tests/docs_src/test_lowlevel.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,17 @@ async def test_output_schema_and_structured_content_are_both_yours_to_build() ->
8787
}
8888
)
8989
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
90-
assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune'.")]
91-
assert result.structured_content == {"matches": 3, "query": "dune"}
90+
# The page shows this exact payload; tutorial003 pins `version="2.0.0"` so
91+
# the identity stamp is deterministic and the fence is proved verbatim.
92+
assert result.model_dump(by_alias=True, exclude_none=True) == snapshot(
93+
{
94+
"_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}},
95+
"content": [{"type": "text", "text": "Found 3 books matching 'dune'."}],
96+
"structuredContent": {"matches": 3, "query": "dune"},
97+
"isError": False,
98+
"resultType": "complete",
99+
}
100+
)
92101

93102

94103
async def test_the_client_checks_the_schema_you_promised() -> None:

tests/server/mcpserver/test_extension.py

Lines changed: 30 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,19 @@
22
33
These exercise the closed set of extension contribution kinds - tools,
44
resources, request methods, and the single `tools/call` interceptor - through
5-
the highest-level public surface (in-memory `Client`), plus the
6-
`compose_tool_call_interceptor` helper directly.
5+
the highest-level public surface (in-memory `Client`).
76
"""
87

9-
from typing import Any, Literal, cast
8+
from importlib.metadata import version
9+
from typing import Any, Literal
1010

1111
import mcp_types as types
1212
import pytest
1313
from inline_snapshot import snapshot
1414
from mcp_types import (
1515
METHOD_NOT_FOUND,
1616
MISSING_REQUIRED_CLIENT_CAPABILITY,
17+
SERVER_INFO_META_KEY,
1718
CallToolResult,
1819
TextContent,
1920
)
@@ -26,7 +27,6 @@
2627
MethodBinding,
2728
ResourceBinding,
2829
ToolBinding,
29-
compose_tool_call_interceptor,
3030
)
3131
from mcp.server.mcpserver import Context, MCPServer, require_client_extension
3232
from mcp.server.mcpserver.resources import TextResource
@@ -222,23 +222,28 @@ async def test_short_circuiting_interceptor_replaces_tool_result() -> None:
222222
assert result == snapshot(CallToolResult(content=[TextContent(text="intercepted")]))
223223

224224

225-
def test_plain_extension_installs_no_tool_call_interceptor() -> None:
226-
"""SDK-defined: an extension that does not override `intercept_tool_call` adds no
227-
middleware - the composed interceptor exists only when at least one extension
228-
overrides it."""
229-
baseline = len(MCPServer("test")._lowlevel_server.middleware)
225+
def test_plain_extension_leaves_the_tool_call_handler_bare() -> None:
226+
"""SDK-defined: an extension that does not override `intercept_tool_call` leaves
227+
`tools/call` registered as the server's own handler - the interceptor chain is
228+
composed only when at least one extension overrides it."""
230229
server = MCPServer("test", extensions=[_AdditiveExt()])
231230

232-
assert len(server._lowlevel_server.middleware) == baseline
231+
entry = server._lowlevel_server.get_request_handler("tools/call")
232+
assert entry is not None
233+
assert entry.handler == server._handle_call_tool
233234

234235

235-
def test_overriding_extension_installs_one_tool_call_interceptor() -> None:
236-
"""SDK-defined: an extension that overrides `intercept_tool_call` composes exactly
237-
one additional `tools/call` middleware."""
236+
def test_overriding_extension_wraps_the_tool_call_handler() -> None:
237+
"""SDK-defined: an extension that overrides `intercept_tool_call` re-registers
238+
`tools/call` with the interceptor chain wrapped around the server's own handler,
239+
and installs no middleware."""
238240
baseline = len(MCPServer("test")._lowlevel_server.middleware)
239241
server = MCPServer("test", extensions=[_ReplacingExt()])
240242

241-
assert len(server._lowlevel_server.middleware) == baseline + 1
243+
entry = server._lowlevel_server.get_request_handler("tools/call")
244+
assert entry is not None
245+
assert entry.handler != server._handle_call_tool
246+
assert len(server._lowlevel_server.middleware) == baseline
242247

243248

244249
async def test_default_interceptor_passes_through_alongside_an_overriding_one() -> None:
@@ -255,7 +260,7 @@ async def test_default_interceptor_passes_through_alongside_an_overriding_one()
255260

256261

257262
async def test_interceptors_run_in_registration_order_with_threaded_params() -> None:
258-
"""SDK-defined: `compose_tool_call_interceptor` nests extensions first-outermost, so
263+
"""SDK-defined: `compose_tool_call_handler` nests extensions first-outermost, so
259264
two passing-through interceptors record in registration order, each seeing the
260265
validated `tools/call` params (the real tool name)."""
261266
log: list[tuple[str, str]] = []
@@ -271,26 +276,18 @@ async def test_interceptors_run_in_registration_order_with_threaded_params() ->
271276
assert log == [("com.example/first", "echo"), ("com.example/second", "echo")]
272277

273278

274-
async def test_compose_tool_call_interceptor_passes_through_non_tools_call() -> None:
275-
"""SDK-defined: the composed middleware is a no-op for any method other than
276-
`tools/call` - it forwards to `call_next` without touching the interceptors."""
277-
sentinel = types.EmptyResult()
278-
279-
async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult:
280-
return sentinel
281-
282-
middleware = compose_tool_call_interceptor([_ReplacingExt()])
283-
ctx = ServerRequestContext(
284-
session=cast("Any", None),
285-
lifespan_context={},
286-
protocol_version="2026-07-28",
287-
method="tasks/get",
288-
params={"taskId": "t-1"},
289-
)
279+
async def test_short_circuited_interceptor_result_carries_the_server_info_stamp() -> None:
280+
"""Spec-mandated (2026-07-28, #3002): an interceptor that answers without running
281+
the tool still produces a stamped result - interception happens at the handler
282+
layer, below the runner's outbound envelope pass."""
283+
server = MCPServer("test", extensions=[_ReplacingExt()])
284+
server.tool(name="echo", structured_output=False)(_echo)
290285

291-
result = await middleware(ctx, call_next)
286+
async with Client(server) as client:
287+
result = await client.call_tool("echo", {"value": "hi"})
292288

293-
assert result is sentinel
289+
assert result.content == [TextContent(text="intercepted")]
290+
assert result.meta == {SERVER_INFO_META_KEY: {"name": "test", "version": version("mcp")}}
294291

295292

296293
def test_extension_subclass_without_prefixed_identifier_is_rejected_at_definition() -> None:

tests/server/test_runner.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,9 +1042,9 @@ async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
10421042

10431043
@pytest.mark.anyio
10441044
async def test_stamping_never_mutates_a_handler_retained_result_dict(server: SrvT):
1045-
"""SDK-defined: the stamp lands on a shallow copy at the runner's exit, so
1046-
a dict the handler retains (module-level, cached, shared) is never mutated
1047-
underneath it."""
1045+
"""SDK-defined: the outbound pass dumps the handler's dict to a copy
1046+
(`_dump_result`) and the stamp writes into that copy, so a dict the handler
1047+
retains (module-level, cached, shared) is never mutated underneath it."""
10481048

10491049
retained: dict[str, Any] = {"ok": True}
10501050

0 commit comments

Comments
 (0)