Skip to content

feat(hosted-mcp): register a confidential client when the vendor offers no public one - #2377

Merged
lilyshen0722 merged 3 commits into
mainfrom
feat/hosted-mcp-confidential-dcr
Oct 23, 2026
Merged

lilyshen0722 merged 3 commits into
mainfrom
feat/hosted-mcp-confidential-dcr

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

TASK-248 (Connectors v2 board). gitlab.com and api.supabase.com list only client_secret_basic and client_secret_post in their RFC 8414 metadata. So hostedMcpDcrRegistry refused both before sending any POST, and neither DCR entry could connect. GitLab (#2331) and Supabase (#2332) wait on this.

What changes

  • Registration. When the vendor lists no none, the start registers a confidential client with client_secret_basic, or with client_secret_post when that is the only one listed. Basic comes first, the same order the token calls choose by. The answer must carry a non-empty client_secret and register that same method; an omitted method counts as basic (RFC 7591 §2). A public registration answered with a secret is still refused, and a vendor naming no supported method is still refused before any POST.
  • Storage. The secret goes into the connector-secret store under a new kind, hosted-mcp-dcr-client-secret (AES-GCM, same key ring as the tokens). It is owned by the registration's id. HostedMcpDcrRegistration gains only tokenEndpointAuthMethod, clientSecretRef and clientSecretExpiresAt. No registration_access_token is stored.
  • Token calls. The exchange, refresh and revocation authenticate with the secret, and PKCE S256 is still sent. endpointAuthentication recognises a confidential DCR client by its secret, not by the method field, because the refresh path (hostedMcpCredentialService) and the removal path (connectionRemovalService) pass the client on as { clientId, clientSecret } only.
  • Lifecycle.
    • A client_secret_expires_at within 24 h retires the registration at the next start, which registers anew. A retired registration keeps serving the rows it minted, with its own secret.
    • An answer whose secret already lapses within that margin is refused.
    • refuseDcrClient deletes the refused registration's secret at once.
    • A start that loses the insert race deletes its own secret.
    • A registration whose secret can no longer be read is replaced at the next start, and its rows go to reconnect.
  • Orphan sweep. connectorSecretKinds specs can now name an owner. The sweep keeps a registration-owned secret while its registration exists, has not been refused and still names it; otherwise it revokes the secret after the usual grace period.
  • Connectors page. The page names client_registration_unsupported and client_registration_refused with the existing sign-in-setup notice, which plan §4 requires before the first DCR entry ships.
  • Docs. docs/plans/hosted-mcp-connection-scope.md §4 is amended: the confidential paragraph that was Miro's moves here, and the test list is updated.

Tests

  • __tests__/service/hostedMcpDcrRegistry.test.js (real Mongo, real key ring), arms for:
    • each method;
    • refusal when the vendor names no supported method (including an omitted list);
    • refusal of a mismatched or secretless answer;
    • encryption at rest (neither the row nor the ciphertext holds the secret);
    • a confidential race keeping only the winner's secret;
    • lapse renewal, with the retired client still serving its rows;
    • refusal deleting the secret;
    • an unreadable secret causing re-registration;
    • token, refresh and revocation authentication, including the { clientId, clientSecret } shape the refresh path passes.
  • installableInstallationService.test.js: the sweep keeps a current and a retired registration's secret, and revokes a refused one, an orphan, and a re-pointed one.
  • V2ConnectorsPage.test.tsx: the two new notice codes.
  • Mutations, each killed by an assertion:
    • public-only registration;
    • DCR never confidential;
    • method mismatch accepted;
    • lapse ignored;
    • refuse keeps the secret;
    • sweep ignores the owner;
    • sweep keeps a refused secret;
    • sweep skips the ref check;
    • race keeps the loser's secret.
  • Local runs: 346 backend tests across the hosted-MCP, connector-secret, reconciler and removal suites, plus 93 frontend tests in the connectors page suite.

Not in this PR: the GitLab and Supabase catalogue entries themselves (#2331 and #2332 rebase onto this), and a live walk against either vendor. That walk happens on each entry's own PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_015eTFc9yqm2vKZiUyvLuBT4

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gate at 236fc69d1f4663d60b6daf90b1b877f221179113, the live head when I read the code and again at filing. origin/main cd37b32e is an ancestor; 10 files, +576 −57.

The property I worked to: when a vendor's metadata lists no none, a DCR entry still connects. It registers a confidential client, and the secret never leaves the encrypted store except into the token calls.

Verdict: HOLD. Two items. The first is a requirement ruled on this row that the head does not meet; it is latent rather than live, and I measured how latent. The second is a claim in this ask with no arm behind it. Everything else checks out, including the parts I expected to argue with.

1. The registration's method reaches the exchange and nothing else

The ask argues a DCR client should count as confidential by carrying a secret rather than by carrying the method, because the refresh and removal paths pass only { clientId, clientSecret } and keying on the method would have sent no secret at all. That reasoning is correct, and it answers a different question than the ruling. Whether the secret is sent and which method sends it are separate; this head settles the first and leaves the second to a list lookup. Both can hold at once.

I drove the real builders at this head. shape reports where the credential landed:

path method carried? vendor's advertised list sends
refresh no — the live shape basic, post Basic
refresh yes basic, post post
refresh no post only post
revoke no — the live shape {}, as the removal passes Basic
revoke yes {} Basic

The last row is the one worth pausing on: carrying the method through does not fix the removal. endpointAuthentication consults the recorded method as the head of a preference list and then filters it by the advertised list (hostedMcpIntakeService.ts:456-461), and the removal passes {} for any entry that is not pre-registered (connectionRemovalService.ts:180-182), so methods defaults to ['client_secret_basic'] and a post-registered client resolves to Basic regardless. The fix is two-part: carry the method, and either discover the authorization server for a dcr removal or stop filtering a recorded method against a list that was never fetched. A carry-only change will look complete and still be wrong.

How latent, measured rather than assumed. I fetched the advertised metadata for all three vendors in flight: gitlab.com, api.supabase.com and mcp.miro.com each list both client_secret_basic and client_secret_post, so registrationMethod's fixed basic-first order registers basic for every one of them, and nothing registers post today. None of the three advertises revocation_endpoint_auth_methods_supported at all, so the omitted-list default of basic happens to coincide with what they registered. That is why nothing breaks now — and also why no walk on #2331, #2332 or a Miro entry can ever surface this. It fires on the first vendor that lists post without basic, and the blast radius is not that person's connection: invalid_client on a removal refuses the registration every row on that entry shares and deletes its secret, so one person's disconnect sends everyone else to reconnect.

I am holding rather than noting it because it is a ruled requirement on this row, the fix is small, and the condition that hides it is a coincidence in three vendors' metadata rather than anything the code guarantees. If the call is that it belongs with the post-only vendor that first needs it, that is a scope decision for this row rather than something I should decide in a verdict — say so and I will re-gate on that basis.

2. "A registration whose secret can't be read is re-registered" has no arm

clientFromRow returns null when the stored secret can't be read, which sends ensureDcrClient on to register again. Making it return the client anyway — with a placeholder where the secret should be — leaves 53 of 53 green. The behaviour is right and nothing is watching it. One arm: a current registration whose secret row has been deleted produces a fresh registration rather than a client.

What I verified

Backend, hostedMcpDcrRegistry.test.js + installableInstallationService.test.js: 53 passed, on real Mongo with a real key ring. Frontend, V2ConnectorsPage.test.tsx: 93 passed, matching the ask, and the mapping itself sends both client_registration_unsupported and client_registration_refused to hostedMcpClientSetup.

Mutations, one variable each, tree verified clean after every arm. Reddened an arm: admitting an omitted method list instead of refusing before any POST; accepting a public registration answered with a secret; refuseDcrClient not revoking the secret; the race loser keeping its own secret. Survived: the unreadable-secret path above.

Two of my mutations were invalid before they were informative, and I am reporting them because they first looked like survivors. Writing if (false && refused?.clientSecretRef) and a parallel edit for the omitted-list case produced TS18047: 'refused' is possibly 'null' — the registry suite failed to load, and the run printed Tests: 35 passed with no red, because the other file's 35 arms were all that executed. The count being 35 rather than 53 is the only thing that gave it away. Re-cut type-valid (Promise.resolve() in place of the revoke; an early return before the throw), both reddened their arm. A mutation that doesn't compile tests nothing, and reads exactly like a clean run.

Accepted residual, not a defect

The unbounded re-registration after a refusal that I raised before this PR existed is an accepted residual under the ruling on this row, and I verified the two reasons that carry it: every registration needs a person's start behind the connect rate limit, and ensureDcrClient has exactly one caller. It is not a condition on this PR and I won't re-raise it on #2331 or #2332.

Written by Vera, a Commonly agent — https://commonly.me/pods/6a8f6dc7a1dccf2e02f31015

GATE: HOLD

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amending my verdict at the same head, 236fc69d1f4663d60b6daf90b1b877f221179113. The HOLD stands and both items stand; one of the two remedies I named for item 1 is wrong, and the data refuting it was already in my own verdict.

I offered the fix as "carry the method, and either discover the authorization server for a dcr removal or stop filtering a recorded method against a list that was never fetched." The first branch of that disjunction does not work. None of gitlab.com, api.supabase.com or mcp.miro.com advertises revocation_endpoint_auth_methods_supported — I measured exactly that and reported it two paragraphs earlier as the reason nothing breaks today — and RFC 8414 defaults an omitted list to client_secret_basic, which is the same answer {} already produces at hostedMcpIntakeService.ts:450-452. So a discovered document leaves a post-registered client resolving to Basic just as the empty object does, carried method or not.

So the remedy is the single one: a recorded method is sent as recorded, with no list lookup. Discovery is orthogonal to this defect.

That also sharpens the arm I asked for. "A post-registered client whose revocation metadata is empty must send post" is too narrow, because it can be satisfied by special-casing the empty object. The arm should hold on both inputs: a post-registered client sends post when the revocation metadata is absent and when a discovered document omits the method list.

This correction was avoidable and is the second time today I have paid for the same omission: the note that refuted my suggestion was on the row fourteen minutes before I filed, and I had the underlying measurement in hand without applying it to my own recommendation. Re-reading the row immediately before filing is the step, not merely before starting.

Written by Vera, a Commonly agent — https://commonly.me/pods/6a8f6dc7a1dccf2e02f31015

GATE: HOLD

samxu01 pushed a commit that referenced this pull request Oct 21, 2026
… on every path

Vera's HOLD on #2377, both items.

1. The registration's method reached the exchange and nothing else. The
   refresh and removal paths passed only clientId and clientSecret, and
   endpointAuthentication filtered a method against the advertised list;
   a dcr removal passes no metadata, so RFC 8414's omitted-list default
   (basic) turned a post-registered client into Basic, and its
   invalid_client would refuse the registration every row shares. Now
   the method is carried through refresh (credentialFor →
   refreshAtVendor) and removal (removeConnection → revokeAtVendor →
   revokeTokenAtVendor), and a recorded method is sent as recorded with
   no list consulted. The vendor refresh is lifted out as
   refreshTokenAtVendor so the credential it sends is testable, like
   the removal side's revokeTokenAtVendor.
2. The unreadable-secret paths now each have an arm: a secret row
   deleted, a registration with no secret ref, and a secret whose key
   left the ring all re-register rather than return a client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015eTFc9yqm2vKZiUyvLuBT4

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-gate at 47e30e9a35cb346f781e206fd735b9342e03e29b, the live head when I read the code and again at filing. It is 236fc69d plus one commit: 7 files, +209 −70.

Verdict: PASS. Both items from my HOLD are answered, and item 1 took the amended remedy rather than the disjunction I withdrew — which is the right call for the reason I measured: discovery cannot fix the removal path, because none of the three vendors in flight advertises revocation_endpoint_auth_methods_supported and RFC 8414's omitted-list default is the same Basic that {} already produces.

Item 1 — a recorded method is sent as recorded, and it is witnessed on both paths

endpointAuthentication now returns confidentialAuthentication(client, client.tokenEndpointAuthMethod) for a dcr client carrying both a secret and a recorded method, before any metadata is read, and the list-preference code is gone. The pre-registered path still chooses from the advertised list, unchanged.

Measured, one variable each, tree verified clean after every arm:

mutation result
the recorded method filtered by the advertised list again 3 arms red, across all three suites
the refresh drops the recorded method 1 red
the vendor refresh builder (refreshTokenAtVendor) drops it 1 red
the removal drops it 1 red
revokeTokenAtVendor drops it 1 red

The first row is the one worth noting: reverting item 1 reddens an arm in each of the registry, credential and removal suites, so the property is pinned at three independent call sites rather than at one. Lifting refreshTokenAtVendor out was the right move — its own arm is what makes the third row possible, and that is the mutation your note says survived until the lift.

The arm I asked for on both inputs is there and inverted correctly: "a post-registered DCR client revokes with post, though a dcr removal reads no metadata" and "the vendor refresh sends a DCR client's recorded method, whatever the vendor lists". Suites: 94 passed across the three.

One residual, not a defect. A dcr row with a secret but no recorded method still falls through to the list path, where a removal's {} metadata yields Basic. No such row can exist — the only writer of a confidential registration sets the method in the same $setOnInsert — and this PR has not merged, so there is no legacy population either. It becomes reachable only if a future writer stores a secret without the method.

Item 2 — two of the three branches now have arms, and the third is unreachable

The new arms cover the no-clientSecretRef row and the key-left-the-ring case, and your reproduction of the catch path was correct. My survivor was the third branch, and it still survives: making clientFromRow return a client when the secret reads falsy leaves 94 of 94 green.

I carried it to its mint before calling it anything, and it is not a defect. connectorSecrets.get is typed Promise<string> and throws ConnectorSecretNotFoundError on a missing row (connectorSecrets.ts:153-157), so a deleted row lands in the catch, not in the falsy branch. The only remaining way to reach it is a stored secret that decrypts to the empty string, and the write path refuses that: put runs only after the answer check requires typeof issued.client_secret === 'string' && issued.client_secret. So the ternary is correct defence in depth over an input nothing can currently produce — keep it, spend no arm on it, and note that it becomes load-bearing the moment any writer stores a secret without that non-empty check.

That is the third unwitnessed-but-unreachable guard I have found on this track (#2374's integer status check, #2378's entryId digest term, this one). All three are right to keep; none needs a test; and in each case the thing worth writing down was why the input cannot arrive, so the next reader does not re-open it.

The doc

§4's two bullets now say what the code does: the method is carried and sent as recorded with no list consulted, and the unreadable-secret clause names all three causes (row gone, key left the ring, no ref). That matches what I measured.

Written by Vera, a Commonly agent — https://commonly.me/pods/6a8f6dc7a1dccf2e02f31015

GATE: PASS

lilyshen0722 and others added 2 commits October 22, 2026 15:09
…rs no public one

TASK-248. gitlab.com and api.supabase.com list only client_secret_basic and
client_secret_post, so the DCR registry refused them before any POST and
neither entry could connect (#2331, #2332 wait on this).

- When `none` is not listed, register with client_secret_basic, or
  client_secret_post when that is the only one; the answer must carry a
  secret and register that method.
- The secret is stored encrypted in the connector-secret store, owned by
  the registration (new kind hosted-mcp-dcr-client-secret); the row keeps
  only the ref, the method and the expiry.
- Exchange, refresh and revocation authenticate with it; PKCE still rides.
  endpointAuthentication recognises a confidential DCR client by its
  secret, so callers that pass only clientId/clientSecret still send it.
- A secret lapsing within 24 h is replaced at the next start; a refused
  registration's secret is deleted at once; a race loser deletes its own.
- The orphan sweep keeps a registration-owned secret while its
  registration exists, is not refused and still names it.
- The connectors page names client_registration_unsupported and
  client_registration_refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015eTFc9yqm2vKZiUyvLuBT4
… on every path

Vera's HOLD on #2377, both items.

1. The registration's method reached the exchange and nothing else. The
   refresh and removal paths passed only clientId and clientSecret, and
   endpointAuthentication filtered a method against the advertised list;
   a dcr removal passes no metadata, so RFC 8414's omitted-list default
   (basic) turned a post-registered client into Basic, and its
   invalid_client would refuse the registration every row shares. Now
   the method is carried through refresh (credentialFor →
   refreshAtVendor) and removal (removeConnection → revokeAtVendor →
   revokeTokenAtVendor), and a recorded method is sent as recorded with
   no list consulted. The vendor refresh is lifted out as
   refreshTokenAtVendor so the credential it sends is testable, like
   the removal side's revokeTokenAtVendor.
2. The unreadable-secret paths now each have an arm: a secret row
   deleted, a registration with no secret ref, and a secret whose key
   left the ring all re-register rather than return a client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015eTFc9yqm2vKZiUyvLuBT4
@samxu01
samxu01 force-pushed the feat/hosted-mcp-confidential-dcr branch from 47e30e9 to fc61adb Compare October 22, 2026 22:12
refreshTokenAtVendor took `fetchImpl = fetch`, so a refresh with no injected
fetch reached the vendor through the platform fetch and skipped the outbound
guard. It now defaults to hostedVendorFetch, as revokeTokenAtVendor and
discoverAuthorizationServer already do. hostedVendorFetch.wiring.test.js
caught it on CI after the rebase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015eTFc9yqm2vKZiUyvLuBT4

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exact-head gate at bd6ba2262aa03a1b72d8e0d1f471637e736dfcf9, re-resolved immediately before filing. Baseline: my PASS at 47e30e9a35cb346f781e206fd735b9342e03e29b (review 5598435488). main is 71bd369d; the branch is behind it and git merge-tree is clean (fdfd52d4).

Verdict: PASS. The property holds, and I established it by census rather than by sampling the two files the ask names.

What actually changed in the PR's own contribution. Comparing the branch's three-dot delta at both heads, file by file, exactly two of the fourteen moved: hostedMcpCredentialService.ts (the refreshTokenAtVendor default) and docs/plans/hosted-mcp-connection-scope.md. Everything else — including hostedMcpDcrRegistry.ts — contributes byte-identically to what I passed, which is what a correct keep-both resolution should produce: the conflict restored main's lines, so the branch's own delta there is unchanged.

The resolutions, attacked. Both halves survive: main's import { hostedVendorFetch } sits beside this branch's connectorSecrets / connectorSecretKinds requires, and ensureDcrClient carries fetchImpl: typeof fetch = hostedVendorFetch beside now: Date = new Date(). The hazard I went looking for in the second one — a keep-both that silently reorders parameters, so a caller's now lands in fetchImpl — does not exist here: the pre-rebase signature at 47e30e9a already had fetchImpl at position 4 and now at 5, so the rebase changed only the default. I checked every caller rather than reasoning about it: the sole production call passes three arguments and takes both defaults, and the two test calls that pass five keep (…, callback, fetch, now) in order.

The property, enumerated. Every typeof fetch default on the hosted path is hostedVendorFetch — five sites (connectionRemovalService.ts:174, hostedMcpCredentialService.ts:221, hostedMcpDcrRegistry.ts:96, hostedMcpIntakeService.ts:294, upstreamFetch.ts:41) — plus the tool path's dep default, guardedFetch, which delegates to the same wrapper. Zero direct platform-fetch calls in any of the seven modules. Production wiring takes the default: refreshAtVendor: (input) => refreshTokenAtVendor(input) passes no fetch. And upstreamFetch itself defaults to the guard, so a future caller that forgets still goes through it. Mutations: reverting the refresh default to the platform fetch reddens the credential-service arm; reverting the DCR default reddens the registry arm. Suites at this head: DCR registry, credential, connection-removal, installable-installation and the wiring guard, 137 passed / 5 suites, with the wiring guard 8/8; tsc --noEmit -p tsconfig.typescheck.json clean.

One note on the guard, which is main's and not this PR's. hostedVendorFetch.wiring.test.js arrived with #2358 and is the only thing standing behind "unless a test injects its own fetch" — it says so itself, that the fallback "would undo TASK-253 silently, because every test injects its own fetch and would stay green." Its three regexes are evadable, which I measured rather than inferred: fetchImpl: typeof fetch = globalThis.fetch, keeps it 8/8 green, and so does a ;-terminated local alias (const hostedVendorFetch: typeof fetch = fetch;) that shadows the import name. Either one reinstates the platform fetch on the DCR path. Its module list is a literal array of seven; I ran the independent census above and it currently matches exactly, so nothing is missing today, but a new vendor-request module would be covered only if someone remembers to add it. Nothing here is this PR's to fix, and it does not weaken the guard — I am recording it because the property I just cleared rests on it.

A method note, since it nearly cost me a false finding. My first pass compared the two heads' per-file contributions with a two-dot diff against current main, which made this branch look as though it reverted #2421's TASK-263 paragraphs in the scope doc. It does not: on the merged tree both paragraphs are present and be32f102 is still cited four times. Two-dot against a moved main reports main's newer lines as removals; the merged tree is the instrument that answers the question.

Written by Vera, a Commonly agent — https://commonly.me/pods/6a8f6dc7a1dccf2e02f31015

GATE: PASS

@lilyshen0722
lilyshen0722 marked this pull request as ready for review October 23, 2026 02:36
@lilyshen0722
lilyshen0722 added this pull request to the merge queue Oct 23, 2026
Merged via the queue into main with commit 8ee579e Oct 23, 2026
30 checks passed
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.

1 participant