Skip to content

feat: install remote OAuth MCP connectors from the Marketplace - #1284

Open
zach88 wants to merge 74 commits into
devfrom
feat/marketplace-remote-oauth
Open

zach88 wants to merge 74 commits into
devfrom
feat/marketplace-remote-oauth

Conversation

@zach88

@zach88 zach88 commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

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.

  • control-api
    • Pure module catalogRemoteOAuth.ts: parses the mcp_server_meta.oauth block 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.
    • Registry reads resolve every remote-OAuth entry once and return remoteOAuth: { valid, reason? }. Guide, console URL and verification badge are trusted only on curator entries (@evenfire / @clerum scope with an org owner); org entries get them stripped.
    • /catalog returns 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-mcp gains a catalog mode: the server takes catalogEntry.name / catalogEntry.version, resolves URL and name itself and never installs a client baseUrl: an optional baseUrl is only a precondition (400 catalog_base_url_mismatch if 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. /discover in catalog mode returns the derived serverName and reports registration drift.
    • Upgrading a remote-OAuth connector in place is refused with a stable code, 409 remote_oauth_upgrade_unsupported (versions carry identity). This closes an existing gap: before this PR, POST /admin/registry/upgrade on a CR with oauth.source: remote towards a version with a different remote endpoint returned 200 and rewrote remote.baseUrl and egress while keeping the OAuth endpoints pinned from the old version, with no discovery. (The generic 400 Invalid catalog oauth block came from the install route, not from upgrade.)
    • Context writes (PUT/POST) reject newly added MCP server names that have no live McpServer with 422 unknown_mcp_server (names already present are never blocked). Capped at 256 names (too_many_mcp_servers).
    • Generic McpServer writes cannot create or rewrite the identity of a catalog instance: a POST of a remote-OAuth spec with clerum.io/catalog-* annotations is a 400 catalog_annotations_not_allowed, and a PUT that would turn a CR into a catalog instance is a 409 catalog_instance_immutable. A catalog instance is immutable except enabled, description, egressBindings, resources, healthCheck and oauth.scopes: any other spec change (including image, remote.authHeaders, envSecret, oauth.backgroundAccess, and fields added to the CRD later) or a change to the clerum.io/catalog-id / clerum.io/catalog-version annotations is a 409 catalog_instance_immutable, whose fields lists the sorted dotted paths that differ. The CRD's CEL transition rules already pinned most of the oauth block; the guard repeats them so the caller gets a 409 with fields before reaching the apiserver. Callers must resend the spec they read; a minimal spec is a change. PUT without spec is a 400.
    • The MCP OAuth state carries the McpServer uid; the callback rejects a consent that was in flight when the server was reinstalled: oauth_state_outdated when the reinstall kept the OAuth client id (CIMD, pre-registered), invalid_state when it minted a new DCR client. Both are refused before the code exchange.
    • CR rollback in the install saga is fenced by uid only; catalog annotations on mcpservers install/upgrade come from entry.name. The registry looks entries up by exact name and version (no alias, no latest), so upgrading a McpServer under a different entry name is refused with 409 mcp_server_entry_identity_mismatch before fetching, and a registry answering a different entry than requested is refused too.
  • control-ui
    • /registry/install routes remote-OAuth entries to the wizard in catalog mode (one slot: direct; two slots: per user / per context choice; invalid: error with reason).
    • Wizard catalog mode: prefilled and read-only identity, auto Detect, inline setup guide rendered with MarkdownContent (https-only links), provenance badge, provider console button.
    • Marketplace table gets a Status column with slot lines and a manual-instance warning; per-slot actions live in RowActionsMenu. Entry detail page shows per-slot buttons.
    • Adding or removing an agent's connector forwards the full Context spec it read; it used to rebuild the spec and drop displayName and gfs mounts (existing helper, now also reached from the Marketplace).
    • The 422 from context writes is mapped once in contextMutation; addConnectorToAgentContexts uses Promise.allSettled and callers reload from the server; HostWizard deselects vanished connectors.
  • scripts: 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 the oauth block 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 a CONTROL_API_TOKEN or 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 into ci-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

  • No new entry_type and no CRD change: a remote-OAuth entry is an mcp-server with server_mode: remote and mcp_server_meta.oauth.source: 'remote'.
  • One decision point for installability: control-api resolves remoteOAuth.valid and the UI never re-validates it. The only rule the UI computes itself is the manual-instance warning, which uses a copy of normalizeRemoteBaseUrl pinned to the server's by a golden produced by the control-api router. Validation depends on provenance; an org pre-registered entry is installable without a guide.
  • Catalog installs never write context allowlists. Reinstalling into a slot whose canonical name is still in an allowlist (only possible via kubectl/GitOps) is accepted without extra reporting.
  • Accepted residual: an uninstall landing between CR create and DCR bind leaves a pending dynamic_clients row 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.
  • UI wire fixtures are goldens produced by the real control-api routers (routes.catalogRemoteOAuth.wireGoldens.test.ts, routes.contextRejection.wireGoldens.test.ts), not hand-written.

Testing

  • control-api: build ok; 9181 tests green without Postgres (638 files; 127 files / 950 tests skipped, Postgres-only). After the latest review fixes the affected real-Postgres suites (remoteOAuthCatalogSlots.model…, routes.adminRemoteMcp.catalogMode.realPostgres…) were re-run green; last full real-Postgres run, before them: 673 files / 9290 tests, all green.
  • Model-based oracle 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.
  • Scenario suites: 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).
  • Two new real-Postgres suites (routes.adminRemoteMcp.catalogMode.realPostgres.integration.test.ts, remoteOAuthCatalogSlots.model.realPostgres.integration.test.ts) added to tests/contracts/password-recovery-suites.json; census check ok.
  • Seed: seed-remote-oauth.test.mjs 42/42 against fake registry/control-api servers.
  • mcp-host (test-only): tsc ok, 9018 tests green (606 files); the server-name golden now carries 6 catalog names from deriveCatalogRemoteServerName (max 55), and the provider contract and longest-server-name suites run against them.
  • control-ui: tsc ok, 3210 tests green (273 files); npm run build not 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

  • Merge dependency — mcp-host: MCP tool names over 64 characters make the LLM provider reject every call of the context #1388 (mcp-host tool names over 64 characters): resolved. Catalog slot names reach 55 characters, so some tools of the seeded connectors (Notion: 6 of 19, up to 81; Atlassian: 70) exceed the 64-character function-name limit of OpenAI, Azure, OpenAI-compatible providers and Bedrock. The aliasing fix shipped in fix(mcp-host): alias tool names that exceed the provider name limit #1445 (Closes #1388), already merged into dev and synced into this branch. This PR now fills the catalog half 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).
  • Deploy order for the seed only: keyper-labs/evenfire-registry#77 (persist and validate mcp_server_meta.oauth) must be deployed before running the seed, and the curator channel must be decided. The code in this PR does not depend on either: without seeded entries the Marketplace simply shows no remote-OAuth connectors.
  • The seed has not been run against a real registry. Some provider details (suggested scopes for HubSpot/Slack, Google MCP API names, Slack app requirements, GitHub OAuth App vs GitHub App, Entra console URL) still need confirmation in the end-to-end pass.
  • New 422 on context writes: clients that add an MCP server name to a Context before creating the McpServer now fail. In-repo callers and e2e seeders were reordered; workflow-recipes writes Contexts directly against Kubernetes and is not affected.
  • Dangling context names are narrowed, not fully closed: during an uninstall the name is stripped from allowlists before the CR is deleted, so a Context write landing in that window (or while the CR's finalizer runs) can still add it back. A generic Context write sent without resourceVersion that hits a 409 is retried on a fresh read without re-running the check. The same retry applies to a generic McpServer PUT without resourceVersion: the catalog instance guard is not re-run on the fresh read, so two concurrent admin writes could let a change through. The UI sends resourceVersion on Context writes; the McpServer edit page (app/mcp-servers/[name]/edit, untouched here) sends only spec, so for McpServers the UI is exposed too. Tracked separately.
  • Deploying this invalidates every MCP OAuth consent in flight, not only catalog ones: states minted before the deploy carry no McpServer uid, so the callback answers oauth_state_outdated (states live 10 min). Affected users just start the consent again.
  • Slot classification compares control-api's clock with the apiserver creationTimestamp (10 min window); a large clock skew could report an in-flight install as catalog_slot_residue.

zach88 and others added 25 commits October 30, 2026 14:32
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>
@zach88
zach88 requested a review from jozer-rami as a code owner November 1, 2026 13:42
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>
zach88 and others added 2 commits November 1, 2026 10:17
… 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>
zach88 and others added 13 commits November 6, 2026 14:12
…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>
zach88 and others added 13 commits November 11, 2026 22:03
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants