From adda20509c3ec513d60661c80b7a81a681ab5996 Mon Sep 17 00:00:00 2001 From: looselyhuman Date: Fri, 8 May 2026 16:15:24 -0600 Subject: [PATCH] fix(mcp): remove asyncio.wait_for/shield from MCP cleanup in chat handler (#24105) asyncio.wait_for() and asyncio.shield() create new asyncio Tasks which violate anyio cancel-scope task-ownership rules. The MCPClient's exit_stack contains anyio resources (streamable_http transport) that use anyio cancel scopes. When exited from a different task, anyio raises 'Attempted to exit a cancel scope that isn't the current task's current cancel scope' as a BaseException. This BaseException propagates through the finally block, discards the completed response return value, and surfaces as a 500 Internal Server Error / 'No response returned.' - silently swallowing successful MCP tool calls and blocking the chat endpoint. Fix: call client.disconnect() directly in a simple loop. MCPClient.disconnect() already catches BaseException internally (see prior commit), so no wrapper is needed. Signed-off-by: Adam Tao Co-authored-by: Tim Baek Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com> Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com> Co-authored-by: Kylapaallikko Co-authored-by: Teay Co-authored-by: tcx4c70 Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com> Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com> Co-authored-by: RomualdYT Co-authored-by: Lucas Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com> Co-authored-by: Constantine Co-authored-by: Circe (Claude Code Sonnet 4.6) Co-authored-by: Claude --- backend/open_webui/main.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index c53382a758..aaa98ec7d4 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -2040,25 +2040,28 @@ async def chat_completion( detail=error_detail, ) finally: - # MCP cleanup — MUST run in the SAME asyncio task as - # connect() because the MCP SDK's streamablehttp_client - # uses anyio task groups whose cancel scopes enforce - # same-task exit. Do NOT wrap in asyncio.shield() or - # asyncio.wait_for() — both create a new task. + # Clean up MCP clients. Each client is isolated so one + # failure doesn't skip the rest. + # + # NOTE: asyncio.wait_for() / asyncio.shield() must NOT be used + # here — they create new asyncio Tasks, which violate anyio + # cancel-scope task-ownership rules when the MCPClient's + # exit_stack contains anyio transport resources (streamable_http). + # Exiting those cancel scopes from the wrong task raises + # "Attempted to exit a cancel scope that isn't the current + # task's current cancel scope", which propagates as a + # BaseException through the finally block, discards the response + # return value, and surfaces as a 500 "No response returned." + # MCPClient.disconnect() already catches BaseException internally. try: if mcp_clients := metadata.get('mcp_clients'): for client in reversed(list(mcp_clients.values())): try: await client.disconnect() - except Exception as e: + except BaseException as e: log.debug(f'Error disconnecting MCP client: {e}') - except asyncio.CancelledError: - # Let the client close asynchronously by GC - pass - except Exception as e: + except BaseException as e: log.debug(f'Error cleaning up MCP clients: {e}') - except asyncio.CancelledError: - pass try: if metadata.get('chat_id'):