| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Sorry, something went wrong.
🦋 Changeset detectedLatest commit: 00879bb The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Sorry, something went wrong.
|
@modelcontextprotocol/client
npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@1710
npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@1710
npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@1710
npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@1710
npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@1710 commit: 00879bb |
Sorry, something went wrong.
|
📌 Commit 9e2716ab is the breaking alternative — review it as a standalone proposal. It's presented as a delta on top of the TokenProvider commits so you can see it replace the additive approach in-place. Net −52 lines despite adding the interface + migration docs + type guard, because each transport drops ~50 lines of inline OAuth orchestration. Who breaks: only users who hand-implement OAuthClientProvider for interactive browser flows (the simpleOAuthClientProvider.ts pattern). They add one method: async token() { return (await this.tokens())?.access_token; }Built-in providers (ClientCredentialsProvider, PrivateKeyJwtProvider, CrossAppAccessProvider) are unchanged from the user's perspective. What stays scoped out: auth() and authInternal() — the 227-line OAuth orchestrator — are untouched. They still take OAuthClientProvider. The transport/provider boundary is the only thing that moved. Happy to drop this commit if we prefer the additive path, or drop the earlier commits if we take this one. |
Sorry, something went wrong.
| * For OAuth flows, use {@linkcode OAuthClientProvider} which extends this interface, | ||
| * or one of the built-in providers ({@linkcode index.ClientCredentialsProvider | ClientCredentialsProvider} etc.). | ||
| */ | ||
| export interface AuthProvider { |
There was a problem hiding this comment.
The core new abstraction
Sorry, something went wrong.
There was a problem hiding this comment.
Thorough and well-documented refactoring. The AuthProvider abstraction is a clear improvement: the one-liner bearer token pattern ({ token: async () => key }) eliminates the 8-member stub problem that every non-OAuth user hit. Migration docs are excellent.
A few observations on the design:
1. token() called before every request
The docs say transports call token() before every request. If token() involves async work (cache lookup, refresh check, remote call), this adds latency to every single MCP request. Consider:
2. Concurrent 401 handling
The _authRetryInFlight circuit breaker prevents infinite retry loops, but what happens when multiple concurrent requests all get 401 simultaneously? If onUnauthorized does a token refresh, N concurrent requests could trigger N parallel refresh flows. Only the first should refresh; others should wait for the result.
This was likely also a problem with the old _hasCompletedAuthFlow approach, but worth addressing in this refactoring if possible. A shared promise that coalesces concurrent onUnauthorized calls would prevent token endpoint flooding.
3. Breaking change surface area
The changeset says @modelcontextprotocol/client: major. Custom OAuthClientProvider implementations must add token() and onUnauthorized(). The migration guide covers this well, but worth considering: could token() have a default implementation on OAuthClientProvider that calls this.tokens()?.access_token? That would reduce the breaking surface to zero for the common case.
4. Type guard pattern
isOAuthClientProvider() is a runtime type guard. Since OAuthClientProvider extends AuthProvider, this is the right approach for gating OAuth-specific features (like finishAuth()). Clean.
5. Exported auth helpers
Exposing applyBasicAuth, applyPostAuth, applyPublicAuth, executeTokenRequest is a good move for custom flow builders. These were previously internal, so worth noting in the docs that they are now part of the public API surface and subject to semver.
Strong PR. The main concern is the concurrent 401 coalescing question.
Sorry, something went wrong.
| }); | ||
| }); | ||
|
|
||
| describe('AuthProvider integration — both modes against a real server', () => { |
There was a problem hiding this comment.
@pcarleton: added tests covering both authentication modes here
Sorry, something went wrong.
Adds a minimal `() => Promise<string | undefined>` function type as a lightweight alternative to OAuthClientProvider, for scenarios where bearer tokens are managed externally (gateway/proxy patterns, service accounts, API keys). - New TokenProvider type + withBearerAuth(getToken, fetchFn?) helper - New tokenProvider option on StreamableHTTPClientTransport and SSEClientTransport, used as fallback after authProvider in _commonHeaders(). authProvider takes precedence when both set. - On 401 with tokenProvider (no authProvider), transports throw UnauthorizedError — no retry, since tokenProvider() is already called before every request and would likely return the same rejected token. Callers catch UnauthorizedError, invalidate external cache, reconnect. - Exported previously-internal auth helpers for building custom flows: applyBasicAuth, applyPostAuth, applyPublicAuth, executeTokenRequest. - Tests, example, docs, changeset. Zero breakage. Bughunter fleet review: 28 findings submitted, 2 confirmed, both addressed.
Transports now accept AuthProvider { token(), onUnauthorized() } instead
of being typed as OAuthClientProvider. OAuthClientProvider extends
AuthProvider, so built-in providers work unchanged — custom
implementations add two methods (both TypeScript-enforced).
Core changes:
- New AuthProvider interface — transports only need token() +
onUnauthorized(), not the full 21-member OAuth interface
- OAuthClientProvider extends AuthProvider; onUnauthorized() is
required (not optional) on OAuthClientProvider since OAuth providers
that omit it lose all 401 recovery. The 4 built-in providers
implement both methods, delegating to new handleOAuthUnauthorized
helper.
- Transports call authProvider.token() in _commonHeaders() — one code
path, no precedence rules
- Transports call authProvider.onUnauthorized() on 401, retry once —
~50 lines of inline OAuth orchestration removed per transport.
Circuit breaker via _authRetryInFlight (reset in outer catch so
transient onUnauthorized failures don't permanently disable
retries).
- Response body consumption deferred until after the onUnauthorized
branch so custom implementations can read ctx.response.text()
- WWW-Authenticate extraction guarded with headers.has() check
(pre-existing inconsistency; the SSE connect path already did this)
- finishAuth() and 403 upscoping gated on isOAuthClientProvider()
- TokenProvider type + tokenProvider option deleted — subsumed by
{ token: async () => ... } as authProvider
Simple case: { authProvider: { token: async () => apiKey } } — no
class needed, TypeScript structural typing.
auth() and authInternal() (227 LOC of OAuth orchestration) untouched.
They still take OAuthClientProvider. Only the transport/provider
boundary moved.
See docs/migration.md and docs/migration-SKILL.md for before/after.
Alternative to the breaking 'extends AuthProvider' approach. Instead of
requiring OAuthClientProvider implementations to add token() +
onUnauthorized(), the transport constructor classifies the authProvider
option once and adapts OAuth providers via adaptOAuthProvider().
- OAuthClientProvider interface is unchanged from v1
- Transport option: authProvider?: AuthProvider | OAuthClientProvider
- Constructor: if OAuth, store both original (for finishAuth/403) and
adapted (for _commonHeaders/401) — classification happens once, no
runtime type guards in the hot path
- 4 built-in providers no longer need token()/onUnauthorized()
- migration.md/migration-SKILL.md entries removed — nothing to migrate
- Changeset downgraded to minor
Net -142 lines vs the breaking approach. Same transport simplification,
zero migration burden. Duck-typing via isOAuthClientProvider()
('tokens' + 'clientMetadata' in provider) at construction only.
Check typeof === 'function' on two required methods (tokens + clientInformation) instead of bare 'in' operator. Slightly more robust — verifies they're actually callable, not just properties with those names. Same semantics, reads cleaner.
… on retry Two fixes from claude[bot] round-6 review: 1. Shared _authRetryInFlight between _startOrAuthSse() and send() created a race: if the fire-and-forget GET SSE gets 401 and sets the flag while awaiting onUnauthorized(), a concurrent POST send() that also gets 401 would see flag=true and throw ClientHttpAuthentication without ever attempting its own re-auth. The old _hasCompletedAuthFlow was only set in send() — I introduced the regression when adding 401 handling to _startOrAuthSse. Split into _authRetryInFlight (send path) and _sseAuthRetryInFlight (GET-SSE path). 2. Pre-existing: send() 401/403 retries called this.send(message) without forwarding the options parameter, dropping onresumptiontoken on the retried request. Added options to both call sites.
Proof-of-life that both auth shapes work against a real HTTP server:
- MODE A (minimal AuthProvider): { token: () => 'token' } → server
sees Authorization: Bearer token
- MODE A 401: onUnauthorized signals UI and throws → caller sees the
thrown error (the host-managed pattern where the enclosing app
handles reauth)
- MODE B (OAuthClientProvider): passed directly, adapter synthesizes
token() from tokens() → server sees Authorization: Bearer
<access_token>
- Combined: same constructor option slot, same send() call, both
shapes hit the same server
Uses real node:http server (not fetch mocks) to verify the
Authorization header actually reaches the wire.
Removes eslint-disable suppression. process.exitCode = 1 lets the event loop drain before exit; process.exit(1) kills immediately and can cut off pending writes.
…ameter Stops the 10-comment whack-a-mole around flag lifecycle. A mutable boolean class field is the wrong primitive for 'retry once per operation' when operations are concurrent and recursive — every reset point creates a race, every missed reset creates a stuck flag. Now all four 401 paths use parameter-passed isAuthRetry: - StreamableHTTP _startOrAuthSse(options, isAuthRetry = false): recursion passes true. No class field, no reset sites. - StreamableHTTP send() delegates to private _send(message, options, isAuthRetry). Recursion passes true. No class field. - SSE _startOrAuth(isAuthRetry = false): onerror callback captures isAuthRetry from closure; retry calls _startOrAuth(true). - SSE send() delegates to private _send(message, isAuthRetry). Per-operation state dies with the stack frame. Concurrent operations cannot observe each other's retry state. 12 reset sites deleted. Also makes SSE onerror fallback consistent with other paths — throws SdkError(ClientHttpAuthentication) for the circuit-breaker case instead of plain UnauthorizedError. Not addressed (noted for auth() cleanup): concurrent 401s still each call onUnauthorized() independently. Deduplicating that (in-flight promise pattern) would be a behavior change.
The isAuthRetry parameter approach works for recursive method calls (send, _startOrAuthSse) but not for the EventSource onerror callback. Passing _startOrAuth(true) on retry permanently captures isAuthRetry=true in the new EventSource's closure — if that EventSource auto-reconnects later (network blip on a long-lived stream) and gets 401, onUnauthorized is skipped and the transport cannot recover. Verified against eventsource lib: non-200 → failConnection (CLOSED, no reconnect); stream end after OPEN → scheduleReconnect → reconnect attempt can get 401 → failConnection → onerror fires. The 'hours later' scenario is real. Fix: retry always calls _startOrAuth() fresh (no parameter). Matches pre-PR _authThenStart() behavior. Trade-off: no circuit breaker on the SSE connect path — if onUnauthorized succeeds but server keeps 401ing, it loops (same as pre-PR). Also fixes double-onerror: two-arg .then(onSuccess, onFail) separates retry failures (inner _startOrAuth already fired onerror) from onUnauthorized failures (not yet reported). Added close + clear _last401Response before retry for hygiene. Two regression tests added, both verified to FAIL against the buggy code: - 401→401→200: onUnauthorized called TWICE, start() resolves - 401→onUnauthorized succeeds→401→onUnauthorized throws: onerror fires ONCE with the thrown error
There was a problem hiding this comment.
All previous findings have been addressed — nice iteration. The remaining nit (SSE connect circuit breaker) is acknowledged and low-priority. However, this is a large architectural change introducing a new auth abstraction across both transports, touching security-sensitive 401 handling paths, so it warrants a human maintainer sign-off on the design.
Extended reasoning...This PR introduces AuthProvider — a minimal two-method interface (token() + optional onUnauthorized()) — as the transport auth abstraction, with adaptOAuthProvider() bridging existing OAuthClientProvider implementations. It touches 13 files: core auth types (auth.ts), both transports (sse.ts, streamableHttp.ts), migration docs, examples, and tests. The 401 handling in both transports was significantly refactored from inline auth() calls to the onUnauthorized() delegation pattern, with isAuthRetry as a stack-local parameter instead of a class field.
The changes are in the auth/401 handling path, which is security-sensitive. The isAuthRetry parameter approach is sound — it provides per-operation isolation without closure capture bugs or concurrent-operation races. The adaptOAuthProvider correctly delegates to handleOAuthUnauthorized which runs the full OAuth flow. No new injection vectors, credential exposure risks, or auth bypass paths are introduced. The one remaining nit (SSE connect path lacks a circuit breaker for repeated 401s) is explicitly acknowledged and only affects the deprecated SSE transport.
This PR warrants careful human review because: (1) it introduces a new public API surface (AuthProvider, UnauthorizedContext, adaptOAuthProvider, handleOAuthUnauthorized, isOAuthClientProvider, plus newly-exported auth helpers), (2) it refactors security-sensitive 401 retry logic across both transports, (3) it involves architectural design decisions (adapter pattern, stack-local vs class-field circuit breakers) that affect the SDK's long-term API evolution, and (4) the authProvider option type is widened to a union which, while non-breaking, is a significant API contract change.
The PR includes 15 new tests covering both provider shapes via fetch mocks and wire-level integration tests against real HTTP servers. All 18 previous inline comments from bug hunting rounds have been resolved. The remaining finding is a deliberate trade-off on the deprecated SSE transport. The code quality is high with good JSDoc, clear comments explaining design decisions (e.g., why return vs return await), and consistent patterns across both transports. The migration docs are thorough. A human maintainer should confirm the API design decisions are aligned with the project's direction.
Sorry, something went wrong.
| this._eventSource.onerror = event => { | ||
| if (event.code === 401 && this._authProvider) { | ||
| this._authThenStart().then(resolve, reject); | ||
| if (this._authProvider.onUnauthorized && this._last401Response) { | ||
| const response = this._last401Response; | ||
| this._last401Response = undefined; | ||
| this._eventSource?.close(); | ||
| this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._fetchWithInit }).then( | ||
| // onUnauthorized succeeded → retry fresh. Its onerror handles its own onerror?.() + reject. | ||
| () => this._startOrAuth().then(resolve, reject), | ||
| // onUnauthorized failed → not yet reported. | ||
| error => { | ||
| this.onerror?.(error); | ||
| reject(error); | ||
| } | ||
| ); | ||
| return; | ||
| } | ||
| const error = new UnauthorizedError(); | ||
| reject(error); | ||
| this.onerror?.(error); |
There was a problem hiding this comment.
🟡 Nit: SSE _startOrAuth() has no circuit breaker for repeated 401s — if onUnauthorized always resolves but the server keeps returning 401, the retry loop has no upper bound, unlike _startOrAuthSse(options, isAuthRetry) in StreamableHTTP and _send(message, isAuthRetry) in both transports which all limit retries. This is an acknowledged trade-off (commit 56ffc7b notes "no circuit breaker on the SSE connect path") to avoid the closure capture regression, but a counter-based approach could provide both safety and correctness. Low priority since SSE transport is deprecated.
Extended reasoning...The SSE _startOrAuth() method has no isAuthRetry parameter or equivalent circuit breaker. When the onerror handler fires on 401, onUnauthorized is called, and on success, this._startOrAuth().then(resolve, reject) recursively creates a new EventSource. If the new EventSource also gets 401, the cycle repeats with no upper bound.
All three other 401 retry paths in the codebase have circuit breakers:
Commit 56ffc7b explicitly removed isAuthRetry from _startOrAuth(). The commit message explains the architectural reason: the SSE EventSource model is fundamentally different from one-shot fetch calls. The onerror closure lives for the lifetime of the EventSource. Passing _startOrAuth(true) on retry bakes isAuthRetry=true into the new EventSource's closure permanently. If that EventSource later auto-reconnects (e.g., network drop on a long-lived stream hours later) and gets 401, onUnauthorized would be skipped and the transport could never recover from token expiration.
The commit acknowledges this trade-off explicitly: "no circuit breaker on the SSE connect path — if onUnauthorized succeeds but server keeps 401ing, it loops (same as pre-PR)."
Concrete scenario: an AuthProvider whose onUnauthorized successfully refreshes a token (resolves), but the server permanently rejects the new token (e.g., token was revoked server-side, scope insufficient, client banned):
The test at line 1628 validates multi-retry (2 calls to onUnauthorized, 3rd attempt succeeds), confirming the behavior is intentional. The test terminates because the mock server stops returning 401 on the 3rd attempt — but there's nothing in the code that would stop it if the server never stops.
A counter-based approach would provide both safety and correctness without the closure capture issue:
private _connectRetryCount = 0;
// In onerror:
if (++this._connectRetryCount > MAX_CONNECT_RETRIES) { reject(new UnauthorizedError()); return; }
// Reset on successful connection (in the endpoint event handler):
this._connectRetryCount = 0;This avoids baking state into the EventSource closure while still providing an upper bound.
Sorry, something went wrong.
There was a problem hiding this comment.
This looks good to me!
Thanks for adding the 2 modes, I think that helps clear up how you'd do the "I can't just pop a redirect right now" flow.
Sorry, something went wrong.
| await this._authProvider.onUnauthorized({ | ||
| response, | ||
| serverUrl: this._url, | ||
| fetchFn: this._fetchWithInit | ||
| }); | ||
| await response.text?.().catch(() => {}); |
There was a problem hiding this comment.
🟡 Nit: 401 response bodies are never drained when onUnauthorized() throws in the three async retry paths (_startOrAuthSse line 229, _send streamableHttp line 522, _send SSE line 278), and are never drained at all in the SSE connect onerror handler (lines 148-170). In Node.js (undici), unconsumed response bodies prevent TCP socket recycling until GC. Fix: wrap onUnauthorized() + drain in try/finally in the async paths; add response.text?.().catch(() => {}) in the SSE onerror paths.
Extended reasoning...There are two related issues where 401 response bodies are left unconsumed:
Issue 1 — onUnauthorized() failure path: In all three async 401 retry paths, the response.text() drain call is positioned after await onUnauthorized(). If onUnauthorized() throws (e.g., network error during token refresh), execution jumps directly to the catch block, skipping the drain:
// streamableHttp.ts _startOrAuthSse (line 229-234), same in _send (522-527) and sse.ts _send (278-283)
await this._authProvider.onUnauthorized({ response, ... }); // throws here
await response.text?.().catch(() => {}); // skipped
return this._startOrAuthSse(options, true);The catch blocks do not drain the response either — they just call onerror and rethrow.
Issue 2 — SSE connect onerror handler: In the SSE onerror handler (lines 148-170), the 401 response stored in _last401Response is never consumed in any path. The response object is passed to onUnauthorized() which only reads headers via extractWWWAuthenticateParams(). After onUnauthorized succeeds, the code calls _startOrAuth() without draining. After it fails, the error is reported without draining. When there is no onUnauthorized, UnauthorizedError is thrown without draining. In contrast, all three _send() paths consistently drain the body.
Practical impact is low: (1) 401 response bodies are typically small or empty, (2) GC will reclaim the socket quickly, (3) these are uncommon error paths (onUnauthorized throwing is itself an edge case), and (4) the SSE transport is deprecated. Socket exhaustion would require sustained rapid onUnauthorized failures, which is unusual. However, the fix is trivial and matches the drain pattern already used everywhere else in this code.
For Issue 1, wrap onUnauthorized() + drain in try/finally in all three async paths:
try {
await this._authProvider.onUnauthorized({ response, ... });
} finally {
await response.text?.().catch(() => {});
}
return this._send(message, options, true);For Issue 2, add a drain call in the SSE onerror handler after capturing the response:
const response = this._last401Response;
this._last401Response = undefined;
response.text?.().catch(() => {}); // fire-and-forget drain
Sorry, something went wrong.
| Back | FazBrowse Home | New Git URL |
Adds AuthProvider — a minimal two-method interface — as the transport's auth abstraction. OAuthClientProvider is adapted at the transport boundary, so existing code passing OAuth providers is unchanged.
Non-breaking. OAuthClientProvider keeps its existing shape. adaptOAuthProvider() synthesizes token() from tokens() and onUnauthorized() from handleOAuthUnauthorized() when the transport receives a full OAuth provider.
Motivation and Context
OAuthClientProvider assumes an interactive browser-redirect flow. Many deployments don't fit: gateway/proxy patterns, service accounts with pre-provisioned tokens, enterprise SSO where tokens come from a separate pipeline. Today those users either stub out unused OAuthClientProvider methods or wrap fetch manually.
The minimal interface covers the transport's actual needs: "give me a token" and "the token was rejected, do something." Everything OAuth-specific (discovery, refresh, redirect) lives in the provider, not the transport.
What changed
Transports (sse.ts, streamableHttp.ts):
auth.ts:
Docs:
Tests (packages/client/test/client/tokenProvider.test.ts):
How Has This Been Tested?
Breaking Changes
None. OAuthClientProvider is unchanged. authProvider option widened to a union.
Out of scope (noted for the auth() cleanup doc)
Concurrent 401s each call onUnauthorized() independently (thundering herd). Deduplicating via an in-flight-promise pattern would be a behavior change and belongs in the broader auth() refactor.
Types of changes
Checklist
Earlier drafts preserved in commits 9aea20fb (additive TokenProvider sidecar) and 29611017 (breaking extends approach). Both superseded by the adapter design above.