Skip to content

Commit 2e506e7

Browse files
committed
perf: discriminate JSONRPCMessage union by key presence instead of smart-union scoring
Every transport (stdio, SSE, streamable HTTP; client and server) decodes inbound messages through jsonrpc_message_adapter, which validated the 4-member JSONRPCMessage union in Pydantic smart-union mode - scoring all four branches per message. The variants are distinguishable by key presence per JSON-RPC 2.0, so a callable Discriminator selects exactly one branch: 1.6-2.2x faster envelope decoding (see scripts/bench_jsonrpc_codec.py), byte-identical wire output, and exact classification parity with the previous behavior (including the null/float/bool-id notification downgrade and error-over-result precedence for degenerate shapes), pinned by tests. JSONRPCMessage itself stays a plain union so isinstance() keeps working; the Tag/Discriminator wrapping lives only inside the TypeAdapter, so no call sites change. Unclassifiable input now raises a single jsonrpc_message_invalid error with an actionable message instead of a four-branch error dump; error codes and HTTP statuses are unchanged. Also documents why the server POST path keeps its two-phase from_json + validate_python parse: with a callable discriminator, validate_json must materialize the payload to call the discriminator and measures ~1.75x slower on large bodies.
1 parent 3a6f299 commit 2e506e7

4 files changed

Lines changed: 270 additions & 3 deletions

File tree

