Skip to content
65 changes: 64 additions & 1 deletion apps/registry/src/routes/mcp/v0.1/servers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down
109 changes: 12 additions & 97 deletions apps/registry/src/routes/v1/bundles.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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('/');
Expand Down Expand Up @@ -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',
});
},
});

Expand Down
30 changes: 30 additions & 0 deletions apps/registry/src/services/artifact-resolver.ts
Original file line number Diff line number Diff line change
@@ -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;
}
138 changes: 138 additions & 0 deletions apps/registry/src/services/download-handler.ts
Original file line number Diff line number Diff line change
@@ -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<DownloadInfoPayload | FastifyReply> {
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);
}
Loading
Loading