Skip to content

fix(auth): accept RFC 8414 metadata served at the OIDC well-known path - #2184

Open
cliffhall wants to merge 5 commits into
v2/mainfrom
v2/fix/2172-oidc-discovery-compat
Open

fix(auth): accept RFC 8414 metadata served at the OIDC well-known path#2184
cliffhall wants to merge 5 commits into
v2/mainfrom
v2/fix/2172-oidc-discovery-compat

Conversation

@cliffhall

@cliffhall cliffhall commented Aug 28, 2026

Copy link
Copy Markdown
Member

Closes #2172

A plain OAuth 2.0 authorization server that publishes its RFC 8414 metadata at /.well-known/openid-configuration cannot be connected to at all — authorization fails before the browser opens, with a ZodError naming three fields the server had no reason to publish.

The cause is upstream

discoverAuthorizationServerMetadata in @modelcontextprotocol/client@2.0.0 picks its validation schema from the well-known filename that resolved, not from the document that came back:

const parsed = type === "oauth"
  ? OAuthMetadataSchema.parse(await response.json())
  : OpenIdProviderDiscoveryMetadataSchema.parse(await response.json());

type is "oidc" for every openid-configuration candidate, so a document found there must carry jwks_uri, subject_types_supported and id_token_signing_alg_values_supported — three fields OpenID Connect Discovery 1.0 requires and RFC 8414 does not. RFC 8414 §5 explicitly permits that filename for general OAuth metadata, so the server is conforming and the client is not. And because the parse throws rather than continuing the candidate loop (every other per-candidate failure continues), discovery aborts outright instead of falling through.

Filed upstream as modelcontextprotocol/typescript-sdk#2733, with a standalone repro and two smaller findings in the same code path (OpenIdProviderDiscoveryMetadataSchema is a z.object, so a successful OIDC parse silently strips revocation_endpoint / introspection_endpoint; and the function's doc comment describes a fallback the schema selection does not do).

What this PR does

core/auth/oidcDiscoveryCompat.ts — a fetch wrapper that fabricates nothing. When the RFC 8414 candidate comes back 4xx (or 502 — the statuses the SDK walks past), it fetches the OIDC candidates the SDK would try next. If one returns a document that satisfies OAuthMetadataSchema but fails the OIDC schema — i.e. it is RFC 8414 metadata and is not an OpenID provider document — that body is returned as the response to the RFC 8414 request, so the SDK validates it under the schema that actually describes it.

Three properties worth calling out, because they are what made this the chosen approach:

  • It does not invent jwks_uri. Back-filling the three OIDC-required fields would also make the parse succeed, but the Inspector would then be showing, in the Auth and Network tabs, a metadata document the server never published — the one thing a debugging tool must not do.
  • It does not weaken issuer validation. The substituted document is the one the server published, issuer included, so the SDK's RFC 8414 §3.3 issuer-echo check runs on it unchanged.
  • It does not present the substitution as the server's own answer. It wraps InspectorClient's base fetch — the same seam withOAuthEndpointOverrides uses — because that is the only place that also covers the discovery the SDK runs from inside the transport, whose tracker is built inside the transport where nothing here can reach above it. That is below both fetch trackers, so a captured Network entry shows the substituted document rather than the real 404; it therefore carries an x-inspector-oauth-metadata-source response header naming the URL the body was actually fetched from, and the console warning says the same. The probe is issued through the wrapped fetch and so is not separately tracked.

A document that is a valid OpenID provider document is left alone and takes the SDK's normal OIDC leg, at the cost of one duplicate request on a genuine OIDC server. That is the price of not changing behavior for the case that already works.

More generally, the probe loop advances to the next candidate only where the SDK's own loop would — a 4xx or 502. A 500, a body that will not parse, or a network error stops the shim and returns the original response, leaving the SDK to make the same request and reach the same verdict. Promoting a later candidate over an earlier one the SDK would have stopped at would turn a failure into a success, which is a worse defect than the one being worked around. It also does not gate on content-type, because the SDK does not: doing so could skip a genuine OIDC document served with an odd media type. Only GETs are considered, so an authorization or token endpoint that happens to live under the RFC 8414 prefix keeps its own error.

The candidate derivation (oidcDiscoveryCandidates) is a hand-written mirror of the SDK's buildDiscoveryUrls — a fetch wrapper sees a request URL, not the authorization-server URL — so a test pins it against the SDK's own exported buildDiscoveryUrls across root, path, and trailing-slash AS URLs. The two cannot drift.

Reproducing it

New showcase server, test-servers/configs/oauth-rfc8414-at-oidc-path-http.json: an OAuth-protected server whose RFC 8414 metadata is served only from /.well-known/openid-configuration, with the RFC 8414 route deliberately unserved. Load it with --config, add the server, connect — authorization proceeds normally on this branch and fails with the issue's ZodError on v2/main.

That needed one knob on the test server, oauth.asMetadataPath, mirroring the existing oauth.resourceMetadataPath (#2071) field in shape, validation, and the "move it, don't also serve the default" semantics.

Tests

  • clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts — 28 unit tests: the buildDiscoveryUrls drift guard, the substitution on both OIDC candidate shapes and its source header, the genuine-OIDC passthrough, the well-known boundary and GET-only guards, and every stop/continue path (unparseable body, 500, throwing probe, 404 walk-past, odd media type, root AS).
  • clients/web/src/test/integration/auth/rfc8414AtOidcPath.test.ts — end to end against a real server: the unwrapped SDK rejects it (so the workaround is load-bearing, and this test starts failing the day the SDK is fixed), the wrapper resolves it, the returned metadata carries none of the three OIDC-only fields, and the fetch the transport is handed carries the shim.
  • clients/cli/__tests__/stored-auth.test.ts — the stored-token refresh path, which calls SDK discovery directly rather than through effectiveAuthFetch and so carries its own copy of the shim.

Verification

npm run local:gate passes. No screenshots — there is no UI change; the observable difference is that a connection which previously failed now completes.

The SDK's `discoverAuthorizationServerMetadata` picks its validation schema
from the well-known filename that resolved rather than from the document that
came back, so anything found at `/.well-known/openid-configuration` is
validated as an OpenID provider document — requiring `jwks_uri`,
`subject_types_supported` and `id_token_signing_alg_values_supported`, three
fields RFC 8414 does not define. RFC 8414 §5 permits that filename for general
OAuth metadata, so a conforming plain OAuth 2.0 authorization server is
rejected; and because the parse throws instead of continuing the candidate
loop, discovery aborts and the connection fails outright.

Filed upstream as modelcontextprotocol/typescript-sdk#2733.

`core/auth/oidcDiscoveryCompat.ts` works around it without fabricating a
field: on a failed RFC 8414 candidate it probes the OIDC candidates the SDK
would try next and, when one returns a document that satisfies
`OAuthMetadataSchema` but fails the OIDC schema, serves that body as the RFC
8414 response so the SDK picks the schema that describes it. A genuine OpenID
provider document is left to the SDK's own OIDC leg. Issuer validation runs
unchanged, since the substituted document is the one the server published.

It wraps `effectiveAuthFetch` — above the fetch tracker, the opposite of
`withOAuthEndpointOverrides` — so the Network tab still records the real 404
and the real probe rather than the substitution.

The candidate derivation mirrors the SDK's `buildDiscoveryUrls`, which a fetch
wrapper cannot call, and a test pins it against that export so the two cannot
drift.

Adds `oauth.asMetadataPath` to the composable test server (mirroring
`oauth.resourceMetadataPath`) and the `oauth-rfc8414-at-oidc-path-http.json`
showcase config that reproduces the failure.

Closes #2172

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds an OAuth discovery compatibility shim for RFC 8414 metadata served from an OIDC well-known path.

Changes:

  • Adds and wires the discovery compatibility wrapper.
  • Extends OAuth test-server configuration and fixtures.
  • Adds unit/integration tests and documentation.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
core/auth/oidcDiscoveryCompat.ts Implements the compatibility wrapper.
core/auth/index.ts Exports compatibility helpers.
core/mcp/inspectorClient.ts Applies the wrapper to authentication fetches.
test-servers/src/test-server-oauth.ts Supports custom AS metadata paths.
test-servers/src/test-server-fixtures.ts Exposes the fixture option.
test-servers/src/test-helpers.ts Supports polling custom metadata paths.
test-servers/src/load-config.ts Parses and validates the option.
test-servers/src/composable-test-server.ts Documents the server configuration field.
test-servers/configs/oauth-rfc8414-at-oidc-path-http.json Adds a reproduction server.
clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts Tests compatibility behavior and edge cases.
clients/web/src/test/integration/auth/rfc8414AtOidcPath.test.ts Verifies discovery against a real server.
README.md Documents the showcase and workaround.
AGENTS.md Records the new authentication component.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread core/mcp/inspectorClient.ts Outdated
Comment on lines +801 to +803
this.effectiveAuthFetch = withRfc8414OidcCompat(
this.buildEffectiveAuthFetch(),
);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 1db1b9b. You're right — refreshStoredAuthToken calls SDK discovery directly rather than through InspectorClient.effectiveAuthFetch, so a state file with a refresh token and client information but no persisted serverMetadata still hit the upstream failure and could not refresh against exactly the servers this PR is for.

Its default discover now wraps the global fetch with withRfc8414OidcCompat. A caller-supplied options.fetchFn is left alone — it is theirs to compose — and the candidate walker passes no options, so in practice this wraps the global.

Covered by a new test in clients/cli/__tests__/stored-auth.test.ts that injects no discover: the wrapper lives in the default, so injecting one would bypass the thing under test. It stands up a real server that 404s /.well-known/oauth-authorization-server/mcp and serves plain RFC 8414 metadata (no jwks_uri) at /mcp/.well-known/openid-configuration, then asserts the refresh completes and that the RFC 8414 location really was probed — so the document could only have come from the OIDC path.

Comment on lines +116 to +117
if (!url.pathname.startsWith(RFC8414_WELL_KNOWN)) return [];
const path = url.pathname.slice(RFC8414_WELL_KNOWN.length);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 1db1b9b. Agreed, and the consequence you name is the serious half: this wrapper is on the general auth fetch, so a bare prefix match meant a failed request to a path like /.well-known/oauth-authorization-server-backup could have its real response replaced by a document fetched from a URL derived from it.

oidcDiscoveryCandidates now requires the remainder to be empty or to start with /. That is exactly the set buildDiscoveryUrls can emit — it appends the authorization server's pathname, which always begins with / — so nothing legitimate is lost. Added a unit test asserting the -backup path yields no candidates.

Two review findings.

The stored-token refresh path (`refreshStoredAuthToken`) calls SDK discovery
directly rather than through `InspectorClient.effectiveAuthFetch`, so a state
file holding a refresh token and client information but no `serverMetadata`
still hit the upstream OIDC-schema failure and could not refresh against the
very servers this change is for. Its default `discover` now wraps the global
fetch with the compat shim; a caller-supplied `fetchFn` is left alone. Covered
by a test that injects no `discover` — injecting one would bypass the default
under test — against a real server that 404s the RFC 8414 path and serves plain
RFC 8414 metadata at the appended OIDC path.

`oidcDiscoveryCandidates` matched the well-known path by bare prefix, so it
also claimed `/.well-known/oauth-authorization-server-backup` and would have
replaced that path's failed response with a document fetched from a derived
URL. It now requires the exact path or a `/` boundary, which is precisely the
set `buildDiscoveryUrls` can emit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Review round 1 — both comments addressed (1db1b9b)

Mirroring the inline replies here, since they go outdated once the fix is pushed.

1. core/mcp/inspectorClient.ts — the CLI's stored-token refresh path was not covered. Correct, and it mattered: refreshStoredAuthToken calls SDK discovery directly rather than through effectiveAuthFetch, so a state file holding a refresh token and client information but no persisted serverMetadata still hit the upstream failure — against exactly the servers this PR exists for. Its default discover now wraps the global fetch with withRfc8414OidcCompat; a caller-supplied options.fetchFn is left alone, and the candidate walker passes no options.

New test in clients/cli/__tests__/stored-auth.test.ts injects no discover — the wrapper lives in the default, so injecting one would bypass what is under test. It stands up a real server that 404s /.well-known/oauth-authorization-server/mcp and serves plain RFC 8414 metadata at /mcp/.well-known/openid-configuration, then asserts the refresh completes and that the RFC 8414 location really was probed.

2. core/auth/oidcDiscoveryCompat.ts — bare prefix match claimed neighbouring paths. Also correct, and the consequence named is the serious half: on the general auth fetch, a failed request to /.well-known/oauth-authorization-server-backup could have had its real response replaced by a document from a derived URL. oidcDiscoveryCandidates now requires the remainder to be empty or start with / — precisely what buildDiscoveryUrls can emit, since it appends a pathname that always begins with /. Unit test added.

npm run local:gate passes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

core/mcp/inspectorClient.ts:803

  • This only wraps effectiveAuthFetch, but the transport is still constructed with this.fetchFn (core/mcp/inspectorClient.ts:2033-2037). The SDK's SSE/Streamable HTTP transports pass that transport fetch into their internal OAuth auth()/onUnauthorized flow, so a Web/TUI reconnect with an existing auth provider—especially stored tokens without serverMetadata—can still run discovery without this compatibility shim and hit the same OIDC-schema failure. The adjacent comment at lines 784-790 already calls out this transport-owned discovery path for endpoint overrides. Please compose the shim into that path as well while preserving transport request tracking, and cover a transport-triggered discovery case.
    this.effectiveAuthFetch = withRfc8414OidcCompat(
      this.buildEffectiveAuthFetch(),
    );

Comment on lines +213 to +214
const candidates = oidcDiscoveryCandidates(requestUrlOf(input));
if (candidates.length === 0) return response;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 28c7a9f. Agreed — nothing stops an authorization or token endpoint from living under /.well-known/oauth-authorization-server/…, and replacing its ordinary OAuth 4xx with a metadata document would mask the real failure.

The wrapper now checks the effective method (init.method, else the Request's, else GET, upper-cased since fetch treats it case-insensitively) and returns the response untouched for anything that is not a GET. Regression test covers both forms — { method: "post" } on a string URL and a Request constructed with POST — against a 400 invalid_grant on a matching path, asserting the original response object comes back and no probe was made.

Two more review findings, both real.

The shim wrapped `effectiveAuthFetch` only, but the transport is constructed
with the base `this.fetchFn`, and the SDK runs its own discovery from inside
the transport on the 401/refresh leg — so a reconnect with an existing auth
provider could still hit the upstream failure. It now wraps the base fetch,
the same seam `withOAuthEndpointOverrides` uses and the only one that reaches
both paths.

That trades away the previous claim that a captured Network entry shows the
real 404: below the trackers, what they record is the substitution. Rather
than leave that silently misleading, a substituted response now carries
`x-inspector-oauth-metadata-source` naming the URL its body was actually
fetched from — the transport builds its tracker internally, so there is no
seam above it to install into instead. An integration test captures the fetch
the transport is handed and drives it against a real server, since nothing
else proves that wiring.

The wrapper also keyed on URL shape alone, but nothing stops an authorization
or token endpoint from living under `/.well-known/oauth-authorization-server/`;
a POST there returning an ordinary OAuth 4xx would have had its error replaced
by a metadata document. Metadata discovery is a GET, so anything else is now
left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Review round 2 — both comments addressed (28c7a9f)

1. core/auth/oidcDiscoveryCompat.ts — URL shape alone does not prove a discovery request. Right; an authorization or token endpoint may legally live under that prefix, and replacing its ordinary OAuth 4xx with a metadata document would mask the real failure. The wrapper now resolves the effective method (init.method, else the Request's, else GET, upper-cased) and leaves anything that is not a GET untouched. Regression test covers both forms against a 400 invalid_grant on a matching path.

2. Suppressed comment — the transport is still constructed with this.fetchFn. This one was the more important of the two and I've taken it in full. The shim wrapped effectiveAuthFetch only, so the SDK's transport-internal discovery on the 401/refresh leg bypassed it, and a reconnect with an existing auth provider could still fail. It now wraps the base fetch — the same seam withOAuthEndpointOverrides uses, and the only one reaching both paths.

That does cost the property the previous placement bought, and I'd rather state it than let it read as free: below the trackers, what a captured Network entry records is the substitution, not the real 404. The transport builds its tracker internally, so there is no seam above it to install into instead. Rather than leave that silently misleading, a substituted response now carries an x-inspector-oauth-metadata-source response header naming the URL its body was actually fetched from; the console warning says the same. README and the module docstring updated to describe the placement as it now is.

For coverage of that path, clients/web/src/test/integration/auth/rfc8414AtOidcPath.test.ts now captures the fetch the transport was handed — through a createTransportNode wrapper passed as environment.transport — and drives it against the real server, asserting the 200, the source header, and the issuer. Driving the SDK's internal 401 leg end to end would prove the same thing far more indirectly, and this asserts the actual wiring.

npm run local:gate passes. (One unrelated flake on the way there — ServerImportJsonModal "guards against a live edit before the debounce re-validates", a timing-sensitive test that passes in isolation and on a clean rerun of the full gate.)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.

Suppressed comments (4)

Previously missed (1) — in code that hasn't changed since the last review.

core/auth/oidcDiscoveryCompat.ts:263

  • This catches every probe failure and then may substitute a later candidate, but the SDK only swallows browser CORS TypeErrors; in Node it propagates network TypeErrors, and it always propagates non-TypeError exceptions. Thus a failing first OIDC candidate can be silently bypassed by the shim even though normal SDK discovery would stop there. Apply the SDK's CORS retry/error policy before deciding whether to continue.

This issue also appears in the following locations of the same file:

  • line 265
  • line 266
  • line 270
      } catch {
        // A network or CORS failure on a URL the SDK had not asked for yet is
        // not this wrapper's to report — the SDK will make the same request and
        // handle it. Try the next candidate.
        continue;

core/auth/oidcDiscoveryCompat.ts:265

  • The SDK advances to the next discovery candidate only for 4xx responses and 502; other statuses such as 500/503 terminate discovery. This unconditional continue can therefore hide an outage on the first OIDC candidate and substitute RFC 8414 metadata from the second candidate, turning a failure into success. Mirror the SDK's status policy here so non-continuing responses are left for the SDK to surface.
      if (!probe.ok) continue;

core/auth/oidcDiscoveryCompat.ts:266

  • The SDK does not gate metadata parsing on Content-Type; a successful first OIDC response is parsed immediately. Continuing here can skip a genuine OIDC document served with a nonstandard/missing media type and substitute a later RFC-only document, changing a case the SDK currently accepts. Return the original RFC response so the SDK processes this first OIDC candidate itself.
      if (!isJsonDocumentResponse(probe.headers.get("content-type"))) continue;

core/auth/oidcDiscoveryCompat.ts:274

  • A 2xx OIDC candidate with malformed JSON is terminal in the SDK because response.json() throws. Continuing to a lower-priority candidate can instead make discovery succeed and hide the malformed higher-priority endpoint. Return the original RFC response here so the SDK replays and surfaces its normal parse failure.
      try {
        body = await probe.text();
        parsed = JSON.parse(body);
      } catch {
        continue;

Comment on lines +801 to +804
// still hit the upstream failure. The substituted response is stamped with
// `COMPAT_SOURCE_HEADER` so a captured entry names the URL its body came
// from rather than appearing to be a 200 from the RFC 8414 path.
this.fetchFn = withRfc8414OidcCompat(this.fetchFn);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed the description in the same pass as 89d6af4. You're right that it still described the previous placement — the shim moved onto the base fetch (below both trackers) in 28c7a9f so it could also cover the transport-owned discovery leg, and the PR body had not caught up.

It now says what the implementation does: a captured entry shows the substituted document rather than the real 404, which is why the substituted response carries x-inspector-oauth-metadata-source naming the URL its body was actually fetched from, and why the probe is not separately tracked. The module docstring, the code comment, and the README section were already updated in 28c7a9f.

The probe loop advanced to the next OIDC candidate on any failure, which meant
it could promote a later candidate over an earlier one the SDK would have
stopped at — turning a failure into a success, a worse defect than the one
being worked around. Four distinct cases: a 500 (an outage the SDK surfaces), a
2xx body that will not parse (terminal for the SDK), a network error (which the
SDK propagates outside the browser's CORS case), and a non-JSON media type.

It now continues only where the SDK's own loop does — a 4xx or 502 — and
otherwise returns the original response, leaving the SDK to make the same
request and reach its own verdict. The `content-type` gate is gone entirely,
because the SDK parses a 2xx discovery body whatever media type it carries, so
gating could skip a genuine OpenID provider document and substitute an
RFC-8414-only one from a lower-priority candidate in its place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Review round 3 — addressed (89d6af4, plus a description fix)

Inline: the PR description claimed traffic visibility the implementation no longer provides. Correct — the shim moved onto the base fetch in 28c7a9f so it could cover the transport-owned discovery leg, and the description still described the old placement. Rewritten to say what it actually does: a captured entry shows the substituted document, which is why it carries x-inspector-oauth-metadata-source, and the probe is not separately tracked. (The module docstring, code comment and README were already updated in 28c7a9f; the description was the straggler.)

Suppressed (4) — the probe loop was more permissive than the SDK's candidate policy. These are four faces of one real defect and I've taken all of them. Advancing on any probe failure meant the shim could promote a later candidate over an earlier one the SDK would have stopped at — turning a failure into a success, which is worse than the bug being worked around:

  • 500 on the first OIDC candidate — an outage the SDK surfaces; now returns the original response instead of hiding it behind the second candidate's document.
  • 2xx with a body that will not parse — terminal for the SDK; now stops there.
  • Probe throws — the SDK swallows only the browser's CORS TypeError and propagates otherwise; now stops and lets the SDK make the same request.
  • content-type gate — removed entirely. You're right that the SDK parses a 2xx discovery body whatever media type it carries, so gating could skip a genuine OpenID provider document and substitute an RFC-8414-only one in its place. A body that will not parse now falls through to the stop path, where the SDK re-fetches and raises its own parse error.

The rule is now stated once, in a comment on the loop: continue only where the SDK's own loop continues (4xx or 502); anywhere else, hand back the original response. Five tests pin the individual branches, including a new one asserting a 500 on the first candidate is not papered over by a valid document on the second.

npm run local:gate passes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 3 comments.

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

core/auth/oidcDiscoveryCompat.ts:302

  • This records the requested candidate, not necessarily the URL that supplied the body. Since fetch follows redirects, a redirected metadata endpoint makes this diagnostic claim the wrong source; use the final probe.url, with the candidate as a fallback for synthetic/custom responses.
          [COMPAT_SOURCE_HEADER]: candidate,

core/auth/oidcDiscoveryCompat.ts:286

  • The warning has the same redirect ambiguity as the response header: after a followed redirect, candidate is not where the body came from. Report probe.url when available so console diagnostics identify the actual metadata source.
        `[oauth] ${candidate} returned RFC 8414 OAuth 2.0 authorization server ` +

Comment on lines +259 to +262
if (!probe.ok) {
if (continuesDiscovery(probe.status)) continue;
return response;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 039fc6f — and the second half of it (the discarded original) too. A skipped probe and the failed RFC 8414 response the substitution replaces are both cancelled now, via a small releaseBody helper following the authChallengeFetch.ts precedent you pointed at. Test asserts it: three streamed bodies with cancel callbacks, and the 404 probe plus the original are the two that get released.

`(modelcontextprotocol/typescript-sdk#2733), so the Inspector is ` +
`handing it to discovery as the RFC 8414 document it is.`,
);
return new Response(body, {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 039fc6f — see the reply on the sibling comment; both the skipped probe and this discarded original are released now.

Comment thread clients/cli/src/cli.ts Outdated
// wrapper — otherwise a stored refresh token with no persisted
// `serverMetadata` still cannot refresh against an authorization server that
// publishes RFC 8414 metadata at the OIDC well-known path (Copilot).
const compatFetch = withRfc8414OidcCompat(fetch);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 039fc6f, both legs as you suggested. refreshStoredAuthToken runs outside InspectorClient, so nothing else puts a proxy under it — it now builds one withRfc8414OidcCompat(createProxyFetch() ?? fetch) and passes it to the discovery probe and to refreshAuthorization, so the token request does not bypass the proxy either. (That second one was a pre-existing gap rather than something this PR introduced, but it is the same fetch and the same function, so fixing it separately would have been artificial.)

…ce, proxy the CLI refresh

Three review findings.

Probe responses the loop skips — and the original failed response the
substitution replaces — were dropped without releasing their bodies. On
Node/undici that holds the connection, and this loop can run on every OAuth
attempt, so repeated discovery against a 4xx candidate could exhaust the
origin's pool. Both paths now cancel, the same discipline
`core/mcp/node/authChallengeFetch.ts` uses for a discarded 401.

`fetch` follows redirects, so the candidate URL is where the probe was aimed,
not necessarily where the document came from. The source header and the console
warning now report `probe.url`, falling back to the candidate for a synthesized
response that carries none.

The CLI's stored-token refresh built its fetch from the global rather than from
`createProxyFetch()`, so a server reachable only through `HTTPS_PROXY` was
probed directly. It now builds one proxy-aware fetch and hands it to both the
discovery probe and `refreshAuthorization`, so neither leg bypasses the proxy —
this function runs outside `InspectorClient`, so nothing else puts a proxy
under it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Review round 4 — all five addressed (039fc6f)

Inline 1 & 2 — discarded response bodies are never released. Taken, both halves. A skipped probe and the failed RFC 8414 response the substitution replaces are now cancelled through a small releaseBody helper, following the core/mcp/node/authChallengeFetch.ts precedent you cited. This loop can run on every OAuth attempt, so leaving connections occupied was a real leak rather than a tidiness point. Test asserts it directly: three streamed bodies with cancel callbacks, and exactly the 404 probe and the original come back released.

Inline 3 — the CLI refresh path hard-codes the global fetch, bypassing createProxyFetch(). Correct, and I took the second half of your suggestion too: refreshStoredAuthToken runs outside InspectorClient, so nothing else puts a proxy under it. It now builds one withRfc8414OidcCompat(createProxyFetch() ?? fetch) and hands it to the discovery probe and to refreshAuthorization, so the token request does not go direct either. (That token-request gap predates this PR, but it is the same fetch in the same function — splitting it out would have been artificial.)

Suppressed 1 & 2 — candidate is where the probe was aimed, not necessarily where the body came from. Right: fetch follows redirects. The x-inspector-oauth-metadata-source header and the console warning now report probe.url, falling back to the candidate for a synthesized response that carries no url. New test drives a probe whose url differs from the candidate and asserts both the header and the warning name the redirected URL.

npm run local:gate passes. (One unrelated flake en route — oauth-resource-metadata-challenge › "observes the SSE challenge through a caller-supplied eventSourceInit.fetch", which builds a transport directly and never touches this code path; passes in isolation and on the clean rerun. Second such flake this PR, both in untouched files under a loaded run.)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants