Skip to content

feat(registry): compose ServerDetail from manifest, mount /v1/servers#100

Merged
mgoldsborough merged 5 commits intomainfrom
feat/server-json-alignment
May 9, 2026
Merged

feat(registry): compose ServerDetail from manifest, mount /v1/servers#100
mgoldsborough merged 5 commits intomainfrom
feat/server-json-alignment

Conversation

@mgoldsborough
Copy link
Copy Markdown
Contributor

@mgoldsborough mgoldsborough commented May 9, 2026

Summary

  • Server.json is now composed at request time from each bundle's stored mcpb v0.4 manifest plus mpak's own data (downloads, provenance, certification, artifacts). The deprecated PackageVersion.serverJson column is no longer read — bundles can drop their server.json file (it was a misnamed K8s-deploy-hints artifact, not the MCP registry shape) and the registry keeps serving correct ServerDetail.
  • New /v1/servers/... family mirrors the existing /v0.1/servers/... paths so consumers can pick whichever versioned URL space is conventional for their stack.
  • Both prefixes share one handler — write-once, deploy-twice.
  • New @nimblebrain/mpak-schemas exports ServerDetailSchema (Zod) plus reverse-DNS naming helpers.
  • TypeScript + Python SDKs gained searchServers / getServer / getServerVersion methods that target the new endpoints. Both URL-encode name and version so single-segment Fastify params match.

Endpoints (mounted at both /v0.1 and /v1)

  • GET /servers — paginated list, optional search, updated_since (filter pushed into the DB; pagination math reflects the filter)
  • GET /servers/search?q=&limit=&cursor= — explicit search alias
  • GET /servers/{name} — latest ServerDetail; accepts @scope/pkg or ai.nimblebrain/echo
  • GET /servers/{name}/versions/{version} — version-specific (or latest)
  • GET /servers/{name}/versions — version listing for a server
  • GET /health — registry health probe (servers_count). Distinct from the top-level /health LB liveness probe; intentional.

Naming

