Skip to content

Commit 329d722

Browse files
feat(registry): compose ServerDetail from manifest, mount /v1/servers (#100)
* 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. * feat(sdk): expose /v1/servers in TypeScript and Python SDKs 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. * fix(registry): address QA findings on /v1/servers route + composer 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). * fix: address second QA round on PR #100 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 (`@<scope>` always implicitly owns `dev.mpak.<scope>/*`). 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. * style: apply prettier + ruff format to test files --------- Co-authored-by: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com>
1 parent 1deb4bc commit 329d722

19 files changed

Lines changed: 2638 additions & 387 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: 76 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33
* Handles operations for packages and versions
44
*/
55

6-
import type { Package, PackageVersion, Artifact, SecurityScan } from '@prisma/client';
7-
import { Prisma } from '@prisma/client';
6+
import type { Artifact, Package, PackageVersion, Prisma, SecurityScan } from '@prisma/client';
87
import { getPrismaClient, type TransactionClient } from '../client.js';
98
import type { PackageSearchFilters, FindOptions, PackageWithRelations } from '../types.js';
109

@@ -19,6 +18,20 @@ export type PackageVersionWithArtifactsAndScans = PackageVersion & {
1918
securityScans: SecurityScan[];
2019
};
2120

21+
/**
22+
* Package row joined with its versions, per-version artifacts, and
23+
* (when present) the latest completed security scan per version. The
24+
* shape `findPackageForServerLookup` and `findPackagesForServerListing`
25+
* return — both pull the same data in one query so the route layer
26+
* can compose `ServerDetail` without further round-trips.
27+
*/
28+
export type PackageForServerLookup = Package & {
29+
versions: (PackageVersion & {
30+
artifacts: Artifact[];
31+
securityScans: SecurityScan[];
32+
})[];
33+
};
34+
2235
export interface CreatePackageData {
2336
name: string;
2437
displayName?: string;
@@ -299,35 +312,69 @@ export class PackageRepository {
299312
}
300313

301314
/**
302-
* Find packages that have server.json set (for MCP Registry /v0.1/servers)
315+
* Find a package by its npm-style scoped name with all versions,
316+
* artifacts, and the latest completed security scan per version
317+
* (joined in one query — avoids the per-version round-trip the
318+
* caller used to do for certification metadata).
303319
*/
304-
async findPackagesWithServerJson(
305-
filters: { search?: string },
306-
options: { skip?: number; take?: number },
320+
async findPackageForServerLookup(
321+
name: string,
307322
tx?: TransactionClient
308-
): Promise<{ packages: (Package & { versions: (PackageVersion & { artifacts: Artifact[] })[] })[]; total: number }> {
323+
): Promise<PackageForServerLookup | null> {
309324
const client = tx ?? getPrismaClient();
310-
311-
// Filter packages that have at least one version with serverJson set
312-
const where: Prisma.PackageWhereInput = {
313-
versions: {
314-
some: {
315-
serverJson: { not: Prisma.DbNull },
325+
return client.package.findUnique({
326+
where: { name },
327+
include: {
328+
versions: {
329+
orderBy: { publishedAt: 'desc' },
330+
include: {
331+
artifacts: true,
332+
securityScans: {
333+
where: { status: 'completed' },
334+
orderBy: { startedAt: 'desc' },
335+
take: 1,
336+
},
337+
},
316338
},
317339
},
318-
};
340+
});
341+
}
342+
343+
/**
344+
* List packages with their latest version, artifacts, and the
345+
* latest completed security scan — all in one query. Honors a
346+
* case-insensitive substring search on name / displayName /
347+
* description and an optional `updatedSince` filter pushed down to
348+
* the database so pagination math reflects the filter (a request
349+
* like `limit=100&updatedSince=...` returns up to 100 *matching*
350+
* packages, not 100 fetched then filtered to a few).
351+
*/
352+
async findPackagesForServerListing(
353+
filters: { search?: string; updatedSince?: Date },
354+
options: { skip?: number; take?: number },
355+
tx?: TransactionClient
356+
): Promise<{ packages: PackageForServerLookup[]; total: number }> {
357+
const client = tx ?? getPrismaClient();
319358

359+
const conditions: Prisma.PackageWhereInput[] = [];
320360
if (filters.search) {
321-
where.AND = [
322-
{
323-
OR: [
324-
{ name: { contains: filters.search, mode: 'insensitive' } },
325-
{ displayName: { contains: filters.search, mode: 'insensitive' } },
326-
{ description: { contains: filters.search, mode: 'insensitive' } },
327-
],
328-
},
329-
];
361+
conditions.push({
362+
OR: [
363+
{ name: { contains: filters.search, mode: 'insensitive' } },
364+
{ displayName: { contains: filters.search, mode: 'insensitive' } },
365+
{ description: { contains: filters.search, mode: 'insensitive' } },
366+
],
367+
});
330368
}
369+
if (filters.updatedSince) {
370+
// "Updated" here means "has at least one version published since".
371+
// Filter at the DB so pagination cursor math is consistent.
372+
conditions.push({
373+
versions: { some: { publishedAt: { gte: filters.updatedSince } } },
374+
});
375+
}
376+
const where: Prisma.PackageWhereInput =
377+
conditions.length === 0 ? {} : conditions.length === 1 ? conditions[0]! : { AND: conditions };
331378

332379
const [packages, total] = await Promise.all([
333380
client.package.findMany({
@@ -337,11 +384,15 @@ export class PackageRepository {
337384
orderBy: { name: 'asc' },
338385
include: {
339386
versions: {
340-
where: { serverJson: { not: Prisma.DbNull } },
341387
orderBy: { publishedAt: 'desc' },
342388
take: 1,
343389
include: {
344390
artifacts: true,
391+
securityScans: {
392+
where: { status: 'completed' },
393+
orderBy: { startedAt: 'desc' },
394+
take: 1,
395+
},
345396
},
346397
},
347398
},
@@ -352,29 +403,9 @@ export class PackageRepository {
352403
return { packages, total };
353404
}
354405

355-
/**
356-
* Find a package with server.json and its version artifacts (for single-server lookup)
357-
*/
358-
async findPackageWithServerJsonByName(
359-
name: string,
360-
tx?: TransactionClient
361-
): Promise<(Package & { versions: (PackageVersion & { artifacts: Artifact[] })[] }) | null> {
362-
const client = tx ?? getPrismaClient();
363-
return client.package.findUnique({
364-
where: { name },
365-
include: {
366-
versions: {
367-
orderBy: { publishedAt: 'desc' },
368-
include: {
369-
artifacts: true,
370-
},
371-
},
372-
},
373-
});
374-
}
375-
376406
// ==================== Package Version Methods ====================
377407

408+
378409
/**
379410
* Find version by package ID and version string, including latest completed security scan
380411
*/

apps/registry/src/index.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,26 @@ 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+
//
156+
// RFC 8594 specifies `Deprecation = HTTP-date / "@" 1*DIGIT`. The
157+
// boolean string "true" is a common shortcut from a superseded
158+
// draft but isn't conformant; strict parsers ignore it. Emit an
159+
// IMF-fixdate equal to when the deprecation took effect (the
160+
// first commit of this PR's day) so the header round-trips
161+
// through any conforming client.
162+
const DEPRECATION_DATE = 'Fri, 09 May 2026 00:00:00 GMT';
163+
instance.addHook('onSend', async (request, reply) => {
164+
if (request.method === 'GET' || request.method === 'HEAD') {
165+
reply.header('Deprecation', DEPRECATION_DATE);
166+
reply.header('Link', '</v1/servers>; rel="successor-version"');
167+
}
168+
});
149169
await instance.register(bundleRoutes);
150170
await instance.register(securityRoutes); // /@:scope/:package/security routes
151171
}, { prefix: '/v1/bundles' });
@@ -165,7 +185,10 @@ async function start() {
165185
await instance.register(skillRoutes);
166186
}, { prefix: '/v1/skills' });
167187

168-
// MCP Registry API
188+
// MCP Registry API — mounted at both /v0.1 (the upstream MCP Registry
189+
// public API prefix) and /v1 (mpak's `/v1/...` family). Same routes,
190+
// same handlers; consumers pick whichever URL space is conventional
191+
// for their stack.
169192
await fastify.register(async (instance) => {
170193
await instance.register(cors, {
171194
origin: true,
@@ -175,6 +198,15 @@ async function start() {
175198
await instance.register(mcpRegistryRoutes);
176199
}, { prefix: '/v0.1' });
177200

201+
await fastify.register(async (instance) => {
202+
await instance.register(cors, {
203+
origin: true,
204+
methods: ['GET', 'HEAD'],
205+
credentials: false,
206+
});
207+
await instance.register(mcpRegistryRoutes);
208+
}, { prefix: '/v1' });
209+
178210
// Health check endpoint
179211
fastify.get('/health', {
180212
schema: {

0 commit comments

Comments
 (0)