#!/usr/bin/env bun /** * Schema gate for a connector catalog. * * Takes a catalog path (a `ServerDetail` file or a directory of them) * and reports every entry the runtime would silently drop: one that * fails the upstream `ServerDetail` schema, duplicates a name already * claimed by an earlier file, sits in a file that cannot be read or * parsed, shares its server name with another entry (both are refused), * is scrubbed at the directory boundary for an unsafe URL or a * reserved OAuth param, or resolves to nothing installable — and every * entry whose malformed `admin_tools` makes all its tools admin-only. * Exit 1 if there is any, 0 if every entry reaches Browse as written. * * **Why this exists.** A catalog entry is validated at load, and an * invalid one is dropped with a warn log while its siblings load fine * — so the connector simply never appears in Browse, with no hard * failure anywhere. The catalog is deployment-managed config, edited in * a repo that does not compile against this schema, which means a * malformed entry merges green and fails only in a running tenant. That * exact failure has shipped four times, always on `description`, which * upstream caps at 100 characters. * * It runs the real `validateCatalog` rather than a second * validator, so the gate cannot drift from what production does — if * this passes, the runtime keeps every entry. * * Offline and deterministic, unlike its sibling * `check-catalog-dcr.ts`, which probes vendor OAuth endpoints over the * network. This one is safe to run on every PR that touches a catalog. * * bun run scripts/check-catalog-schema.ts * bun run check:catalog-schema # the in-repo example catalog */ import { validateCatalog } from "../src/connectors/catalog/read.ts"; function main(): void { const path = process.argv[2]; if (!path) { console.error("usage: bun run scripts/check-catalog-schema.ts "); process.exit(2); } const diagnostics = validateCatalog(path); if (diagnostics.length === 0) { console.log(`✓ ${path}: every entry reaches Browse (schema, safety, installable)`); return; } console.error( `✗ ${path}: ${diagnostics.length} problem(s) — these entries would be dropped or gated\n`, ); // Print the diagnostic verbatim — it is already file-and-entry // qualified. A source-stage line is worded exactly as the runtime logs // it, so it is the string to grep for when confirming a fix landed in a // running deployment; the directory-boundary lines name the entry and // the reason, but the runtime logs those from a different site with its // own registry-tagged wording. for (const d of diagnostics) console.error(` ${d.message}`); // Only entry-level problems have an entry to go fix. A missing path or // an unparseable file has no `index`, and telling that operator about // field rules would point them at the wrong thing entirely. if (diagnostics.some((d) => d.index !== undefined)) { console.error( "\nA dropped entry does not fail at runtime — the connector just never appears.\n" + "Field rules: `description` is capped at 100 characters by the upstream MCP\n" + "registry schema (src/connectors/catalog/schemas/server.schema.json); icon, docs, and\n" + "portal URLs must be http(s); OAuth params may not use reserved keys; and an\n" + "entry needs `packages` or a `remotes` entry the installer supports; `admin_tools`\n" + "must be a list of at most 64 tool names with no whitespace; and no two names may\n" + "slugify to one server name (`a.b/c` and `a/b.c` are both `a-b-c`).", ); } process.exit(1); } // Only run when invoked directly, so the module can be imported without // tripping the argv reads and the process.exit calls. if (import.meta.main) { main(); }