Skip to content

Commit 416c9d9

Browse files
committed
T3: interaction-suite additions for modern stateless entry
1 parent 7231664 commit 416c9d9

2 files changed

Lines changed: 149 additions & 8 deletions

File tree

‎tests/interaction/_requirements.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3046,6 +3046,50 @@ def __post_init__(self) -> None:
30463046
transports=("streamable-http",),
30473047
note="Only observable over streamable HTTP: the modern entry's exception-to-JSONRPCError boundary.",
30483048
),
3049+
"hosting:http:modern:discover-response-shape": Requirement(
3050+
source=f"{SPEC_2026_BASE_URL}/basic/index",
3051+
behavior=(
3052+
"A 2026-07-28 server/discover response carries supportedVersions, capabilities, and "
3053+
"serverInfo, with supportedVersions naming the modern protocol revisions the server accepts."
3054+
),
3055+
added_in="2026-07-28",
3056+
transports=("streamable-http",),
3057+
note="Only observable over streamable HTTP: the raw result body is asserted at the wire.",
3058+
),
3059+
"hosting:http:modern:removed-method-status-404": Requirement(
3060+
source=f"{SPEC_2026_BASE_URL}/basic/index",
3061+
behavior=(
3062+
"A method that exists at earlier protocol revisions but is removed at 2026-07-28 is "
3063+
"answered METHOD_NOT_FOUND, and the modern entry maps that error code to HTTP 404."
3064+
),
3065+
added_in="2026-07-28",
3066+
transports=("streamable-http",),
3067+
note=(
3068+
"Only observable over streamable HTTP: the HTTP status is the assertion. Kernel-origin "
3069+
"METHOD_NOT_FOUND travels through the same status table as classifier-origin errors."
3070+
),
3071+
),
3072+
"hosting:http:modern:envelope-missing-key-status-400": Requirement(
3073+
source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http",
3074+
behavior=(
3075+
"A 2026-07-28 request whose params._meta envelope omits a required reserved key is "
3076+
"rejected as INVALID_PARAMS at HTTP 400 before kernel dispatch."
3077+
),
3078+
added_in="2026-07-28",
3079+
transports=("streamable-http",),
3080+
note="Only observable over streamable HTTP: the HTTP status is the assertion.",
3081+
),
3082+
"hosting:http:modern:handler-error-status-via-table": Requirement(
3083+
source="sdk",
3084+
behavior=(
3085+
"A handler-raised MCPError on the 2026-07-28 entry reaches the wire as a top-level "
3086+
"JSON-RPC error with its data preserved, and the HTTP status is the error-code table "
3087+
"entry for that code (handler-origin and classifier-origin errors share one table)."
3088+
),
3089+
added_in="2026-07-28",
3090+
transports=("streamable-http",),
3091+
note="Only observable over streamable HTTP: the modern entry's JSONRPCError-to-HTTP-status mapping.",
3092+
),
30493093
# ═══════════════════════════════════════════════════════════════════════════
30503094
# Client transport: streamable HTTP
30513095
# ═══════════════════════════════════════════════════════════════════════════

‎tests/interaction/transports/test_hosting_http_modern.py‎

Lines changed: 105 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,30 @@
1616
import pytest
1717
from inline_snapshot import snapshot
1818

19+
from mcp import MCPError
1920
from mcp.client.session import ClientSession
2021
from mcp.client.streamable_http import streamable_http_client
2122
from mcp.server import Server, ServerRequestContext
23+
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
2224
from mcp.types import (
25+
CLIENT_CAPABILITIES_META_KEY,
2326
INTERNAL_ERROR,
27+
INVALID_PARAMS,
2428
METHOD_NOT_FOUND,
29+
MISSING_REQUIRED_CLIENT_CAPABILITY,
2530
CallToolRequestParams,
2631
CallToolResult,
32+
EmptyResult,
2733
Implementation,
2834
JSONRPCError,
2935
JSONRPCResponse,
3036
ListToolsResult,
3137
PaginatedRequestParams,
38+
RequestParams,
3239
TextContent,
3340
Tool,
3441
)
35-
from tests.interaction._connect import BASE_URL, base_headers, initialize_body, initialize_via_http, mounted_app
42+
from tests.interaction._connect import BASE_URL, base_headers, initialize_via_http, mounted_app
3643
from tests.interaction._requirements import requirement
3744

3845
pytestmark = pytest.mark.anyio
@@ -133,16 +140,20 @@ async def test_modern_response_carries_no_session_id_header() -> None:
133140

