fix(mcp): give stdio servers the environment, and let builtins reconnect - #5770
fix(mcp): give stdio servers the environment, and let builtins reconnect#5770MummIndia wants to merge 2 commits into
Conversation
Two defects that combine to make the built-in browser MCP server
unusable, each of which hides the other.
`_connect_stdio` built `env={**os.environ, **env} if env else None`.
`None` does not mean "inherit the parent environment" — the MCP SDK
substitutes a minimal default one. Callers that pass an env inherit
everything, which is why the Python builtins work: they pass
`builtin_python_env(base_dir)`. The NPX browser server passes nothing,
so it loses the entire container environment, including
PLAYWRIGHT_BROWSERS_PATH. It then looks for browsers in the default
cache and reports `Browser "firefox" is not installed` — with the
browser sitting one directory away, which is what makes this so hard to
place.
Second, a stdio session can disappear without the process dying: the
teardown races across asyncio tasks. `call_tool` returned early on a
missing session, and the existing recovery only ran when a call raised
— which presupposes a session. A missing one was therefore terminal,
even though reconnecting would have fixed it. Reconnection is now
attempted in that case too.
`_reconnect_builtin` also excluded the browser outright. It tested
membership against `_BUILTIN_SERVERS`, the Python-server dict, while
`is_builtin()` counts the NPX servers as builtins as well — so the one
server most likely to need a restart was the one that could never get
one. It now handles both kinds.
Together these mean a browser server that dropped mid-session stayed
down for the rest of the process lifetime, and a fresh one started
without the environment it needs. Verified on Docker/Windows: the
server reconnects on demand and reports its 30 tools, and
`browser_navigate` returns exit_code=0 against a real URL.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
o3LL
left a comment
There was a problem hiding this comment.
Reviewed 32bfcb16.
Findings
P1 issue (security): every stdio MCP server now receives the full process environment, overriding the SDK's allow-list
-
Problem:
env={**os.environ, **(env or {})}removes the last case in which a stdio server got a filtered environment. The SDK'sNoneis not an oversight to route around: in the pinnedmcp1.29,mcp/client/stdio.py::get_default_environment()is documented as returning "only environment variables deemed safe to inherit" —DEFAULT_INHERITED_ENV_VARS = ['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER']— and it drops values beginning with()with the comment "Skip functions, which are a security risk". Worth knowing that the SDK already merges that safe set under whatever env you pass ({**get_default_environment(), **server.env}), so an empty env was never a starved environment; it was the filtered one. Measured through the running app, adding a probe server viaPOST /api/mcp/serverswith an empty env field:before: env_var_count=7 canary=<absent> ssh_agent=no after : env_var_count=44 canary=sk-canary-do-not-ship ssh_agent=yes -
Impact: Odysseus reads 13 secret-bearing variables from the environment —
OPENAI_API_KEY(src/constants.py:96),GOOGLE_API_KEY,GOOGLE_OAUTH_CLIENT_SECRET,HF_TOKEN,HUGGING_FACE_HUB_TOKEN,IMAP_PASSWORD,SMTP_PASSWORD,CARDDAV_PASSWORD,TAVILY_API_KEY,SERPER_API_KEY,DATA_BRAVE_API_KEY,EMBEDDING_API_KEYandODYSSEUS_INTERNAL_TOKEN. After this change all of them go tonpx -y @playwright/mcp@latest— a floating third-party package re-resolved on every start — and to any stdio server a user adds from Settings.ODYSSEUS_INTERNAL_TOKENis the sharpest edge:core/middleware.py:31treats it as an admin bypass on every admin route, so it isn't a leaked credential, it's authority over the app's own API — including registering another stdio server, which that route's docstring correctly calls equivalent to executing arbitrary binaries on the host. No shipped compose file sets it, so that part is limited to deployments that set it deliberately. In fairness this widens an existing hole rather than opening one: servers configured with a custom env already took the{**os.environ, **env}branch. But it removes the only configuration where the SDK's protection still applied. -
Ask: keep the widening where it already exists and stop there — full inheritance for the builtins, the SDK default for user-added servers:
from mcp.client.stdio import get_default_environment base = os.environ if self.is_builtin(server_id) else get_default_environment() server_params = StdioServerParameters(command=command, args=args, env={**base, **env})
If servers genuinely need more, make it an opt-in per-server "inherit host environment" toggle in the MCP settings UI so the blast radius is a deliberate choice. If full inheritance for everything is the intended contract, say so in the PR — it then needs a line in that UI, because "add MCP server" would mean "hand this binary every credential in the process".
-
Location:
src/mcp_manager.py:201
P1 issue: the new NPX reconnect drops the browser's args and env — it reintroduces the failure this PR cites
-
Problem: startup and reconnect build the launch two different ways.
register_builtin_serverspasses_browser_mcp_args(cfg["args"])— which appends--executable-path <resolved browser>,--isolatedand--no-sandbox— plusenv={"XDG_CACHE_HOME": ..., "PLAYWRIGHT_BROWSERS_PATH": ...}. The reconnect branch passes rawcfg["args"]and noenv=at all. Captured on this head:STARTUP args=['-y','@playwright/mcp@latest','--headless','--caps','vision', '--executable-path','/opt/homebrew/bin/chromium','--isolated','--no-sandbox'] STARTUP env ={'XDG_CACHE_HOME': '<data>/local/playwright-mcp-cache', 'PLAYWRIGHT_BROWSERS_PATH': '<data>/local/playwright-mcp-cache/browsers'} RECONNECT args=['-y','@playwright/mcp@latest','--headless','--caps','vision'] RECONNECT env =NoneEnd to end: start the browser as startup does, kill the npx child, call
browser_navigatetwice. The first call reaches the configured binary (<launching> /opt/homebrew/bin/chromium ...); after the reconnect the second fails withChromium distribution 'chrome' is not found at /Applications/Google Chrome.app/.... Different browser, because--executable-pathis gone. -
Impact: a reconnected browser server looks for browsers in the default cache instead of Odysseus's — precisely the
Browser "..." is not installedclass of failure this PR quotes as the bug being fixed, on the path added to recover from a crash. Losing--no-sandboxis worse in Docker, where Chromium refuses to start as root, so the reconnect silently downgrades to a browser that cannot launch at all. Losing--isolatedchanges profile persistence, and losingXDG_CACHE_HOMEmeansnpx -y ...@latestre-resolves into a different cache and can bring up a different package version than the one that was running. The reconnect also skips theODYSSEUS_BROWSER_MCP_REQUIRE_CACHEguard, so an install that opted out of network installs at startup gets one anyway. -
Ask: factor the per-server launch configuration out of
register_builtin_serversinto one function —builtin_npx_launch(server_id) -> (command, args, env)— and call it from both places so they can't drift again.tests/test_mcp_reconnect_args.pyalready encodes this rule for the admin-tool reconnect: a reconnect passes the full server config. -
Location:
src/mcp_manager.py:563
P2 issue (docs): the stated diagnosis doesn't hold for the browser server on this branch's own base
-
Problem: the Summary puts the NPX browser server among "callers that pass none" and concludes it "loses
PLAYWRIGHT_BROWSERS_PATH". It doesn't.src/builtin_mcp.py:226— present ind8a2059d, this branch's parent — builds a truthy env forbuiltin_browsercontaining that exact variable, so the old ternary always took the{**os.environ, **env}branch. For the builtin browser at startup this change is a no-op. The linked issue says it was observed on a fork branched fromdevon 2026-06-01; the browser env landed 2026-07-23. "Verified against currentdevby reading the source — unchanged" is true of_connect_stdiobut not of its caller, and the caller decides which branch runs. I booted unmodifieddev:Built-in: Browser (builtin_browser) - 30 tools via stdio, launched with--executable-path. Also worth noting the shipped args carry no--browserflag (src/builtin_mcp.py:84) and Playwright MCP defaults to thechromechannel — I hit that default directly — so aBrowser "firefox" is not installedmessage implies a non-default configuration and probably a different root cause again. -
Impact: the "Before" step in How to Test won't reproduce, so a reviewer can't confirm the fix by the route the PR gives them, and whatever really produced the original error stays unfixed — my best candidate is the reconnect gap above. The callers actually affected by
env=Noneare user-added stdio servers with an empty env field, and, after this PR, the reconnect path itself. That population is the one carrying the security question, so the framing matters. -
Ask: re-run the repro against this branch's base and rewrite the Summary, the code comment and How to Test around whoever is actually affected, or point at the deployment where
builtin_browserreachesenv=None. -
Location:
src/builtin_mcp.py:226
P2 issue: reconnect-on-every-call turns a fast error into an unbounded retry inside the agent turn
-
Problem: a missing session used to return immediately. It now runs a full teardown plus
npx -y @playwright/mcp@lateston every tool call, with no timeout, no backoff and no cap._connect_stdiohas no timeout of its own — the 20s guard exists only in_connect_with_timeout, on the startup path. Five calls against a down builtin produce five connect attempts and five times the latency. -
Impact: an agent turn against a server that is down and stays down stalls once per tool call instead of erroring instantly, and a hung
npx— no network, slow registry — hangs the turn with nothing to cancel it. -
Ask: bound it.
asyncio.wait_for(self._reconnect_builtin(server_id), timeout=20)plus a per-server cooldown so a dead server isn't respawned on every call. -
Location:
src/mcp_manager.py:488
P2 issue: reconnects are unsynchronised
-
Problem:
call_toolnow triggers_reconnect_builtinwhenever a session is missing, and there is not a single lock insrc/mcp_manager.py. N concurrent tool calls against a dropped server each rundisconnect_server+connect_serverfor the sameserver_id. -
Impact: a tool-call burst after a crash spawns overlapping npx processes and interleaves teardown with setup — the same class of race the PR blames for losing the session in the first place.
-
Ask: guard reconnects with a per-server
asyncio.Lock, and have waiters re-checkself._sessionsafter acquiring it. -
Location:
src/mcp_manager.py:493
P2 issue: a failed teardown orphans the old subprocess before the new one starts
-
Problem:
disconnect_servercatches thestack.aclose()failure, logs a warning and drops the reference anyway. The PR's own premise is that teardown races across asyncio tasks — that race is exactly what makesaclose()raise (Attempted to exit cancel scope in a different task), so the reconnect path is the one most likely to hit it. -
Impact: each reconnect can leave an orphaned
npx+ Chromium behind. Combined with the retry above, a down browser server leaks a process per tool call. -
Ask: treat a failed close as a failed teardown — log at error, and terminate the child rather than dropping the handle.
-
Location:
src/mcp_manager.py:407
P3 issue (test): no test, and both halves are cheap to test
-
Problem: neither change has coverage. The env construction is testable by patching
StdioServerParametersand asserting on the dict; the reconnect branch is testable with a fake manager, asserting that a reconnect produces the same command, args and env as the initial connect. -
Ask: one test per half. The reconnect one would have caught the first P1 above, which is the argument for writing it.
-
Location:
src/mcp_manager.py:556
P3 nit: (env or {}) is dead
-
connect_serveralready normalizes withenv or {}before calling, and the parameter is typedDict[str, str].{**os.environ, **env}is enough. -
Location:
src/mcp_manager.py:201
Open Questions
-
question (security): is full-environment inheritance the intended contract for user-added MCP servers, or only for the shipped builtins? That single answer decides whether the fix is one line or three, and it is the only thing keeping me from approving the transport half.
-
question (nit, non-blocking):
_BUILTIN_NPX_SERVERS["builtin_browser"]["command"] = "npx"is now unused — both call sites resolve_find_npx()instead. Drop the key or use it?
Validation
-
Ran:
32bfcb16merged intodevatf9235ebb(clean merge). Full suite: 5306 passed, 2 failed, 4 skipped in 142.5s — both failures the known macOS environment ones (test_workspace_confine.py::test_glob_confined_e2e,test_integration_api_call_ssrf.py::test_real_socket_falls_back_from_dead_first_to_live_second), reproducing on unmodifieddev.python -m compileallclean on the touched files. Booted the app on:7099: all five builtins register, browser included, 30 tools. Added a probe stdio MCP server throughPOST /api/mcp/serverswith an empty env field and read back what it received, on this branch and on unmodifieddev. Drove the builtin browser through kill-and-reconnect and captured what the reconnect spawns. Readget_default_environmentandDEFAULT_INHERITED_ENV_VARSout of the pinnedmcp1.29.0 rather than trusting the docs. Enumerated the secret-bearing variables the app reads from the environment. -
Not run: Docker, and Windows — the linked issue reports both, and the container is where both the
--no-sandboxconsequence and the credential exposure land hardest. No live agent turn against a real model, so the reconnect is verified at the manager layer, not through the agent loop. -
Residual risk: the P1 on reconnect args is verified on macOS against a Homebrew Chromium; the exact error text in a container will differ, but the launch-config divergence is structural and platform-independent. The env change is the kind that looks harmless in testing and only shows up in someone else's threat model — nothing in the suite would fail if it shipped.
PR Hygiene
-
Targets
dev, title matches the Conventional Commits check, template complete, one file, +41/−2.Fixes #5769is open and is exactly this change, so auto-close is correct. Nothing renders, so no screenshot needed. Two independent changes in one PR — defensible since they share a file and a story, but the env change is security-relevant and would be easier to accept or reject on its own. -
The checks are not green in any useful sense. Seven workflows on this head — CI, Secret scan, CodeQL, Dependency review, Container scan (Trivy), Container scan, Workflow security — sit at
action_required, waiting on approval. The only run that executed isci / PR checksviapull_request_target: title, description and the mergeable flag.compileall,node --check, gitleaks and pytest have never run against32bfcb16. -
No other open PR touches
src/mcp_manager.pyorsrc/builtin_mcp.py— checked all 117 open. No duplicate, nothing superseding. Same-author batch as #5762/#5765/#5767/#5772/#5774/#5776, all opened the same day. -
Base
d8a2059dis three weeks behind; merges clean into currentdev, which is what I tested. -
The PR body and the issue body both start with a UTF-8 BOM and every em dash renders as
—. Worth re-pasting the body as UTF-8.
…ment Follow-up to review feedback on this PR (thanks @o3LL). The previous commit widened the stdio environment for every MCP server so the built-in browser could find PLAYWRIGHT_BROWSERS_PATH. That fixed the browser but was too broad: it also handed the entire process environment to user-added servers, bypassing the SDK's allowlist. Odysseus reads 13 secrets from the environment, including ODYSSEUS_INTERNAL_TOKEN — an admin bypass — so an arbitrary third-party command configured as an MCP server received credentials it has no business seeing. Splits the two cases. Built-ins are our own code and still inherit os.environ, which is what the browser needs. Everything else gets the SDK's own `get_default_environment()` — HOME and PATH on this platform — plus whatever that server's configuration explicitly sets, so a legitimate server is not starved of what it was given on purpose. Also drops the `(env or {})` guard: `connect_server` already normalises with `env or {}` before calling, so the parameter is never None here. tests/test_mcp_stdio_env_scope.py pins both directions: a canary variable and ODYSSEUS_INTERNAL_TOKEN reach a built-in, neither reaches a user-added server, the SDK defaults and the server's own env survive the filtering, and an explicit per-server value still overrides the inherited one. Verified they fail against the previous code — reverting the split turns the two user-server tests red and leaves the built-in ones green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Summary
Two defects that combine to make the built-in browser MCP server unusable, each of
which hides the other.
_connect_stdiobuiltenv={**os.environ, **env} if env else None.Nonedoes notmean "inherit the parent environment" — the MCP SDK substitutes a minimal default one.
Callers that pass an env inherit everything, which is why the Python builtins work:
they pass
builtin_python_env(base_dir). The NPX browser server passes nothing, so itloses the entire container environment including
PLAYWRIGHT_BROWSERS_PATH. It thenlooks for browsers in the default cache and reports
Browser "firefox" is not installed— with the browser sitting one directory away, which is what makes thishard to place.
Second, a stdio session can disappear without the process dying: the teardown races
across asyncio tasks.
call_toolreturned early on a missing session, and the existingrecovery only ran when a call raised — which presupposes a session. A missing one was
therefore terminal, even though reconnecting would have fixed it. Reconnection is now
attempted in that case too.
_reconnect_builtinalso excluded the browser outright: it tested membership against_BUILTIN_SERVERS, the Python-server dict, whileis_builtin()counts the NPX serversas builtins as well. It now handles both kinds.
Target branch
dev, notmain.Linked Issue
Fixes #5769
Type of Change
Checklist
devdocker compose up) and verified the change works end-to-end.How to Test
PLAYWRIGHT_BROWSERS_PATHpoints somewhere other than thedefault cache, with the built-in browser server enabled.
Browser "firefox" is not installed.Confirm the env is the cause by printing
os.environinside the spawned server.browser_navigateagainst a real URL returnsexit_code=0.tool. Before, every subsequent call returns
MCP server not connected: builtin_browserfor the process lifetime. After, thelog shows
No session for builtin builtin_browser; attempting reconnectfollowed byReconnected builtin MCP server: Built-in: Browser, and the call succeeds.Visual / UI changes
None.
src/mcp_manager.pyonly — transport and lifecycle, no rendering path.