fix: allow null id in JSONRPCError per JSON-RPC 2.0 spec (#2056)

This commit is contained in:
Max Isbey 2026-02-17 10:30:34 +00:00 • committed by GitHub
commit 705497a593
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 151 additions and 24 deletions

View file

@ -138,7 +138,7 @@ async def sse_client(
json=session_message.message.model_dump(
by_alias=True,
mode="json",
exclude_none=True,
exclude_unset=True,
),
)
response.raise_for_status()

View file

@ -167,7 +167,7 @@ async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stder
try:
async with write_stream_reader:
async for session_message in write_stream_reader:
json = session_message.message.model_dump_json(by_alias=True, exclude_none=True)
json = session_message.message.model_dump_json(by_alias=True, exclude_unset=True)
await process.stdin.send(
(json + "\n").encode(
encoding=server.encoding,

View file

@ -260,7 +260,7 @@ class StreamableHTTPTransport:
async with ctx.client.stream(
"POST",
self.url,
json=message.model_dump(by_alias=True, mode="json", exclude_none=True),
json=message.model_dump(by_alias=True, mode="json", exclude_unset=True),
headers=headers,
) as response:
if response.status_code == 202:

View file

@ -65,7 +65,7 @@ async def websocket_client(
async with write_stream_reader:
async for session_message in write_stream_reader:
# Convert to a dict, then to JSON
msg_dict = session_message.message.model_dump(by_alias=True, mode="json", exclude_none=True)
msg_dict = session_message.message.model_dump(by_alias=True, mode="json", exclude_unset=True)
await ws.send(json.dumps(msg_dict))
async with anyio.create_task_group() as tg:

View file

@ -490,7 +490,7 @@ class Server(Generic[LifespanResultT]):
except Exception as err:
if raise_exceptions: # pragma: no cover
raise err
response = types.ErrorData(code=0, message=str(err), data=None)
response = types.ErrorData(code=0, message=str(err))
await message.respond(response)
else: # pragma: no cover

View file

@ -170,7 +170,7 @@ class SseServerTransport:
await sse_stream_writer.send(
{
"event": "message",
"data": session_message.message.model_dump_json(by_alias=True, exclude_none=True),
"data": session_message.message.model_dump_json(by_alias=True, exclude_unset=True),
}
)

View file

@ -71,7 +71,7 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.
try:
async with write_stream_reader:
async for session_message in write_stream_reader:
json = session_message.message.model_dump_json(by_alias=True, exclude_none=True)
json = session_message.message.model_dump_json(by_alias=True, exclude_unset=True)
await stdout.write(json + "\n")
await stdout.flush()
except anyio.ClosedResourceError: # pragma: no cover

View file

@ -298,12 +298,12 @@ class StreamableHTTPServerTransport:
# Return a properly formatted JSON error response
error_response = JSONRPCError(
jsonrpc="2.0",
id="server-error", # We don't have a request ID for general errors
id=None,
error=ErrorData(code=error_code, message=error_message),
)
return Response(
error_response.model_dump_json(by_alias=True, exclude_none=True),
error_response.model_dump_json(by_alias=True, exclude_unset=True),
status_code=status_code,
headers=response_headers,
)
@ -323,7 +323,7 @@ class StreamableHTTPServerTransport:
response_headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id
return Response(
response_message.model_dump_json(by_alias=True, exclude_none=True) if response_message else None,
response_message.model_dump_json(by_alias=True, exclude_unset=True) if response_message else None,
status_code=status_code,
headers=response_headers,
)
@ -336,7 +336,7 @@ class StreamableHTTPServerTransport:
"""Create event data dictionary from an EventMessage."""
event_data = {
"event": "message",
"data": event_message.message.model_dump_json(by_alias=True, exclude_none=True),
"data": event_message.message.model_dump_json(by_alias=True, exclude_unset=True),
}
# If an event ID was provided, include it
@ -975,12 +975,11 @@ class StreamableHTTPServerTransport:
# Determine which request stream(s) should receive this message
message = session_message.message
target_request_id = None
# Check if this is a response
if isinstance(message, JSONRPCResponse | JSONRPCError):
response_id = str(message.id)
# If this response is for an existing request stream,
# send it there
target_request_id = response_id
# Check if this is a response with a known request id.
# Null-id errors (e.g., parse errors) fall through to
# the GET stream since they can't be correlated.
if isinstance(message, JSONRPCResponse | JSONRPCError) and message.id is not None:
target_request_id = str(message.id)
# Extract related_request_id from meta if it exists
elif ( # pragma: no cover
session_message.metadata is not None

View file

@ -244,11 +244,11 @@ class StreamableHTTPSessionManager:
# See: https://github.com/modelcontextprotocol/python-sdk/issues/1821
error_response = JSONRPCError(
jsonrpc="2.0",
id="server-error",
id=None,
error=ErrorData(code=INVALID_REQUEST, message="Session not found"),
)
response = Response(
content=error_response.model_dump_json(by_alias=True, exclude_none=True),
content=error_response.model_dump_json(by_alias=True, exclude_unset=True),
status_code=HTTPStatus.NOT_FOUND,
media_type="application/json",
)

View file

@ -47,7 +47,7 @@ async def websocket_server(scope: Scope, receive: Receive, send: Send):
try:
async with write_stream_reader:
async for session_message in write_stream_reader:
obj = session_message.message.model_dump_json(by_alias=True, exclude_none=True)
obj = session_message.message.model_dump_json(by_alias=True, exclude_unset=True)
await websocket.send_text(obj)
except anyio.ClosedResourceError:
await websocket.close()

View file

@ -12,7 +12,10 @@ class MCPError(Exception):
def __init__(self, code: int, message: str, data: Any = None):
super().__init__(code, message, data)
self.error = ErrorData(code=code, message=message, data=data)
if data is not None:
self.error = ErrorData(code=code, message=message, data=data)
else:
self.error = ErrorData(code=code, message=message)
@property
def code(self) -> int:

View file

@ -142,7 +142,7 @@ class RequestResponder(Generic[ReceiveRequestT, SendResultT]):
# Send an error response to indicate cancellation
await self._session._send_response( # type: ignore[reportPrivateUsage]
request_id=self.request_id,
response=ErrorData(code=0, message="Request cancelled", data=None),
response=ErrorData(code=0, message="Request cancelled"),
)
@property
@ -458,6 +458,12 @@ class BaseSession(
if not isinstance(message.message, JSONRPCResponse | JSONRPCError):
return # pragma: no cover
if message.message.id is None:
# Narrows to JSONRPCError since JSONRPCResponse.id is always RequestId
error = message.message.error
logging.warning(f"Received error with null ID: {error.message}")
await self._handle_incoming(MCPError(error.code, error.message, error.data))
return
# Normalize response ID to handle type mismatches (e.g., "0" vs 0)
response_id = self._normalize_request_id(message.message.id)

View file

@ -75,7 +75,7 @@ class JSONRPCError(BaseModel):
"""A response to a request that indicates an error occurred."""
jsonrpc: Literal["2.0"]
id: RequestId
id: RequestId | None
error: ErrorData