From 4b9f5aaea4d5fca0700c7495d7a8ccfec56f3945 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Fri, 13 Mar 2026 18:11:39 -0400 Subject: [PATCH 1/6] Add package (bundle) seed data for echo and nationalparks Add seed data for two MCPB packages alongside existing skill seeds: - @nimblebraininc/echo: 9 versions (including prereleases), python server - @nimblebraininc/nationalparks: 6 versions, node server with user config Includes manifest helpers, version upserts, and download counts. Co-Authored-By: Claude Opus 4.6 --- apps/registry/prisma/seed.ts | 350 ++++++++++++++++++++++++++++++++++- 1 file changed, 341 insertions(+), 9 deletions(-) diff --git a/apps/registry/prisma/seed.ts b/apps/registry/prisma/seed.ts index fe69af5..24d093b 100644 --- a/apps/registry/prisma/seed.ts +++ b/apps/registry/prisma/seed.ts @@ -122,7 +122,10 @@ const SKILLS: SeedSkill[] = [ surfaces: ['claude-code'], author: { name: 'NimbleBrain', url: 'https://nimblebrain.ai' }, examples: [ - { prompt: 'Audit the docs in the docs/ folder against the current codebase', context: 'After a major refactor' }, + { + prompt: 'Audit the docs in the docs/ folder against the current codebase', + context: 'After a major refactor', + }, { prompt: 'Find all stale documentation that references removed APIs' }, ], }, @@ -182,8 +185,7 @@ The audit produces a structured report with: downloads: 45, frontmatter: { name: 'seo-optimizer', - description: - 'Analyzes and optimizes content for search engine visibility.', + description: 'Analyzes and optimizes content for search engine visibility.', metadata: { version: '1.0.0', category: 'writing', @@ -207,8 +209,7 @@ The audit produces a structured report with: downloads: 213, frontmatter: { name: 'seo-optimizer', - description: - 'Analyzes and optimizes content for search engine visibility.', + description: 'Analyzes and optimizes content for search engine visibility.', metadata: { version: '1.0.7', category: 'writing', @@ -225,7 +226,10 @@ The audit produces a structured report with: surfaces: ['claude-code', 'claude-ai'], author: { name: 'NimbleBrain', url: 'https://nimblebrain.ai' }, examples: [ - { prompt: 'Optimize this blog post for SEO', context: 'Before publishing a new article' }, + { + prompt: 'Optimize this blog post for SEO', + context: 'Before publishing a new article', + }, { prompt: 'Check the meta descriptions on our landing pages' }, ], }, @@ -328,7 +332,10 @@ The optimizer follows current SEO best practices: surfaces: ['claude-code', 'claude-ai'], author: { name: 'NimbleBrain', url: 'https://nimblebrain.ai' }, examples: [ - { prompt: 'Help me think through whether to pivot from B2C to B2B', context: 'Early-stage startup with declining consumer metrics' }, + { + prompt: 'Help me think through whether to pivot from B2C to B2B', + context: 'Early-stage startup with declining consumer metrics', + }, { prompt: 'Evaluate the tradeoffs of building vs buying our auth system' }, ], }, @@ -366,6 +373,269 @@ The skill follows a structured thinking process: }, ]; +// --------------------------------------------------------------------------- +// Seed data: Packages (bundles) +// --------------------------------------------------------------------------- + +interface SeedPackageVersion { + version: string; + prerelease?: boolean; + downloads: number; + manifest: object; + publishedAt: string; + publishMethod: string; + provenanceRepository: string; + provenanceSha: string; + releaseTag?: string; + releaseUrl?: string; +} + +interface SeedPackage { + name: string; + description: string; + authorName: string; + serverType: string; + license?: string; + githubRepo?: string; + versions: SeedPackageVersion[]; +} + +const echoManifest = (version: string) => ({ + name: '@nimblebraininc/echo', + version, + description: 'Echo server for testing and debugging MCP connections', + manifest_version: '0.3', + author: { name: 'NimbleBrain Inc' }, + server: { + type: 'python', + mcp_config: { command: 'python', args: ['-m', 'mcp_echo.server'] }, + entry_point: 'mcp_echo.server', + }, +}); + +const nationalparksManifest = (version: string) => ({ + name: '@nimblebraininc/nationalparks', + version, + description: 'MCP server for National Parks Service API', + manifest_version: '0.3', + author: { name: 'NimbleBrain Inc' }, + server: { + type: 'node', + mcp_config: { + command: 'node', + args: ['${__dirname}/build/index.js'], + env: { NPS_API_KEY: '${user_config.api_key}' }, + }, + entry_point: 'build/index.js', + }, + user_config: { + api_key: { + type: 'string', + title: 'NPS API Key', + required: true, + sensitive: true, + description: 'Your NPS API key from https://www.nps.gov/subjects/developer/get-started.htm', + }, + }, +}); + +const PACKAGES: SeedPackage[] = [ + { + name: '@nimblebraininc/echo', + description: 'Echo server for testing and debugging MCP connections', + authorName: 'NimbleBrain Inc', + serverType: 'python', + license: 'Apache-2.0', + githubRepo: 'NimbleBrainInc/mcp-echo', + versions: [ + { + version: '0.1.0', + downloads: 103, + publishedAt: '2025-12-31T19:46:28.468Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-echo', + provenanceSha: 'e3406ec72697feaba4da26f18f356ac9aae8a31f', + releaseTag: 'v0.1.0', + releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.0', + manifest: echoManifest('0.1.0'), + }, + { + version: '0.1.1-beta.1', + prerelease: true, + downloads: 208, + publishedAt: '2026-01-02T22:00:46.118Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-echo', + provenanceSha: '1b0be7da2ff7f6f88e738e8897b8b2e602816935', + releaseTag: 'v0.1.1-beta.1', + releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.1-beta.1', + manifest: echoManifest('0.1.1-beta.1'), + }, + { + version: '0.1.1', + downloads: 336, + publishedAt: '2026-01-02T22:27:05.591Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-echo', + provenanceSha: 'df13c722759cd066aa97ce6a9921cab52dbf5c58', + releaseTag: 'v0.1.1', + releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.1', + manifest: echoManifest('0.1.1'), + }, + { + version: '0.1.2', + downloads: 124, + publishedAt: '2026-01-04T19:22:00.731Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-echo', + provenanceSha: '01a67c0c69847783c53fb428b898ebd64d439a4a', + releaseTag: 'v0.1.2', + releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.2', + manifest: echoManifest('0.1.2'), + }, + { + version: '0.1.3', + downloads: 226, + publishedAt: '2026-01-04T19:48:38.421Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-echo', + provenanceSha: 'ea9ea341fdd7085e5ced55c8748010efa07ef492', + releaseTag: 'v0.1.3', + releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.3', + manifest: echoManifest('0.1.3'), + }, + { + version: '0.1.4-rc.1', + prerelease: true, + downloads: 0, + publishedAt: '2026-02-09T18:47:48.584Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-echo', + provenanceSha: 'facc63fda3553268eee4da38ceb7758dc7d47607', + releaseTag: 'v0.1.4-rc.1', + releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.4-rc.1', + manifest: echoManifest('0.1.4-rc.1'), + }, + { + version: '0.1.4-rc.4', + prerelease: true, + downloads: 0, + publishedAt: '2026-02-09T19:21:38.476Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-echo', + provenanceSha: 'facc63fda3553268eee4da38ceb7758dc7d47607', + releaseTag: 'v0.1.4-rc.4', + releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.4-rc.4', + manifest: echoManifest('0.1.4-rc.4'), + }, + { + version: '0.1.4', + downloads: 2, + publishedAt: '2026-02-11T03:40:32.500Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-echo', + provenanceSha: '638181a3357e89fcf8f77234667459df97d61d89', + releaseTag: 'v0.1.4', + releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.4', + manifest: echoManifest('0.1.4'), + }, + { + version: '0.1.5', + downloads: 101, + publishedAt: '2026-02-11T08:11:50.559Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-echo', + provenanceSha: '640aa8ef2dd3843f834292015b3562349ebcbf00', + releaseTag: 'v0.1.5', + releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.5', + manifest: echoManifest('0.1.5'), + }, + ], + }, + { + name: '@nimblebraininc/nationalparks', + description: 'MCP server for National Parks Service API', + authorName: 'NimbleBrain Inc', + serverType: 'node', + license: 'Apache-2.0', + githubRepo: 'NimbleBrainInc/mcp-server-nationalparks', + versions: [ + { + version: '0.1.1', + downloads: 255, + publishedAt: '2026-01-05T05:52:56.802Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-server-nationalparks', + provenanceSha: '528a517c72167f6e2903a40a67b233a3c2bb641a', + releaseTag: 'v0.1.1', + releaseUrl: + 'https://github.com/NimbleBrainInc/mcp-server-nationalparks/releases/tag/v0.1.1', + manifest: nationalparksManifest('0.1.1'), + }, + { + version: '0.1.2', + downloads: 56, + publishedAt: '2026-01-05T06:12:53.512Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-server-nationalparks', + provenanceSha: 'acd8c36aa4bea46ce69c8b6f3225a23ce8b83e19', + releaseTag: 'v0.1.2', + releaseUrl: + 'https://github.com/NimbleBrainInc/mcp-server-nationalparks/releases/tag/v0.1.2', + manifest: nationalparksManifest('0.1.2'), + }, + { + version: '0.1.3', + downloads: 67, + publishedAt: '2026-01-05T07:01:29.791Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-server-nationalparks', + provenanceSha: 'd4d7e54b40a4f96aa79778239da71b5c635f4377', + releaseTag: 'v0.1.3', + releaseUrl: + 'https://github.com/NimbleBrainInc/mcp-server-nationalparks/releases/tag/v0.1.3', + manifest: nationalparksManifest('0.1.3'), + }, + { + version: '0.1.4', + downloads: 171, + publishedAt: '2026-01-05T07:04:34.396Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-server-nationalparks', + provenanceSha: '4897a961ace1e34760fd5aff15496ff520ca7ce7', + releaseTag: 'v0.1.4', + releaseUrl: + 'https://github.com/NimbleBrainInc/mcp-server-nationalparks/releases/tag/v0.1.4', + manifest: nationalparksManifest('0.1.4'), + }, + { + version: '0.1.5', + downloads: 174, + publishedAt: '2026-01-05T07:08:30.892Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-server-nationalparks', + provenanceSha: '5e6f1f3f5512b837a14f564d6c182e6a370d7a66', + releaseTag: 'v0.1.5', + releaseUrl: + 'https://github.com/NimbleBrainInc/mcp-server-nationalparks/releases/tag/v0.1.5', + manifest: nationalparksManifest('0.1.5'), + }, + { + version: '0.2.0', + downloads: 2, + publishedAt: '2026-02-12T23:33:07.687Z', + publishMethod: 'oidc', + provenanceRepository: 'NimbleBrainInc/mcp-server-nationalparks', + provenanceSha: 'b4566b1298b2617aedd1a2cc7d23b1576fe96e5d', + releaseTag: 'v0.2.0', + releaseUrl: + 'https://github.com/NimbleBrainInc/mcp-server-nationalparks/releases/tag/v0.2.0', + manifest: nationalparksManifest('0.2.0'), + }, + ], + }, +]; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -451,7 +721,10 @@ async function seed() { downloadCount: BigInt(v.downloads), publishMethod: 'oidc', provenanceRepository: s.githubRepo, - provenanceSha: createHash('sha256').update(`${s.name}@${v.version}-commit`).digest('hex').slice(0, 40), + provenanceSha: createHash('sha256') + .update(`${s.name}@${v.version}-commit`) + .digest('hex') + .slice(0, 40), releaseTag: `${skillName}/v${v.version}`, releaseUrl: `https://github.com/${s.githubRepo}/releases/tag/${skillName}%2Fv${v.version}`, }, @@ -466,7 +739,66 @@ async function seed() { } } - console.log(`\nSeeded ${SKILLS.length} skills successfully.`); + console.log(`\nSeeded ${SKILLS.length} skills successfully.\n`); + + // Seed packages (bundles) + for (const p of PACKAGES) { + const totalDownloads = p.versions.reduce((sum, v) => sum + v.downloads, 0); + const latestVersion = p.versions[p.versions.length - 1]!.version; + + const pkg = await prisma.package.upsert({ + where: { name: p.name }, + create: { + name: p.name, + description: p.description, + authorName: p.authorName, + serverType: p.serverType, + license: p.license ?? null, + githubRepo: p.githubRepo ?? null, + latestVersion, + totalDownloads: BigInt(totalDownloads), + }, + update: { + description: p.description, + authorName: p.authorName, + serverType: p.serverType, + license: p.license ?? null, + latestVersion, + totalDownloads: BigInt(totalDownloads), + }, + }); + + console.log(` Package: ${p.name} (${pkg.id})`); + + for (const v of p.versions) { + await prisma.packageVersion.upsert({ + where: { + packageId_version: { packageId: pkg.id, version: v.version }, + }, + create: { + packageId: pkg.id, + version: v.version, + manifest: v.manifest, + prerelease: v.prerelease ?? false, + downloadCount: BigInt(v.downloads), + publishMethod: v.publishMethod, + provenanceRepository: v.provenanceRepository, + provenanceSha: v.provenanceSha, + releaseTag: v.releaseTag ?? null, + releaseUrl: v.releaseUrl ?? null, + publishedAt: new Date(v.publishedAt), + }, + update: { + manifest: v.manifest, + downloadCount: BigInt(v.downloads), + }, + }); + + console.log(` v${v.version} (${v.downloads} downloads)`); + } + } + + console.log(`\nSeeded ${PACKAGES.length} packages successfully.`); } // --------------------------------------------------------------------------- From e0d00161b418a120b86d30dfbf3a1a694b00063e Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Fri, 13 Mar 2026 18:32:13 -0400 Subject: [PATCH 2/6] Replace registry generated schemas with @nimblebrain/mpak-schemas The registry had duplicated schema files in src/schemas/generated/ that mirrored the shared package. This caused maintenance burden and version drift. Import directly from @nimblebrain/mpak-schemas instead and delete the generated copies. Co-Authored-By: Claude Opus 4.6 --- apps/registry/package.json | 1 + apps/registry/src/routes/auth.ts | 2 +- apps/registry/src/routes/packages.ts | 4 +- apps/registry/src/routes/v1/bundles.ts | 2 +- apps/registry/src/routes/v1/skills.ts | 2 +- .../src/schemas/generated/api-responses.ts | 375 ------------------ apps/registry/src/schemas/generated/auth.ts | 22 - .../registry/src/schemas/generated/package.ts | 29 -- apps/registry/src/schemas/generated/skill.ts | 258 ------------ apps/registry/src/types.ts | 8 +- pnpm-lock.yaml | 3 + 11 files changed, 13 insertions(+), 693 deletions(-) delete mode 100644 apps/registry/src/schemas/generated/api-responses.ts delete mode 100644 apps/registry/src/schemas/generated/auth.ts delete mode 100644 apps/registry/src/schemas/generated/package.ts delete mode 100644 apps/registry/src/schemas/generated/skill.ts diff --git a/apps/registry/package.json b/apps/registry/package.json index fb36acf..43b57ca 100644 --- a/apps/registry/package.json +++ b/apps/registry/package.json @@ -46,6 +46,7 @@ "jose": "^6.1.3", "pg": "^8.13.1", "prisma": "^7.2.0", + "@nimblebrain/mpak-schemas": "workspace:*", "semver": "^7.6.3", "zod": "^4.3.4" }, diff --git a/apps/registry/src/routes/auth.ts b/apps/registry/src/routes/auth.ts index 8242f13..15306d9 100644 --- a/apps/registry/src/routes/auth.ts +++ b/apps/registry/src/routes/auth.ts @@ -1,6 +1,6 @@ import type { FastifyPluginAsync } from 'fastify'; import { toJsonSchema } from '../lib/zod-schema.js'; -import { UserProfileSchema, type UserProfile } from '../schemas/generated/auth.js'; +import { UserProfileSchema, type UserProfile } from '@nimblebrain/mpak-schemas'; export const authRoutes: FastifyPluginAsync = async (fastify) => { // GET /app/auth/me - Get current authenticated user diff --git a/apps/registry/src/routes/packages.ts b/apps/registry/src/routes/packages.ts index 0d01b90..12c2a32 100644 --- a/apps/registry/src/routes/packages.ts +++ b/apps/registry/src/routes/packages.ts @@ -11,7 +11,7 @@ import { handleError, } from '../errors/index.js'; import { toJsonSchema } from '../lib/zod-schema.js'; -import type { PackageSearchParams } from '../schemas/generated/package.js'; +import type { PackageSearchParams } from '@nimblebrain/mpak-schemas'; import { PublishResponseSchema, PackageSearchResponseSchema, @@ -21,7 +21,7 @@ import { ClaimResponseSchema, MyPackagesResponseSchema, UnclaimedPackagesResponseSchema, -} from '../schemas/generated/api-responses.js'; +} from '@nimblebrain/mpak-schemas'; import { generateMpakJsonExample } from '../schemas/mpak-schema.js'; import { extractScannerVersion } from '../utils/scanner-version.js'; import { fetchGitHubRepoStats, parseGitHubRepo, verifyPackageClaim } from '../services/github-verifier.js'; diff --git a/apps/registry/src/routes/v1/bundles.ts b/apps/registry/src/routes/v1/bundles.ts index 30aa5b3..4649e54 100644 --- a/apps/registry/src/routes/v1/bundles.ts +++ b/apps/registry/src/routes/v1/bundles.ts @@ -22,7 +22,7 @@ import { MCPBIndexSchema, AnnounceRequestSchema, AnnounceResponseSchema, -} from '../../schemas/generated/api-responses.js'; +} from '@nimblebrain/mpak-schemas'; import { generateBadge } from '../../utils/badge.js'; import { notifyDiscordAnnounce } from '../../utils/discord.js'; import { triggerSecurityScan } from '../../services/scanner.js'; diff --git a/apps/registry/src/routes/v1/skills.ts b/apps/registry/src/routes/v1/skills.ts index d6aec1e..1e2f27b 100644 --- a/apps/registry/src/routes/v1/skills.ts +++ b/apps/registry/src/routes/v1/skills.ts @@ -19,7 +19,7 @@ import { SkillDownloadInfoSchema, SkillAnnounceRequestSchema, SkillAnnounceResponseSchema, -} from '../../schemas/generated/skill.js'; +} from '@nimblebrain/mpak-schemas'; import { generateBadge } from '../../utils/badge.js'; import { notifyDiscordAnnounce } from '../../utils/discord.js'; import { extractSkillContent } from '../../utils/skill-content.js'; diff --git a/apps/registry/src/schemas/generated/api-responses.ts b/apps/registry/src/schemas/generated/api-responses.ts deleted file mode 100644 index 748c3e0..0000000 --- a/apps/registry/src/schemas/generated/api-responses.ts +++ /dev/null @@ -1,375 +0,0 @@ -import { z } from 'zod'; - -export const PackageAuthorSchema = z.object({ - name: z.string(), -}); - -export const PackageToolSchema = z.object({ - name: z.string(), - description: z.string().optional(), -}); - -export const PackageGitHubSchema = z.object({ - repo: z.string(), - stars: z.number().nullable(), - forks: z.number().nullable(), - watchers: z.number().nullable(), - updated_at: z.string().nullable().optional(), -}); - -export const PackageSchema = z.object({ - name: z.string(), - display_name: z.string().nullable(), - description: z.string().nullable(), - author: PackageAuthorSchema.nullable(), - latest_version: z.string(), - icon: z.string().nullable(), - server_type: z.string(), - tools: z.array(PackageToolSchema), - downloads: z.number(), - published_at: z.union([z.string(), z.date()]), - verified: z.boolean(), - claimable: z.boolean().optional(), - claimed: z.boolean().optional(), - github: PackageGitHubSchema.nullable().optional(), - certification_level: z.number().nullable().optional(), -}); - -export const ArtifactSchema = z.object({ - os: z.string(), - arch: z.string(), - size_bytes: z.number(), - digest: z.string(), - downloads: z.number(), -}); - -export const ProvenanceSchema = z.object({ - publish_method: z.string().nullable(), - repository: z.string().nullable(), - sha: z.string().nullable(), -}); - -export const CertificationSchema = z.object({ - level: z.number().nullable(), - level_name: z.string().nullable(), - controls_passed: z.number().nullable(), - controls_failed: z.number().nullable(), - controls_total: z.number().nullable(), -}); - -export const SecurityScanSchema = z.object({ - status: z.enum(['pending', 'scanning', 'completed', 'failed']), - risk_score: z.enum(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']).nullable(), - scanned_at: z.union([z.string(), z.date()]).nullable(), - scanner_version: z.string().nullable().optional(), - certification: CertificationSchema.nullable().optional(), - summary: z.object({ - components: z.number(), - vulnerabilities: z.object({ - critical: z.number(), - high: z.number(), - medium: z.number(), - low: z.number(), - }), - secrets: z.number(), - malicious: z.number(), - code_issues: z.number(), - }).optional(), -}); - -export const PackageVersionSchema = z.object({ - version: z.string(), - published_at: z.union([z.string(), z.date()]), - downloads: z.number(), - artifacts: z.array(ArtifactSchema).optional(), - readme: z.string().nullable().optional(), - provenance: ProvenanceSchema.nullable().optional(), - release_url: z.string().nullable().optional(), - prerelease: z.boolean().optional(), - manifest: z.record(z.string(), z.unknown()).nullable().optional(), - security_scan: SecurityScanSchema.nullable().optional(), -}); - -export const PackageClaimingSchema = z.object({ - claimable: z.boolean(), - claimed: z.boolean(), - claimed_by: z.string().nullable(), - claimed_at: z.union([z.string(), z.date()]).nullable(), - github_repo: z.string().nullable(), -}); - -export const PackageDetailSchema = PackageSchema.extend({ - homepage: z.string().nullable(), - license: z.string().nullable(), - claiming: PackageClaimingSchema, - versions: z.array(PackageVersionSchema), -}); - -export const PackageSearchResponseSchema = z.object({ - packages: z.array(PackageSchema), - total: z.number(), -}); - -// V1 Bundle API Schemas - -export const PlatformInfoSchema = z.object({ - os: z.string(), - arch: z.string(), -}); - -export const FullProvenanceSchema = z.object({ - schema_version: z.string(), - provider: z.string(), - repository: z.string(), - sha: z.string(), -}); - -export const BundleSchema = z.object({ - name: z.string(), - display_name: z.string().nullable().optional(), - description: z.string().nullable().optional(), - author: PackageAuthorSchema.nullable().optional(), - latest_version: z.string(), - icon: z.string().nullable().optional(), - server_type: z.string().nullable().optional(), - tools: z.array(PackageToolSchema).optional(), - downloads: z.number(), - published_at: z.union([z.string(), z.date()]), - verified: z.boolean(), - provenance: FullProvenanceSchema.nullable().optional(), - certification_level: z.number().nullable().optional(), -}); - -export const BundleDetailSchema = BundleSchema.extend({ - homepage: z.string().nullable().optional(), - license: z.string().nullable().optional(), - certification: CertificationSchema.nullable().optional(), - versions: z.array(z.object({ - version: z.string(), - published_at: z.union([z.string(), z.date()]), - downloads: z.number(), - })), -}); - -export const BundleSearchResponseSchema = z.object({ - bundles: z.array(BundleSchema), - total: z.number(), - pagination: z.object({ - limit: z.number(), - offset: z.number(), - has_more: z.boolean(), - }), -}); - -export const VersionInfoSchema = z.object({ - version: z.string(), - artifacts_count: z.number(), - platforms: z.array(PlatformInfoSchema), - published_at: z.union([z.string(), z.date()]), - downloads: z.number(), - publish_method: z.string().nullable(), - provenance: FullProvenanceSchema.nullable().optional(), -}); - -export const VersionsResponseSchema = z.object({ - name: z.string(), - latest: z.string(), - versions: z.array(VersionInfoSchema), -}); - -export const DownloadInfoSchema = z.object({ - url: z.string(), - bundle: z.object({ - name: z.string(), - version: z.string(), - platform: PlatformInfoSchema, - sha256: z.string(), - size: z.number(), - }), - expires_at: z.string().optional(), -}); - -// Internal API Schemas - -export const PaginationSchema = z.object({ - limit: z.number(), - offset: z.number(), - has_more: z.boolean(), -}); - -export const PublishResponseSchema = z.object({ - success: z.boolean(), - package: z.object({ - name: z.string(), - version: z.string(), - manifest: z.record(z.string(), z.unknown()), - }), - sha256: z.string(), - size: z.number(), - url: z.string(), - auto_claimed: z.boolean().optional(), - message: z.string().optional(), -}); - -export const InternalDownloadResponseSchema = z.object({ - url: z.string(), - package: z.object({ - name: z.string(), - version: z.string(), - sha256: z.string(), - size: z.number(), - }), - expires_at: z.string(), -}); - -export const ClaimStatusResponseSchema = z.object({ - claimable: z.boolean(), - reason: z.string().optional(), - claimed_by: z.string().nullable().optional(), - claimed_at: z.union([z.string(), z.date()]).nullable().optional(), - package_name: z.string().optional(), - github_repo: z.string().nullable().optional(), - instructions: z.object({ - steps: z.array(z.string()), - mpak_json_example: z.string(), - verification_url: z.string().nullable(), - }).optional(), -}); - -export const ClaimResponseSchema = z.object({ - success: z.boolean(), - message: z.string(), - package: z.object({ - name: z.string(), - claimed_by: z.string().nullable(), - claimed_at: z.union([z.string(), z.date()]).nullable(), - github_repo: z.string().nullable(), - }), - verification: z.object({ - mpak_json_url: z.string().nullable().optional(), - verified_at: z.string(), - }), -}); - -export const MyPackagesResponseSchema = z.object({ - packages: z.array(PackageSchema), - total: z.number(), - pagination: PaginationSchema, -}); - -export const UnclaimedPackageSchema = z.object({ - name: z.string(), - display_name: z.string().nullable(), - description: z.string().nullable(), - server_type: z.string().nullable(), - latest_version: z.string(), - downloads: z.number(), - github_repo: z.string().nullable(), - created_at: z.union([z.string(), z.date()]), -}); - -export const UnclaimedPackagesResponseSchema = z.object({ - packages: z.array(UnclaimedPackageSchema), - total: z.number(), - pagination: PaginationSchema, -}); - -// V1 API Additional Schemas - -export const VersionDetailSchema = z.object({ - name: z.string(), - version: z.string(), - published_at: z.union([z.string(), z.date()]), - downloads: z.number(), - artifacts: z.array(z.object({ - platform: PlatformInfoSchema, - digest: z.string(), - size: z.number(), - download_url: z.string(), - source_url: z.string().optional(), - })), - manifest: z.record(z.string(), z.unknown()), - release: z.object({ - tag: z.string().nullable(), - url: z.string().nullable(), - }).optional(), - publish_method: z.string().nullable(), - provenance: FullProvenanceSchema.nullable(), -}); - -export const MCPBIndexSchema = z.object({ - index_version: z.string(), - mimeType: z.string(), - name: z.string(), - version: z.string(), - description: z.string().nullable(), - bundles: z.array(z.object({ - mimeType: z.string().nullable(), - digest: z.string(), - size: z.number(), - platform: PlatformInfoSchema, - urls: z.array(z.string()), - })), - annotations: z.record(z.string(), z.string()).optional(), -}); - -export const AnnounceRequestSchema = z.object({ - name: z.string(), - version: z.string(), - manifest: z.record(z.string(), z.unknown()), - release_tag: z.string(), - prerelease: z.boolean().optional().default(false), - artifact: z.object({ - filename: z.string(), - os: z.string(), - arch: z.string(), - sha256: z.string(), - size: z.number(), - }), -}); - -export const AnnounceResponseSchema = z.object({ - package: z.string(), - version: z.string(), - artifact: z.object({ - os: z.string(), - arch: z.string(), - filename: z.string(), - }), - total_artifacts: z.number(), - status: z.enum(['created', 'updated']), -}); - -// TypeScript types -export type PackageAuthor = z.infer; -export type PackageTool = z.infer; -export type PackageGitHub = z.infer; -export type Package = z.infer; -export type Artifact = z.infer; -export type Provenance = z.infer; -export type Certification = z.infer; -export type SecurityScan = z.infer; -export type PackageVersion = z.infer; -export type PackageClaiming = z.infer; -export type PackageDetail = z.infer; -export type PackageSearchResponse = z.infer; -export type PlatformInfo = z.infer; -export type FullProvenance = z.infer; -export type Bundle = z.infer; -export type BundleDetail = z.infer; -export type BundleSearchResponse = z.infer; -export type VersionInfo = z.infer; -export type VersionsResponse = z.infer; -export type DownloadInfo = z.infer; -export type VersionDetail = z.infer; -export type MCPBIndex = z.infer; -export type AnnounceRequest = z.infer; -export type AnnounceResponse = z.infer; -export type Pagination = z.infer; -export type PublishResponse = z.infer; -export type InternalDownloadResponse = z.infer; -export type ClaimStatusResponse = z.infer; -export type ClaimResponse = z.infer; -export type MyPackagesResponse = z.infer; -export type UnclaimedPackage = z.infer; -export type UnclaimedPackagesResponse = z.infer; diff --git a/apps/registry/src/schemas/generated/auth.ts b/apps/registry/src/schemas/generated/auth.ts deleted file mode 100644 index e922e3a..0000000 --- a/apps/registry/src/schemas/generated/auth.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { z } from 'zod'; - -// User profile response from /app/auth/me -export const UserProfileSchema = z.object({ - id: z.string(), - email: z.string().email(), - emailVerified: z.boolean(), - username: z.string().nullable(), - name: z.string().nullable(), - avatarUrl: z.string().nullable(), - githubUsername: z.string().nullable(), - githubLinked: z.boolean(), - verified: z.boolean(), - publishedBundles: z.number(), - totalDownloads: z.number(), - role: z.string().nullable(), // admin, etc. from Clerk publicMetadata - createdAt: z.union([z.string(), z.date()]).nullable(), - lastLoginAt: z.union([z.string(), z.date()]).nullable(), -}); - -// Export TypeScript type -export type UserProfile = z.infer; diff --git a/apps/registry/src/schemas/generated/package.ts b/apps/registry/src/schemas/generated/package.ts deleted file mode 100644 index f162d13..0000000 --- a/apps/registry/src/schemas/generated/package.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { z } from 'zod'; - -// Server type enum -export const ServerTypeSchema = z.enum(['node', 'python', 'binary']); - -// Platform enum -export const PlatformSchema = z.enum(['darwin', 'win32', 'linux']); - -// Sort options -export const PackageSortSchema = z.enum(['downloads', 'recent', 'name']); - -// Package search params schema -// Query params from HTTP are always strings, but we parse them to proper types -export const PackageSearchParamsSchema = z.object({ - q: z.string().optional(), - type: ServerTypeSchema.optional(), - tool: z.string().optional(), - prompt: z.string().optional(), - platform: PlatformSchema.optional(), - sort: PackageSortSchema.optional(), - limit: z.union([z.string(), z.number()]).optional(), - offset: z.union([z.string(), z.number()]).optional(), -}); - -// Export TypeScript types -export type ServerType = z.infer; -export type Platform = z.infer; -export type PackageSort = z.infer; -export type PackageSearchParams = z.infer; diff --git a/apps/registry/src/schemas/generated/skill.ts b/apps/registry/src/schemas/generated/skill.ts deleted file mode 100644 index c437718..0000000 --- a/apps/registry/src/schemas/generated/skill.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { z } from 'zod'; - -// ============================================================================= -// Agent Skills Specification - Skill Frontmatter Schema -// https://agentskills.io/specification -// ============================================================================= - -/** - * Skill name validation - * - 1-64 characters - * - Lowercase alphanumeric with single hyphens - * - Cannot start or end with hyphen - * - Must match directory name (validated separately) - */ -export const SkillNameSchema = z - .string() - .min(1) - .max(64) - .regex( - /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/, - 'Lowercase alphanumeric with single hyphens, cannot start/end with hyphen' - ); - -/** - * Skill description - * - 1-1024 characters - * - Should describe what the skill does AND when to use it - */ -export const SkillDescriptionSchema = z.string().min(1).max(1024); - -// ============================================================================= -// Discovery Metadata Extension (via metadata: field) -// ============================================================================= - -/** - * Category taxonomy for skill discovery - */ -export const SkillCategorySchema = z.enum([ - 'development', // Code, debugging, architecture - 'writing', // Documentation, content, editing - 'research', // Investigation, analysis, learning - 'consulting', // Strategy, decisions, planning - 'data', // Analysis, visualization, processing - 'design', // UI/UX, visual, creative - 'operations', // DevOps, infrastructure, automation - 'security', // Auditing, compliance, protection - 'other', // Uncategorized -]); - -/** - * Author information for attribution - */ -export const SkillAuthorSchema = z.object({ - name: z.string().min(1), - url: z.string().url().optional(), - email: z.string().email().optional(), -}); - -/** - * Example usage for discovery - */ -export const SkillExampleSchema = z.object({ - prompt: z.string().min(1), - context: z.string().optional(), -}); - -/** - * Discovery metadata (via metadata: field in frontmatter) - * All fields optional - skills can start minimal and add discovery metadata later - */ -export const SkillDiscoveryMetadataSchema = z - .object({ - // Discovery - tags: z.array(z.string().max(32)).max(10).optional(), - category: SkillCategorySchema.optional(), - triggers: z.array(z.string().max(128)).max(20).optional(), - keywords: z.array(z.string().max(32)).max(30).optional(), - - // Attribution - author: SkillAuthorSchema.optional(), - - // Version (for registry tracking) - version: z.string().optional(), - - // Examples - examples: z.array(SkillExampleSchema).max(5).optional(), - }) - .passthrough(); // Allow additional custom keys - -// ============================================================================= -// Complete SKILL.md Frontmatter Schema -// ============================================================================= - -/** - * Complete SKILL.md frontmatter schema - * Combines official Agent Skills spec with discovery metadata extension - */ -export const SkillFrontmatterSchema = z.object({ - // Required (official spec) - name: SkillNameSchema, - description: SkillDescriptionSchema, - - // Optional (official spec - informational pass-through) - version: z.string().optional(), - license: z.string().optional(), - compatibility: z.string().max(500).optional(), - 'allowed-tools': z.string().optional(), // space-delimited, experimental - - // Extensible metadata (official spec) - our discovery extension - metadata: SkillDiscoveryMetadataSchema.optional(), -}); - -// ============================================================================= -// Registry API Schemas -// ============================================================================= - -/** - * Scoped skill name for registry (e.g., @nimblebraininc/strategic-thought-partner) - */ -export const ScopedSkillNameSchema = z - .string() - .regex( - /^@[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/, - 'Scoped name format: @scope/name' - ); - -/** - * Skill artifact info for announce endpoint - */ -export const SkillArtifactSchema = z.object({ - filename: z.string().regex(/\.skill$/, 'Must have .skill extension'), - sha256: z.string().length(64), - size: z.number().int().positive(), -}); - -/** - * Announce request schema for POST /v1/skills/announce - */ -export const SkillAnnounceRequestSchema = z.object({ - name: ScopedSkillNameSchema, - version: z.string(), - skill: SkillFrontmatterSchema, - release_tag: z.string(), - prerelease: z.boolean().optional().default(false), - artifact: SkillArtifactSchema, -}); - -/** - * Announce response schema - */ -export const SkillAnnounceResponseSchema = z.object({ - skill: z.string(), - version: z.string(), - status: z.enum(['created', 'exists']), -}); - -// ============================================================================= -// Search/List API Schemas -// ============================================================================= - -/** - * Skill search parameters - */ -export const SkillSearchParamsSchema = z.object({ - q: z.string().optional(), - tags: z.string().optional(), // comma-separated - category: SkillCategorySchema.optional(), - sort: z.enum(['downloads', 'recent', 'name']).optional(), - limit: z.union([z.string(), z.number()]).optional(), - offset: z.union([z.string(), z.number()]).optional(), -}); - -/** - * Skill summary for search results - */ -export const SkillSummarySchema = z.object({ - name: z.string(), // scoped name - description: z.string(), - latest_version: z.string(), - tags: z.array(z.string()).optional(), - category: SkillCategorySchema.optional(), - downloads: z.number(), - published_at: z.string(), - author: SkillAuthorSchema.optional(), -}); - -/** - * Skill search response - */ -export const SkillSearchResponseSchema = z.object({ - skills: z.array(SkillSummarySchema), - total: z.number(), - pagination: z.object({ - limit: z.number(), - offset: z.number(), - has_more: z.boolean(), - }), -}); - -/** - * Skill detail response - */ -export const SkillDetailSchema = z.object({ - name: z.string(), // scoped name - description: z.string(), - latest_version: z.string(), - license: z.string().optional(), - compatibility: z.string().optional(), - allowed_tools: z.array(z.string()).optional(), - tags: z.array(z.string()).optional(), - category: SkillCategorySchema.optional(), - triggers: z.array(z.string()).optional(), - downloads: z.number(), - published_at: z.string(), - author: SkillAuthorSchema.optional(), - examples: z.array(SkillExampleSchema).optional(), - versions: z.array( - z.object({ - version: z.string(), - published_at: z.string(), - downloads: z.number(), - }) - ), -}); - -/** - * Skill download info response - */ -export const SkillDownloadInfoSchema = z.object({ - url: z.string(), - skill: z.object({ - name: z.string(), - version: z.string(), - sha256: z.string(), - size: z.number(), - }), - expires_at: z.string(), -}); - -// ============================================================================= -// TypeScript Types -// ============================================================================= - -export type SkillName = z.infer; -export type SkillCategory = z.infer; -export type SkillAuthor = z.infer; -export type SkillExample = z.infer; -export type SkillDiscoveryMetadata = z.infer; -export type SkillFrontmatter = z.infer; -export type ScopedSkillName = z.infer; -export type SkillArtifact = z.infer; -export type SkillAnnounceRequest = z.infer; -export type SkillAnnounceResponse = z.infer; -export type SkillSearchParams = z.infer; -export type SkillSummary = z.infer; -export type SkillSearchResponse = z.infer; -export type SkillDetail = z.infer; -export type SkillDownloadInfo = z.infer; diff --git a/apps/registry/src/types.ts b/apps/registry/src/types.ts index 81fabd8..be6aaba 100644 --- a/apps/registry/src/types.ts +++ b/apps/registry/src/types.ts @@ -87,14 +87,14 @@ export interface PackageVersion { published_at: Date; } -// API response types - now imported from schemas +// API response types - imported from shared schemas package export type { Package as PackageListItem, PackageDetail as PackageInfo, -} from './schemas/generated/api-responses.js'; +} from '@nimblebrain/mpak-schemas'; -// API query params - now imported from schemas -export type { PackageSearchParams } from './schemas/generated/package.js'; +// API query params - imported from shared schemas package +export type { PackageSearchParams } from '@nimblebrain/mpak-schemas'; // MCP Registry types export interface MCPServerDetail { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40d5eb3..adec441 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: '@kubernetes/client-node': specifier: ^1.4.0 version: 1.4.0 + '@nimblebrain/mpak-schemas': + specifier: workspace:* + version: link:../../packages/schemas '@prisma/adapter-pg': specifier: ^7.2.0 version: 7.3.0 From 692db07fe7e54e798c0deb4d4710c9fe55e160dd Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Fri, 13 Mar 2026 19:03:41 -0400 Subject: [PATCH 3/6] Add Zod-based type safety to bundle search route Replace hand-written JSON Schema querystring with BundleSearchParamsSchema, use Fastify generics for type-safe request access, type the response as BundleSearchResponse, and replace runtime clamping with schema validation. Co-Authored-By: Claude Opus 4.6 --- apps/registry/src/routes/v1/bundles.ts | 69 ++++++++++---------------- packages/schemas/src/package.ts | 10 ++++ 2 files changed, 35 insertions(+), 44 deletions(-) diff --git a/apps/registry/src/routes/v1/bundles.ts b/apps/registry/src/routes/v1/bundles.ts index 4649e54..d372a6c 100644 --- a/apps/registry/src/routes/v1/bundles.ts +++ b/apps/registry/src/routes/v1/bundles.ts @@ -22,7 +22,12 @@ import { MCPBIndexSchema, AnnounceRequestSchema, AnnounceResponseSchema, + BundleSearchParamsSchema, + type BundleSearchParams, + type BundleSearchResponse, + type PackageTool, } from '@nimblebrain/mpak-schemas'; +import type { PackageSearchFilters } from '../../db/types.js'; import { generateBadge } from '../../utils/badge.js'; import { notifyDiscordAnnounce } from '../../utils/discord.js'; import { triggerSecurityScan } from '../../services/scanner.js'; @@ -72,7 +77,7 @@ function getProvenanceSummary(version: { publishMethod: string | null; provenanc } const p = version.provenance as ProvenanceRecord; return { - schema_version: p.schema_version, + schema_version: String(p.schema_version), provider: p.provider, repository: p.repository, sha: p.sha, @@ -133,63 +138,38 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { const { packages: packageRepo } = fastify.repositories; // GET /v1/bundles/search - Search bundles - fastify.get('/search', { + fastify.get<{ Querystring: BundleSearchParams }>('/search', { schema: { tags: ['bundles'], description: 'Search for bundles', - querystring: { - type: 'object', - properties: { - q: { type: 'string', description: 'Search query' }, - type: { type: 'string', description: 'Filter by server type' }, - sort: { type: 'string', enum: ['downloads', 'recent', 'name'], default: 'downloads' }, - limit: { type: 'number', default: 20, maximum: 100 }, - offset: { type: 'number', default: 0 }, - }, - }, + querystring: toJsonSchema(BundleSearchParamsSchema), response: { 200: toJsonSchema(BundleSearchResponseSchema), }, }, handler: async (request) => { - const { - q, - type, - sort = 'downloads', - limit = 20, - offset = 0, - } = request.query as { - q?: string; - type?: string; - sort?: string; - limit?: number; - offset?: number; - }; + const { q, type, sort, limit, offset } = request.query; // Build filters - const filters: Record = {}; - if (q) filters['query'] = q; - if (type) filters['serverType'] = type; + const filters: PackageSearchFilters = {}; + if (q) filters.query = q; + if (type) filters.serverType = type; // Build sort options - let orderBy: Record = { totalDownloads: 'desc' }; - if (sort === 'recent') { - orderBy = { createdAt: 'desc' }; - } else if (sort === 'name') { - orderBy = { name: 'asc' }; - } - - // Clamp pagination values to safe ranges - const safeLimit = Math.max(1, Math.min(limit, 100)); - const safeOffset = Math.max(0, offset); + const sortMap: Record> = { + downloads: { totalDownloads: 'desc' }, + recent: { createdAt: 'desc' }, + name: { name: 'asc' }, + }; + const orderBy = sortMap[sort]; // Search packages const startTime = Date.now(); const { packages, total } = await packageRepo.search( filters, { - skip: safeOffset, - take: safeLimit, + skip: offset, + take: limit, orderBy, } ); @@ -218,17 +198,17 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { latest_version: pkg.latestVersion, icon: pkg.iconUrl, server_type: pkg.serverType, - tools: (manifest['tools'] as unknown[]) ?? [], + tools: (manifest['tools'] as PackageTool[]) ?? [], downloads: Number(pkg.totalDownloads), - published_at: latestVersion?.publishedAt ?? pkg.createdAt, - verified: pkg.verified, + published_at: latestVersion?.publishedAt ?? pkg.createdAt as Date, + verified: Boolean(pkg.verified), provenance: latestVersion ? getProvenanceSummary(latestVersion) : null, certification_level: scan?.certificationLevel ?? null, }; }) ); - return { + const response: BundleSearchResponse = { bundles, total, pagination: { @@ -237,6 +217,7 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { has_more: offset + bundles.length < total, }, }; + return response; }, }); diff --git a/packages/schemas/src/package.ts b/packages/schemas/src/package.ts index c03f3c0..17f85d8 100644 --- a/packages/schemas/src/package.ts +++ b/packages/schemas/src/package.ts @@ -28,6 +28,15 @@ export const PackageSearchParamsSchema = z.object({ offset: z.union([z.string(), z.number()]).optional(), }); +/** Bundle search query parameters. */ +export const BundleSearchParamsSchema = z.object({ + q: z.string().max(200).optional(), + type: ServerTypeSchema.optional(), + sort: PackageSortSchema.optional().default("downloads"), + limit: z.number().min(1).max(100).optional().default(20), + offset: z.number().min(0).optional().default(0), +}); + // ============================================================================= // TypeScript Types // ============================================================================= @@ -36,3 +45,4 @@ export type ServerType = z.infer; export type Platform = z.infer; export type PackageSort = z.infer; export type PackageSearchParams = z.infer; +export type BundleSearchParams = z.infer; From f5cce63135f90d315d701b1d7000ea15262b965f Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Sat, 14 Mar 2026 17:24:03 -0400 Subject: [PATCH 4/6] Add search validation tests and artifact seed data - Register errorHandler in test setup for 422 parity with production - Replace clamping test with schema validation rejection tests - Add tests for defaults, type filter, enum validation, q length, limit boundary, and has_more pagination - Seed artifacts for echo (universal) and nationalparks (multi-platform) - Create placeholder .mcpb files on disk for local download testing Co-Authored-By: Claude Opus 4.6 --- apps/registry/prisma/seed.ts | 82 ++++++++++++++++++++++++++++- apps/registry/tests/bundles.test.ts | 73 ++++++++++++++++++++++--- 2 files changed, 146 insertions(+), 9 deletions(-) diff --git a/apps/registry/prisma/seed.ts b/apps/registry/prisma/seed.ts index 24d093b..cedcfac 100644 --- a/apps/registry/prisma/seed.ts +++ b/apps/registry/prisma/seed.ts @@ -17,6 +17,8 @@ import { PrismaClient } from '@prisma/client'; import { PrismaPg } from '@prisma/adapter-pg'; import pg from 'pg'; import { createHash, randomUUID } from 'crypto'; +import { mkdir, writeFile } from 'fs/promises'; +import { dirname, join } from 'path'; // --------------------------------------------------------------------------- // Database setup (standalone, doesn't use the app's singleton) @@ -377,6 +379,13 @@ The skill follows a structured thinking process: // Seed data: Packages (bundles) // --------------------------------------------------------------------------- +interface SeedArtifact { + os: string; + arch: string; + sizeBytes: number; + sourceUrl: string; +} + interface SeedPackageVersion { version: string; prerelease?: boolean; @@ -388,6 +397,7 @@ interface SeedPackageVersion { provenanceSha: string; releaseTag?: string; releaseUrl?: string; + artifacts?: SeedArtifact[]; } interface SeedPackage { @@ -458,6 +468,7 @@ const PACKAGES: SeedPackage[] = [ releaseTag: 'v0.1.0', releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.0', manifest: echoManifest('0.1.0'), + artifacts: universalArtifact('NimbleBrainInc/mcp-echo', '0.1.0', 14_200), }, { version: '0.1.1-beta.1', @@ -470,6 +481,7 @@ const PACKAGES: SeedPackage[] = [ releaseTag: 'v0.1.1-beta.1', releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.1-beta.1', manifest: echoManifest('0.1.1-beta.1'), + artifacts: universalArtifact('NimbleBrainInc/mcp-echo', '0.1.1-beta.1', 14_350), }, { version: '0.1.1', @@ -481,6 +493,7 @@ const PACKAGES: SeedPackage[] = [ releaseTag: 'v0.1.1', releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.1', manifest: echoManifest('0.1.1'), + artifacts: universalArtifact('NimbleBrainInc/mcp-echo', '0.1.1', 14_500), }, { version: '0.1.2', @@ -492,6 +505,7 @@ const PACKAGES: SeedPackage[] = [ releaseTag: 'v0.1.2', releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.2', manifest: echoManifest('0.1.2'), + artifacts: universalArtifact('NimbleBrainInc/mcp-echo', '0.1.2', 14_600), }, { version: '0.1.3', @@ -503,6 +517,7 @@ const PACKAGES: SeedPackage[] = [ releaseTag: 'v0.1.3', releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.3', manifest: echoManifest('0.1.3'), + artifacts: universalArtifact('NimbleBrainInc/mcp-echo', '0.1.3', 14_700), }, { version: '0.1.4-rc.1', @@ -515,6 +530,7 @@ const PACKAGES: SeedPackage[] = [ releaseTag: 'v0.1.4-rc.1', releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.4-rc.1', manifest: echoManifest('0.1.4-rc.1'), + artifacts: universalArtifact('NimbleBrainInc/mcp-echo', '0.1.4-rc.1', 15_000), }, { version: '0.1.4-rc.4', @@ -527,6 +543,7 @@ const PACKAGES: SeedPackage[] = [ releaseTag: 'v0.1.4-rc.4', releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.4-rc.4', manifest: echoManifest('0.1.4-rc.4'), + artifacts: universalArtifact('NimbleBrainInc/mcp-echo', '0.1.4-rc.4', 15_100), }, { version: '0.1.4', @@ -538,6 +555,7 @@ const PACKAGES: SeedPackage[] = [ releaseTag: 'v0.1.4', releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.4', manifest: echoManifest('0.1.4'), + artifacts: universalArtifact('NimbleBrainInc/mcp-echo', '0.1.4', 15_200), }, { version: '0.1.5', @@ -549,6 +567,7 @@ const PACKAGES: SeedPackage[] = [ releaseTag: 'v0.1.5', releaseUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/tag/v0.1.5', manifest: echoManifest('0.1.5'), + artifacts: universalArtifact('NimbleBrainInc/mcp-echo', '0.1.5', 15_400), }, ], }, @@ -571,6 +590,7 @@ const PACKAGES: SeedPackage[] = [ releaseUrl: 'https://github.com/NimbleBrainInc/mcp-server-nationalparks/releases/tag/v0.1.1', manifest: nationalparksManifest('0.1.1'), + artifacts: multiPlatformArtifacts('NimbleBrainInc/mcp-server-nationalparks', '0.1.1', 82_000), }, { version: '0.1.2', @@ -583,6 +603,7 @@ const PACKAGES: SeedPackage[] = [ releaseUrl: 'https://github.com/NimbleBrainInc/mcp-server-nationalparks/releases/tag/v0.1.2', manifest: nationalparksManifest('0.1.2'), + artifacts: multiPlatformArtifacts('NimbleBrainInc/mcp-server-nationalparks', '0.1.2', 83_000), }, { version: '0.1.3', @@ -595,6 +616,7 @@ const PACKAGES: SeedPackage[] = [ releaseUrl: 'https://github.com/NimbleBrainInc/mcp-server-nationalparks/releases/tag/v0.1.3', manifest: nationalparksManifest('0.1.3'), + artifacts: multiPlatformArtifacts('NimbleBrainInc/mcp-server-nationalparks', '0.1.3', 83_500), }, { version: '0.1.4', @@ -607,6 +629,7 @@ const PACKAGES: SeedPackage[] = [ releaseUrl: 'https://github.com/NimbleBrainInc/mcp-server-nationalparks/releases/tag/v0.1.4', manifest: nationalparksManifest('0.1.4'), + artifacts: multiPlatformArtifacts('NimbleBrainInc/mcp-server-nationalparks', '0.1.4', 84_000), }, { version: '0.1.5', @@ -619,6 +642,7 @@ const PACKAGES: SeedPackage[] = [ releaseUrl: 'https://github.com/NimbleBrainInc/mcp-server-nationalparks/releases/tag/v0.1.5', manifest: nationalparksManifest('0.1.5'), + artifacts: multiPlatformArtifacts('NimbleBrainInc/mcp-server-nationalparks', '0.1.5', 84_500), }, { version: '0.2.0', @@ -631,6 +655,7 @@ const PACKAGES: SeedPackage[] = [ releaseUrl: 'https://github.com/NimbleBrainInc/mcp-server-nationalparks/releases/tag/v0.2.0', manifest: nationalparksManifest('0.2.0'), + artifacts: multiPlatformArtifacts('NimbleBrainInc/mcp-server-nationalparks', '0.2.0', 86_000), }, ], }, @@ -645,6 +670,26 @@ function fakeDigest(input: string): string { return 'sha256:' + createHash('sha256').update(input).digest('hex'); } +/** Universal artifact for python/any-platform bundles */ +function universalArtifact(repo: string, version: string, sizeBytes: number): SeedArtifact[] { + return [{ + os: 'any', + arch: 'any', + sizeBytes, + sourceUrl: `https://github.com/${repo}/releases/download/v${version}/${repo.split('/')[1]}-${version}.mcpb`, + }]; +} + +/** Multi-platform artifacts for node bundles */ +function multiPlatformArtifacts(repo: string, version: string, sizeBytes: number): SeedArtifact[] { + const name = repo.split('/')[1]; + return [ + { os: 'darwin', arch: 'arm64', sizeBytes, sourceUrl: `https://github.com/${repo}/releases/download/v${version}/${name}-${version}-darwin-arm64.mcpb` }, + { os: 'darwin', arch: 'x64', sizeBytes: sizeBytes + 1024, sourceUrl: `https://github.com/${repo}/releases/download/v${version}/${name}-${version}-darwin-x64.mcpb` }, + { os: 'linux', arch: 'x64', sizeBytes: sizeBytes + 2048, sourceUrl: `https://github.com/${repo}/releases/download/v${version}/${name}-${version}-linux-x64.mcpb` }, + ]; +} + /** Generate a deterministic fake storage path */ function storagePath(scope: string, name: string, version: string): string { return `skills/${scope}/${name}/${version}/skill.bundle`; @@ -771,7 +816,7 @@ async function seed() { console.log(` Package: ${p.name} (${pkg.id})`); for (const v of p.versions) { - await prisma.packageVersion.upsert({ + const pkgVersion = await prisma.packageVersion.upsert({ where: { packageId_version: { packageId: pkg.id, version: v.version }, }, @@ -794,7 +839,40 @@ async function seed() { }, }); - console.log(` v${v.version} (${v.downloads} downloads)`); + // Upsert artifacts for this version + for (const a of v.artifacts ?? []) { + const digest = fakeDigest(`${p.name}@${v.version}-${a.os}-${a.arch}`); + const artifactPath = `${p.name}/${v.version}/${a.os}-${a.arch}.mcpb`; + + await prisma.artifact.upsert({ + where: { + versionId_os_arch: { versionId: pkgVersion.id, os: a.os, arch: a.arch }, + }, + create: { + versionId: pkgVersion.id, + os: a.os, + arch: a.arch, + digest, + sizeBytes: BigInt(a.sizeBytes), + storagePath: artifactPath, + sourceUrl: a.sourceUrl, + }, + update: { + digest, + sizeBytes: BigInt(a.sizeBytes), + sourceUrl: a.sourceUrl, + }, + }); + + // Create placeholder file on disk so local storage can serve it + const storagePath = process.env['STORAGE_PATH'] || './packages'; + const fullPath = join(storagePath, artifactPath); + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, `placeholder:${p.name}@${v.version}:${a.os}-${a.arch}`); + } + + const artifactCount = v.artifacts?.length ?? 0; + console.log(` v${v.version} (${v.downloads} downloads, ${artifactCount} artifacts)`); } } diff --git a/apps/registry/tests/bundles.test.ts b/apps/registry/tests/bundles.test.ts index a2b02c9..b880bf6 100644 --- a/apps/registry/tests/bundles.test.ts +++ b/apps/registry/tests/bundles.test.ts @@ -71,6 +71,7 @@ import { mockVersionWithScans, } from './helpers.js'; import { verifyGitHubOIDC } from '../src/lib/oidc.js'; +import { errorHandler } from '../src/errors/middleware.js'; // --------------------------------------------------------------------------- // Test setup @@ -90,6 +91,7 @@ describe('Bundle Routes', () => { app = Fastify({ logger: false }); app.setReplySerializer((payload) => JSON.stringify(payload)); await app.register(sensible); + app.setErrorHandler(errorHandler); // Decorate with mocks app.decorate('repositories', { @@ -143,28 +145,85 @@ describe('Bundle Routes', () => { expect(body.total).toBe(0); }); - it('clamps pagination limits to safe ranges', async () => { + it('rejects invalid pagination values', async () => { + const res = await app.inject({ method: 'GET', url: '/search?q=x&limit=0&offset=-5' }); + + expect(res.statusCode).toBe(422); + expect(packageRepo.search).not.toHaveBeenCalled(); + }); + + it('supports sort parameter', async () => { packageRepo.search.mockResolvedValue({ packages: [], total: 0 }); - // limit=0 should be clamped to 1, offset=-5 should be clamped to 0 - await app.inject({ method: 'GET', url: '/search?q=x&limit=0&offset=-5' }); + await app.inject({ method: 'GET', url: '/search?q=x&sort=name' }); expect(packageRepo.search).toHaveBeenCalledWith( expect.any(Object), - expect.objectContaining({ take: 1, skip: 0 }), + expect.objectContaining({ orderBy: { name: 'asc' } }), ); }); - it('supports sort parameter', async () => { + it('applies defaults when no params provided', async () => { packageRepo.search.mockResolvedValue({ packages: [], total: 0 }); - await app.inject({ method: 'GET', url: '/search?q=x&sort=name' }); + const res = await app.inject({ method: 'GET', url: '/search' }); + expect(res.statusCode).toBe(200); + expect(packageRepo.search).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + skip: 0, + take: 20, + orderBy: { totalDownloads: 'desc' }, + }), + ); + }); + + it('passes type filter to search', async () => { + packageRepo.search.mockResolvedValue({ packages: [], total: 0 }); + + const res = await app.inject({ method: 'GET', url: '/search?type=node' }); + + expect(res.statusCode).toBe(200); expect(packageRepo.search).toHaveBeenCalledWith( + expect.objectContaining({ serverType: 'node' }), expect.any(Object), - expect.objectContaining({ orderBy: { name: 'asc' } }), ); }); + + it('rejects invalid type and sort enum values', async () => { + const typeRes = await app.inject({ method: 'GET', url: '/search?type=invalid' }); + const sortRes = await app.inject({ method: 'GET', url: '/search?sort=bogus' }); + + expect(typeRes.statusCode).toBe(422); + expect(sortRes.statusCode).toBe(422); + expect(packageRepo.search).not.toHaveBeenCalled(); + }); + + it('rejects q longer than 200 characters', async () => { + const res = await app.inject({ method: 'GET', url: `/search?q=${'a'.repeat(201)}` }); + + expect(res.statusCode).toBe(422); + expect(packageRepo.search).not.toHaveBeenCalled(); + }); + + it('rejects limit above 100', async () => { + const res = await app.inject({ method: 'GET', url: '/search?limit=101' }); + + expect(res.statusCode).toBe(422); + expect(packageRepo.search).not.toHaveBeenCalled(); + }); + + it('sets has_more when total exceeds returned results', async () => { + packageRepo.search.mockResolvedValue({ packages: [mockPackage], total: 50 }); + packageRepo.findVersionWithLatestScan.mockResolvedValue(mockVersionWithScans); + + const res = await app.inject({ method: 'GET', url: '/search?limit=1&offset=0' }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.payload); + expect(body.pagination).toEqual({ limit: 1, offset: 0, has_more: true }); + }); }); // ========================================================================= From 2d18e8acd1566e8340049cf1ea925369ad3ac1a0 Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Sat, 14 Mar 2026 19:13:09 -0400 Subject: [PATCH 5/6] Add Zod schemas for bundle download route params and query Co-Authored-By: Claude Opus 4.6 --- apps/registry/src/routes/v1/bundles.ts | 37 ++++++++++---------------- apps/registry/src/schemas/bundles.ts | 10 +++++++ packages/schemas/src/package.ts | 7 +++++ 3 files changed, 31 insertions(+), 23 deletions(-) create mode 100644 apps/registry/src/schemas/bundles.ts diff --git a/apps/registry/src/routes/v1/bundles.ts b/apps/registry/src/routes/v1/bundles.ts index d372a6c..56d108b 100644 --- a/apps/registry/src/routes/v1/bundles.ts +++ b/apps/registry/src/routes/v1/bundles.ts @@ -23,11 +23,17 @@ import { AnnounceRequestSchema, AnnounceResponseSchema, BundleSearchParamsSchema, + BundleDownloadParamsSchema, type BundleSearchParams, + type BundleDownloadParams, type BundleSearchResponse, type PackageTool, } from '@nimblebrain/mpak-schemas'; import type { PackageSearchFilters } from '../../db/types.js'; +import { + BundleVersionPathParamsSchema, + type BundleVersionPathParams, +} from '../../schemas/bundles.js'; import { generateBadge } from '../../utils/badge.js'; import { notifyDiscordAnnounce } from '../../utils/discord.js'; import { triggerSecurityScan } from '../../services/scanner.js'; @@ -539,38 +545,23 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { }); // GET /v1/bundles/@:scope/:package/versions/:version/download - Download bundle - fastify.get('/@:scope/:package/versions/:version/download', { + fastify.get<{ + Params: BundleVersionPathParams; + Querystring: BundleDownloadParams; + }>('/@:scope/:package/versions/:version/download', { schema: { tags: ['bundles'], description: 'Download a specific version of a bundle', - params: { - type: 'object', - properties: { - scope: { type: 'string' }, - package: { type: 'string' }, - version: { type: 'string' }, - }, - required: ['scope', 'package', 'version'], - }, - querystring: { - type: 'object', - properties: { - os: { type: 'string', description: 'Target OS (darwin, linux, win32, any)' }, - arch: { type: 'string', description: 'Target arch (x64, arm64, any)' }, - }, - }, + params: toJsonSchema(BundleVersionPathParamsSchema), + querystring: toJsonSchema(BundleDownloadParamsSchema), response: { 200: toJsonSchema(DownloadInfoSchema), 302: { type: 'null', description: 'Redirect to download URL' }, }, }, handler: async (request, reply) => { - const { scope, package: packageName, version: versionParam } = request.params as { - scope: string; - package: string; - version: string; - }; - const { os: queryOs, arch: queryArch } = request.query as { os?: string; arch?: string }; + const { scope, package: packageName, version: versionParam } = request.params; + const { os: queryOs, arch: queryArch } = request.query; const name = `@${scope}/${packageName}`; const pkg = await packageRepo.findByName(name); diff --git a/apps/registry/src/schemas/bundles.ts b/apps/registry/src/schemas/bundles.ts new file mode 100644 index 0000000..5f1b638 --- /dev/null +++ b/apps/registry/src/schemas/bundles.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +/** Path params for routes like /@:scope/:package/versions/:version/download */ +export const BundleVersionPathParamsSchema = z.object({ + scope: z.string(), + package: z.string(), + version: z.string(), +}); + +export type BundleVersionPathParams = z.infer; diff --git a/packages/schemas/src/package.ts b/packages/schemas/src/package.ts index 17f85d8..7ebbc03 100644 --- a/packages/schemas/src/package.ts +++ b/packages/schemas/src/package.ts @@ -37,6 +37,12 @@ export const BundleSearchParamsSchema = z.object({ offset: z.number().min(0).optional().default(0), }); +/** Bundle download query parameters (os + arch). */ +export const BundleDownloadParamsSchema = z.object({ + os: z.enum(["darwin", "linux", "win32"]).describe("Target OS (darwin, linux, win32)").optional(), + arch: z.enum(["x64", "arm64"]).describe("Target arch (x64, arm64)").optional(), +}); + // ============================================================================= // TypeScript Types // ============================================================================= @@ -46,3 +52,4 @@ export type Platform = z.infer; export type PackageSort = z.infer; export type PackageSearchParams = z.infer; export type BundleSearchParams = z.infer; +export type BundleDownloadParams = z.infer; From ce2c7b57541d47b5563ef468c35228bbf9103f0d Mon Sep 17 00:00:00 2001 From: shwetank-dev Date: Sat, 14 Mar 2026 19:50:08 -0400 Subject: [PATCH 6/6] Fix download endpoint returning wrong artifact for unmatched platform Add resolveArtifact helper to enforce platform selection rules: - Both os and arch required together (400 if only one provided) - Exact platform match when both specified (404 if no match) - Universal any/any artifact when no platform params (404 if none) - Invalid enum values rejected at schema level (422) Previously the endpoint fell back to artifacts[0] when no match was found, silently serving a wrong-platform artifact (e.g. darwin-arm64 when linux-arm64 was requested). Fixes #28. Co-Authored-By: Claude Opus 4.6 --- apps/registry/src/routes/v1/bundles.ts | 47 ++++++----- apps/registry/tests/bundles.test.ts | 108 ++++++++++++++++++++++++- 2 files changed, 133 insertions(+), 22 deletions(-) diff --git a/apps/registry/src/routes/v1/bundles.ts b/apps/registry/src/routes/v1/bundles.ts index 56d108b..3f5ae9f 100644 --- a/apps/registry/src/routes/v1/bundles.ts +++ b/apps/registry/src/routes/v1/bundles.ts @@ -1,3 +1,4 @@ +import type { Artifact } from '@prisma/client'; import type { FastifyPluginAsync } from 'fastify'; import { createHash, randomUUID } from 'crypto'; import { createWriteStream, createReadStream, promises as fs } from 'fs'; @@ -64,6 +65,30 @@ 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('/'); @@ -580,28 +605,10 @@ export const bundleRoutes: FastifyPluginAsync = async (fastify) => { } // Find the appropriate artifact - let artifact = packageVersion.artifacts[0]; // Default to first - - if (queryOs || queryArch) { - // Look for exact match - const match = packageVersion.artifacts.find( - (a) => a.os === queryOs && a.arch === queryArch - ); - if (match) { - artifact = match; - } else { - // Look for universal fallback - const universal = packageVersion.artifacts.find( - (a) => a.os === 'any' && a.arch === 'any' - ); - if (universal) { - artifact = universal; - } - } - } + const artifact = resolveArtifact(packageVersion.artifacts, queryOs, queryArch); if (!artifact) { - throw new NotFoundError('No artifact found for this version'); + throw new NotFoundError('No artifact found for the requested platform'); } // Log download diff --git a/apps/registry/tests/bundles.test.ts b/apps/registry/tests/bundles.test.ts index b880bf6..9a834c5 100644 --- a/apps/registry/tests/bundles.test.ts +++ b/apps/registry/tests/bundles.test.ts @@ -65,6 +65,7 @@ import { createMockPackageRepo, createMockStorage, createMockPrisma, + mockArtifact, mockPackage, mockVersion, mockVersionWithArtifacts, @@ -323,7 +324,7 @@ describe('Bundle Routes', () => { const res = await app.inject({ method: 'GET', - url: '/@test/mcp-server/versions/1.0.0/download', + url: '/@test/mcp-server/versions/1.0.0/download?os=linux&arch=x64', headers: { accept: 'application/json' }, }); @@ -341,7 +342,7 @@ describe('Bundle Routes', () => { const res = await app.inject({ method: 'GET', - url: '/@test/mcp-server/versions/latest/download', + url: '/@test/mcp-server/versions/latest/download?os=linux&arch=x64', headers: { accept: 'application/json' }, }); @@ -376,6 +377,109 @@ describe('Bundle Routes', () => { expect(res.statusCode).toBe(404); }); + + it('returns 400 when only os is provided without arch', async () => { + packageRepo.findByName.mockResolvedValue(mockPackage); + packageRepo.findVersionWithArtifacts.mockResolvedValue(mockVersionWithArtifacts); + + const res = await app.inject({ + method: 'GET', + url: '/@test/mcp-server/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.findByName.mockResolvedValue(mockPackage); + packageRepo.findVersionWithArtifacts.mockResolvedValue(mockVersionWithArtifacts); + + const res = await app.inject({ + method: 'GET', + url: '/@test/mcp-server/versions/1.0.0/download?arch=x64', + headers: { accept: 'application/json' }, + }); + + expect(res.statusCode).toBe(400); + }); + + it('returns 422 when os and arch are invalid enum values', async () => { + const res = await app.inject({ + method: 'GET', + url: '/@test/mcp-server/versions/1.0.0/download?os=foo&arch=bar', + headers: { accept: 'application/json' }, + }); + + expect(res.statusCode).toBe(422); + expect(packageRepo.findVersionWithArtifacts).not.toHaveBeenCalled(); + }); + + it('returns 200 when both os and arch match an artifact', async () => { + packageRepo.findByName.mockResolvedValue(mockPackage); + packageRepo.findVersionWithArtifacts.mockResolvedValue(mockVersionWithArtifacts); + + const res = await app.inject({ + method: 'GET', + url: '/@test/mcp-server/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.bundle.platform).toEqual({ os: 'linux', arch: 'x64' }); + }); + + it('returns 404 when both os and arch provided but no matching artifact', async () => { + packageRepo.findByName.mockResolvedValue(mockPackage); + packageRepo.findVersionWithArtifacts.mockResolvedValue(mockVersionWithArtifacts); + + const res = await app.inject({ + method: 'GET', + url: '/@test/mcp-server/versions/1.0.0/download?os=darwin&arch=arm64', + headers: { accept: 'application/json' }, + }); + + expect(res.statusCode).toBe(404); + }); + + it('returns 200 with any/any artifact when no platform params provided', async () => { + const anyArtifact = { + ...mockArtifact, + id: 'art-any', + os: 'any', + arch: 'any', + storagePath: '@test/mcp-server/1.0.0/any-any.mcpb', + }; + packageRepo.findByName.mockResolvedValue(mockPackage); + packageRepo.findVersionWithArtifacts.mockResolvedValue({ + ...mockVersion, + artifacts: [anyArtifact], + }); + + const res = await app.inject({ + method: 'GET', + url: '/@test/mcp-server/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('returns 404 when no platform params and no any/any artifact', async () => { + packageRepo.findByName.mockResolvedValue(mockPackage); + packageRepo.findVersionWithArtifacts.mockResolvedValue(mockVersionWithArtifacts); + + const res = await app.inject({ + method: 'GET', + url: '/@test/mcp-server/versions/1.0.0/download', + headers: { accept: 'application/json' }, + }); + + expect(res.statusCode).toBe(404); + }); }); // =========================================================================