From eb42c747fa598ed1f27c9c4457f01db3a486c193 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:13:22 +0000 Subject: [PATCH 1/8] feat: downloadable .mcpb bundles for local stdio servers Every local (npx/uvx/command/docker) server can now be exported as an MCPB bundle so users can run the same server directly in Claude Desktop: - app/mcpb.py generates manifest.json + zip on the fly from the Server row via the same runners.build_spec the bridge launches (SSOT, nothing stored) - GET /api/servers/{id}/mcpb streams the bundle (control-plane gated, 400 for remote servers, which have nothing to run locally) - SPA: a Download .mcpb button in the server detail Endpoints card, fetched with the bearer header and saved via an object URL Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Gxd3iyJwW8Jean5nkABtsD --- README.md | 2 + backend/app/api/servers.py | 24 +++++++ backend/app/mcpb.py | 64 ++++++++++++++++++ backend/tests/test_mcpb.py | 68 ++++++++++++++++++++ frontend/src/lib/api.ts | 23 +++++++ frontend/src/routes/server/[id]/+page.svelte | 42 ++++++++++++ 6 files changed, 223 insertions(+) create mode 100644 backend/app/mcpb.py create mode 100644 backend/tests/test_mcpb.py diff --git a/README.md b/README.md index 9c37b8c3..1ed99e16 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,8 @@ curl -X POST http://127.0.0.1:8080/s/memory/rest/create_entities \ tool list (request schemas included), ready to feed to GPT Actions or any OpenAPI tooling. `GET /s//rest` lists the tools. +**Prefer to run it locally after all?** Every local (stdio) server offers a **Download `.mcpb`** button on its detail page (`GET /api/servers//mcpb`): a generated [MCPB bundle](https://github.com/anthropics/mcpb) of the server's exact launch config (command, args, env), ready to install in Claude Desktop. It's built on the fly from the same runner spec the bridge launches, so it never drifts from what the elevator runs. Remote servers have nothing to run locally and don't offer one. + **Already remote?** Use the `remote` runner to proxy an existing Streamable-HTTP/SSE MCP URL — no local process. The launch spec reuses the same fields: `command` is the upstream URL, `args[0]` is the transport (`streamable-http` or `sse`), and `env` is the upstream HTTP headers. ```bash diff --git a/backend/app/api/servers.py b/backend/app/api/servers.py index 96fee20d..c8e16d38 100644 --- a/backend/app/api/servers.py +++ b/backend/app/api/servers.py @@ -36,6 +36,7 @@ Transports, Urls, ) +from app import mcpb from app.api.util import base_url, oauth_public_base, resync_groups from app.auth import oauth_flow, policy from app.auth import principal as principal_mod @@ -554,6 +555,29 @@ async def delete_server( return Response(status_code=204) +@router.get("/servers/{server_id}/mcpb") +async def download_mcpb( + server_id: str, + session: Session = Depends(get_session), + principal: Principal = Depends(current_principal), +): + """Download the server's launch spec as a ``.mcpb`` bundle (see app.mcpb). + + Generated per request from the row — the DB stays the single source of + truth. 400 for a remote server (nothing to run locally).""" + server = _visible(principal, session, server_id) + try: + data = mcpb.bundle(server) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return Response( + content=data, + media_type="application/octet-stream", + # slug is validated url-safe at the service boundary, so it's header-safe here. + headers={"Content-Disposition": f'attachment; filename="{server.slug}.mcpb"'}, + ) + + def _oauth_callback_url(request: Request) -> str: """The public URL the upstream redirects the operator's browser back to after sign-in. Built from the same base as the copy-menu links so it matches the host diff --git a/backend/app/mcpb.py b/backend/app/mcpb.py new file mode 100644 index 00000000..da25e877 --- /dev/null +++ b/backend/app/mcpb.py @@ -0,0 +1,64 @@ +"""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 zipfile + +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. ``env`` is embedded + verbatim — the download is control-plane-gated, and the same principal + already reads those values on the server detail endpoint. ``cwd`` and + ``setup_script`` have no MCPB equivalent and are not represented. + """ + spec = build_spec(server) + if spec.transport != "stdio": + raise ValueError("only local stdio servers can be exported as .mcpb") + mcp_config: dict = {"command": spec.command, "args": list(spec.args)} + if spec.env: + mcp_config["env"] = dict(spec.env) + return { + # 0.2 is the MCPB baseline every bundle-aware client accepts; nothing + # here needs a newer manifest feature. + "manifest_version": "0.2", + "name": server.slug, + "display_name": server.name, + "version": "1.0.0", + "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, + "mcp_config": mcp_config, + }, + } + + +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() diff --git a/backend/tests/test_mcpb.py b/backend/tests/test_mcpb.py new file mode 100644 index 00000000..fb15ba0e --- /dev/null +++ b/backend/tests/test_mcpb.py @@ -0,0 +1,68 @@ +"""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.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["slug"] + 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_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) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ec6d10ae..8592b2f0 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -229,6 +229,29 @@ export function updateServer( ); } +/** + * Download the generated `.mcpb` bundle for a local stdio server. Raw fetch + * (not `request()`): the body is a zip, not JSON, and it needs the same + * bearer header an `` can't carry. + */ +export async function downloadMcpb(id: string): Promise { + const url = `${BASE}/servers/${encodeURIComponent(id)}/mcpb`; + const token = getToken(); + const res = await fetch(url, { + headers: token ? { authorization: `Bearer ${token}` } : {} + }); + if (!res.ok) { + let body = ''; + try { + body = await res.text(); + } catch { + // ignore — body may be unreadable on some error responses + } + throw new ApiError(res.status, body, url); + } + return res.blob(); +} + export function deleteServer(id: string): Promise { return request(`/servers/${encodeURIComponent(id)}`, { method: 'DELETE' diff --git a/frontend/src/routes/server/[id]/+page.svelte b/frontend/src/routes/server/[id]/+page.svelte index 1492563c..872d1e52 100644 --- a/frontend/src/routes/server/[id]/+page.svelte +++ b/frontend/src/routes/server/[id]/+page.svelte @@ -6,6 +6,7 @@ deleteServer, disableServer, disconnectOauth, + downloadMcpb, enableServer, errorMessage, getServer, @@ -110,6 +111,28 @@ } } + let downloadingMcpb = $state(false); + + // Save the backend-generated .mcpb bundle (local stdio servers only). Fetched + // with the bearer header, then handed to the browser as an object-URL download. + async function saveMcpb() { + if (!server || downloadingMcpb) return; + downloadingMcpb = true; + try { + const blob = await downloadMcpb(server.id); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${server.slug}.mcpb`; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + flashToast(errorMessage(err)); + } finally { + downloadingMcpb = false; + } + } + // Per-tool enable/disable (issue #105). The disabled set lives on the server row // (`disabled_tools`); the bridge hides those tools from every surface. A hidden tool // drops out of discovery, so it's absent from `server.tools` — the row list below @@ -815,6 +838,25 @@ {/if} + {#if server.runner !== 'remote'} +
+
+

