Skip to content

feat(registry,sdk): /servers/.../download endpoint + tools[] projection#117

Open
Ovaculos wants to merge 8 commits into
NimbleBrainInc:mainfrom
Ovaculos:fix/servers-download-tools
Open

feat(registry,sdk): /servers/.../download endpoint + tools[] projection#117
Ovaculos wants to merge 8 commits into
NimbleBrainInc:mainfrom
Ovaculos:fix/servers-download-tools

Conversation

@Ovaculos
Copy link
Copy Markdown
Contributor

@Ovaculos Ovaculos commented May 14, 2026

Closes #101

Summary

  • New endpoint GET /servers/{name}/versions/{version}/download on the MCP registry surface, mirroring the legacy /v1/bundles/.../download shape 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, optional os/arch query params, and Accept-based JSON-vs-redirect response selection.
  • tools[] projection into _meta["dev.mpak/registry"] on every server detail response — server's manifest.tools[] (MCP Capability[]) gets projected as { name, description? }[] so listing/detail consumers keep the tool listing that the legacy /v1/bundles envelope carried.
  • TS SDK: new getServerDownload() (low-level) + downloadServerBundle() (high-level, auto-detects platform, defaults version to latest, verifies SHA-256).
  • Python SDK: new get_server_download() matching the TS low-level method.
  • Refactor: shared resolveArtifact (commit 24638d3) and shared handleArtifactDownload (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.
  • Test coverage: +44 LOC for browser-fallback paths (local stream + 302) on the new endpoint (servers.test.ts), bringing the new code to parity with legacy /v1/bundles coverage. Full suite: registry 143/143 ✓, sdk-typescript 252/252 ✓, sdk-python 22/22 ✓.

Follow-ups (not in this PR)

  • SDK error classification (#116): both SDKs collapse non-404 4xx/5xx into a single MpakNetworkError, discarding the registry's structured error envelope. New endpoint is reachable through this path when os xor arch is supplied. Spec'd separately.
  • Richer tool projection: the current _meta projection keeps only { name, description } and drops MCP Capability fields like inputSchema, outputSchema, annotations. Sufficient for listing UIs but limits agent-discovery use cases. Worth a follow-up: either widen the projection, or add a dedicated GET /servers/{name}/versions/{version}/tools endpoint so detail responses stay compact while a full schema view is still reachable.
  • Python SDK universal-artifact support: get_server_download(platform=None) auto-detects and always sends os/arch, so Python callers can't request the any/any artifact through this method (TS SDK can — it just omits both query params). Pre-existing in get_bundle_download too. Future cross-SDK consistency pass should add a way to explicitly request "no platform" — e.g. a platform="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 in servers.test.ts covering 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 for getServerDownload / downloadServerBundle)
  • cd packages/sdk-python && uv run pytest (22 tests, includes 5 new for get_server_download)
  • Manual: curl -H 'Accept: application/json' https://registry.mpak.dev/v1/servers/<encoded>/versions/latest/download?os=linux&arch=x64 returns the expected { url, bundle, expires_at } envelope
  • Manual: same URL with Accept: */* in a browser triggers a download (302 in CDN mode, attachment stream in local mode)

🤖 Generated with Claude Code

Ovaculos and others added 7 commits May 14, 2026 11:43
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>
@Ovaculos Ovaculos requested a review from mgoldsborough as a code owner May 14, 2026 17:18
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Phase 2: close the gaps that block /v1/bundles deprecation

1 participant