feat(registry,sdk): /servers/.../download endpoint + tools[] projection#117
Open
Ovaculos wants to merge 8 commits into
Open
feat(registry,sdk): /servers/.../download endpoint + tools[] projection#117Ovaculos wants to merge 8 commits into
Ovaculos wants to merge 8 commits into
Conversation
Pulls the artifact-selection helper out of `routes/v1/bundles.ts` into `services/artifact-resolver.ts` so the upcoming `/servers/.../download` route can use it without duplicating the os/arch dispatch + BadRequestError semantics. No behavior change. The legacy `/v1/bundles/.../download` handler imports the same function it used to define inline; tests stay green. Refs NimbleBrainInc#101 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Carries the bundle's `manifest.tools[]` list (mcpb Capability[]: name + optional description) onto `ServerDetail._meta` under `dev.mpak/registry.tools[]`. Consumers on the new `/v0.1/servers/...` / `/v1/servers/...` surface previously lost the tool list when they moved off the legacy `/v1/bundles/.../show` shape — upstream `ServerDetail` has no `tools` field, and the composer was dropping it on the floor. Mechanical projection only — entries without a string `name` are skipped; entries without a `description` are emitted with `name` alone (so JSON consumers can rely on every entry having `name`, optionally `description`). Absent / non-array `manifest.tools` produces no `tools` key on the meta block at all, keeping diffs against existing fixtures stable. Closes part 2b of NimbleBrainInc#101. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last gap blocking deprecation of
`/v1/bundles/.../download`: the new servers surface had no
signed-fetch endpoint, so SDKs that switched to `getServer()`
still had to fall back to the legacy bundle route to actually
download.
The new handler is registered inside `mcpRegistryRoutes`, so
it's automatically reachable at both prefixes the plugin is
mounted under: `/v0.1/servers/.../download` and
`/v1/servers/.../download`. The response shape matches the
legacy route's `DownloadInfo` byte-for-byte (verified against
the live seed data with a `diff` on the `bundle{}` block) so
SDK consumers can swap base paths without re-handling the
payload.
Behavioral parity with the legacy handler:
- `:name` accepts both npm-style (`@scope/pkg`) and
reverse-DNS (`ai.nimblebrain/echo`) — same `resolveByName`
used by the other `/servers/...` routes.
- `latest` aliases the most recent published version.
- `os` + `arch` query params select a per-platform artifact;
both required when either is set (400 otherwise).
- Universal (`any`/`any`) artifact returned when neither is
set, 404 when none exists.
- `Accept: application/json` returns the DownloadInfo JSON;
everything else gets a 302 to the signed CDN URL (or a
local stream in `STORAGE_TYPE=local` mode).
- Download counts incremented in a non-blocking background
transaction; failures log but never fail the response.
Closes part 2a of NimbleBrainInc#101.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror of `getBundleDownload` / `downloadBundle` on the new `/v1/servers/...` surface. `getServerDownload` returns the same `DownloadInfo` shape the legacy bundle endpoint produces; `downloadServerBundle` is the higher-level helper that combines URL resolution with sha256-verified content fetch (parity with `downloadBundle`). Naming follows the rest of the ServerDetail-flavored methods (`getServer`, `getServerVersion`, `searchServers`) so the swap from `getBundleDownload(name, version, platform)` to `getServerDownload(name, version, platform)` is mechanical. `name` accepts both the npm-style scoped form (`@scope/pkg`) and reverse-DNS form (`ai.nimblebrain/echo`); both are URL- encoded with `encodeURIComponent` so the slash never reaches Fastify's single-segment `:name` parameter. Closes part 2c (TypeScript) of NimbleBrainInc#101. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Counterpart to `get_bundle_download` on the new `/v1/servers/.../download` route. Returns the raw JSON envelope (`dict[str, Any]`) for parity with `get_server` and `get_server_version` — the rest of the server-shaped methods hand back dicts rather than Pydantic models, since the upstream `ServerDetail` _meta block is intentionally open-ended. Note on OpenAPI regen: the existing `scripts/generate-types.py` fetches from the live registry (`https://registry.mpak.dev/docs/json`). The new route isn't deployed there yet, so a regen during this PR would either miss the response model or fetch from a stale spec. Once the registry deploy lands, re-running the script will emit a `V1ServersNameVersionsVersionDownloadGetResponse` model that can be aliased as `ServerDownloadResponse` in `types.py` if a typed return value is wanted later. Closes part 2c (Python) of NimbleBrainInc#101. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds two cases to `tests/servers.test.ts`: - Local-storage path: when `storage.getSignedDownloadUrlFromPath` returns a `/local/...` URL, the route streams the bundle directly with `Content-Type: application/octet-stream` and `Content-Disposition: attachment; filename="<base>-<ver>.mcpb"`. - CDN path: when storage returns an absolute `https://` URL, the route 302-redirects with that URL in `Location`. Both branches existed but were uncovered — same gap mirrors the legacy `/v1/bundles/.../download` tests. With these in place the shared handler refactor in the follow-up commit lands on green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both /v1/bundles/.../download and the new /servers/.../download
ran ~80 lines of near-identical logic: resolve version, look up
artifact via resolveArtifact, log, increment download counters in
a fire-and-forget transaction, then branch on Accept between a
DownloadInfo JSON envelope and a stream/302 for browsers.
Extracts that body into `services/download-handler.ts`:
- Caller does package lookup (npm-style direct vs reverse-DNS-aware)
and passes the resolved `pkg` plus the raw version param.
- Optional `logSurface` tags the log entry so dashboards can split
`/v1/bundles` vs `/servers` traffic.
- Optional `versionNotFoundMessage` preserves each route's existing
404 copy ("Version not found" vs "Version '...' not found for
server '...'").
Both routes shrink to lookup + delegate. Behaviour is unchanged —
all 39 bundles tests + 28 servers tests stay green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI's format checks (`uv run ruff format --check` and `prettier --check`) flagged the two test files added in the preceding commits. No logic changes; just whitespace + quote style alignment with the rest of each SDK's test suite. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #101
Summary
GET /servers/{name}/versions/{version}/downloadon the MCP registry surface, mirroring the legacy/v1/bundles/.../downloadshape so SDK consumers can swap base paths without re-handling the response. Supports both npm-style (@scope/pkg) and reverse-DNS (ai.nimblebrain/echo) names,"latest"aliasing, optionalos/archquery params, and Accept-based JSON-vs-redirect response selection.tools[]projection into_meta["dev.mpak/registry"]on every server detail response — server'smanifest.tools[](MCPCapability[]) gets projected as{ name, description? }[]so listing/detail consumers keep the tool listing that the legacy/v1/bundlesenvelope carried.getServerDownload()(low-level) +downloadServerBundle()(high-level, auto-detects platform, defaults version tolatest, verifies SHA-256).get_server_download()matching the TS low-level method.resolveArtifact(commit 24638d3) and sharedhandleArtifactDownload(commit 3560e6b) — both download routes now share platform-selection + counter-increment + JSON/redirect logic. Net change: -29/+90 across the two routes despite adding the new endpoint.servers.test.ts), bringing the new code to parity with legacy/v1/bundlescoverage. Full suite: registry 143/143 ✓, sdk-typescript 252/252 ✓, sdk-python 22/22 ✓.Follow-ups (not in this PR)
MpakNetworkError, discarding the registry's structured error envelope. New endpoint is reachable through this path whenosxorarchis supplied. Spec'd separately._metaprojection keeps only{ name, description }and drops MCPCapabilityfields likeinputSchema,outputSchema,annotations. Sufficient for listing UIs but limits agent-discovery use cases. Worth a follow-up: either widen the projection, or add a dedicatedGET /servers/{name}/versions/{version}/toolsendpoint so detail responses stay compact while a full schema view is still reachable.get_server_download(platform=None)auto-detects and always sendsos/arch, so Python callers can't request theany/anyartifact through this method (TS SDK can — it just omits both query params). Pre-existing inget_bundle_downloadtoo. Future cross-SDK consistency pass should add a way to explicitly request "no platform" — e.g. aplatform="universal"sentinel that the SDK translates to dropping the query params client-side.Test plan
pnpm --filter @nimblebrain/mpak-registry test(143 tests, includes 28 new inservers.test.tscovering JSON, latest aliasing, 404/400 paths, any/any artifact, reverse-DNS resolution, local stream, 302 redirect)pnpm --filter @nimblebrain/mpak-sdk test(252 tests, includes 9 new forgetServerDownload/downloadServerBundle)cd packages/sdk-python && uv run pytest(22 tests, includes 5 new forget_server_download)curl -H 'Accept: application/json' https://registry.mpak.dev/v1/servers/<encoded>/versions/latest/download?os=linux&arch=x64returns the expected{ url, bundle, expires_at }envelopeAccept: */*in a browser triggers a download (302 in CDN mode, attachment stream in local mode)🤖 Generated with Claude Code