Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions client/src/lib/hooks/__tests__/useConnection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
26 changes: 25 additions & 1 deletion client/src/lib/hooks/useConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand All @@ -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
Expand Down