The composer resolves a bundle's reverse-DNS name as:

  1. Author override at manifest._meta["dev.mpak/registry"].name (when set + valid + the publisher is authorized to claim that namespace — see "Override authorization" below)
  2. Curated org map: @nimblebraininc/*ai.nimblebrain/* (single entry today; extend in packages/schemas/src/server-detail.ts)
  3. Mechanical default: @scope/namedev.mpak.<scope>/<name>

The same resolution runs in reverse on lookups, so GET /v1/servers/ai.nimblebrain/echo and GET /v1/servers/@nimblebraininc/echo return the same record.

Override authorization

A publisher of @evil/spam previously could claim name: "io.modelcontextprotocol/legitimate-tool" via the override, squatting that label in registry listings. After the round-2 review the override is now silently rejected (falls back to mechanical default) unless the override's namespace either:

  • Matches the publisher's curated org-mapped reverse-DNS prefix (e.g. @nimblebraininc/* may claim ai.nimblebrain/*), OR
  • Falls under the publisher's mechanical-default namespace (@<scope> always implicitly owns dev.mpak.<scope>/*).

A future iteration can upgrade this to a publish-time rejection using OIDC claims to verify scope ownership; this composer-side gate prevents squatted labels from reaching listings now.

Backwards compatibility

  • Every GET /v1/bundles/... response now carries an RFC 8594-conformant Deprecation: <IMF-fixdate> header plus Link: </v1/servers>; rel="successor-version". The bundle endpoints stay alive (CLI publish + OIDC announce still depend on them); consumer-read shapes should migrate.
  • apps/registry switched from ^0.2.0 to workspace:* for the schemas dep so monorepo builds pick up the new exports.

Breaking changes (intentional, accepted)

  • _meta["io.modelcontextprotocol.registry/official"] no longer emitted on /v0.1/servers/.... The previous handler set it (with {serverId, versionId, publishedAt, updatedAt, isLatest}) from the stored serverJson column. The new composer emits _meta["dev.mpak/registry"] instead. Confirmed there are no current consumers of the old block; if upstream MCP registry tooling later expects the official-namespace metadata we can re-add it as a sibling block (the upstream _meta slot accepts arbitrary reverse-DNS keys).
  • Legacy repository methods removed. findPackagesWithServerJson / findPackageWithServerJsonByName / findLatestCompletedScan had no internal callers; deleted in the round-1 QA-fix commit. (Earlier PR-body draft incorrectly said they were kept.)

Test plan

  • pnpm typecheck — green
  • pnpm lint — green
  • pnpm test — green
    • @nimblebrain/mpak-registry: 130 tests (composer + route layer + N+1 elimination + updated_since push-down + cursor coercion + override authorization + title truncation + RFC-conformant deprecation)
    • @nimblebrain/mpak-sdk: 244 tests (searchServers / getServer / getServerVersion URL-shape + 404 / 5xx / network paths)
    • @nimblebrain/mpak-schemas: 99 tests
    • @nimblebrain/mpak-web: 61 tests
    • @nimblebrain/mpak: 174 tests
  • Python SDK: 28 tests; the new ones now register at the URL-encoded paths AND assert on request.url.raw_path so a regression to unencoded f-strings fails the suite.
  • pnpm build — green (web prerender warning about Playwright is env-specific, not a regression).

Follow-ups (deferred to keep this PR focused)

…, 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.
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.
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).
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.
@mgoldsborough mgoldsborough merged commit 329d722 into main May 9, 2026
4 checks passed
mgoldsborough added a commit that referenced this pull request May 9, 2026
#100 switched the registry's `@nimblebrain/mpak-schemas` dep from
`^0.2.0` (published) to `workspace:*` so schemas changes are
immediately consumable without a publish-bump dance — matching the
convention every other package in the workspace already uses
(`cli`, `sdk-typescript`, `web`). The Dockerfile, however, was
written single-package and only copied `apps/registry/`, never any
workspace dep source. Build then errored across every file
importing schemas:

  src/routes/auth.ts(3,53): error TS2307: Cannot find module
  '@nimblebrain/mpak-schemas' or its corresponding type declarations.
  ... (15 more)

## The fix: `pnpm deploy`

`pnpm deploy --filter=<pkg> --prod <out>` is pnpm's purpose-built
solution for "containerize a workspace package": it walks the full
dependency closure (workspace + npm), builds a flattened deployable
directory, and resolves all `workspace:*` links by bundling each
workspace package's compiled `dist/` inline. Single COPY in the
runtime stage; future workspace deps require no Dockerfile change.

Builder stage:
- `COPY . .` brings the whole monorepo in (kept lean by .dockerignore).
- `pnpm install --frozen-lockfile` resolves all workspace links.
- `pnpm --filter ... exec prisma generate` writes the generated client
  into the pnpm hoisted store.
- `pnpm --filter @nimblebrain/mpak-registry... build` builds the
  registry AND every transitive workspace dep (`...` filter syntax).
- `pnpm deploy --filter=@nimblebrain/mpak-registry --prod /deploy`
  flattens registry + prod deps + workspace closures into /deploy.

Production stage:
- One `COPY --from=builder /deploy ./` brings everything the runtime
  needs — registry dist, schemas dist, every npm prod dep.
- The Prisma generated client is the one exception: its sibling
  `.prisma/` folder lives in the pnpm hoisted store and isn't carried
  by `pnpm deploy`. Two explicit COPY lines preserved from the prior
  Dockerfile handle that special case.

`.dockerignore` (new) keeps the build context small — excludes every
package's `node_modules`, `dist`, `.astro`, `.turbo`, `.git`, `.env*`,
test outputs, editor state.

## Verified locally

- `docker build -f apps/registry/Dockerfile -t mpak-registry:test .` →
  succeeds end-to-end (was failing on the original tsc step).
- `docker run mpak-registry:test` → boots: Prisma initializes, every
  module loads, Fastify listens on :3200.
- `node -e "import('@nimblebrain/mpak-schemas').then(...)"` inside
  the image → schemas resolves, `ServerDetailSchema` is an object.

## Why not the simpler `COPY packages/schemas/...` patch

Considered first, rejected because it's per-dep — every future
workspace dep added to the registry would need another COPY pair in
both stages. `pnpm deploy` is what pnpm's monorepo-Docker docs
recommend and scales to N workspace deps with no Dockerfile churn.
mgoldsborough added a commit that referenced this pull request May 9, 2026
#100 switched the registry's `@nimblebrain/mpak-schemas` dep from
`^0.2.0` (published) to `workspace:*` so schemas changes are
immediately consumable without a publish-bump dance — matching the
convention every other package in the workspace already uses
(`cli`, `sdk-typescript`, `web`). The Dockerfile, however, was
written single-package and only copied `apps/registry/`, never any
workspace dep source. Build then errored across every file
importing schemas:

  src/routes/auth.ts(3,53): error TS2307: Cannot find module
  '@nimblebrain/mpak-schemas' or its corresponding type declarations.
  ... (15 more)

## The fix: `pnpm deploy`

`pnpm deploy --filter=<pkg> --prod <out>` is pnpm's purpose-built
solution for "containerize a workspace package": it walks the full
dependency closure (workspace + npm), builds a flattened deployable
directory, and resolves all `workspace:*` links by bundling each
workspace package's compiled `dist/` inline. Single COPY in the
runtime stage; future workspace deps require no Dockerfile change.

Builder stage:
- `COPY . .` brings the whole monorepo in (kept lean by .dockerignore).
- `pnpm install --frozen-lockfile` resolves all workspace links.
- `pnpm --filter ... exec prisma generate` writes the generated client
  into the pnpm hoisted store.
- `pnpm --filter @nimblebrain/mpak-registry... build` builds the
  registry AND every transitive workspace dep (`...` filter syntax).
- `pnpm deploy --filter=@nimblebrain/mpak-registry --prod /deploy`
  flattens registry + prod deps + workspace closures into /deploy.

Production stage:
- One `COPY --from=builder /deploy ./` brings everything the runtime
  needs — registry dist, schemas dist, every npm prod dep.
- The Prisma generated client is the one exception: its sibling
  `.prisma/` folder lives in the pnpm hoisted store and isn't carried
  by `pnpm deploy`. Two explicit COPY lines preserved from the prior
  Dockerfile handle that special case.

`.dockerignore` (new) keeps the build context small — excludes every
package's `node_modules`, `dist`, `.astro`, `.turbo`, `.git`, `.env*`,
test outputs, editor state.

## Verified locally

- `docker build -f apps/registry/Dockerfile -t mpak-registry:test .` →
  succeeds end-to-end (was failing on the original tsc step).
- `docker run mpak-registry:test` → boots: Prisma initializes, every
  module loads, Fastify listens on :3200.
- `node -e "import('@nimblebrain/mpak-schemas').then(...)"` inside
  the image → schemas resolves, `ServerDetailSchema` is an object.

## Why not the simpler `COPY packages/schemas/...` patch

Considered first, rejected because it's per-dep — every future
workspace dep added to the registry would need another COPY pair in
both stages. `pnpm deploy` is what pnpm's monorepo-Docker docs
recommend and scales to N workspace deps with no Dockerfile churn.

Co-authored-by: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com>
mgoldsborough added a commit that referenced this pull request May 9, 2026
Adds /v1/servers exports from #100 to all three SDK surfaces:
- @nimblebrain/mpak-schemas: ServerDetailSchema + reverse-DNS naming helpers
- @nimblebrain/mpak-sdk: searchServers / getServer / getServerVersion
- mpak (Python): search_servers / get_server / get_server_version

Published manually from the local tree (the OIDC tag-triggered workflows
in .github/workflows/{schemas,sdk-typescript,sdk-python}-publish.yml
should be the canonical path going forward — see #112). No git trigger
tags pushed for these versions because the package versions are already
on npm/PyPI; pushing schemas-v0.4.0 etc. now would no-op with a noisy
publish-failed run.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant