feat: downloadable .mcpb bundles for local stdio servers - #109
Conversation
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gxd3iyJwW8Jean5nkABtsD
|
Warning Review limit reached
Next review available in: 20 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (7)
📝 WalkthroughSummary by CodeRabbit
WalkthroughThe PR adds MCPB bundle generation for local stdio servers, an authenticated backend download endpoint, frontend download handling, and a server-detail control. Tests validate bundle contents and reject remote servers. ChangesMCPB download flow
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant ServerDetailPage
participant ServersAPI
participant mcpb
User->>ServerDetailPage: Select Download .mcpb
ServerDetailPage->>ServersAPI: Authenticated GET /servers/{server_id}/mcpb
ServersAPI->>mcpb: Generate local server bundle
mcpb-->>ServersAPI: Return manifest.json ZIP
ServersAPI-->>ServerDetailPage: Return downloadable attachment
ServerDetailPage-->>User: Save ${server.slug}.mcpb
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/app/mcpb.py`:
- Around line 45-50: The MCPB export declares spec.command as server.entry_point
without packaging that executable, producing invalid bundles for npx, uvx,
command, and Docker launches. Update bundle() and the server configuration
around mcp_config to either include a valid bundled launch file and point
entry_point to it, or reject non-bundled commands instead of emitting an invalid
MCPB; add coverage that validates generated bundle bytes with the MCPB
validator.
In `@frontend/src/lib/api.ts`:
- Around line 243-250: Update the error handling in downloadMcpb to detect HTTP
401 before reading the response body, and reuse request’s existing
unauthorized-response behavior to clear the stored token and redirect to /login.
Preserve the current ApiError construction for all other unsuccessful responses.
In `@frontend/src/routes/server/`[id]/+page.svelte:
- Around line 118-128: Update saveMcpb to capture the current server id and slug
before awaiting downloadMcpb, then use those captured values for the request and
generated filename so navigation during the download cannot change the target
bundle name.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: d52d8016-cb4a-458f-83db-210d96769141
📒 Files selected for processing (6)
README.mdbackend/app/api/servers.pybackend/app/mcpb.pybackend/tests/test_mcpb.pyfrontend/src/lib/api.tsfrontend/src/routes/server/[id]/+page.svelte
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gxd3iyJwW8Jean5nkABtsD
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: eb42c747fa
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| "type": "binary", | ||
| "entry_point": spec.command, |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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
| 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. |
There was a problem hiding this comment.
Reject launch specs whose required context cannot be exported
For a command configured with cwd or a nonempty setup_script, the running bridge depends on context that this export silently discards. Relative arguments/files will resolve from a different directory in Claude Desktop, and prerequisites created by setup will be absent, so the UI still offers a supposedly ready-to-install bundle that cannot reproduce the server. Since these fields have no MCPB representation, either package/rewrite their dependencies or reject and hide downloads for such configurations.
Useful? React with 👍 / 👎.
| "manifest_version": "0.2", | ||
| "name": server.slug, | ||
| "display_name": server.name, | ||
| "version": "1.0.0", |
There was a problem hiding this comment.
Derive the bundle version instead of hardcoding it
All exports are permanently labeled 1.0.0, even after the deployed release or a same-slug server configuration changes. MCPB clients use this package version to distinguish updates, so a later download can be treated as the already-installed version rather than an upgrade; derive it from the repository's existing app.__version__ source (or another deterministic version that changes with the exported config) instead of adding a stale constant.
AGENTS.md reference: AGENTS.md:L69-L74
Useful? React with 👍 / 👎.
| mcp_config: dict = {"command": spec.command, "args": list(spec.args)} | ||
| if spec.env: | ||
| mcp_config["env"] = dict(spec.env) |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gxd3iyJwW8Jean5nkABtsD
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4ca69a4aca
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| "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"), |
There was a problem hiding this comment.
Change the bundle version when its launch config changes
When an operator edits a server's command, arguments, or environment and re-downloads it during the same mcpelevator release, this still emits the identical package version, so an MCPB client can treat the changed bundle as the already-installed version rather than an update. Fresh evidence after the prior review is that the follow-up now derives the value solely from __version__, leaving same-release configuration changes unrepresented; retain the release-derived base but include a valid, changing server-configuration revision.
AGENTS.md reference: AGENTS.md:L69-L74
Useful? React with 👍 / 👎.
A same-release config edit now yields a distinguishable version string: <release>+<config_hash> (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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gxd3iyJwW8Jean5nkABtsD
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b1d5433f53
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| # (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.
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 👍 / 👎.
There was a problem hiding this comment.
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
| # 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, |
There was a problem hiding this comment.
Keep the MCPB package name stable across slug renames
When an operator uses the supported slug-rename flow and downloads the bundle again, using the mutable routing slug as the manifest name changes the package identity, so the client can install a second extension instead of updating the first and leave the stale server enabled. The same common slug can also collide with a bundle exported from another mcpelevator instance; derive name from an immutable, instance-unique identifier and keep the mutable human-facing value in display_name.
Useful? React with 👍 / 👎.
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 <slug>.mcpb. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gxd3iyJwW8Jean5nkABtsD
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 449c70bee8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 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: |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gxd3iyJwW8Jean5nkABtsD
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a6877ec344
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| </div> | ||
| {/if} | ||
| <!-- cwd/setup_script have no MCPB equivalent (the backend refuses those too). --> | ||
| {#if server.runner !== 'remote' && !server.cwd && !server.setup_script} |
There was a problem hiding this comment.
Hide downloads that the backend will reject
For a command server whose executable is a relative path such as ./server, this condition still displays the download action because it checks only runner, cwd, and setup_script, while the new backend guard in mcpb.manifest() rejects that server with a 400. The advertised button therefore always fails for this supported configuration; mirror the backend eligibility check here or expose an mcpb_exportable field from the API.
Useful? React with 👍 / 👎.
…rule 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gxd3iyJwW8Jean5nkABtsD
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cb34e00d18
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| # 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("/"): |
There was a problem hiding this comment.
Reject Windows-style relative command paths
When an imported command uses Windows syntax such as .\server.exe or bin\server.exe, this check sees no /, marks the server exportable, and emits a bundle containing only manifest.json; Claude Desktop then resolves that executable relative to its own working directory, where the file is not packaged. Fresh evidence after the prior relative-path review is that the new guard handles only POSIX separators, even though the repository's path classification elsewhere normalizes backslashes too; reject Windows-relative paths while continuing to allow drive-absolute paths.
Useful? React with 👍 / 👎.
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gxd3iyJwW8Jean5nkABtsD
Every local (npx/uvx/command/docker) server can now be exported as an MCPB bundle so users can install it in Claude Desktop and run the same server on their own machine.
What's in the change
backend/app/mcpb.py— a small pure module:manifest(server)builds the MCPBmanifest.jsonfrom the row through the samerunners.build_specthe bridge launches (single source of truth — the bundle always carries the exact command/args/env the elevator runs, nothing is stored so nothing can drift), andbundle(server)zips it. Deterministic output: fixed zip timestamp, so the same row yields byte-identical bytes. Non-stdio (remote) specs raiseValueError— there's nothing to run locally.GET /api/servers/{id}/mcpb— streams the bundle as an attachment named<slug>.mcpb. Control-plane gated like every server route (env values in the manifest are visible to exactly the principals who can already read them on the detail endpoint); remote servers get a clean 400.downloadMcpbhelper that carries the bearer header a plain<a href>can't, then saved through an object URL.Notes
manifest_versionis pinned to0.2(the baseline every bundle-aware client accepts);server.typeis"binary"since the bundle ships no code — itsmcp_configinvokes the host's ownnpx/uvx/docker/executable.cwdandsetup_scripthave no MCPB equivalent and are not represented (noted in the module docstring).Testing
backend/tests/test_mcpb.py: HTTP round trip (zip contents,Content-Disposition, exactmcp_configmirror of the launch spec) and the remote-server 400.svelte-check0 errors, vitest 67 passed.🤖 Generated with Claude Code
https://claude.ai/code/session_01Gxd3iyJwW8Jean5nkABtsD
Generated by Claude Code