diff --git a/.changeset/tidy-pandas-fix.md b/.changeset/tidy-pandas-fix.md new file mode 100644 index 0000000000..84ba5ddd87 --- /dev/null +++ b/.changeset/tidy-pandas-fix.md @@ -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. diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index f58da89a43..cbbf79e3ba 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -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 `. +_github_mcp_server_wire_names: Dict[str, str] = { + "run_cmd": "runCmd", + "install_cmd": "installCmd", +} + + +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: + wire[server] = config + return wire + class SandboxNetworkTransform(TypedDict): """ diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index a5eac92642..f8f6ec884b 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -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: diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 5dfae376fe..f57769f9c9 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -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: diff --git a/packages/python-sdk/tests/shared/sandbox/test_mcp_wire_names.py b/packages/python-sdk/tests/shared/sandbox/test_mcp_wire_names.py new file mode 100644 index 0000000000..d49d50b8e9 --- /dev/null +++ b/packages/python-sdk/tests/shared/sandbox/test_mcp_wire_names.py @@ -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()