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:
parent
a7e525a100
commit
df56e35322
4 changed files with 165 additions and 23 deletions
|
|
@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue