Initial Checks
Description
ClientSession.send_request() (and therefore every call_tool, list_tools, etc.) never emits a notifications/cancelled message when its in-flight await is interrupted, regardless of whether the interruption comes from the SDK's own timeout or from the caller's asyncio.wait_for. The MCP spec (cancellation.mdx) requires the sender to issue this notification on timeout, and any cooperative cancellation likewise leaves the server with an orphan request.
Empirical impact: server-side tool coroutines remain suspended after a client cancellation. They hold whatever resources they had acquired (DB connections, cursors, locks, file handles) until the session itself ends. With long-lived sessions and cancellable workloads, every cancelled call is a silent leak.
This was previously raised in #1458 ("Missing Cancellation Notifications on Request Timeout") and closed as DUPLICATE, but the proposed fix never landed and the underlying behavior is still present in 1.29.0. The current report adds a second uncovered path (external CancelledError) and concrete evidence of the resource-leak impact, so I'm filing rather than commenting on the closed issue.
Two paths, neither sends the notification
Path A — SDK-internal timeout (anyio.fail_after): Already documented in #1458. The except TimeoutError branch raises McpError and falls into the finally block that closes the local response stream. No CancelledNotification is sent.
mcp/shared/session.py:290-303 (1.29.0):
try:
withanyio.fail_after(timeout):
response_or_error=awaitresponse_stream_reader.receive()
exceptTimeoutError:
raiseMcpError(
ErrorData(
code=httpx.codes.REQUEST_TIMEOUT,
message=(
f"Timed out while waiting for response to "f"{request.__class__.__name__}. Waited "f"{timeout} seconds."
),
)
)Path B — external cancellation: When the caller wraps session.call_tool(...) in asyncio.wait_for(...) (or any other cancellation source), asyncio.CancelledError is raised at the await response_stream_reader.receive() point. There is no except for CancelledError / anyio.get_cancelled_exc_class() anywhere in send_request. The exception flows up through the finally, which only cleans up the client-local response stream:
mcp/shared/session.py:310-313:
finally:
self._response_streams.pop(request_id, None)
self._progress_callbacks.pop(request_id, None)
awaitresponse_stream.aclose()
awaitresponse_stream_reader.aclose()
The server is never told the request is gone. Its in-flight tool task continues to completion, then sends back a response that gets dropped because no one is reading the response stream.
Reproduction
Minimal repro script (full version: https://github.com/sherman94062/databricks-ai-steward/blob/main/stress/probe_a1_leak.py):
importasynciofrommcpimportClientSession, StdioServerParametersfrommcp.client.stdioimportstdio_client# stress.server is a tiny FastMCP server with two tools:# hangs_forever_async_guarded — `await asyncio.sleep(300)`# task_count — returns len(asyncio.all_tasks()) - 1asyncdefmain():
params=StdioServerParameters(
command="python", args=["-m", "stress.server"]
)
asyncwithstdio_client(params) as (read, write):
asyncwithClientSession(read, write) assession:
awaitsession.initialize()
baseline=awaitsession.call_tool("task_count", {})
print("baseline:", baseline)
for_inrange(50):
try:
awaitasyncio.wait_for(
session.call_tool("hangs_forever_async_guarded", {}),
timeout=0.1,
)
exceptasyncio.TimeoutError:
passawaitasyncio.sleep(0.5) # let any cleanup settleafter=awaitsession.call_tool("task_count", {})
print("after 50 cancels:", after)
asyncio.run(main())Output (mcp 1.29.0, Python 3.14):
baseline: 4 tasks
after 50 cancels: 54 tasks
50 cancelled call_tool invocations → 50 leaked server-side coroutines, persistent until the session closes. Same numbers under stdio and streamable_http.
If the client sent notifications/cancelled, the server's existing handler at mcp/shared/session.py:402-406 would cancel each leaked coroutine immediately:
ifisinstance(notification.root, CancelledNotification):
cancelled_id=notification.root.params.requestIdifcancelled_idinself._in_flight:
awaitself._in_flight[cancelled_id].cancel()
The server side already does the right thing on receipt. Only the client-side emit is missing.
Spec citations
Implementations SHOULD establish timeouts for all sent requests… When the request has not received a success or error response within the timeout period, the sender SHOULD issue a cancellation notification for that request and stop waiting for a response.
Either side can cancel an in-progress request by sending a cancellation notification.
— https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/draft/basic/utilities/cancellation.mdx
Proposed fix
Two small additions to BaseSession.send_request. The internal-timeout branch is exactly what #1458 proposed; the external-cancellation branch is new and uses anyio.get_cancelled_exc_class() so it works under both asyncio and trio backends:
try:
withanyio.fail_after(timeout):
response_or_error=awaitresponse_stream_reader.receive()
exceptTimeoutError:
awaitself._send_cancelled_notification(request_id, "request timed out")
raiseMcpError(...)
exceptanyio.get_cancelled_exc_class():
awaitself._send_cancelled_notification(request_id, "request cancelled by caller")
raiseasyncdef_send_cancelled_notification(self, request_id, reason):
try:
awaitself.send_notification(
ClientNotification(
CancelledNotification(
method="notifications/cancelled",
params=CancelledNotificationParams(
requestId=request_id, reason=reason
),
)
)
)
exceptException:
# Best-effort: if the transport is already gone, nothing to do.logger.warning(
"failed to send cancellation notification for request %s",
request_id,
)
The notification must be sent before re-raising — once the cancellation propagates out of send_request, the caller may close the session and the write stream becomes unusable. A small async-shielded wrapper around the send_notification call may be needed to guarantee delivery on the cancellation path; happy to put that into a PR.
Why this matters in practice
Most production MCP servers acquire external resources inside tool handlers — DB connections, HTTP clients, transactions, file locks. Without the cancellation notification, every aborted client call wastes one such resource for the lifetime of the session. We discovered this while building a Databricks-facing MCP server: a connection pool of 10 plus 10 cancelled tool calls = pool exhausted.
A server-side per-tool timeout (asyncio.wait_for inside the tool wrapper) bounds the leak window, but it shouldn't be load-bearing. The client should tell the server when a request is dead.
Environment
mcp 1.29.0 (latest at time of writing)- Python 3.14.3, macOS 14
- Same behavior reproduced with
streamable_http transport (different transport, identical client cancellation path)
Related
Initial Checks
Description
ClientSession.send_request()(and therefore everycall_tool,list_tools, etc.) never emits anotifications/cancelledmessage when its in-flightawaitis interrupted, regardless of whether the interruption comes from the SDK's own timeout or from the caller'sasyncio.wait_for. The MCP spec (cancellation.mdx) requires the sender to issue this notification on timeout, and any cooperative cancellation likewise leaves the server with an orphan request.Empirical impact: server-side tool coroutines remain suspended after a client cancellation. They hold whatever resources they had acquired (DB connections, cursors, locks, file handles) until the session itself ends. With long-lived sessions and cancellable workloads, every cancelled call is a silent leak.
This was previously raised in #1458 ("Missing Cancellation Notifications on Request Timeout") and closed as
DUPLICATE, but the proposed fix never landed and the underlying behavior is still present in1.29.0. The current report adds a second uncovered path (externalCancelledError) and concrete evidence of the resource-leak impact, so I'm filing rather than commenting on the closed issue.Two paths, neither sends the notification
Path A — SDK-internal timeout (
anyio.fail_after): Already documented in #1458. Theexcept TimeoutErrorbranch raisesMcpErrorand falls into thefinallyblock that closes the local response stream. NoCancelledNotificationis sent.mcp/shared/session.py:290-303(1.29.0):Path B — external cancellation: When the caller wraps
session.call_tool(...)inasyncio.wait_for(...)(or any other cancellation source),asyncio.CancelledErroris raised at theawait response_stream_reader.receive()point. There is noexceptforCancelledError/anyio.get_cancelled_exc_class()anywhere insend_request. The exception flows up through thefinally, which only cleans up the client-local response stream:mcp/shared/session.py:310-313:The server is never told the request is gone. Its in-flight tool task continues to completion, then sends back a response that gets dropped because no one is reading the response stream.
Reproduction
Minimal repro script (full version: https://github.com/sherman94062/databricks-ai-steward/blob/main/stress/probe_a1_leak.py):
Output (mcp 1.29.0, Python 3.14):
50 cancelled
call_toolinvocations → 50 leaked server-side coroutines, persistent until the session closes. Same numbers under stdio andstreamable_http.If the client sent
notifications/cancelled, the server's existing handler atmcp/shared/session.py:402-406would cancel each leaked coroutine immediately:The server side already does the right thing on receipt. Only the client-side emit is missing.
Spec citations
— https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/draft/basic/utilities/cancellation.mdx
Proposed fix
Two small additions to
BaseSession.send_request. The internal-timeout branch is exactly what #1458 proposed; the external-cancellation branch is new and usesanyio.get_cancelled_exc_class()so it works under both asyncio and trio backends:The notification must be sent before re-raising — once the cancellation propagates out of
send_request, the caller may close the session and the write stream becomes unusable. A small async-shielded wrapper around thesend_notificationcall may be needed to guarantee delivery on the cancellation path; happy to put that into a PR.Why this matters in practice
Most production MCP servers acquire external resources inside tool handlers — DB connections, HTTP clients, transactions, file locks. Without the cancellation notification, every aborted client call wastes one such resource for the lifetime of the session. We discovered this while building a Databricks-facing MCP server: a connection pool of 10 plus 10 cancelled tool calls = pool exhausted.
A server-side per-tool timeout (
asyncio.wait_forinside the tool wrapper) bounds the leak window, but it shouldn't be load-bearing. The client should tell the server when a request is dead.Environment
mcp1.29.0 (latest at time of writing)streamable_httptransport (different transport, identical client cancellation path)Related