-
Notifications
You must be signed in to change notification settings - Fork 1
feat: downloadable .mcpb bundles for local stdio servers #109
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
Changes from all commits
eb42c74
029d1ab
4ca69a4
b1d5433
449c70b
a6877ec
cb34e00
42cde0c
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,111 @@ | ||
| """MCPB export — package a local stdio server as a downloadable ``.mcpb`` bundle. | ||
|
|
||
| An MCPB bundle (https://github.com/anthropics/mcpb) is a zip whose | ||
| ``manifest.json`` tells an MCP client (e.g. Claude Desktop) how to launch the | ||
| server locally. We generate it on the fly from the ``Server`` row through the | ||
| same runner builder the bridge uses (``runners.build_spec``), so the bundle | ||
| always launches exactly what the elevator runs — no stored artifact to drift. | ||
|
|
||
| Only stdio specs qualify: a ``remote`` server has nothing to run locally. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import io | ||
| import json | ||
| import re | ||
| import zipfile | ||
|
|
||
| from app import __version__ | ||
| from app.db.models import Server | ||
| from app.runners import build_spec | ||
|
|
||
|
|
||
| def manifest(server: Server) -> dict: | ||
| """The MCPB ``manifest.json`` for a local stdio server. | ||
|
|
||
| Raises ``ValueError`` for a non-stdio (remote) server, and for a spec that | ||
| depends on launch context a bundle can't carry (``cwd``/``setup_script``) — | ||
| exporting those would hand out a bundle that can't reproduce the server. | ||
| ``env`` is embedded verbatim — the download is control-plane-gated, and the | ||
| same principal already reads those values on the server detail endpoint. | ||
| ``disabled_tools`` is deliberately NOT enforced: it filters the elevator's | ||
| exposed surfaces, and the downloader is the operator who set that policy — | ||
| a local run is their own machine, outside the elevator's enforcement. | ||
| """ | ||
| spec = build_spec(server) | ||
| if spec.transport != "stdio": | ||
| raise ValueError("only local stdio servers can be exported as .mcpb") | ||
| if spec.cwd or spec.setup_script: | ||
| raise ValueError( | ||
| "this server depends on a working directory or setup script, " | ||
| "which a .mcpb bundle cannot carry" | ||
| ) | ||
| # A relative command path (./server, bin\server.exe) resolves against the | ||
| # elevator's own working directory — it cannot exist where a client launches | ||
| # the bundle. Bare names (npx, python) PATH-resolve; absolute paths | ||
| # (/usr/bin/x, C:\tools\x.exe, \\host\share\x) are well-defined — both stay | ||
| # exportable. Args are not analyzed: whether "server.py" is a file reference | ||
| # or an opaque token is undecidable here. | ||
| cmd = spec.command | ||
| is_path = "/" in cmd or "\\" in cmd | ||
| is_absolute = cmd.startswith(("/", "\\\\")) or bool(re.match(r"[A-Za-z]:[\\/]", cmd)) | ||
| if is_path and not is_absolute: | ||
| raise ValueError( | ||
| "this server's command is a relative path, " | ||
| "which won't resolve outside the elevator" | ||
| ) | ||
| # Version = elevator release (release-tag-derived, never hardcoded — see | ||
| # app.__init__) + the row's config_hash as semver build metadata (hex + dots, | ||
| # which is valid there), so a same-release config edit still yields a | ||
| # distinguishable version string. Split off any existing build metadata | ||
| # (the "0.0.0+unknown" fallback) — semver allows only one "+". | ||
| version = __version__.lstrip("v").split("+", 1)[0] | ||
| if server.config_hash: | ||
| version = f"{version}+{server.config_hash}" | ||
|
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.
When command, arguments, or environment change within one mcpelevator release, the only version difference is now placed after AGENTS.md reference: AGENTS.md:L69-L74 Useful? React with 👍 / 👎.
Owner
Author
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. Declining the precedence escalation — build metadata is the deliberate stopping point. A precedence-bearing revision needs a monotonically increasing counter per config change. The If a real client is ever shown to skip on precedence, a revision column is the fix — noted, but not worth the schema change today. The name-stability finding is fixed in 449c70b ( Generated by Claude Code |
||
| mcp_config: dict = {"command": spec.command, "args": list(spec.args)} | ||
| if spec.env: | ||
| mcp_config["env"] = dict(spec.env) | ||
|
Comment on lines
+66
to
+68
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.
When Useful? React with 👍 / 👎.
Owner
Author
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. Declining as intended behavior, now documented in the module docstring (4ca69a4). The other two findings are fixed in 4ca69a4: export now returns 400 (and the UI hides the button) when the spec depends on Generated by Claude Code |
||
| return { | ||
| # 0.2 is the MCPB baseline every bundle-aware client accepts; nothing | ||
| # here needs a newer manifest feature. | ||
| "manifest_version": "0.2", | ||
| # Package identity = the immutable server id: a slug rename or a | ||
| # same-slug server on another instance must not fork/collide the | ||
| # installed extension. The human-facing name lives in display_name | ||
| # (and the download filename stays <slug>.mcpb — cosmetic only). | ||
| "name": server.id, | ||
| "display_name": server.name, | ||
| "version": version, | ||
| "description": f"{server.name} ({server.runner}: {server.command}) — exported from mcpelevator", | ||
| "author": {"name": "mcpelevator"}, | ||
| "server": { | ||
| # "binary": the bundle ships no code — mcp_config invokes the | ||
| # host's own npx/uvx/docker/executable, same argv as the bridge. | ||
| "type": "binary", | ||
| "entry_point": spec.command, | ||
|
Comment on lines
+85
to
+86
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.
Every generated archive declares a Useful? React with 👍 / 👎.
Owner
Author
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. Declining — this was verified empirically and CodeRabbit withdrew the same finding above (see the resolved thread on this file). The official Generated by Claude Code |
||
| "mcp_config": mcp_config, | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| }, | ||
| } | ||
|
|
||
|
|
||
| def exportable(server: Server) -> bool: | ||
| """Would :func:`manifest` accept this server? The single eligibility source | ||
| for the UI (via the detail response) — no mirrored client-side rule to drift.""" | ||
| try: | ||
| manifest(server) | ||
| return True | ||
| except ValueError: | ||
| return False | ||
|
|
||
|
|
||
| def bundle(server: Server) -> bytes: | ||
| """The ``.mcpb`` file bytes: a zip containing only ``manifest.json``.""" | ||
| buf = io.BytesIO() | ||
| with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: | ||
| # Fixed ZipInfo timestamp (the 1980 zip epoch): same row, byte-identical bundle. | ||
| zf.writestr( | ||
| zipfile.ZipInfo("manifest.json"), | ||
| json.dumps(manifest(server), indent=2) + "\n", | ||
| ) | ||
| return buf.getvalue() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| """MCPB export — the generated bundle mirrors the launch spec; remote servers 400.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import io | ||
| import json | ||
| import zipfile | ||
|
|
||
| from fastapi.testclient import TestClient | ||
|
|
||
| from conftest import LOOPBACK | ||
|
|
||
| from app import __version__ | ||
| from app.main import app | ||
|
|
||
|
|
||
| def _manifest_from(body: bytes) -> dict: | ||
| with zipfile.ZipFile(io.BytesIO(body)) as zf: | ||
| return json.loads(zf.read("manifest.json")) | ||
|
|
||
|
|
||
| def test_mcpb_download_round_trip(): | ||
| with TestClient(app) as c: | ||
| created = c.post( | ||
| "/api/servers", | ||
| json={ | ||
| "name": "Everything", | ||
| "runner": "npx", | ||
| "command": "npx", | ||
| "args": ["-y", "@modelcontextprotocol/server-everything"], | ||
| "env": {"FOO": "bar"}, | ||
| }, | ||
| headers=LOOPBACK, | ||
| ) | ||
| assert created.status_code == 201, created.text | ||
| server = created.json() | ||
| try: | ||
| r = c.get(f"/api/servers/{server['id']}/mcpb", headers=LOOPBACK) | ||
| assert r.status_code == 200 | ||
| assert r.headers["content-disposition"] == ( | ||
| f'attachment; filename="{server["slug"]}.mcpb"' | ||
| ) | ||
| m = _manifest_from(r.content) | ||
| assert m["manifest_version"] == "0.2" | ||
| assert m["name"] == server["id"] # immutable identity, not the renameable slug | ||
| assert m["display_name"] == "Everything" | ||
| detail = c.get(f"/api/servers/{server['id']}", headers=LOOPBACK).json() | ||
| base = __version__.lstrip("v").split("+", 1)[0] | ||
| assert m["version"] == f"{base}+{detail['config_hash']}" | ||
| assert detail["mcpb_exportable"] is True | ||
| assert m["server"]["mcp_config"] == { | ||
| "command": "npx", | ||
| "args": ["-y", "@modelcontextprotocol/server-everything"], | ||
| "env": {"FOO": "bar"}, | ||
| } | ||
| finally: | ||
| c.delete(f"/api/servers/{server['id']}", headers=LOOPBACK) | ||
|
|
||
|
|
||
| def test_mcpb_rejects_unexportable_launch_context(): | ||
| """cwd/setup_script have no MCPB equivalent — refuse rather than hand out a | ||
| bundle that can't reproduce the server.""" | ||
| with TestClient(app) as c: | ||
| created = c.post( | ||
| "/api/servers", | ||
| json={ | ||
| "name": "Prepared", | ||
| "runner": "command", | ||
| "command": "/bin/true", | ||
| "setup_script": "printf 'ready\\n'\n", | ||
| }, | ||
| headers=LOOPBACK, | ||
| ) | ||
| assert created.status_code == 201, created.text | ||
| server_id = created.json()["id"] | ||
| try: | ||
| r = c.get(f"/api/servers/{server_id}/mcpb", headers=LOOPBACK) | ||
| assert r.status_code == 400 | ||
| assert "setup script" in r.json()["detail"] | ||
| # The detail response advertises the same verdict the endpoint enforces. | ||
| detail = c.get(f"/api/servers/{server_id}", headers=LOOPBACK).json() | ||
| assert detail["mcpb_exportable"] is False | ||
| finally: | ||
| c.delete(f"/api/servers/{server_id}", headers=LOOPBACK) | ||
|
|
||
|
|
||
| def test_mcpb_rejects_relative_command_paths(): | ||
| with TestClient(app) as c: | ||
| created = c.post( | ||
| "/api/servers", | ||
| json={"name": "Local build", "runner": "command", "command": "./server"}, | ||
| headers=LOOPBACK, | ||
| ) | ||
| assert created.status_code == 201, created.text | ||
| server_id = created.json()["id"] | ||
| try: | ||
| r = c.get(f"/api/servers/{server_id}/mcpb", headers=LOOPBACK) | ||
| assert r.status_code == 400 | ||
| assert "relative path" in r.json()["detail"] | ||
| finally: | ||
| c.delete(f"/api/servers/{server_id}", headers=LOOPBACK) | ||
|
|
||
|
|
||
| def test_mcpb_command_path_classification(): | ||
| """Both path flavors: relative rejects, PATH-names and absolute paths export.""" | ||
| from app.db.models import Server | ||
| from app import mcpb | ||
|
|
||
| def row(command: str) -> Server: | ||
| return Server(id="x", slug="s", name="S", runner="command", | ||
| command=command, args=[], env={}) | ||
|
|
||
| for cmd in (r".\server.exe", r"bin\server.exe", "./server", "bin/server"): | ||
| try: | ||
| mcpb.manifest(row(cmd)) | ||
| raise AssertionError(f"{cmd!r} should have been rejected") | ||
| except ValueError as exc: | ||
| assert "relative path" in str(exc) | ||
| for cmd in ("npx", "/usr/local/bin/server", r"C:\tools\server.exe", r"\\host\share\server.exe"): | ||
| assert mcpb.manifest(row(cmd))["server"]["mcp_config"]["command"] == cmd | ||
|
|
||
|
|
||
| def test_mcpb_rejects_remote_servers(): | ||
| with TestClient(app) as c: | ||
| created = c.post( | ||
| "/api/servers", | ||
| json={"name": "Upstream", "runner": "remote", "command": "https://up.example/mcp"}, | ||
| headers=LOOPBACK, | ||
| ) | ||
| assert created.status_code == 201, created.text | ||
| server_id = created.json()["id"] | ||
| try: | ||
| r = c.get(f"/api/servers/{server_id}/mcpb", headers=LOOPBACK) | ||
| assert r.status_code == 400 | ||
| assert "stdio" in r.json()["detail"] | ||
| finally: | ||
| c.delete(f"/api/servers/{server_id}", headers=LOOPBACK) |
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.
When a
commandrunner uses a relative executable or file argument without settingcwd—for example,python server.pyor./server—this guard permits the export even though the archive contains onlymanifest.json. The elevator resolves that path from its backend working directory, while Claude Desktop launches it from a different directory where the referenced file is absent, so the downloaded server fails or may run an unintended file. Fresh evidence after the prior launch-context review is that the follow-up rejects only explicitcwd/setup_scriptdependencies; reject or package filesystem-relative launch inputs as well.Useful? React with 👍 / 👎.
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.
Partially fixed in a6877ec: a relative command path (
./server,bin/server) is now rejected with a 400 — that's precisely detectable and genuinely non-portable. Relative arguments stay exportable by design: whetherserver.pyis a file reference or an opaque token (-y,@scope/pkg, a subcommand) is undecidable without executing the tool, and a heuristic would reject working configs. The trade-off is noted in a code comment at the guard.Generated by Claude Code