Run locally

+

+ Download a .mcpb bundle of this server's launch + config — install it in Claude Desktop to run the server on your own machine. +

+
+ +
+ {/if} {#if effectiveAuth === 'bearer'}

Date: Sun, 2 Aug 2026 02:19:03 +0000 Subject: [PATCH 2/8] fix(review): 401 parity in downloadMcpb + capture slug before await - downloadMcpb now clears the stale token and redirects to /login on 401, matching request()'s behavior - saveMcpb captures the target id/slug before the await so a same-route navigation mid-download can't mislabel the saved file Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Gxd3iyJwW8Jean5nkABtsD --- frontend/src/lib/api.ts | 7 +++++++ frontend/src/routes/server/[id]/+page.svelte | 7 +++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8592b2f0..4b7c5664 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -241,6 +241,13 @@ export async function downloadMcpb(id: string): Promise { headers: token ? { authorization: `Bearer ${token}` } : {} }); if (!res.ok) { + // Same 401 handling as request(): drop the stale token and bounce to /login. + if (res.status === 401) { + clearToken(); + if (typeof window !== 'undefined' && window.location.pathname !== '/login') { + void goto('/login'); + } + } let body = ''; try { body = await res.text(); diff --git a/frontend/src/routes/server/[id]/+page.svelte b/frontend/src/routes/server/[id]/+page.svelte index 872d1e52..08fc72e3 100644 --- a/frontend/src/routes/server/[id]/+page.svelte +++ b/frontend/src/routes/server/[id]/+page.svelte @@ -117,13 +117,16 @@ // with the bearer header, then handed to the browser as an object-URL download. async function saveMcpb() { if (!server || downloadingMcpb) return; + // Capture the target before the await: clone reuses this component (same-route + // nav), so `server` can change mid-download and must not rename the file. + const { id: serverId, slug } = server; downloadingMcpb = true; try { - const blob = await downloadMcpb(server.id); + const blob = await downloadMcpb(serverId); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `${server.slug}.mcpb`; + a.download = `${slug}.mcpb`; a.click(); URL.revokeObjectURL(url); } catch (err) { From 4ca69a4aca0a7ea9dc7b525ecf785792fd87bf2f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:22:33 +0000 Subject: [PATCH 3/8] fix(review): refuse unexportable launch context, derive bundle version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reject .mcpb export (400) when the launch spec depends on cwd or a setup_script — a bundle can't carry either, so exporting would hand out a file that can't reproduce the server; the UI hides the download row for those configs - manifest version now derives from app.__version__ (release-tag SSOT) instead of a hardcoded 1.0.0, so re-exports after an upgrade register as updates in MCPB clients Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Gxd3iyJwW8Jean5nkABtsD --- backend/app/mcpb.py | 22 +++++++++++++---- backend/tests/test_mcpb.py | 26 ++++++++++++++++++++ frontend/src/routes/server/[id]/+page.svelte | 3 ++- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/backend/app/mcpb.py b/backend/app/mcpb.py index da25e877..79ef9ac0 100644 --- a/backend/app/mcpb.py +++ b/backend/app/mcpb.py @@ -15,6 +15,7 @@ import json import zipfile +from app import __version__ from app.db.models import Server from app.runners import build_spec @@ -22,14 +23,23 @@ def manifest(server: Server) -> dict: """The MCPB ``manifest.json`` for a local stdio server. - Raises ``ValueError`` for a non-stdio (remote) server. ``env`` is embedded - verbatim — the download is control-plane-gated, and the same principal - already reads those values on the server detail endpoint. ``cwd`` and - ``setup_script`` have no MCPB equivalent and are not represented. + 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" + ) mcp_config: dict = {"command": spec.command, "args": list(spec.args)} if spec.env: mcp_config["env"] = dict(spec.env) @@ -39,7 +49,9 @@ def manifest(server: Server) -> dict: "manifest_version": "0.2", "name": server.slug, "display_name": server.name, - "version": "1.0.0", + # The elevator's own version (release-tag-derived, never hardcoded — see + # app.__init__), so re-exports after an upgrade register as updates. + "version": __version__.lstrip("v"), "description": f"{server.name} ({server.runner}: {server.command}) — exported from mcpelevator", "author": {"name": "mcpelevator"}, "server": { diff --git a/backend/tests/test_mcpb.py b/backend/tests/test_mcpb.py index fb15ba0e..deb78ac2 100644 --- a/backend/tests/test_mcpb.py +++ b/backend/tests/test_mcpb.py @@ -10,6 +10,7 @@ from conftest import LOOPBACK +from app import __version__ from app.main import app @@ -42,6 +43,7 @@ def test_mcpb_download_round_trip(): m = _manifest_from(r.content) assert m["manifest_version"] == "0.2" assert m["name"] == server["slug"] + assert m["version"] == __version__.lstrip("v") assert m["server"]["mcp_config"] == { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-everything"], @@ -51,6 +53,30 @@ def test_mcpb_download_round_trip(): 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"] + finally: + c.delete(f"/api/servers/{server_id}", headers=LOOPBACK) + + def test_mcpb_rejects_remote_servers(): with TestClient(app) as c: created = c.post( diff --git a/frontend/src/routes/server/[id]/+page.svelte b/frontend/src/routes/server/[id]/+page.svelte index 08fc72e3..adc0c863 100644 --- a/frontend/src/routes/server/[id]/+page.svelte +++ b/frontend/src/routes/server/[id]/+page.svelte @@ -841,7 +841,8 @@ {/if} - {#if server.runner !== 'remote'} + + {#if server.runner !== 'remote' && !server.cwd && !server.setup_script}

Run locally

From b1d5433f53dbc7991843e57f43661208b5933987 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:28:28 +0000 Subject: [PATCH 4/8] fix(review): include config_hash in the bundle version metadata A same-release config edit now yields a distinguishable version string: + (hex + dots are valid semver build metadata), so MCPB clients can tell a re-export of a changed launch config apart from the already-installed bundle. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Gxd3iyJwW8Jean5nkABtsD --- backend/app/mcpb.py | 12 +++++++++--- backend/tests/test_mcpb.py | 4 +++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/backend/app/mcpb.py b/backend/app/mcpb.py index 79ef9ac0..b11e4b89 100644 --- a/backend/app/mcpb.py +++ b/backend/app/mcpb.py @@ -40,6 +40,14 @@ def manifest(server: Server) -> dict: "this server depends on a working directory or setup script, " "which a .mcpb bundle cannot carry" ) + # 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}" mcp_config: dict = {"command": spec.command, "args": list(spec.args)} if spec.env: mcp_config["env"] = dict(spec.env) @@ -49,9 +57,7 @@ def manifest(server: Server) -> dict: "manifest_version": "0.2", "name": server.slug, "display_name": server.name, - # The elevator's own version (release-tag-derived, never hardcoded — see - # app.__init__), so re-exports after an upgrade register as updates. - "version": __version__.lstrip("v"), + "version": version, "description": f"{server.name} ({server.runner}: {server.command}) — exported from mcpelevator", "author": {"name": "mcpelevator"}, "server": { diff --git a/backend/tests/test_mcpb.py b/backend/tests/test_mcpb.py index deb78ac2..3153371b 100644 --- a/backend/tests/test_mcpb.py +++ b/backend/tests/test_mcpb.py @@ -43,7 +43,9 @@ def test_mcpb_download_round_trip(): m = _manifest_from(r.content) assert m["manifest_version"] == "0.2" assert m["name"] == server["slug"] - assert m["version"] == __version__.lstrip("v") + 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 m["server"]["mcp_config"] == { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-everything"], From 449c70bee8ce3a79ec1f103f4e30ac843075a4d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:35:34 +0000 Subject: [PATCH 5/8] fix(review): anchor MCPB package identity on the immutable server id manifest name now uses server.id instead of the operator-renameable slug, so a slug rename (or a same-slug server on another instance) updates the installed extension instead of forking a second one. display_name keeps the human-facing name; the download filename stays .mcpb. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Gxd3iyJwW8Jean5nkABtsD --- backend/app/mcpb.py | 6 +++++- backend/tests/test_mcpb.py | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/app/mcpb.py b/backend/app/mcpb.py index b11e4b89..40b4d932 100644 --- a/backend/app/mcpb.py +++ b/backend/app/mcpb.py @@ -55,7 +55,11 @@ def manifest(server: Server) -> dict: # 0.2 is the MCPB baseline every bundle-aware client accepts; nothing # here needs a newer manifest feature. "manifest_version": "0.2", - "name": server.slug, + # 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 .mcpb — cosmetic only). + "name": server.id, "display_name": server.name, "version": version, "description": f"{server.name} ({server.runner}: {server.command}) — exported from mcpelevator", diff --git a/backend/tests/test_mcpb.py b/backend/tests/test_mcpb.py index 3153371b..ab2122bd 100644 --- a/backend/tests/test_mcpb.py +++ b/backend/tests/test_mcpb.py @@ -42,7 +42,8 @@ def test_mcpb_download_round_trip(): ) m = _manifest_from(r.content) assert m["manifest_version"] == "0.2" - assert m["name"] == server["slug"] + 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']}" From a6877ec3445eaa1530bff13cf7591c7bebca18d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:41:23 +0000 Subject: [PATCH 6/8] fix(review): reject relative command paths in .mcpb export A relative command (./server, bin/server) resolves against the elevator's working directory and cannot exist where a client launches the bundle. Bare names PATH-resolve and absolute paths are well-defined, so both stay exportable; args are not analyzed (whether 'server.py' is a file or a token is undecidable). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Gxd3iyJwW8Jean5nkABtsD --- backend/app/mcpb.py | 10 ++++++++++ backend/tests/test_mcpb.py | 17 +++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/backend/app/mcpb.py b/backend/app/mcpb.py index 40b4d932..ed4d08a3 100644 --- a/backend/app/mcpb.py +++ b/backend/app/mcpb.py @@ -40,6 +40,16 @@ def manifest(server: Server) -> dict: "this server depends on a working directory or setup script, " "which a .mcpb bundle cannot carry" ) + # A relative command path (./server, bin/server) resolves against the + # elevator's own working directory — it cannot exist where a client launches + # the bundle. Bare names (npx, python) PATH-resolve and absolute paths are + # well-defined, so both stay exportable. Args are not analyzed: whether + # "server.py" is a file or a token is undecidable here. + if "/" in spec.command and not spec.command.startswith("/"): + 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 diff --git a/backend/tests/test_mcpb.py b/backend/tests/test_mcpb.py index ab2122bd..54ca9422 100644 --- a/backend/tests/test_mcpb.py +++ b/backend/tests/test_mcpb.py @@ -80,6 +80,23 @@ def test_mcpb_rejects_unexportable_launch_context(): 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_rejects_remote_servers(): with TestClient(app) as c: created = c.post( From cb34e00d18c5a0aa4fefb5665e6122a08c443c10 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:47:26 +0000 Subject: [PATCH 7/8] fix(review): expose mcpb_exportable so the UI mirrors no eligibility rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail response now carries the backend's own verdict (app.mcpb.exportable — 'would manifest() accept this row'), and the download button renders on that flag alone. Removes the UI's duplicated runner/cwd/setup_script checks, which had already drifted from the new relative-path guard. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Gxd3iyJwW8Jean5nkABtsD --- backend/app/api/schemas.py | 3 +++ backend/app/api/servers.py | 1 + backend/app/mcpb.py | 10 ++++++++++ backend/tests/test_mcpb.py | 4 ++++ frontend/src/lib/types.ts | 3 +++ frontend/src/routes/server/[id]/+page.svelte | 3 +-- 6 files changed, 22 insertions(+), 2 deletions(-) diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index 01e90a05..5e3d556b 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -113,6 +113,9 @@ class ServerDetail(ServerSummary): disabled_tools: list[str] = [] config_hash: str = "" source: str = "manual" + # Whether GET /servers/{id}/mcpb would produce a bundle (app.mcpb.exportable); + # the UI shows the download only when true, with no mirrored eligibility rule. + mcpb_exportable: bool = False tools: list[dict] = [] diff --git a/backend/app/api/servers.py b/backend/app/api/servers.py index c8e16d38..1ccef60f 100644 --- a/backend/app/api/servers.py +++ b/backend/app/api/servers.py @@ -251,6 +251,7 @@ def _detail(server: Server, sup, session: Session, base: str) -> ServerDetail: disabled_tools=list(server.disabled_tools or []), config_hash=server.config_hash, source=server.source, + mcpb_exportable=mcpb.exportable(server), tools=tools or [], ) diff --git a/backend/app/mcpb.py b/backend/app/mcpb.py index ed4d08a3..fbc3d9fb 100644 --- a/backend/app/mcpb.py +++ b/backend/app/mcpb.py @@ -84,6 +84,16 @@ def manifest(server: Server) -> dict: } +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() diff --git a/backend/tests/test_mcpb.py b/backend/tests/test_mcpb.py index 54ca9422..866cdd35 100644 --- a/backend/tests/test_mcpb.py +++ b/backend/tests/test_mcpb.py @@ -47,6 +47,7 @@ def test_mcpb_download_round_trip(): 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"], @@ -76,6 +77,9 @@ def test_mcpb_rejects_unexportable_launch_context(): 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) diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 021f49e7..d88eb346 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -121,6 +121,9 @@ export interface ServerDetail extends ServerSummary { /** Idle quiescence override in seconds: null = inherit the global setting, * 0 = never idle this server out. */ idle_timeout_s: number | null; + /** Whether GET /servers/{id}/mcpb would produce a bundle — the backend's + * eligibility verdict, so the UI carries no mirrored rule. */ + mcpb_exportable: boolean; /** Upstream tool names hidden from every exposed surface (MCP list/call, REST, * groups). Empty = expose every discovered tool (the default). A hidden tool no * longer appears in `tools` (it drops out of discovery), so the UI unions this diff --git a/frontend/src/routes/server/[id]/+page.svelte b/frontend/src/routes/server/[id]/+page.svelte index adc0c863..bb390d96 100644 --- a/frontend/src/routes/server/[id]/+page.svelte +++ b/frontend/src/routes/server/[id]/+page.svelte @@ -841,8 +841,7 @@
{/if} - - {#if server.runner !== 'remote' && !server.cwd && !server.setup_script} + {#if server.mcpb_exportable}

Run locally

From 42cde0c3a3a4a0e7bea1e54e1b081957718cba3e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:53:33 +0000 Subject: [PATCH 8/8] fix(review): reject Windows-style relative command paths too The relative-path guard now catches both separator flavors (.\server.exe, bin\server.exe) while drive-absolute (C:\...) and UNC (\\host\share) paths stay exportable, matching the POSIX rule. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Gxd3iyJwW8Jean5nkABtsD --- backend/app/mcpb.py | 15 ++++++++++----- backend/tests/test_mcpb.py | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/backend/app/mcpb.py b/backend/app/mcpb.py index fbc3d9fb..fe769bc2 100644 --- a/backend/app/mcpb.py +++ b/backend/app/mcpb.py @@ -13,6 +13,7 @@ import io import json +import re import zipfile from app import __version__ @@ -40,12 +41,16 @@ def manifest(server: Server) -> dict: "this server depends on a working directory or setup script, " "which a .mcpb bundle cannot carry" ) - # A relative command path (./server, bin/server) resolves against the + # 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 and absolute paths are - # well-defined, so both stay exportable. Args are not analyzed: whether - # "server.py" is a file or a token is undecidable here. - if "/" in spec.command and not spec.command.startswith("/"): + # 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" diff --git a/backend/tests/test_mcpb.py b/backend/tests/test_mcpb.py index 866cdd35..938942d4 100644 --- a/backend/tests/test_mcpb.py +++ b/backend/tests/test_mcpb.py @@ -101,6 +101,25 @@ def test_mcpb_rejects_relative_command_paths(): 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(