Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/tidy-pandas-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@e2b/python-sdk": patch
---

Fix custom GitHub MCP server configs being silently ignored: map the documented snake_case keys (`run_cmd`/`install_cmd`) to the camelCase wire names (`runCmd`/`installCmd`) the mcp-gateway expects before the config is serialized.
38 changes: 38 additions & 0 deletions packages/python-sdk/e2b/sandbox/sandbox_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,44 @@ class GitHubMcpServerConfig(TypedDict):
# Union type that combines base MCP servers with GitHub-based servers
McpServer = Union[BaseMcpServer, GitHubMcpServer]

# Wire names the mcp-gateway expects for GitHub MCP server configs.
# The documented Python form of GitHubMcpServerConfig is snake_case, but the
# gateway only recognizes camelCase, so these keys are renamed before the
# config is serialized into `mcp-gateway --config <json>`.
_github_mcp_server_wire_names: Dict[str, str] = {
"run_cmd": "runCmd",
"install_cmd": "installCmd",
}


def build_mcp_wire_config(mcp: McpServer) -> Dict[str, Any]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

T-54 — everything public is listed in __init__.py's __all__; this helper is internal wiring for create() and isn't (and shouldn't be) exported, so give it the module-private prefix like the _github_mcp_server_wire_names table above it. The tests can import the underscored name.

Suggested change
def build_mcp_wire_config(mcp: McpServer) -> Dict[str, Any]:
def _build_mcp_wire_config(mcp: McpServer) -> Dict[str, Any]:

"""Map a user-facing :data:`McpServer` config to the wire form the mcp-gateway expects.

Entries keyed ``"github/owner/repo"`` have their documented snake_case keys
(``run_cmd``/``install_cmd``) renamed to the camelCase names the gateway
recognizes (``runCmd``/``installCmd``). Base server entries and all other
keys pass through unchanged. An explicitly spelled wire key (e.g. ``runCmd``)
always wins over its snake_case alias. The input dict is never mutated.
"""
wire: Dict[str, Any] = {}
for server, config in mcp.items():
if (
isinstance(server, str)
and server.startswith("github/")
and isinstance(config, dict)
):
mapped: Dict[str, Any] = {}
for key, value in config.items():
wire_key = _github_mcp_server_wire_names.get(key, key)
# An explicitly spelled wire key wins over its snake_case alias,
# regardless of dict order.
if wire_key == key or wire_key not in config:
mapped[wire_key] = value
wire[server] = mapped
else:
Comment on lines +130 to +137

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

T-1a / T-40 — the Python surface is snake_case only; camelCase is confined to the serialized shape and the SDK converts on the way in. Making an explicitly spelled runCmd/installCmd "win" over run_cmd/install_cmd quietly turns the wire spelling into a second accepted Python spelling (one that GitHubMcpServerConfig doesn't document), and adds a client-side precedence rule the gateway never defined (T-52). Just rename and let the gateway validate:

Suggested change
for key, value in config.items():
wire_key = _github_mcp_server_wire_names.get(key, key)
# An explicitly spelled wire key wins over its snake_case alias,
# regardless of dict order.
if wire_key == key or wire_key not in config:
mapped[wire_key] = value
wire[server] = mapped
else:
wire[server] = {
_github_mcp_server_wire_names.get(key, key): value
for key, value in config.items()
}

wire[server] = config
return wire


class SandboxNetworkTransform(TypedDict):
"""
Expand Down
8 changes: 8 additions & 0 deletions packages/python-sdk/e2b/sandbox_async/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,14 @@ async def create(

Use this method instead of using the constructor to create a new sandbox.
"""
# The mcp-gateway only understands camelCase keys (runCmd/installCmd);
# map the documented snake_case form to the wire form once, so the API
# payload and the gateway --config JSON agree.
if mcp is not None:
from e2b.sandbox.sandbox_api import build_mcp_wire_config

mcp = build_mcp_wire_config(mcp)

if not template and mcp is not None:
template = cls.default_mcp_template
elif not template:
Expand Down
8 changes: 8 additions & 0 deletions packages/python-sdk/e2b/sandbox_sync/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,14 @@ def create(

Use this method instead of using the constructor to create a new sandbox.
"""
# The mcp-gateway only understands camelCase keys (runCmd/installCmd);
# map the documented snake_case form to the wire form once, so the API
# payload and the gateway --config JSON agree.
if mcp is not None:
from e2b.sandbox.sandbox_api import build_mcp_wire_config

mcp = build_mcp_wire_config(mcp)

if not template and mcp is not None:
template = cls.default_mcp_template
elif not template:
Expand Down
149 changes: 149 additions & 0 deletions packages/python-sdk/tests/shared/sandbox/test_mcp_wire_names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""Tests for the GitHub MCP server key mapping (e2b-dev/E2B#1899).

The Python SDK documents snake_case keys (``run_cmd``/``install_cmd``) for
``GitHubMcpServerConfig``, but the mcp-gateway only recognizes camelCase
(``runCmd``/``installCmd``). ``Sandbox.create``/``AsyncSandbox.create`` must
map the keys to the wire form before the config reaches the gateway.
"""

import json
import shlex
from types import SimpleNamespace
from typing import Any, Dict
from unittest.mock import AsyncMock, Mock

from e2b import AsyncSandbox, Sandbox
from e2b.api.client.api.sandboxes import post_v2_sandboxes
from e2b.api.client.models import Sandbox as SandboxModel
from e2b.sandbox.sandbox_api import GitHubMcpServer, build_mcp_wire_config


def _gateway_config_from_command(command: str) -> Dict[str, Any]:
argv = shlex.split(command)
assert argv[:2] == ["mcp-gateway", "--config"]
return json.loads(argv[2])


def _github_mcp(config: Dict[str, Any]) -> GitHubMcpServer:
return {"github/tenuo-ai/tenuo-demos": config}


def test_maps_snake_case_keys_to_wire_names():
wire = build_mcp_wire_config(
_github_mcp({"install_cmd": "npm install", "run_cmd": "npx start"})
)

assert wire == {
"github/tenuo-ai/tenuo-demos": {
"installCmd": "npm install",
"runCmd": "npx start",
}
}


def test_partial_config_without_install_cmd():
mcp: GitHubMcpServer = {"github/owner/repo": {"run_cmd": "cat"}}

assert build_mcp_wire_config(mcp) == {"github/owner/repo": {"runCmd": "cat"}}


def test_envs_and_unknown_keys_pass_through():
mcp: GitHubMcpServer = {
"github/owner/repo": {"run_cmd": "cat", "envs": {"FOO": "bar"}}
}

assert build_mcp_wire_config(mcp) == {
"github/owner/repo": {"runCmd": "cat", "envs": {"FOO": "bar"}}
}


def test_base_server_entries_are_untouched():
mcp = {
"airtable": {"airtableApiKey": "key", "nodeenv": "production"},
**_github_mcp({"run_cmd": "cat"}),
}

wire = build_mcp_wire_config(mcp)

assert wire["airtable"] == {"airtableApiKey": "key", "nodeenv": "production"}
assert wire["github/tenuo-ai/tenuo-demos"] == {"runCmd": "cat"}


def test_explicit_wire_key_wins_over_snake_case_alias():
config: Dict[str, Any] = {"run_cmd": "ignored", "runCmd": "cat"}
mcp: GitHubMcpServer = {"github/owner/repo": config}

assert build_mcp_wire_config(mcp) == {"github/owner/repo": {"runCmd": "cat"}}


def test_does_not_mutate_input():
mcp = _github_mcp({"install_cmd": "make", "run_cmd": "cat"})

build_mcp_wire_config(mcp)

assert mcp == {
"github/tenuo-ai/tenuo-demos": {"install_cmd": "make", "run_cmd": "cat"}
}


def test_empty_config():
assert build_mcp_wire_config({}) == {}


def _created_sandbox():
return SimpleNamespace(
status_code=200,
parsed=SandboxModel(
client_id="client-id",
envd_version="0.2.4",
sandbox_id="sbx-test",
template_id="template-id",
),
)


def _expected_wire() -> Dict[str, Any]:
return {
"github/tenuo-ai/tenuo-demos": {
"installCmd": "echo hi",
"runCmd": "cat",
}
}


def test_sync_create_sends_wire_names_to_api_and_gateway(monkeypatch, test_api_key):
request = Mock(return_value=_created_sandbox())
monkeypatch.setattr(post_v2_sandboxes, "sync_detailed", request)
gateway_run = Mock()
monkeypatch.setattr("e2b.sandbox_sync.commands.command.Commands.run", gateway_run)

Sandbox.create(
api_key=test_api_key,
mcp=_github_mcp({"install_cmd": "echo hi", "run_cmd": "cat"}),
)

body = request.call_args.kwargs["body"].to_dict()
assert body["mcp"] == _expected_wire()

command = gateway_run.call_args.args[0]
assert _gateway_config_from_command(command) == _expected_wire()


async def test_async_create_sends_wire_names_to_api_and_gateway(
monkeypatch, test_api_key
):
request = AsyncMock(return_value=_created_sandbox())
monkeypatch.setattr(post_v2_sandboxes, "asyncio_detailed", request)
gateway_run = AsyncMock()
monkeypatch.setattr("e2b.sandbox_async.commands.command.Commands.run", gateway_run)

await AsyncSandbox.create(
api_key=test_api_key,
mcp=_github_mcp({"install_cmd": "echo hi", "run_cmd": "cat"}),
)

body = request.call_args.kwargs["body"].to_dict()
assert body["mcp"] == _expected_wire()

command = gateway_run.call_args.args[0]
assert _gateway_config_from_command(command) == _expected_wire()