Commit 329d722
authored
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
- src
- db/repositories
- routes/mcp/v0.1
- services
- tests
- web/public
- packages
- schemas/src
- sdk-python
- src/mpak
- tests
- sdk-typescript
- src
- tests
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
46 | 46 | | |
47 | 47 | | |
48 | 48 | | |
49 | | - | |
| 49 | + | |
50 | 50 | | |
51 | 51 | | |
52 | 52 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
6 | | - | |
7 | | - | |
| 6 | + | |
8 | 7 | | |
9 | 8 | | |
10 | 9 | | |
| |||
19 | 18 | | |
20 | 19 | | |
21 | 20 | | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
22 | 35 | | |
23 | 36 | | |
24 | 37 | | |
| |||
299 | 312 | | |
300 | 313 | | |
301 | 314 | | |
302 | | - | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
303 | 319 | | |
304 | | - | |
305 | | - | |
306 | | - | |
| 320 | + | |
| 321 | + | |
307 | 322 | | |
308 | | - | |
| 323 | + | |
309 | 324 | | |
310 | | - | |
311 | | - | |
312 | | - | |
313 | | - | |
314 | | - | |
315 | | - | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
316 | 338 | | |
317 | 339 | | |
318 | | - | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
319 | 358 | | |
| 359 | + | |
320 | 360 | | |
321 | | - | |
322 | | - | |
323 | | - | |
324 | | - | |
325 | | - | |
326 | | - | |
327 | | - | |
328 | | - | |
329 | | - | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
330 | 368 | | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
331 | 378 | | |
332 | 379 | | |
333 | 380 | | |
| |||
337 | 384 | | |
338 | 385 | | |
339 | 386 | | |
340 | | - | |
341 | 387 | | |
342 | 388 | | |
343 | 389 | | |
344 | 390 | | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
345 | 396 | | |
346 | 397 | | |
347 | 398 | | |
| |||
352 | 403 | | |
353 | 404 | | |
354 | 405 | | |
355 | | - | |
356 | | - | |
357 | | - | |
358 | | - | |
359 | | - | |
360 | | - | |
361 | | - | |
362 | | - | |
363 | | - | |
364 | | - | |
365 | | - | |
366 | | - | |
367 | | - | |
368 | | - | |
369 | | - | |
370 | | - | |
371 | | - | |
372 | | - | |
373 | | - | |
374 | | - | |
375 | | - | |
376 | 406 | | |
377 | 407 | | |
| 408 | + | |
378 | 409 | | |
379 | 410 | | |
380 | 411 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
146 | 146 | | |
147 | 147 | | |
148 | 148 | | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
149 | 169 | | |
150 | 170 | | |
151 | 171 | | |
| |||
165 | 185 | | |
166 | 186 | | |
167 | 187 | | |
168 | | - | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
169 | 192 | | |
170 | 193 | | |
171 | 194 | | |
| |||
175 | 198 | | |
176 | 199 | | |
177 | 200 | | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
178 | 210 | | |
179 | 211 | | |
180 | 212 | | |
| |||
0 commit comments