From 66dd81047f127451fe93371d4874c5e212c5f56c Mon Sep 17 00:00:00 2001 From: Justin Edelson <65906+justinedelson@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:00:24 -0400 Subject: [PATCH 1/2] fix: use auth provider's current token instead of a connect-time snapshot After a successful OAuth token refresh, direct (non-proxy) connections kept sending the old access token, so the session entered an unrecoverable 401 loop once the initial access token expired. useConnection read the access token from the provider once at connection setup and baked "Authorization: Bearer " into requestInit.headers (and the custom fetch's header override). The transports already receive that same provider as authProvider and add a fresh Authorization from it on every request (refreshing on 401), but the SDK's _commonHeaders() spreads requestInit.headers after the provider token, so the connect-time snapshot always shadowed the refreshed token. Stop snapshotting the OAuth token for direct connections and rely on the authProvider as the single, always-current source. A user-supplied static Authorization header still flows through and intentionally overrides the provider. The SSE custom fetch is also reordered so the SDK-provided init (carrying the fresh token) wins over the connect-time header snapshot. Fixes #1434 --- .../hooks/__tests__/useConnection.test.tsx | 57 +++++++++++++++++++ client/src/lib/hooks/useConnection.ts | 25 +++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index 875c9e387..4e34f5e04 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -1387,6 +1387,63 @@ describe("useConnection", () => { }); }); + describe("Direct connection OAuth token handling (#1434)", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSSETransport.options = undefined; + mockStreamableHTTPTransport.options = undefined; + }); + + test("does NOT bake the OAuth provider token into requestInit for direct connections", async () => { + // Regression test for #1434: the OAuth token must not be snapshotted into + // requestInit at connect time. The SDK's authProvider is the single, + // always-current source and refreshes the token on 401; a baked snapshot + // shadows the refreshed token and leaves the session stuck on the stale + // one after a successful refresh. + const directProps = { + ...defaultProps, + connectionType: "direct" as const, + }; + + const { result } = renderHook(() => useConnection(directProps)); + + await act(async () => { + await result.current.connect(); + }); + + const headers = mockSSETransport.options?.requestInit?.headers; + // The provider (mocked to return "mock-token") must NOT be baked in. + expect(headers).not.toHaveProperty("Authorization"); + // ...instead the authProvider is wired up so the SDK injects and + // refreshes the token per request. + expect(mockSSETransport.options?.authProvider).toBeDefined(); + }); + + test("still sends a user-supplied static Authorization header for direct connections", async () => { + // A user-provided static Authorization header is an intentional override + // of the provider and must still flow through untouched. + const customHeaders: CustomHeaders = [ + { name: "Authorization", value: "Bearer user-token", enabled: true }, + ]; + + const directProps = { + ...defaultProps, + connectionType: "direct" as const, + customHeaders, + }; + + const { result } = renderHook(() => useConnection(directProps)); + + await act(async () => { + await result.current.connect(); + }); + + const headers = mockSSETransport.options?.requestInit?.headers; + expect(headers).toHaveProperty("Authorization", "Bearer user-token"); + expect(mockSSETransport.options?.authProvider).toBeDefined(); + }); + }); + describe("Connection URL Verification", () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 9694b891f..36f8598df 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -525,9 +525,17 @@ export function useConnection({ header.name.trim().toLowerCase() === "authorization", ); + // Track whether the Authorization header was injected from the OAuth + // provider (as opposed to a user-supplied static header). For direct + // connections this token must NOT be baked into requestInit, because the + // SDK's authProvider already injects and refreshes it on every request; a + // baked connect-time snapshot would shadow the refreshed token (#1434). + let oauthTokenInjected = false; + if (needsOAuthToken) { const oauthToken = (await serverAuthProvider.tokens())?.access_token; if (oauthToken) { + oauthTokenInjected = true; // Add the OAuth token finalHeaders = [ // Remove any existing Authorization headers with empty tokens @@ -575,6 +583,17 @@ export function useConnection({ serverUrl = new URL(sseUrl); const requestHeaders = { ...headers }; + // For direct connections the SDK's authProvider is the single, + // always-current source of the OAuth Authorization header: it injects + // the token on every request and refreshes it on 401. Baking a + // connect-time token snapshot into requestInit/the custom fetch would + // shadow the refreshed token and leave the session permanently sending + // the stale token after a refresh (#1434). A user-supplied static + // Authorization header must still override the provider, so only strip + // the header here when it was injected from the OAuth provider. + if (oauthTokenInjected) { + delete requestHeaders["Authorization"]; + } if (mcpSessionId) { requestHeaders["mcp-session-id"] = mcpSessionId; } @@ -588,9 +607,13 @@ export function useConnection({ url: string | URL | globalThis.Request, init?: RequestInit, ) => { + // Let the SDK-provided init (which already merges the + // provider's fresh Authorization with requestInit.headers) + // win over the connect-time snapshot, so a refreshed token is + // actually sent (#1434). const response = await fetch(url, { - ...init, headers: requestHeaders, + ...init, }); // Capture protocol-related headers from response From f10ed2e10ad4454a9ee7ed0dd47311891f13d2fd Mon Sep 17 00:00:00 2001 From: Justin Edelson <65906+justinedelson@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:57:08 -0400 Subject: [PATCH 2/2] review: clarify injected-header comment; drop issue ref from describe title --- client/src/lib/hooks/__tests__/useConnection.test.tsx | 2 +- client/src/lib/hooks/useConnection.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index 4e34f5e04..4550f30a7 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -1387,7 +1387,7 @@ describe("useConnection", () => { }); }); - describe("Direct connection OAuth token handling (#1434)", () => { + describe("Direct connection OAuth token handling", () => { beforeEach(() => { jest.clearAllMocks(); mockSSETransport.options = undefined; diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 36f8598df..401885e92 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -590,7 +590,8 @@ export function useConnection({ // shadow the refreshed token and leave the session permanently sending // the stale token after a refresh (#1434). A user-supplied static // Authorization header must still override the provider, so only strip - // the header here when it was injected from the OAuth provider. + // the header here when it was injected from the OAuth provider (the + // header we inject is always named exactly "Authorization"). if (oauthTokenInjected) { delete requestHeaders["Authorization"]; }