Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/quiet-sandboxes-clean.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 17 additions & 11 deletions packages/js-sdk/src/sandbox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -29,7 +30,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'

Expand Down Expand Up @@ -320,17 +321,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
}
}
Comment thread
mishushakov marked this conversation as resolved.

Expand Down
37 changes: 36 additions & 1 deletion packages/js-sdk/tests/sandbox/create.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
)
)
}
}
)
28 changes: 21 additions & 7 deletions packages/python-sdk/e2b/sandbox_async/main.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import datetime
import json
import logging
Expand All @@ -16,9 +17,11 @@
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,
Expand Down Expand Up @@ -242,13 +245,24 @@ async def create(
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

Expand Down
25 changes: 18 additions & 7 deletions packages/python-sdk/e2b/sandbox_sync/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
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,
Expand Down Expand Up @@ -228,13 +230,22 @@ def create(
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

Expand Down
31 changes: 30 additions & 1 deletion packages/python-sdk/tests/async/sandbox_async/test_create.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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",
Expand Down
31 changes: 30 additions & 1 deletion packages/python-sdk/tests/sync/sandbox_sync/test_create.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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",
Expand Down
Loading