Skip to content
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>/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/<id>/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
Expand Down
3 changes: 3 additions & 0 deletions backend/app/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []


Expand Down
25 changes: 25 additions & 0 deletions backend/app/api/servers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 [],
)

Expand Down Expand Up @@ -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
Expand Down
111 changes: 111 additions & 0 deletions backend/app/mcpb.py
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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject bundles that rely on implicit relative paths

When a command runner uses a relative executable or file argument without setting cwd—for example, python server.py or ./server—this guard permits the export even though the archive contains only manifest.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 explicit cwd/setup_script dependencies; reject or package filesystem-relative launch inputs as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

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: whether server.py is 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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Put the config revision in SemVer precedence

When command, arguments, or environment change within one mcpelevator release, the only version difference is now placed after +; SemVer §10 requires build metadata to be ignored when determining precedence, so an MCPB installer using semantic version comparison can consider the old and new bundles equal and skip the replacement. Fresh evidence after the earlier review is that this follow-up places config_hash specifically in build metadata; keep the release-tag-derived base but use a client-supported revision or a monotonically increasing, precedence-bearing version component instead.

AGENTS.md reference: AGENTS.md:L69-L74

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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 Server row has none (config_hash is a hash, not a sequence; updated_at can't be embedded in a valid three-component SemVer), so honoring this would mean adding and persisting a revision column purely for this export. That's real schema surface for a speculative client behavior: manually downloaded .mcpb files are installed by user action, where the installer replaces on confirmation rather than short-circuiting on version precedence — there's no known consumer that semver-compares a hand-installed bundle and skips it. The build-metadata suffix already makes changed configs distinguishable (string-unequal, visible in the UI), which is the concrete need.

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 (name = immutable server id, human name in display_name).


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve disabled-tool enforcement in exported servers

When server.disabled_tools is nonempty, the normal bridge installs middleware that hides and rejects those tools, but the generated bundle launches the upstream command directly and serializes only its command, arguments, and environment. Installing the bundle therefore silently re-enables operations the operator deliberately disabled. Either package a filtering wrapper that applies spec.disabled_tools or refuse the export with a clear explanation when this policy is configured.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining as intended behavior, now documented in the module docstring (4ca69a4). disabled_tools filters the elevator's exposed surfaces (MCP proxy, REST, group hub). The .mcpb download is control-plane-gated: the only people who can fetch it are admins/owners who can already read the full launch spec (command, args, env) on the detail endpoint and could reproduce the local run by hand — so no policy is escapable via this export that wasn't already. A packaged filtering wrapper would mean shipping code and a drift surface, which this export deliberately avoids; refusing would block the legitimate "run my own server locally" case for the policy's own author.

The other two findings are fixed in 4ca69a4: export now returns 400 (and the UI hides the button) when the spec depends on cwd/setup_script, and the manifest version derives from app.__version__ instead of a hardcoded constant.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Package the declared binary entry point

Every generated archive declares a binary server whose entry_point is an external host command such as npx, uvx, or docker, while bundle() writes only manifest.json. MCPB binary entry points must identify an executable included in the bundle, so validation or installation cannot resolve this path and the downloaded bundle will not launch. Include the executable and its supporting files, or generate a supported packaged-server layout instead of classifying an external launcher as the bundled binary.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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 @anthropic-ai/mcpb CLI accepts the wrapper-style bundle: validate passes, clean (unpack → re-validate → repack) passes, info reads it. The entry-point-in-archive check exists only in mcpb pack (authoring from a source directory); consumers launch via server.mcp_config, which is exactly what we emit. Shipping no code is the design: the bundle invokes the host's own npx/uvx/docker/executable with the same argv the elevator runs.


Generated by Claude Code

"mcp_config": mcp_config,
Comment thread
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()
137 changes: 137 additions & 0 deletions backend/tests/test_mcpb.py
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)
30 changes: 30 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<a href>` can't carry.
*/
export async function downloadMcpb(id: string): Promise<Blob> {
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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return res.blob();
}

export function deleteServer(id: string): Promise<void> {
return request<void>(`/servers/${encodeURIComponent(id)}`, {
method: 'DELETE'
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading