Repository navigation
fix(python-sdk): map GitHub MCP server keys to gateway wire names #1901
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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]: | ||||||||||||||||||||||||||
| """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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. T-1a / T-40 — the Python surface is
Suggested change
|
||||||||||||||||||||||||||
| wire[server] = config | ||||||||||||||||||||||||||
| return wire | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| class SandboxNetworkTransform(TypedDict): | ||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| 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() |
There was a problem hiding this comment.
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 forcreate()and isn't (and shouldn't be) exported, so give it the module-private prefix like the_github_mcp_server_wire_namestable above it. The tests can import the underscored name.