Skip to content

Commit 1ccb6f9

Browse files
committed
feat(registry): compose ServerDetail from manifest, mount /v1/servers, deprecate /v1/bundles
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.<scope>/<name>` Compatibility: - Every /v1/bundles GET response now carries `Deprecation: true` plus `Link: </v1/servers>; 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.
1 parent 1deb4bc commit 1ccb6f9

10 files changed

Lines changed: 1196 additions & 205 deletions

File tree

apps/registry/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"jose": "^6.1.3",
4747
"pg": "^8.13.1",
4848
"prisma": "^7.2.0",
49-
"@nimblebrain/mpak-schemas": "^0.2.0",
49+
"@nimblebrain/mpak-schemas": "workspace:*",
5050
"semver": "^7.6.3",
5151
"zod": "^4.3.4"
5252
},

apps/registry/src/db/repositories/package.repository.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,94 @@ export class PackageRepository {
373373
});
374374
}
375375

376+
/**
377+
* Find a package by its npm-style scoped name with all versions and
378+
* artifacts. Variant of {@link findPackageWithServerJsonByName} that
379+
* doesn't require the deprecated `serverJson` column to be set —
380+
* server.json metadata is now composed from the manifest, so any
381+
* indexed bundle can be served as an MCP `ServerDetail`.
382+
*/
383+
async findPackageForServerLookup(
384+
name: string,
385+
tx?: TransactionClient
386+
): Promise<(Package & { versions: (PackageVersion & { artifacts: Artifact[] })[] }) | null> {
387+
const client = tx ?? getPrismaClient();
388+
return client.package.findUnique({
389+
where: { name },
390+
include: {
391+
versions: {
392+
orderBy: { publishedAt: 'desc' },
393+
include: {
394+
artifacts: true,
395+
},
396+
},
397+
},
398+
});
399+
}
400+
401+
/**
402+
* List packages with their latest version + artifacts. Variant of
403+
* {@link findPackagesWithServerJson} without the deprecated
404+
* `serverJson` filter — server.json metadata composes from the
405+
* manifest, so every indexed package can be served. Honors a
406+
* case-insensitive substring search on name / displayName /
407+
* description.
408+
*/
409+
async findPackagesForServerListing(
410+
filters: { search?: string },
411+
options: { skip?: number; take?: number },
412+
tx?: TransactionClient
413+
): Promise<{ packages: (Package & { versions: (PackageVersion & { artifacts: Artifact[] })[] })[]; total: number }> {
414+
const client = tx ?? getPrismaClient();
415+
416+
const where: Prisma.PackageWhereInput = filters.search
417+
? {
418+
OR: [
419+
{ name: { contains: filters.search, mode: 'insensitive' } },
420+
{ displayName: { contains: filters.search, mode: 'insensitive' } },
421+
{ description: { contains: filters.search, mode: 'insensitive' } },
422+
],
423+
}
424+
: {};
425+
426+
const [packages, total] = await Promise.all([
427+
client.package.findMany({
428+
where,
429+
skip: options.skip,
430+
take: options.take,
431+
orderBy: { name: 'asc' },
432+
include: {
433+
versions: {
434+
orderBy: { publishedAt: 'desc' },
435+
take: 1,
436+
include: {
437+
artifacts: true,
438+
},
439+
},
440+
},
441+
}),
442+
client.package.count({ where }),
443+
]);
444+
445+
return { packages, total };
446+
}
447+
448+
/**
449+
* Latest completed security scan for a version, when present.
450+
* Returned for the registry's MTF certification fields (level,
451+
* controls passed/failed/total) on `_meta["dev.mpak/registry"].certification`.
452+
*/
453+
async findLatestCompletedScan(
454+
versionId: string,
455+
tx?: TransactionClient
456+
): Promise<SecurityScan | null> {
457+
const client = tx ?? getPrismaClient();
458+
return client.securityScan.findFirst({
459+
where: { versionId, status: 'completed' },
460+
orderBy: { startedAt: 'desc' },
461+
});
462+
}
463+
376464
// ==================== Package Version Methods ====================
377465

378466
/**

apps/registry/src/index.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,18 @@ async function start() {
146146
max: 10,
147147
timeWindow: '1 minute',
148148
});
149+
// Mark every /v1/bundles response as deprecated per RFC 8594. The
150+
// successor is the MCP-spec-aligned /v1/servers family; the legacy
151+
// bundle-shape endpoints stay alive (announce / publish flow still
152+
// depends on them) but consumers fetching read shapes should
153+
// migrate. Skip POST /announce — that's a publish path, not a
154+
// consumer-read path.
155+
instance.addHook('onSend', async (request, reply) => {
156+
if (request.method === 'GET' || request.method === 'HEAD') {
157+
reply.header('Deprecation', 'true');
158+
reply.header('Link', '</v1/servers>; rel="successor-version"');
159+
}
160+
});
149161
await instance.register(bundleRoutes);
150162
await instance.register(securityRoutes); // /@:scope/:package/security routes
151163
}, { prefix: '/v1/bundles' });
@@ -165,7 +177,10 @@ async function start() {
165177
await instance.register(skillRoutes);
166178
}, { prefix: '/v1/skills' });
167179

168-
// MCP Registry API
180+
// MCP Registry API — mounted at both /v0.1 (the upstream MCP Registry
181+
// public API prefix) and /v1 (mpak's `/v1/...` family). Same routes,
182+
// same handlers; consumers pick whichever URL space is conventional
183+
// for their stack.
169184
await fastify.register(async (instance) => {
170185
await instance.register(cors, {
171186
origin: true,
@@ -175,6 +190,15 @@ async function start() {
175190
await instance.register(mcpRegistryRoutes);
176191
}, { prefix: '/v0.1' });
177192

193+
await fastify.register(async (instance) => {
194+
await instance.register(cors, {
195+
origin: true,
196+
methods: ['GET', 'HEAD'],
197+
credentials: false,
198+
});
199+
await instance.register(mcpRegistryRoutes);
200+
}, { prefix: '/v1' });
201+
178202
// Health check endpoint
179203
fastify.get('/health', {
180204
schema: {

0 commit comments

Comments
 (0)