diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index 875c9e387..4550f30a7 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", () => { + 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..401885e92 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,18 @@ 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 (the + // header we inject is always named exactly "Authorization"). + if (oauthTokenInjected) { + delete requestHeaders["Authorization"]; + } if (mcpSessionId) { requestHeaders["mcp-session-id"] = mcpSessionId; } @@ -588,9 +608,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