Stale stdio MCP sessions are never recovered for user-configured servers, and fail with an empty error
Prerequisites
- I searched open issues and discussions and did not find an existing report of this bug.
- This is not a security vulnerability.
- I am running the latest code from the
devbranch and the bug still reproduces there.
Odysseus Revision
3b6c16916233 (2026-09-14)
Install Method
Docker (docker compose up)
Operating System
Linux
Steps to Reproduce
- With the app running, add a new stdio MCP server through Settings → Integrations → Add MCP Server. Any server works; a one-tool script is enough.
- The server connects:
list_toolssucceeds, the integration shows green, and the tool appears in the agent's tool selection. - In agent mode, ask the model to use that tool.
Expected Behaviour
The tool call runs, or fails with a message that says what went wrong.
Actual Behaviour
The call fails, and the log line ends at the colon:
src.mcp_manager - ERROR - MCP tool call failed: mcp__ab545df6__send_message:
src.tool_execution - INFO - Tool executed: mcp: mcp__ab545df6__send_message -> exit_code=1
Nothing after the colon. The same empty string is returned to the model as the tool result, so the model invents a cause — in my case it told me to check the API key of a server whose credentials were fine.
Restarting the app fixes it permanently: the same server, connected during startup instead of from a request, works on the first try.
Two separate defects
1. call_tool only reconnects builtin servers.
# src/mcp_manager.py
except Exception as e:
# Auto-reconnect for builtin servers whose subprocess may have died
if self.is_builtin(server_id):
...
else:
logger.error(f"MCP tool call failed: {qualified_name}: {e}")
return {"error": str(e), "exit_code": 1}
The comment describes exactly the situation a user-configured server hits, but
the branch excludes it. _reconnect_builtin has no counterpart for servers
loaded from the McpServer table, even though _connect_with_timeout already
connects them from the DB at startup.
2. str(e) is empty for the exception that actually occurs.
The session's streams are gone, so anyio.ClosedResourceError is raised, and
str(anyio.ClosedResourceError()) is "". Reported verbatim it produces the
bare colon above — in the log and in the tool result. This is what makes the
bug expensive: there is nothing to search for, and the model fills the gap with
a plausible-sounding but wrong explanation.
Root cause, as far as I can tell
stdio_client opens the subprocess inside whatever asyncio task is running.
When a server is registered through the UI, that is the HTTP request task,
which ends as soon as the response is sent; the streams then belong to a scope
that no longer exists. Servers connected during startup live in a long-lived
task and are unaffected — which matches the observed behaviour exactly.
Properly binding MCP sessions to a long-lived task is a larger change and I have not attempted it. The two defects above are what turn a recoverable condition into a permanent, undiagnosable failure.
Workaround
do_manage_mcp with action: "reconnect" recovers the server without a
restart, so the capability is present — it just has to be invoked manually,
which assumes you already know that a stale session is what you are looking
at. With an empty error message, you do not.
Evidence
The same server, driven directly with the same SDK from inside the same container, works:
TOOLS: ['send_message']
ISERROR: False
CONTENT: Gesendet, message_id=3
Model / Backend (if relevant)
Ollama + qwen3:14b, but the failure is in the MCP layer and is independent of the model.
Are you willing to submit a fix?
Yes — I can open a PR
Additional Information
The empty message also reaches the model, so an agent will confidently report a wrong cause to the user. Even without the reconnect change, falling back to the exception class name would have saved a considerable amount of searching.
Source: odysseus-dev/odysseus