Skip to content

Commit ea209b1

Browse files
Merge remote-tracking branch 'upstream/main' into peter_contribution
2 parents c3dc56a + 92140e5 commit ea209b1

21 files changed

+285
-72
lines changed

CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ This document contains critical information about working with this codebase. Fo
3131
- IMPORTANT: Before pushing, verify 100% branch coverage on changed files by running
3232
`uv run --frozen pytest -x` (coverage is configured in `pyproject.toml` with `fail_under = 100`
3333
and `branch = true`). If any branch is uncovered, add a test for it before pushing.
34+
- Avoid `anyio.sleep()` with a fixed duration to wait for async operations. Instead:
35+
- Use `anyio.Event` — set it in the callback/handler, `await event.wait()` in the test
36+
- For stream messages, use `await stream.receive()` instead of `sleep()` + `receive_nowait()`
37+
- Exception: `sleep()` is appropriate when testing time-based features (e.g., timeouts)
38+
- Wrap indefinite waits (`event.wait()`, `stream.receive()`) in `anyio.fail_after(5)` to prevent hangs
3439

3540
Test files mirror the source tree: `src/mcp/client/streamable_http.py``tests/client/test_streamable_http.py`
3641
Add tests to the existing file for that module.

src/mcp/client/auth/oauth2.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -493,12 +493,6 @@ async def _validate_resource_match(self, prm: ProtectedResourceMetadata) -> None
493493
if not prm_resource:
494494
return # pragma: no cover
495495
default_resource = resource_url_from_server_url(self.context.server_url)
496-
# Normalize: Pydantic AnyHttpUrl adds trailing slash to root URLs
497-
# (e.g. "https://example.com/") while resource_url_from_server_url may not.
498-
if not default_resource.endswith("/"):
499-
default_resource += "/"
500-
if not prm_resource.endswith("/"):
501-
prm_resource += "/"
502496
if not check_resource_allowed(requested_resource=default_resource, configured_resource=prm_resource):
503497
raise OAuthFlowError(f"Protected resource {prm_resource} does not match expected {default_resource}")
504498

src/mcp/client/sse.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ async def post_writer(endpoint_url: str):
138138
json=session_message.message.model_dump(
139139
by_alias=True,
140140
mode="json",
141-
exclude_none=True,
141+
exclude_unset=True,
142142
),
143143
)
144144
response.raise_for_status()

src/mcp/client/stdio.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ async def stdin_writer():
167167
try:
168168
async with write_stream_reader:
169169
async for session_message in write_stream_reader:
170-
json = session_message.message.model_dump_json(by_alias=True, exclude_none=True)
170+
json = session_message.message.model_dump_json(by_alias=True, exclude_unset=True)
171171
await process.stdin.send(
172172
(json + "\n").encode(
173173
encoding=server.encoding,

src/mcp/client/streamable_http.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ async def _handle_post_request(self, ctx: RequestContext) -> None:
260260
async with ctx.client.stream(
261261
"POST",
262262
self.url,
263-
json=message.model_dump(by_alias=True, mode="json", exclude_none=True),
263+
json=message.model_dump(by_alias=True, mode="json", exclude_unset=True),
264264
headers=headers,
265265
) as response:
266266
if response.status_code == 202:

src/mcp/client/websocket.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ async def ws_writer():
6565
async with write_stream_reader:
6666
async for session_message in write_stream_reader:
6767
# Convert to a dict, then to JSON
68-
msg_dict = session_message.message.model_dump(by_alias=True, mode="json", exclude_none=True)
68+
msg_dict = session_message.message.model_dump(by_alias=True, mode="json", exclude_unset=True)
6969
await ws.send(json.dumps(msg_dict))
7070

7171
async with anyio.create_task_group() as tg:

src/mcp/server/lowlevel/server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,7 @@ async def _handle_request(
490490
except Exception as err:
491491
if raise_exceptions: # pragma: no cover
492492
raise err
493-
response = types.ErrorData(code=0, message=str(err), data=None)
493+
response = types.ErrorData(code=0, message=str(err))
494494

495495
await message.respond(response)
496496
else: # pragma: no cover

src/mcp/server/sse.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ async def sse_writer():
170170
await sse_stream_writer.send(
171171
{
172172
"event": "message",
173-
"data": session_message.message.model_dump_json(by_alias=True, exclude_none=True),
173+
"data": session_message.message.model_dump_json(by_alias=True, exclude_unset=True),
174174
}
175175
)
176176

src/mcp/server/stdio.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ async def stdout_writer():
7171
try:
7272
async with write_stream_reader:
7373
async for session_message in write_stream_reader:
74-
json = session_message.message.model_dump_json(by_alias=True, exclude_none=True)
74+
json = session_message.message.model_dump_json(by_alias=True, exclude_unset=True)
7575
await stdout.write(json + "\n")
7676
await stdout.flush()
7777
except (anyio.ClosedResourceError, anyio.BrokenResourceError): # pragma: no cover

src/mcp/server/streamable_http.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ def __init__(
169169
] = {}
170170
self._sse_stream_writers: dict[RequestId, MemoryObjectSendStream[dict[str, str]]] = {}
171171
self._terminated = False
172+
# Idle timeout cancel scope; managed by the session manager.
173+
self.idle_scope: anyio.CancelScope | None = None
172174

173175
@property
174176
def is_terminated(self) -> bool:
@@ -298,12 +300,12 @@ def _create_error_response(
298300
# Return a properly formatted JSON error response
299301
error_response = JSONRPCError(
300302
jsonrpc="2.0",
301-
id="server-error", # We don't have a request ID for general errors
303+
id=None,
302304
error=ErrorData(code=error_code, message=error_message),
303305
)
304306

305307
return Response(
306-
error_response.model_dump_json(by_alias=True, exclude_none=True),
308+
error_response.model_dump_json(by_alias=True, exclude_unset=True),
307309
status_code=status_code,
308310
headers=response_headers,
309311
)
@@ -323,7 +325,7 @@ def _create_json_response(
323325
response_headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id
324326

325327
return Response(
326-
response_message.model_dump_json(by_alias=True, exclude_none=True) if response_message else None,
328+
response_message.model_dump_json(by_alias=True, exclude_unset=True) if response_message else None,
327329
status_code=status_code,
328330
headers=response_headers,
329331
)
@@ -336,7 +338,7 @@ def _create_event_data(self, event_message: EventMessage) -> dict[str, str]:
336338
"""Create event data dictionary from an EventMessage."""
337339
event_data = {
338340
"event": "message",
339-
"data": event_message.message.model_dump_json(by_alias=True, exclude_none=True),
341+
"data": event_message.message.model_dump_json(by_alias=True, exclude_unset=True),
340342
}
341343

342344
# If an event ID was provided, include it
@@ -975,12 +977,11 @@ async def message_router():
975977
# Determine which request stream(s) should receive this message
976978
message = session_message.message
977979
target_request_id = None
978-
# Check if this is a response
979-
if isinstance(message, JSONRPCResponse | JSONRPCError):
980-
response_id = str(message.id)
981-
# If this response is for an existing request stream,
982-
# send it there
983-
target_request_id = response_id
980+
# Check if this is a response with a known request id.
981+
# Null-id errors (e.g., parse errors) fall through to
982+
# the GET stream since they can't be correlated.
983+
if isinstance(message, JSONRPCResponse | JSONRPCError) and message.id is not None:
984+
target_request_id = str(message.id)
984985
# Extract related_request_id from meta if it exists
985986
elif ( # pragma: no cover
986987
session_message.metadata is not None

0 commit comments

Comments
 (0)