Skip to content

Avoid unconditional MCP gateway bootstrap for workspaces that do not use MCP #2113

Description

@jwx0925

Problem

SandboxedWorkspaceBase.initialize() unconditionally initializes the in-sandbox MCP gateway:

await self._provision_backend()
await self._ensure_workspace_layout()
await self._setup_mcp_gateway()
await self._setup_skills()

This happens even when:

  • default_mcps is empty;
  • the persisted .mcp file is empty;
  • the application only uses built-in Bash/file tools and Skills.

All workspace implementations derived from SandboxedWorkspaceBase are 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:

  1. install system packages such as ripgrep;
  2. download and install uv;
  3. create a gateway virtual environment;
  4. install mcp, fastapi, uvicorn, and agentscope;
  5. upload, launch, and health-check _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

workspace = OpenSandboxWorkspace(
    image="python:3.11-slim",
    domain=domain,
    api_key=api_key,
    skill_paths=["./skills/pdf"],
)

await workspace.initialize()

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:

  • During initialize(), inspect default_mcps and the persisted .mcp configuration.
  • If both are empty, skip gateway bootstrap and launch.
  • Add an internal _ensure_mcp_gateway() path that bootstraps/starts the gateway when add_mcp() or another MCP-dependent operation is first used.
  • If persisted MCP configuration exists when a workspace is resumed, start the gateway eagerly and restore those MCPs.
  • Make 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

  • A new sandboxed workspace with no MCP configuration does not install or launch the MCP gateway.
  • Bash, Read, Write, Edit, Glob, Grep, and Skills still work without the gateway.
  • Adding an MCP lazily starts the gateway and preserves current MCP behavior.
  • A resumed workspace with persisted MCP configuration restores its gateway and MCP servers.
  • Existing eager behavior remains available for users who rely on it.
  • The behavior is covered consistently for OpenSandbox, E2B, Daytona, Kubernetes, and Docker workspaces.

Environment

Observed with AgentScope 2.0.4.post1 while using OpenSandboxWorkspace, but the unconditional call is in SandboxedWorkspaceBase and therefore applies to the other sandboxed workspace implementations as well.

Activity

  1. fancyboi999 commented on Jul 17, 2026

    @fancyboi999
    Contributor

    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:

    1. provision backend
    2. create/validate the workspace layout and .mcp
    3. bootstrap and start the MCP gateway unconditionally
    4. set up skills

    That made sense when sandbox tools were exposed through MCP. Since #1903, however, Bash, Read, Write, Edit, Glob, and Grep use BackendBase directly. 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 ripgrep for the builtin Grep tool and uploads _glob_helper.py for the builtin Glob tool. Therefore, simply returning early from _setup_mcp_gateway() when .mcp is 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 ripgrep dependency.
    • 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 .mcp file.
    • 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 _gateway absent 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(), and close() remain valid when no gateway has ever existed.

    I would keep this as automatic, configuration-driven behavior rather than adding a public eager/lazy/disabled mode 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 its environment_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 only cfg.mcp, skips disabled entries, and calls create() 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 or add_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 MCPSessionManager creates 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/list before 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 dynamic add_mcp() -> ensure gateway, then register.

    Questions to settle before implementation

    1. What does "no external network access" cover? On slim E2B/OpenSandbox/Daytona/K8s images, Grep currently requires installing ripgrep. Should that remain a minimal base-runtime dependency, should provider images/snapshots guarantee it, or should Grep gain a fallback/lazy dependency path? We cannot guarantee both zero package installation and a functional Grep on an image without rg unless one of those contracts changes.
    2. 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?
    3. 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?
    4. 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, builtin Glob/Grep, reset/close without a gateway, and real Docker startup in addition to backend-fake tests.

  2. Solaris-star commented on Jul 20, 2026

    @Solaris-star
    Contributor

    Opened a fix in #2136 — skip MCP gateway bootstrap when no MCP is configured; start lazily on add_mcp.

  3. nuthalapativarun commented on Sep 13, 2026

    @nuthalapativarun
    Contributor

    Would like to pick this up. Looked at the current SandboxedWorkspaceBase on main and the closed #2136:

    _setup_mcp_gateway() currently bundles two independent concerns:

    1. installing OS packages (ripgrep) + writing the Glob helper script — used by the builtin Grep/Glob tools, unrelated to MCP
    2. 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.py plus 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_path existing — always called from initialize(), 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) when default_mcps or the restored .mcp specs 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.

  4. nanami7777777 commented on Sep 24, 2026

    @nanami7777777

    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.

  5. github-actions commented on Sep 24, 2026

    @github-actions
    Contributor

    It'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.

  6. github-actions commented on Oct 9, 2026

    @github-actions
    Contributor

    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.

  7. HsbcJone commented on Oct 9, 2026

    @HsbcJone

    /assign

  8. github-actions commented on Oct 9, 2026

    @github-actions
    Contributor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions