diff --git a/apps/registry/src/routes/mcp/v0.1/servers.ts b/apps/registry/src/routes/mcp/v0.1/servers.ts index fc046fa..862a323 100644 --- a/apps/registry/src/routes/mcp/v0.1/servers.ts +++ b/apps/registry/src/routes/mcp/v0.1/servers.ts @@ -14,8 +14,17 @@ */ 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 { NotFoundError } from '../../../errors/index.js'; +import { toJsonSchema } from '../../../lib/zod-schema.js'; import type { PackageForServerLookup } from '../../../db/repositories/package.repository.js'; +import { handleArtifactDownload } from '../../../services/download-handler.js'; import { composeServerDetail } from '../../../services/server-detail-composer.js'; const REGISTRY_VERSION = 'v1.0.0'; @@ -404,6 +413,60 @@ 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`); + } + + 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). // 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/src/routes/v1/bundles.ts b/apps/registry/src/routes/v1/bundles.ts index 483762f..a4a5d41 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 { handleArtifactDownload } from '../../services/download-handler.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('/'); @@ -595,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/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; +} 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/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); + }); }); diff --git a/apps/registry/tests/servers.test.ts b/apps/registry/tests/servers.test.ts index 7acf882..40d564e 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,211 @@ 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'); + }); + + 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(); + }); + }); }); 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..4457074 100644 --- a/packages/sdk-python/tests/test_client.py +++ b/packages/sdk-python/tests/test_client.py @@ -299,3 +299,99 @@ 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 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" }, 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..9217a1b 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, + ); + }); + }); });