|
16 | 16 | import pytest |
17 | 17 | from inline_snapshot import snapshot |
18 | 18 |
|
| 19 | +from mcp import MCPError |
19 | 20 | from mcp.client.session import ClientSession |
20 | 21 | from mcp.client.streamable_http import streamable_http_client |
21 | 22 | from mcp.server import Server, ServerRequestContext |
| 23 | +from mcp.shared.version import MODERN_PROTOCOL_VERSIONS |
22 | 24 | from mcp.types import ( |
| 25 | + CLIENT_CAPABILITIES_META_KEY, |
23 | 26 | INTERNAL_ERROR, |
| 27 | + INVALID_PARAMS, |
24 | 28 | METHOD_NOT_FOUND, |
| 29 | + MISSING_REQUIRED_CLIENT_CAPABILITY, |
25 | 30 | CallToolRequestParams, |
26 | 31 | CallToolResult, |
| 32 | + EmptyResult, |
27 | 33 | Implementation, |
28 | 34 | JSONRPCError, |
29 | 35 | JSONRPCResponse, |
30 | 36 | ListToolsResult, |
31 | 37 | PaginatedRequestParams, |
| 38 | + RequestParams, |
32 | 39 | TextContent, |
33 | 40 | Tool, |
34 | 41 | ) |
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 |
36 | 43 | from tests.interaction._requirements import requirement |
37 | 44 |
|
38 | 45 | pytestmark = pytest.mark.anyio |
@@ -133,16 +140,20 @@ async def test_modern_response_carries_no_session_id_header() -> None: |
133 | 140 |
|
134 | 141 | @requirement("hosting:http:modern:initialize-removed") |
135 | 142 | 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. |
141 | 151 | """ |
| 152 | + body = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"_meta": _meta_envelope()}} |
142 | 153 | 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")) |
144 | 155 |
|
145 | | - assert response.status_code == 200 |
| 156 | + assert response.status_code == 404 |
146 | 157 | assert JSONRPCError.model_validate(response.json()).error.code == METHOD_NOT_FOUND |
147 | 158 |
|
148 | 159 |
|
@@ -198,6 +209,92 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> |
198 | 209 | assert "kaboom" not in error.message |
199 | 210 |
|
200 | 211 |
|
| 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 | + |
201 | 298 | @requirement("hosting:http:modern:tools-call-stateless") |
202 | 299 | @requirement("lifecycle:stateless:request-envelope") |
203 | 300 | @requirement("lifecycle:stateless:caller-meta-preserved") |
|
0 commit comments