fix: Pass RequestInit options to auth requests (#1066)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com>
This commit is contained in:
David Soria Parra 2025-11-17 16:06:21 +00:00 • committed by GitHub
commit df56e35322
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 165 additions and 23 deletions

View file

@ -2558,4 +2558,113 @@ describe('OAuth Authorization', () => {
expect(body.get('refresh_token')).toBe('refresh123');
});
});
describe('RequestInit headers passthrough', () => {
it('custom headers from RequestInit are passed to auth discovery requests', async () => {
const { createFetchWithInit } = await import('../shared/transport.js');
const customFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
resource: 'https://resource.example.com',
authorization_servers: ['https://auth.example.com']
})
});
// Create a wrapped fetch with custom headers
const wrappedFetch = createFetchWithInit(customFetch, {
headers: {
'user-agent': 'MyApp/1.0',
'x-custom-header': 'test-value'
}
});
await discoverOAuthProtectedResourceMetadata('https://resource.example.com', undefined, wrappedFetch);
expect(customFetch).toHaveBeenCalledTimes(1);
const [url, options] = customFetch.mock.calls[0];
expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource');
expect(options.headers).toMatchObject({
'user-agent': 'MyApp/1.0',
'x-custom-header': 'test-value',
'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION
});
});
it('auth-specific headers override base headers from RequestInit', async () => {
const { createFetchWithInit } = await import('../shared/transport.js');
const customFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
})
});
// Create a wrapped fetch with a custom Accept header
const wrappedFetch = createFetchWithInit(customFetch, {
headers: {
Accept: 'text/plain',
'user-agent': 'MyApp/1.0'
}
});
await discoverAuthorizationServerMetadata('https://auth.example.com', {
fetchFn: wrappedFetch
});
expect(customFetch).toHaveBeenCalled();
const [, options] = customFetch.mock.calls[0];
// Auth-specific Accept header should override base Accept header
expect(options.headers).toMatchObject({
Accept: 'application/json', // Auth-specific value wins
'user-agent': 'MyApp/1.0', // Base value preserved
'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION
});
});
it('other RequestInit options are passed through', async () => {
const { createFetchWithInit } = await import('../shared/transport.js');
const customFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
resource: 'https://resource.example.com',
authorization_servers: ['https://auth.example.com']
})
});
// Create a wrapped fetch with various RequestInit options
const wrappedFetch = createFetchWithInit(customFetch, {
credentials: 'include',
mode: 'cors',
cache: 'no-cache',
headers: {
'user-agent': 'MyApp/1.0'
}
});
await discoverOAuthProtectedResourceMetadata('https://resource.example.com', undefined, wrappedFetch);
expect(customFetch).toHaveBeenCalledTimes(1);
const [, options] = customFetch.mock.calls[0];
// All RequestInit options should be preserved
expect(options.credentials).toBe('include');
expect(options.mode).toBe('cors');
expect(options.cache).toBe('no-cache');
expect(options.headers).toMatchObject({
'user-agent': 'MyApp/1.0'
});
});
});
});

View file

@ -1,5 +1,5 @@
import { EventSource, type ErrorEvent, type EventSourceInit } from 'eventsource';
import { Transport, FetchLike } from '../shared/transport.js';
import { Transport, FetchLike, createFetchWithInit } from '../shared/transport.js';
import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js';
import { auth, AuthResult, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js';
@ -70,6 +70,7 @@ export class SSEClientTransport implements Transport {
private _requestInit?: RequestInit;
private _authProvider?: OAuthClientProvider;
private _fetch?: FetchLike;
private _fetchWithInit: FetchLike;
private _protocolVersion?: string;
onclose?: () => void;
@ -84,6 +85,7 @@ export class SSEClientTransport implements Transport {
this._requestInit = opts?.requestInit;
this._authProvider = opts?.authProvider;
this._fetch = opts?.fetch;
this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit);
}
private async _authThenStart(): Promise<void> {
@ -97,7 +99,7 @@ export class SSEClientTransport implements Transport {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetch
fetchFn: this._fetchWithInit
});
} catch (error) {
this.onerror?.(error as Error);
@ -220,7 +222,7 @@ export class SSEClientTransport implements Transport {
authorizationCode,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetch
fetchFn: this._fetchWithInit
});
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError('Failed to authorize');
@ -260,7 +262,7 @@ export class SSEClientTransport implements Transport {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetch
fetchFn: this._fetchWithInit
});
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError();

View file

@ -1,4 +1,4 @@
import { Transport, FetchLike } from '../shared/transport.js';
import { Transport, FetchLike, createFetchWithInit, normalizeHeaders } from '../shared/transport.js';
import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from '../types.js';
import { auth, AuthResult, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js';
import { EventSourceParserStream } from 'eventsource-parser/stream';
@ -129,6 +129,7 @@ export class StreamableHTTPClientTransport implements Transport {
private _requestInit?: RequestInit;
private _authProvider?: OAuthClientProvider;
private _fetch?: FetchLike;
private _fetchWithInit: FetchLike;
private _sessionId?: string;
private _reconnectionOptions: StreamableHTTPReconnectionOptions;
private _protocolVersion?: string;
@ -145,6 +146,7 @@ export class StreamableHTTPClientTransport implements Transport {
this._requestInit = opts?.requestInit;
this._authProvider = opts?.authProvider;
this._fetch = opts?.fetch;
this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit);
this._sessionId = opts?.sessionId;
this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS;
}
@ -160,7 +162,7 @@ export class StreamableHTTPClientTransport implements Transport {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetch
fetchFn: this._fetchWithInit
});
} catch (error) {
this.onerror?.(error as Error);
@ -190,7 +192,7 @@ export class StreamableHTTPClientTransport implements Transport {
headers['mcp-protocol-version'] = this._protocolVersion;
}
const extraHeaders = this._normalizeHeaders(this._requestInit?.headers);
const extraHeaders = normalizeHeaders(this._requestInit?.headers);
return new Headers({
...headers,
@ -255,20 +257,6 @@ export class StreamableHTTPClientTransport implements Transport {
return Math.min(initialDelay * Math.pow(growFactor, attempt), maxDelay);
}
private _normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
if (!headers) return {};
if (headers instanceof Headers) {
return Object.fromEntries(headers.entries());
}
if (Array.isArray(headers)) {
return Object.fromEntries(headers);
}
return { ...(headers as Record<string, string>) };
}
/**
* Schedule a reconnection attempt with exponential backoff
*
@ -388,7 +376,7 @@ export class StreamableHTTPClientTransport implements Transport {
authorizationCode,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetch
fetchFn: this._fetchWithInit
});
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError('Failed to authorize');
@ -452,7 +440,7 @@ export class StreamableHTTPClientTransport implements Transport {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetch
fetchFn: this._fetchWithInit
});
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError();

View file

@ -2,6 +2,49 @@ import { JSONRPCMessage, MessageExtraInfo, RequestId } from '../types.js';
export type FetchLike = (url: string | URL, init?: RequestInit) => Promise<Response>;
/**
* Normalizes HeadersInit to a plain Record<string, string> for manipulation.
* Handles Headers objects, arrays of tuples, and plain objects.
*/
export function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
if (!headers) return {};
if (headers instanceof Headers) {
return Object.fromEntries(headers.entries());
}
if (Array.isArray(headers)) {
return Object.fromEntries(headers);
}
return { ...(headers as Record<string, string>) };
}
/**
* Creates a fetch function that includes base RequestInit options.
* This ensures requests inherit settings like credentials, mode, headers, etc. from the base init.
*
* @param baseFetch - The base fetch function to wrap (defaults to global fetch)
* @param baseInit - The base RequestInit to merge with each request
* @returns A wrapped fetch function that merges base options with call-specific options
*/
export function createFetchWithInit(baseFetch: FetchLike = fetch, baseInit?: RequestInit): FetchLike {
if (!baseInit) {
return baseFetch;
}
// Return a wrapped fetch that merges base RequestInit with call-specific init
return async (url: string | URL, init?: RequestInit): Promise<Response> => {
const mergedInit: RequestInit = {
...baseInit,
...init,
// Headers need special handling - merge instead of replace
headers: init?.headers ? { ...normalizeHeaders(baseInit.headers), ...normalizeHeaders(init.headers) } : baseInit.headers
};
return baseFetch(url, mergedInit);
};
}
/**
* Options for sending a JSON-RPC message.
*/