agentic-community / agentic-community/mcp-gateway-registry
feat: canonical server.json export endpoint (Option A toward #264)
- Dominant language
- Python
- Stars
- 911
- Forks
- 234
- Avg merge
- 1d 11h
- Merged PRs (30d)
- 62
Description
## Why
`GET /api/servers/{path}` returns the registry's bespoke shape (`server_name`, `proxy_pass_url`, `tool_list`, ...). Validating that response against the official MCP Registry schema (`server.schema.json`) fails with `'name' is a required property` — this is the exact defect customers hit when downloading a server card from the UI and trying to validate it against the upstream schema.
The customer-preferred fix — refactor storage to be canonical — is the broader effort tracked under #264 and would churn `server_name`, `proxy_pass_url`, etc. across the entire codebase and break every existing client.
The narrower fix proposed here: add a **read-only canonical-export endpoint** that projects the stored bespoke document into a canonical `server.json`. Storage shape stays the same. Existing endpoints unchanged. Closes the round-trip gap for customers who only need canonical *output*; leaves Option B (canonical *input*) as a separate decision.
## Endpoint design
Two path options:
1. `GET /api/servers/{path}?format=canonical` — single endpoint, query param toggles shape. Easier to discover, harder to cache.
2. `GET /api/servers/{path}/server.json` — dedicated path. The `.json` suffix makes it obvious to humans and tooling that this is the canonical artefact. **Recommended** because the canonical form is a different artefact (different top-level keys, schema, `_meta` layout) rather than a different rendering of the same one, and it caches independently.
### Auth
Same as the existing `GET /api/servers/{path}`. Reuse `nginx_proxied_auth`, the same `user_can_access_server_path` check, and inherit the `proxy_pass_url`-stripping for non-admin in with-gateway mode (canonical equivalent: strip `remotes[0].url`).
### Response shape
A `dict[str, Any]` conforming to `mcp_server_schema.json`. No new Pydantic response model — the schema isn't ours, and we don't want to drift from upstream. Vendor the schema under `registry/data/` so we don't fetch it at boot; pin the upstream commit SHA in a comment.
## Mapping
The transform is a pure function — `_to_canonical(stored: dict) -> dict`. Reverse-engineered from `registry/schemas/mcp_registry_transform.py` (the incoming federation transform) plus the field table in the customer analysis:
```python
INTERNAL_META_NS = settings.canonical_export_meta_ns # e.g. "ai.agenticshelf.registry/internal"
def _to_canonical(stored: dict) -> dict:
# First: if metadata.mcp_registry_spec exists, the doc was imported from a
# canonical-shaped file. Prefer the preserved original where present.
spec = (stored.get("metadata") or {}).get("mcp_registry_spec") or {}
out: dict[str, Any] = {
"$schema": spec.get("$schema", DEFAULT_SCHEMA_URL),
"name": spec.get("original_name") or _derive_canonical_name(stored),
"description": stored["description"],
"version": stored.get("version") or spec.get("version") or "0.0.0",
}
if spec.get("remotes"):
out["remotes"] = spec["remotes"]
elif stored.get("proxy_pass_url"):
out["remotes"] = [{
"type": (stored.get("supported_transports") or ["streamable-http"])[0],
"url": stored["proxy_pass_url"],
}]
if spec.get("packages"):
out["packages"] = spec["packages"]
if spec.get("repository"):
out["repository"] = spec["repository"]
# _meta: keep preserved upstream _meta verbatim, and add the registry's
# own internal block under our reverse-DNS namespace.
preserved_meta = spec.get("_meta") or {}
internal = {
k: stored[k] for k in [
"id", "server_name", "tags", "num_tools", "license", "deployment",
"registered_by", "proxy_pass_url", "auth_scheme", "auth_provider",
"path", "is_active", "is_enabled", "registered_at", "updated_at",
"tool_list", "visibility", "allowed_groups", "status",
"provider_organization", "provider_url",
"source_created_at", "source_updated_at",
"mcp_server_version", "health_status", "last_checked_iso",
] if k in stored
}
internal_metadata = {
k: v for k, v in (stored.get("metadata") or {}).items()
if k != "mcp_registry_spec"
}
if internal_metadata:
internal["metadata"] = internal_metadata
out["_meta"] = {**preserved_meta, INTERNAL_META_NS: internal}
return out
def _derive_canonical_name(stored: dict) -> str:
"""Synthesize a reverse-DNS name when the original wasn't preserved."""
name = stored.get("server_name", "")
if "/" in name and "." in name.split("/", 1)[0]:
return name
slug = (stored.get("path") or "").lstrip("/")
return f"{settings.canonical_export_name_vendor}/{slug or 'unknown'}"
```
## Settings to add
- `CANONICAL_EXPORT_META_NS` (e.g. `"ai.agenticshelf.registry/internal"`) — reverse-DNS namespace for the registry's own `_meta` block.
- `CANONICAL_EXPORT_NAME_VENDOR` (e.g. `"ai.agenticshelf"`) — vendor prefix for synthesized names when `server_name` isn't already reverse-DNS.
Both default to `ai.agenticshelf.*`; deployers override (e.g. `com.example.*`).
## Edge cases worth testing
- **No `metadata.mcp_registry_spec`** — server registered via the legacy form. Transform must synthesize `name`, `version`, `remotes` from bespoke fields and still produce a schema-valid doc.
- **Local (stdio) servers** — `proxy_pass_url` empty, `local_runtime` set. Canonical equivalent is `packages: [{registryType, identifier, transport:{type:"stdio"}, runtimeHint, environmentVariables}]`. Map from `local_runtime` shape.
- **Description > 100 chars** — canonical schema caps `description` at 100 chars; registry allows 4096. Truncate with `…` suffix, stash full text under `_meta./internal.description_full`, set response header `X-Description-Truncated: true`.
- **Missing `version`** — canonical requires it. Default to `"0.0.0"`.
- **`server_name` not reverse-DNS** — synthesize via `_derive_canonical_name` using the configured vendor prefix.
## Acceptance criteria
- [ ] New endpoint at `GET /api/servers/{path}/server.json` returns a doc conforming to `mcp_server_schema.json`.
- [ ] Schema conformance test: for every server in a fixture set, `_to_canonical(stored)` must pass `jsonschema.validate` against `mcp_server_schema.json`.
- [ ] Round-trip test: register `mcp_server_data_VALID_v2.json` (with `$schema` set), GET the canonical export, validate against schema, byte-compare the `_meta."/internal"` block to the input. Must be byte-for-byte equal modulo key reordering.
- [ ] No-spec path: register a server via the legacy form, GET the canonical export, assert it validates.
- [ ] Description truncation: register with a 200-char description, GET canonical, assert `len(out["description"]) == 100` and `X-Description-Truncated: true` header.
- [ ] Local server: register a stdio server, GET canonical, assert `packages[0].transport.type == "stdio"` and no `remotes`.
- [ ] Existing `GET /api/servers/{path}` (bespoke shape) is unchanged.
- [ ] Existing register/update endpoints are unchanged.
## Out of scope
- Accepting canonical-shaped JSON on the public register/update path (rejected today by `extra="forbid"`). That's **Option B**, separate ticket.
- UI changes beyond linking to the canonical URL from the existing details modal.
- Any change to existing storage shape or response shape.
## Files touched
- `registry/api/server_routes.py` — new route handler near the existing `get_server` at `/api/servers/{path}`.
- `registry/services/server_service.py` or new `registry/services/canonical_export.py` — `_to_canonical` and `_derive_canonical_name`.
- `registry/core/config.py` — two settings.
- `tests/unit/services/test_canonical_export.py` — new.
- `registry/data/mcp_server_schema.json` — vendored copy of the upstream schema, commit SHA in a comment.
- `frontend/src/components/ServerDetailsModal.tsx` (optional) — link to the canonical URL.
## References
- Partial step toward #264 (input-side parity remains under #264 / Option B).
- Companion to PR #1180 (closed #1178), which fixed the upload-side gap.
Contributor guide
Assessment
This issue has not been assessed yet.