From 532c60fe8c2af64f4cb87c29ba3a90d47a127aba Mon Sep 17 00:00:00 2001 From: Prometheus Date: Fri, 24 Apr 2026 06:33:42 +0200 Subject: [PATCH] fix(mcp): remove asyncio.wait_for/shield from MCP cleanup in chat handler 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. 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 af570af0af..d32fd8de70 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -1919,25 +1919,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'):