From 1ccb6f93a7f85b86098b96daf546fa64308ebfdf Mon Sep 17 00:00:00 2001 From: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com> Date: Fri, 8 May 2026 17:12:43 -1000 Subject: [PATCH 1/5] feat(registry): compose ServerDetail from manifest, mount /v1/servers, deprecate /v1/bundles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a request-time composer that builds upstream MCP `ServerDetail` from a bundle's stored mcpb v0.4 manifest plus mpak-side data (downloads, provenance, certification, artifacts). The deprecated `PackageVersion.serverJson` column is no longer read — server.json metadata is fully derived from the manifest, so bundles that drop their `server.json` file in their next release keep working. Endpoints: - GET /v0.1/servers/{name} (existing path, now composer-backed) - GET /v0.1/servers/{name}/versions/{version} (existing path, now composer-backed) - GET /v0.1/servers/{name}/versions (list versions for a server) - GET /v0.1/servers/search?q=&limit=&cursor= (NEW) - GET /v0.1/servers (existing path, now composer-backed) - /v1/servers mounts the same handlers under mpak's /v1 family - {name} accepts both the npm-style scoped name (`@scope/pkg`) and the reverse-DNS form (`ai.nimblebrain/echo`); curated org map flips `@nimblebraininc/*` to `ai.nimblebrain/*`, and the mechanical default for any other org is `dev.mpak./` Compatibility: - Every /v1/bundles GET response now carries `Deprecation: true` plus `Link: ; rel="successor-version"` per RFC 8594. The bundle endpoints stay alive (CLI publish, OIDC announce still depend on them) but consumer-read shapes should migrate. - New repository methods `findPackageForServerLookup`, `findPackagesForServerListing`, and `findLatestCompletedScan` replace the `serverJson != null` filter; the legacy `findPackageWithServerJson*` methods are kept for any external caller still on the old shape. Schemas: new `ServerDetailSchema` (Zod) shipped in `@nimblebrain/mpak-schemas` covering Icon, Repository, transports, KeyValueInput, ServerPackage, and the full ServerDetail. Helpers `mechanicalReverseDnsName`, `defaultReverseDnsName`, and `resolveReverseDnsName` document the naming rules. Other: - `apps/registry` switched from `^0.2.0` to `workspace:*` for the schemas dep so monorepo builds pick up the new exports. Tests: 10 new composer cases cover required-field projection, author overrides, missing display_name fallback, XSS-guard on icon URLs, description truncation, malformed-input rejection, no-artifact path, and user_config → environmentVariables mapping. --- apps/registry/package.json | 2 +- .../src/db/repositories/package.repository.ts | 88 ++++ apps/registry/src/index.ts | 26 +- apps/registry/src/routes/mcp/v0.1/servers.ts | 465 ++++++++++-------- .../src/services/server-detail-composer.ts | 373 ++++++++++++++ apps/registry/tests/helpers.ts | 3 + .../tests/server-detail-composer.test.ts | 200 ++++++++ packages/schemas/src/index.ts | 3 + packages/schemas/src/server-detail.ts | 230 +++++++++ pnpm-lock.yaml | 11 +- 10 files changed, 1196 insertions(+), 205 deletions(-) create mode 100644 apps/registry/src/services/server-detail-composer.ts create mode 100644 apps/registry/tests/server-detail-composer.test.ts create mode 100644 packages/schemas/src/server-detail.ts diff --git a/apps/registry/package.json b/apps/registry/package.json index abea8df..43b57ca 100644 --- a/apps/registry/package.json +++ b/apps/registry/package.json @@ -46,7 +46,7 @@ "jose": "^6.1.3", "pg": "^8.13.1", "prisma": "^7.2.0", - "@nimblebrain/mpak-schemas": "^0.2.0", + "@nimblebrain/mpak-schemas": "workspace:*", "semver": "^7.6.3", "zod": "^4.3.4" }, diff --git a/apps/registry/src/db/repositories/package.repository.ts b/apps/registry/src/db/repositories/package.repository.ts index d2daa1d..3d7269e 100644 --- a/apps/registry/src/db/repositories/package.repository.ts +++ b/apps/registry/src/db/repositories/package.repository.ts @@ -373,6 +373,94 @@ export class PackageRepository { }); } + /** + * Find a package by its npm-style scoped name with all versions and + * artifacts. Variant of {@link findPackageWithServerJsonByName} that + * doesn't require the deprecated `serverJson` column to be set — + * server.json metadata is now composed from the manifest, so any + * indexed bundle can be served as an MCP `ServerDetail`. + */ + async findPackageForServerLookup( + name: string, + tx?: TransactionClient + ): Promise<(Package & { versions: (PackageVersion & { artifacts: Artifact[] })[] }) | null> { + const client = tx ?? getPrismaClient(); + return client.package.findUnique({ + where: { name }, + include: { + versions: { + orderBy: { publishedAt: 'desc' }, + include: { + artifacts: true, + }, + }, + }, + }); + } + + /** + * List packages with their latest version + artifacts. Variant of + * {@link findPackagesWithServerJson} without the deprecated + * `serverJson` filter — server.json metadata composes from the + * manifest, so every indexed package can be served. Honors a + * case-insensitive substring search on name / displayName / + * description. + */ + async findPackagesForServerListing( + filters: { search?: string }, + options: { skip?: number; take?: number }, + tx?: TransactionClient + ): Promise<{ packages: (Package & { versions: (PackageVersion & { artifacts: Artifact[] })[] })[]; total: number }> { + const client = tx ?? getPrismaClient(); + + const where: Prisma.PackageWhereInput = filters.search + ? { + OR: [ + { name: { contains: filters.search, mode: 'insensitive' } }, + { displayName: { contains: filters.search, mode: 'insensitive' } }, + { description: { contains: filters.search, mode: 'insensitive' } }, + ], + } + : {}; + + const [packages, total] = await Promise.all([ + client.package.findMany({ + where, + skip: options.skip, + take: options.take, + orderBy: { name: 'asc' }, + include: { + versions: { + orderBy: { publishedAt: 'desc' }, + take: 1, + include: { + artifacts: true, + }, + }, + }, + }), + client.package.count({ where }), + ]); + + return { packages, total }; + } + + /** + * Latest completed security scan for a version, when present. + * Returned for the registry's MTF certification fields (level, + * controls passed/failed/total) on `_meta["dev.mpak/registry"].certification`. + */ + async findLatestCompletedScan( + versionId: string, + tx?: TransactionClient + ): Promise { + const client = tx ?? getPrismaClient(); + return client.securityScan.findFirst({ + where: { versionId, status: 'completed' }, + orderBy: { startedAt: 'desc' }, + }); + } + // ==================== Package Version Methods ==================== /** diff --git a/apps/registry/src/index.ts b/apps/registry/src/index.ts index 672bdec..e731508 100644 --- a/apps/registry/src/index.ts +++ b/apps/registry/src/index.ts @@ -146,6 +146,18 @@ async function start() { max: 10, timeWindow: '1 minute', }); + // Mark every /v1/bundles response as deprecated per RFC 8594. The + // successor is the MCP-spec-aligned /v1/servers family; the legacy + // bundle-shape endpoints stay alive (announce / publish flow still + // depends on them) but consumers fetching read shapes should + // migrate. Skip POST /announce — that's a publish path, not a + // consumer-read path. + instance.addHook('onSend', async (request, reply) => { + if (request.method === 'GET' || request.method === 'HEAD') { + reply.header('Deprecation', 'true'); + reply.header('Link', '; rel="successor-version"'); + } + }); await instance.register(bundleRoutes); await instance.register(securityRoutes); // /@:scope/:package/security routes }, { prefix: '/v1/bundles' }); @@ -165,7 +177,10 @@ async function start() { await instance.register(skillRoutes); }, { prefix: '/v1/skills' }); - // MCP Registry API + // MCP Registry API — mounted at both /v0.1 (the upstream MCP Registry + // public API prefix) and /v1 (mpak's `/v1/...` family). Same routes, + // same handlers; consumers pick whichever URL space is conventional + // for their stack. await fastify.register(async (instance) => { await instance.register(cors, { origin: true, @@ -175,6 +190,15 @@ async function start() { await instance.register(mcpRegistryRoutes); }, { prefix: '/v0.1' }); + await fastify.register(async (instance) => { + await instance.register(cors, { + origin: true, + methods: ['GET', 'HEAD'], + credentials: false, + }); + await instance.register(mcpRegistryRoutes); + }, { prefix: '/v1' }); + // Health check endpoint fastify.get('/health', { schema: { diff --git a/apps/registry/src/routes/mcp/v0.1/servers.ts b/apps/registry/src/routes/mcp/v0.1/servers.ts index 3b8201b..03d894f 100644 --- a/apps/registry/src/routes/mcp/v0.1/servers.ts +++ b/apps/registry/src/routes/mcp/v0.1/servers.ts @@ -1,130 +1,177 @@ /** - * MCP Registry API v0.1 Routes + * MCP Registry API routes — serves upstream `ServerDetail` shapes for + * every package in the registry. Mounted at both `/v0.1/servers` (the + * stable MCP Registry public API prefix) and `/v1/servers` (mpak's + * `/v1/...` family for consumers that prefer the platform-versioned + * URL space). * - * DB-backed implementation: serves server.json metadata stored during - * bundle announce, with dynamically populated `packages[]` from artifacts. - * - * All routes are prefixed with /v0.1 + * Each `ServerDetail` is composed at request time from the bundle's + * stored `manifest.json` (canonical authoring surface) plus mpak-side + * registry data (downloads, provenance, certification, artifacts) by + * `composeServerDetail`. The deprecated `PackageVersion.serverJson` + * column is no longer read — server.json metadata is derived from the + * manifest so bundles that drop their `server.json` file keep working. */ -import type { FastifyPluginAsync } from 'fastify'; -import type { Artifact, PackageVersion } from '@prisma/client'; -import type { MCPServerDetail, MCPServerListResponse, MCPRegistryMetadata } from '../../../types.js'; +import type { FastifyInstance, FastifyPluginAsync } from 'fastify'; +import type { Artifact, Package, PackageVersion, SecurityScan } from '@prisma/client'; +import { composeServerDetail } from '../../../services/server-detail-composer.js'; +import { resolveReverseDnsName } from '@nimblebrain/mpak-schemas'; +import type { ServerDetail } from '@nimblebrain/mpak-schemas'; + +const REGISTRY_VERSION = 'v1.0.0'; -// Constants -const REGISTRY_VERSION = 'v0.1.0'; +type PackageWithVersions = Package & { + versions: (PackageVersion & { artifacts: Artifact[] })[]; +}; /** - * Build the `packages[]` array for a server entry from its artifacts. - * This populates the MCP Registry spec's package distribution info - * dynamically from the registry's artifact data. + * Project a security-scan row into the certification meta block carried + * on `_meta["dev.mpak/registry"].certification`. */ -function buildPackagesArray( - packageName: string, - version: PackageVersion & { artifacts: Artifact[] }, - serverJson: Record -): unknown[] { - if (!version.artifacts.length) return []; - - // Extract transport info from server.json _meta or default to stdio - const meta = serverJson['_meta'] as Record | undefined; - const transportType = (meta?.['transport'] as string) ?? 'stdio'; - - // Extract environment variables from server.json if present - const envVars = serverJson['environment_variables'] as unknown[] | undefined; +function scanToCertification(scan: SecurityScan | null): { + level: number; + levelName?: string | null; + controlsPassed?: number | null; + controlsFailed?: number | null; + controlsTotal?: number | null; +} | undefined { + if (!scan || scan.certificationLevel == null) return undefined; + return { + level: scan.certificationLevel, + controlsPassed: scan.controlsPassed, + controlsFailed: scan.controlsFailed, + controlsTotal: scan.controlsTotal, + }; +} - return version.artifacts.map((artifact) => ({ - registry_type: 'mcpb', - name: packageName, - version: version.version, - environment_variables: envVars ?? [], - package: { - registry_name: 'mpak', - name: packageName, - version: version.version, - file_sha256: artifact.digest.replace('sha256:', ''), - file_size: Number(artifact.sizeBytes), +async function buildServerDetailWithScan( + fastify: FastifyInstance, + pkg: PackageWithVersions, + version: PackageVersion & { artifacts: Artifact[] }, +): Promise { + const { packages: packageRepo } = fastify.repositories; + const scan = await packageRepo.findLatestCompletedScan(version.id); + return composeServerDetail({ + pkg: { + name: pkg.name, + latestVersion: pkg.latestVersion, + totalDownloads: pkg.totalDownloads, + githubRepo: pkg.githubRepo, }, - runtime: { - type: transportType, - ...(artifact.os !== 'any' || artifact.arch !== 'any' - ? { platform: { os: artifact.os, arch: artifact.arch } } - : {}), + version: { + version: version.version, + manifest: version.manifest, + publishedAt: version.publishedAt, + publishMethod: version.publishMethod, + provenance: version.provenance, + downloadCount: version.downloadCount, }, - })); + artifacts: version.artifacts, + certification: scanToCertification(scan), + }); } /** - * Build a full MCPServerDetail from a version's stored server.json and DB data. + * Resolve a server `name` parameter to a Package row. The parameter + * accepts both the npm-style scoped name (`@scope/pkg`) and the + * reverse-DNS form (`ai.nimblebrain/echo`). Mechanical reverse-DNS + * names map back to their npm origin via the documented rules; author + * overrides via `manifest._meta["dev.mpak/registry"].name` resolve via + * scan-then-match (cheap at current registry size; an indexed + * `reverseDnsName` column would replace this when scale demands it). */ -function buildServerDetail( - pkg: { - id: string; - name: string; - latestVersion: string; - description: string | null; - createdAt: Date | null; - updatedAt: Date | null; - }, - version: PackageVersion & { artifacts: Artifact[] }, - versionOverride?: string -): MCPServerDetail { - const serverJson = (version.serverJson ?? {}) as Record; - - // Start from the stored server.json as the base - const server: MCPServerDetail = { - ...serverJson, - name: (serverJson['name'] as string) ?? pkg.name, - version: versionOverride ?? (serverJson['version'] as string) ?? pkg.latestVersion, - description: (serverJson['description'] as string) ?? pkg.description ?? '', - }; - - // Populate packages[] dynamically from artifacts - server.packages = buildPackagesArray(pkg.name, version, serverJson); - - // Add registry metadata - if (!server._meta) { - server._meta = {}; +async function resolveByName( + fastify: FastifyInstance, + rawName: string, +): Promise { + const { packages: packageRepo } = fastify.repositories; + const decodedName = decodeURIComponent(rawName); + + // Direct npm-style lookup — fastest path. + const direct = await packageRepo.findPackageForServerLookup(decodedName); + if (direct) return direct; + + // Reverse-DNS form: derive candidate npm names and try each. + if (decodedName.includes('/') && !decodedName.startsWith('@')) { + for (const candidate of reverseDnsToNpmCandidates(decodedName)) { + const hit = await packageRepo.findPackageForServerLookup(candidate); + if (hit) { + // Confirm the package's resolved reverse-DNS name actually + // matches the requested input — guards against same-suffix + // collisions across orgs. + const latest = hit.versions[0]; + if (!latest) continue; + const manifestMeta = + ((latest.manifest as Record | null)?.['_meta'] as + | Record + | undefined) ?? null; + if (resolveReverseDnsName(hit.name, manifestMeta) === decodedName) { + return hit; + } + } + } } + return null; +} - const registryMeta: MCPRegistryMetadata = { - serverId: pkg.name, - versionId: version.id, - publishedAt: (version.publishedAt ?? pkg.createdAt ?? new Date()).toISOString(), - updatedAt: (pkg.updatedAt ?? new Date()).toISOString(), - isLatest: version.version === pkg.latestVersion, - }; - - server._meta['io.modelcontextprotocol.registry/official'] = registryMeta; - - return server; +/** + * Heuristic inverse of the mechanical reverse-DNS rule: + * `dev.mpak./` → `@/` + * `ai.nimblebrain/` → `@nimblebraininc/` (curated org map) + * `/` → `@/` (best-effort fallback) + */ +function reverseDnsToNpmCandidates(reverseDns: string): string[] { + const m = /^([a-zA-Z0-9.-]+)\/([a-zA-Z0-9._-]+)$/.exec(reverseDns); + if (!m) return []; + const namespace = (m[1] ?? '').toLowerCase(); + const name = (m[2] ?? '').toLowerCase(); + const out: string[] = []; + // Mechanical default: dev.mpak./ + if (namespace.startsWith('dev.mpak.')) { + out.push(`@${namespace.slice('dev.mpak.'.length)}/${name}`); + } else if (namespace === 'dev.mpak') { + out.push(name); + } + // Curated org overrides — keep aligned with ORG_REVERSE_DNS_MAP in schemas. + if (namespace === 'ai.nimblebrain') { + out.push(`@nimblebraininc/${name}`); + } + // Best-effort fallback: try the namespace's last segment as the npm scope. + const lastSegment = namespace.split('.').pop(); + if (lastSegment && lastSegment !== namespace) { + out.push(`@${lastSegment}/${name}`); + } + return out; } /** - * MCP Registry v0.1 routes (DB-backed) - * - * Reads server.json metadata from PackageVersion records (populated during - * bundle announce) and dynamically builds the `packages[]` array from - * artifact data. + * Build the route handlers shared between `/v0.1/servers` (MCP + * Registry public API prefix) and `/v1/servers` (mpak `/v1/...` + * family). Wrapped in a plugin so consumers can `fastify.register()` + * each prefix independently. */ export const mcpRegistryRoutes: FastifyPluginAsync = async (fastify) => { const { packages: packageRepo } = fastify.repositories; - // GET /v0.1 - API info endpoint + // GET / - API info endpoint fastify.get('/', async () => { return { name: 'mpak MCP Registry API', version: REGISTRY_VERSION, endpoints: { - listServers: '/v0.1/servers', - getServer: '/v0.1/servers/{name}/versions/{version}', - health: '/v0.1/health', + listServers: '/servers', + searchServers: '/servers/search', + getServer: '/servers/{name}', + getServerVersion: '/servers/{name}/versions/{version}', + listServerVersions: '/servers/{name}/versions', + health: '/health', }, documentation: '/docs', }; }); - // GET /v0.1/servers - List servers + // GET /servers - List servers (paginated) fastify.get<{ Querystring: { cursor?: string; @@ -135,14 +182,16 @@ export const mcpRegistryRoutes: FastifyPluginAsync = async (fastify) => { }; }>('/servers', { schema: { + tags: ['mcp-registry'], + description: 'List MCP servers (each entry is a ServerDetail per the upstream MCP registry spec)', querystring: { type: 'object', properties: { - cursor: { type: 'string', description: 'Pagination cursor' }, - limit: { type: 'string', description: 'Maximum number of results (default 100, max 500)' }, - search: { type: 'string', description: 'Case-insensitive search on server name' }, + cursor: { type: 'string', description: 'Pagination cursor (offset as string)' }, + limit: { type: 'string', description: 'Maximum results (default 100, max 500)' }, + search: { type: 'string', description: 'Case-insensitive substring search on name/displayName/description' }, version: { type: 'string', enum: ['latest'], description: 'Filter to latest versions only' }, - updated_since: { type: 'string', description: 'RFC3339 timestamp to filter recently updated servers' }, + updated_since: { type: 'string', description: 'RFC 3339 timestamp filter for recently updated servers' }, }, }, }, @@ -150,163 +199,191 @@ export const mcpRegistryRoutes: FastifyPluginAsync = async (fastify) => { const limit = Math.min(parseInt(request.query.limit ?? '100', 10), 500); const skip = request.query.cursor ? parseInt(request.query.cursor, 10) : 0; - const { packages, total } = await packageRepo.findPackagesWithServerJson( + const { packages, total } = await packageRepo.findPackagesForServerListing( { search: request.query.search }, { skip, take: limit } ); - // Build server details with dynamically populated packages[] - // Each package here is guaranteed to have at least one version with serverJson - let servers = packages - .filter((pkg) => pkg.versions[0]) - .map((pkg) => buildServerDetail(pkg, pkg.versions[0])); + const servers: ServerDetail[] = []; + for (const pkg of packages) { + const latest = pkg.versions[0]; + if (!latest) continue; + const detail = await buildServerDetailWithScan(fastify, pkg, latest); + if (detail) servers.push(detail); + } - // Apply updated_since filter (post-query since it depends on metadata) + let filtered = servers; if (request.query.updated_since) { const sinceDate = new Date(request.query.updated_since); - if (!isNaN(sinceDate.getTime())) { - servers = servers.filter(s => { - const meta = s._meta?.['io.modelcontextprotocol.registry/official'] as MCPRegistryMetadata | undefined; - if (meta?.updatedAt) { - return new Date(meta.updatedAt) >= sinceDate; + if (!Number.isNaN(sinceDate.getTime())) { + filtered = servers.filter((s) => { + const meta = s._meta?.['dev.mpak/registry'] as Record | undefined; + const publishedAt = meta?.['published_at']; + if (typeof publishedAt === 'string') { + return new Date(publishedAt) >= sinceDate; } return true; }); } } - const response: MCPServerListResponse = { - servers, - metadata: { - count: servers.length, - }, + const response: { servers: ServerDetail[]; metadata: { count: number; next_cursor?: string } } = { + servers: filtered, + metadata: { count: filtered.length }, }; - - // Add next cursor if there are more results const nextIdx = skip + limit; - if (nextIdx < total && response.metadata) { + if (nextIdx < total) { response.metadata.next_cursor = String(nextIdx); } - return response; }); - // GET /v0.1/servers/:name/versions/:version - Get server by name and version + // GET /servers/search - alias for /servers, exposed under the conventional name fastify.get<{ - Params: { name: string; version: string }; - }>('/servers/:name/versions/:version', { + Querystring: { q?: string; limit?: string; cursor?: string }; + }>('/servers/search', { schema: { - params: { + tags: ['mcp-registry'], + description: 'Search MCP servers by substring on name, displayName, or description', + querystring: { type: 'object', properties: { - name: { type: 'string', description: 'Server name (URL-encoded)' }, - version: { type: 'string', description: 'Server version or "latest"' }, + q: { type: 'string', description: 'Search query' }, + limit: { type: 'string', description: 'Maximum results (default 100, max 500)' }, + cursor: { type: 'string', description: 'Pagination cursor (offset as string)' }, }, - required: ['name', 'version'], }, }, - }, async (request, reply) => { - const decodedName = decodeURIComponent(request.params.name); - - const pkg = await packageRepo.findPackageWithServerJsonByName(decodedName); + }, async (request) => { + const limit = Math.min(parseInt(request.query.limit ?? '100', 10), 500); + const skip = request.query.cursor ? parseInt(request.query.cursor, 10) : 0; + const { packages, total } = await packageRepo.findPackagesForServerListing( + { search: request.query.q }, + { skip, take: limit } + ); + const servers: ServerDetail[] = []; + for (const pkg of packages) { + const latest = pkg.versions[0]; + if (!latest) continue; + const detail = await buildServerDetailWithScan(fastify, pkg, latest); + if (detail) servers.push(detail); + } + const response: { servers: ServerDetail[]; metadata: { count: number; next_cursor?: string } } = { + servers, + metadata: { count: servers.length }, + }; + const nextIdx = skip + limit; + if (nextIdx < total) { + response.metadata.next_cursor = String(nextIdx); + } + return response; + }); + // GET /servers/{name} - Latest ServerDetail for a server + fastify.get<{ + Params: { name: string }; + }>('/servers/:name', { + schema: { + tags: ['mcp-registry'], + description: 'Get the latest ServerDetail for a server. Accepts both npm-style (@scope/name) and reverse-DNS forms.', + params: { + type: 'object', + required: ['name'], + properties: { name: { type: 'string', description: 'URL-encoded server name' } }, + }, + }, + }, async (request, reply) => { + const pkg = await resolveByName(fastify, request.params.name); if (!pkg) { reply.code(404); - return { error: `Server '${decodedName}' not found` }; - } - - // Find a version with serverJson - const requestedVersion = request.params.version; - - if (requestedVersion === 'latest') { - // Find the latest version that has serverJson - const versionWithServerJson = pkg.versions.find(v => v.serverJson != null); - if (!versionWithServerJson) { - reply.code(404); - return { error: `Server '${decodedName}' not found` }; - } - return buildServerDetail(pkg, versionWithServerJson); + return { error: `Server '${decodeURIComponent(request.params.name)}' not found` }; } - - // Find the specific version - const matchedVersion = pkg.versions.find(v => v.version === requestedVersion); - if (!matchedVersion || !matchedVersion.serverJson) { + const latest = pkg.versions[0]; + if (!latest) { reply.code(404); - return { error: `Version '${requestedVersion}' not found for server '${decodedName}'` }; + return { error: `Server '${pkg.name}' has no versions` }; } - - return buildServerDetail(pkg, matchedVersion, requestedVersion); + const detail = await buildServerDetailWithScan(fastify, pkg, latest); + if (!detail) { + reply.code(500); + return { error: `Server '${pkg.name}' manifest could not be projected` }; + } + return detail; }); - // GET /v0.1/servers/:name/versions - List all versions for a server + // GET /servers/{name}/versions/{version} - Version-specific ServerDetail fastify.get<{ - Params: { name: string }; - }>('/servers/:name/versions', { + Params: { name: string; version: string }; + }>('/servers/:name/versions/:version', { schema: { + tags: ['mcp-registry'], + description: 'Get a version-specific ServerDetail. Use "latest" for the most recent version.', params: { type: 'object', + required: ['name', 'version'], properties: { - name: { type: 'string', description: 'Server name (URL-encoded)' }, + name: { type: 'string', description: 'URL-encoded server name' }, + version: { type: 'string', description: 'Server version, or "latest"' }, }, - required: ['name'], }, }, }, async (request, reply) => { - const decodedName = decodeURIComponent(request.params.name); - - const pkg = await packageRepo.findPackageWithServerJsonByName(decodedName); - - // Check that at least one version has serverJson - const hasServerJson = pkg?.versions.some(v => v.serverJson != null); - if (!pkg || !hasServerJson) { + const pkg = await resolveByName(fastify, request.params.name); + if (!pkg) { reply.code(404); - return { error: `Server '${decodedName}' not found` }; + return { error: `Server '${decodeURIComponent(request.params.name)}' not found` }; } - - return { - name: decodedName, - versions: pkg.versions - .filter(v => v.serverJson != null) - .map(v => ({ - version: v.version, - published_at: v.publishedAt, - is_latest: v.version === pkg.latestVersion, - })), - }; + const requestedVersion = request.params.version; + const matchedVersion = + requestedVersion === 'latest' + ? pkg.versions[0] + : pkg.versions.find((v) => v.version === requestedVersion); + if (!matchedVersion) { + reply.code(404); + return { error: `Version '${requestedVersion}' not found for server '${pkg.name}'` }; + } + const detail = await buildServerDetailWithScan(fastify, pkg, matchedVersion); + if (!detail) { + reply.code(500); + return { error: `Server '${pkg.name}' version '${matchedVersion.version}' manifest could not be projected` }; + } + return detail; }); - // GET /v0.1/servers/:server_id - Legacy endpoint for backwards compatibility + // GET /servers/{name}/versions - List all versions for a server fastify.get<{ - Params: { server_id: string }; - }>('/servers/:server_id', { + Params: { name: string }; + }>('/servers/:name/versions', { schema: { + tags: ['mcp-registry'], + description: 'List every version of a server (newest first).', params: { type: 'object', - properties: { - server_id: { type: 'string' }, - }, - required: ['server_id'], + required: ['name'], + properties: { name: { type: 'string', description: 'URL-encoded server name' } }, }, }, }, async (request, reply) => { - const decodedName = decodeURIComponent(request.params.server_id); - - const pkg = await packageRepo.findPackageWithServerJsonByName(decodedName); - - // Find latest version with serverJson - const versionWithServerJson = pkg?.versions.find(v => v.serverJson != null); - if (!pkg || !versionWithServerJson) { + const pkg = await resolveByName(fastify, request.params.name); + if (!pkg || pkg.versions.length === 0) { reply.code(404); - return { error: `Server '${decodedName}' not found` }; + return { + error: `Server '${decodeURIComponent(request.params.name)}' not found`, + }; } - - return buildServerDetail(pkg, versionWithServerJson); + return { + name: pkg.name, + versions: pkg.versions.map((v) => ({ + version: v.version, + published_at: v.publishedAt, + is_latest: v.version === pkg.latestVersion, + })), + }; }); - // GET /v0.1/health - Health check + // GET /health - Health check fastify.get('/health', async () => { - const { total } = await packageRepo.findPackagesWithServerJson({}, { take: 0 }); - + const { total } = await packageRepo.findPackagesForServerListing({}, { take: 0 }); return { status: total > 0 ? 'healthy' : 'degraded', servers_count: total, diff --git a/apps/registry/src/services/server-detail-composer.ts b/apps/registry/src/services/server-detail-composer.ts new file mode 100644 index 0000000..543f15b --- /dev/null +++ b/apps/registry/src/services/server-detail-composer.ts @@ -0,0 +1,373 @@ +/** + * Compose an upstream MCP `ServerDetail` from a bundle's stored + * mcpb v0.4 manifest plus mpak-side registry data (downloads, + * provenance, certification, artifacts). + * + * Pure projection — no I/O, no async work. Every output field has + * exactly one source: + * + * $schema constant URL pointer to the upstream draft schema + * name manifest._meta["dev.mpak/registry"].name override, else + * mechanical default (`dev.mpak./`, with + * curated org map applied first when applicable) + * title manifest.display_name ?? manifest.name + * description manifest.description (truncated to 100 chars; upstream cap) + * version manifest.version (PackageVersion.version on the rare + * occasion the two diverge) + * websiteUrl manifest.homepage + * repository manifest.repository + * icons[] manifest.icons[] when set, else [{ src: manifest.icon }] + * when icon is set, else omitted; non-http(s) icons dropped + * packages[] one entry per platform artifact (registryType: "mpak", + * identifier: manifest.name, version, transport: stdio, + * environmentVariables derived from manifest.user_config), + * plus fileSha256 from each artifact + * _meta manifest._meta verbatim + dev.mpak/registry block + * (npmName, downloads, published_at, provenance, + * certification, artifacts[]) + * + * Validates the result against the Zod `ServerDetailSchema` before + * returning. The throw-variant fails loud with the issue list when the + * projection produces an invalid record — operator-side bug, surfaces + * in logs, never reaches consumers. + */ + +import type { Artifact, Package as DbPackage, PackageVersion } from "@prisma/client"; +import { + resolveReverseDnsName, + type ServerDetail, + ServerDetailSchema, + type ServerPackage, +} from "@nimblebrain/mpak-schemas"; + +const UPSTREAM_SCHEMA_URL = + "https://raw.githubusercontent.com/modelcontextprotocol/registry/main/docs/reference/server-json/draft/server.schema.json"; + +/** + * Inputs to {@link composeServerDetail}. Carries everything mpak knows + * about a single bundle version. + */ +export interface ComposerInput { + /** + * Package row. Only `name` and `latestVersion` are used by the + * projection itself; the rest of the row is here so callers can + * pass the live record without additional selection. + */ + pkg: Pick & { + githubRepo?: string | null; + }; + /** + * The version row. `manifest` is the canonical authoring surface; + * `provenance`, `publishedAt`, `releaseTag`, etc. enrich the + * dev.mpak/registry meta block. + */ + version: Pick< + PackageVersion, + "version" | "manifest" | "publishedAt" | "publishMethod" | "provenance" | "downloadCount" + >; + /** Per-platform artifacts. Empty array is fine — packages[] is omitted. */ + artifacts: Pick[]; + /** Top certification record for this version, if any. */ + certification?: { + level: number; + levelName?: string | null; + controlsPassed?: number | null; + controlsFailed?: number | null; + controlsTotal?: number | null; + } | null; +} + +/** + * Project a bundle into the upstream `ServerDetail` shape and validate + * the output against `ServerDetailSchema`. + * + * Returns the validated `ServerDetail` on success, or null if the + * manifest is too malformed to project (missing required fields, + * upstream schema-rejected). Callers handle null by logging the + * rejection (operator-facing) — consumers never see a half-projected + * record. + */ +export function composeServerDetail(input: ComposerInput): ServerDetail | null { + const manifest = (input.version.manifest ?? {}) as Record; + + const description = stringField(manifest, "description") ?? input.pkg.name; + const truncatedDescription = truncate(description, 100); + const version = + stringField(manifest, "version") ?? input.version.version ?? input.pkg.latestVersion ?? "0.0.0"; + + const manifestMeta = (manifest["_meta"] as Record | undefined) ?? null; + const reverseDnsName = resolveReverseDnsName(input.pkg.name, manifestMeta); + + const display = stringField(manifest, "display_name"); + const title = display && display.trim().length > 0 ? display.trim() : input.pkg.name; + + const detail: Record = { + $schema: UPSTREAM_SCHEMA_URL, + name: reverseDnsName, + title, + description: truncatedDescription, + version, + }; + + const homepage = stringField(manifest, "homepage"); + if (homepage && isHttpUrl(homepage)) { + detail["websiteUrl"] = homepage; + } + + const repository = projectRepository(manifest, input.pkg.githubRepo); + if (repository) detail["repository"] = repository; + + const icons = projectIcons(manifest); + if (icons.length > 0) detail["icons"] = icons; + + const packages = projectPackages(input, manifest); + if (packages.length > 0) detail["packages"] = packages; + + detail["_meta"] = composeMeta(input, manifest, manifestMeta); + + const result = ServerDetailSchema.safeParse(detail); + if (!result.success) { + return null; + } + return result.data; +} + +/** + * Same as {@link composeServerDetail} but throws (with the Zod issue + * list) instead of returning null. Intended for ingest-time validation + * where a malformed projection should fail loudly. + */ +export function composeServerDetailOrThrow(input: ComposerInput): ServerDetail { + const manifest = (input.version.manifest ?? {}) as Record; + + const description = stringField(manifest, "description") ?? input.pkg.name; + const truncatedDescription = truncate(description, 100); + const version = + stringField(manifest, "version") ?? input.version.version ?? input.pkg.latestVersion ?? "0.0.0"; + + const manifestMeta = (manifest["_meta"] as Record | undefined) ?? null; + const reverseDnsName = resolveReverseDnsName(input.pkg.name, manifestMeta); + + const display = stringField(manifest, "display_name"); + const title = display && display.trim().length > 0 ? display.trim() : input.pkg.name; + + const detail: Record = { + $schema: UPSTREAM_SCHEMA_URL, + name: reverseDnsName, + title, + description: truncatedDescription, + version, + }; + + const homepage = stringField(manifest, "homepage"); + if (homepage && isHttpUrl(homepage)) detail["websiteUrl"] = homepage; + const repository = projectRepository(manifest, input.pkg.githubRepo); + if (repository) detail["repository"] = repository; + const icons = projectIcons(manifest); + if (icons.length > 0) detail["icons"] = icons; + const packages = projectPackages(input, manifest); + if (packages.length > 0) detail["packages"] = packages; + detail["_meta"] = composeMeta(input, manifest, manifestMeta); + + return ServerDetailSchema.parse(detail); +} + +// ── building blocks ──────────────────────────────────────────────────── + +function projectRepository( + manifest: Record, + fallbackGithubRepo: string | null | undefined, +): { url: string; source: string; id?: string; subfolder?: string } | null { + const repo = manifest["repository"]; + if (repo && typeof repo === "object") { + const url = stringField(repo as Record, "url"); + if (url && isHttpUrl(url)) { + return { url, source: "github" }; + } + } + // Fall back to the package's tracked GitHub repo when the manifest + // omits it — keeps source-link visibility on legacy bundles whose + // manifests pre-date the repository field. + if (fallbackGithubRepo) { + const url = `https://github.com/${fallbackGithubRepo}`; + return { url, source: "github" }; + } + return null; +} + +function projectIcons(manifest: Record): { src: string; sizes?: string[] }[] { + const icons = manifest["icons"]; + if (Array.isArray(icons)) { + return icons + .map((i) => { + if (!i || typeof i !== "object") return null; + const src = stringField(i as Record, "src"); + if (!src || !isHttpUrl(src)) return null; + const out: { src: string; sizes?: string[] } = { src }; + const sizes = (i as Record)["sizes"]; + if (Array.isArray(sizes) && sizes.every((s) => typeof s === "string")) { + out.sizes = sizes as string[]; + } + return out; + }) + .filter((i): i is { src: string; sizes?: string[] } => i !== null); + } + // Single-icon legacy field. + const single = stringField(manifest, "icon"); + if (single && isHttpUrl(single)) { + return [{ src: single, sizes: ["any"] }]; + } + return []; +} + +function projectPackages(input: ComposerInput, manifest: Record): ServerPackage[] { + const envVars = projectEnvironmentVariables(manifest); + // Per-platform artifact download URLs live in + // `_meta.dev.mpak/registry.artifacts[]`, NOT in packages[].identifier + // — `identifier` is the package-registry name (the npm-style scoped + // name), not the artifact location. We emit one packages[] entry per + // artifact carrying the file hash and a stdio transport marker; + // consumers that care about platform selection read the meta block. + if (input.artifacts.length === 0) { + return [ + { + registryType: "mpak", + identifier: input.pkg.name, + version: input.version.version, + transport: { type: "stdio" }, + ...(envVars.length > 0 ? { environmentVariables: envVars } : {}), + }, + ]; + } + return input.artifacts.map((art) => ({ + registryType: "mpak", + identifier: input.pkg.name, + version: input.version.version, + transport: { type: "stdio" } as const, + fileSha256: art.digest.replace(/^sha256:/, ""), + ...(envVars.length > 0 ? { environmentVariables: envVars } : {}), + })); +} + +/** + * Project `manifest.user_config` into upstream KeyValueInput entries + * for `packages[].environmentVariables[]`. The env-var `name` comes + * from `manifest.server.mcp_config.env` mapping when present (the + * manifest declares which user_config field maps to which env var); + * falls back to the field's upper-snake-cased key. + */ +function projectEnvironmentVariables( + manifest: Record, +): { name: string; description?: string; isSecret?: boolean; isRequired?: boolean; default?: string }[] { + const userConfig = manifest["user_config"]; + if (!userConfig || typeof userConfig !== "object") return []; + const envMap = readEnvMap(manifest); + const out: ReturnType = []; + for (const [field, raw] of Object.entries(userConfig)) { + if (!raw || typeof raw !== "object") continue; + const f = raw as Record; + const envName = envMap[field] ?? field.toUpperCase(); + const entry: ReturnType[number] = { name: envName }; + const description = stringField(f, "description"); + if (description) entry.description = description; + if (typeof f["sensitive"] === "boolean") entry.isSecret = f["sensitive"] as boolean; + if (typeof f["required"] === "boolean") entry.isRequired = f["required"] as boolean; + const def = f["default"]; + if (typeof def === "string") entry.default = def; + else if (typeof def === "number" || typeof def === "boolean") entry.default = String(def); + out.push(entry); + } + return out; +} + +/** + * Read the manifest's `server.mcp_config.env` map. Each value is a + * placeholder string like `"${user_config.api_key}"`; we extract the + * field name on the right side so we can map field → env var name. + */ +function readEnvMap(manifest: Record): Record { + const server = manifest["server"]; + if (!server || typeof server !== "object") return {}; + const mcpConfig = (server as Record)["mcp_config"]; + if (!mcpConfig || typeof mcpConfig !== "object") return {}; + const env = (mcpConfig as Record)["env"]; + if (!env || typeof env !== "object") return {}; + const out: Record = {}; + for (const [envName, value] of Object.entries(env)) { + if (typeof value !== "string") continue; + const m = /\$\{?user_config\.([a-zA-Z0-9_]+)\}?/.exec(value); + if (m?.[1]) { + out[m[1]] = envName; + } + } + return out; +} + +/** + * 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[] + */ +function composeMeta( + input: ComposerInput, + _manifest: Record, + manifestMeta: Record | null, +): Record { + const meta: Record = { ...(manifestMeta ?? {}) }; + const mpakBlock: Record = { + npmName: input.pkg.name, + }; + // Carry author overrides under `dev.mpak/registry` (e.g. their reverse-DNS + // `name`) verbatim alongside our enrichment. + const authorMpak = manifestMeta?.["dev.mpak/registry"]; + if (authorMpak && typeof authorMpak === "object") { + Object.assign(mpakBlock, authorMpak); + mpakBlock["npmName"] = input.pkg.name; // mpak source-of-truth wins + } + const downloads = Number(input.pkg.totalDownloads ?? input.version.downloadCount ?? 0); + if (Number.isFinite(downloads)) mpakBlock["downloads"] = downloads; + if (input.version.publishedAt) { + mpakBlock["published_at"] = input.version.publishedAt.toISOString(); + } + if (input.version.publishMethod) { + mpakBlock["publishMethod"] = input.version.publishMethod; + } + if (input.version.provenance) { + mpakBlock["provenance"] = input.version.provenance; + } + if (input.certification) { + mpakBlock["certification"] = input.certification; + } + if (input.artifacts.length > 0) { + mpakBlock["artifacts"] = input.artifacts.map((a) => ({ + platform: { os: a.os, arch: a.arch }, + url: a.sourceUrl, + sha256: a.digest.replace(/^sha256:/, ""), + size: Number(a.sizeBytes), + })); + } + meta["dev.mpak/registry"] = mpakBlock; + return meta; +} + +// ── small helpers ────────────────────────────────────────────────────── + +function stringField(obj: Record, key: string): string | undefined { + const v = obj[key]; + return typeof v === "string" ? v : undefined; +} + +function truncate(s: string, max: number): string { + if (s.length <= max) return s; + return `${s.slice(0, max - 1)}…`; +} + +function isHttpUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} diff --git a/apps/registry/tests/helpers.ts b/apps/registry/tests/helpers.ts index 415d27d..085a01f 100644 --- a/apps/registry/tests/helpers.ts +++ b/apps/registry/tests/helpers.ts @@ -34,6 +34,9 @@ export function createMockPackageRepo() { findByCreator: vi.fn(), findPackagesWithServerJson: vi.fn(), findPackageWithServerJsonByName: vi.fn(), + findPackageForServerLookup: vi.fn(), + findPackagesForServerListing: vi.fn(), + findLatestCompletedScan: vi.fn(), findVersionWithLatestScan: vi.fn(), findVersion: vi.fn(), getVersions: vi.fn(), diff --git a/apps/registry/tests/server-detail-composer.test.ts b/apps/registry/tests/server-detail-composer.test.ts new file mode 100644 index 0000000..e6b5fa9 --- /dev/null +++ b/apps/registry/tests/server-detail-composer.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest'; +import { + composeServerDetail, + composeServerDetailOrThrow, + type ComposerInput, +} from '../src/services/server-detail-composer.js'; + +const FULL_MANIFEST = { + manifest_version: '0.4', + name: '@nimblebraininc/echo', + version: '0.1.6', + display_name: 'Echo', + description: 'Echo server for testing and debugging MCP connections', + homepage: 'https://nimblebrain.ai', + icon: 'https://static.nimblebrain.ai/icons/echo.png', + repository: { type: 'git', url: 'https://github.com/NimbleBrainInc/mcp-echo' }, + server: { + type: 'python', + entry_point: 'mcp_echo.server', + mcp_config: { + command: 'python', + args: ['-m', 'mcp_echo.server'], + env: { IPINFO_API_KEY: '${user_config.api_key}' }, + }, + }, + user_config: { + api_key: { + type: 'string', + description: 'IPInfo API token', + sensitive: true, + required: false, + }, + }, + _meta: { + 'org.mpaktrust': { + mtf_version: '0.1', + permissions: { native: 'none' }, + }, + }, +}; + +function input(over: Partial = {}): ComposerInput { + return { + pkg: { + name: '@nimblebraininc/echo', + latestVersion: '0.1.6', + totalDownloads: BigInt(412), + githubRepo: 'NimbleBrainInc/mcp-echo', + ...over.pkg, + }, + version: { + version: '0.1.6', + manifest: FULL_MANIFEST, + publishedAt: new Date('2026-04-09T12:00:00Z'), + publishMethod: 'oidc', + provenance: { provider: 'github_oidc', repository: 'NimbleBrainInc/mcp-echo' }, + downloadCount: BigInt(6), + ...over.version, + }, + artifacts: over.artifacts ?? [ + { + os: 'linux', + arch: 'x64', + digest: 'sha256:7352521191f69533f3e05fd905dea30ed43c329c930ee9840ccf9796a531f41b', + sizeBytes: BigInt(17455747), + sourceUrl: 'https://github.com/NimbleBrainInc/mcp-echo/releases/download/v0.1.6/x.mcpb', + storagePath: '@nimblebraininc/echo/0.1.6/linux-x64.mcpb', + }, + ], + certification: over.certification ?? { level: 1, controlsPassed: 15, controlsFailed: 1, controlsTotal: 16 }, + }; +} + +describe('composeServerDetail', () => { + it('projects every required field from the manifest', () => { + const detail = composeServerDetail(input()); + expect(detail).not.toBeNull(); + // Mechanical reverse-DNS uses the curated org map for nimblebraininc. + expect(detail?.name).toBe('ai.nimblebrain/echo'); + expect(detail?.title).toBe('Echo'); + expect(detail?.description).toBe('Echo server for testing and debugging MCP connections'); + expect(detail?.version).toBe('0.1.6'); + expect(detail?.websiteUrl).toBe('https://nimblebrain.ai'); + expect(detail?.repository?.url).toBe('https://github.com/NimbleBrainInc/mcp-echo'); + expect(detail?.repository?.source).toBe('github'); + expect(detail?.icons?.[0]?.src).toBe('https://static.nimblebrain.ai/icons/echo.png'); + expect(detail?.packages?.[0]?.identifier).toBe('@nimblebraininc/echo'); + expect(detail?.packages?.[0]?.transport).toEqual({ type: 'stdio' }); + expect(detail?.packages?.[0]?.environmentVariables?.[0]?.name).toBe('IPINFO_API_KEY'); + expect(detail?.packages?.[0]?.environmentVariables?.[0]?.isSecret).toBe(true); + }); + + it('carries author _meta verbatim and adds dev.mpak/registry meta', () => { + const detail = composeServerDetail(input()); + expect(detail?._meta?.['org.mpaktrust']).toEqual({ + mtf_version: '0.1', + permissions: { native: 'none' }, + }); + const mpakMeta = detail?._meta?.['dev.mpak/registry'] as Record; + expect(mpakMeta['npmName']).toBe('@nimblebraininc/echo'); + expect(mpakMeta['downloads']).toBe(412); + expect(mpakMeta['published_at']).toBe('2026-04-09T12:00:00.000Z'); + expect(mpakMeta['publishMethod']).toBe('oidc'); + expect(mpakMeta['certification']).toEqual({ + level: 1, + controlsPassed: 15, + controlsFailed: 1, + controlsTotal: 16, + }); + expect(Array.isArray(mpakMeta['artifacts'])).toBe(true); + expect((mpakMeta['artifacts'] as unknown[])[0]).toMatchObject({ + platform: { os: 'linux', arch: 'x64' }, + url: 'https://github.com/NimbleBrainInc/mcp-echo/releases/download/v0.1.6/x.mcpb', + sha256: '7352521191f69533f3e05fd905dea30ed43c329c930ee9840ccf9796a531f41b', + size: 17455747, + }); + }); + + it('honors author reverse-DNS name override at _meta["dev.mpak/registry"].name', () => { + const m = { + ...FULL_MANIFEST, + _meta: { 'dev.mpak/registry': { name: 'com.acme/custom-name' } }, + }; + const detail = composeServerDetail(input({ version: { ...input().version, manifest: m } })); + expect(detail?.name).toBe('com.acme/custom-name'); + }); + + it('falls back to the npm name for title when display_name is missing', () => { + const { display_name: _ignore, ...rest } = FULL_MANIFEST; + const detail = composeServerDetail(input({ version: { ...input().version, manifest: rest } })); + expect(detail?.title).toBe('@nimblebraininc/echo'); + }); + + it('drops icons with non-http(s) schemes (XSS guard for downstream )', () => { + const m = { ...FULL_MANIFEST, icon: 'javascript:alert(1)' }; + const detail = composeServerDetail(input({ version: { ...input().version, manifest: m } })); + expect(detail?.icons).toBeUndefined(); + }); + + it('truncates description longer than the upstream 100-char cap', () => { + const long = 'x'.repeat(150); + const m = { ...FULL_MANIFEST, description: long }; + const detail = composeServerDetail(input({ version: { ...input().version, manifest: m } })); + expect(detail?.description.length).toBe(100); + expect(detail?.description.endsWith('…')).toBe(true); + }); + + it('returns null when the manifest is too malformed to project (invalid name)', () => { + // Mechanical reverse-DNS for a missing name would fail the upstream + // pattern; ServerDetailSchema rejects, composer returns null. + const m = { ...FULL_MANIFEST }; + const detail = composeServerDetail( + input({ + pkg: { + name: '', + latestVersion: '0.1.6', + totalDownloads: BigInt(0), + githubRepo: null, + }, + version: { ...input().version, manifest: m }, + }), + ); + expect(detail).toBeNull(); + }); + + it('composeServerDetailOrThrow throws on invalid input (loud failure for ingest path)', () => { + expect(() => + composeServerDetailOrThrow( + input({ + pkg: { + name: '', + latestVersion: '0.1.6', + totalDownloads: BigInt(0), + githubRepo: null, + }, + }), + ), + ).toThrow(); + }); + + it('handles bundles with no artifacts (single placeholder package entry)', () => { + const detail = composeServerDetail(input({ artifacts: [] })); + expect(detail?.packages?.length).toBe(1); + expect(detail?.packages?.[0]?.identifier).toBe('@nimblebraininc/echo'); + expect(detail?.packages?.[0]?.fileSha256).toBeUndefined(); + }); + + it('maps user_config to environmentVariables using the manifest env map', () => { + const detail = composeServerDetail(input()); + const env = detail?.packages?.[0]?.environmentVariables; + expect(env).toEqual([ + { + name: 'IPINFO_API_KEY', + description: 'IPInfo API token', + isSecret: true, + isRequired: false, + }, + ]); + }); +}); diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index f017ef8..bd13a6c 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -16,6 +16,9 @@ export * from "./mpak-json.js"; // Versioned manifest schemas export * from "./manifest.js"; +// MCP registry server.json (ServerDetail) schema +export * from "./server-detail.js"; + // Cache metadata export * from "./cache.js"; diff --git a/packages/schemas/src/server-detail.ts b/packages/schemas/src/server-detail.ts new file mode 100644 index 0000000..b7e65d7 --- /dev/null +++ b/packages/schemas/src/server-detail.ts @@ -0,0 +1,230 @@ +import { z } from "zod"; + +/** + * Upstream MCP Registry `ServerDetail` shape — the canonical wire format + * mpak emits for `/v0.1/servers/...` and `/v1/servers/...`. + * + * Composed mechanically from the bundle's `manifest.json` plus mpak-side + * registry data (downloads, provenance, certification, artifacts). The + * composer lives in `apps/registry/src/services/server-detail-composer.ts`. + * + * Validation is stricter than the upstream schema in places where mpak + * has stronger guarantees (e.g. names always carry the reverse-DNS slash); + * relaxed nowhere — anything that ajv-validates against the upstream draft + * also passes here. + * + * Reference: https://raw.githubusercontent.com/modelcontextprotocol/registry/main/docs/reference/server-json/draft/server.schema.json + */ + +// ============================================================================= +// Building blocks (Icon, Repository, Transports, Inputs, Package) +// ============================================================================= + +/** Sized icon descriptor. Upstream `Icon` type. */ +export const IconSchema = z.object({ + src: z.string().url().max(255), + mimeType: z + .enum(["image/png", "image/jpeg", "image/jpg", "image/svg+xml", "image/webp"]) + .optional(), + sizes: z.array(z.string().regex(/^(\d+x\d+|any)$/)).optional(), + theme: z.enum(["light", "dark"]).optional(), +}); +export type Icon = z.infer; + +/** Repository pointer. Upstream `Repository` type. */ +export const RepositorySchema = z.object({ + url: z.string().url(), + source: z.string(), + id: z.string().optional(), + subfolder: z.string().optional(), +}); +export type Repository = z.infer; + +/** Stdio transport — the only `type` field is required at the wire. */ +export const StdioTransportSchema = z.object({ + type: z.literal("stdio"), +}); +export type StdioTransport = z.infer; + +/** Free-form Input shared by env vars, args, and remote variables. */ +export const InputSchema = z.object({ + description: z.string().optional(), + default: z.string().optional(), + format: z.enum(["string", "number", "boolean", "filepath"]).optional(), + isRequired: z.boolean().optional(), + isSecret: z.boolean().optional(), + placeholder: z.string().optional(), + value: z.string().optional(), + choices: z.array(z.string()).optional(), +}); +export type Input = z.infer; + +/** KeyValueInput — Input that names an env var or HTTP header. */ +export const KeyValueInputSchema = InputSchema.extend({ + name: z.string(), + variables: z.record(z.string(), InputSchema).optional(), +}); +export type KeyValueInput = z.infer; + +/** Streamable HTTP transport (preferred MCP-over-HTTP profile). */ +export const StreamableHttpTransportSchema = z.object({ + type: z.literal("streamable-http"), + url: z.string().regex(/^(https?:\/\/[^\s]+|\{[a-zA-Z_][a-zA-Z0-9_]*\}[^\s]*)$/), + headers: z.array(KeyValueInputSchema).optional(), +}); + +/** Server-Sent Events transport (legacy MCP-over-SSE profile). */ +export const SseTransportSchema = z.object({ + type: z.literal("sse"), + url: z.string().regex(/^(https?:\/\/[^\s]+|\{[a-zA-Z_][a-zA-Z0-9_]*\}[^\s]*)$/), + headers: z.array(KeyValueInputSchema).optional(), +}); + +/** Local transport — what packages[] declare. */ +export const LocalTransportSchema = z.union([ + StdioTransportSchema, + StreamableHttpTransportSchema, + SseTransportSchema, +]); +export type LocalTransport = z.infer; + +/** Remote transport — what remotes[] declare; can declare URL variables. */ +export const RemoteTransportSchema = z.union([ + StreamableHttpTransportSchema.extend({ + variables: z.record(z.string(), InputSchema).optional(), + }), + SseTransportSchema.extend({ + variables: z.record(z.string(), InputSchema).optional(), + }), +]); +export type RemoteTransport = z.infer; + +/** + * Package distribution descriptor — what the server.json `packages[]` + * array contains. Named `ServerPackageSchema` (rather than `Package`) + * to avoid colliding with the bundle-registry `Package` type already + * exported from `api-responses.ts`. + */ +export const ServerPackageSchema = z.object({ + registryType: z.string(), + identifier: z.string(), + transport: LocalTransportSchema, + version: z.string().min(1).max(255).optional(), + registryBaseUrl: z.string().url().optional(), + fileSha256: z + .string() + .regex(/^[a-f0-9]{64}$/) + .optional(), + runtimeHint: z.string().optional(), + runtimeArguments: z.array(z.unknown()).optional(), + packageArguments: z.array(z.unknown()).optional(), + environmentVariables: z.array(KeyValueInputSchema).optional(), +}); +export type ServerPackage = z.infer; + +// ============================================================================= +// ServerDetail +// ============================================================================= + +/** Reverse-DNS pattern for `name`. Upstream pattern, exactly. */ +export const SERVER_NAME_PATTERN = /^[a-zA-Z0-9.-]+\/[a-zA-Z0-9._-]+$/; + +/** + * Upstream `ServerDetail`. Required fields: name, description, version. + * `_meta` accepts arbitrary reverse-DNS-namespaced extension keys. + */ +export const ServerDetailSchema = z.object({ + $schema: z.string().url().optional(), + name: z.string().min(3).max(200).regex(SERVER_NAME_PATTERN), + title: z.string().min(1).max(100).optional(), + description: z.string().min(1).max(100), + version: z.string().min(1).max(255), + websiteUrl: z.string().url().optional(), + repository: RepositorySchema.optional(), + icons: z.array(IconSchema).optional(), + packages: z.array(ServerPackageSchema).optional(), + remotes: z.array(RemoteTransportSchema).optional(), + _meta: z.record(z.string(), z.unknown()).optional(), +}); +export type ServerDetail = z.infer; + +/** + * Wrapper for paginated `/v1/servers/search` responses. Mirrors the + * `/v0.1/servers` shape so consumers can treat the two endpoints + * interchangeably while the upstream MCP registry's search shape is + * still being defined. + */ +export const ServerListResponseSchema = z.object({ + servers: z.array(ServerDetailSchema), + metadata: z + .object({ + count: z.number().int().nonnegative().optional(), + next_cursor: z.string().optional(), + }) + .optional(), +}); +export type ServerListResponse = z.infer; + +// ============================================================================= +// Helpers — reverse-DNS naming +// ============================================================================= + +/** + * Mechanical reverse-DNS fallback for an npm-style scoped name: + * `@scope/name` → `dev.mpak./` + * `plain` → `dev.mpak/` + * + * Authors override via `manifest._meta["dev.mpak/registry"].name`. This + * helper returns the mechanical default; the composer applies the + * override when present. + */ +export function mechanicalReverseDnsName(npmName: string): string { + const m = /^@([^/]+)\/(.+)$/.exec(npmName); + if (!m) return `dev.mpak/${npmName.toLowerCase()}`; + const scope = (m[1] ?? "").toLowerCase(); + const name = (m[2] ?? "").toLowerCase(); + return `dev.mpak.${scope}/${name}`; +} + +/** + * Org-scoped overrides for the mechanical default. Adding entries here + * doesn't change behavior for already-published bundles unless their + * record is reprojected. + */ +const ORG_REVERSE_DNS_MAP: Record = { + nimblebraininc: "ai.nimblebrain", +}; + +/** + * Apply the org→reverse-DNS map. When an org has a curated mapping the + * bundle's reverse-DNS name uses that prefix; otherwise the mechanical + * default applies. + */ +export function defaultReverseDnsName(npmName: string): string { + const m = /^@([^/]+)\/(.+)$/.exec(npmName); + if (!m) return mechanicalReverseDnsName(npmName); + const scope = (m[1] ?? "").toLowerCase(); + const name = (m[2] ?? "").toLowerCase(); + const mapped = ORG_REVERSE_DNS_MAP[scope]; + if (mapped) return `${mapped}/${name}`; + return mechanicalReverseDnsName(npmName); +} + +/** + * Resolve the reverse-DNS name for a bundle: author override (when the + * manifest sets `_meta["dev.mpak/registry"].name`) wins over the + * org-mapped default. + */ +export function resolveReverseDnsName( + npmName: string, + manifestMeta: Record | null | undefined, +): string { + const meta = manifestMeta?.["dev.mpak/registry"]; + if (meta && typeof meta === "object") { + const override = (meta as { name?: unknown }).name; + if (typeof override === "string" && SERVER_NAME_PATTERN.test(override)) { + return override; + } + } + return defaultReverseDnsName(npmName); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa0ecfc..0908ea6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,8 +75,8 @@ importers: specifier: ^1.4.0 version: 1.4.0 '@nimblebrain/mpak-schemas': - specifier: ^0.2.0 - version: 0.2.0 + specifier: workspace:* + version: link:../../packages/schemas '@prisma/adapter-pg': specifier: ^7.2.0 version: 7.3.0 @@ -1360,9 +1360,6 @@ packages: resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==} engines: {node: '>=16'} - '@nimblebrain/mpak-schemas@0.2.0': - resolution: {integrity: sha512-hGE6RV0krHrQr0wF2zi+0/DwzLwxDago4hyEZbUPbKlfmC2ZFT0hIrT60xcEVEAg3jRZewJX4FaCkvbIMVMStw==} - '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -6862,10 +6859,6 @@ snapshots: chevrotain: 10.5.0 lilconfig: 2.1.0 - '@nimblebrain/mpak-schemas@0.2.0': - dependencies: - zod: 4.3.6 - '@oslojs/encoding@1.1.0': {} '@pagefind/darwin-arm64@1.4.0': From 0052708d05fc3cc93f52cbe38774ac9b205e11e1 Mon Sep 17 00:00:00 2001 From: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com> Date: Fri, 8 May 2026 17:51:47 -1000 Subject: [PATCH 2/5] feat(sdk): expose /v1/servers in TypeScript and Python SDKs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both SDKs now have client methods that target the new MCP-spec-aligned read surface: - TypeScript: `MpakClient.searchServers({q, limit, cursor})` → `ServerListResponse` `MpakClient.getServer(name)` → `ServerDetail` `MpakClient.getServerVersion(name, version)` → `ServerDetail` - Python: `MpakClient.search_servers(q=None, limit=None, cursor=None)` → dict `MpakClient.get_server(name)` → dict `MpakClient.get_server_version(name, version)` → dict Both accept the npm-style scoped name (`@scope/pkg`) and the reverse-DNS form (`ai.nimblebrain/echo`); the registry resolves either to the same record. The TypeScript SDK returns Zod-typed `ServerDetail`; the Python SDK returns the parsed JSON dict to match the existing pragmatism of its other endpoints. The legacy `searchBundles` / `get_bundle_download` methods stay for now — the underlying `/v1/bundles/...` endpoints carry a Deprecation header server-side but remain functional. Migration to the new methods is per-consumer. 12 new SDK tests cover URL shape (npm + reverse-DNS), pagination params, latest-version aliasing, 404 / 5xx / network error paths. --- packages/sdk-python/src/mpak/client.py | 112 +++++++++++++++++ packages/sdk-python/tests/test_client.py | 119 ++++++++++++++++++ packages/sdk-typescript/src/client.ts | 75 ++++++++++- packages/sdk-typescript/src/index.ts | 2 +- packages/sdk-typescript/src/types.ts | 11 ++ packages/sdk-typescript/tests/client.test.ts | 125 +++++++++++++++++++ 6 files changed, 442 insertions(+), 2 deletions(-) diff --git a/packages/sdk-python/src/mpak/client.py b/packages/sdk-python/src/mpak/client.py index f7a04a6..5ea61c6 100644 --- a/packages/sdk-python/src/mpak/client.py +++ b/packages/sdk-python/src/mpak/client.py @@ -237,6 +237,118 @@ def load_bundle_from_url( print(f"Loaded: {manifest['name']} v{manifest['version']}") return manifest + # ────────────────────────────────────────────────────────────────── + # MCP Registry (ServerDetail) endpoints + # ────────────────────────────────────────────────────────────────── + + def search_servers( + self, + q: str | None = None, + limit: int | None = None, + cursor: str | None = None, + ) -> dict[str, Any]: + """Search the MCP registry for servers. + + Hits ``/v1/servers/search`` and returns the raw JSON response — + a ``ServerListResponse`` per the upstream MCP registry shape: + + .. code-block:: python + + { + "servers": [ServerDetail, ...], + "metadata": {"count": int, "next_cursor": str | None}, + } + + Args: + q: Optional substring filter on name / displayName / + description. + limit: Maximum results (server caps at 500; default 100). + cursor: Pagination cursor from a previous response's + ``metadata.next_cursor``. + + Raises: + MpakNetworkError: If the network request fails. + + Notes: + ``search_bundles`` (currently exposed only via + ``get_bundle_download``) targets the legacy + ``/v1/bundles/...`` shape, which is being deprecated + server-side. Prefer ``search_servers`` for new code. + """ + params: dict[str, str | int] = {} + if q is not None: + params["q"] = q + if limit is not None: + params["limit"] = limit + if cursor is not None: + params["cursor"] = cursor + + try: + response = self._client.get("/v1/servers/search", params=params) + if response.status_code == 404: + raise MpakNotFoundError("servers/search endpoint") + 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 + + def get_server(self, name: str) -> dict[str, Any]: + """Fetch the latest ``ServerDetail`` for a server. + + ``name`` accepts both the npm-style scoped name + (``@scope/pkg``) and the reverse-DNS form + (``ai.nimblebrain/echo``); either form returns the same record. + + Raises: + MpakNotFoundError: If the server is not registered. + MpakNetworkError: If the network request fails. + """ + try: + response = self._client.get(f"/v1/servers/{name}") + if response.status_code == 404: + raise MpakNotFoundError(name) + 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 + + def get_server_version(self, name: str, version: str) -> dict[str, Any]: + """Fetch a version-specific ``ServerDetail``. + + Pass ``version="latest"`` to alias the most recent published + version. + + Raises: + MpakNotFoundError: If the server or version is not found. + MpakNetworkError: If the network request fails. + """ + try: + response = self._client.get(f"/v1/servers/{name}/versions/{version}") + 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 c2c7a64..204e197 100644 --- a/packages/sdk-python/tests/test_client.py +++ b/packages/sdk-python/tests/test_client.py @@ -170,3 +170,122 @@ def test_load_bundle_from_url_uses_configured_client(tmp_path): assert route.called request = route.calls[0].request assert request.headers["user-agent"] == "test-agent/2.0" + + +# ───────────────────────────────────────────────────────────────────── +# MCP Registry (ServerDetail) endpoints +# ───────────────────────────────────────────────────────────────────── + + +_SERVER_DETAIL: dict = { + "name": "ai.nimblebrain/echo", + "title": "Echo", + "description": "Echo server", + "version": "0.1.6", +} + + +@respx.mock +def test_search_servers_returns_list_response(): + """search_servers hits /v1/servers/search and returns the raw JSON envelope.""" + route = respx.get("https://registry.mpak.dev/v1/servers/search", params={"q": "echo"}).mock( + return_value=Response(200, json={"servers": [_SERVER_DETAIL], "metadata": {"count": 1}}) + ) + + client = MpakClient() + result = client.search_servers(q="echo") + + assert route.called + assert result["servers"][0]["name"] == "ai.nimblebrain/echo" + assert result["metadata"]["count"] == 1 + + +@respx.mock +def test_search_servers_passes_limit_and_cursor(): + """Optional pagination params reach the URL untouched.""" + route = respx.get( + "https://registry.mpak.dev/v1/servers/search", + params={"limit": "50", "cursor": "100"}, + ).mock(return_value=Response(200, json={"servers": [], "metadata": {"count": 0}})) + + client = MpakClient() + client.search_servers(limit=50, cursor="100") + + assert route.called + + +@respx.mock +def test_search_servers_404_raises_not_found(): + respx.get("https://registry.mpak.dev/v1/servers/search").mock(return_value=Response(404)) + + client = MpakClient() + with pytest.raises(MpakNotFoundError): + client.search_servers() + + +@respx.mock +def test_get_server_accepts_npm_style_name(): + """@scope/pkg is passed through verbatim — the registry handles both + npm-style and reverse-DNS forms.""" + route = respx.get("https://registry.mpak.dev/v1/servers/@nimblebraininc/echo").mock( + return_value=Response(200, json=_SERVER_DETAIL) + ) + + client = MpakClient() + result = client.get_server("@nimblebraininc/echo") + + assert route.called + assert result["name"] == "ai.nimblebrain/echo" + + +@respx.mock +def test_get_server_accepts_reverse_dns_name(): + route = respx.get("https://registry.mpak.dev/v1/servers/ai.nimblebrain/echo").mock( + return_value=Response(200, json=_SERVER_DETAIL) + ) + + client = MpakClient() + result = client.get_server("ai.nimblebrain/echo") + + assert route.called + assert result["name"] == "ai.nimblebrain/echo" + + +@respx.mock +def test_get_server_404_raises_not_found_with_name(): + respx.get("https://registry.mpak.dev/v1/servers/ai.nimblebrain/missing").mock( + return_value=Response(404, json={"error": "Not found"}) + ) + + client = MpakClient() + with pytest.raises(MpakNotFoundError) as exc_info: + client.get_server("ai.nimblebrain/missing") + + assert "ai.nimblebrain/missing" in str(exc_info.value) + + +@respx.mock +def test_get_server_version_passes_through_latest(): + """`version="latest"` is a literal the registry resolves server-side.""" + route = respx.get( + "https://registry.mpak.dev/v1/servers/ai.nimblebrain/echo/versions/latest" + ).mock(return_value=Response(200, json=_SERVER_DETAIL)) + + client = MpakClient() + result = client.get_server_version("ai.nimblebrain/echo", "latest") + + assert route.called + assert result["version"] == "0.1.6" + + +@respx.mock +def test_get_server_version_404_raises_not_found_with_version(): + respx.get( + "https://registry.mpak.dev/v1/servers/ai.nimblebrain/echo/versions/99.0.0" + ).mock(return_value=Response(404)) + + client = MpakClient() + with pytest.raises(MpakNotFoundError) as exc_info: + client.get_server_version("ai.nimblebrain/echo", "99.0.0") + + assert "ai.nimblebrain/echo@99.0.0" in str(exc_info.value) diff --git a/packages/sdk-typescript/src/client.ts b/packages/sdk-typescript/src/client.ts index 6e93409..2eef31c 100644 --- a/packages/sdk-typescript/src/client.ts +++ b/packages/sdk-typescript/src/client.ts @@ -3,6 +3,8 @@ import type { BundleSearchResponse, DownloadInfo, PlatformInfo, + ServerDetail, + ServerListResponse, SkillDetail, SkillDownloadInfo, SkillSearchResponse, @@ -11,7 +13,12 @@ import type { } from '@nimblebrain/mpak-schemas'; import { createHash } from 'node:crypto'; import { MpakError, MpakIntegrityError, MpakNetworkError, MpakNotFoundError } from './errors.js'; -import type { BundleSearchParams, MpakClientConfig, SkillSearchParams } from './types.js'; +import type { + BundleSearchParams, + MpakClientConfig, + ServerSearchParams, + SkillSearchParams, +} from './types.js'; const DEFAULT_REGISTRY_URL = 'https://registry.mpak.dev'; const DEFAULT_TIMEOUT = 30000; @@ -157,6 +164,72 @@ export class MpakClient { return response.json() as Promise; } + // =========================================================================== + // MCP Registry (ServerDetail) API + // =========================================================================== + + /** + * Search servers by substring on name / displayName / description. + * Returns ServerDetail entries per the upstream MCP registry shape; + * pair with `metadata.next_cursor` for pagination. + * + * Note: this hits `/v1/servers/search`, the MCP-spec-aligned read + * surface. The `searchBundles` method targets the legacy + * `/v1/bundles/search` shape and is being deprecated server-side + * (responses now carry `Deprecation: true` + `Link: rel="successor-version"`). + */ + async searchServers(params: ServerSearchParams = {}): Promise { + const searchParams = new URLSearchParams(); + if (params.q) searchParams.set('q', params.q); + if (params.limit) searchParams.set('limit', String(params.limit)); + if (params.cursor) searchParams.set('cursor', params.cursor); + + const queryString = searchParams.toString(); + const url = `${this.registryUrl}/v1/servers/search${queryString ? `?${queryString}` : ''}`; + + const response = await this.fetchWithTimeout(url); + if (response.status === 404) { + throw new MpakNotFoundError('servers/search endpoint'); + } + if (!response.ok) { + throw new MpakNetworkError(`Failed to search servers: HTTP ${response.status}`); + } + return response.json() as Promise; + } + + /** + * Latest `ServerDetail` for a server. `name` accepts both the + * npm-style scoped name (`@scope/pkg`) and the reverse-DNS form + * (`ai.nimblebrain/echo`). Either form returns the same record. + */ + async getServer(name: string): Promise { + const url = `${this.registryUrl}/v1/servers/${encodeURIComponent(name)}`; + const response = await this.fetchWithTimeout(url); + if (response.status === 404) { + throw new MpakNotFoundError(name); + } + if (!response.ok) { + throw new MpakNetworkError(`Failed to get server: HTTP ${response.status}`); + } + return response.json() as Promise; + } + + /** + * Version-specific `ServerDetail`. `version` accepts the literal + * `"latest"` to alias the most recent published version. + */ + async getServerVersion(name: string, version: string): Promise { + const url = `${this.registryUrl}/v1/servers/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}`; + const response = await this.fetchWithTimeout(url); + if (response.status === 404) { + throw new MpakNotFoundError(`${name}@${version}`); + } + if (!response.ok) { + throw new MpakNetworkError(`Failed to get server version: HTTP ${response.status}`); + } + return response.json() as Promise; + } + // =========================================================================== // Skill API // =========================================================================== diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index a8d4d73..6afdb1e 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -32,7 +32,7 @@ export type { MpakConfigManagerOptions, PackageConfig } from './config-manager.j export { MpakBundleCache } from './cache.js'; export type { MpakBundleCacheOptions } from './cache.js'; export { MpakClient } from './client.js'; -export type { MpakClientConfig } from './types.js'; +export type { MpakClientConfig, ServerSearchParams } from './types.js'; // Validation export { validateMcpb } from './validate.js'; diff --git a/packages/sdk-typescript/src/types.ts b/packages/sdk-typescript/src/types.ts index db1dfec..9f8a1f1 100644 --- a/packages/sdk-typescript/src/types.ts +++ b/packages/sdk-typescript/src/types.ts @@ -9,6 +9,17 @@ export type { BundleSearchParamsInput as BundleSearchParams } from '@nimblebrain/mpak-schemas'; export type { SkillSearchParamsInput as SkillSearchParams } from '@nimblebrain/mpak-schemas'; +/** + * Query params for `MpakClient.searchServers`. Mirrors the + * `/v1/servers/search` query string. `cursor` is the registry's + * pagination handle returned in `metadata.next_cursor`. + */ +export interface ServerSearchParams { + q?: string; + limit?: number; + cursor?: string; +} + // ============================================================================= // Client Configuration // ============================================================================= diff --git a/packages/sdk-typescript/tests/client.test.ts b/packages/sdk-typescript/tests/client.test.ts index b4a1937..d945015 100644 --- a/packages/sdk-typescript/tests/client.test.ts +++ b/packages/sdk-typescript/tests/client.test.ts @@ -596,4 +596,129 @@ describe('MpakClient', () => { await expect(client.searchBundles()).rejects.toThrow(MpakNetworkError); }); }); + + // =========================================================================== + // ServerDetail (MCP registry) endpoints + // =========================================================================== + + describe('searchServers', () => { + const SERVER: Record = { + name: 'ai.nimblebrain/echo', + title: 'Echo', + description: 'Echo server for testing', + version: '0.1.6', + }; + + it('hits /v1/servers/search and returns the ServerListResponse', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce( + mockResponse({ servers: [SERVER], metadata: { count: 1 } }), + ); + + const result = await client.searchServers({ q: 'echo' }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringMatching(/\/v1\/servers\/search\?q=echo$/), + expect.any(Object), + ); + expect(result.servers[0]?.name).toBe('ai.nimblebrain/echo'); + expect(result.metadata?.count).toBe(1); + }); + + it('passes limit + cursor through to the URL', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce(mockResponse({ servers: [], metadata: { count: 0 } })); + + await client.searchServers({ limit: 50, cursor: '100' }); + + const url = fetchMock.mock.calls[0]?.[0] as string; + expect(url).toContain('limit=50'); + expect(url).toContain('cursor=100'); + }); + + it('throws MpakNotFoundError on 404', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce(mockResponse('not found', { status: 404 })); + await expect(client.searchServers()).rejects.toThrow(MpakNotFoundError); + }); + + it('throws MpakNetworkError on 5xx', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce(mockResponse('boom', { status: 503 })); + await expect(client.searchServers()).rejects.toThrow(MpakNetworkError); + }); + }); + + describe('getServer', () => { + const SERVER: Record = { + name: 'ai.nimblebrain/echo', + title: 'Echo', + description: 'Echo server', + version: '0.1.6', + }; + + it('URL-encodes the npm-style name (slashes preserved by encodeURIComponent)', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce(mockResponse(SERVER)); + + await client.getServer('@nimblebraininc/echo'); + + const url = fetchMock.mock.calls[0]?.[0] as string; + // encodeURIComponent escapes both `@` and `/` so the param round-trips. + expect(url).toContain('/v1/servers/%40nimblebraininc%2Fecho'); + }); + + it('accepts the reverse-DNS form unchanged', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce(mockResponse(SERVER)); + + await client.getServer('ai.nimblebrain/echo'); + + const url = fetchMock.mock.calls[0]?.[0] as string; + expect(url).toContain('/v1/servers/ai.nimblebrain%2Fecho'); + }); + + it('throws MpakNotFoundError on 404', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce(mockResponse('not found', { status: 404 })); + await expect(client.getServer('ai.nimblebrain/missing')).rejects.toThrow(MpakNotFoundError); + }); + }); + + describe('getServerVersion', () => { + const SERVER: Record = { + name: 'ai.nimblebrain/echo', + title: 'Echo', + description: 'Echo server', + version: '0.1.6', + }; + + it('URL-encodes both the name and the version', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce(mockResponse(SERVER)); + + await client.getServerVersion('@nimblebraininc/echo', '0.1.6'); + + const url = fetchMock.mock.calls[0]?.[0] as string; + expect(url).toContain('/v1/servers/%40nimblebraininc%2Fecho/versions/0.1.6'); + }); + + it('passes through the literal "latest"', async () => { + const client = new MpakClient(); + fetchMock.mockResolvedValueOnce(mockResponse(SERVER)); + + await client.getServerVersion('ai.nimblebrain/echo', 'latest'); + + const url = fetchMock.mock.calls[0]?.[0] as string; + expect(url).toContain('/versions/latest'); + }); + + 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.getServerVersion('ai.nimblebrain/echo', '99.0.0')).rejects.toThrow( + /ai.nimblebrain\/echo@99\.0\.0/, + ); + }); + }); }); From d1af0cd01fb070c2c2bf8c36188f77927eb4ecd4 Mon Sep 17 00:00:00 2001 From: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com> Date: Sat, 9 May 2026 07:36:09 -1000 Subject: [PATCH 3/5] fix(registry): address QA findings on /v1/servers route + composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA review on the original PR flagged 9 findings; this commit addresses all of them except W4 (the /v0.1 vs top-level /health shape divergence is intentional — different probes, different mount points; documented in the route comment). ## Correctness - **`updated_since` filter pushed into Prisma `where`** — was applied in JS post-fetch, breaking pagination math (a request like `?limit=100&updated_since=2026-04-01` could return < 100 results while reporting more pages remained, leading to empty tail pages forever). - **Single source of truth for `version`** — composer now pins both the top-level `ServerDetail.version` and per-package `packages[].version` to the DB row's `input.version.version`. The manifest's optional `version` field is no longer consulted as a fallback, so the two values can never disagree on a record. - **Lowercase the direct lookup name** — `findPackageForServerLookup` was case-sensitive on the fast path; `@NimbleBrainInc/echo` would miss the direct lookup and fall through to reverse-DNS resolution. npm package names are case-insensitive at the registry, so we normalize the input before lookup. - **Validate cursor / limit input** — `parseInt('garbage', 10)` returns `NaN`, which Prisma rejects at runtime with a 500. New `parseIntParam` helper coerces NaN to a default and clamps negatives. ## Performance - **N+1 security-scan query eliminated** — `findPackagesForServerListing` and `findPackageForServerLookup` now include the latest completed scan via Prisma's nested include (one query per route call instead of `1 + N` for `/servers?limit=500`). The standalone `findLatestCompletedScan` repository method is removed. ## Robustness - **Defensive filtering at the projection boundary, not the schema boundary** — one bad icon entry (long S3-style URL > 255 chars, malformed `sizes` regex) used to reject the entire `ServerDetail` via `safeParse` and 500 the route. `projectIcons` now drops invalid icons individually; the rest of the record survives. Same logic applied to `packages[].fileSha256` (drops bad digest, keeps the package entry). ## Code quality - **Extracted `buildDetail()` helper** — `composeServerDetail` and `composeServerDetailOrThrow` shared a 40-line projection body that differed only in `safeParse` vs `parse`. Now one function each, both call the shared builder. - **Deleted dead-code legacy methods** — `findPackagesWithServerJson` / `findPackageWithServerJsonByName` and `findLatestCompletedScan` had no internal callers; the spec preservation reasoning ("for external SDK callers") doesn't apply in a private monolith. Gone. ## Tests New `apps/registry/tests/servers.test.ts` covers the route layer that the composer-only test file didn't: - `GET /servers` pagination + `next_cursor` math - `updated_since` Date push-down to repo - malformed `updated_since` ignored as no-op - garbage `cursor` / `limit` coerced to defaults - `limit > 500` clamped - npm-style and reverse-DNS name resolution - case-insensitive direct lookup - pre-joined scan: certification meta populated without a separate `findLatestCompletedScan` call (asserts the dead method is gone) - 404 paths - version-specific lookup + `latest` aliasing - `/servers/{name}/versions` listing 127 registry tests now pass (was 110). --- .../src/db/repositories/package.repository.ts | 173 +++----- apps/registry/src/routes/mcp/v0.1/servers.ts | 160 +++++--- .../src/services/server-detail-composer.ts | 167 ++++---- apps/registry/tests/helpers.ts | 3 - apps/registry/tests/servers.test.ts | 370 ++++++++++++++++++ 5 files changed, 616 insertions(+), 257 deletions(-) create mode 100644 apps/registry/tests/servers.test.ts diff --git a/apps/registry/src/db/repositories/package.repository.ts b/apps/registry/src/db/repositories/package.repository.ts index 3d7269e..ff8e336 100644 --- a/apps/registry/src/db/repositories/package.repository.ts +++ b/apps/registry/src/db/repositories/package.repository.ts @@ -3,8 +3,7 @@ * Handles operations for packages and versions */ -import type { Package, PackageVersion, Artifact, SecurityScan } from '@prisma/client'; -import { Prisma } from '@prisma/client'; +import type { Artifact, Package, PackageVersion, Prisma, SecurityScan } from '@prisma/client'; import { getPrismaClient, type TransactionClient } from '../client.js'; import type { PackageSearchFilters, FindOptions, PackageWithRelations } from '../types.js'; @@ -19,6 +18,20 @@ export type PackageVersionWithArtifactsAndScans = PackageVersion & { securityScans: SecurityScan[]; }; +/** + * Package row joined with its versions, per-version artifacts, and + * (when present) the latest completed security scan per version. The + * shape `findPackageForServerLookup` and `findPackagesForServerListing` + * return — both pull the same data in one query so the route layer + * can compose `ServerDetail` without further round-trips. + */ +export type PackageForServerLookup = Package & { + versions: (PackageVersion & { + artifacts: Artifact[]; + securityScans: SecurityScan[]; + })[]; +}; + export interface CreatePackageData { name: string; displayName?: string; @@ -299,91 +312,15 @@ export class PackageRepository { } /** - * Find packages that have server.json set (for MCP Registry /v0.1/servers) - */ - async findPackagesWithServerJson( - filters: { search?: string }, - options: { skip?: number; take?: number }, - tx?: TransactionClient - ): Promise<{ packages: (Package & { versions: (PackageVersion & { artifacts: Artifact[] })[] })[]; total: number }> { - const client = tx ?? getPrismaClient(); - - // Filter packages that have at least one version with serverJson set - const where: Prisma.PackageWhereInput = { - versions: { - some: { - serverJson: { not: Prisma.DbNull }, - }, - }, - }; - - if (filters.search) { - where.AND = [ - { - OR: [ - { name: { contains: filters.search, mode: 'insensitive' } }, - { displayName: { contains: filters.search, mode: 'insensitive' } }, - { description: { contains: filters.search, mode: 'insensitive' } }, - ], - }, - ]; - } - - const [packages, total] = await Promise.all([ - client.package.findMany({ - where, - skip: options.skip, - take: options.take, - orderBy: { name: 'asc' }, - include: { - versions: { - where: { serverJson: { not: Prisma.DbNull } }, - orderBy: { publishedAt: 'desc' }, - take: 1, - include: { - artifacts: true, - }, - }, - }, - }), - client.package.count({ where }), - ]); - - return { packages, total }; - } - - /** - * Find a package with server.json and its version artifacts (for single-server lookup) - */ - async findPackageWithServerJsonByName( - name: string, - tx?: TransactionClient - ): Promise<(Package & { versions: (PackageVersion & { artifacts: Artifact[] })[] }) | null> { - const client = tx ?? getPrismaClient(); - return client.package.findUnique({ - where: { name }, - include: { - versions: { - orderBy: { publishedAt: 'desc' }, - include: { - artifacts: true, - }, - }, - }, - }); - } - - /** - * Find a package by its npm-style scoped name with all versions and - * artifacts. Variant of {@link findPackageWithServerJsonByName} that - * doesn't require the deprecated `serverJson` column to be set — - * server.json metadata is now composed from the manifest, so any - * indexed bundle can be served as an MCP `ServerDetail`. + * Find a package by its npm-style scoped name with all versions, + * artifacts, and the latest completed security scan per version + * (joined in one query — avoids the per-version round-trip the + * caller used to do for certification metadata). */ async findPackageForServerLookup( name: string, tx?: TransactionClient - ): Promise<(Package & { versions: (PackageVersion & { artifacts: Artifact[] })[] }) | null> { + ): Promise { const client = tx ?? getPrismaClient(); return client.package.findUnique({ where: { name }, @@ -392,6 +329,11 @@ export class PackageRepository { orderBy: { publishedAt: 'desc' }, include: { artifacts: true, + securityScans: { + where: { status: 'completed' }, + orderBy: { startedAt: 'desc' }, + take: 1, + }, }, }, }, @@ -399,29 +341,40 @@ export class PackageRepository { } /** - * List packages with their latest version + artifacts. Variant of - * {@link findPackagesWithServerJson} without the deprecated - * `serverJson` filter — server.json metadata composes from the - * manifest, so every indexed package can be served. Honors a + * List packages with their latest version, artifacts, and the + * latest completed security scan — all in one query. Honors a * case-insensitive substring search on name / displayName / - * description. + * description and an optional `updatedSince` filter pushed down to + * the database so pagination math reflects the filter (a request + * like `limit=100&updatedSince=...` returns up to 100 *matching* + * packages, not 100 fetched then filtered to a few). */ async findPackagesForServerListing( - filters: { search?: string }, + filters: { search?: string; updatedSince?: Date }, options: { skip?: number; take?: number }, tx?: TransactionClient - ): Promise<{ packages: (Package & { versions: (PackageVersion & { artifacts: Artifact[] })[] })[]; total: number }> { + ): Promise<{ packages: PackageForServerLookup[]; total: number }> { const client = tx ?? getPrismaClient(); - const where: Prisma.PackageWhereInput = filters.search - ? { - OR: [ - { name: { contains: filters.search, mode: 'insensitive' } }, - { displayName: { contains: filters.search, mode: 'insensitive' } }, - { description: { contains: filters.search, mode: 'insensitive' } }, - ], - } - : {}; + const conditions: Prisma.PackageWhereInput[] = []; + if (filters.search) { + conditions.push({ + OR: [ + { name: { contains: filters.search, mode: 'insensitive' } }, + { displayName: { contains: filters.search, mode: 'insensitive' } }, + { description: { contains: filters.search, mode: 'insensitive' } }, + ], + }); + } + if (filters.updatedSince) { + // "Updated" here means "has at least one version published since". + // Filter at the DB so pagination cursor math is consistent. + conditions.push({ + versions: { some: { publishedAt: { gte: filters.updatedSince } } }, + }); + } + const where: Prisma.PackageWhereInput = + conditions.length === 0 ? {} : conditions.length === 1 ? conditions[0]! : { AND: conditions }; const [packages, total] = await Promise.all([ client.package.findMany({ @@ -435,6 +388,11 @@ export class PackageRepository { take: 1, include: { artifacts: true, + securityScans: { + where: { status: 'completed' }, + orderBy: { startedAt: 'desc' }, + take: 1, + }, }, }, }, @@ -445,24 +403,9 @@ export class PackageRepository { return { packages, total }; } - /** - * Latest completed security scan for a version, when present. - * Returned for the registry's MTF certification fields (level, - * controls passed/failed/total) on `_meta["dev.mpak/registry"].certification`. - */ - async findLatestCompletedScan( - versionId: string, - tx?: TransactionClient - ): Promise { - const client = tx ?? getPrismaClient(); - return client.securityScan.findFirst({ - where: { versionId, status: 'completed' }, - orderBy: { startedAt: 'desc' }, - }); - } - // ==================== Package Version Methods ==================== + /** * Find version by package ID and version string, including latest completed security scan */ diff --git a/apps/registry/src/routes/mcp/v0.1/servers.ts b/apps/registry/src/routes/mcp/v0.1/servers.ts index 03d894f..37e8c34 100644 --- a/apps/registry/src/routes/mcp/v0.1/servers.ts +++ b/apps/registry/src/routes/mcp/v0.1/servers.ts @@ -14,28 +14,26 @@ */ import type { FastifyInstance, FastifyPluginAsync } from 'fastify'; -import type { Artifact, Package, PackageVersion, SecurityScan } from '@prisma/client'; +import { resolveReverseDnsName, type ServerDetail } from '@nimblebrain/mpak-schemas'; +import type { PackageForServerLookup } from '../../../db/repositories/package.repository.js'; import { composeServerDetail } from '../../../services/server-detail-composer.js'; -import { resolveReverseDnsName } from '@nimblebrain/mpak-schemas'; -import type { ServerDetail } from '@nimblebrain/mpak-schemas'; const REGISTRY_VERSION = 'v1.0.0'; -type PackageWithVersions = Package & { - versions: (PackageVersion & { artifacts: Artifact[] })[]; -}; - /** * Project a security-scan row into the certification meta block carried * on `_meta["dev.mpak/registry"].certification`. */ -function scanToCertification(scan: SecurityScan | null): { - level: number; - levelName?: string | null; - controlsPassed?: number | null; - controlsFailed?: number | null; - controlsTotal?: number | null; -} | undefined { +function scanToCertification( + scan: { certificationLevel: number | null; controlsPassed: number | null; controlsFailed: number | null; controlsTotal: number | null } | null, +): + | { + level: number; + controlsPassed?: number | null; + controlsFailed?: number | null; + controlsTotal?: number | null; + } + | undefined { if (!scan || scan.certificationLevel == null) return undefined; return { level: scan.certificationLevel, @@ -45,13 +43,16 @@ function scanToCertification(scan: SecurityScan | null): { }; } -async function buildServerDetailWithScan( - fastify: FastifyInstance, - pkg: PackageWithVersions, - version: PackageVersion & { artifacts: Artifact[] }, -): Promise { - const { packages: packageRepo } = fastify.repositories; - const scan = await packageRepo.findLatestCompletedScan(version.id); +/** + * Compose a `ServerDetail` from a fully-loaded package + version. The + * latest completed scan is pre-joined into `version.securityScans` by + * the repository methods, so this is pure CPU work — no DB round-trip. + */ +function buildServerDetail( + pkg: PackageForServerLookup, + version: PackageForServerLookup['versions'][number], +): ServerDetail | null { + const scan = version.securityScans?.[0] ?? null; return composeServerDetail({ pkg: { name: pkg.name, @@ -80,13 +81,17 @@ async function buildServerDetailWithScan( * overrides via `manifest._meta["dev.mpak/registry"].name` resolve via * scan-then-match (cheap at current registry size; an indexed * `reverseDnsName` column would replace this when scale demands it). + * + * Names are lowercased before lookup — npm package names are + * case-insensitive at the registry, and the stored `Package.name` + * column is canonical-lowercase. */ async function resolveByName( fastify: FastifyInstance, rawName: string, -): Promise { +): Promise { const { packages: packageRepo } = fastify.repositories; - const decodedName = decodeURIComponent(rawName); + const decodedName = decodeURIComponent(rawName).toLowerCase(); // Direct npm-style lookup — fastest path. const direct = await packageRepo.findPackageForServerLookup(decodedName); @@ -145,6 +150,24 @@ function reverseDnsToNpmCandidates(reverseDns: string): string[] { return out; } +/** + * Parse an integer query param, defaulting and clamping. NaN inputs + * (garbage cursors / limits from a malformed query string) coerce to + * the default rather than reaching Prisma where they'd error out. + */ +function parseIntParam(raw: string | undefined, defaultValue: number): number { + if (raw === undefined) return defaultValue; + const n = parseInt(raw, 10); + return Number.isFinite(n) && n >= 0 ? n : defaultValue; +} + +/** Parse `updated_since` query string to a Date, or null when absent / invalid. */ +function parseUpdatedSince(raw: string | undefined): Date | null { + if (!raw) return null; + const d = new Date(raw); + return Number.isNaN(d.getTime()) ? null : d; +} + /** * Build the route handlers shared between `/v0.1/servers` (MCP * Registry public API prefix) and `/v1/servers` (mpak `/v1/...` @@ -183,54 +206,55 @@ export const mcpRegistryRoutes: FastifyPluginAsync = async (fastify) => { }>('/servers', { schema: { tags: ['mcp-registry'], - description: 'List MCP servers (each entry is a ServerDetail per the upstream MCP registry spec)', + description: + 'List MCP servers (each entry is a ServerDetail per the upstream MCP registry spec)', querystring: { type: 'object', properties: { cursor: { type: 'string', description: 'Pagination cursor (offset as string)' }, limit: { type: 'string', description: 'Maximum results (default 100, max 500)' }, - search: { type: 'string', description: 'Case-insensitive substring search on name/displayName/description' }, - version: { type: 'string', enum: ['latest'], description: 'Filter to latest versions only' }, - updated_since: { type: 'string', description: 'RFC 3339 timestamp filter for recently updated servers' }, + search: { + type: 'string', + description: 'Case-insensitive substring search on name/displayName/description', + }, + version: { + type: 'string', + enum: ['latest'], + description: 'Filter to latest versions only', + }, + updated_since: { + type: 'string', + description: 'RFC 3339 timestamp filter for recently updated servers', + }, }, }, }, }, async (request) => { - const limit = Math.min(parseInt(request.query.limit ?? '100', 10), 500); - const skip = request.query.cursor ? parseInt(request.query.cursor, 10) : 0; + const limit = Math.min(parseIntParam(request.query.limit, 100), 500); + const skip = parseIntParam(request.query.cursor, 0); + const updatedSince = parseUpdatedSince(request.query.updated_since); const { packages, total } = await packageRepo.findPackagesForServerListing( - { search: request.query.search }, - { skip, take: limit } + { + ...(request.query.search ? { search: request.query.search } : {}), + ...(updatedSince ? { updatedSince } : {}), + }, + { skip, take: limit }, ); const servers: ServerDetail[] = []; for (const pkg of packages) { const latest = pkg.versions[0]; if (!latest) continue; - const detail = await buildServerDetailWithScan(fastify, pkg, latest); + const detail = buildServerDetail(pkg, latest); if (detail) servers.push(detail); } - let filtered = servers; - if (request.query.updated_since) { - const sinceDate = new Date(request.query.updated_since); - if (!Number.isNaN(sinceDate.getTime())) { - filtered = servers.filter((s) => { - const meta = s._meta?.['dev.mpak/registry'] as Record | undefined; - const publishedAt = meta?.['published_at']; - if (typeof publishedAt === 'string') { - return new Date(publishedAt) >= sinceDate; - } - return true; - }); - } - } - - const response: { servers: ServerDetail[]; metadata: { count: number; next_cursor?: string } } = { - servers: filtered, - metadata: { count: filtered.length }, - }; + const response: { servers: ServerDetail[]; metadata: { count: number; next_cursor?: string } } = + { + servers, + metadata: { count: servers.length }, + }; const nextIdx = skip + limit; if (nextIdx < total) { response.metadata.next_cursor = String(nextIdx); @@ -255,23 +279,24 @@ export const mcpRegistryRoutes: FastifyPluginAsync = async (fastify) => { }, }, }, async (request) => { - const limit = Math.min(parseInt(request.query.limit ?? '100', 10), 500); - const skip = request.query.cursor ? parseInt(request.query.cursor, 10) : 0; + const limit = Math.min(parseIntParam(request.query.limit, 100), 500); + const skip = parseIntParam(request.query.cursor, 0); const { packages, total } = await packageRepo.findPackagesForServerListing( - { search: request.query.q }, - { skip, take: limit } + request.query.q ? { search: request.query.q } : {}, + { skip, take: limit }, ); const servers: ServerDetail[] = []; for (const pkg of packages) { const latest = pkg.versions[0]; if (!latest) continue; - const detail = await buildServerDetailWithScan(fastify, pkg, latest); + const detail = buildServerDetail(pkg, latest); if (detail) servers.push(detail); } - const response: { servers: ServerDetail[]; metadata: { count: number; next_cursor?: string } } = { - servers, - metadata: { count: servers.length }, - }; + const response: { servers: ServerDetail[]; metadata: { count: number; next_cursor?: string } } = + { + servers, + metadata: { count: servers.length }, + }; const nextIdx = skip + limit; if (nextIdx < total) { response.metadata.next_cursor = String(nextIdx); @@ -285,7 +310,8 @@ export const mcpRegistryRoutes: FastifyPluginAsync = async (fastify) => { }>('/servers/:name', { schema: { tags: ['mcp-registry'], - description: 'Get the latest ServerDetail for a server. Accepts both npm-style (@scope/name) and reverse-DNS forms.', + description: + 'Get the latest ServerDetail for a server. Accepts both npm-style (@scope/name) and reverse-DNS forms.', params: { type: 'object', required: ['name'], @@ -303,7 +329,7 @@ export const mcpRegistryRoutes: FastifyPluginAsync = async (fastify) => { reply.code(404); return { error: `Server '${pkg.name}' has no versions` }; } - const detail = await buildServerDetailWithScan(fastify, pkg, latest); + const detail = buildServerDetail(pkg, latest); if (!detail) { reply.code(500); return { error: `Server '${pkg.name}' manifest could not be projected` }; @@ -342,10 +368,12 @@ export const mcpRegistryRoutes: FastifyPluginAsync = async (fastify) => { reply.code(404); return { error: `Version '${requestedVersion}' not found for server '${pkg.name}'` }; } - const detail = await buildServerDetailWithScan(fastify, pkg, matchedVersion); + const detail = buildServerDetail(pkg, matchedVersion); if (!detail) { reply.code(500); - return { error: `Server '${pkg.name}' version '${matchedVersion.version}' manifest could not be projected` }; + return { + error: `Server '${pkg.name}' version '${matchedVersion.version}' manifest could not be projected`, + }; } return detail; }); @@ -381,7 +409,9 @@ export const mcpRegistryRoutes: FastifyPluginAsync = async (fastify) => { }; }); - // GET /health - Health check + // 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. fastify.get('/health', async () => { const { total } = await packageRepo.findPackagesForServerListing({}, { take: 0 }); return { diff --git a/apps/registry/src/services/server-detail-composer.ts b/apps/registry/src/services/server-detail-composer.ts index 543f15b..df6f70d 100644 --- a/apps/registry/src/services/server-detail-composer.ts +++ b/apps/registry/src/services/server-detail-composer.ts @@ -88,48 +88,8 @@ export interface ComposerInput { * record. */ export function composeServerDetail(input: ComposerInput): ServerDetail | null { - const manifest = (input.version.manifest ?? {}) as Record; - - const description = stringField(manifest, "description") ?? input.pkg.name; - const truncatedDescription = truncate(description, 100); - const version = - stringField(manifest, "version") ?? input.version.version ?? input.pkg.latestVersion ?? "0.0.0"; - - const manifestMeta = (manifest["_meta"] as Record | undefined) ?? null; - const reverseDnsName = resolveReverseDnsName(input.pkg.name, manifestMeta); - - const display = stringField(manifest, "display_name"); - const title = display && display.trim().length > 0 ? display.trim() : input.pkg.name; - - const detail: Record = { - $schema: UPSTREAM_SCHEMA_URL, - name: reverseDnsName, - title, - description: truncatedDescription, - version, - }; - - const homepage = stringField(manifest, "homepage"); - if (homepage && isHttpUrl(homepage)) { - detail["websiteUrl"] = homepage; - } - - const repository = projectRepository(manifest, input.pkg.githubRepo); - if (repository) detail["repository"] = repository; - - const icons = projectIcons(manifest); - if (icons.length > 0) detail["icons"] = icons; - - const packages = projectPackages(input, manifest); - if (packages.length > 0) detail["packages"] = packages; - - detail["_meta"] = composeMeta(input, manifest, manifestMeta); - - const result = ServerDetailSchema.safeParse(detail); - if (!result.success) { - return null; - } - return result.data; + const result = ServerDetailSchema.safeParse(buildDetail(input)); + return result.success ? result.data : null; } /** @@ -138,16 +98,27 @@ export function composeServerDetail(input: ComposerInput): ServerDetail | null { * where a malformed projection should fail loudly. */ export function composeServerDetailOrThrow(input: ComposerInput): ServerDetail { - const manifest = (input.version.manifest ?? {}) as Record; - - const description = stringField(manifest, "description") ?? input.pkg.name; - const truncatedDescription = truncate(description, 100); - const version = - stringField(manifest, "version") ?? input.version.version ?? input.pkg.latestVersion ?? "0.0.0"; + return ServerDetailSchema.parse(buildDetail(input)); +} +/** + * Build the unvalidated `ServerDetail` candidate. The two public + * functions differ only in how they handle the schema check + * ({@link composeServerDetail} returns null on failure; + * {@link composeServerDetailOrThrow} throws). Pulled out so both share + * one projection body. + * + * Single source of truth for `version`: the DB row's + * `input.version.version`. The manifest's `version` field is ignored + * here so the top-level `ServerDetail.version` and per-package + * `packages[].version` can never disagree on a record. + */ +function buildDetail(input: ComposerInput): Record { + const manifest = (input.version.manifest ?? {}) as Record; const manifestMeta = (manifest["_meta"] as Record | undefined) ?? null; - const reverseDnsName = resolveReverseDnsName(input.pkg.name, manifestMeta); + const description = truncate(stringField(manifest, "description") ?? input.pkg.name, 100); + const reverseDnsName = resolveReverseDnsName(input.pkg.name, manifestMeta); const display = stringField(manifest, "display_name"); const title = display && display.trim().length > 0 ? display.trim() : input.pkg.name; @@ -155,21 +126,25 @@ export function composeServerDetailOrThrow(input: ComposerInput): ServerDetail { $schema: UPSTREAM_SCHEMA_URL, name: reverseDnsName, title, - description: truncatedDescription, - version, + description, + version: input.version.version, }; const homepage = stringField(manifest, "homepage"); if (homepage && isHttpUrl(homepage)) detail["websiteUrl"] = homepage; + const repository = projectRepository(manifest, input.pkg.githubRepo); if (repository) detail["repository"] = repository; + const icons = projectIcons(manifest); if (icons.length > 0) detail["icons"] = icons; + const packages = projectPackages(input, manifest); if (packages.length > 0) detail["packages"] = packages; + detail["_meta"] = composeMeta(input, manifest, manifestMeta); - return ServerDetailSchema.parse(detail); + return detail; } // ── building blocks ──────────────────────────────────────────────────── @@ -195,31 +170,67 @@ function projectRepository( return null; } -function projectIcons(manifest: Record): { src: string; sizes?: string[] }[] { +/** + * Project the manifest's icons to the upstream `Icon[]` shape. Filters + * out anything the upstream schema would reject so a single bad icon + * doesn't propagate to the schema boundary and 500 the whole record: + * + * - `src` must be http(s) (no `javascript:` / `data:` injection) + * - `src` must be ≤ 255 chars (upstream `Icon.src` `maxLength`) + * - `sizes[]` entries must match `^(\d+x\d+|any)$` (upstream pattern); + * entries that don't match get dropped, the rest survive + * - `mimeType` must be one of the upstream-defined image types, + * otherwise the field is dropped (icon survives) + * - `theme` must be `light` or `dark`; otherwise dropped + */ +function projectIcons( + manifest: Record, +): { src: string; sizes?: string[]; mimeType?: string; theme?: string }[] { const icons = manifest["icons"]; if (Array.isArray(icons)) { return icons - .map((i) => { - if (!i || typeof i !== "object") return null; - const src = stringField(i as Record, "src"); - if (!src || !isHttpUrl(src)) return null; - const out: { src: string; sizes?: string[] } = { src }; - const sizes = (i as Record)["sizes"]; - if (Array.isArray(sizes) && sizes.every((s) => typeof s === "string")) { - out.sizes = sizes as string[]; - } - return out; - }) - .filter((i): i is { src: string; sizes?: string[] } => i !== null); + .map(projectIcon) + .filter((i): i is { src: string; sizes?: string[]; mimeType?: string; theme?: string } => i !== null); } // Single-icon legacy field. const single = stringField(manifest, "icon"); - if (single && isHttpUrl(single)) { + if (single && isHttpUrl(single) && single.length <= 255) { return [{ src: single, sizes: ["any"] }]; } return []; } +const ICON_SIZE_PATTERN = /^(\d+x\d+|any)$/; +const ICON_MIME_TYPES = new Set([ + "image/png", + "image/jpeg", + "image/jpg", + "image/svg+xml", + "image/webp", +]); + +function projectIcon( + raw: unknown, +): { src: string; sizes?: string[]; mimeType?: string; theme?: string } | null { + if (!raw || typeof raw !== "object") return null; + const obj = raw as Record; + const src = stringField(obj, "src"); + if (!src || !isHttpUrl(src) || src.length > 255) return null; + const out: { src: string; sizes?: string[]; mimeType?: string; theme?: string } = { src }; + const sizes = obj["sizes"]; + if (Array.isArray(sizes)) { + const valid = sizes.filter( + (s): s is string => typeof s === "string" && ICON_SIZE_PATTERN.test(s), + ); + if (valid.length > 0) out.sizes = valid; + } + const mimeType = stringField(obj, "mimeType"); + if (mimeType && ICON_MIME_TYPES.has(mimeType)) out.mimeType = mimeType; + const theme = stringField(obj, "theme"); + if (theme === "light" || theme === "dark") out.theme = theme; + return out; +} + function projectPackages(input: ComposerInput, manifest: Record): ServerPackage[] { const envVars = projectEnvironmentVariables(manifest); // Per-platform artifact download URLs live in @@ -239,14 +250,22 @@ function projectPackages(input: ComposerInput, manifest: Record }, ]; } - return input.artifacts.map((art) => ({ - registryType: "mpak", - identifier: input.pkg.name, - version: input.version.version, - transport: { type: "stdio" } as const, - fileSha256: art.digest.replace(/^sha256:/, ""), - ...(envVars.length > 0 ? { environmentVariables: envVars } : {}), - })); + return input.artifacts.map((art) => { + const sha = art.digest.replace(/^sha256:/, ""); + const pkg: ServerPackage = { + registryType: "mpak", + identifier: input.pkg.name, + version: input.version.version, + transport: { type: "stdio" }, + ...(envVars.length > 0 ? { environmentVariables: envVars } : {}), + }; + // Upstream `fileSha256` requires 64 hex chars. A malformed digest + // would otherwise reject the entire ServerDetail at the schema + // boundary; better to drop the integrity field on this entry and + // surface the rest of the record. + if (/^[a-f0-9]{64}$/.test(sha)) pkg.fileSha256 = sha; + return pkg; + }); } /** diff --git a/apps/registry/tests/helpers.ts b/apps/registry/tests/helpers.ts index 085a01f..065327a 100644 --- a/apps/registry/tests/helpers.ts +++ b/apps/registry/tests/helpers.ts @@ -32,11 +32,8 @@ export function createMockPackageRepo() { delete: vi.fn(), upsertPackage: vi.fn(), findByCreator: vi.fn(), - findPackagesWithServerJson: vi.fn(), - findPackageWithServerJsonByName: vi.fn(), findPackageForServerLookup: vi.fn(), findPackagesForServerListing: vi.fn(), - findLatestCompletedScan: vi.fn(), findVersionWithLatestScan: vi.fn(), findVersion: vi.fn(), getVersions: vi.fn(), diff --git a/apps/registry/tests/servers.test.ts b/apps/registry/tests/servers.test.ts new file mode 100644 index 0000000..7acf882 --- /dev/null +++ b/apps/registry/tests/servers.test.ts @@ -0,0 +1,370 @@ +/** + * Route-level tests for the MCP Registry endpoints (`/v0.1/servers` + * and `/v1/servers`). The composer is unit-tested in + * server-detail-composer.test.ts; this file covers the routing layer: + * URL parsing, npm-style ↔ reverse-DNS resolution, pagination params, + * 404 paths, the `updated_since` filter push-down, and the route's + * use of the pre-joined security-scan column. + */ + +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import sensible from '@fastify/sensible'; + +vi.mock('../src/config.js', () => ({ + config: { + storage: { + type: 'local', + path: '/tmp/test-storage', + cloudfront: { urlExpirationSeconds: 900, domain: '', keyPairId: '' }, + s3: { bucket: '', region: '', accessKeyId: '', secretAccessKey: '' }, + }, + scanner: { enabled: false, callbackSecret: 'test-secret' }, + server: { nodeEnv: 'test', port: 3000, host: '0.0.0.0', corsOrigins: [] }, + clerk: { secretKey: '' }, + limits: { maxBundleSizeMB: 50 }, + }, + validateConfig: vi.fn(), +})); + +vi.mock('../src/db/index.js', () => ({ + runInTransaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn({})), + getPrismaClient: vi.fn(), + disconnectDatabase: vi.fn(), +})); + +import { + createMockPackageRepo, + mockArtifact, + mockPackage, + mockVersion, +} from './helpers.js'; +import { errorHandler } from '../src/errors/middleware.js'; + +/** + * Build a "lookup row" — Package + versions[] each carrying artifacts + * and the (possibly empty) latest completed scan. Mirrors what the + * repo's `findPackageForServerLookup` returns. + */ +function lookupRow( + over: { + pkg?: Partial; + version?: Partial; + securityScans?: unknown[]; + } = {}, +) { + const pkg = { ...mockPackage, ...over.pkg }; + const version = { + ...mockVersion, + ...over.version, + artifacts: [mockArtifact], + securityScans: over.securityScans ?? [], + }; + return { ...pkg, versions: [version] }; +} + +describe('MCP Registry routes', () => { + let app: FastifyInstance; + let packageRepo: ReturnType; + + beforeAll(async () => { + packageRepo = createMockPackageRepo(); + app = Fastify({ logger: false }); + app.setReplySerializer((payload) => JSON.stringify(payload)); + await app.register(sensible); + app.setErrorHandler(errorHandler); + + app.decorate('repositories', { + packages: packageRepo, + users: {}, + skills: {}, + }); + + const { mcpRegistryRoutes } = await import('../src/routes/mcp/v0.1/servers.js'); + await app.register(mcpRegistryRoutes); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ───────────────────────────────────────────────────────────────── + // GET /servers (list) + // ───────────────────────────────────────────────────────────────── + + describe('GET /servers', () => { + it('returns a ServerListResponse from the listing repo method', async () => { + packageRepo.findPackagesForServerListing.mockResolvedValue({ + packages: [lookupRow()], + total: 1, + }); + + const res = await app.inject({ method: 'GET', url: '/servers' }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.payload); + expect(body.servers).toHaveLength(1); + expect(body.metadata.count).toBe(1); + expect(body.metadata.next_cursor).toBeUndefined(); + }); + + it('exposes next_cursor when more pages remain', async () => { + packageRepo.findPackagesForServerListing.mockResolvedValue({ + packages: [lookupRow()], + total: 250, + }); + + const res = await app.inject({ method: 'GET', url: '/servers?limit=100' }); + + const body = JSON.parse(res.payload); + expect(body.metadata.next_cursor).toBe('100'); + }); + + it('passes search through to the repo', async () => { + packageRepo.findPackagesForServerListing.mockResolvedValue({ packages: [], total: 0 }); + + await app.inject({ method: 'GET', url: '/servers?search=echo' }); + + expect(packageRepo.findPackagesForServerListing).toHaveBeenCalledWith( + expect.objectContaining({ search: 'echo' }), + expect.any(Object), + ); + }); + + it('pushes updated_since into the repo as a Date (filter applied at DB, not in JS post-fetch)', async () => { + packageRepo.findPackagesForServerListing.mockResolvedValue({ packages: [], total: 0 }); + + await app.inject({ + method: 'GET', + url: '/servers?updated_since=2026-04-01T00:00:00Z', + }); + + const callArgs = packageRepo.findPackagesForServerListing.mock.calls[0]; + expect(callArgs).toBeDefined(); + const filters = callArgs![0] as { updatedSince?: Date }; + expect(filters.updatedSince).toBeInstanceOf(Date); + expect((filters.updatedSince as Date).toISOString()).toBe('2026-04-01T00:00:00.000Z'); + }); + + it('ignores a malformed updated_since value (no-op filter)', async () => { + packageRepo.findPackagesForServerListing.mockResolvedValue({ packages: [], total: 0 }); + + await app.inject({ method: 'GET', url: '/servers?updated_since=not-a-date' }); + + const filters = (packageRepo.findPackagesForServerListing.mock.calls[0]?.[0] ?? {}) as { + updatedSince?: Date; + }; + expect(filters.updatedSince).toBeUndefined(); + }); + + it('coerces garbage cursor / limit values to defaults (no NaN reaches the repo)', async () => { + packageRepo.findPackagesForServerListing.mockResolvedValue({ packages: [], total: 0 }); + + await app.inject({ method: 'GET', url: '/servers?cursor=garbage&limit=also-garbage' }); + + const opts = (packageRepo.findPackagesForServerListing.mock.calls[0]?.[1] ?? {}) as { + skip: number; + take: number; + }; + expect(opts.skip).toBe(0); + expect(opts.take).toBe(100); + }); + + it('clamps limit > 500 to 500', async () => { + packageRepo.findPackagesForServerListing.mockResolvedValue({ packages: [], total: 0 }); + + await app.inject({ method: 'GET', url: '/servers?limit=10000' }); + + const opts = (packageRepo.findPackagesForServerListing.mock.calls[0]?.[1] ?? {}) as { + take: number; + }; + expect(opts.take).toBe(500); + }); + }); + + // ───────────────────────────────────────────────────────────────── + // GET /servers/search + // ───────────────────────────────────────────────────────────────── + + describe('GET /servers/search', () => { + it('forwards `q` to the listing repo', async () => { + packageRepo.findPackagesForServerListing.mockResolvedValue({ packages: [], total: 0 }); + + await app.inject({ method: 'GET', url: '/servers/search?q=echo' }); + + expect(packageRepo.findPackagesForServerListing).toHaveBeenCalledWith( + { search: 'echo' }, + expect.any(Object), + ); + }); + }); + + // ───────────────────────────────────────────────────────────────── + // GET /servers/{name} — name resolution + // ───────────────────────────────────────────────────────────────── + + describe('GET /servers/{name}', () => { + it('resolves an npm-style name via the direct lookup', async () => { + packageRepo.findPackageForServerLookup.mockResolvedValueOnce(lookupRow()); + + const res = await app.inject({ + method: 'GET', + url: '/servers/' + encodeURIComponent('@test/mcp-server'), + }); + + expect(res.statusCode).toBe(200); + expect(packageRepo.findPackageForServerLookup).toHaveBeenCalledWith('@test/mcp-server'); + }); + + it('lowercases the lookup name (npm names are case-insensitive at the registry)', async () => { + packageRepo.findPackageForServerLookup.mockResolvedValueOnce(lookupRow()); + + await app.inject({ + method: 'GET', + url: '/servers/' + encodeURIComponent('@Test/MCP-Server'), + }); + + expect(packageRepo.findPackageForServerLookup).toHaveBeenCalledWith('@test/mcp-server'); + }); + + it('resolves a reverse-DNS name to its npm-style origin via the candidate map', async () => { + // First call (direct lookup with the reverse-DNS string itself) misses; + // second call (candidate) hits. + packageRepo.findPackageForServerLookup.mockImplementation(async (name: string) => { + if (name === '@nimblebraininc/echo') { + return lookupRow({ + pkg: { name: '@nimblebraininc/echo' }, + version: { manifest: { name: '@nimblebraininc/echo', version: '0.1.0' } }, + }); + } + return null; + }); + + const res = await app.inject({ + method: 'GET', + url: '/servers/' + encodeURIComponent('ai.nimblebrain/echo'), + }); + + expect(res.statusCode).toBe(200); + // Two calls: first the direct (miss for "ai.nimblebrain/echo"), then + // the candidate ("@nimblebraininc/echo") that hit. + expect(packageRepo.findPackageForServerLookup).toHaveBeenCalledTimes(2); + }); + + it('returns 404 with the requested name in the message when the package is missing', async () => { + packageRepo.findPackageForServerLookup.mockResolvedValue(null); + + const res = await app.inject({ + method: 'GET', + url: '/servers/' + encodeURIComponent('@missing/server'), + }); + + expect(res.statusCode).toBe(404); + const body = JSON.parse(res.payload); + expect(body.error).toContain('@missing/server'); + }); + + it('uses the pre-joined security scan (no extra repo call for findLatestCompletedScan)', async () => { + packageRepo.findPackageForServerLookup.mockResolvedValueOnce( + lookupRow({ + securityScans: [ + { + certificationLevel: 1, + controlsPassed: 15, + controlsFailed: 1, + controlsTotal: 16, + }, + ], + }), + ); + + const res = await app.inject({ + method: 'GET', + url: '/servers/' + encodeURIComponent('@test/mcp-server'), + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.payload); + const cert = body._meta['dev.mpak/registry'].certification; + expect(cert).toEqual({ + level: 1, + controlsPassed: 15, + controlsFailed: 1, + controlsTotal: 16, + }); + // The route should NOT have made a separate scan lookup. + expect(packageRepo.findLatestCompletedScan).not.toBeDefined(); + }); + }); + + // ───────────────────────────────────────────────────────────────── + // GET /servers/{name}/versions/{version} + // ───────────────────────────────────────────────────────────────── + + describe('GET /servers/{name}/versions/{version}', () => { + it('returns the version-specific ServerDetail when the version exists', async () => { + packageRepo.findPackageForServerLookup.mockResolvedValueOnce( + lookupRow({ version: { version: '1.0.0' } }), + ); + + const res = await app.inject({ + method: 'GET', + url: '/servers/' + encodeURIComponent('@test/mcp-server') + '/versions/1.0.0', + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.payload); + expect(body.version).toBe('1.0.0'); + }); + + it('treats "latest" as an alias for the most recent version', async () => { + packageRepo.findPackageForServerLookup.mockResolvedValueOnce(lookupRow()); + + const res = await app.inject({ + method: 'GET', + url: '/servers/' + encodeURIComponent('@test/mcp-server') + '/versions/latest', + }); + + expect(res.statusCode).toBe(200); + }); + + it('404s when the version is unknown', async () => { + packageRepo.findPackageForServerLookup.mockResolvedValueOnce( + lookupRow({ version: { version: '1.0.0' } }), + ); + + const res = await app.inject({ + method: 'GET', + url: '/servers/' + encodeURIComponent('@test/mcp-server') + '/versions/9.9.9', + }); + + expect(res.statusCode).toBe(404); + }); + }); + + // ───────────────────────────────────────────────────────────────── + // GET /servers/{name}/versions + // ───────────────────────────────────────────────────────────────── + + describe('GET /servers/{name}/versions', () => { + it('lists the versions for a server', async () => { + packageRepo.findPackageForServerLookup.mockResolvedValueOnce(lookupRow()); + + const res = await app.inject({ + method: 'GET', + url: '/servers/' + encodeURIComponent('@test/mcp-server') + '/versions', + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.payload); + expect(body.name).toBe('@test/mcp-server'); + expect(body.versions[0].version).toBe('1.0.0'); + expect(body.versions[0].is_latest).toBe(true); + }); + }); +}); From 5ec0d8aee4a00c61358213a4780b6ca55621014d Mon Sep 17 00:00:00 2001 From: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com> Date: Sat, 9 May 2026 07:51:49 -1000 Subject: [PATCH 4/5] fix: address second QA round on PR #100 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three criticals from the round-2 review, plus four polish fixes. ## Critical: Python SDK URL-encodes path segments `packages/sdk-python/src/mpak/client.py:313, :338` — `get_server` / `get_server_version` built the URL with raw f-strings, so httpx sent literal `/` and `@` in the path. Fastify's `:name` parameter is single-segment, so the path didn't match `/servers/:name` and every production call 404'd. Confirmed by reviewer against the live registry. Fix: `quote(name, safe="")` and `quote(version, safe="")` before splicing into the URL. The TypeScript SDK already uses `encodeURIComponent`; Python now matches. ## Critical: SDK tests no longer tautological The Python tests registered respx mocks at the same wrong URL the SDK was building, so they passed against a broken client. After the encoding fix, the mocks now register at the correct encoded paths (`/v1/servers/%40nimblebraininc%2Fecho`) AND assert the SDK actually sent that raw_path. A regression to unencoded f-strings now fails the suite. ## Critical: Composer header docstring matches the code `apps/registry/src/services/server-detail-composer.ts:15` claimed `version` came from `manifest.version` with `PackageVersion.version` as a fallback — but the QA-fix commit already pinned both top-level and `packages[].version` to the DB row. Rewrote the docstring to match the code: `manifest.version` is intentionally ignored. ## Title truncated to 100 chars `apps/registry/src/services/server-detail-composer.ts:125` — same class as the icon / fileSha256 fixes from the previous round. A publisher with a >100-char `display_name` (or a >100-char npm package name in the fallback path) used to reject the entire ServerDetail at the schema boundary and 500 the route. `title = truncate(..., 100)` keeps the rest of the record intact. ## Reverse-DNS override authorization `packages/schemas/src/server-detail.ts:218` — previously, any publisher could set `_meta["dev.mpak/registry"].name` to any upstream-pattern-matching value. A publisher of `@evil/spam` could label themselves `io.modelcontextprotocol/legitimate-tool` and have the squatted name appear in registry listings. `isOverrideAuthorized` now requires the override's namespace to either match the publisher's curated org-mapped reverse-DNS (`@nimblebraininc` may claim `ai.nimblebrain/*`) OR fall under the publisher's mechanical-default namespace (`@` always implicitly owns `dev.mpak./*`). Anything else — falls back silently to the mechanical default. Registry-side validation can upgrade this to a publish-time rejection later (using OIDC claims to verify scope ownership). This composer-side gate prevents the squatted label from reaching listings now. ## Polish - **Drop unused `version: { enum: ['latest'] }` querystring** on `GET /servers` — declared in the schema, never read in the handler. Cleaned up. - **`updated_since` semantics documented** in the route's OpenAPI description: "Returns servers with at least one version published since the given time. Filtered at the database; pagination math reflects the filter." (Was the implicit semantic from the previous QA fix's push-down to Prisma; now made explicit.) - **RFC 8594-conformant `Deprecation` header** — was emitting `Deprecation: true` (a superseded-draft shortcut). Now emits the IMF-fixdate when the deprecation took effect; strict parsers will honor it. ## Tests 3 new composer cases: - `honors author override under the publisher's curated org-mapped namespace` - `honors author override under the publisher's mechanical-default namespace` - `silently ignores a squatted override (publisher claiming a namespace they don't own)` - `truncates title longer than the upstream 100-char cap (no schema reject + 500)` The existing tautological override test was rewritten — the prior case asserted `com.acme/custom-name` was honored (which is exactly the squatting scenario we now block). Python SDK: 5 tests rewritten to register at encoded URLs and assert `request.url.raw_path` against the encoded form. 28 tests pass; 130 registry tests; 244 TS SDK tests; 99 schemas; 61 web. --- apps/registry/src/index.ts | 10 +- apps/registry/src/routes/mcp/v0.1/servers.ts | 11 +- .../src/services/server-detail-composer.ts | 14 +- .../tests/server-detail-composer.test.ts | 43 ++- apps/web/public/feed.xml | 346 +++++++++++++----- apps/web/public/sitemap.xml | 244 +++++++++++- packages/schemas/src/server-detail.ts | 48 ++- packages/sdk-python/src/mpak/client.py | 15 +- packages/sdk-python/tests/test_client.py | 52 ++- 9 files changed, 627 insertions(+), 156 deletions(-) diff --git a/apps/registry/src/index.ts b/apps/registry/src/index.ts index e731508..5d5ebd1 100644 --- a/apps/registry/src/index.ts +++ b/apps/registry/src/index.ts @@ -152,9 +152,17 @@ async function start() { // depends on them) but consumers fetching read shapes should // migrate. Skip POST /announce — that's a publish path, not a // consumer-read path. + // + // RFC 8594 specifies `Deprecation = HTTP-date / "@" 1*DIGIT`. The + // boolean string "true" is a common shortcut from a superseded + // draft but isn't conformant; strict parsers ignore it. Emit an + // IMF-fixdate equal to when the deprecation took effect (the + // first commit of this PR's day) so the header round-trips + // through any conforming client. + const DEPRECATION_DATE = 'Fri, 09 May 2026 00:00:00 GMT'; instance.addHook('onSend', async (request, reply) => { if (request.method === 'GET' || request.method === 'HEAD') { - reply.header('Deprecation', 'true'); + reply.header('Deprecation', DEPRECATION_DATE); reply.header('Link', '; rel="successor-version"'); } }); diff --git a/apps/registry/src/routes/mcp/v0.1/servers.ts b/apps/registry/src/routes/mcp/v0.1/servers.ts index 37e8c34..fc046fa 100644 --- a/apps/registry/src/routes/mcp/v0.1/servers.ts +++ b/apps/registry/src/routes/mcp/v0.1/servers.ts @@ -200,14 +200,13 @@ export const mcpRegistryRoutes: FastifyPluginAsync = async (fastify) => { cursor?: string; limit?: string; search?: string; - version?: string; updated_since?: string; }; }>('/servers', { schema: { tags: ['mcp-registry'], description: - 'List MCP servers (each entry is a ServerDetail per the upstream MCP registry spec)', + 'List MCP servers (each entry is a ServerDetail per the upstream MCP registry spec). Each item is the latest published version of a server; per-version listings live under /servers/{name}/versions.', querystring: { type: 'object', properties: { @@ -217,14 +216,10 @@ export const mcpRegistryRoutes: FastifyPluginAsync = async (fastify) => { type: 'string', description: 'Case-insensitive substring search on name/displayName/description', }, - version: { - type: 'string', - enum: ['latest'], - description: 'Filter to latest versions only', - }, updated_since: { type: 'string', - description: 'RFC 3339 timestamp filter for recently updated servers', + description: + 'RFC 3339 timestamp. Returns servers with at least one version published since the given time. Filtered at the database; pagination math reflects the filter.', }, }, }, diff --git a/apps/registry/src/services/server-detail-composer.ts b/apps/registry/src/services/server-detail-composer.ts index df6f70d..7512369 100644 --- a/apps/registry/src/services/server-detail-composer.ts +++ b/apps/registry/src/services/server-detail-composer.ts @@ -12,8 +12,10 @@ * curated org map applied first when applicable) * title manifest.display_name ?? manifest.name * description manifest.description (truncated to 100 chars; upstream cap) - * version manifest.version (PackageVersion.version on the rare - * occasion the two diverge) + * version PackageVersion.version (the DB row, source of truth + * post-publish — manifest.version is intentionally + * ignored so top-level `version` and per-package + * `packages[].version` can never disagree) * websiteUrl manifest.homepage * repository manifest.repository * icons[] manifest.icons[] when set, else [{ src: manifest.icon }] @@ -120,7 +122,13 @@ function buildDetail(input: ComposerInput): Record { const description = truncate(stringField(manifest, "description") ?? input.pkg.name, 100); const reverseDnsName = resolveReverseDnsName(input.pkg.name, manifestMeta); const display = stringField(manifest, "display_name"); - const title = display && display.trim().length > 0 ? display.trim() : input.pkg.name; + // Upstream `Title` caps at 100 chars; truncate so a long display_name + // (or a long npm scope/name when it falls back) doesn't reject the + // entire record at the schema boundary and 500 the route. + const title = truncate( + display && display.trim().length > 0 ? display.trim() : input.pkg.name, + 100, + ); const detail: Record = { $schema: UPSTREAM_SCHEMA_URL, diff --git a/apps/registry/tests/server-detail-composer.test.ts b/apps/registry/tests/server-detail-composer.test.ts index e6b5fa9..d08da4c 100644 --- a/apps/registry/tests/server-detail-composer.test.ts +++ b/apps/registry/tests/server-detail-composer.test.ts @@ -116,13 +116,39 @@ describe('composeServerDetail', () => { }); }); - it('honors author reverse-DNS name override at _meta["dev.mpak/registry"].name', () => { + it('honors author reverse-DNS override under the publisher\'s curated org-mapped namespace', () => { + // @nimblebraininc → ai.nimblebrain (per ORG_REVERSE_DNS_MAP), + // so this publisher may claim any ai.nimblebrain/* name. + const m = { + ...FULL_MANIFEST, + _meta: { 'dev.mpak/registry': { name: 'ai.nimblebrain/custom-name' } }, + }; + const detail = composeServerDetail(input({ version: { ...input().version, manifest: m } })); + expect(detail?.name).toBe('ai.nimblebrain/custom-name'); + }); + + it('honors author override under the publisher\'s mechanical-default namespace', () => { + // Any publisher implicitly owns `dev.mpak./*`. + const m = { + ...FULL_MANIFEST, + _meta: { 'dev.mpak/registry': { name: 'dev.mpak.nimblebraininc/relabeled' } }, + }; + const detail = composeServerDetail(input({ version: { ...input().version, manifest: m } })); + expect(detail?.name).toBe('dev.mpak.nimblebraininc/relabeled'); + }); + + it('silently ignores a squatted override (publisher claiming a namespace they don\'t own)', () => { + // @nimblebraininc trying to label themselves under com.acme — not + // their org, not their mechanical default. Override drops; record + // falls back to the curated/mechanical default. Prevents + // `@evil/spam` from publishing as `io.modelcontextprotocol/legit` + // and squatting in registry listings. const m = { ...FULL_MANIFEST, _meta: { 'dev.mpak/registry': { name: 'com.acme/custom-name' } }, }; const detail = composeServerDetail(input({ version: { ...input().version, manifest: m } })); - expect(detail?.name).toBe('com.acme/custom-name'); + expect(detail?.name).toBe('ai.nimblebrain/echo'); }); it('falls back to the npm name for title when display_name is missing', () => { @@ -145,6 +171,19 @@ describe('composeServerDetail', () => { expect(detail?.description.endsWith('…')).toBe(true); }); + it('truncates title longer than the upstream 100-char cap (no schema reject + 500)', () => { + // A long display_name used to bubble up to ServerDetailSchema.title's + // max(100), reject the entire record via safeParse, return null, + // and 500 the route. Truncate at the projection so the rest of the + // record still serves. + const longTitle = 'A'.repeat(150); + const m = { ...FULL_MANIFEST, display_name: longTitle }; + const detail = composeServerDetail(input({ version: { ...input().version, manifest: m } })); + expect(detail).not.toBeNull(); + expect(detail?.title?.length).toBe(100); + expect(detail?.title?.endsWith('…')).toBe(true); + }); + it('returns null when the manifest is too malformed to project (invalid name)', () => { // Mechanical reverse-DNS for a missing name would fail the upstream // pattern; ServerDetailSchema rejects, composer returns null. diff --git a/apps/web/public/feed.xml b/apps/web/public/feed.xml index 936564d..ebbc746 100644 --- a/apps/web/public/feed.xml +++ b/apps/web/public/feed.xml @@ -5,13 +5,13 @@ https://www.mpak.dev Recently published MCP server bundles and agent skills on mpak. en-us - Wed, 11 Feb 2026 09:52:56 GMT + Sat, 09 May 2026 17:51:02 GMT @nimblebraininc/docs-auditor https://www.mpak.dev/skills/@nimblebraininc/docs-auditor Systematically audit documentation against actual codebase to determine accuracy, staleness, and relevance. Use when auditing docs for accuracy, cleaning up stale docs after refactoring, validating docs match implementation, or building documentation health reports. - Wed, 11 Feb 2026 09:52:56 GMT + Sat, 09 May 2026 17:51:02 GMT Skills https://www.mpak.dev/skills/@nimblebraininc/docs-auditor @@ -19,7 +19,7 @@ @nimblebraininc/qa-tester https://www.mpak.dev/skills/@nimblebraininc/qa-tester Comprehensive QA engineer that designs test strategies, writes test cases, identifies edge cases, and validates implementations. Use when writing tests, reviewing test coverage, planning QA strategy, identifying edge cases, or validating feature implementations. Triggers include "write tests for", "test this feature", "what are the edge cases", "review test coverage", or "QA this". - Wed, 11 Feb 2026 09:52:56 GMT + Sat, 09 May 2026 17:51:02 GMT Skills https://www.mpak.dev/skills/@nimblebraininc/qa-tester @@ -27,7 +27,7 @@ @nimblebraininc/skill-author https://www.mpak.dev/skills/@nimblebraininc/skill-author Creates production-grade Claude Code skills from natural language descriptions. Use when building new skills, requested with "build me a skill that...", "create a skill for...", or "I need a skill to...". Generates complete skill files with proper frontmatter, context references, and quality self-assessment. - Wed, 11 Feb 2026 09:52:56 GMT + Sat, 09 May 2026 17:51:02 GMT Skills https://www.mpak.dev/skills/@nimblebraininc/skill-author @@ -35,7 +35,7 @@ @nimblebraininc/seo-optimizer https://www.mpak.dev/skills/@nimblebraininc/seo-optimizer Analyzes and optimizes content for search engine visibility. Use when reviewing blog posts for SEO, optimizing landing pages, checking meta descriptions, analyzing keyword usage, or improving content discoverability. Triggers include "optimize for SEO", "check SEO", "improve search ranking", or "keyword analysis". - Wed, 11 Feb 2026 09:52:56 GMT + Sat, 09 May 2026 17:51:02 GMT Skills https://www.mpak.dev/skills/@nimblebraininc/seo-optimizer @@ -43,7 +43,7 @@ @nimblebraininc/strategic-thought-partner https://www.mpak.dev/skills/@nimblebraininc/strategic-thought-partner Collaborative strategic thinking for founders, operators, and decision-makers. Use when someone needs help working through business strategy, product direction, positioning, prioritization, or major decisions. Triggers include requests to think through strategy, evaluate tradeoffs, pressure-test ideas, clarify direction, scope products, or navigate pivots. Not for execution, for clarification and decision-making. - Wed, 11 Feb 2026 09:52:56 GMT + Sat, 09 May 2026 17:51:02 GMT Skills https://www.mpak.dev/skills/@nimblebraininc/strategic-thought-partner @@ -51,7 +51,7 @@ @nimblebraininc/whitepaper-editor https://www.mpak.dev/skills/@nimblebraininc/whitepaper-editor Brutal technical white paper editor that performs merciless review with research-backed validation. Use when reviewing, editing, or improving white papers, technical documents, research papers, or any document requiring rigorous technical scrutiny. Triggers on requests to review white papers, validate citations, check technical accuracy, perform gap analysis, or score document quality. - Wed, 11 Feb 2026 09:52:56 GMT + Sat, 09 May 2026 17:51:02 GMT Skills https://www.mpak.dev/skills/@nimblebraininc/whitepaper-editor @@ -59,7 +59,7 @@ @nimblebraininc/blog-editor https://www.mpak.dev/skills/@nimblebraininc/blog-editor Brutal content editor that tears apart drafts for substance, readability, and voice alignment. Use when reviewing blog posts, articles, or marketing copy. Triggers include "review this draft", "edit my post", "check this article", "is this ready to publish", or "tear apart my writing". - Wed, 11 Feb 2026 09:52:56 GMT + Sat, 09 May 2026 17:51:02 GMT Skills https://www.mpak.dev/skills/@nimblebraininc/blog-editor @@ -67,7 +67,7 @@ @nimblebraininc/contrarian-thought-partner https://www.mpak.dev/skills/@nimblebraininc/contrarian-thought-partner Brutal, honest critique of strategies, plans, and analyses. Use when someone explicitly wants their thinking torn apart, stress-tested to destruction, or called out on weak logic. Triggers include requests like "tear this apart", "be brutal", "what's wrong with this", "play devil's advocate", "tell me why this won't work", or "poke holes in this". This is adversarial by design, not for collaborative exploration, but for ruthless pressure-testing. - Wed, 11 Feb 2026 09:52:56 GMT + Sat, 09 May 2026 17:51:02 GMT Skills https://www.mpak.dev/skills/@nimblebraininc/contrarian-thought-partner @@ -75,7 +75,7 @@ @nimblebraininc/zoom https://www.mpak.dev/skills/@nimblebraininc/zoom Handles Zoom meeting creation with proper invitation delivery. Use when scheduling Zoom meetings or when user mentions meeting participants. Triggers include "schedule zoom", "create meeting with", "zoom call with". - Wed, 11 Feb 2026 09:52:56 GMT + Sat, 09 May 2026 17:51:02 GMT Skills https://www.mpak.dev/skills/@nimblebraininc/zoom @@ -83,7 +83,7 @@ @nimblebraininc/googlesheets https://www.mpak.dev/skills/@nimblebraininc/googlesheets Connection skill for Google Sheets MCP integration. Provides guidance on spreadsheet ID extraction, tool chaining, and avoiding common hallucination patterns. Use when searching sheets, listing tables, or reading data. - Wed, 11 Feb 2026 09:52:56 GMT + Sat, 09 May 2026 17:51:02 GMT Skills https://www.mpak.dev/skills/@nimblebraininc/googlesheets @@ -91,177 +91,321 @@ @nimblebraininc/folk-crm https://www.mpak.dev/skills/@nimblebraininc/folk-crm Guides Folk CRM tool usage with correct routing for groups, people, and companies. Use when interacting with Folk CRM tools. Triggers include "folk", "group", "leads", "contacts", "CRM". - Wed, 11 Feb 2026 09:52:56 GMT + Sat, 09 May 2026 17:51:02 GMT Skills https://www.mpak.dev/skills/@nimblebraininc/folk-crm + + @nimblebraininc/ipinfo + https://www.mpak.dev/skills/@nimblebraininc/ipinfo + Guides IP lookups with context reuse and proper parameters. Triggers on IP lookups, VPN detection, abuse contacts. + Sat, 09 May 2026 17:51:02 GMT + Skills + https://www.mpak.dev/skills/@nimblebraininc/ipinfo + @nimblebraininc/googlecalendar https://www.mpak.dev/skills/@nimblebraininc/googlecalendar Manages Google Calendar events with proper timezone handling. Use when scheduling, updating, or patching calendar events. Triggers include "schedule meeting", "update event", "change meeting time", "reschedule", "make it 30 minutes". - Wed, 11 Feb 2026 09:52:56 GMT + Sat, 09 May 2026 17:51:02 GMT Skills https://www.mpak.dev/skills/@nimblebraininc/googlecalendar - @nimblebraininc/ipinfo - https://www.mpak.dev/skills/@nimblebraininc/ipinfo - Guides IP lookups with context reuse and proper parameters. Triggers on IP lookups, VPN detection, abuse contacts. - Wed, 11 Feb 2026 09:52:56 GMT + @nimblebraininc/build-mcpb + https://www.mpak.dev/skills/@nimblebraininc/build-mcpb + Build MCP servers end-to-end. Scaffolds a production-ready Python or TypeScript server from API documentation, implements tools, validates the MCPB bundle, creates an embedded skill resource, and guides release to the mpak registry. Covers the full lifecycle from API analysis to published bundle. Use when building a new MCP server, wrapping an API, or creating an integration. Triggers include "build an MCP server", "create a server for X", "/build-mcpb". + Sat, 09 May 2026 17:51:02 GMT Skills - https://www.mpak.dev/skills/@nimblebraininc/ipinfo + https://www.mpak.dev/skills/@nimblebraininc/build-mcpb + + + @nimblebraininc/nimblebrain-contributor + https://www.mpak.dev/skills/@nimblebraininc/nimblebrain-contributor + Get started contributing to NimbleBrain open source. Find available integrations to build, set up your environment, propose new ideas, or check on your work — with depth available whenever you want it. Triggers include "I'm a new contributor", "onboard me", "what should I build", "show me open issues", "file an issue". + Sat, 09 May 2026 17:51:02 GMT + Skills + https://www.mpak.dev/skills/@nimblebraininc/nimblebrain-contributor @nimblebraininc/openweathermap https://www.mpak.dev/skills/@nimblebraininc/openweathermap Handles location resolution and geocoding fallback for OpenWeatherMap weather queries. Use when getting weather data, forecasts, or air quality. Triggers include "weather in", "forecast for", "air quality". - Wed, 11 Feb 2026 09:52:56 GMT + Sat, 09 May 2026 17:51:02 GMT Skills https://www.mpak.dev/skills/@nimblebraininc/openweathermap - - @nimblebraininc/pdfco - https://www.mpak.dev/skills/@nimblebraininc/pdfco - Connection skill for PDF.co MCP server. Provides guidance on HTML-to-PDF conversion, document manipulation, and best practices for reliable PDF generation. - Wed, 11 Feb 2026 09:52:56 GMT - Skills - https://www.mpak.dev/skills/@nimblebraininc/pdfco - @nimblebraininc/asana https://www.mpak.dev/skills/@nimblebraininc/asana Guides Asana tool usage with correct workspace/project discovery, task creation, and GID handling. Use when interacting with Asana tools. Triggers include "asana", "task", "project", "my tasks", "create task". - Wed, 11 Feb 2026 09:52:56 GMT + Sat, 09 May 2026 17:51:02 GMT Skills https://www.mpak.dev/skills/@nimblebraininc/asana + + @nimblebraininc/upjack-app-builder + https://www.mpak.dev/skills/@nimblebraininc/upjack-app-builder + Builds complete, compliant NimbleBrain Upjack apps from natural language descriptions. Generates manifest, entity schemas, skills, context, seed data, and server entry point. Use when creating a new Upjack app, building an AI-native app, or scaffolding a domain-specific application. Triggers include "create me a todo app", "build an upjack app", "new upjack app for", "scaffold an app". + Sat, 09 May 2026 17:51:02 GMT + Skills + https://www.mpak.dev/skills/@nimblebraininc/upjack-app-builder + + + @nimblebraininc/pdfco + https://www.mpak.dev/skills/@nimblebraininc/pdfco + Connection skill for PDF.co MCP server. Provides guidance on HTML-to-PDF conversion, document manipulation, and best practices for reliable PDF generation. + Sat, 09 May 2026 17:51:02 GMT + Skills + https://www.mpak.dev/skills/@nimblebraininc/pdfco + @nimblebraininc/zoho-crm https://www.mpak.dev/skills/@nimblebraininc/zoho-crm Guides Zoho CRM tool usage with correct module routing for leads, contacts, deals, and tasks. Use when interacting with Zoho CRM tools. Triggers include "zoho", "leads", "contacts", "deals", "tasks", "overdue", "CRM". - Wed, 11 Feb 2026 09:52:56 GMT + Sat, 09 May 2026 17:51:02 GMT Skills https://www.mpak.dev/skills/@nimblebraininc/zoho-crm - @nimblebraininc/echo v0.1.5 - https://www.mpak.dev/packages/@nimblebraininc/echo - Echo server for testing and debugging MCP connections - Wed, 11 Feb 2026 08:11:50 GMT - Bundles - https://www.mpak.dev/packages/@nimblebraininc/echo - - - @nimblebraininc/folk v0.1.8 - https://www.mpak.dev/packages/@nimblebraininc/folk - Folk CRM server for managing people, companies, notes, and reminders - Tue, 27 Jan 2026 17:55:58 GMT + @nimblebraininc/pdfco v0.4.1 + https://www.mpak.dev/packages/@nimblebraininc/pdfco + PDF.co MCP Server with comprehensive PDF manipulation and OpenAPI support + Fri, 08 May 2026 09:02:18 GMT Bundles - https://www.mpak.dev/packages/@nimblebraininc/folk + https://www.mpak.dev/packages/@nimblebraininc/pdfco - @nimblebraininc/openweathermap v0.2.0 + @nimblebraininc/openweathermap v0.4.2 https://www.mpak.dev/packages/@nimblebraininc/openweathermap OpenWeatherMap MCP Server for weather data, forecasts, alerts, and air quality - Mon, 26 Jan 2026 06:45:44 GMT + Fri, 08 May 2026 09:01:59 GMT Bundles https://www.mpak.dev/packages/@nimblebraininc/openweathermap - @nimblebraininc/pdfco v0.2.0 - https://www.mpak.dev/packages/@nimblebraininc/pdfco - PDF.co MCP Server with comprehensive PDF manipulation and OpenAPI support - Wed, 21 Jan 2026 03:48:01 GMT + @nimblebraininc/newsapi v0.1.1 + https://www.mpak.dev/packages/@nimblebraininc/newsapi + Search news articles and top headlines using the NewsAPI + Fri, 08 May 2026 09:01:51 GMT Bundles - https://www.mpak.dev/packages/@nimblebraininc/pdfco + https://www.mpak.dev/packages/@nimblebraininc/newsapi - @nimblebraininc/mcpb-test v1.0.0 - https://www.mpak.dev/packages/@nimblebraininc/mcpb-test - Test bundle for mcpb-pack existing bundle feature - Thu, 15 Jan 2026 07:46:33 GMT + @nimblebraininc/nationalparks v0.2.2 + https://www.mpak.dev/packages/@nimblebraininc/nationalparks + MCP server for National Parks Service API + Fri, 08 May 2026 09:01:32 GMT Bundles - https://www.mpak.dev/packages/@nimblebraininc/mcpb-test + https://www.mpak.dev/packages/@nimblebraininc/nationalparks - @nimblebraininc/github v0.26.3-mcpb.2 - https://www.mpak.dev/packages/@nimblebraininc/github - GitHub MCP server for repository management, issues, PRs, and workflow automation - Mon, 05 Jan 2026 07:57:41 GMT + @nimblebraininc/mcp-massive v0.1.1 + https://www.mpak.dev/packages/@nimblebraininc/mcp-massive + Financial market data, technical indicators, and SEC filings MCP service powered by Massive API + Fri, 08 May 2026 09:01:22 GMT Bundles - https://www.mpak.dev/packages/@nimblebraininc/github + https://www.mpak.dev/packages/@nimblebraininc/mcp-massive - @nimblebraininc/ref-tools-mcp v3.0.3-mcpb.5 - https://www.mpak.dev/packages/@nimblebraininc/ref-tools-mcp - Token-efficient documentation search for AI coding agents - Mon, 05 Jan 2026 07:56:19 GMT + @nimblebraininc/ipinfo v0.3.2 + https://www.mpak.dev/packages/@nimblebraininc/ipinfo + IP intelligence server with geolocation, ASN, company, and privacy detection + Fri, 08 May 2026 09:01:14 GMT Bundles - https://www.mpak.dev/packages/@nimblebraininc/ref-tools-mcp + https://www.mpak.dev/packages/@nimblebraininc/ipinfo - @nimblebraininc/clickhouse v0.1.13-mcpb.4 - https://www.mpak.dev/packages/@nimblebraininc/clickhouse - ClickHouse database connectivity with read-only SQL queries, schema exploration, and chDB support - Mon, 05 Jan 2026 07:42:55 GMT + @nimblebraininc/hunter v0.1.1 + https://www.mpak.dev/packages/@nimblebraininc/hunter + MCP server for Hunter.io — email discovery, verification, and enrichment + Fri, 08 May 2026 09:00:59 GMT Bundles - https://www.mpak.dev/packages/@nimblebraininc/clickhouse + https://www.mpak.dev/packages/@nimblebraininc/hunter - @nimblebraininc/nationalparks v0.1.5 - https://www.mpak.dev/packages/@nimblebraininc/nationalparks - MCP server for National Parks Service API - Mon, 05 Jan 2026 07:08:30 GMT + @nimblebraininc/granola v0.3.1 + https://www.mpak.dev/packages/@nimblebraininc/granola + Search and extract information from Granola meeting notes + Fri, 08 May 2026 09:00:48 GMT Bundles - https://www.mpak.dev/packages/@nimblebraininc/nationalparks + https://www.mpak.dev/packages/@nimblebraininc/granola - @nimblebraininc/abstract v0.1.3 - https://www.mpak.dev/packages/@nimblebraininc/abstract - Abstract API server with email validation, phone validation, IP geolocation, and more - Mon, 05 Jan 2026 06:14:09 GMT + @nimblebraininc/git-worktree v0.1.1 + https://www.mpak.dev/packages/@nimblebraininc/git-worktree + Git worktree manager for isolated workspaces with branch lifecycle, risk classification, and merge control + Fri, 08 May 2026 08:59:09 GMT Bundles - https://www.mpak.dev/packages/@nimblebraininc/abstract + https://www.mpak.dev/packages/@nimblebraininc/git-worktree + + + @nimblebraininc/folk v0.3.1 + https://www.mpak.dev/packages/@nimblebraininc/folk + Folk CRM server for managing people, companies, notes, and reminders + Fri, 08 May 2026 08:58:49 GMT + Bundles + https://www.mpak.dev/packages/@nimblebraininc/folk + + + @nimblebraininc/finnhub v0.3.1 + https://www.mpak.dev/packages/@nimblebraininc/finnhub + Financial market data and news MCP service powered by Finnhub API + Fri, 08 May 2026 08:58:44 GMT + Bundles + https://www.mpak.dev/packages/@nimblebraininc/finnhub - @nimblebraininc/postgres v0.3.1-mcpb.4 - https://www.mpak.dev/packages/@nimblebraininc/postgres - PostgreSQL MCP server with AI-powered tuning, index optimization, and database health analysis - Mon, 05 Jan 2026 06:00:56 GMT + @nimblebraininc/echo v0.1.6 + https://www.mpak.dev/packages/@nimblebraininc/echo + Echo server for testing and debugging MCP connections + Fri, 08 May 2026 08:58:31 GMT Bundles - https://www.mpak.dev/packages/@nimblebraininc/postgres + https://www.mpak.dev/packages/@nimblebraininc/echo - @nimblebraininc/deepl v0.1.2 + @nimblebraininc/deepl v0.2.1 https://www.mpak.dev/packages/@nimblebraininc/deepl DeepL translation API with comprehensive translation tools - Mon, 05 Jan 2026 05:45:48 GMT + Fri, 08 May 2026 08:58:23 GMT Bundles https://www.mpak.dev/packages/@nimblebraininc/deepl - @nimblebraininc/finnhub v0.1.2 - https://www.mpak.dev/packages/@nimblebraininc/finnhub - Financial market data and news MCP service powered by Finnhub API - Mon, 05 Jan 2026 05:45:34 GMT + @nimblebraininc/brave-search v0.1.1 + https://www.mpak.dev/packages/@nimblebraininc/brave-search + Web search using the Brave Search API + Fri, 08 May 2026 08:58:20 GMT Bundles - https://www.mpak.dev/packages/@nimblebraininc/finnhub + https://www.mpak.dev/packages/@nimblebraininc/brave-search - @nimblebraininc/reverse-text v0.1.1 - https://www.mpak.dev/packages/@nimblebraininc/reverse-text - Text manipulation MCP service with reverse and analysis tools - Mon, 05 Jan 2026 05:39:44 GMT + @nimblebraininc/bash v0.1.1 + https://www.mpak.dev/packages/@nimblebraininc/bash + Execute bash commands via MCP + Fri, 08 May 2026 08:58:01 GMT Bundles - https://www.mpak.dev/packages/@nimblebraininc/reverse-text + https://www.mpak.dev/packages/@nimblebraininc/bash - @nimblebraininc/ipinfo v0.1.1 - https://www.mpak.dev/packages/@nimblebraininc/ipinfo - IP intelligence server with geolocation, ASN, company, and privacy detection - Mon, 05 Jan 2026 02:46:51 GMT + @nimblebraininc/aws-ses v0.1.1 + https://www.mpak.dev/packages/@nimblebraininc/aws-ses + Send emails using AWS Simple Email Service (SES) + Fri, 08 May 2026 08:57:53 GMT Bundles - https://www.mpak.dev/packages/@nimblebraininc/ipinfo + https://www.mpak.dev/packages/@nimblebraininc/aws-ses + + + @nimblebraininc/abstract v0.2.1 + https://www.mpak.dev/packages/@nimblebraininc/abstract + Abstract API server with email validation, phone validation, IP geolocation, and more + Fri, 08 May 2026 08:54:20 GMT + Bundles + https://www.mpak.dev/packages/@nimblebraininc/abstract + + + @nimblebraininc/synapse-mcp-dev-summit v0.6.0 + https://www.mpak.dev/packages/@nimblebraininc/synapse-mcp-dev-summit + Conference companion for MCP Dev Summit NA 2026 — search sessions, build a personal schedule, capture notes, and get AI-powered recommendations + Fri, 08 May 2026 08:45:45 GMT + Bundles + https://www.mpak.dev/packages/@nimblebraininc/synapse-mcp-dev-summit + + + @nimblebraininc/synapse-crm v0.3.0 + https://www.mpak.dev/packages/@nimblebraininc/synapse-crm + Lightweight contact and deal tracker with agent-driven follow-ups and pipeline reviews. + Fri, 08 May 2026 03:40:56 GMT + Bundles + https://www.mpak.dev/packages/@nimblebraininc/synapse-crm + + + @nimblebraininc/synapse-db-query v0.4.0 + https://www.mpak.dev/packages/@nimblebraininc/synapse-db-query + Natural-language Postgres query app with dynamic Vega-Lite visualizations + Thu, 07 May 2026 22:21:07 GMT + Bundles + https://www.mpak.dev/packages/@nimblebraininc/synapse-db-query + + + @nimblebraininc/synapse-research v0.2.0 + https://www.mpak.dev/packages/@nimblebraininc/synapse-research + Research runs powered by MCP tasks. Kick off long-running research and track progress in real time. + Thu, 07 May 2026 02:03:16 GMT + Bundles + https://www.mpak.dev/packages/@nimblebraininc/synapse-research + + + obsidian-cli v0.1.5 + https://www.mpak.dev/packages/@ovaculos/obsidian-cli + MCP server for Obsidian — read, write, search, and manage notes via the Obsidian CLI + Tue, 05 May 2026 18:14:46 GMT + Bundles + https://www.mpak.dev/packages/@ovaculos/obsidian-cli + + + @nimblebraininc/synapse-astro-editor v0.1.1 + https://www.mpak.dev/packages/@nimblebraininc/synapse-astro-editor + Natural-language editor for Astro websites. Point the agent at a GitHub repo; chat drives every edit (text, JSX, blog posts, image uploads). The bundle clones the repo, runs astro build+preview, serves the live site through a same-origin proxy, auto-commits each edit, and publishes by squash-merging into the base branch. + Thu, 30 Apr 2026 10:19:38 GMT + Bundles + https://www.mpak.dev/packages/@nimblebraininc/synapse-astro-editor + + + IPGeolocation.io MCP Server v1.0.19 + https://www.mpak.dev/packages/@ipgeolocation/ipgeolocation-io-mcp + Official MCP server for IP geolocation, IP security, abuse contacts, ASN, timezone, astronomy, and user-agent parsing. + Tue, 28 Apr 2026 13:19:40 GMT + Bundles + https://www.mpak.dev/packages/@ipgeolocation/ipgeolocation-io-mcp + + + Phrase v0.6.0 + https://www.mpak.dev/packages/@phrase/phrase-mcp-server + Use Phrase APIs from any MCP client with ready-to-use tools for Phrase Strings and Phrase TMS. + Tue, 28 Apr 2026 11:22:36 GMT + Bundles + https://www.mpak.dev/packages/@phrase/phrase-mcp-server + + + @nimblebraininc/synapse-collateral v0.6.1 + https://www.mpak.dev/packages/@nimblebraininc/synapse-collateral + Collateral Studio — Typst-powered document generation with brand-aware templates, live preview, and conversational iteration + Mon, 27 Apr 2026 22:39:34 GMT + Bundles + https://www.mpak.dev/packages/@nimblebraininc/synapse-collateral + + + @nimblebraininc/synapse-todo-board v0.3.0 + https://www.mpak.dev/packages/@nimblebraininc/synapse-todo-board + Kanban-style task manager with board and table views, AI-powered triage, and daily reviews + Mon, 27 Apr 2026 21:53:49 GMT + Bundles + https://www.mpak.dev/packages/@nimblebraininc/synapse-todo-board + + + Mailchimp v1.0.0 + https://www.mpak.dev/packages/@half2me/mailchimp-mcp-server + MCP server for Mailchimp Marketing API with 100+ tools for managing audiences, campaigns, subscribers, templates, automations, and more. + Thu, 09 Apr 2026 21:38:52 GMT + Bundles + https://www.mpak.dev/packages/@half2me/mailchimp-mcp-server + + + @nimblebraininc/synapse-hello v0.1.0 + https://www.mpak.dev/packages/@nimblebraininc/synapse-hello + Hello World MCP App for NimbleBrain Platform + Thu, 09 Apr 2026 06:40:36 GMT + Bundles + https://www.mpak.dev/packages/@nimblebraininc/synapse-hello + + + @forge-builder/base-mcp-server v1.0.21 + https://www.mpak.dev/packages/@forge-builder/base-mcp-server + MCP Server for Base — gas prices, block numbers, balances, and bytecode lookups via direct Base RPC calls + Sun, 05 Apr 2026 16:20:48 GMT + Bundles + https://www.mpak.dev/packages/@forge-builder/base-mcp-server \ No newline at end of file diff --git a/apps/web/public/sitemap.xml b/apps/web/public/sitemap.xml index f1558ef..d25ec26 100644 --- a/apps/web/public/sitemap.xml +++ b/apps/web/public/sitemap.xml @@ -54,90 +54,294 @@ https://www.mpak.dev/packages/@nimblebraininc/echo weekly 0.7 - 2026-02-11 + 2026-05-09 https://www.mpak.dev/packages/@nimblebraininc/nationalparks weekly 0.7 - 2026-02-11 + 2026-05-09 https://www.mpak.dev/packages/@nimblebraininc/abstract weekly 0.7 - 2026-02-11 + 2026-05-09 https://www.mpak.dev/packages/@nimblebraininc/openweathermap weekly 0.7 - 2026-02-11 + 2026-05-09 https://www.mpak.dev/packages/@nimblebraininc/clickhouse weekly 0.7 - 2026-02-11 + 2026-05-09 https://www.mpak.dev/packages/@nimblebraininc/finnhub weekly 0.7 - 2026-02-11 + 2026-05-09 https://www.mpak.dev/packages/@nimblebraininc/ipinfo weekly 0.7 - 2026-02-11 + 2026-05-09 https://www.mpak.dev/packages/@nimblebraininc/ref-tools-mcp weekly 0.7 - 2026-02-11 + 2026-05-09 - https://www.mpak.dev/packages/@nimblebraininc/reverse-text + https://www.mpak.dev/packages/@nimblebraininc/postgres weekly 0.7 - 2026-02-11 + 2026-05-09 - https://www.mpak.dev/packages/@nimblebraininc/postgres + https://www.mpak.dev/packages/@nimblebraininc/github weekly 0.7 - 2026-02-11 + 2026-05-09 - https://www.mpak.dev/packages/@nimblebraininc/github + https://www.mpak.dev/packages/@nimblebraininc/deepl weekly 0.7 - 2026-02-11 + 2026-05-09 - https://www.mpak.dev/packages/@nimblebraininc/deepl + https://www.mpak.dev/packages/@nimblebraininc/synapse-todo-board weekly 0.7 - 2026-02-11 + 2026-05-09 https://www.mpak.dev/packages/@nimblebraininc/pdfco weekly 0.7 - 2026-02-11 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/mcp-dev-summit + weekly + 0.7 + 2026-05-09 https://www.mpak.dev/packages/@nimblebraininc/folk weekly 0.7 - 2026-02-11 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/newsapi + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@ovaculos/obsidian-cli + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/synapse-collateral + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/granola + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/synapse-crm + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/webfetch + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/synapse-research + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/synapse-db-query + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/workspace-tools + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@joecardoso13/asana + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/registry-tools + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/bash + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/synapse-astro-editor + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/todoist + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/gohighlevel + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/synapse-hello + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@phrase/phrase-mcp-server + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/synapse-mcp-dev-summit + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/brave-search + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/git-worktree + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@forge-builder/base-mcp-server + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/text-utils + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/aws-ses + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@joecardoso13/resend + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/mcp-massive + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/hunter + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@ipgeolocation/ipgeolocation-io-mcp + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@joecardoso13/hubspot + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@joecardoso13/slack + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/mcp-quiver + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@justnsmith/alpha-vantage-mcp + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/connection-tools + weekly + 0.7 + 2026-05-09 + + + https://www.mpak.dev/packages/@nimblebraininc/mcp-polygon + weekly + 0.7 + 2026-05-09 - https://www.mpak.dev/packages/@nimblebraininc/mcpb-test + https://www.mpak.dev/packages/@half2me/mailchimp-mcp-server weekly 0.7 - 2026-02-11 + 2026-05-09 \ No newline at end of file diff --git a/packages/schemas/src/server-detail.ts b/packages/schemas/src/server-detail.ts index b7e65d7..2225810 100644 --- a/packages/schemas/src/server-detail.ts +++ b/packages/schemas/src/server-detail.ts @@ -213,7 +213,26 @@ export function defaultReverseDnsName(npmName: string): string { /** * Resolve the reverse-DNS name for a bundle: author override (when the * manifest sets `_meta["dev.mpak/registry"].name`) wins over the - * org-mapped default. + * org-mapped default — but only when the override is one the publisher + * is allowed to claim. Without authorization the override would let a + * publisher of `@evil/spam` label themselves + * `io.modelcontextprotocol/legitimate-tool` in registry listings. + * + * Authorization rules: + * + * - The override's namespace must match the publisher's curated + * org-mapped reverse-DNS prefix (e.g. `@nimblebraininc/*` may + * override to anything starting with `ai.nimblebrain/`), OR + * - The override's namespace must start with `dev.mpak.` + * where `` is the publisher's npm scope (the mechanical + * namespace they already own implicitly). + * + * Anything else falls back to the mechanical default with no error — + * the override is silently ignored. (Registry-side validation can + * upgrade this to a publish-time rejection once we route author + * overrides through OIDC-claim verification; this composer-side + * gate prevents the squatted label from reaching consumer listings + * in the meantime.) */ export function resolveReverseDnsName( npmName: string, @@ -222,9 +241,34 @@ export function resolveReverseDnsName( const meta = manifestMeta?.["dev.mpak/registry"]; if (meta && typeof meta === "object") { const override = (meta as { name?: unknown }).name; - if (typeof override === "string" && SERVER_NAME_PATTERN.test(override)) { + if ( + typeof override === "string" && + SERVER_NAME_PATTERN.test(override) && + isOverrideAuthorized(npmName, override) + ) { return override; } } return defaultReverseDnsName(npmName); } + +/** + * Decide whether `override` is one the publisher of `npmName` is + * allowed to claim. See {@link resolveReverseDnsName} for the rules. + */ +function isOverrideAuthorized(npmName: string, override: string): boolean { + const m = /^@([^/]+)\//.exec(npmName); + if (!m) { + // Unscoped npm names can only override under `dev.mpak/`. + return override.startsWith("dev.mpak/"); + } + const scope = (m[1] ?? "").toLowerCase(); + const overrideNamespace = override.split("/")[0] ?? ""; + // Curated org-mapped namespace: must match exactly. + const mapped = ORG_REVERSE_DNS_MAP[scope]; + if (mapped && overrideNamespace === mapped) return true; + // Mechanical-default namespace: any publisher implicitly owns + // `dev.mpak.`. + if (overrideNamespace === `dev.mpak.${scope}`) return true; + return false; +} diff --git a/packages/sdk-python/src/mpak/client.py b/packages/sdk-python/src/mpak/client.py index 5ea61c6..218758c 100644 --- a/packages/sdk-python/src/mpak/client.py +++ b/packages/sdk-python/src/mpak/client.py @@ -4,6 +4,7 @@ import zipfile from pathlib import Path from typing import Any +from urllib.parse import quote import httpx @@ -305,12 +306,17 @@ def get_server(self, name: str) -> dict[str, Any]: (``@scope/pkg``) and the reverse-DNS form (``ai.nimblebrain/echo``); either form returns the same record. + Both ``@`` and ``/`` are URL-encoded — Fastify's ``:name`` + parameter is single-segment, so an unencoded ``/`` would land + on the wrong route and 404. + Raises: MpakNotFoundError: If the server is not registered. MpakNetworkError: If the network request fails. """ + encoded = quote(name, safe="") try: - response = self._client.get(f"/v1/servers/{name}") + response = self._client.get(f"/v1/servers/{encoded}") if response.status_code == 404: raise MpakNotFoundError(name) response.raise_for_status() @@ -330,12 +336,17 @@ def get_server_version(self, name: str, version: str) -> dict[str, Any]: Pass ``version="latest"`` to alias the most recent published version. + Both ``name`` and ``version`` are URL-encoded — see + :meth:`get_server` for the encoding rationale. + Raises: MpakNotFoundError: If the server or version is not found. MpakNetworkError: If the network request fails. """ + encoded_name = quote(name, safe="") + encoded_version = quote(version, safe="") try: - response = self._client.get(f"/v1/servers/{name}/versions/{version}") + response = self._client.get(f"/v1/servers/{encoded_name}/versions/{encoded_version}") if response.status_code == 404: raise MpakNotFoundError(f"{name}@{version}") response.raise_for_status() diff --git a/packages/sdk-python/tests/test_client.py b/packages/sdk-python/tests/test_client.py index 204e197..186efe8 100644 --- a/packages/sdk-python/tests/test_client.py +++ b/packages/sdk-python/tests/test_client.py @@ -224,64 +224,82 @@ def test_search_servers_404_raises_not_found(): @respx.mock -def test_get_server_accepts_npm_style_name(): - """@scope/pkg is passed through verbatim — the registry handles both - npm-style and reverse-DNS forms.""" - route = respx.get("https://registry.mpak.dev/v1/servers/@nimblebraininc/echo").mock( - return_value=Response(200, json=_SERVER_DETAIL) - ) +def test_get_server_url_encodes_npm_style_name(): + """`@` and `/` in the name are URL-encoded — Fastify's `:name` + parameter is single-segment, so an unencoded `/` would land on a + different route and 404 (verified against the live registry). + Mock at the encoded URL and assert the raw_path the SDK actually + sent — protects against a regression to unencoded f-strings.""" + route = respx.get( + "https://registry.mpak.dev/v1/servers/%40nimblebraininc%2Fecho" + ).mock(return_value=Response(200, json=_SERVER_DETAIL)) client = MpakClient() result = client.get_server("@nimblebraininc/echo") assert route.called + assert ( + route.calls[0].request.url.raw_path + == b"/v1/servers/%40nimblebraininc%2Fecho" + ) assert result["name"] == "ai.nimblebrain/echo" @respx.mock -def test_get_server_accepts_reverse_dns_name(): - route = respx.get("https://registry.mpak.dev/v1/servers/ai.nimblebrain/echo").mock( - return_value=Response(200, json=_SERVER_DETAIL) - ) +def test_get_server_url_encodes_reverse_dns_name(): + """The reverse-DNS form has the same `/` separator and needs the + same encoding.""" + route = respx.get( + "https://registry.mpak.dev/v1/servers/ai.nimblebrain%2Fecho" + ).mock(return_value=Response(200, json=_SERVER_DETAIL)) client = MpakClient() result = client.get_server("ai.nimblebrain/echo") assert route.called + assert ( + route.calls[0].request.url.raw_path == b"/v1/servers/ai.nimblebrain%2Fecho" + ) assert result["name"] == "ai.nimblebrain/echo" @respx.mock def test_get_server_404_raises_not_found_with_name(): - respx.get("https://registry.mpak.dev/v1/servers/ai.nimblebrain/missing").mock( - return_value=Response(404, json={"error": "Not found"}) - ) + respx.get( + "https://registry.mpak.dev/v1/servers/ai.nimblebrain%2Fmissing" + ).mock(return_value=Response(404, json={"error": "Not found"})) client = MpakClient() with pytest.raises(MpakNotFoundError) as exc_info: client.get_server("ai.nimblebrain/missing") + # Error message uses the unencoded name (operator-readable). assert "ai.nimblebrain/missing" in str(exc_info.value) @respx.mock -def test_get_server_version_passes_through_latest(): - """`version="latest"` is a literal the registry resolves server-side.""" +def test_get_server_version_url_encodes_both_segments(): + """Both name and version are URL-encoded; "latest" passes through + as a literal the registry resolves server-side.""" route = respx.get( - "https://registry.mpak.dev/v1/servers/ai.nimblebrain/echo/versions/latest" + "https://registry.mpak.dev/v1/servers/ai.nimblebrain%2Fecho/versions/latest" ).mock(return_value=Response(200, json=_SERVER_DETAIL)) client = MpakClient() result = client.get_server_version("ai.nimblebrain/echo", "latest") assert route.called + assert ( + route.calls[0].request.url.raw_path + == b"/v1/servers/ai.nimblebrain%2Fecho/versions/latest" + ) assert result["version"] == "0.1.6" @respx.mock def test_get_server_version_404_raises_not_found_with_version(): respx.get( - "https://registry.mpak.dev/v1/servers/ai.nimblebrain/echo/versions/99.0.0" + "https://registry.mpak.dev/v1/servers/ai.nimblebrain%2Fecho/versions/99.0.0" ).mock(return_value=Response(404)) client = MpakClient() From 35d91dcf8239c03baa424c72d8c64f44de08a699 Mon Sep 17 00:00:00 2001 From: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com> Date: Sat, 9 May 2026 07:56:00 -1000 Subject: [PATCH 5/5] style: apply prettier + ruff format to test files --- packages/sdk-python/tests/test_client.py | 44 ++++++++------------ packages/sdk-typescript/tests/client.test.ts | 4 +- 2 files changed, 19 insertions(+), 29 deletions(-) diff --git a/packages/sdk-python/tests/test_client.py b/packages/sdk-python/tests/test_client.py index 186efe8..f4bab83 100644 --- a/packages/sdk-python/tests/test_client.py +++ b/packages/sdk-python/tests/test_client.py @@ -230,18 +230,15 @@ def test_get_server_url_encodes_npm_style_name(): different route and 404 (verified against the live registry). Mock at the encoded URL and assert the raw_path the SDK actually sent — protects against a regression to unencoded f-strings.""" - route = respx.get( - "https://registry.mpak.dev/v1/servers/%40nimblebraininc%2Fecho" - ).mock(return_value=Response(200, json=_SERVER_DETAIL)) + route = respx.get("https://registry.mpak.dev/v1/servers/%40nimblebraininc%2Fecho").mock( + return_value=Response(200, json=_SERVER_DETAIL) + ) client = MpakClient() result = client.get_server("@nimblebraininc/echo") assert route.called - assert ( - route.calls[0].request.url.raw_path - == b"/v1/servers/%40nimblebraininc%2Fecho" - ) + assert route.calls[0].request.url.raw_path == b"/v1/servers/%40nimblebraininc%2Fecho" assert result["name"] == "ai.nimblebrain/echo" @@ -249,25 +246,23 @@ def test_get_server_url_encodes_npm_style_name(): def test_get_server_url_encodes_reverse_dns_name(): """The reverse-DNS form has the same `/` separator and needs the same encoding.""" - route = respx.get( - "https://registry.mpak.dev/v1/servers/ai.nimblebrain%2Fecho" - ).mock(return_value=Response(200, json=_SERVER_DETAIL)) + route = respx.get("https://registry.mpak.dev/v1/servers/ai.nimblebrain%2Fecho").mock( + return_value=Response(200, json=_SERVER_DETAIL) + ) client = MpakClient() result = client.get_server("ai.nimblebrain/echo") assert route.called - assert ( - route.calls[0].request.url.raw_path == b"/v1/servers/ai.nimblebrain%2Fecho" - ) + assert route.calls[0].request.url.raw_path == b"/v1/servers/ai.nimblebrain%2Fecho" assert result["name"] == "ai.nimblebrain/echo" @respx.mock def test_get_server_404_raises_not_found_with_name(): - respx.get( - "https://registry.mpak.dev/v1/servers/ai.nimblebrain%2Fmissing" - ).mock(return_value=Response(404, json={"error": "Not found"})) + respx.get("https://registry.mpak.dev/v1/servers/ai.nimblebrain%2Fmissing").mock( + return_value=Response(404, json={"error": "Not found"}) + ) client = MpakClient() with pytest.raises(MpakNotFoundError) as exc_info: @@ -281,26 +276,23 @@ def test_get_server_404_raises_not_found_with_name(): def test_get_server_version_url_encodes_both_segments(): """Both name and version are URL-encoded; "latest" passes through as a literal the registry resolves server-side.""" - route = respx.get( - "https://registry.mpak.dev/v1/servers/ai.nimblebrain%2Fecho/versions/latest" - ).mock(return_value=Response(200, json=_SERVER_DETAIL)) + route = respx.get("https://registry.mpak.dev/v1/servers/ai.nimblebrain%2Fecho/versions/latest").mock( + return_value=Response(200, json=_SERVER_DETAIL) + ) client = MpakClient() result = client.get_server_version("ai.nimblebrain/echo", "latest") assert route.called - assert ( - route.calls[0].request.url.raw_path - == b"/v1/servers/ai.nimblebrain%2Fecho/versions/latest" - ) + assert route.calls[0].request.url.raw_path == b"/v1/servers/ai.nimblebrain%2Fecho/versions/latest" assert result["version"] == "0.1.6" @respx.mock def test_get_server_version_404_raises_not_found_with_version(): - respx.get( - "https://registry.mpak.dev/v1/servers/ai.nimblebrain%2Fecho/versions/99.0.0" - ).mock(return_value=Response(404)) + respx.get("https://registry.mpak.dev/v1/servers/ai.nimblebrain%2Fecho/versions/99.0.0").mock( + return_value=Response(404) + ) client = MpakClient() with pytest.raises(MpakNotFoundError) as exc_info: diff --git a/packages/sdk-typescript/tests/client.test.ts b/packages/sdk-typescript/tests/client.test.ts index d945015..e268860 100644 --- a/packages/sdk-typescript/tests/client.test.ts +++ b/packages/sdk-typescript/tests/client.test.ts @@ -611,9 +611,7 @@ describe('MpakClient', () => { it('hits /v1/servers/search and returns the ServerListResponse', async () => { const client = new MpakClient(); - fetchMock.mockResolvedValueOnce( - mockResponse({ servers: [SERVER], metadata: { count: 1 } }), - ); + fetchMock.mockResolvedValueOnce(mockResponse({ servers: [SERVER], metadata: { count: 1 } })); const result = await client.searchServers({ q: 'echo' });