From 24638d3ba349a3fa76db4b6ff59575bb7d75a429 Mon Sep 17 00:00:00 2001 From: Mason <31372737+Ovaculos@users.noreply.github.com> Date: Thu, 14 May 2026 11:43:46 -0500 Subject: [PATCH 1/8] refactor(registry): extract resolveArtifact into shared service 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 #101 Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/registry/src/routes/v1/bundles.ts | 26 +--------------- .../src/services/artifact-resolver.ts | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 25 deletions(-) create mode 100644 apps/registry/src/services/artifact-resolver.ts diff --git a/apps/registry/src/routes/v1/bundles.ts b/apps/registry/src/routes/v1/bundles.ts index 483762f..91e0faf 100644 --- a/apps/registry/src/routes/v1/bundles.ts +++ b/apps/registry/src/routes/v1/bundles.ts @@ -1,4 +1,3 @@ -import type { Artifact } from '@prisma/client'; import type { FastifyPluginAsync } from 'fastify'; import { createHash, randomUUID } from 'crypto'; import { createWriteStream, createReadStream, promises as fs } from 'fs'; @@ -38,6 +37,7 @@ import { import { generateBadge } from '../../utils/badge.js'; import { notifyDiscordAnnounce } from '../../utils/discord.js'; import { triggerSecurityScan } from '../../services/scanner.js'; +import { resolveArtifact } from '../../services/artifact-resolver.js'; // GitHub release asset type interface GitHubReleaseAsset { @@ -65,30 +65,6 @@ function isValidScopedPackageName(name: string): boolean { return SCOPED_REGEX.test(name); } -/** - * Resolve the correct artifact given optional platform query params. - * - * - Neither os nor arch → return the any/any (universal) artifact, or null - * - Only one of os/arch → throws BadRequestError - * - Both os and arch → return exact match, or null - */ -function resolveArtifact( - artifacts: Artifact[], - os?: string, - arch?: string, -): Artifact | null { - if ((os && !arch) || (!os && arch)) { - throw new BadRequestError('Both os and arch are required when specifying platform'); - } - - if (os && arch) { - return artifacts.find((a) => a.os === os && a.arch === arch) ?? null; - } - - // No platform params: return universal artifact only - return artifacts.find((a) => a.os === 'any' && a.arch === 'any') ?? null; -} - function parsePackageName(name: string): { scope: string; packageName: string } | null { if (!name.startsWith('@')) return null; const parts = name.split('/'); diff --git a/apps/registry/src/services/artifact-resolver.ts b/apps/registry/src/services/artifact-resolver.ts new file mode 100644 index 0000000..4ef445c --- /dev/null +++ b/apps/registry/src/services/artifact-resolver.ts @@ -0,0 +1,30 @@ +import type { Artifact } from '@prisma/client'; +import { BadRequestError } from '../errors/index.js'; + +/** + * Resolve the correct artifact given optional platform query params. + * + * - Neither os nor arch → return the any/any (universal) artifact, or null + * - Only one of os/arch → throws BadRequestError + * - Both os and arch → return exact match, or null + * + * Shared between the legacy `/v1/bundles/.../download` route and the + * new `/servers/.../download` route so both implement identical + * platform-selection semantics. + */ +export function resolveArtifact( + artifacts: Artifact[], + os?: string, + arch?: string, +): Artifact | null { + if ((os && !arch) || (!os && arch)) { + throw new BadRequestError('Both os and arch are required when specifying platform'); + } + + if (os && arch) { + return artifacts.find((a) => a.os === os && a.arch === arch) ?? null; + } + + // No platform params: return universal artifact only + return artifacts.find((a) => a.os === 'any' && a.arch === 'any') ?? null; +} From b79347a78aa9c9040f5cd2c11ebcf9667790fe6e Mon Sep 17 00:00:00 2001 From: Mason <31372737+Ovaculos@users.noreply.github.com> Date: Thu, 14 May 2026 11:43:55 -0500 Subject: [PATCH 2/8] feat(registry): project manifest.tools[] into _meta dev.mpak/registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #101. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/services/server-detail-composer.ts | 26 ++++++++++++++++--- .../tests/server-detail-composer.test.ts | 26 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/apps/registry/src/services/server-detail-composer.ts b/apps/registry/src/services/server-detail-composer.ts index 7512369..2049d1e 100644 --- a/apps/registry/src/services/server-detail-composer.ts +++ b/apps/registry/src/services/server-detail-composer.ts @@ -26,7 +26,7 @@ * plus fileSha256 from each artifact * _meta manifest._meta verbatim + dev.mpak/registry block * (npmName, downloads, published_at, provenance, - * certification, artifacts[]) + * certification, artifacts[], tools[]) * * Validates the result against the Zod `ServerDetailSchema` before * returning. The throw-variant fails loud with the issue list when the @@ -334,11 +334,12 @@ function readEnvMap(manifest: Record): Record { * Compose the `_meta` field: * - every author-provided `_meta` block carried verbatim * - mpak adds its own `dev.mpak/registry` block with npmName, - * downloads, published_at, provenance, certification, artifacts[] + * downloads, published_at, provenance, certification, artifacts[], + * tools[] */ function composeMeta( input: ComposerInput, - _manifest: Record, + manifest: Record, manifestMeta: Record | null, ): Record { const meta: Record = { ...(manifestMeta ?? {}) }; @@ -374,6 +375,25 @@ function composeMeta( size: Number(a.sizeBytes), })); } + // Project manifest.tools[] (mcpb v0.4 Capability[]) so consumers that + // moved to `/servers/{name}` retain the tool listing the legacy + // `/v1/bundles/{name}` response carried. Unset / non-array values are + // skipped — the field is optional in the manifest schema. + const manifestTools = manifest["tools"]; + if (Array.isArray(manifestTools)) { + const projected = manifestTools + .filter((t): t is Record => typeof t === "object" && t !== null) + .map((t) => { + const name = typeof t["name"] === "string" ? t["name"] : null; + if (!name) return null; + const description = typeof t["description"] === "string" ? t["description"] : undefined; + return description !== undefined ? { name, description } : { name }; + }) + .filter((t): t is { name: string; description?: string } => t !== null); + if (projected.length > 0) { + mpakBlock["tools"] = projected; + } + } meta["dev.mpak/registry"] = mpakBlock; return meta; } diff --git a/apps/registry/tests/server-detail-composer.test.ts b/apps/registry/tests/server-detail-composer.test.ts index d08da4c..0662f37 100644 --- a/apps/registry/tests/server-detail-composer.test.ts +++ b/apps/registry/tests/server-detail-composer.test.ts @@ -236,4 +236,30 @@ describe('composeServerDetail', () => { }, ]); }); + + it('projects manifest.tools[] into _meta["dev.mpak/registry"].tools[]', () => { + const manifestWithTools = { + ...FULL_MANIFEST, + tools: [ + { name: 'echo', description: 'Echoes back the input string' }, + { name: 'reverse', description: 'Reverses the input string' }, + { name: 'no_desc' }, + ], + }; + const detail = composeServerDetail( + input({ version: { manifest: manifestWithTools } as ComposerInput['version'] }), + ); + const mpakMeta = detail?._meta?.['dev.mpak/registry'] as Record; + expect(mpakMeta['tools']).toEqual([ + { name: 'echo', description: 'Echoes back the input string' }, + { name: 'reverse', description: 'Reverses the input string' }, + { name: 'no_desc' }, + ]); + }); + + it('omits tools[] from dev.mpak/registry when manifest has no tools', () => { + const detail = composeServerDetail(input()); + const mpakMeta = detail?._meta?.['dev.mpak/registry'] as Record; + expect('tools' in mpakMeta).toBe(false); + }); }); From 0109002c98cb95fa92005700be2eff20c3e52ceb Mon Sep 17 00:00:00 2001 From: Mason <31372737+Ovaculos@users.noreply.github.com> Date: Thu, 14 May 2026 11:44:07 -0500 Subject: [PATCH 3/8] feat(registry): add GET /servers/:name/versions/:version/download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #101. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/registry/src/routes/mcp/v0.1/servers.ts | 120 ++++++++++++- apps/registry/tests/servers.test.ts | 168 +++++++++++++++++++ 2 files changed, 287 insertions(+), 1 deletion(-) diff --git a/apps/registry/src/routes/mcp/v0.1/servers.ts b/apps/registry/src/routes/mcp/v0.1/servers.ts index fc046fa..d35ec21 100644 --- a/apps/registry/src/routes/mcp/v0.1/servers.ts +++ b/apps/registry/src/routes/mcp/v0.1/servers.ts @@ -14,8 +14,19 @@ */ import type { FastifyInstance, FastifyPluginAsync } from 'fastify'; -import { resolveReverseDnsName, type ServerDetail } from '@nimblebrain/mpak-schemas'; +import { + BundleDownloadParamsSchema, + DownloadInfoSchema, + resolveReverseDnsName, + type BundleDownloadParams, + type ServerDetail, +} from '@nimblebrain/mpak-schemas'; +import { config } from '../../../config.js'; +import { runInTransaction } from '../../../db/index.js'; +import { NotFoundError } from '../../../errors/index.js'; +import { toJsonSchema } from '../../../lib/zod-schema.js'; import type { PackageForServerLookup } from '../../../db/repositories/package.repository.js'; +import { resolveArtifact } from '../../../services/artifact-resolver.js'; import { composeServerDetail } from '../../../services/server-detail-composer.js'; const REGISTRY_VERSION = 'v1.0.0'; @@ -404,6 +415,113 @@ export const mcpRegistryRoutes: FastifyPluginAsync = async (fastify) => { }; }); + // GET /servers/{name}/versions/{version}/download - Signed download + // URL + bundle metadata. Mirrors the legacy + // `/v1/bundles/.../download` shape so SDK consumers can swap base + // paths without changing the response handling. The `Accept` header + // selects JSON (CLI/API) vs an HTTP redirect (browser). + fastify.get<{ + Params: { name: string; version: string }; + Querystring: BundleDownloadParams; + }>('/servers/:name/versions/:version/download', { + schema: { + tags: ['mcp-registry'], + description: + 'Resolve a server version to a signed CDN download URL plus the bundle\'s sha256+size. `os`+`arch` query params select per-platform artifacts; both required when either is set. Without them, returns the universal (any/any) artifact if one exists. Use "latest" as the version to alias the most recent published version.', + params: { + type: 'object', + required: ['name', 'version'], + properties: { + name: { type: 'string', description: 'URL-encoded server name' }, + version: { type: 'string', description: 'Server version, or "latest"' }, + }, + }, + querystring: toJsonSchema(BundleDownloadParamsSchema), + response: { + 200: toJsonSchema(DownloadInfoSchema), + 302: { type: 'null', description: 'Redirect to download URL' }, + }, + }, + }, async (request, reply) => { + const { name: rawName, version: versionParam } = request.params; + const { os: queryOs, arch: queryArch } = request.query; + + const pkg = await resolveByName(fastify, rawName); + if (!pkg) { + throw new NotFoundError(`Server '${decodeURIComponent(rawName)}' not found`); + } + + // Resolve "latest" to the actual version (pkg.latestVersion). + const resolvedVersion = versionParam === 'latest' ? pkg.latestVersion : versionParam; + + const { packages: packageRepo } = fastify.repositories; + const packageVersion = await packageRepo.findVersionWithArtifacts(pkg.id, resolvedVersion); + if (!packageVersion) { + throw new NotFoundError(`Version '${resolvedVersion}' not found for server '${pkg.name}'`); + } + + // resolveArtifact throws BadRequestError when only one of os/arch + // is supplied — Fastify converts that into a 400. + const artifact = resolveArtifact(packageVersion.artifacts, queryOs, queryArch); + if (!artifact) { + throw new NotFoundError('No artifact found for the requested platform'); + } + + const platform = artifact.os === 'any' ? 'universal' : `${artifact.os}-${artifact.arch}`; + fastify.log.info({ + op: 'download', + pkg: pkg.name, + version: resolvedVersion, + platform, + surface: 'servers', + }, `download (servers): ${pkg.name}@${resolvedVersion} (${platform})`); + + // Best-effort download count bumps — failures get logged but never + // block the response (mirrors the legacy /v1/bundles handler). + void runInTransaction(async (tx) => { + await packageRepo.incrementArtifactDownloads(artifact.id, tx); + await packageRepo.incrementVersionDownloads(pkg.id, resolvedVersion, tx); + await packageRepo.incrementDownloads(pkg.id, tx); + }).catch((err: unknown) => + fastify.log.error({ err }, 'Failed to update download counts'), + ); + + const acceptHeader = request.headers.accept ?? ''; + const wantsJson = acceptHeader.includes('application/json'); + + const downloadUrl = await fastify.storage.getSignedDownloadUrlFromPath(artifact.storagePath); + + if (wantsJson) { + const expiresAt = new Date(); + expiresAt.setSeconds( + expiresAt.getSeconds() + (config.storage.cloudfront.urlExpirationSeconds || 900), + ); + return { + url: downloadUrl, + bundle: { + name: pkg.name, + version: resolvedVersion, + platform: { os: artifact.os, arch: artifact.arch }, + sha256: artifact.digest.replace('sha256:', ''), + size: Number(artifact.sizeBytes), + }, + expires_at: expiresAt.toISOString(), + }; + } + + // Browser-style download: stream local files directly; redirect to + // signed CDN URLs in S3/CloudFront mode. + if (downloadUrl.startsWith('/')) { + const fileBuffer = await fastify.storage.getBundle(artifact.storagePath); + const npmPart = pkg.name.startsWith('@') ? pkg.name.split('/')[1] ?? pkg.name : pkg.name; + return reply + .header('Content-Type', 'application/octet-stream') + .header('Content-Disposition', `attachment; filename="${npmPart}-${resolvedVersion}.mcpb"`) + .send(fileBuffer); + } + return reply.code(302).redirect(downloadUrl); + }); + // GET /health - Registry-specific health probe (counts servers). // The top-level /health route is the LB liveness probe with a // simpler `{ status: "ok" }` shape — kept distinct on purpose. diff --git a/apps/registry/tests/servers.test.ts b/apps/registry/tests/servers.test.ts index 7acf882..e9a923e 100644 --- a/apps/registry/tests/servers.test.ts +++ b/apps/registry/tests/servers.test.ts @@ -35,9 +35,11 @@ vi.mock('../src/db/index.js', () => ({ import { createMockPackageRepo, + createMockStorage, mockArtifact, mockPackage, mockVersion, + mockVersionWithArtifacts, } from './helpers.js'; import { errorHandler } from '../src/errors/middleware.js'; @@ -66,9 +68,11 @@ function lookupRow( describe('MCP Registry routes', () => { let app: FastifyInstance; let packageRepo: ReturnType; + let storage: ReturnType; beforeAll(async () => { packageRepo = createMockPackageRepo(); + storage = createMockStorage(); app = Fastify({ logger: false }); app.setReplySerializer((payload) => JSON.stringify(payload)); await app.register(sensible); @@ -79,6 +83,7 @@ describe('MCP Registry routes', () => { users: {}, skills: {}, }); + app.decorate('storage', storage); const { mcpRegistryRoutes } = await import('../src/routes/mcp/v0.1/servers.js'); await app.register(mcpRegistryRoutes); @@ -367,4 +372,167 @@ describe('MCP Registry routes', () => { expect(body.versions[0].is_latest).toBe(true); }); }); + + // ───────────────────────────────────────────────────────────────── + // GET /servers/{name}/versions/{version}/download + // ───────────────────────────────────────────────────────────────── + + describe('GET /servers/{name}/versions/{version}/download', () => { + const NAME_ENCODED = encodeURIComponent('@test/mcp-server'); + + it('returns JSON download info when Accept: application/json', async () => { + packageRepo.findPackageForServerLookup.mockResolvedValueOnce(lookupRow()); + packageRepo.findVersionWithArtifacts.mockResolvedValueOnce(mockVersionWithArtifacts); + + const res = await app.inject({ + method: 'GET', + url: `/servers/${NAME_ENCODED}/versions/1.0.0/download?os=linux&arch=x64`, + headers: { accept: 'application/json' }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.payload); + expect(body.url).toBe('https://cdn.example.com/signed'); + expect(body.bundle.name).toBe('@test/mcp-server'); + expect(body.bundle.version).toBe('1.0.0'); + expect(body.bundle.platform).toEqual({ os: 'linux', arch: 'x64' }); + expect(body.bundle.sha256).toBe('abc123'); + expect(body.expires_at).toBeDefined(); + }); + + it('resolves "latest" to the actual latest version', async () => { + packageRepo.findPackageForServerLookup.mockResolvedValueOnce(lookupRow()); + packageRepo.findVersionWithArtifacts.mockResolvedValueOnce(mockVersionWithArtifacts); + + const res = await app.inject({ + method: 'GET', + url: `/servers/${NAME_ENCODED}/versions/latest/download?os=linux&arch=x64`, + headers: { accept: 'application/json' }, + }); + + expect(res.statusCode).toBe(200); + // findVersionWithArtifacts called with the resolved latest version + expect(packageRepo.findVersionWithArtifacts).toHaveBeenCalledWith('pkg-001', '1.0.0'); + }); + + it('returns 404 when the package is unknown', async () => { + packageRepo.findPackageForServerLookup.mockResolvedValueOnce(null); + + const res = await app.inject({ + method: 'GET', + url: `/servers/${encodeURIComponent('@test/nope')}/versions/1.0.0/download`, + headers: { accept: 'application/json' }, + }); + + expect(res.statusCode).toBe(404); + }); + + it('returns 404 when the version is unknown', async () => { + packageRepo.findPackageForServerLookup.mockResolvedValueOnce(lookupRow()); + packageRepo.findVersionWithArtifacts.mockResolvedValueOnce(null); + + const res = await app.inject({ + method: 'GET', + url: `/servers/${NAME_ENCODED}/versions/9.9.9/download?os=linux&arch=x64`, + headers: { accept: 'application/json' }, + }); + + expect(res.statusCode).toBe(404); + }); + + it('returns 404 when no artifact matches the requested platform', async () => { + packageRepo.findPackageForServerLookup.mockResolvedValueOnce(lookupRow()); + packageRepo.findVersionWithArtifacts.mockResolvedValueOnce({ + ...mockVersion, + artifacts: [], + }); + + const res = await app.inject({ + method: 'GET', + url: `/servers/${NAME_ENCODED}/versions/1.0.0/download`, + headers: { accept: 'application/json' }, + }); + + expect(res.statusCode).toBe(404); + }); + + it('returns 400 when only os is provided without arch', async () => { + packageRepo.findPackageForServerLookup.mockResolvedValueOnce(lookupRow()); + packageRepo.findVersionWithArtifacts.mockResolvedValueOnce(mockVersionWithArtifacts); + + const res = await app.inject({ + method: 'GET', + url: `/servers/${NAME_ENCODED}/versions/1.0.0/download?os=linux`, + headers: { accept: 'application/json' }, + }); + + expect(res.statusCode).toBe(400); + }); + + it('returns 400 when only arch is provided without os', async () => { + packageRepo.findPackageForServerLookup.mockResolvedValueOnce(lookupRow()); + packageRepo.findVersionWithArtifacts.mockResolvedValueOnce(mockVersionWithArtifacts); + + const res = await app.inject({ + method: 'GET', + url: `/servers/${NAME_ENCODED}/versions/1.0.0/download?arch=x64`, + headers: { accept: 'application/json' }, + }); + + expect(res.statusCode).toBe(400); + }); + + it('returns 200 with any/any artifact when no platform params are provided', async () => { + const anyArtifact = { + ...mockArtifact, + id: 'art-any', + os: 'any', + arch: 'any', + storagePath: '@test/mcp-server/1.0.0/any-any.mcpb', + }; + packageRepo.findPackageForServerLookup.mockResolvedValueOnce(lookupRow()); + packageRepo.findVersionWithArtifacts.mockResolvedValueOnce({ + ...mockVersion, + artifacts: [anyArtifact], + }); + + const res = await app.inject({ + method: 'GET', + url: `/servers/${NAME_ENCODED}/versions/1.0.0/download`, + headers: { accept: 'application/json' }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.payload); + expect(body.bundle.platform).toEqual({ os: 'any', arch: 'any' }); + }); + + it('resolves a reverse-DNS name to its npm-style origin', async () => { + // Reverse-DNS form `ai.nimblebrain/echo` → `@nimblebraininc/echo` + const pkg = { + ...mockPackage, + id: 'pkg-nb', + name: '@nimblebraininc/echo', + latestVersion: '0.1.0', + }; + const ver = { ...mockVersion, packageId: 'pkg-nb', version: '0.1.0' }; + const verWithArts = { ...ver, artifacts: [mockArtifact] }; + // resolveByName performs a direct lookup first; that misses for the + // reverse-DNS form, then falls back to candidate lookups. + packageRepo.findPackageForServerLookup + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ ...pkg, versions: [{ ...ver, artifacts: [], securityScans: [] }] }); + packageRepo.findVersionWithArtifacts.mockResolvedValueOnce(verWithArts); + + const res = await app.inject({ + method: 'GET', + url: `/servers/${encodeURIComponent('ai.nimblebrain/echo')}/versions/0.1.0/download?os=linux&arch=x64`, + headers: { accept: 'application/json' }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.payload); + expect(body.bundle.name).toBe('@nimblebraininc/echo'); + }); + }); }); From 1436b574fc744f177e42cc40964daabc87e1cb73 Mon Sep 17 00:00:00 2001 From: Mason <31372737+Ovaculos@users.noreply.github.com> Date: Thu, 14 May 2026 11:44:15 -0500 Subject: [PATCH 4/8] feat(sdk): add getServerDownload + downloadServerBundle 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 #101. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/sdk-typescript/src/client.ts | 66 ++++++++++ packages/sdk-typescript/tests/client.test.ts | 129 +++++++++++++++++++ 2 files changed, 195 insertions(+) diff --git a/packages/sdk-typescript/src/client.ts b/packages/sdk-typescript/src/client.ts index 2eef31c..3ac56c6 100644 --- a/packages/sdk-typescript/src/client.ts +++ b/packages/sdk-typescript/src/client.ts @@ -230,6 +230,45 @@ export class MpakClient { return response.json() as Promise; } + /** + * Resolve a server version to a signed download URL plus the + * bundle's `sha256`/`size`. Counterpart to `getBundleDownload` on + * the new `/v1/servers/...` surface; the response shape is + * identical so consumers swapping base paths get the same + * `DownloadInfo` back. + * + * `name` accepts both the npm-style scoped name (`@scope/pkg`) and + * the reverse-DNS form (`ai.nimblebrain/echo`). Passing + * `version = "latest"` aliases the most recent published version. + * Omit `platform` only when a universal (any/any) artifact exists + * for the server. + */ + async getServerDownload( + name: string, + version: string, + platform?: PlatformInfo, + ): Promise { + const params = new URLSearchParams(); + if (platform) { + params.set('os', platform.os); + params.set('arch', platform.arch); + } + const queryString = params.toString(); + const url = `${this.registryUrl}/v1/servers/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}/download${queryString ? `?${queryString}` : ''}`; + + const response = await this.fetchWithTimeout(url, { + headers: { Accept: 'application/json' }, + }); + + if (response.status === 404) { + throw new MpakNotFoundError(`${name}@${version}`); + } + if (!response.ok) { + throw new MpakNetworkError(`Failed to get server download: HTTP ${response.status}`); + } + return response.json() as Promise; + } + // =========================================================================== // Skill API // =========================================================================== @@ -380,6 +419,33 @@ export class MpakClient { return { data, metadata: downloadInfo.bundle }; } + /** + * Download a server bundle by name from the `/v1/servers/...` + * surface, with optional version and platform. Defaults to latest + * version and auto-detected platform. Verifies the bundle's + * sha256 before returning. + * + * @throws {MpakNotFoundError} If server not found + * @throws {MpakIntegrityError} If SHA-256 doesn't match + * @throws {MpakNetworkError} For network failures + */ + async downloadServerBundle( + name: string, + version?: string, + platform?: PlatformInfo, + ): Promise<{ + data: Uint8Array; + metadata: DownloadInfo['bundle']; + }> { + const resolvedPlatform = platform ?? MpakClient.detectPlatform(); + const resolvedVersion = version ?? 'latest'; + + const downloadInfo = await this.getServerDownload(name, resolvedVersion, resolvedPlatform); + const data = await this.downloadContent(downloadInfo.url, downloadInfo.bundle.sha256); + + return { data, metadata: downloadInfo.bundle }; + } + /** * Download a skill bundle by name, with optional version. * Defaults to latest version. diff --git a/packages/sdk-typescript/tests/client.test.ts b/packages/sdk-typescript/tests/client.test.ts index e268860..422c853 100644 --- a/packages/sdk-typescript/tests/client.test.ts +++ b/packages/sdk-typescript/tests/client.test.ts @@ -719,4 +719,133 @@ describe('MpakClient', () => { ); }); }); + + describe('getServerDownload', () => { + const downloadInfo = { + url: 'https://storage.example.com/bundle.mcpb', + bundle: { + name: '@nimblebraininc/echo', + version: '0.1.6', + platform: { os: 'linux', arch: 'x64' }, + sha256: 'abc123', + size: 17455747, + }, + expires_at: '2026-04-09T12:15:00Z', + }; + + it('hits /v1/servers/{name}/versions/{version}/download with platform params', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce(mockResponse(downloadInfo)); + + const result = await client.getServerDownload('@nimblebraininc/echo', '0.1.6', { + os: 'linux', + arch: 'x64', + }); + + const url = fetchMock.mock.calls[0]?.[0] as string; + expect(url).toContain('/v1/servers/%40nimblebraininc%2Fecho/versions/0.1.6/download'); + expect(url).toContain('os=linux'); + expect(url).toContain('arch=x64'); + expect(result.url).toBe('https://storage.example.com/bundle.mcpb'); + expect(result.bundle.sha256).toBe('abc123'); + }); + + it('omits platform query when not provided', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce(mockResponse(downloadInfo)); + + await client.getServerDownload('ai.nimblebrain/echo', 'latest'); + + const url = fetchMock.mock.calls[0]?.[0] as string; + expect(url).toContain('/v1/servers/ai.nimblebrain%2Fecho/versions/latest/download'); + expect(url).not.toContain('os='); + expect(url).not.toContain('arch='); + }); + + it('sends Accept: application/json header', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce(mockResponse(downloadInfo)); + + await client.getServerDownload('@nimblebraininc/echo', '0.1.6'); + + const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined; + const headers = init?.headers as Record | undefined; + expect(headers?.Accept).toBe('application/json'); + }); + + it('throws MpakNotFoundError on 404 with name@version in the message', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce(mockResponse('not found', { status: 404 })); + + await expect( + client.getServerDownload('ai.nimblebrain/echo', '99.0.0'), + ).rejects.toThrow(/ai.nimblebrain\/echo@99\.0\.0/); + }); + + it('throws MpakNetworkError on 5xx', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce(mockResponse('boom', { status: 503 })); + await expect( + client.getServerDownload('ai.nimblebrain/echo', '0.1.6'), + ).rejects.toThrow(MpakNetworkError); + }); + }); + + describe('downloadServerBundle', () => { + const content = new TextEncoder().encode('fake mcpb bundle bytes'); + const hash = sha256(content); + const downloadInfo = { + url: 'https://storage.example.com/bundle.mcpb', + bundle: { + name: '@nimblebraininc/echo', + version: '0.1.6', + platform: { os: 'darwin', arch: 'arm64' }, + sha256: hash, + size: content.length, + }, + expires_at: '2026-04-09T12:15:00Z', + }; + + it('resolves info and returns verified buffer + metadata', async () => { + const client = new MpakClient(); + fetchMock + .mockResolvedValueOnce(mockResponse(downloadInfo)) + .mockResolvedValueOnce(mockBinaryResponse(content)); + + const result = await client.downloadServerBundle('@nimblebraininc/echo', '0.1.6', { + os: 'darwin', + arch: 'arm64', + }); + + expect(new TextDecoder().decode(result.data)).toBe('fake mcpb bundle bytes'); + expect(result.metadata.sha256).toBe(hash); + expect(result.metadata.name).toBe('@nimblebraininc/echo'); + }); + + it('defaults version to latest and auto-detects platform', async () => { + const client = new MpakClient(); + fetchMock + .mockResolvedValueOnce(mockResponse(downloadInfo)) + .mockResolvedValueOnce(mockBinaryResponse(content)); + + await client.downloadServerBundle('ai.nimblebrain/echo'); + + const url = fetchMock.mock.calls[0]?.[0] as string; + expect(url).toContain('/versions/latest/download'); + expect(url).toContain('os='); + expect(url).toContain('arch='); + }); + + it('propagates MpakIntegrityError on SHA-256 mismatch', async () => { + const client = new MpakClient(); + const tampered = new TextEncoder().encode('tampered content'); + fetchMock + .mockResolvedValueOnce(mockResponse(downloadInfo)) + .mockResolvedValueOnce(mockBinaryResponse(tampered)); + + await expect( + client.downloadServerBundle('@nimblebraininc/echo', '0.1.6'), + ).rejects.toThrow(MpakIntegrityError); + }); + }); }); From 181f3c52469e53ad955fbfa8efede47cd7dae6f1 Mon Sep 17 00:00:00 2001 From: Mason <31372737+Ovaculos@users.noreply.github.com> Date: Thu, 14 May 2026 11:44:24 -0500 Subject: [PATCH 5/8] feat(sdk-python): add get_server_download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #101. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/sdk-python/src/mpak/client.py | 61 +++++++++++++ packages/sdk-python/tests/test_client.py | 104 +++++++++++++++++++++++ 2 files changed, 165 insertions(+) diff --git a/packages/sdk-python/src/mpak/client.py b/packages/sdk-python/src/mpak/client.py index 218758c..32bcfa2 100644 --- a/packages/sdk-python/src/mpak/client.py +++ b/packages/sdk-python/src/mpak/client.py @@ -360,6 +360,67 @@ def get_server_version(self, name: str, version: str) -> dict[str, Any]: except httpx.RequestError as e: raise MpakNetworkError(f"Network error: {e}") from e + def get_server_download( + self, + name: str, + version: str = "latest", + platform: tuple[str, str] | None = None, + ) -> dict[str, Any]: + """Resolve a server version to a signed download URL and bundle metadata. + + Counterpart to :meth:`get_bundle_download` on the new + ``/v1/servers/...`` surface. The response shape is identical + — ``{"url", "bundle": {...}, "expires_at"}`` — so callers + switching from the legacy bundle endpoint to the server + endpoint can drop in the new method without re-handling the + payload. + + ``name`` accepts both the npm-style scoped name + (``@scope/pkg``) and the reverse-DNS form + (``ai.nimblebrain/echo``); the registry resolves both. Both + ``name`` and ``version`` are URL-encoded — see + :meth:`get_server` for the encoding rationale. + + Args: + name: Server name (npm-style or reverse-DNS). + version: Version to download. ``"latest"`` aliases the + most recent published version. + platform: Tuple of (os, arch). If ``None``, auto-detects + the current platform via :meth:`detect_platform`. + + Returns: + Raw JSON envelope from the registry — typed as ``dict`` + for parity with :meth:`get_server` and friends. + + Raises: + MpakNotFoundError: If the server, version, or matching + artifact is not found. + MpakNetworkError: If the network request fails. + """ + if platform is None: + platform = detect_platform() + os_name, arch = platform + + encoded_name = quote(name, safe="") + encoded_version = quote(version, safe="") + url = f"/v1/servers/{encoded_name}/versions/{encoded_version}/download" + params = {"os": os_name, "arch": arch} + + try: + response = self._client.get(url, params=params) + if response.status_code == 404: + raise MpakNotFoundError(f"{name}@{version}") + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + raise MpakError( + f"HTTP {e.response.status_code}: {e.response.text}", + "HTTP_ERROR", + e.response.status_code, + ) from e + except httpx.RequestError as e: + raise MpakNetworkError(f"Network error: {e}") from e + @staticmethod def _parse_package_name(package: str) -> tuple[str, str]: """Parse a scoped package name into scope and name. diff --git a/packages/sdk-python/tests/test_client.py b/packages/sdk-python/tests/test_client.py index f4bab83..fd05da8 100644 --- a/packages/sdk-python/tests/test_client.py +++ b/packages/sdk-python/tests/test_client.py @@ -299,3 +299,107 @@ def test_get_server_version_404_raises_not_found_with_version(): client.get_server_version("ai.nimblebrain/echo", "99.0.0") assert "ai.nimblebrain/echo@99.0.0" in str(exc_info.value) + + +# ───────────────────────────────────────────────────────────────────── +# get_server_download — /v1/servers/{name}/versions/{version}/download +# ───────────────────────────────────────────────────────────────────── + + +_DOWNLOAD_INFO: dict = { + "url": "https://cdn.example.com/bundle.mcpb", + "bundle": { + "name": "@nimblebraininc/echo", + "version": "0.1.6", + "platform": {"os": "linux", "arch": "x64"}, + "sha256": "abc123def456", + "size": 17455747, + }, + "expires_at": "2026-04-09T12:15:00Z", +} + + +@respx.mock +def test_get_server_download_returns_download_info(): + """get_server_download hits the new /v1/servers/.../download endpoint.""" + route = respx.get( + "https://registry.mpak.dev/v1/servers/%40nimblebraininc%2Fecho/versions/0.1.6/download", + params={"os": "linux", "arch": "x64"}, + ).mock(return_value=Response(200, json=_DOWNLOAD_INFO)) + + client = MpakClient() + download = client.get_server_download( + "@nimblebraininc/echo", "0.1.6", platform=("linux", "x64") + ) + + assert route.called + assert download["url"] == "https://cdn.example.com/bundle.mcpb" + assert download["bundle"]["sha256"] == "abc123def456" + assert download["bundle"]["version"] == "0.1.6" + + +@respx.mock +def test_get_server_download_accepts_reverse_dns_name(): + """Reverse-DNS names are URL-encoded and forwarded; the registry + resolves them server-side via the reverse-DNS candidate map.""" + route = respx.get( + "https://registry.mpak.dev/v1/servers/ai.nimblebrain%2Fecho/versions/0.1.6/download", + params={"os": "linux", "arch": "x64"}, + ).mock(return_value=Response(200, json=_DOWNLOAD_INFO)) + + client = MpakClient() + download = client.get_server_download( + "ai.nimblebrain/echo", "0.1.6", platform=("linux", "x64") + ) + + assert route.called + assert download["bundle"]["name"] == "@nimblebraininc/echo" + + +@respx.mock +def test_get_server_download_auto_detects_platform_when_omitted(): + """Platform tuple defaults to detect_platform() output.""" + os_name, arch = MpakClient.detect_platform() + route = respx.get( + "https://registry.mpak.dev/v1/servers/%40nimblebraininc%2Fecho/versions/latest/download", + params={"os": os_name, "arch": arch}, + ).mock(return_value=Response(200, json=_DOWNLOAD_INFO)) + + client = MpakClient() + client.get_server_download("@nimblebraininc/echo") + + assert route.called + + +@respx.mock +def test_get_server_download_404_raises_not_found_with_name_at_version(): + respx.get( + "https://registry.mpak.dev/v1/servers/ai.nimblebrain%2Fecho/versions/99.0.0/download", + params={"os": "linux", "arch": "x64"}, + ).mock(return_value=Response(404, json={"error": "Not found"})) + + client = MpakClient() + with pytest.raises(MpakNotFoundError) as exc_info: + client.get_server_download( + "ai.nimblebrain/echo", "99.0.0", platform=("linux", "x64") + ) + + assert "ai.nimblebrain/echo@99.0.0" in str(exc_info.value) + + +@respx.mock +def test_get_server_download_500_raises_mpak_error(): + """Non-404 HTTP errors raise MpakError with status code, not MpakNotFoundError.""" + respx.get( + "https://registry.mpak.dev/v1/servers/%40nimblebraininc%2Fecho/versions/0.1.6/download", + params={"os": "linux", "arch": "x64"}, + ).mock(return_value=Response(500, text="Internal Server Error")) + + client = MpakClient() + with pytest.raises(MpakError) as exc_info: + client.get_server_download( + "@nimblebraininc/echo", "0.1.6", platform=("linux", "x64") + ) + + assert not isinstance(exc_info.value, MpakNotFoundError) + assert exc_info.value.status_code == 500 From dab651505ce90e7451c7867bdaf33f5d3ff4277b Mon Sep 17 00:00:00 2001 From: Mason <31372737+Ovaculos@users.noreply.github.com> Date: Thu, 14 May 2026 12:18:16 -0500 Subject: [PATCH 6/8] test(registry): cover browser-fallback paths on /servers download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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="-.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) --- apps/registry/tests/servers.test.ts | 44 +++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/apps/registry/tests/servers.test.ts b/apps/registry/tests/servers.test.ts index e9a923e..40d564e 100644 --- a/apps/registry/tests/servers.test.ts +++ b/apps/registry/tests/servers.test.ts @@ -534,5 +534,49 @@ describe('MCP Registry routes', () => { const body = JSON.parse(res.payload); expect(body.bundle.name).toBe('@nimblebraininc/echo'); }); + + it('streams local file with attachment headers when storage returns a local path', async () => { + packageRepo.findPackageForServerLookup.mockResolvedValueOnce(lookupRow()); + packageRepo.findVersionWithArtifacts.mockResolvedValueOnce(mockVersionWithArtifacts); + storage.getSignedDownloadUrlFromPath.mockResolvedValueOnce( + '/local/@test/mcp-server/1.0.0/linux-x64.mcpb', + ); + const bundleBytes = Buffer.from('fake mcpb bytes'); + storage.getBundle.mockResolvedValueOnce(bundleBytes); + + const res = await app.inject({ + method: 'GET', + url: `/servers/${NAME_ENCODED}/versions/1.0.0/download?os=linux&arch=x64`, + headers: { accept: '*/*' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('application/octet-stream'); + expect(res.headers['content-disposition']).toBe( + 'attachment; filename="mcp-server-1.0.0.mcpb"', + ); + expect(res.rawPayload.equals(bundleBytes)).toBe(true); + expect(storage.getBundle).toHaveBeenCalledWith( + '@test/mcp-server/1.0.0/linux-x64.mcpb', + ); + }); + + it('redirects to the signed CDN URL when storage returns an absolute URL', async () => { + packageRepo.findPackageForServerLookup.mockResolvedValueOnce(lookupRow()); + packageRepo.findVersionWithArtifacts.mockResolvedValueOnce(mockVersionWithArtifacts); + storage.getSignedDownloadUrlFromPath.mockResolvedValueOnce( + 'https://cdn.example.com/signed?token=xyz', + ); + + const res = await app.inject({ + method: 'GET', + url: `/servers/${NAME_ENCODED}/versions/1.0.0/download?os=linux&arch=x64`, + headers: { accept: '*/*' }, + }); + + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe('https://cdn.example.com/signed?token=xyz'); + expect(storage.getBundle).not.toHaveBeenCalled(); + }); }); }); From 3560e6b21998dddcf4576c922214891b0fa2937b Mon Sep 17 00:00:00 2001 From: Mason <31372737+Ovaculos@users.noreply.github.com> Date: Thu, 14 May 2026 12:18:25 -0500 Subject: [PATCH 7/8] refactor(registry): extract shared handleArtifactDownload service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/registry/src/routes/mcp/v0.1/servers.ts | 89 +++-------- apps/registry/src/routes/v1/bundles.ts | 85 ++--------- .../registry/src/services/download-handler.ts | 138 ++++++++++++++++++ packages/sdk-python/uv.lock | 2 +- 4 files changed, 168 insertions(+), 146 deletions(-) create mode 100644 apps/registry/src/services/download-handler.ts diff --git a/apps/registry/src/routes/mcp/v0.1/servers.ts b/apps/registry/src/routes/mcp/v0.1/servers.ts index d35ec21..862a323 100644 --- a/apps/registry/src/routes/mcp/v0.1/servers.ts +++ b/apps/registry/src/routes/mcp/v0.1/servers.ts @@ -21,12 +21,10 @@ import { type BundleDownloadParams, type ServerDetail, } from '@nimblebrain/mpak-schemas'; -import { config } from '../../../config.js'; -import { runInTransaction } from '../../../db/index.js'; import { NotFoundError } from '../../../errors/index.js'; import { toJsonSchema } from '../../../lib/zod-schema.js'; import type { PackageForServerLookup } from '../../../db/repositories/package.repository.js'; -import { resolveArtifact } from '../../../services/artifact-resolver.js'; +import { handleArtifactDownload } from '../../../services/download-handler.js'; import { composeServerDetail } from '../../../services/server-detail-composer.js'; const REGISTRY_VERSION = 'v1.0.0'; @@ -451,75 +449,22 @@ export const mcpRegistryRoutes: FastifyPluginAsync = async (fastify) => { throw new NotFoundError(`Server '${decodeURIComponent(rawName)}' not found`); } - // Resolve "latest" to the actual version (pkg.latestVersion). - const resolvedVersion = versionParam === 'latest' ? pkg.latestVersion : versionParam; - - const { packages: packageRepo } = fastify.repositories; - const packageVersion = await packageRepo.findVersionWithArtifacts(pkg.id, resolvedVersion); - if (!packageVersion) { - throw new NotFoundError(`Version '${resolvedVersion}' not found for server '${pkg.name}'`); - } - - // resolveArtifact throws BadRequestError when only one of os/arch - // is supplied — Fastify converts that into a 400. - const artifact = resolveArtifact(packageVersion.artifacts, queryOs, queryArch); - if (!artifact) { - throw new NotFoundError('No artifact found for the requested platform'); - } - - const platform = artifact.os === 'any' ? 'universal' : `${artifact.os}-${artifact.arch}`; - fastify.log.info({ - op: 'download', - pkg: pkg.name, - version: resolvedVersion, - platform, - surface: 'servers', - }, `download (servers): ${pkg.name}@${resolvedVersion} (${platform})`); - - // Best-effort download count bumps — failures get logged but never - // block the response (mirrors the legacy /v1/bundles handler). - void runInTransaction(async (tx) => { - await packageRepo.incrementArtifactDownloads(artifact.id, tx); - await packageRepo.incrementVersionDownloads(pkg.id, resolvedVersion, tx); - await packageRepo.incrementDownloads(pkg.id, tx); - }).catch((err: unknown) => - fastify.log.error({ err }, 'Failed to update download counts'), - ); - - const acceptHeader = request.headers.accept ?? ''; - const wantsJson = acceptHeader.includes('application/json'); - - const downloadUrl = await fastify.storage.getSignedDownloadUrlFromPath(artifact.storagePath); - - if (wantsJson) { - const expiresAt = new Date(); - expiresAt.setSeconds( - expiresAt.getSeconds() + (config.storage.cloudfront.urlExpirationSeconds || 900), - ); - return { - url: downloadUrl, - bundle: { - name: pkg.name, - version: resolvedVersion, - platform: { os: artifact.os, arch: artifact.arch }, - sha256: artifact.digest.replace('sha256:', ''), - size: Number(artifact.sizeBytes), - }, - expires_at: expiresAt.toISOString(), - }; - } - - // Browser-style download: stream local files directly; redirect to - // signed CDN URLs in S3/CloudFront mode. - if (downloadUrl.startsWith('/')) { - const fileBuffer = await fastify.storage.getBundle(artifact.storagePath); - const npmPart = pkg.name.startsWith('@') ? pkg.name.split('/')[1] ?? pkg.name : pkg.name; - return reply - .header('Content-Type', 'application/octet-stream') - .header('Content-Disposition', `attachment; filename="${npmPart}-${resolvedVersion}.mcpb"`) - .send(fileBuffer); - } - return reply.code(302).redirect(downloadUrl); + const filenameBase = pkg.name.startsWith('@') + ? pkg.name.split('/')[1] ?? pkg.name + : pkg.name; + + return handleArtifactDownload({ + fastify, + request, + reply, + pkg, + versionParam, + queryOs, + queryArch, + filenameBase, + logSurface: 'servers', + versionNotFoundMessage: `Version '${versionParam === 'latest' ? pkg.latestVersion : versionParam}' not found for server '${pkg.name}'`, + }); }); // GET /health - Registry-specific health probe (counts servers). diff --git a/apps/registry/src/routes/v1/bundles.ts b/apps/registry/src/routes/v1/bundles.ts index 91e0faf..a4a5d41 100644 --- a/apps/registry/src/routes/v1/bundles.ts +++ b/apps/registry/src/routes/v1/bundles.ts @@ -37,7 +37,7 @@ import { import { generateBadge } from '../../utils/badge.js'; import { notifyDiscordAnnounce } from '../../utils/discord.js'; import { triggerSecurityScan } from '../../services/scanner.js'; -import { resolveArtifact } from '../../services/artifact-resolver.js'; +import { handleArtifactDownload } from '../../services/download-handler.js'; // GitHub release asset type interface GitHubReleaseAsset { @@ -571,78 +571,17 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { throw new NotFoundError('Bundle not found'); } - // Resolve "latest" to actual version - const version = versionParam === 'latest' ? pkg.latestVersion : versionParam; - - const packageVersion = await packageRepo.findVersionWithArtifacts(pkg.id, version); - - if (!packageVersion) { - throw new NotFoundError('Version not found'); - } - - // Find the appropriate artifact - const artifact = resolveArtifact(packageVersion.artifacts, queryOs, queryArch); - - if (!artifact) { - throw new NotFoundError('No artifact found for the requested platform'); - } - - // Log download - const platform = artifact.os === 'any' ? 'universal' : `${artifact.os}-${artifact.arch}`; - fastify.log.info({ - op: 'download', - pkg: name, - version, - platform, - }, `download: ${name}@${version} (${platform})`); - - // Increment download counts atomically in a single transaction - void runInTransaction(async (tx) => { - await packageRepo.incrementArtifactDownloads(artifact.id, tx); - await packageRepo.incrementVersionDownloads(pkg.id, version, tx); - await packageRepo.incrementDownloads(pkg.id, tx); - }).catch((err: unknown) => - fastify.log.error({ err }, 'Failed to update download counts') - ); - - // Check if client wants JSON response (CLI/API) or redirect (browser) - const acceptHeader = request.headers.accept ?? ''; - const wantsJson = acceptHeader.includes('application/json'); - - // Generate signed download URL using the actual storage path - const downloadUrl = await fastify.storage.getSignedDownloadUrlFromPath(artifact.storagePath); - - if (wantsJson) { - // CLI/API mode: Return JSON with download URL and metadata - const expiresAt = new Date(); - expiresAt.setSeconds(expiresAt.getSeconds() + (config.storage.cloudfront.urlExpirationSeconds || 900)); - - return { - url: downloadUrl, - bundle: { - name, - version, - platform: { os: artifact.os, arch: artifact.arch }, - sha256: artifact.digest.replace('sha256:', ''), - size: Number(artifact.sizeBytes), - }, - expires_at: expiresAt.toISOString(), - }; - } else { - // Browser mode: Redirect to download URL - if (downloadUrl.startsWith('/')) { - // Local storage - serve file directly - const fileBuffer = await fastify.storage.getBundle(artifact.storagePath); - - return reply - .header('Content-Type', 'application/octet-stream') - .header('Content-Disposition', `attachment; filename="${packageName}-${version}.mcpb"`) - .send(fileBuffer); - } else { - // S3/CloudFront - redirect to signed URL - return reply.code(302).redirect(downloadUrl); - } - } + return handleArtifactDownload({ + fastify, + request, + reply, + pkg, + versionParam, + queryOs, + queryArch, + filenameBase: packageName, + versionNotFoundMessage: 'Version not found', + }); }, }); diff --git a/apps/registry/src/services/download-handler.ts b/apps/registry/src/services/download-handler.ts new file mode 100644 index 0000000..0d07ced --- /dev/null +++ b/apps/registry/src/services/download-handler.ts @@ -0,0 +1,138 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { config } from '../config.js'; +import { runInTransaction } from '../db/index.js'; +import { NotFoundError } from '../errors/index.js'; +import { resolveArtifact } from './artifact-resolver.js'; + +/** + * JSON payload returned to callers requesting `Accept: application/json`. + * Matches the `DownloadInfoSchema` shape in `@nimblebrain/mpak-schemas`. + */ +export interface DownloadInfoPayload { + url: string; + bundle: { + name: string; + version: string; + platform: { os: string; arch: string }; + sha256: string; + size: number; + }; + expires_at: string; +} + +interface HandleArtifactDownloadOptions { + fastify: FastifyInstance; + request: FastifyRequest; + reply: FastifyReply; + /** Already-resolved package (lookup strategy varies per caller). */ + pkg: { id: string; name: string; latestVersion: string }; + /** Raw version path param; `"latest"` is resolved to `pkg.latestVersion`. */ + versionParam: string; + queryOs?: string; + queryArch?: string; + /** + * Used to build the `Content-Disposition` filename in the + * local-storage stream branch (e.g. `echo` → `echo-1.0.0.mcpb`). + */ + filenameBase: string; + /** + * Optional tag included on the download log entry so dashboards can + * split traffic between the legacy `/v1/bundles` route and the new + * `/servers/.../download` route. + */ + logSurface?: string; + /** Overrides the default "Version not found" 404 message. */ + versionNotFoundMessage?: string; +} + +/** + * Shared handler for the download endpoint body — resolves the + * artifact for the requested platform, increments download counters, + * and responds with either a JSON envelope (CLI/API) or a + * stream/302 (browser) depending on the `Accept` header. + * + * The package lookup itself is caller-specific (npm-style direct + * lookup on `/v1/bundles`, reverse-DNS-aware lookup on `/servers`), + * so this function takes an already-resolved `pkg`. + * + * Returns the JSON payload for the API branch; in the browser branch + * the response is written to `reply` and the returned `FastifyReply` + * must be returned from the route handler so Fastify finalises it. + */ +export async function handleArtifactDownload( + opts: HandleArtifactDownloadOptions, +): Promise { + const { + fastify, request, reply, pkg, versionParam, + queryOs, queryArch, filenameBase, logSurface, + versionNotFoundMessage, + } = opts; + const { packages: packageRepo } = fastify.repositories; + + const version = versionParam === 'latest' ? pkg.latestVersion : versionParam; + + const packageVersion = await packageRepo.findVersionWithArtifacts(pkg.id, version); + if (!packageVersion) { + throw new NotFoundError(versionNotFoundMessage ?? `Version '${version}' not found`); + } + + // resolveArtifact throws BadRequestError when only one of os/arch + // is supplied — Fastify converts that into a 400. + const artifact = resolveArtifact(packageVersion.artifacts, queryOs, queryArch); + if (!artifact) { + throw new NotFoundError('No artifact found for the requested platform'); + } + + const platform = artifact.os === 'any' ? 'universal' : `${artifact.os}-${artifact.arch}`; + fastify.log.info({ + op: 'download', + pkg: pkg.name, + version, + platform, + ...(logSurface ? { surface: logSurface } : {}), + }, `download${logSurface ? ` (${logSurface})` : ''}: ${pkg.name}@${version} (${platform})`); + + // Best-effort download count bumps — failures get logged but never + // block the response. + void runInTransaction(async (tx) => { + await packageRepo.incrementArtifactDownloads(artifact.id, tx); + await packageRepo.incrementVersionDownloads(pkg.id, version, tx); + await packageRepo.incrementDownloads(pkg.id, tx); + }).catch((err: unknown) => + fastify.log.error({ err }, 'Failed to update download counts'), + ); + + const downloadUrl = await fastify.storage.getSignedDownloadUrlFromPath(artifact.storagePath); + + const acceptHeader = request.headers.accept ?? ''; + const wantsJson = acceptHeader.includes('application/json'); + + if (wantsJson) { + const expiresAt = new Date(); + expiresAt.setSeconds( + expiresAt.getSeconds() + (config.storage.cloudfront.urlExpirationSeconds || 900), + ); + return { + url: downloadUrl, + bundle: { + name: pkg.name, + version, + platform: { os: artifact.os, arch: artifact.arch }, + sha256: artifact.digest.replace('sha256:', ''), + size: Number(artifact.sizeBytes), + }, + expires_at: expiresAt.toISOString(), + }; + } + + // Browser-style download: stream local files directly; redirect to + // signed CDN URLs in S3/CloudFront mode. + if (downloadUrl.startsWith('/')) { + const fileBuffer = await fastify.storage.getBundle(artifact.storagePath); + return reply + .header('Content-Type', 'application/octet-stream') + .header('Content-Disposition', `attachment; filename="${filenameBase}-${version}.mcpb"`) + .send(fileBuffer); + } + return reply.code(302).redirect(downloadUrl); +} diff --git a/packages/sdk-python/uv.lock b/packages/sdk-python/uv.lock index 6f3bf9d..f799405 100644 --- a/packages/sdk-python/uv.lock +++ b/packages/sdk-python/uv.lock @@ -304,7 +304,7 @@ wheels = [ [[package]] name = "mpak" -version = "0.1.1" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "httpx" }, From 816f481222f63d5712c1f3f863076688923cf9c6 Mon Sep 17 00:00:00 2001 From: Mason <31372737+Ovaculos@users.noreply.github.com> Date: Thu, 14 May 2026 12:23:05 -0500 Subject: [PATCH 8/8] style(sdk): apply ruff + prettier to new server-download tests 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) --- packages/sdk-python/tests/test_client.py | 16 ++++------------ packages/sdk-typescript/tests/client.test.ts | 18 +++++++++--------- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/packages/sdk-python/tests/test_client.py b/packages/sdk-python/tests/test_client.py index fd05da8..4457074 100644 --- a/packages/sdk-python/tests/test_client.py +++ b/packages/sdk-python/tests/test_client.py @@ -328,9 +328,7 @@ def test_get_server_download_returns_download_info(): ).mock(return_value=Response(200, json=_DOWNLOAD_INFO)) client = MpakClient() - download = client.get_server_download( - "@nimblebraininc/echo", "0.1.6", platform=("linux", "x64") - ) + download = client.get_server_download("@nimblebraininc/echo", "0.1.6", platform=("linux", "x64")) assert route.called assert download["url"] == "https://cdn.example.com/bundle.mcpb" @@ -348,9 +346,7 @@ def test_get_server_download_accepts_reverse_dns_name(): ).mock(return_value=Response(200, json=_DOWNLOAD_INFO)) client = MpakClient() - download = client.get_server_download( - "ai.nimblebrain/echo", "0.1.6", platform=("linux", "x64") - ) + download = client.get_server_download("ai.nimblebrain/echo", "0.1.6", platform=("linux", "x64")) assert route.called assert download["bundle"]["name"] == "@nimblebraininc/echo" @@ -380,9 +376,7 @@ def test_get_server_download_404_raises_not_found_with_name_at_version(): client = MpakClient() with pytest.raises(MpakNotFoundError) as exc_info: - client.get_server_download( - "ai.nimblebrain/echo", "99.0.0", platform=("linux", "x64") - ) + client.get_server_download("ai.nimblebrain/echo", "99.0.0", platform=("linux", "x64")) assert "ai.nimblebrain/echo@99.0.0" in str(exc_info.value) @@ -397,9 +391,7 @@ def test_get_server_download_500_raises_mpak_error(): client = MpakClient() with pytest.raises(MpakError) as exc_info: - client.get_server_download( - "@nimblebraininc/echo", "0.1.6", platform=("linux", "x64") - ) + client.get_server_download("@nimblebraininc/echo", "0.1.6", platform=("linux", "x64")) assert not isinstance(exc_info.value, MpakNotFoundError) assert exc_info.value.status_code == 500 diff --git a/packages/sdk-typescript/tests/client.test.ts b/packages/sdk-typescript/tests/client.test.ts index 422c853..9217a1b 100644 --- a/packages/sdk-typescript/tests/client.test.ts +++ b/packages/sdk-typescript/tests/client.test.ts @@ -777,17 +777,17 @@ describe('MpakClient', () => { const client = new MpakClient(); fetchMock.mockResolvedValueOnce(mockResponse('not found', { status: 404 })); - await expect( - client.getServerDownload('ai.nimblebrain/echo', '99.0.0'), - ).rejects.toThrow(/ai.nimblebrain\/echo@99\.0\.0/); + await expect(client.getServerDownload('ai.nimblebrain/echo', '99.0.0')).rejects.toThrow( + /ai.nimblebrain\/echo@99\.0\.0/, + ); }); it('throws MpakNetworkError on 5xx', async () => { const client = new MpakClient(); fetchMock.mockResolvedValueOnce(mockResponse('boom', { status: 503 })); - await expect( - client.getServerDownload('ai.nimblebrain/echo', '0.1.6'), - ).rejects.toThrow(MpakNetworkError); + await expect(client.getServerDownload('ai.nimblebrain/echo', '0.1.6')).rejects.toThrow( + MpakNetworkError, + ); }); }); @@ -843,9 +843,9 @@ describe('MpakClient', () => { .mockResolvedValueOnce(mockResponse(downloadInfo)) .mockResolvedValueOnce(mockBinaryResponse(tampered)); - await expect( - client.downloadServerBundle('@nimblebraininc/echo', '0.1.6'), - ).rejects.toThrow(MpakIntegrityError); + await expect(client.downloadServerBundle('@nimblebraininc/echo', '0.1.6')).rejects.toThrow( + MpakIntegrityError, + ); }); }); });