Skip to content

Commit 9f54d31

Browse files
committed
Sieve only core-vocabulary results; claimed shapes pass through
Moving extension interception to the handler layer put interceptor results through the per-version result sieve, which strips fields the surface doesn't know - breaking claimed extension shapes (a resultType outside the core vocabulary carrying vendor fields). The sieve's job is per-version surface enforcement for core results; an extension resultType marks a shape the extension owns, so it now applies to core-vocabulary results only. Claimed shapes still get the 2026-era serverInfo stamp: they are results like any other.
1 parent debec20 commit 9f54d31

2 files changed

Lines changed: 35 additions & 6 deletions

File tree

src/mcp/server/runner.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from mcp_types import (
2525
CLIENT_CAPABILITIES_META_KEY,
2626
CLIENT_INFO_META_KEY,
27+
CORE_RESULT_TYPES,
2728
INTERNAL_ERROR,
2829
INVALID_PARAMS,
2930
INVALID_REQUEST,
@@ -335,9 +336,10 @@ def _serialize(self, method: str, version: str, result: HandlerResult) -> dict[s
335336
of the inbound classification ladder.
336337
337338
One pass owns the whole response envelope, in order: cache hints fill
338-
`ttlMs`/`cacheScope` the handler left unset, spec-method results are
339-
validated and sieved by the per-version surface, and 2026-era results
340-
get the `serverInfo` `_meta` stamp (spec #3002). Runs inside the
339+
`ttlMs`/`cacheScope` the handler left unset, core-vocabulary spec-method
340+
results are validated and sieved by the per-version surface (a claimed
341+
extension `resultType` shape is the extension's to own), and 2026-era
342+
results get the `serverInfo` `_meta` stamp (spec #3002). Runs inside the
341343
middleware chain so the OpenTelemetry span observes a failing return
342344
shape (unsupported type, malformed spec result) as an error rather
343345
than closing on a request that the client sees fail - and so a
@@ -352,10 +354,15 @@ def _serialize(self, method: str, version: str, result: HandlerResult) -> dict[s
352354
# Hint keys first so wire keys the handler set win, matching `apply_cache_hint` precedence.
353355
result = {"ttlMs": hint.ttl_ms, "cacheScope": hint.scope, **result}
354356
dumped = _dump_result(result)
355-
# TODO(L56): reject resultType values outside {"complete", "input_required"} unless the
356-
# corresponding extension is in this request's _meta clientCapabilities.extensions; the
357+
# An extension `resultType` (outside the core vocabulary) marks a claimed
358+
# shape owned by the extension that defined it: the per-version surface
359+
# doesn't describe it, so the sieve applies to core results only.
360+
# TODO(L56): reject extension resultType values unless the corresponding
361+
# extension is in this request's _meta clientCapabilities.extensions; the
357362
# explicit MUST-reject is client-side (basic/index.mdx ResultType), this enforces it proactively.
358-
if method in _methods.SPEC_CLIENT_METHODS:
363+
result_type = dumped.get("resultType")
364+
core_shape = not isinstance(result_type, str) or result_type in CORE_RESULT_TYPES
365+
if method in _methods.SPEC_CLIENT_METHODS and core_shape:
359366
try:
360367
dumped = _methods.serialize_server_result(method, version, dumped)
361368
except ValidationError:

tests/server/test_runner.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
PROTOCOL_VERSION_META_KEY,
2828
SERVER_INFO_META_KEY,
2929
UNSUPPORTED_PROTOCOL_VERSION,
30+
CallToolRequestParams,
3031
ClientCapabilities,
3132
EmptyResult,
3233
ErrorData,
@@ -1059,6 +1060,27 @@ async def custom(ctx: Ctx, params: RequestParams) -> dict[str, Any]:
10591060
assert retained == {"ok": True}
10601061

10611062

1063+
@pytest.mark.anyio
1064+
async def test_a_claimed_extension_result_type_bypasses_the_sieve_and_is_stamped(server: SrvT):
1065+
"""SDK-defined: a spec-method result carrying an extension `resultType` is a
1066+
claimed shape the extension owns - the per-version sieve would strip its
1067+
vendor fields, so it applies to core-vocabulary results only. The identity
1068+
stamp still lands: claimed shapes are results like any other."""
1069+
1070+
async def custom(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:
1071+
return {"resultType": "voucher", "voucherCode": "v-42"}
1072+
1073+
server.add_request_handler("tools/call", CallToolRequestParams, custom)
1074+
born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None)
1075+
async with connected_runner(server, initialized=False, connection=born_ready) as (client, _):
1076+
result = await client.send_raw_request("tools/call", _modern_params(name="issue"))
1077+
assert result == {
1078+
"resultType": "voucher",
1079+
"voucherCode": "v-42",
1080+
"_meta": {SERVER_INFO_META_KEY: {"name": "test-server", "version": "0.0.1"}},
1081+
}
1082+
1083+
10621084
@pytest.mark.anyio
10631085
async def test_an_empty_result_on_the_modern_path_carries_only_the_stamp(server: SrvT):
10641086
"""Spec-mandated (2026-07-28, #3002): serverInfo is stamped into every

0 commit comments

Comments
 (0)