fix(sdk): clean up sandbox when MCP gateway startup fails (#1548)

## Problem

Fixes #1498.

`Sandbox.create` allocates a remote sandbox before starting
`mcp-gateway`. If gateway startup fails, creation throws before the
sandbox object is returned. As a result, the caller has no sandbox ID to
clean up, and the orphaned sandbox continues consuming resources until
it times out.

This state transition exists in synchronous Python, asynchronous Python,
and JavaScript/TypeScript.

## Changes

- Add a rollback boundary around MCP gateway startup in all three SDK
implementations: on failure, best-effort kill the newly allocated
sandbox, then re-raise.
- Surface gateway startup failure as `SandboxError` (JS) /
`SandboxException` (Python) with a `Failed to start MCP gateway:
<stderr>` message. Previously the intended message was unreachable dead
code — foreground `commands.run` already throws on non-zero exit — so
callers got a bare `CommandExitError`/`CommandExitException`.
- In async Python, re-raise `asyncio.CancelledError` from the
best-effort `kill()` so caller cancellation (e.g. `asyncio.timeout`) is
honored; only ordinary cleanup failures are suppressed and never mask
the original error.
- Add integration coverage for synchronous Python, asynchronous Python,
and TypeScript. The tests pin the sandbox to the base template (which
has no `mcp-gateway` binary) so gateway startup genuinely fails after
allocation.
- Add a patch changeset for `e2b` and `@e2b/python-sdk`.

## Usage Behavior

No API changes. A failed creation no longer leaves a sandbox behind, and
the error is now descriptive:

```ts
try {
  const sandbox = await Sandbox.create({ mcp: { ... } })
} catch (err) {
  // err is SandboxError: "Failed to start MCP gateway: <stderr>"
  // the allocated sandbox has already been killed — no orphan is left running
}
```

## Validation

All three integration tests verified against real infra: creation
rejects with the documented error and no sandbox remains.

## Notes

Supersedes #1547 by @hxaxd (squash-merged into this branch to preserve
attribution).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: 苏紫辰 <155808914+hxaxd@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mish Ushakov
2026-08-07 17:26:08 +02:00
committed by GitHub
parent e6111419b5
commit cab27aa6fa
7 changed files with 158 additions and 28 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"e2b": patch
"@e2b/python-sdk": patch
---
Kill newly created sandboxes when MCP gateway startup fails. The failure now surfaces as `SandboxError` (JS) / `SandboxException` (Python) with a `Failed to start MCP gateway: <stderr>` message instead of a bare command exit error.
+17 -11
View File
@@ -11,6 +11,7 @@ import { EnvdApiClient, handleEnvdApiError } from '../envd/api'
import { createEnvdFetch, createEnvdRpcFetch } from '../envd/http2'
import { createRpcLogger } from '../logs'
import { Commands, Pty } from './commands'
import { CommandExitError } from './commands/commandHandle'
import { Filesystem } from './filesystem'
import { Git } from './git'
import {
@@ -30,7 +31,7 @@ import {
} from './sandboxApi'
import { getSignature } from './signature'
import { compareVersions } from 'compare-versions'
import { InvalidArgumentError, TemplateError } from '../errors'
import { InvalidArgumentError, SandboxError, TemplateError } from '../errors'
import { ENVD_DEBUG_FALLBACK, ENVD_DEFAULT_USER } from '../envd/versions'
import { shellQuote } from '../utils'
@@ -321,17 +322,22 @@ export class Sandbox extends SandboxApi {
if (sandboxOpts?.mcp) {
sandbox.mcpToken = crypto.randomUUID()
const res = await sandbox.commands.run(
`mcp-gateway --config ${shellQuote(JSON.stringify(sandboxOpts.mcp))}`,
{
user: 'root',
envs: {
GATEWAY_ACCESS_TOKEN: sandbox.mcpToken ?? '',
},
try {
await sandbox.commands.run(
`mcp-gateway --config ${shellQuote(JSON.stringify(sandboxOpts.mcp))}`,
{
user: 'root',
envs: {
GATEWAY_ACCESS_TOKEN: sandbox.mcpToken ?? '',
},
}
)
} catch (error) {
await sandbox.kill().catch(() => undefined)
if (error instanceof CommandExitError) {
throw new SandboxError(`Failed to start MCP gateway: ${error.stderr}`)
}
)
if (res.exitCode !== 0) {
throw new Error(`Failed to start MCP gateway: ${res.stderr}`)
throw error
}
}
+36 -1
View File
@@ -1,4 +1,4 @@
import { assert, test } from 'vitest'
import { assert, expect, test } from 'vitest'
import { Sandbox } from '../../src'
import { template, isDebug } from '../setup.js'
@@ -32,3 +32,38 @@ test.skipIf(isDebug)('metadata', async () => {
await sbx.kill()
}
})
test.skipIf(isDebug)(
'MCP gateway start failure kills the created sandbox',
async () => {
const metadata = { mcpGatewayCleanupTestId: crypto.randomUUID() }
const query = { state: ['running' as const], metadata }
let remainingSandboxes: Awaited<
ReturnType<ReturnType<typeof Sandbox.list>['nextItems']>
> = []
try {
// The base template has no mcp-gateway binary, so gateway startup
// reliably fails after the sandbox has been allocated.
await expect(
Sandbox.create(template, {
timeoutMs: 60_000,
metadata,
mcp: { invalid_server: {} } as never,
})
).rejects.toThrow('Failed to start MCP gateway')
remainingSandboxes = await Sandbox.list({ query }).nextItems()
expect(remainingSandboxes).toEqual([])
} finally {
remainingSandboxes = await Sandbox.list({ query })
.nextItems()
.catch(() => remainingSandboxes)
await Promise.all(
remainingSandboxes.map((sandbox) =>
Sandbox.kill(sandbox.sandboxId).catch(() => false)
)
)
}
}
)
+21 -7
View File
@@ -1,3 +1,4 @@
import asyncio
import datetime
import json
import logging
@@ -16,9 +17,11 @@ from e2b.connection_config import ApiParams, ConnectionConfig
from e2b.envd.api import ENVD_API_HEALTH_ROUTE, ahandle_envd_api_exception
from e2b.envd.versions import ENVD_DEBUG_FALLBACK
from e2b.exceptions import (
SandboxException,
TemplateException,
format_request_timeout_error,
)
from e2b.sandbox.commands.command_handle import CommandExitException
from e2b.sandbox.main import SandboxOpts
from e2b.sandbox.sandbox_api import (
McpServer,
@@ -239,13 +242,24 @@ class AsyncSandbox(SandboxApi):
token = str(uuid.uuid4())
sandbox._mcp_token = token
res = await sandbox.commands.run(
f"mcp-gateway --config {shlex.quote(json.dumps(mcp))}",
user="root",
envs={"GATEWAY_ACCESS_TOKEN": token},
)
if res.exit_code != 0:
raise Exception(f"Failed to start MCP gateway: {res.stderr}")
try:
await sandbox.commands.run(
f"mcp-gateway --config {shlex.quote(json.dumps(mcp))}",
user="root",
envs={"GATEWAY_ACCESS_TOKEN": token},
)
except BaseException as e:
try:
await sandbox.kill()
except asyncio.CancelledError:
raise
except Exception:
pass
if isinstance(e, CommandExitException):
raise SandboxException(
f"Failed to start MCP gateway: {e.stderr}"
) from e
raise
return sandbox
+18 -7
View File
@@ -14,9 +14,11 @@ from e2b.connection_config import ApiParams, ConnectionConfig
from e2b.envd.api import ENVD_API_HEALTH_ROUTE, handle_envd_api_exception
from e2b.envd.versions import ENVD_DEBUG_FALLBACK
from e2b.exceptions import (
SandboxException,
TemplateException,
format_request_timeout_error,
)
from e2b.sandbox.commands.command_handle import CommandExitException
from e2b.sandbox.main import SandboxOpts
from e2b.sandbox.sandbox_api import (
McpServer,
@@ -228,13 +230,22 @@ class Sandbox(SandboxApi):
token = str(uuid.uuid4())
sandbox._mcp_token = token
res = sandbox.commands.run(
f"mcp-gateway --config {shlex.quote(json.dumps(mcp))}",
user="root",
envs={"GATEWAY_ACCESS_TOKEN": token},
)
if res.exit_code != 0:
raise Exception(f"Failed to start MCP gateway: {res.stderr}")
try:
sandbox.commands.run(
f"mcp-gateway --config {shlex.quote(json.dumps(mcp))}",
user="root",
envs={"GATEWAY_ACCESS_TOKEN": token},
)
except BaseException as e:
try:
sandbox.kill()
except Exception:
pass
if isinstance(e, CommandExitException):
raise SandboxException(
f"Failed to start MCP gateway: {e.stderr}"
) from e
raise
return sandbox
@@ -1,10 +1,11 @@
import asyncio
from typing import Any, cast
from uuid import uuid4
import httpx
import pytest
from e2b import AsyncSandbox, SandboxQuery, SandboxState
from e2b import AsyncSandbox, SandboxException, SandboxQuery, SandboxState
from e2b.api.client.models import (
NewSandbox,
SandboxAutoResumeConfig,
@@ -36,6 +37,34 @@ async def test_metadata(async_sandbox_factory):
assert False, "Sandbox not found"
@pytest.mark.skip_debug()
async def test_mcp_gateway_start_failure_kills_created_sandbox(template):
metadata = {"mcp_gateway_cleanup_test_id": str(uuid4())}
query = SandboxQuery(state=[SandboxState.RUNNING], metadata=metadata)
remaining_sandboxes = []
try:
# The base template has no mcp-gateway binary, so gateway startup
# reliably fails after the sandbox has been allocated.
with pytest.raises(SandboxException, match="Failed to start MCP gateway"):
await AsyncSandbox.create(
template,
timeout=60,
metadata=metadata,
mcp=cast(Any, {"invalid_server": {}}),
)
remaining_sandboxes = await AsyncSandbox.list(query=query).next_items()
assert remaining_sandboxes == []
finally:
try:
remaining_sandboxes = await AsyncSandbox.list(query=query).next_items()
except Exception:
pass
for sandbox in remaining_sandboxes:
await AsyncSandbox.kill(sandbox.sandbox_id)
def test_create_payload_serializes_auto_resume_enabled():
body = NewSandbox(
template_id="template-id",
@@ -1,10 +1,11 @@
from time import sleep
from typing import Any, cast
from uuid import uuid4
import httpx
import pytest
from e2b import Sandbox, SandboxState
from e2b import Sandbox, SandboxException, SandboxState
from e2b.api.client.models import (
NewSandbox,
SandboxAutoResumeConfig,
@@ -37,6 +38,34 @@ def test_metadata(sandbox_factory):
assert False, "Sandbox not found"
@pytest.mark.skip_debug()
def test_mcp_gateway_start_failure_kills_created_sandbox(template):
metadata = {"mcp_gateway_cleanup_test_id": str(uuid4())}
query = SandboxQuery(state=[SandboxState.RUNNING], metadata=metadata)
remaining_sandboxes = []
try:
# The base template has no mcp-gateway binary, so gateway startup
# reliably fails after the sandbox has been allocated.
with pytest.raises(SandboxException, match="Failed to start MCP gateway"):
Sandbox.create(
template,
timeout=60,
metadata=metadata,
mcp=cast(Any, {"invalid_server": {}}),
)
remaining_sandboxes = Sandbox.list(query=query).next_items()
assert remaining_sandboxes == []
finally:
try:
remaining_sandboxes = Sandbox.list(query=query).next_items()
except Exception:
pass
for sandbox in remaining_sandboxes:
Sandbox.kill(sandbox.sandbox_id)
def test_create_payload_serializes_auto_resume_enabled():
body = NewSandbox(
template_id="template-id",