Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/registry/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
121 changes: 76 additions & 45 deletions apps/registry/src/db/repositories/package.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
Expand Down Expand Up @@ -299,35 +312,69 @@ export class PackageRepository {
}

/**
* Find packages that have server.json set (for MCP Registry /v0.1/servers)
* 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 findPackagesWithServerJson(
filters: { search?: string },
options: { skip?: number; take?: number },
async findPackageForServerLookup(
name: string,
tx?: TransactionClient
): Promise<{ packages: (Package & { versions: (PackageVersion & { artifacts: Artifact[] })[] })[]; total: number }> {
): Promise<PackageForServerLookup | null> {
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 },
return client.package.findUnique({
where: { name },
include: {
versions: {
orderBy: { publishedAt: 'desc' },
include: {
artifacts: true,
securityScans: {
where: { status: 'completed' },
orderBy: { startedAt: 'desc' },
take: 1,
},
},
},
},
};
});
}

/**
* 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 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; updatedSince?: Date },
options: { skip?: number; take?: number },
tx?: TransactionClient
): Promise<{ packages: PackageForServerLookup[]; total: number }> {
const client = tx ?? getPrismaClient();

const conditions: Prisma.PackageWhereInput[] = [];
if (filters.search) {
where.AND = [
{
OR: [
{ name: { contains: filters.search, mode: 'insensitive' } },
{ displayName: { contains: filters.search, mode: 'insensitive' } },
{ description: { contains: filters.search, mode: 'insensitive' } },
],
},
];
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({
Expand All @@ -337,11 +384,15 @@ export class PackageRepository {
orderBy: { name: 'asc' },
include: {
versions: {
where: { serverJson: { not: Prisma.DbNull } },
orderBy: { publishedAt: 'desc' },
take: 1,
include: {
artifacts: true,
securityScans: {
where: { status: 'completed' },
orderBy: { startedAt: 'desc' },
take: 1,
},
},
},
},
Expand All @@ -352,29 +403,9 @@ export class PackageRepository {
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,
},
},
},
});
}

// ==================== Package Version Methods ====================


/**
* Find version by package ID and version string, including latest completed security scan
*/
Expand Down
34 changes: 33 additions & 1 deletion apps/registry/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,26 @@ 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.
//
// 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', DEPRECATION_DATE);
reply.header('Link', '</v1/servers>; rel="successor-version"');
}
});
await instance.register(bundleRoutes);
await instance.register(securityRoutes); // /@:scope/:package/security routes
}, { prefix: '/v1/bundles' });
Expand All @@ -165,7 +185,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,
Expand All @@ -175,6 +198,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: {
Expand Down
Loading
Loading