Repository navigation
Conversation
Introduce catalogRemoteOAuth.ts, a side-effect-free module that later
phases wire into the registry and remote MCP routes:
- isRemoteOAuthEntry: the single predicate the registry guards share.
- parseRemoteOAuthCatalogBlock(entry, { trusted }): validates the
mcp_server_meta.oauth block; curator pre-registered entries must ship
a setup guide and an https provider console URL, org entries do not.
- isCuratorEntry: positive, fail-closed provenance check (org owner
sealed by the registry plus an unscoped or reserved-scope name parsed
with the registry's own name grammar).
- resolveRemoteOAuthEntry: provenance -> strip untrusted presentation
metadata -> validate -> remoteOAuth verdict, rebuilding the oauth
block from an allowlist so look-alike keys never reach the UI.
- deriveCatalogRemoteServerName, normalizeRemoteBaseUrl,
isCatalogRemoteOAuthInstance and catalogInstanceIdentityChanges.
Shared naming pieces move to registryNaming.ts and
mergeAnnotationsForReplace moves to services/annotationMerge.ts, both
without behaviour change. Tests derive registry rows from a fixture that
mirrors the registry's createEntry and validation; property tests cover
name injectivity, normalisation and resolution idempotence.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Route every catalog read (catalog, search, entry, entry version) through one wrapper that applies the remote-OAuth resolution: untrusted entries lose curator-only presentation metadata and every remote-OAuth entry carries a server-decided remoteOAuth verdict. Close the other registry paths for this subtype explicitly: - POST /admin/registry/install answers 400 remote_oauth_entry_requires_remote_install before any other branch. - POST /admin/registry/upgrade answers 409 remote_oauth_upgrade_unsupported when either the CR or the target entry is remote-OAuth, before any write. - PUT .../versions/:version rejects mcpServer keys other than egressSummary. The installed state gains remoteOAuthSlots (instance/foreign, canonical, terminating) and manualRemoteOAuth, computed from the entries /catalog already fetched; remote-OAuth CRs leave catalogKeys. Slot keys live in a Map and ids outside the registry name grammar are skipped, so a crafted catalog-id annotation cannot write onto Object.prototype. MCP server install and upgrade stamp catalog annotations from the entry the registry returned rather than the request body. The remote MCP CR builder is extracted (no shape change) so tests derive fixtures from it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tall
POST /admin/mcp-servers/remote and /discover accept
{ catalogEntry: { name, version }, grantScope }. The server reads the
entry once through the same resolution the catalog uses and rejects
entries that are not remote-OAuth, invalid, aliased, outside their
allowed grant scopes, or paired with a client-supplied server name or a
different base URL. It then replaces the base URL with the entry's
literal endpoint and derives the canonical slot name, so nothing from
the request decides what is persisted; SSRF kernel, discovery and probe
still run unchanged.
The created CR carries the catalog annotations, and a successful install
reports to the registry best-effort after the DCR bind. /discover returns
catalogDrift when the detected registration mode differs from the
entry's expectation, and the canonical serverName.
In catalog mode a lost pre-registered race answers install_in_progress
while a CR is alive or the client Secret is younger than the pending
TTL, and catalog_slot_residue (naming the Secret) only for an older
Secret with no CR; a lost CR create answers server_name_in_use. Manual
mode keeps its responses.
Rolling back a created CR is now fenced by uid only, so a status write
that bumps the resourceVersion no longer leaves a broken CR behind.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntries in slot classification The slot classifier read entry.remoteOAuth for any entry, but the resolution leaves non-remote-OAuth entries untouched, so a verdict found on one never came from control-api. Guard it with isRemoteOAuthEntry, as the catalog-mode install already does. Class-check: `\.remoteOAuth\b` reads in control-api/src → 3 sites → remoteMcp.ts catalog decision (already guarded), catalogRemoteOAuth.ts classifyRemoteOAuthSlots (fixed here), catalogRemoteOAuth.ts prior invalid verdict in resolveRemoteOAuthEntry (intentional: only honours negative verdicts on remote-OAuth entries). Repro-test: control-api/test/routes.registryCatalogRemoteOAuthSlots.test.ts "a verdict on an entry that is not remote-OAuth opens no slot" (fails without the guard: slot marked foreign) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A consent started on one installation of an McpServer could be sealed to a later reinstallation with the same name: the signed state only carried the server name, and the callback sealed the grant with the uid of the live CR. With a platform client id (CIMD) or the same pre-registered app the code exchange still succeeds. The MCP state now carries crUid, taken from the same CR read that produces the client declaration, and the mint refuses to sign without one. After resolving the live CR the callback answers oauth_state_outdated, before any exchange or grant write, when either side lacks a uid or they differ. States signed before this change carry no uid and are refused the same way; recipe states are unchanged. Tests derive MCP states from the real mint instead of hand-written claims. Two real-Postgres scenarios that pinned the old outcome of an uninstall/reinstall between mint and callback now expect the refusal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…try (R2-L5) The registry tightened its providerConsoleUrl validation (backslash, HTML breakout characters, "@" or "%" in the raw authority) and now rejects an mcpServer.oauth block on any entryType other than mcp-server. control-api's parser still documented "the same rules the registry applies at publish" but accepted https://a.com\@evil.com/, https://@host.com/, https://%65vil.com/ and https://x.com/"><svg>, so a row written by an older registry or directly in the database could render a console link that opens a different host or breaks out of an HTML attribute. Reason codes follow the existing taxonomy: malformed strings (backslash, unsafe characters, percent-encoded host) are provider_console_url_invalid; "@" in the authority is provider_console_url_not_https, the code credentialed URLs already returned, so https://u:p@host keeps its reason now that the raw check sees it first. Some strings that already failed in new URL() (e.g. https://<x>/, https://%zz/) now fail earlier and move from _not_https to _invalid; no consumer maps these codes beyond the parser and its tests. The registryEntryRow fixture mirrors the same rules, plus the entryType rule, so it still throws on anything the registry would refuse. The "not an mcp-server" case moves to malformedRow because the registry can no longer store that row. A fixture test pins the new mirror rules (mutation-checked: relaxing the "%" rule turns it red). Class-check: raw providerConsoleUrl validators (`CONSOLE_URL_PREFIX_RE|CONSOLE_PREFIX_RE` in src/ and test/) -> 2 sites (catalogRemoteOAuth.ts parser, registryEntryRow.ts fixture) -> fixed both. isHttpsUrl on remoteEndpoints[0].url is a different rule (canonicalHttpsUrlError in the registry) and is left as is. Repro-test: control-api/src/routes/admin/__tests__/catalogRemoteOAuth.test.ts:217 (fails on 19002bc) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ist additions Context writes (POST/PUT) answer 422 unknown_mcp_server when spec.mcpServers adds a name with no live McpServer; names already on the Context are not re-checked, so a Context with a stale name stays editable. The check reads each added server (four at a time, at most 256 per write, too_many_mcp_servers beyond that) and fails closed on read errors. This stops a dialog opened before an uninstall from re-enabling the server under its fixed catalog name on reinstall. The generic McpServer routes protect Marketplace instances: - POST refuses clerum.io/catalog-* annotations on a remote-OAuth spec (400 catalog_annotations_not_allowed); only the catalog install creates such CRs. - PUT on a catalog instance answers 409 catalog_instance_immutable when the base URL, transport URL, OAuth source, grant scope, client id, endpoints, resource or catalog annotations change; scopes and egress bindings stay editable. - Every McpServer PUT requires a spec object, reuses the guard's read and fences the replace on that read's uid, so a CR reinstalled in between is never overwritten. readLiveMcpServerUid moves to services/liveMcpServer.ts for reuse. A QA e2e seed now creates its McpServers before the Context that enables them, and the real-Postgres purge suite seeds the servers it names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…manifest The catalog-mode real-Postgres integration suite was added without its manifest entry, so the CI census check (password-recovery-suite-contract verify) fails with "PostgreSQL suite census differs from exact manifest". Class-check: realPostgres suites added on this branch (git diff --name-only --diff-filter=A origin/dev -- 'control-api/test/*realPostgres*') → 1 (routes.adminRemoteMcp.catalogMode.realPostgres.integration.test.ts) → added here; the manifest now matches the census. Repro-test: scripts/ci/password-recovery-suite-contract.mjs verify (fails on 1191520 with the census mismatch, passes here) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
remote-oauth-catalog.json lists the 23 curated remote-OAuth connectors (verified, untested-by-design and bring-your-own-app groups) with their endpoint, allowed grant scopes, expected registration mode, verification state and, for pre-registered ones, a setup guide and provider console. seed-remote-oauth.mjs publishes them as @evenfire/<name> with the curator credential straight to the registry: - validates every item with control-api's own catalog parser (loaded from its TypeScript source via Node's type stripping) before any network call, and refuses names outside @Evenfire; - runs /admin/mcp-servers/remote/discover per URL and skips an item whose transport is not alive or whose detected mode drifts from the expected one, unless --allow-drift; - re-reads every published version and stops the whole run when a fresh publish comes back without its oauth block (versions are immutable), while an already-published version only fails its own item; - takes credentials from the environment only, requires https outside loopback and refuses redirects. The registration-drift rule moves to catalogRemoteOAuth.ts so the seed and /discover share it. CI runs the hermetic seed tests in the control-api matrix job. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l Postgres A fast-check model test drives the real routes (catalog install in CIMD, pre-registered and DCR modes with honest and tampered bodies, uninstall through both routes, catalog reads, context edits from fresh or stale snapshots, generic McpServer edits and forged creates, consents) with a scheduler ordering every apiserver, authorization-server and Postgres step. At rest it checks one instance per slot, catalog identity derived only from the entry, catalog state matching the live CRs with no leftover client Secret, DCR row or servable grant, no context write adding a name without a live server, and that a losing install never deletes what it did not create. Two residual windows are admitted explicitly and nowhere else: a context edit landing between the uninstall's allowlist strip and its CR delete, and a DCR bind landing after the uninstall's row teardown read the CR (a pending row has no uid yet), which the next install of the slot reclaims. The custom-objects apiserver double now honours delete preconditions, as the real apiserver does. The suite is registered in the Postgres suite manifest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ion extractor The fixture extractor evaluates the install handler's catalogAnnotations call from control-api's registry.ts with only `body` in scope. Since the handler stamps annotations from the entry the registry returned (catalogAnnotations(entry.name, entry.version)), every consumer failed with "entry is not defined". The extractor now locates the handler's single getEntryVersion read, evaluates its arguments against the body and builds the entry with control-api's registryEntryRow producer. Class-check: control-ui fixtures/tests that evaluate control-api source (registryMcpServerProducer, guardrailProducer, genericDiscoveryPrefill.projection, the producer contract test) → 4 → only this one reads the changed expression; the other three read untouched code. Repro-test: control-ui/components/__tests__/RegistryConnectorCredentialContract.test.tsx and SecretsTable.test.tsx (fail on 19002bc with "entry is not defined") Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Remote-OAuth Marketplace entries now install through the existing remote wizard, opened as /connectors/remote/new?entry=&version=&scope=: - the install page routes remote-OAuth entries to the wizard (or offers a per-user / per-context choice when both slots exist) and never shows the registry or baked OAuth forms for them; an entry without a valid server verdict shows the reason instead; - in catalog mode the wizard loads the entry, shows its URL and the server-assigned name read-only, keeps the grant scope fixed, detects automatically, never offers a suggested URL, and sends only the entry name/version the registry returned plus the scope; - provenance and verification badges, a drift notice, a single "no curator guide" banner, and catalog-specific handling of occupied slots, residue and per-slot OAuth app conflicts. Curator setup guides render through MarkdownContent with a new https-only link policy (other schemes, images and raw HTML neutralised) next to the provider console button and the redirect URI. Manual mode is unchanged. UI fixtures load wire goldens produced by control-api's real catalog and remote MCP routes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…shed connectors The Marketplace table shows, for each valid remote-OAuth entry, one line per allowed grant scope read from the server's remoteOAuthSlots: free (Install opens the catalog wizard), installed (Agents… and Uninstall), occupied by another server, uninstalling, or not installable with the server's reason. Per-slot actions live in the row menu; the entry detail page and the publisher's owned entries read the same slots, so a patch bump never offers a duplicate install. Badges show verification, "Requires your OAuth app" with the provider console (curator entries only) and org provenance; a notice flags a manual instance with the same normalized URL and scope, and a filter selects remote-OAuth entries by verification. Context writes rejected with unknown_mcp_server or too_many_mcp_servers get their own messages. Granting a connector to agents waits for every write before reporting and reloads from the server, the host wizard drops connectors that no longer exist, and the agent page reloads its connector list. The entry page refreshes in the background with its own refresh error, and busy state is tracked per server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…outer The UI tests for unknown_mcp_server and too_many_mcp_servers used a body copied by hand from control-api. A new wire-golden generator runs the real admin resources router over the in-memory apiserver and records the 422 responses for an added unknown name, two unknown names and an over-limit addition; the UI fixture loads those goldens, so a change in the router's body shape turns the UI tests red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…machine (S5) The scheduler picked gates by arrival position, and gates arrived in the order sockets and Postgres answered, so a seed did not replay under load. Gates now wait in a queue and reach the scheduler in a canonical order (request, then the request's own gate count) once every running command is parked; a command that never parks fails the run. The registry uninstall's settle poll reads a clock its own sleeps advance, and the required coverage keeps only states the seed reaches many times, with the strip-window case pinned by a deterministic scenario. Repro: default seed, normal run vs 10 busy CPU loops: before, coverage differed (ctxEdit 409 conflict 8 vs 10, install 201 651 vs 650, ...); after, the two coverage maps are identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t is reinstalled (S1) The oracle accepted a dynamic client bound to a dead CR whenever an install bound it after an uninstall's teardown claim, even when a later install of the slot had already answered 201 (and so had to reclaim it) or when an install on the free slot answered occupancy instead of reclaiming. It now accepts the row only while no CR holds the name and no later 201 install exists, flags an occupancy 409 on a slot that stayed free with nobody else on the name, and pins the window with a deterministic scenario that also checks the next install takes the row over and revokes the dead client. Repro: classifyExistingDynamicClient returning 'in-use' for a bound row (no reclaim): before, the suite stayed green; after, the scenario fails with 409 where the reinstall must answer 201. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d to strip (S2) At rest the oracle excused a name left in an allowlist whenever its last add fell inside an uninstall's strip window, even if a later uninstall of the same name (after a reinstall) listed the Contexts after that add and deleted the CR. That later uninstall had to strip it, so the old window no longer explains the name. The strip-window scenario now reinstalls and uninstalls again and expects the allowlist clean. Repro: stripServerFromContexts skipping c2 on the second uninstall of a name, scenario with the oracle alone: before, green (the first window excused the name); after, "I8: c2 lists ... with no live McpServer". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…owlist (S4) A catalog install must not touch Context allowlists: enabling the server in a Context is a separate admin decision, and listing every Context costs each install. The oracle observed the install's Context calls but never checked them, so a saga that listed or rewrote allowlists passed. It now fails any install request that listed or wrote a Context. Repro: the install saga listing contexts before its name pre-check: before, all tests green; after, every test red with "listed Contexts 1 time(s)". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…run alone (S3)
The oracle accepted any member of a set of plausible answers for every
install and uninstall, so a wrong code with the right side effects passed
(a 404 after deleting a live CR, "in progress" for a held slot). A command
that ran with no other command in flight now gets its exact outcome from a
model of live CRs (201 installs minus uninstall deletes, by uid); race
codes are only accepted when another command overlapped, and a CIMD loser
may only answer server_name_in_use.
Repro: the name pre-check answering install_in_progress instead of
server_name_in_use: before, green; after, red ("unexpected 409
install_in_progress"). Generic DELETE answering 404 after a completed
uninstall: before, red only through a coverage counter and the scenarios'
status checks; after, the property itself flags "unexpected 404".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ands for The consent command reimplements only the callback's sealing step; say so, and why the whole callback is not driven, so nobody takes it for the real callback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erver per run Handing supertest the app makes it listen on a fresh ephemeral port for every request. Once the settle polls stopped sleeping, a 3000-run sequence issued requests fast enough to exhaust the local ports and failed with EADDRNOTAVAIL. Each run now listens once and every request reuses it. Repro: FC_NUM_RUNS=3000 with seeds 2 and 7: before, "connect EADDRNOTAVAIL 127.0.0.1:..." after about 1000 runs; after, both seeds pass all 3000 runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o stay gone The grant model expected the last consent sealed with the live CR's uid to be served. An uninstall purges that install's grants before deleting the CR; if it then stops (503, CR still live), the install stays live without its grants until a retry finishes the uninstall. The model now records the teardown purges and expects no grant when one ran after the consent. Repro: default seed, FC_NUM_RUNS=2200: before, run 2142 failed with "I6: user-1 on ...-u-51704373 served=false, model=true" after a registry uninstall answered 503; after, all 3000 runs pass on three seeds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e stopped requiring The determinism change dropped "install 409 install_in_progress" and "ctxEdit 409 conflict" from the required coverage on the claim that deterministic scenarios pin them, but only the strip-window and orphan client scenarios existed. A seed now replays the same interleavings, so both counts are fixed for the default seed and are required again; two scenarios pin them on their own: a second install of a slot whose winner is held before its CR create (in progress for pre-registered and DCR, server_name_in_use for CIMD) and an allowlist edit on a stale snapshot. Repro: installPar = fc.constant(false) (installs never overlap): before, green; after, red with "runs never reached: install 409 install_in_progress". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ran alone
Predicting exact outcomes for installs that ran alone moved them out of the
branch that checked a 201 was installed under the canonical name, so an
install answering 201 under another name, with a CR lacking the catalog
annotations, passed whenever it ran alone (the CR is no catalog instance,
so I1/I3 never see it). Every 201 now checks the answered name and that
the CR it created is a catalog instance of the entry and scope.
Repro: the saga installing under "<canonical>-x" with no catalog
annotations: before, the first run (all installs alone) passed and only an
overlapped install in run 2 failed; after, red at the first install that
ran alone ("installed as ...-x").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…at is pending A command stuck outside every gate (a request that never answers) kept the release loop spinning until the 600 s test timeout, with no hint of which command or gate was involved. The release loop and the final settle now stop after 30 s and fail the run naming the running commands and the parked gates. Repro: the discovery double never answering for one entry: before, no result after 120 s (killed); after, the run fails in 32 s with "never settled within 30000 ms: commands 1; parked: none". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clean integration; dev only touched desktop-app, profile-ui, external-rest-api and packages/desktop-app-links, none of which this branch changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a catalog instance (BR-R1-M1) The catalog identity guard on the generic PUT only ran when the stored CR was already a catalog remote-OAuth instance. A replace could therefore acquire that identity: add catalog-id/version (and any baseUrl) to a manual remote-OAuth connector, or add `oauth.source: remote` to a CR that already carried a catalog id (created by a non-remote or spec-less POST). The result was classified as a catalog slot instance with an arbitrary version and was then protected by the very guard it bypassed. Predicate: the PUT runs the identity check when the current CR is an instance OR the state the replace would store becomes one (`becomesCatalogRemoteOAuthInstance`: annotations merged with `mergeAnnotationsForReplace`, exactly as ResourceService merges them, plus `body.spec`). Becoming an instance always changes catalog-id or oauth.source, so the refusal is the existing 409 `catalog_instance_immutable` with the changed fields. F5 on the reviewer's proposal: the PUT half is applied as proposed. The POST half (reject any catalog annotation regardless of spec/source) is not applied. The POST check already judges the full next state (a create body is the whole CR), and once the PUT cannot complete the identity, a non-remote or spec-less CR carrying a catalog id is not a catalog remote-OAuth instance and can never become one through this route. Rejecting the annotation on every POST would widen this fix to the non-OAuth registry identity, which is a separate contract: registry installs and upgrades write it through the gateway directly, not through this route, and registry upgrade already checks both the current CR and the target entry. Edits that keep a catalog id on a non-remote CR, and manual connectors with unrelated annotations, stay 200 (tests pin both). The POST test that certified step 1 of the bypass now covers the whole sequence (create 201, converting PUT 409, CR unchanged). Class-check: identity guards keyed on the current McpServer state only (`isCatalogRemoteOAuthInstance(`, `isRemoteOAuthServer(`, `getCatalogId(` over src/, plus `&& body.spec`-gated annotation checks in resources.ts) -> 6 guard sites: resources.ts PUT (fixed); resources.ts POST claimsCatalogRemoteOAuthIdentity (already checks the next state; the `body.spec` gate is equivalent since no spec means no remote oauth); registry.ts mcpserver upgrade (checks both current CR and target entry via isRemoteOAuthEntry); registry.ts LlmHook/mcpserver/recipe catalog-id mismatch checks (route stamps the next identity itself, not a remote-OAuth conversion path) -> 1 fixed, 5 already sound. Repro-test: control-api/test/routes.resources.catalogInstanceGuard.test.ts:173,420,442 (fails on 4b26a03: 4 cases, expected 200 to be 409) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ved entry (BR-R1-M2) Install now stamps clerum.io/catalog-id from the entry the registry resolved (an unscoped curator alias like `x-mcp` becomes `@clerum/x-mcp`), but the upgrade identity check still compared it against the requested body.registryEntryName. Installing and upgrading through the same alias answered 409 mcp_server_entry_identity_mismatch. The check now runs after getEntryVersion and accepts the installed id when it equals either entry.name or body.registryEntryName. Checking only entry.name (the reviewer's cleaner option) would strand CRs installed before this PR: merge-base stamped body.registryEntryName, so an alias-annotated CR upgraded by that alias resolves to the scoped name and would be refused. Accepting both names keeps those upgradable and is the same trust merge-base placed in the registry's resolution. A genuinely different entry still mismatches both names and gets 409 before any write (tests pin alias->other alias, alias->other scoped, legacy alias->other, scoped->other alias). Contract change: the registry fetch now happens before the identity 409, so a missing target version surfaces as the registry error instead of the mismatch. The 409 reason names entry.name. Class-check: `installedCatalogId !== body.registryEntryName` in registry.ts -> 3 sites (llmhook upgrade :3633, mcpserver upgrade :4172, recipe upgrade :4707) -> fix the mcpserver one; llmhooks and recipes still stamp catalogAnnotations(body.registryEntryName, ...) at install and upgrade (:3009, :3226/:3233, :3830, :4822), so writer and check agree there. The remote OAuth writer (remoteMcp.ts:1652) has no upgrade path, since upgrade refuses remote OAuth CRs. Repro-test: control-api/test/routes.registryRemoteOAuth.test.ts:473 (fails on 791c7ad) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…R-R1-L18) The public-boundary gate rejects literal loopback URLs carrying a port in added lines outside test/ directories, so the parseServiceUrl assertions turned the shell-syntax CI job red. The gate stays as is; the test builds the same URLs at runtime. Class-check: gate regex "private runtime URL" (test-minikube-t2-public-boundary.sh:147) over added lines of f4d058b...HEAD → 2 sites, both seed-remote-oauth.test.mjs:516-517 → fixed both; `http://localhost:3000'` (l.519) is not matched (quote after port) but rewritten for consistency. Repro-test: scripts/tests/test-minikube-t2-public-boundary.sh (PUBLIC_BOUNDARY_REJECTED at 72c1bce) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… (R3-M2) A version that already existed (409) and was stored without any oauth block only failed its own entry, so the run went on to publish the next entry as a new immutable version in a registry that had just shown it discards the block; each rerun left one more broken version. A missing block now stops the run whether the version is fresh or skipped. A block that differs, or a different endpoint, still fails only that entry: those prove the registry keeps the block. Accepted collateral: a legitimate version published before the block existed also stops the run; the message points at --only. Class-check: `VerifyError = fresh ? StopRunError : Error` in seed-remote-oauth.mjs → 4 per-entry failure sites for skipped versions (reread throws, reread non-200, oauth missing, oauth/endpoint differs) → only "oauth missing" is evidence about the registry's capability, so only that one stops; the other 3 stay per entry. Repro-test: scripts/registry/seed-remote-oauth.test.mjs:385 (fails at 5fba2b5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lowing it (R3-L3)
The best-effort registry install report on a catalog install was caught with an
empty handler, and registryFetch throws without logging, so a registry that
rejects every report left no trace. It now emits a structured warning
(registry_report_install_failed) on the request logger with the entry identity
and only the error's name/message/status/code, never the error object, whose
cause chain is outside the redact paths. Still fire-and-forget: the install
response does not change.
Class-check: `.catch(() => {})` / `catch {}` added by the PR in control-api/src
(git diff f4d058b..HEAD) -> 3 sites -> fix remoteMcp.ts reportInstall; the 2
`catch {}` in isHttpsUrl and normalizeRemoteBaseUrl are URL-parse control flow
returning false/null, not a swallowed side-effect failure. The 6 preexisting
swallowed reportInstall/catch sites in registry.ts (2836, 3054, 3517, 3923,
4623, 4917) are out of scope: untouched by the PR and registry.ts is at the F3
fix budget.
Repro-test: control-api/test/routes.adminRemoteMcp.catalogMode.test.ts:822 (fails at 5fba2b5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
requestJson swallowed a res.json() failure as body = null, so a 200 from /discover with a broken body was reported as "MCP transport is unknown", a broken reread as "registry without remote-OAuth support?" and a broken token answer as a bare "HTTP 200". requestJson now flags the parse failure and the callers that need the body say "unparseable response body". What stops the run is unchanged: discover fails the entry, a broken reread follows the same fresh/skipped rule as a failed reread, a broken token answer stops. Class-check: `requestJson(` in seed-remote-oauth.mjs → 4 callers (token mint :417, discover :446, publish :474, reread :505) → fix token, discover, reread; publish reads the body only for the channel in the log line (falls back to "unknown") and for registryError on non-2xx, where a missing body already shows as the bare status. Repro-test: scripts/registry/seed-remote-oauth.test.mjs:531 (3 tests in "unparseable answers"; fail at 2a68ecf) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ectors (R3-H1) The admin Context PUT replaces the whole spec, but the connector access helpers rebuilt it from four fields, so adding or removing a connector on an agent erased spec.displayName and spec.gfs.mounts. Class-check: `updateContext(` / `buildContextUpdatePayload(` in control-ui (non-test) -> 3 sites (app/hosts/[name]/page.tsx:772, lib/connectorAccessManagement.ts add + remove) -> the hosts page already spreads the stored spec; both connectorAccessManagement sites share contextSpec(), fixed there. Other Context writers (lib/privateContext.ts, HostWizard) are create-only POSTs with nothing to preserve. Repro-test: control-ui/components/__tests__/ConnectorAccessManagement.test.ts:201 (fails at 5fba2b5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ivate IP (R3-L10) The endpoint policy check runs on the catalog entry URL in both /discover and install, but every catalog-mode test resolved DNS to a public IP, so dropping the check in catalog mode stayed green. Cover a publicly-named entry host that resolves to 10.0.0.1 on both routes: 400 on baseUrl with the real blocked-IP message, one registry read, no discovery, no probe, no CR, no install report. The entry comes from the file's own registryEntryRow-based builder. Mutation-check: replace the validateOAuthEndpointUrl call with [] in /discover (remoteMcp.ts:817) -> /discover case red (200); same in install (remoteMcp.ts:969) -> install case red (201). Test at control-api/test/routes.adminRemoteMcp.catalogMode.test.ts:563. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Notion advertises CIMD but not RFC 9207 (authorization_response_iss_parameter_supported), so control-api's discovery falls back to its registration endpoint and detects dcr, like Canva. With "cimd" in the catalog the seed's drift guard would have rejected the entry. The test no longer pins the mode by hand: it runs control-api's discovery over the captured provider answers and checks every catalog entry that has a capture against the drift rule. Class-check: catalog entries with a captured discovery fixture (PILOTS: notion, linear, sentry, canva) → 4 sites → notion was the only drift; linear/sentry (cimd) and canva (dcr) already match. Entries without a capture (e.g. monday-remote 'cimd') cannot be derived and keep their literal. Repro-test: scripts/registry/seed-remote-oauth.test.mjs:162 (fails at 5f831eb: "notion-remote: catalog says cimd, discovery detects dcr") Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…log (R3-L12) The read-after-write check on remoteEndpoints[0].url had no test: disabling it left the suite green. The new case preloads a version the registry stored for the same entry under another URL (built with the real row producer) and expects the skip to fail with the endpoint message. Mutation-check: seed-remote-oauth.mjs:273 `if (storedUrl !== ...)` → `if (false && storedUrl !== ...)` → seed-remote-oauth.test.mjs:545 red (ERR_ASSERTION), 37/38; reverted, git diff clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y limit (R3-L11) The cap on McpServer names one Context write may add was only tested above the limit (257 rejected), which an off-by-one comparison also passes. Add the boundary: 256 added names, all live, are accepted (200), each one is read once and the allowlist stores all of them. Mutation-check: `added.length > MAX_ADDED_MCP_SERVERS_PER_WRITE` -> `>=` in resources.ts:588 -> new case red (422 instead of 200). Test at control-api/test/routes.resources.contextUnknownMcpServer.test.ts:228. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…st on entry detail (R3-M3) For a remote-OAuth entry the registry's quality tier turns verified on the first install report and the trust level is publisher-influenced, so the detail page contradicted the provenance badge next to them. It now applies the catalog list's rule: provenance in the Verification field, no Trust row. Class-check: `quality_tier` / `trust_level` rendered in control-ui (non-test) -> 2 sites (components/RegistryCatalog.tsx:425, app/registry/entries/[name]/[version]/page.tsx) -> the catalog already swaps in the provenance badge; fixed the detail page with the same RemoteOAuthProvenanceBadge, and dropped RemoteOAuthEntryBadges' showProvenance prop whose only caller would now duplicate it. Repro-test: control-ui/components/__tests__/RegistryEntryDetailPage.remoteOAuth.test.tsx:101 (fails at 20f5bfa) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…us (R3-L1) The narrow right-aligned rule targeted the fourth column, which was Actions before the Status column was inserted; it squeezed and right-aligned Status and left Actions unstyled. Class-check: `.cu-marketplace-table` users in control-ui -> 1 table (components/RegistryCatalog.tsx:371, 5 columns per REGISTRY_COLUMNS at :49) -> moved the rule to nth-child(5); no other table carries the class. Repro-test: n/a — CSS, verified by inspection of columns in components/RegistryCatalog.tsx:49-55 (fails at 16f942c by inspection: nth-child(4) is Status) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…(R3-L4) After a refused write the dialog re-reads contexts and hosts; when that read failed it stayed open over the old resource versions and bound agents, so a retry could only conflict. It now closes and the write error stays on the page. Class-check: re-read after a failed addConnectorToAgentContexts -> 3 sites (components/RemoteOAuthSlots/useRemoteOAuthSlotActions.tsx:170, app/mcp-servers/page.tsx:136, components/ConnectorDetailAccessProvider/index.tsx:142) -> fixed the slot hook; the other two already surface the refresh failure next to the write error and keep their own page-level state, so they are left as is. Repro-test: control-ui/components/__tests__/RegistryCatalog.remoteOAuth.test.tsx:301 (fails at 45f3c8c) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The catalog install omitted baseUrl, so control-api's catalog_base_url_mismatch precondition never ran from the UI and an entry whose URL changed after the wizard opened would install a URL the operator never saw. The body now echoes the entry URL shown read-only (from the registry entry, never the link); control-api still installs the entry's own URL. Class-check: catalog-mode request builders omitting baseUrl -> 2 sites (buildCatalogRemoteInstallRequest, discoverCatalogRemoteServer in lib/remoteMcp.ts) -> fixed the install; discover is a write-free dry run made from the same entry read, so the install precondition already covers the whole wizard window. Repro-test: control-ui/lib/__tests__/remoteOAuthCatalog.test.ts:141 (fails at 5baf105) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Nov 9, 2026
Textual conflicts resolved: - control-api/control-ui package.json + package-lock.json (version field): kept the higher version on each side (control-api 0.1.942 from dev, control-ui 0.1.771 from the branch). - .github/workflows/ci-public.yml: kept both control-api steps (dev's rpc-proxy npm ci, then the branch's remote-OAuth catalog seed test). - tests/contracts/password-recovery-suites.json: union of both lists of real-Postgres suites. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYXL6paVKn431XWjDTQLwr
The merge resolved the version line to dev's 0.1.942, so one number would name two different control-api trees and the release version validator fails the PR against dev's tip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYXL6paVKn431XWjDTQLwr
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYXL6paVKn431XWjDTQLwr
The server-name golden consumed by mcp-host's wire tool-name tests only carried registry names, because the catalog-slot producer did not exist in dev when the wire-name fix landed. This branch adds it, so: - control-api's golden test now also emits the `catalog` half from the real deriveCatalogRemoteServerName (Notion and Atlassian, user and context scopes, plus a slug past the 40-char cap) and asserts the catalog maximum is 55 characters. serverNames.json regenerated with UPDATE_WIRE_GOLDENS=1. - mcp-host requires a non-empty catalog, runs the per-provider wire-name contract against the catalog Notion slot as well as the registry one, and extends the longest-server-names suite to the 55-character catalog servers, where only `<server>__search` (63) fits the 64 envelope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYXL6paVKn431XWjDTQLwr
…uest (BR-R4-M1) fetch rejects a header value with CR, LF or NUL and its TypeError quotes the whole value, so a badly pasted CONTROL_API_TOKEN was printed once per entry, and a `;` appended cookies of its own. The token is now checked against RFC 6265 cookie-octet (control-api mints a JWT, which fits) and the minted curator token against visible ASCII, so no credential can reach errorMessage through a refused header. Class-check: `process.env|headers|authorization|cookie|errorMessage(` in seed-remote-oauth.mjs → 5 credential sites → fix CONTROL_API_TOKEN cookie (:457) and the registry-minted Bearer token (:475); CURATOR_CLIENT_ID/SECRET go out base64-encoded (always a valid header), SEED_ENTRA_TENANT_AUTHORITY goes in the JSON body, CONTROL_API_URL/REGISTRY_URL already refuse userinfo in parseServiceUrl; the 3 errorMessage( call sites are left as they are because every header value is now validated upstream. Repro-test: scripts/registry/seed-remote-oauth.test.mjs:625 (fails on d3856ae) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYXL6paVKn431XWjDTQLwr
…ure (BR-R4-L10)
The catch around res.json() turned a timeout or connection reset while the body
streamed in into "unparseable response body", blaming the server's answer instead
of the connection. Only a SyntaxError now means unparseable; anything else is
rethrown and reported by errorMessage like any other fetch failure.
Class-check: `catch` in seed-remote-oauth.mjs → 9 sites → fix the res.json() one (:303); the other bare `catch {` (:77 URL parse, :603 realpath) only wrap synchronous non-network calls, and the 7 `catch (err)` already report err. Callers of requestJson (:436 token, :471 discover, :499 publish, :530 reread) all sit inside a try that turns a throw into a reported failure: discover/token fail the entry, publish stops the run as "outcome unknown", reread raises its VerifyError.
Repro-test: scripts/registry/seed-remote-oauth.test.mjs:621 (fails on db4826f)
F3: 4th fix on seed-remote-oauth.mjs, exception authorized by the maintainer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CYXL6paVKn431XWjDTQLwr
Refusals showed raw codes, and an entry without a verdict (control-api too old) told the admin to report it to the curator. One reason-to-copy mapping now serves every surface; unresolved/malformed_response point at a version mismatch, unknown reasons get a generic message, and the code stays as secondary detail. Class-check: rendered remoteOAuth reason (grep "\.reason" in app/components/lib near remoteOAuth/view, plus catalog_entry_invalid) -> 5 sites (remoteMcp.ts resolveRemoteCatalogTarget, remoteMcp.ts describeCatalogRequestError, registry/install page banner, entry detail page hint, RemoteOAuthNotInstallable chip title) -> all 5 use the mapping. Producer list read from control-api RemoteOAuthInvalidReason in lib/__tests__/remoteOAuthNotInstallable.test.ts so a reason without copy fails. Repro-test: control-ui/components/__tests__/RegistryInstallPage.remoteOAuth.test.tsx:157 (fails on 480750a) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYXL6paVKn431XWjDTQLwr
… room (BR-R4-L9) The Status column (16rem) was added without raising min-width: 54rem, so at the narrowest width Description fell from ~28.5rem to ~12.5rem. The Name th carries an inline 24% that overrides the 18rem class rule, so Description = 0.76W - 28.5rem; 76rem gives ~29.3rem, at least the merge-base's 28.54rem. Class-check: fixed-layout table min-width vs. its column widths after a column was added (grep "cu-marketplace-table" in globals.css, REGISTRY_COLUMNS in RegistryCatalog.tsx) -> 1 table changed in this PR -> fixed. The 18rem nth-child(1) rule was already dead at the merge-base (inline 24% wins); left as is. Repro-test: none (CSS-only; widths computed from RegistryCatalog th widths, visual check pending) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYXL6paVKn431XWjDTQLwr
…s (BR-R1-L9) catalogEntryFor prefers the name+version catalog row and only then falls back to any row with the same name; nothing pinned the first lookup. The new case lists a plain 0.9.0 row before the remote-OAuth 1.0.0 row of the same entry and asserts each owned version gets its own row's actions. Mutation: drop the exact name+version find in components/PublisherView/OwnedEntries.tsx catalogEntryFor -> the new test fails (Unable to find menuitem "Install (per user)" for v1.0.0); restored -> 4/4 green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYXL6paVKn431XWjDTQLwr
… (BR-R4-M2) classifyRemoteOAuthSlots decides who owns each catalog install slot and was covered only by hand-picked cases; the properties encode its decision table over inputs from the real CR, install-route and entry producers. Mutation: no name sort -> order-independence + precedence red; reversed sort -> precedence red; canonical check ignored -> precedence + accepted terminating-canonical red; later claimant overwrites owner -> precedence red; manual CRs dropped -> nothing-lost + accepted terminating-manual red; terminating manual dropped -> same two red; terminating claimant loses precedence -> precedence + accepted terminating-canonical red; non-remote names ignored for foreign -> foreign red; invalid catalog id not discarded -> nothing-lost + foreign red; catalog CR also listed as manual -> no-duplicates + nothing-lost red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYXL6paVKn431XWjDTQLwr
This reverts commit da35b5e. At 76rem the table needs a ~1500px viewport once the sidebar is counted, so on 1280-1440px laptops the Actions column (the Install menu) scrolls out of view. A narrow Description at the minimum width is the lesser problem; BR-R4-L9 is closed in writing instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYXL6paVKn431XWjDTQLwr
…eed test (BR-R4-L10) The connection-cut case asserted undici's "TypeError: terminated", which a Node upgrade can reword without any regression. It now checks that the entry is reported as failed and not as an unparseable body; against the pre-fix seed it still fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYXL6paVKn431XWjDTQLwr
…-R4-L8) Three asserts were prefix/regex matches that already passed with the old text, so a site that stopped using the reason-to-copy map stayed green; they now expect the full message derived from describeRemoteOAuthNotInstallable on the producer's reason. Mutation: resolveRemoteCatalogTarget (lib/remoteMcp.ts) -> red, describeCatalogRequestError catalog_entry_invalid (lib/remoteMcp.ts) -> red, entry detail page hint (app/registry/entries/[name]/[version]/page.tsx) -> red Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYXL6paVKn431XWjDTQLwr
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.
What
Admins can install curated remote OAuth MCP connectors (Notion, Linear, Asana, Datadog per region, Work IQ, ...) straight from the Marketplace, reusing the existing remote MCP wizard with the catalog entry prefilled instead of typing a URL by hand.
catalogRemoteOAuth.ts: parses themcp_server_meta.oauthblock of a registry entry, derives the canonical server name per slot (user/context), normalizes base URLs, strips untrusted presentation metadata and checks catalog instance identity.remoteOAuth: { valid, reason? }. Guide, console URL and verification badge are trusted only on curator entries (@evenfire/@clerumscope with an org owner); org entries get them stripped./catalogreturns per-entry slot state (free, canonical, non-canonical,terminating,foreign) plus the list of manual (non-catalog) remote-OAuth instances, computed from data the endpoint already loads (no extra fetch). The UI matches that list to an entry by normalized URL to warn about a manual instance of the same connector.POST /admin/remote-mcpgains a catalog mode: the server takescatalogEntry.name/catalogEntry.version, resolves URL and name itself and never installs a clientbaseUrl: an optionalbaseUrlis only a precondition (400catalog_base_url_mismatchif the entry now points elsewhere), and the wizard sends the URL it showed. New errors:catalog_entry_invalid,catalog_entry_name_mismatch,catalog_grant_scope_not_allowed,catalog_slot_residue,install_in_progress./discoverin catalog mode returns the derivedserverNameand reports registration drift.remote_oauth_upgrade_unsupported(versions carry identity). This closes an existing gap: before this PR,POST /admin/registry/upgradeon a CR withoauth.source: remotetowards a version with a different remote endpoint returned 200 and rewroteremote.baseUrland egress while keeping the OAuth endpoints pinned from the old version, with no discovery. (The generic400 Invalid catalog oauth blockcame from the install route, not from upgrade.)PUT/POST) reject newly added MCP server names that have no live McpServer with 422unknown_mcp_server(names already present are never blocked). Capped at 256 names (too_many_mcp_servers).POSTof a remote-OAuth spec withclerum.io/catalog-*annotations is a 400catalog_annotations_not_allowed, and aPUTthat would turn a CR into a catalog instance is a 409catalog_instance_immutable. A catalog instance is immutable exceptenabled,description,egressBindings,resources,healthCheckandoauth.scopes: any other spec change (includingimage,remote.authHeaders,envSecret,oauth.backgroundAccess, and fields added to the CRD later) or a change to theclerum.io/catalog-id/clerum.io/catalog-versionannotations is a 409catalog_instance_immutable, whosefieldslists the sorted dotted paths that differ. The CRD's CEL transition rules already pinned most of theoauthblock; the guard repeats them so the caller gets a 409 withfieldsbefore reaching the apiserver. Callers must resend the spec they read; a minimal spec is a change.PUTwithoutspecis a 400.statecarries the McpServeruid; the callback rejects a consent that was in flight when the server was reinstalled:oauth_state_outdatedwhen the reinstall kept the OAuth client id (CIMD, pre-registered),invalid_statewhen it minted a new DCR client. Both are refused before the code exchange.uidonly; catalog annotations on mcpservers install/upgrade come fromentry.name. The registry looks entries up by exact name and version (no alias, nolatest), so upgrading a McpServer under a different entry name is refused with 409mcp_server_entry_identity_mismatchbefore fetching, and a registry answering a different entry than requested is refused too./registry/installroutes remote-OAuth entries to the wizard in catalog mode (one slot: direct; two slots: per user / per context choice; invalid: error with reason).MarkdownContent(https-onlylinks), provenance badge, provider console button.RowActionsMenu. Entry detail page shows per-slot buttons.displayNameandgfsmounts (existing helper, now also reached from the Marketplace).contextMutation;addConnectorToAgentContextsusesPromise.allSettledand callers reload from the server; HostWizard deselects vanished connectors.scripts/registry/seed-remote-oauth.mjs+remote-oauth-catalog.json. Validates every entry through control-api/discover, publishes with the curator credential and re-reads each version to confirm theoauthblock survived; a stored version without the block (fresh or already published) stops the run, so a registry that drops it does not collect more broken immutable versions. Refuses aCONTROL_API_TOKENor a minted curator token that is not a valid header value before sending anything, so no credential can be echoed in an error. Reuses control-api's parser through Node type stripping. Hermetic test wired intoci-public.yml.Why
Installing a remote OAuth connector today means finding a hidden "Add remote server" menu item and knowing the exact MCP URL, scopes and registration model of each provider. This moves that knowledge into curated Marketplace entries, keeps one canonical instance per slot so the same connector is not installed twice under different names, and narrows two internal paths that could silently re-enable a connector after it was uninstalled (dangling context allowlist names, stale OAuth consent).
How
entry_typeand no CRD change: a remote-OAuth entry is anmcp-serverwithserver_mode: remoteandmcp_server_meta.oauth.source: 'remote'.remoteOAuth.validand the UI never re-validates it. The only rule the UI computes itself is the manual-instance warning, which uses a copy ofnormalizeRemoteBaseUrlpinned to the server's by a golden produced by the control-api router. Validation depends on provenance; an orgpre-registeredentry is installable without a guide.kubectl/GitOps) is accepted without extra reporting.dynamic_clientsrow bound to a dead uid. Same behavior as the manual install path; in catalog mode the next DCR install of the slot reclaims the row, and revokes the old client only when it has an RFC 7592 management handle; otherwise the old client stays registered at the authorization server, with no log entry. A non-DCR install of the slot does not touch the row. Tracked separately.routes.catalogRemoteOAuth.wireGoldens.test.ts,routes.contextRejection.wireGoldens.test.ts), not hand-written.Testing
remoteOAuthCatalogSlots.model…,routes.adminRemoteMcp.catalogMode.realPostgres…) were re-run green; last full real-Postgres run, before them: 673 files / 9290 tests, all green.remoteOAuthCatalogSlots.model.realPostgres.integration.test.ts(fast-check commands + scheduler over the real routes and real Postgres) checks slot liveness/safety/cost invariants in quiescence; long run 3 seeds x 3000 runs green.routes.adminRemoteMcp.catalogMode{,.realPostgres.integration}.test.ts,routes.registryUninstall.catalogRemoteOAuth.test.ts,routes.resources.catalogInstanceGuard.test.ts,routes.resources.contextUnknownMcpServer.test.ts,catalogRemoteOAuth.test.ts(unit + property tests),catalogRemoteOAuthSlots.property.test.ts(fast-check properties of the slot classification: order independence, one owner per slot, nothing lost, canonical precedence).routes.adminRemoteMcp.catalogMode.realPostgres.integration.test.ts,remoteOAuthCatalogSlots.model.realPostgres.integration.test.ts) added totests/contracts/password-recovery-suites.json; census check ok.seed-remote-oauth.test.mjs42/42 against fake registry/control-api servers.tscok, 9018 tests green (606 files); the server-name golden now carries 6 catalog names fromderiveCatalogRemoteServerName(max 55), and the provider contract and longest-server-name suites run against them.tscok, 3210 tests green (273 files);npm run buildnot re-run after the last review fixes.PR size
166 files across control-api, control-ui, scripts and mcp-host (tests and golden only), above the usual 25 files / 1 service budget. About two thirds (107) are tests and generated goldens (33 JSON fixtures). The UI depends on the new control-api contract (slot state, catalog mode, 422 shape) and its fixtures are produced by the control-api routers in this branch, so the two halves were kept together on purpose. Non-test code: 14 control-api, 35 control-ui, 3 scripts (one is the seed catalog data), none in mcp-host; the remaining 7 are package version bumps and one CI step for the seed test.
Risks / Follow-ups
Closes #1388), already merged intodevand synced into this branch. This PR now fills thecataloghalf of the shared server-name golden (mcp-host/src/llm/__tests__/fixtures/serverNames.json) from the real catalog producer, so mcp-host's wire-name tests also run against catalog slot names (up to 55 characters).mcp_server_meta.oauth) must be deployed before running the seed, and the curatorchannelmust be decided. The code in this PR does not depend on either: without seeded entries the Marketplace simply shows no remote-OAuth connectors.workflow-recipeswrites Contexts directly against Kubernetes and is not affected.resourceVersionthat hits a 409 is retried on a fresh read without re-running the check. The same retry applies to a generic McpServerPUTwithoutresourceVersion: the catalog instance guard is not re-run on the fresh read, so two concurrent admin writes could let a change through. The UI sendsresourceVersionon Context writes; the McpServer edit page (app/mcp-servers/[name]/edit, untouched here) sends onlyspec, so for McpServers the UI is exposed too. Tracked separately.uid, so the callback answersoauth_state_outdated(states live 10 min). Affected users just start the consent again.creationTimestamp(10 min window); a large clock skew could report an in-flight install ascatalog_slot_residue.