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/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 96fee20d..1ccef60f 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 @@ -250,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 [], ) @@ -554,6 +556,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..fe769bc2 --- /dev/null +++ b/backend/app/mcpb.py @@ -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}" + 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", + # 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", + "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 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() diff --git a/backend/tests/test_mcpb.py b/backend/tests/test_mcpb.py new file mode 100644 index 00000000..938942d4 --- /dev/null +++ b/backend/tests/test_mcpb.py @@ -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) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ec6d10ae..4b7c5664 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -229,6 +229,36 @@ 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) { + // 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(); + } 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/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 1492563c..bb390d96 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,31 @@ } } + 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; + // 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(serverId); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${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 +841,25 @@ {/if} + {#if server.mcpb_exportable} +
+
+

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'}