134141
@requirement("hosting:http:modern:initialize-removed")
135142
async def test_modern_initialize_is_method_not_found() -> None:
136-
"""A 2026-07-28 initialize request is answered with METHOD_NOT_FOUND.
137-
138-
Spec-mandated under the draft: initialize is not a defined method at 2026-07-28, so the
139-
method/version gate rejects it before any handler runs. Asserted at the wire because the SDK
140-
client at 2026-07-28 never sends initialize, so only a raw POST can drive the negative.
143+
"""A 2026-07-28 initialize request that carries a valid envelope is answered METHOD_NOT_FOUND at HTTP 404.
144+
145+
Spec-mandated under the draft: initialize is not a defined method at 2026-07-28, so the kernel's
146+
method/version gate rejects it before any handler runs. The body must carry the per-request
147+
``_meta`` envelope so the classifier ladder admits it as far as kernel dispatch -- without the
148+
envelope the request is INVALID_PARAMS at rung 1, never METHOD_NOT_FOUND. Asserted at the wire
149+
because the SDK client at 2026-07-28 never sends initialize, so only a raw POST can drive the
150+
negative.
141151
"""
152+
body = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"_meta": _meta_envelope()}}
142153
async with mounted_app(_server()) as (http, _):
143-
response = await http.post("/mcp", json=initialize_body(), headers=_modern_headers(method="initialize"))
154+
response = await http.post("/mcp", json=body, headers=_modern_headers(method="initialize"))
144155

145-
assert response.status_code == 200
156+
assert response.status_code == 404
146157
assert JSONRPCError.model_validate(response.json()).error.code == METHOD_NOT_FOUND
147158

148159

@@ -198,6 +209,92 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) ->
198209
assert "kaboom" not in error.message
199210

200211

212+
@requirement("hosting:http:modern:discover-response-shape")
213+
async def test_modern_server_discover_returns_capabilities_and_supported_versions() -> None:
214+
"""A 2026-07-28 server/discover POST returns capabilities, serverInfo, and supportedVersions.
215+
216+
Spec-mandated under the draft: server/discover is the 2026 advertisement method that replaces
217+
the initialize-response payload, and ``supportedVersions`` is the field a client picks its
218+
per-request envelope version from. Asserted at the wire because the SDK client never exposes
219+
the raw result body.
220+
"""
221+
body = {"jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {"_meta": _meta_envelope()}}
222+
async with mounted_app(_server()) as (http, _):
223+
response = await http.post("/mcp", json=body, headers=_modern_headers(method="server/discover"))
224+
225+
assert response.status_code == 200
226+
result = JSONRPCResponse.model_validate(response.json()).result
227+
assert result["supportedVersions"] == list(MODERN_PROTOCOL_VERSIONS)
228+
assert result["serverInfo"]["name"] == "modern"
229+
assert "capabilities" in result
230+
231+
232+
@requirement("hosting:http:modern:removed-method-status-404")
233+
async def test_modern_removed_method_is_method_not_found_at_http_404() -> None:
234+
"""A 2026-07-28 ping (removed at 2026) is answered METHOD_NOT_FOUND and the HTTP status is 404.
235+
236+
Spec-mandated for the error code: ping is not a defined method at 2026-07-28 so the kernel's
237+
method/version gate rejects it. SDK-defined for the HTTP status: kernel-origin METHOD_NOT_FOUND
238+
travels through the same error-code-to-status table as classifier-origin errors. Asserted at the
239+
wire because the HTTP status is the assertion.
240+
"""
241+
body = {"jsonrpc": "2.0", "id": 1, "method": "ping", "params": {"_meta": _meta_envelope()}}
242+
async with mounted_app(_server()) as (http, _):
243+
response = await http.post("/mcp", json=body, headers=_modern_headers(method="ping"))
244+
245+
assert response.status_code == 404
246+
assert JSONRPCError.model_validate(response.json()).error.code == METHOD_NOT_FOUND
247+
248+
249+
@requirement("hosting:http:modern:envelope-missing-key-status-400")
250+
async def test_modern_envelope_missing_required_meta_key_is_invalid_params_at_http_400() -> None:
251+
"""A 2026-07-28 request whose ``_meta`` envelope omits a required key is INVALID_PARAMS at HTTP 400.
252+
253+
Spec-mandated under the draft transport: the per-request envelope must carry every reserved key,
254+
so a missing ``clientCapabilities`` fails the classifier's first rung before any kernel dispatch.
255+
Asserted at the wire because the HTTP status is the assertion.
256+
"""
257+
incomplete = _meta_envelope()
258+
del incomplete[CLIENT_CAPABILITIES_META_KEY]
259+
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": incomplete}}
260+
async with mounted_app(_server()) as (http, _):
261+
response = await http.post("/mcp", json=body, headers=_modern_headers(method="tools/list"))
262+
263+
assert response.status_code == 400
264+
assert JSONRPCError.model_validate(response.json()).error.code == INVALID_PARAMS
265+
266+
267+
@requirement("hosting:http:modern:handler-error-status-via-table")
268+
async def test_modern_handler_raised_mcperror_maps_to_status_via_error_code_table() -> None:
269+
"""A handler-raised ``MCPError`` reaches the wire as a top-level JSON-RPC error at the table-mapped HTTP status.
270+
271+
SDK-defined for the HTTP status: the modern entry maps every JSON-RPC ``error.code`` -- whether
272+
classifier-origin or handler-origin -- through one error-code-to-status table, so a handler
273+
raising ``MISSING_REQUIRED_CLIENT_CAPABILITY`` produces HTTP 400 with ``error.data`` preserved.
274+
Spec-mandated for the error code: the named code and its ``requiredCapabilities`` data shape are
275+
the spec's capability-gating contract. Registered via the low-level ``add_request_handler`` so
276+
the high-level tool wrapper's error-swallowing is not on the path.
277+
"""
278+
279+
async def cap_check(ctx: ServerRequestContext, params: RequestParams) -> EmptyResult:
280+
raise MCPError(
281+
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
282+
message="sampling required",
283+
data={"requiredCapabilities": ["sampling"]},
284+
)
285+
286+
server = _server()
287+
server.add_request_handler("test/cap-check", RequestParams, cap_check)
288+
body = {"jsonrpc": "2.0", "id": 1, "method": "test/cap-check", "params": {"_meta": _meta_envelope()}}
289+
async with mounted_app(server) as (http, _):
290+
response = await http.post("/mcp", json=body, headers=_modern_headers(method="test/cap-check"))
291+
292+
assert response.status_code == 400
293+
error = JSONRPCError.model_validate(response.json()).error
294+
assert error.code == MISSING_REQUIRED_CLIENT_CAPABILITY
295+
assert error.data == {"requiredCapabilities": ["sampling"]}
296+
297+
201298
@requirement("hosting:http:modern:tools-call-stateless")
202299
@requirement("lifecycle:stateless:request-envelope")
203300
@requirement("lifecycle:stateless:caller-meta-preserved")

0 commit comments

Comments
 (0)