scripts/bench_jsonrpc_codec.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""Micro-benchmark: JSONRPCMessage decoding, smart union vs key-presence discriminator.
2+
3+
Compares the previous bare-union adapter (reconstructed in-process) against the
4+
shipped `jsonrpc_message_adapter` over representative payloads, for both decode
5+
paths used by the SDK:
6+
7+
- `validate_json(body)` (client SSE/stdio lines)
8+
- `pydantic_core.from_json(body)` + `validate_python(raw)` (server POST path)
9+
10+
Run with: uv run python scripts/bench_jsonrpc_codec.py [--small N] [--large N]
11+
"""
12+
13+
import argparse
14+
import json
15+
import time
16+
from collections.abc import Callable
17+
from typing import Any
18+
19+
import pydantic_core
20+
from mcp_types.jsonrpc import (
21+
JSONRPCError,
22+
JSONRPCNotification,
23+
JSONRPCRequest,
24+
JSONRPCResponse,
25+
jsonrpc_message_adapter,
26+
)
27+
from pydantic import TypeAdapter
28+
29+
# The adapter as it existed before the discriminator change.
30+
bare_union_adapter: TypeAdapter[Any] = TypeAdapter(
31+
JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError
32+
)
33+
34+
SMALL_REQUEST = json.dumps(
35+
{"jsonrpc": "2.0", "id": 42, "method": "tools/call", "params": {"name": "echo", "arguments": {"text": "hi"}}}
36+
).encode()
37+
SMALL_NOTIFICATION = json.dumps(
38+
{"jsonrpc": "2.0", "method": "notifications/progress", "params": {"progressToken": "t", "progress": 0.5}}
39+
).encode()
40+
LARGE_RESULT = json.dumps(
41+
{
42+
"jsonrpc": "2.0",
43+
"id": 42,
44+
"result": {
45+
"content": [{"type": "text", "text": "x" * 256} for _ in range(64)],
46+
"structuredContent": {f"key_{i}": {"value": i, "label": "y" * 32} for i in range(64)},
47+
},
48+
}
49+
).encode()
50+
51+
REPEATS = 5
52+
53+
54+
def bench(fn: Callable[[], Any], iterations: int) -> float:
55+
"""Return best-of-REPEATS per-call time in microseconds."""
56+
best = float("inf")
57+
for _ in range(REPEATS):
58+
start = time.perf_counter()
59+
for _ in range(iterations):
60+
fn()
61+
best = min(best, time.perf_counter() - start)
62+
return best / iterations * 1e6
63+
64+
65+
def _decode_validate_json(adapter: TypeAdapter[Any], body: bytes) -> Any:
66+
return adapter.validate_json(body, by_name=False)
67+
68+
69+
def _decode_two_phase(adapter: TypeAdapter[Any], body: bytes) -> Any:
70+
return adapter.validate_python(pydantic_core.from_json(body), by_name=False)
71+
72+
73+
def main() -> None:
74+
parser = argparse.ArgumentParser(description=__doc__)
75+
parser.add_argument("--small", type=int, default=100_000, help="iterations for small payloads")
76+
parser.add_argument("--large", type=int, default=10_000, help="iterations for the large payload")
77+
args = parser.parse_args()
78+
79+
payloads = [
80+
(f"request {len(SMALL_REQUEST)}B", SMALL_REQUEST, args.small),
81+
(f"notification {len(SMALL_NOTIFICATION)}B", SMALL_NOTIFICATION, args.small),
82+
(f"result {len(LARGE_RESULT) / 1024:.1f}KB", LARGE_RESULT, args.large),
83+
]
84+
decoders: list[tuple[str, Callable[[TypeAdapter[Any], bytes], Any]]] = [
85+
("validate_json", _decode_validate_json),
86+
("two-phase", _decode_two_phase),
87+
]
88+
print(f"{'payload':<20} {'path':<14} {'smart union':>12} {'discriminator':>14} {'speedup':>8}")
89+
for label, body, iterations in payloads:
90+
for path_label, decode in decoders:
91+
old = bench(lambda: decode(bare_union_adapter, body), iterations)
92+
new = bench(lambda: decode(jsonrpc_message_adapter, body), iterations)
93+
print(f"{label:<20} {path_label:<14} {old:>10.2f}us {new:>12.2f}us {old / new:>7.2f}x")
94+
95+
96+
if __name__ == "__main__":
97+
main()

src/mcp-types/mcp_types/jsonrpc.py

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
from __future__ import annotations
44

5-
from typing import Annotated, Any, Final, Literal
5+
from typing import Annotated, Any, Final, Literal, cast
66

7-
from pydantic import BaseModel, Field, TypeAdapter
7+
from pydantic import BaseModel, Discriminator, Field, Tag, TypeAdapter
88

99
RequestId = Annotated[int, Field(strict=True)] | str
1010
"""The ID of a JSON-RPC request."""
@@ -122,4 +122,54 @@ class JSONRPCError(BaseModel):
122122
JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError
123123
"""Any JSON-RPC envelope that can be decoded off the wire or encoded to be sent."""
124124

125-
jsonrpc_message_adapter: TypeAdapter[JSONRPCMessage] = TypeAdapter(JSONRPCMessage)
125+
126+
def _discriminate_jsonrpc_message(value: Any) -> str | None:
127+
"""Tag a wire object by key presence per JSON-RPC 2.0.
128+
129+
Selects exactly one union branch to validate instead of letting smart-union
130+
mode score all four on every message. Mirrors the smart-union outcomes
131+
exactly, including the rule that a ``method`` member with a missing, null,
132+
or non-int/str ``id`` classifies as a notification, and that ``error`` wins
133+
over ``result`` when both are present.
134+
"""
135+
if isinstance(value, dict):
136+
wire = cast("dict[str, Any]", value)
137+
if "method" in wire:
138+
request_id: Any = wire.get("id")
139+
# Mirror `RequestId` (strict int | str): bool/float/None ids do not
140+
# make a request; smart union classified those as notifications.
141+
if isinstance(request_id, str) or (isinstance(request_id, int) and not isinstance(request_id, bool)):
142+
return "request"
143+
return "notification"
144+
if "error" in wire:
145+
return "error"
146+
if "result" in wire:
147+
return "response"
148+
return None
149+
# Revalidation / serialization of already-constructed models.
150+
if isinstance(value, JSONRPCRequest):
151+
return "request"
152+
if isinstance(value, JSONRPCNotification):
153+
return "notification"
154+
if isinstance(value, JSONRPCError):
155+
return "error"
156+
if isinstance(value, JSONRPCResponse):
157+
return "response"
158+
return None
159+
160+
161+
jsonrpc_message_adapter: TypeAdapter[JSONRPCMessage] = TypeAdapter(
162+
Annotated[
163+
Annotated[JSONRPCRequest, Tag("request")]
164+
| Annotated[JSONRPCNotification, Tag("notification")]
165+
| Annotated[JSONRPCResponse, Tag("response")]
166+
| Annotated[JSONRPCError, Tag("error")],
167+
Discriminator(
168+
_discriminate_jsonrpc_message,
169+
custom_error_type="jsonrpc_message_invalid",
170+
custom_error_message=(
171+
"Not a valid JSON-RPC message: expected an object with a 'method', 'result', or 'error' member"
172+
),
173+
),
174+
]
175+
)

src/mcp/server/streamable_http.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,9 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
484484
# Parse the body - only read it once
485485
body = await request.body()
486486

487+
# Two-phase parse is intentional: `validate_json` would re-materialize the
488+
# payload to run the adapter's callable discriminator, which measures ~1.75x
489+
# slower than `from_json` + `validate_python` on large bodies.
487490
try:
488491
raw_message = pydantic_core.from_json(body)
489492
except ValueError as e:
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Behavior pins for the key-presence discriminator on `jsonrpc_message_adapter`.
2+
3+
The adapter validates exactly one union branch chosen by
4+
`_discriminate_jsonrpc_message`; these tests pin classification parity with the
5+
previous smart-union behavior, including the degenerate shapes.
6+
"""
7+
8+
import json
9+
from typing import Any
10+
11+
import pytest
12+
from mcp_types import (
13+
ErrorData,
14+
JSONRPCError,
15+
JSONRPCMessage,
16+
JSONRPCNotification,
17+
JSONRPCRequest,
18+
JSONRPCResponse,
19+
jsonrpc_message_adapter,
20+
)
21+
from pydantic import ValidationError
22+
23+
REQUEST_WIRE: dict[str, Any] = {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
24+
NOTIFICATION_WIRE: dict[str, Any] = {"jsonrpc": "2.0", "method": "notifications/progress"}
25+
RESPONSE_WIRE: dict[str, Any] = {"jsonrpc": "2.0", "id": 1, "result": {"tools": []}}
26+
ERROR_WIRE: dict[str, Any] = {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}}
27+
28+
29+
@pytest.mark.parametrize(
30+
("wire", "expected_type"),
31+
[
32+
(REQUEST_WIRE, JSONRPCRequest),
33+
(NOTIFICATION_WIRE, JSONRPCNotification),
34+
(RESPONSE_WIRE, JSONRPCResponse),
35+
(ERROR_WIRE, JSONRPCError),
36+
],
37+
)
38+
def test_validate_json_classifies_each_variant(wire: dict[str, object], expected_type: type) -> None:
39+
message = jsonrpc_message_adapter.validate_json(json.dumps(wire), by_name=False)
40+
assert type(message) is expected_type
41+
42+
43+
@pytest.mark.parametrize("bad_id", [None, 1.5, True])
44+
def test_method_with_non_request_id_is_a_notification(bad_id: object) -> None:
45+
"""A `method` member with a null/float/bool id downgrades to a notification (smart-union parity)."""
46+
wire = {"jsonrpc": "2.0", "id": bad_id, "method": "m"}
47+
message = jsonrpc_message_adapter.validate_python(wire, by_name=False)
48+
assert type(message) is JSONRPCNotification
49+
50+
51+
def test_method_with_string_id_is_a_request() -> None:
52+
message = jsonrpc_message_adapter.validate_python({"jsonrpc": "2.0", "id": "abc", "method": "m"}, by_name=False)
53+
assert type(message) is JSONRPCRequest
54+
55+
56+
def test_method_wins_over_result() -> None:
57+
"""A degenerate {method, id, result} object classifies as a request (smart-union parity)."""
58+
wire: dict[str, Any] = {"jsonrpc": "2.0", "id": 1, "method": "m", "result": {}}
59+
message = jsonrpc_message_adapter.validate_python(wire, by_name=False)
60+
assert type(message) is JSONRPCRequest
61+
62+
63+
def test_error_wins_over_result() -> None:
64+
"""A degenerate {id, result, error} object classifies as an error (smart-union parity)."""
65+
wire = {"jsonrpc": "2.0", "id": 1, "result": {}, "error": {"code": -32603, "message": "boom"}}
66+
message = jsonrpc_message_adapter.validate_python(wire, by_name=False)
67+
assert type(message) is JSONRPCError
68+
69+
70+
@pytest.mark.parametrize(
71+
"unclassifiable",
72+
[
73+
b'{"foo": 1}',
74+
b"[]",
75+
],
76+
)
77+
def test_unclassifiable_json_raises_single_tagged_error(unclassifiable: bytes) -> None:
78+
with pytest.raises(ValidationError) as exc_info:
79+
jsonrpc_message_adapter.validate_json(unclassifiable, by_name=False)
80+
errors = exc_info.value.errors()
81+
assert len(errors) == 1
82+
assert errors[0]["type"] == "jsonrpc_message_invalid"
83+
84+
85+
def test_unclassifiable_python_scalar_raises_tagged_error() -> None:
86+
with pytest.raises(ValidationError) as exc_info:
87+
jsonrpc_message_adapter.validate_python(42, by_name=False)
88+
assert exc_info.value.errors()[0]["type"] == "jsonrpc_message_invalid"
89+
90+
91+
def test_chosen_branch_failure_reports_single_branch_location() -> None:
92+
"""Once tagged, only the chosen branch validates; its errors carry the tag as the location root."""
93+
wire = {"jsonrpc": "2.0", "id": 1, "method": "m", "params": "notadict"}
94+
with pytest.raises(ValidationError) as exc_info:
95+
jsonrpc_message_adapter.validate_python(wire, by_name=False)
96+
errors = exc_info.value.errors()
97+
assert len(errors) == 1
98+
assert errors[0]["loc"] == ("request", "params")
99+
100+
101+
@pytest.mark.parametrize(
102+
"instance",
103+
[
104+
JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/list"),
105+
JSONRPCNotification(jsonrpc="2.0", method="notifications/progress"),
106+
JSONRPCResponse(jsonrpc="2.0", id=1, result={"tools": []}),
107+
JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=-32700, message="Parse error")),
108+
],
109+
)
110+
def test_model_instances_revalidate_and_dump_identically(instance: JSONRPCMessage) -> None:
111+
"""The discriminator also tags already-constructed models (revalidation and dump paths)."""
112+
revalidated = jsonrpc_message_adapter.validate_python(instance, by_name=False)
113+
assert revalidated is instance
114+
assert (
115+
jsonrpc_message_adapter.dump_json(instance, by_alias=True, exclude_none=True)
116+
== instance.model_dump_json(by_alias=True, exclude_none=True).encode()
117+
)

0 commit comments

Comments
 (0)