Repository navigation
Avoid unconditional MCP gateway bootstrap for workspaces that do not use MCP #2113
Description
Activity
I investigated the current workspace initialization path and the history of the sandbox tooling changes. I would like to work on this, but I think we should align on the lifecycle boundary before implementing it, because this is slightly broader than skipping one method call.
What appears to be the root cause
SandboxedWorkspaceBase.initialize()currently treats the MCP gateway as part of the sandbox's base runtime:- provision backend
- create/validate the workspace layout and
.mcp - bootstrap and start the MCP gateway unconditionally
- set up skills
That made sense when sandbox tools were exposed through MCP. Since #1903, however,
Bash,Read,Write,Edit,Glob, andGrepuseBackendBasedirectly. The gateway is now an MCP-specific capability, but its lifecycle is still attached to every sandbox lifecycle.There is one important coupling hidden inside the current bootstrap: it also installs
ripgrepfor the builtinGreptool and uploads_glob_helper.pyfor the builtinGlobtool. Therefore, simply returning early from_setup_mcp_gateway()when.mcpis empty would regress the builtins that this issue requires to remain functional.Proposed lifecycle
I suggest splitting provisioning into two capability-owned layers:
- Base workspace runtime: workspace directories, the Glob helper, and the chosen strategy for satisfying Grep's
ripgrepdependency. - MCP runtime:
uv/venv, MCP gateway dependencies and script, gateway process, health check, and restored MCP clients.
The behavior would then be:
- During
initialize(), always prepare the base runtime. - Read and validate the persisted
.mcpfile. - If defaults or persisted MCP entries are non-empty, eagerly ensure the gateway and restore clients, preserving the current configured-MCP behavior.
- If there are no MCP entries, leave
_gatewayabsent and return an empty MCP list without installing or launching the gateway runtime. add_mcp()calls a concurrency-safe, retryable_ensure_mcp_gateway()before registering the first server.remove_mcp(),reset(), andclose()remain valid when no gateway has ever existed.
I would keep this as automatic, configuration-driven behavior rather than adding a public
eager/lazy/disabledmode in the first change. I have not found a concrete use case that requires an empty gateway to run eagerly, and a public mode would expand the API and backend compatibility surface without being necessary to fix the cold-start problem.Concrete prior art
The projects below do not all implement the same kind of laziness. The common property is narrower: the MCP runtime is owned by the MCP capability/configuration rather than being an unconditional side effect of creating an execution environment.
Codex: start enabled servers only, in their selected environment
Codex constructs its MCP connection manager by filtering the configured servers before starting any client (source):
let mcp_servers = mcp_servers.clone(); for (server_name, server) in mcp_servers .into_iter() .filter(|(_, server)| server.enabled()) { // create/start this server's client }
With an empty configuration, or with every server disabled, this loop starts no clients;
has_servers()is simply!self.clients.is_empty()(source). It also separates MCP startup policy from execution placement: an MCP server resolves itsenvironment_id, and a non-local stdio server is launched through that environment's exec backend (source):let launcher = if is_local_environment { Arc::new(LocalStdioServerLauncher::new(...)) } else { Arc::new(ExecutorStdioServerLauncher::new( environment.get_exec_backend(), )) };
This is the closest conceptual match for AgentScope's remote sandboxes: the environment can exist without MCP, while configured MCP servers still execute inside the selected environment. Replacing AgentScope's gateway with per-server environment launchers would be a much larger redesign, though; the useful precedent for #2113 is the lifecycle separation, not necessarily Codex's transport implementation.
OpenCode: MCP is an instance-scoped service; empty config creates nothing
OpenCode stores MCP in a separate
InstanceState. When that state is materialized, it reads onlycfg.mcp, skips disabled entries, and callscreate()only for active entries (source):const config = cfg.mcp ?? {} const s: State = { config: {}, status: {}, clients: {}, defs: {}, instructions: {} } yield* Effect.forEach( Object.entries(config), ([key, mcp]) => Effect.gen(function* () { if (mcp.enabled === false) { s.status[key] = { status: "disabled" } return } const result = yield* create(key, mcp) if (result.mcpClient) s.clients[key] = result.mcpClient }), )
Therefore
{}is a zero-client state, while dynamic connection remains an MCP-service operation. The service finalizer closes only the clients it actually owns (source). This maps closely to an AgentScope_ensure_mcp_gateway()that is invoked by persisted/default MCP configuration oradd_mcp(), rather than by every workspace initialization.Deep Agents: no-config short circuit plus two-phase discovery/runtime
Deep Agents returns before loading MCP when no usable configuration remains (source):
if not configs: return [], None, _bad_config_infos() merged = merge_mcp_configs(configs) if not merged.get("mcpServers"): return [], None, _bad_config_infos() # remove disabled servers ... if not merged.get("mcpServers"): return [], None, disabled_infos + _bad_config_infos()
It then distinguishes schema discovery from live invocation sessions. Discovery uses throwaway sessions, while
MCPSessionManagercreates and caches the real per-server session only on the first tool call, protected by a per-server lock (source, source):async def get_session(self, server_name: str) -> ClientSession: entry = self._entries.get(server_name) if entry is not None: return entry.session lock = self._get_lock(server_name) async with lock: entry = self._entries.get(server_name) if entry is None: entry = await self._create_entry(server_name) self._entries[server_name] = entry return entry.session
The applicable lesson for AgentScope is the single-flight state transition: concurrent first
add_mcp()calls must not bootstrap two gateways. The two-phase discovery design is not directly portable because AgentScope's gateway owns the sandbox-side transport itself.Hermes: cheap zero-config probe and bounded background discovery
Hermes explicitly avoids even importing its MCP stack for non-MCP users. It first probes raw configuration, then starts one background discovery thread only when servers exist (source):
def _has_configured_mcp_servers() -> bool: mcp_servers = (read_raw_config() or {}).get("mcp_servers") return isinstance(mcp_servers, dict) and len(mcp_servers) > 0 def start_background_mcp_discovery(*, logger, thread_name: str) -> None: if _mcp_discovery_started: return _mcp_discovery_started = True if not _has_configured_mcp_servers(): return # start background discovery thread
Its first tool snapshot waits only for a bounded timeout; slower servers can appear through a later refresh (source). This is evidence that unconditional MCP work on a general startup path is operationally risky. I would not copy the background/late-binding behavior into the first AgentScope fix because it introduces toolkit snapshot and readiness semantics that #2113 does not require.
A completely tool-call-lazy design would also be a different, larger change: MCP tool schemas normally have to be discovered through
tools/listbefore those tools can be exposed to the model. For AgentScope, the minimal compatible boundary is therefore zero MCP config -> no gateway runtime; configured/persisted MCP -> gateway available before toolkit construction; first dynamicadd_mcp()-> ensure gateway, then register.Questions to settle before implementation
- What does "no external network access" cover? On slim E2B/OpenSandbox/Daytona/K8s images,
Grepcurrently requires installingripgrep. Should that remain a minimal base-runtime dependency, should provider images/snapshots guarantee it, or shouldGrepgain a fallback/lazy dependency path? We cannot guarantee both zero package installation and a functionalGrepon an image withoutrgunless one of those contracts changes. - What is the Docker acceptance boundary? The Docker image already bakes gateway dependencies into the image. Is the requirement that an empty-MCP workspace does not launch the gateway, or must the image also stop installing gateway dependencies at build time?
- Is config-driven eager startup sufficient? I interpret "existing eager behavior remains available" as: non-empty default/persisted MCP configuration still starts during workspace initialization. Is there a separate requirement to force-start an empty gateway?
- How should the open Bubblewrap backend PR feat(workspace): add bubblewrap workspace backend #2051 be handled? It currently adds its own gateway bootstrap/lifecycle. If it lands before this issue is implemented, the new lifecycle should cover or be adopted by Bubblewrap too so backend behavior does not diverge.
If this direction matches the intended contract, I can turn it into a focused implementation and validation matrix covering empty startup, persisted/default MCP restore, first concurrent
add_mcp(), retry after bootstrap failure, builtinGlob/Grep, reset/close without a gateway, and real Docker startup in addition to backend-fake tests.Opened a fix in #2136 — skip MCP gateway bootstrap when no MCP is configured; start lazily on
add_mcp.Would like to pick this up. Looked at the current
SandboxedWorkspaceBaseonmainand the closed #2136:_setup_mcp_gateway()currently bundles two independent concerns:- installing OS packages (
ripgrep) + writing the Glob helper script — used by the builtin Grep/Glob tools, unrelated to MCP - provisioning the gateway venv (
uv,mcp/fastapi/uvicorn,agentscope) and launching + health-checking the gateway process — only needed when MCP servers are configured
#2136 skipped the whole call when no MCP is configured, which also skips (1) — that would silently break Grep/Glob for any MCP-less workspace, which conflicts with this issue's own acceptance criteria ("Bash, Read, Write, Edit, Glob, Grep, and Skills still work without the gateway").
Proposed split (touches
_sandboxed_base.pyplus the per-backend_bootstrap_commands()in OpenSandbox/E2B/Daytona/Kubernetes/AppleContainer/Bubblewrap — Docker's image already bundles both at build time, so it's unaffected):- New
_system_bootstrap_commands()hook: just the OS-package install (ripgrep,curl,ca-certificates) each backend already has, moved out of_bootstrap_commands(). - New
_ensure_system_runtime()in the base class: runs_system_bootstrap_commands()+ writes the Glob helper once, gated on_glob_helper_pathexisting — always called frominitialize(), independent of MCP config. _bootstrap_commands()keeps only the venv/package install for the gateway itself.initialize()calls_ensure_system_runtime()unconditionally, then only calls_setup_mcp_gateway()(bootstrap gateway venv + launch + health-check) whendefault_mcpsor the restored.mcpspecs are non-empty; otherwise leaves_gateway = None.add_mcp()lazily calls_setup_mcp_gateway()first if_gateway is None.
Bubblewrap fully overrides
_setup_mcp_gateway()/initialize()with its own port-allocation/retry logic, so it needs the same split applied locally rather than just relying on the base class change.Before I put together the PR: does this split look right to you, or would you rather see the
mcp_gateway="auto"|"eager"|"disabled"mode from the issue's alternative suggestion instead? Want to avoid a repeat of #2136 going stale on a design nobody signed off on.- installing OS packages (
I would like to work on this issue using Codex, with a focused change that separates base Glob/Grep setup from MCP gateway setup and starts the gateway only when MCPs are configured or first added. I will review the implementation and tests before submitting a PR.
github-actions commented
on Sep 24, 2026 on Sep 24, 2026 – with GitHub ActionsContributorMore actionsIt's yours, @nanami7777777. If there is no pull request and no word from you by 2026-10-08 the claim is released so nobody is blocked — a comment here renews it.
Releasing this claim: 14 days have passed with no pull request and no word from @nanami7777777. If you are still on it, just say so and it is yours again — otherwise it is open for anyone to take.
/assign
It's yours, @HsbcJone. If there is no pull request and no word from you by 2026-10-23 the claim is released so nobody is blocked — a comment here renews it.
Problem
SandboxedWorkspaceBase.initialize()unconditionally initializes the in-sandbox MCP gateway:This happens even when:
default_mcpsis empty;.mcpfile is empty;All workspace implementations derived from
SandboxedWorkspaceBaseare affected, including OpenSandbox, E2B, Daytona, Kubernetes, and Docker. Docker usually pays the installation cost at image-build time, but it still launches and health-checks the gateway on every initialization.For a fresh remote sandbox, the OpenSandbox/E2B/Daytona/Kubernetes paths may need to:
ripgrep;uv;mcp,fastapi,uvicorn, andagentscope;_mcp_gateway_app.py.This can add minutes to cold-start latency and introduces package-index and external-network dependencies before a workspace can be used, even though Bash, file operations, and Skills do not require the MCP gateway.
Minimal reproduction
No MCP server is configured, but initialization still bootstraps and starts the MCP gateway.
The same behavior follows from the shared base class for E2B, Daytona, and Kubernetes workspaces.
Expected behavior
A sandboxed workspace with no configured or persisted MCP servers should become usable without installing or starting the MCP gateway. Built-in tools and Skills should continue to work through the workspace backend.
The gateway should be initialized when it is actually needed.
Suggested design
One possible approach is lazy initialization:
initialize(), inspectdefault_mcpsand the persisted.mcpconfiguration._ensure_mcp_gateway()path that bootstraps/starts the gateway whenadd_mcp()or another MCP-dependent operation is first used.list_mcps()return an empty list without requiring a running gateway when no MCP has been configured.Alternatively, expose an explicit mode such as
mcp_gateway="auto" | "eager" | "disabled", with a backward-compatible default.Acceptance criteria
Environment
Observed with AgentScope
2.0.4.post1while usingOpenSandboxWorkspace, but the unconditional call is inSandboxedWorkspaceBaseand therefore applies to the other sandboxed workspace implementations as well.