fix(vault): accept dynamic client registration clientInfo in vault set - #287
fix(vault): accept dynamic client registration clientInfo in vault set#287Yigtwxx wants to merge 4 commits into
Conversation
vault set rejected every non-string clientInfo value, so no RFC 7591 dynamic client registration response could be seeded: redirect_uris, grant_types, response_types and contacts are string arrays, and client_id_issued_at and client_secret_expires_at are numbers. mcporter builds that same array-valued shape itself in buildStaticClientInformation, so its own client info could not round-trip through the command. Check each field against the type OAuthClientInformationFullSchema declares for it, and require client_id, which both members of OAuthClientInformationMixed demand. Iterating the rule table rather than the payload keeps unknown provider fields untouched and makes the reported field independent of JSON key order. Validation still inspects the payload without reparsing it, so seeded extras such as tokens.expires_at continue to reach the vault verbatim.
An unparsable payload escaped JSON.parse as a raw SyntaxError, so piping a truncated one-liner to vault set --stdin printed 'Unexpected end of JSON input' through the unexpected-error path instead of the usage path the other payload problems already use. Report it as a CliUsageError and keep the parser message as the reason.
The payload example showed clientInfo as client_id alone, which reads as the whole contract. Say that a full registration response is accepted.
Two fields decide whether a stored credential survives the read path, and neither was checked on the way in. isStoredOAuthClientInformation requires clientInfo.issuer to be a string and isStoredOAuthTokens requires expires_at and expiresAt to be finite numbers; externalVaultEntry drops the whole tokens or clientInfo object when either guard fails. So a typo in one field silently discarded the rest, and for issuer that also unbinds the refresh issuer check in assertRefreshIssuerBinding. Validate all three where the payload enters. Also stop repeating the parser message for an unparsable payload: V8 quotes a prefix of the input, which here is credential material, and it can reach a CI log. Name the source instead, matching how call-arguments reports a bad argument file.
|
Codex review: needs maintainer review before merge. Reviewed August 8, 2026, 4:14 PM ET / 20:14 UTC. ClawSweeper reviewWhat this changesThis PR updates Regression provenancePossible regression — probable (reproduction; reviewed change). No predecessor PR is attributed. Merge readinessCurrent main still rejects valid array- and number-valued OAuth dynamic-registration metadata, so this PR remains necessary. The fix has strong real CLI proof; the only unresolved point is whether its new required Priority: P1 Review scores
Verification
How this fits together
flowchart LR
A[Credential JSON] --> B[Vault import command]
B --> C[Field validation]
C --> D[Local credential vault]
D --> E[OAuth refresh runtime]
E --> F[Authenticated MCP server]
Decision needed
Why: The central bug fix is compatible, but this additional validation changes a previously accepted input while the stored-client read guard remains broader. Before merge
Agent review detailsSecurityNone. Review metrics
Merge-risk optionsMaintainer options:
Technical reviewBest possible solution: Accept typed dynamic-registration metadata and safer malformed-input handling while preserving partial client-info imports unless maintainers intentionally adopt the stricter client-id contract. Do we have a high-confidence way to reproduce the issue? Yes. Current main visibly rejects non-string client-info fields, and the PR body supplies a real isolated CLI before/after run using a fake credential payload. Is this the best way to solve the issue? Unclear. Typed per-field validation is the narrow fix, but requiring AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against dee2fa23ac82. LabelsLabel justifications:
EvidenceWhat I checked:
Likely related people:
Rank-up movesOptional improvements that raise the rating; they are not merge blockers.
Rating scale
Overall follows the weaker of proof and patch quality. Workflow
HistoryReview history (4 earlier review cycles)
|
|
The one open item is the Nothing in the repo seeds So the tightening only reaches a hand-written script that seeds |
|
Superseded by the narrower compatibility-preserving fix in #288, now merged as Thank you @Yigtwxx for the concurrent analysis and especially for identifying two separate worthwhile follow-ups. They are explicitly preserved outside this PR’s scope:
Closing this overlapping implementation as superseded; those follow-up findings are not being rejected or folded into the #286 fix. |
Summary
Fixes #286.
mcporter vault setrejects everyclientInfovalue that is not a string, so no real dynamic client registration response can be seeded into the vault. In RFC 7591,redirect_uris,grant_types,response_typesandcontactsare string arrays, andclient_id_issued_atandclient_secret_expires_atare numbers. A headless deployment that already holds OAuth credentials has no way to install them.The declared type says otherwise.
VaultPayload.clientInfoisOAuthClientInformationMixed, and mcporter builds that same array-valued shape itself inbuildStaticClientInformation(src/oauth-client-info.ts:15) and during registration (src/oauth.ts:115), then readsclientInfo.redirect_urisback as an array (src/oauth.ts:506). Its own client information cannot round-trip through its own command.Root cause
src/cli/vault-command.ts:154One rule for every field.
validateOAuthTokens, directly above it, already does the opposite:access_tokenandtoken_typenon-empty strings,refresh_token/scope/issueroptional strings,expires_ina finite number. So the tokens half of the payload accepts its real shape and the client-information half does not.Fix
validateOAuthClientInfonow checks each field against the JSON typeOAuthClientInformationFullSchemadeclares for it (@modelcontextprotocol/sdk/shared/auth.js), in that schema's declaration order:client_idtoken_endpoint_auth_method,client_name,client_uri,logo_uri,scope,tos_uri,policy_uri,jwks_uri,software_id,software_version,software_statement,client_secret,issuerredirect_uris,grant_types,response_types,contactsclient_id_issued_at,client_secret_expires_atThe loop walks the rule table rather than the payload. Unknown keys therefore pass through untouched, so a full registration response keeps
registration_client_uri,registration_access_tokenand whatever else the provider returned, and the reported field no longer depends on JSON key order.jwksstays unconstrained because the SDK types it asz.any().redirect_urisstays optional even though the schema's wide member requires it, since the narrow memberOAuthClientInformationis equally valid here.nullis still treated as absent, as before.URL-shaped fields are only checked for being strings. The SDK's
SafeUrlSchemawould additionally require a parseable URL. That starts rejecting redirect URIs that are legal in the wild, such asurn:ietf:wg:oauth:2.0:ooband custom-scheme mobile redirects, which is the wrong trade in a change whose point is to stop over-rejecting.I kept the SDK's zod schemas out of this path for a related reason. They are all
.strip(), so validating through them invites persistingresult.data, which would quietly drop seeded fields the vault depends on:tokens.expires_atis read atsrc/oauth-persistence-stores.ts:37,src/oauth-token-refresh.ts:33andsrc/runtime/oauth.ts:123, andvalidateVaultPayloaddeliberately returns the raw objects by reference. A union ofOAuthClientInformationFullSchemaandOAuthClientInformationSchemawould not validate much either, since the second member requires onlyclient_idand strips the rest, so{ client_id: "abc", redirect_uris: 42 }passes it.validateVaultPayloadis unchanged, including the order in which it validates tokens before client information.Also: three fields the read path requires and the write path did not check
externalVaultEntry(src/oauth-vault.ts:220) runs stored credentials throughisStoredOAuthTokensandisStoredOAuthClientInformation, and drops the wholetokensorclientInfoobject when a guard fails. Those guards requireclientInfo.issuerto be a string andtokens.expires_at/tokens.expiresAtto be finite numbers.vault setchecked none of the three, so a typo in one field silently discarded everything next to it, and the command still printedSaved OAuth credentials.clientInfo.issuerwas covered incidentally by the old string-only rule, so the per-field table has to keep covering it. It is worth naming becausevault setis the only writer of that field and its only consumer is a security check:assertRefreshIssuerBinding(src/oauth-token-refresh.ts:86) pins refresh to the recorded issuer, and an unusable value turns the pin into a no-op.tokens.expires_atandexpiresAtare the pre-existing half, folded into the same loop asexpires_in.Deliberate tightening: client_id is now required
Today
{"clientInfo": {}}is accepted. Both members ofOAuthClientInformationMixedrequireclient_id, so the existingas OAuthClientInformationMixedcast does not hold for that value. And{}is not read downstream as "no client information": it is a truthy object thatsaveVaultEntrypersists andgetClientInformation()later hands back in place of the statically configured client, so the failure surfaces much later as an opaque SDK error during refresh.clientIdFromEntry(src/oauth-vault.ts:302) and same-URL credential inheritance both need it too. This does make the write side stricter thanisStoredOAuthClientInformation, which does not requireclient_id, and it is a behavior change, so I would rather flag it than bury it. It is oneifplus one test case if you would rather not take it.Two adjacent fixes in the same input path
JSON.parseat line 47 was unguarded, so an unparsable payload left the command as a rawSyntaxErrorand printed a stack trace through the unexpected-error path instead of the usage path every other payload problem already uses. It now fails as aCliUsageErrornaming the source. The message deliberately does not repeat the parser's reason: V8 quotes a prefix of the input (Unexpected token 's', "sk-live-9f"... is not valid JSON), and the input here is credential material that can end up in a CI log. Naming the file followssrc/cli/call-arguments.ts:373.vault sethelp text showedclientInfoasclient_idalone, which reads as the whole contract and is plausibly where the expectation in the issue came from. It now says a full registration response is accepted.Behavior proof
The real CLI through
tsx src/cli.ts, throwawayXDG_CONFIG_HOMEandXDG_DATA_HOME, and the payload from the issue verbatim. The access token is fake andhttps://example.test/mcpis never contacted.Same harness for the guard mismatch, reading the entry back through
loadVaultEntryso the read-path guards actually run:The first case is the silent loss: the command reports success and the credentials are unreadable. The second is the one the old rule already caught and the new table has to keep catching.
Tests
tests/vault-validation.test.tsgoes from 16 to 32 cases, in the table-driven style the file already uses:accepts a dynamic client registration clientInfo payload— the issue's payload verbatim.accepts registration timestamps, contacts, and a jwks document—client_secret,client_id_issued_at,client_secret_expires_at: 0,contacts, and ajwksdocument. Both acceptance cases assert the stored entry throughloadVaultEntry, not the input object, so a droppedclientInfofails the test.preserves unknown clientInfo fields and seeded token expiry—issuer,registration_client_uri,registration_access_token,tokens.expires_atandid_tokenall reach the vault verbatim. This is the case that fails if anyone later swaps the validator for one that reparses the payload.persists null clientInfo fields verbatim— the pre-0.12.1 null tolerance, held in place.rejects malformed clientInfo— a non-string entry insideredirect_uris,redirect_urisandgrant_typesgiven as bare strings,client_id_issued_atandclient_secret_expires_atgiven as strings, a non-stringclient_name, a non-stringissuer, andclientInfowith noclient_id.rejects malformed payload— the existing rows plustokens.expires_atandtokens.expiresAtgiven as strings.reports malformed stdin JSON as a usage error without echoing the payloadandnames the file when its JSON is malformed— one per input source.Reverting only
src/cli/vault-command.tsto main and keeping the tests:The other 19 pass on both sides and cover what this change must not alter, including
rejects malformed clientInfo 6, theissuercase.One committed expectation moved: the row asserting
clientInfo.client_id must be a stringforclient_id: 42now expectsmust be a non-empty string, matchingtokens.access_token.Gates
Windows 11, Node 22.20.0, pnpm 10.33.2.
pnpm exec vitest run tests/vault-validation.test.ts tests/vault-command.test.ts— 37 passed.pnpm check— oxfmt, type-aware oxlint andtsc --noEmitclean.pnpm build— clean.pnpm test— 1283 passed, 84 skipped, 7 failed. The 7 are intests/chrome-devtools-relay-handoff.test.tsandtests/runtime-chrome-relay-handoff.test.ts, and they fail identically on unmodifiedmainat 764bb87 on this machine (Invalid URLfrom the relay handoff), so they predate this branch and touch nothing it changes. I could not clear them here because this box is below the engine floor the repo declares (node >=24). CI settles it:buildpasses on ubuntu-latest, macos-15 and windows-latest for this branch.Thanks @feniix for the report. The version table and the pointer straight at the validator left nothing to guess.