feat(registry): compose ServerDetail from manifest, mount /v1/servers#100
Merged
mgoldsborough merged 5 commits intomainfrom May 9, 2026
Merged
feat(registry): compose ServerDetail from manifest, mount /v1/servers#100mgoldsborough merged 5 commits intomainfrom
mgoldsborough merged 5 commits intomainfrom
Conversation
…, 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.
11 tasks
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.
This was referenced May 9, 2026
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>
This was referenced May 9, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PackageVersion.serverJsoncolumn is no longer read — bundles can drop theirserver.jsonfile (it was a misnamed K8s-deploy-hints artifact, not the MCP registry shape) and the registry keeps serving correctServerDetail./v1/servers/...family mirrors the existing/v0.1/servers/...paths so consumers can pick whichever versioned URL space is conventional for their stack.@nimblebrain/mpak-schemasexportsServerDetailSchema(Zod) plus reverse-DNS naming helpers.searchServers/getServer/getServerVersionmethods that target the new endpoints. Both URL-encodenameandversionso single-segment Fastify params match.Endpoints (mounted at both /v0.1 and /v1)
GET /servers— paginated list, optionalsearch,updated_since(filter pushed into the DB; pagination math reflects the filter)GET /servers/search?q=&limit=&cursor=— explicit search aliasGET /servers/{name}— latestServerDetail; accepts@scope/pkgorai.nimblebrain/echoGET /servers/{name}/versions/{version}— version-specific (orlatest)GET /servers/{name}/versions— version listing for a serverGET /health— registry health probe (servers_count). Distinct from the top-level/healthLB liveness probe; intentional.Naming
The composer resolves a bundle's reverse-DNS name as:
manifest._meta["dev.mpak/registry"].name(when set + valid + the publisher is authorized to claim that namespace — see "Override authorization" below)@nimblebraininc/*→ai.nimblebrain/*(single entry today; extend inpackages/schemas/src/server-detail.ts)@scope/name→dev.mpak.<scope>/<name>The same resolution runs in reverse on lookups, so
GET /v1/servers/ai.nimblebrain/echoandGET /v1/servers/@nimblebraininc/echoreturn the same record.Override authorization
A publisher of
@evil/spampreviously could claimname: "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:@nimblebraininc/*may claimai.nimblebrain/*), OR@<scope>always implicitly ownsdev.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
GET /v1/bundles/...response now carries an RFC 8594-conformantDeprecation: <IMF-fixdate>header plusLink: </v1/servers>; rel="successor-version". The bundle endpoints stay alive (CLI publish + OIDC announce still depend on them); consumer-read shapes should migrate.apps/registryswitched from^0.2.0toworkspace:*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 storedserverJsoncolumn. 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_metaslot accepts arbitrary reverse-DNS keys).findPackagesWithServerJson/findPackageWithServerJsonByName/findLatestCompletedScanhad no internal callers; deleted in the round-1 QA-fix commit. (Earlier PR-body draft incorrectly said they were kept.)Test plan
pnpm typecheck— greenpnpm lint— greenpnpm test— green@nimblebrain/mpak-registry: 130 tests (composer + route layer + N+1 elimination +updated_sincepush-down + cursor coercion + override authorization + title truncation + RFC-conformant deprecation)@nimblebrain/mpak-sdk: 244 tests (searchServers/getServer/getServerVersionURL-shape + 404 / 5xx / network paths)@nimblebrain/mpak-schemas: 99 tests@nimblebrain/mpak-web: 61 tests@nimblebrain/mpak: 174 testsrequest.url.raw_pathso 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)
reverseDnsNamecolumn onPackage. Current lookup walks candidate npm names derived from the reverse-DNS form (cheap at registry size). An indexed column unlocks O(1) reverse-DNS lookups + author-override matching at scale.ServerDetailat ingest time. Composing on every read is fine for now; persisting on theupsertVersionpath eliminates per-request work.nimblebraininc → ai.nimblebrain); worth moving before the second entry rather than after the third.