fix(client): let OAuth-derived Authorization override caller-supplied header (#2475)
Signed-off-by: sanjibani <18418553+sanjibani@users.noreply.github.com> Co-authored-by: sanjibani <18418553+sanjibani@users.noreply.github.com> Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com> Co-authored-by: Konstantin Konstantinov <konstantin@mach5technology.com>
This commit is contained in:
parent
5ecc791d81
commit
b65426158e
8 changed files with 438 additions and 28 deletions
5
.changeset/oauth-header-spread-order.md
Normal file
5
.changeset/oauth-header-spread-order.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@modelcontextprotocol/client': patch
|
||||
---
|
||||
|
||||
`StreamableHTTPClientTransport` and `SSEClientTransport` now give their transport-managed headers precedence over same-named entries in `requestInit.headers`: `Authorization` when `authProvider` yields a token, `mcp-protocol-version`, and (Streamable HTTP) `mcp-session-id`. Header names compare case-insensitively and every `HeadersInit` form is covered (plain object, tuple array, `Headers` instance). Previously the caller-supplied value won, so a static `Authorization` placeholder (e.g. an env-var API key) kept overriding the OAuth token even after the provider obtained one and the fallback-to-OAuth flow never completed; a `Headers` instance or lowercase key produced a combined `Bearer <fresh>, Bearer <stale>` value instead. A configured `Authorization` is still sent while the provider has no token, and other configured headers pass through unchanged. Closes #2208.
|
||||
|
|
@ -808,6 +808,13 @@ value to the spec-required `application/json, text/event-stream` (v1 let it repl
|
|||
them). The required media types are always present; additional types are kept for
|
||||
proxy/gateway routing.
|
||||
|
||||
Transport-managed headers now take precedence over same-named entries in
|
||||
`requestInit.headers`: `Authorization` when `authProvider` yields a token,
|
||||
`mcp-protocol-version`, and (Streamable HTTP) `mcp-session-id`. v1 let the configured
|
||||
header win, so a static `Authorization` placeholder kept overriding the OAuth token even
|
||||
after the provider obtained one. A configured `Authorization` value is still sent while
|
||||
the provider has no token, which is what lets a static API key fall back to OAuth.
|
||||
|
||||
`hostHeaderValidation()` and `localhostHostValidation()` moved to
|
||||
`@modelcontextprotocol/express`. The `(allowedHostnames: string[])` signature is the
|
||||
same as every released v1.x — only the import path changes. Framework-agnostic helpers
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import {
|
|||
brandedHasInstance,
|
||||
createFetchWithInit,
|
||||
JSONRPCMessageSchema,
|
||||
normalizeHeaders,
|
||||
SdkError,
|
||||
SdkErrorCode,
|
||||
SdkHttpError,
|
||||
|
|
@ -97,15 +96,23 @@ export type SSEClientTransportOptions = {
|
|||
/**
|
||||
* Customizes the initial SSE request to the server (the request that begins the stream).
|
||||
*
|
||||
* NOTE: Setting this property will prevent an `Authorization` header from
|
||||
* being automatically attached to the SSE request, if an {@linkcode SSEClientTransportOptions.authProvider | authProvider} is
|
||||
* also given. This can be worked around by setting the `Authorization` header
|
||||
* manually.
|
||||
* A custom `fetch` supplied here is still wrapped by the transport: the
|
||||
* transport-managed headers, including the `Authorization` header derived from
|
||||
* {@linkcode SSEClientTransportOptions.authProvider | authProvider}, are attached to the
|
||||
* SSE request and take precedence over a same-named entry in `requestInit.headers`
|
||||
* (see {@linkcode SSEClientTransportOptions.requestInit | requestInit}).
|
||||
*/
|
||||
eventSourceInit?: EventSourceInit;
|
||||
|
||||
/**
|
||||
* Customizes recurring `POST` requests to the server.
|
||||
*
|
||||
* The transport-managed headers take precedence over a same-named entry in
|
||||
* `headers`: `Authorization` when
|
||||
* {@linkcode SSEClientTransportOptions.authProvider | authProvider} yields a token, and
|
||||
* `mcp-protocol-version`. A caller-supplied `Authorization` value is therefore only sent
|
||||
* while the provider has no token, which lets a static API key fall back to OAuth once
|
||||
* the provider obtains one.
|
||||
*/
|
||||
requestInit?: RequestInit;
|
||||
|
||||
|
|
@ -170,7 +177,19 @@ export class SSEClientTransport implements Transport {
|
|||
private _last401Response?: Response;
|
||||
|
||||
private async _commonHeaders(): Promise<Headers> {
|
||||
const headers: RequestInit['headers'] & Record<string, string> = {};
|
||||
// Start from the caller-supplied `requestInit.headers` and `set()` the
|
||||
// transport-managed headers on top. `Headers.set` compares names
|
||||
// case-insensitively, so Authorization / mcp-protocol-version replace a
|
||||
// same-named caller entry whatever its spelling. (A plain-object spread would
|
||||
// keep `authorization` and `Authorization` side by side, and the Fetch `Headers`
|
||||
// constructor would then combine them into one two-token value.) This lets
|
||||
// a stale static `Authorization` placeholder (e.g. an env-var API key) fall back
|
||||
// to the OAuth token once the provider has one, and keeps this transport in step
|
||||
// with StreamableHTTPClientTransport. See #2208.
|
||||
// `|| undefined` keeps the old tolerance for a falsy `headers` value (e.g. `null`
|
||||
// from a JS caller or a JSON config forwarded verbatim): the Fetch `Headers`
|
||||
// constructor accepts `undefined` but throws on `null`.
|
||||
const headers = new Headers(this._requestInit?.headers || undefined);
|
||||
let token: string | undefined;
|
||||
try {
|
||||
token = await this._authProvider?.token();
|
||||
|
|
@ -180,18 +199,12 @@ export class SSEClientTransport implements Transport {
|
|||
throw markAuthSeamEscape(error);
|
||||
}
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
if (this._protocolVersion) {
|
||||
headers['mcp-protocol-version'] = this._protocolVersion;
|
||||
headers.set('mcp-protocol-version', this._protocolVersion);
|
||||
}
|
||||
|
||||
const extraHeaders = normalizeHeaders(this._requestInit?.headers);
|
||||
|
||||
return new Headers({
|
||||
...headers,
|
||||
...extraHeaders
|
||||
});
|
||||
return headers;
|
||||
}
|
||||
|
||||
private _startOrAuth(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import {
|
|||
JSONRPCMessageSchema,
|
||||
mcpNameSource,
|
||||
mediaTypeEssence,
|
||||
normalizeHeaders,
|
||||
PROTOCOL_VERSION_META_KEY,
|
||||
SdkError,
|
||||
SdkErrorCode,
|
||||
|
|
@ -183,6 +182,13 @@ export type StreamableHTTPClientTransportOptions = {
|
|||
|
||||
/**
|
||||
* Customizes HTTP requests to the server.
|
||||
*
|
||||
* `headers` are sent on every request, but the transport-managed headers take
|
||||
* precedence over a same-named entry here: `Authorization` when
|
||||
* {@linkcode StreamableHTTPClientTransportOptions.authProvider | authProvider} yields a
|
||||
* token, `mcp-session-id`, and `mcp-protocol-version`. A caller-supplied `Authorization`
|
||||
* value is therefore only sent while the provider has no token, which lets a static API
|
||||
* key fall back to OAuth once the provider obtains one.
|
||||
*/
|
||||
requestInit?: RequestInit;
|
||||
|
||||
|
|
@ -443,7 +449,19 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
}
|
||||
|
||||
private async _commonHeaders(): Promise<Headers> {
|
||||
const headers: RequestInit['headers'] & Record<string, string> = {};
|
||||
// Start from the caller-supplied `requestInit.headers` and `set()` the
|
||||
// transport-managed headers on top. `Headers.set` compares names
|
||||
// case-insensitively, so Authorization / mcp-session-id / mcp-protocol-version
|
||||
// replace a same-named caller entry whatever its spelling. (A plain-object
|
||||
// spread would keep `authorization` and `Authorization` side by side, and the
|
||||
// Fetch `Headers` constructor would then combine them into one two-token
|
||||
// value.) This lets a stale static `Authorization` placeholder (e.g. an env-var
|
||||
// API key) fall back to the OAuth token once the provider has one, and mirrors
|
||||
// the per-request `RESERVED_REQUEST_HEADER_NAMES` guard in send(). See #2208.
|
||||
// `|| undefined` keeps the old tolerance for a falsy `headers` value (e.g. `null`
|
||||
// from a JS caller or a JSON config forwarded verbatim): the Fetch `Headers`
|
||||
// constructor accepts `undefined` but throws on `null`.
|
||||
const headers = new Headers(this._requestInit?.headers || undefined);
|
||||
let token: string | undefined;
|
||||
try {
|
||||
token = await this._authProvider?.token();
|
||||
|
|
@ -453,22 +471,15 @@ export class StreamableHTTPClientTransport implements Transport {
|
|||
throw markAuthSeamEscape(error);
|
||||
}
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
if (this._sessionId) {
|
||||
headers['mcp-session-id'] = this._sessionId;
|
||||
headers.set('mcp-session-id', this._sessionId);
|
||||
}
|
||||
if (this._protocolVersion) {
|
||||
headers['mcp-protocol-version'] = this._protocolVersion;
|
||||
headers.set('mcp-protocol-version', this._protocolVersion);
|
||||
}
|
||||
|
||||
const extraHeaders = normalizeHeaders(this._requestInit?.headers);
|
||||
|
||||
return new Headers({
|
||||
...headers,
|
||||
...extraHeaders
|
||||
});
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -303,6 +303,26 @@ describe('SSEClientTransport', () => {
|
|||
expect(lastServerRequest.headers.authorization).toBe(authToken);
|
||||
});
|
||||
|
||||
it('tolerates requestInit.headers set to null by a JavaScript caller', async () => {
|
||||
// The TS type excludes null, but a JS caller or a JSON config forwarded verbatim can
|
||||
// pass it. `new Headers(null)` throws, so the transport must map falsy to undefined.
|
||||
transport = new SSEClientTransport(resourceBaseUrl, {
|
||||
requestInit: { headers: null as unknown as RequestInit['headers'] }
|
||||
});
|
||||
|
||||
await transport.start();
|
||||
expect(lastServerRequest.headers.accept).toBe('text/event-stream');
|
||||
|
||||
const message: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: '1',
|
||||
method: 'test',
|
||||
params: {}
|
||||
};
|
||||
await transport.send(message);
|
||||
expect(lastServerRequest.headers['content-type']).toBe('application/json');
|
||||
});
|
||||
|
||||
it('passes custom headers to fetch requests', async () => {
|
||||
const customHeaders = {
|
||||
Authorization: 'Bearer test-token',
|
||||
|
|
@ -660,6 +680,123 @@ describe('SSEClientTransport', () => {
|
|||
expect(lastServerRequest.headers['x-custom-header']).toBe('custom-value');
|
||||
});
|
||||
|
||||
it('lets the auth provider token override a stale caller-supplied Authorization header', async () => {
|
||||
// Regression test for #2208: transport-managed headers are merged on top of
|
||||
// requestInit.headers, so a static Authorization placeholder (e.g. an env-var
|
||||
// API key) gives way to the provider's token on both the SSE GET and POSTs.
|
||||
mockAuthProvider.tokens.mockResolvedValue({
|
||||
access_token: 'fresh-token',
|
||||
token_type: 'Bearer'
|
||||
});
|
||||
|
||||
transport = new SSEClientTransport(resourceBaseUrl, {
|
||||
authProvider: mockAuthProvider,
|
||||
requestInit: {
|
||||
headers: {
|
||||
Authorization: 'Bearer stale-placeholder',
|
||||
'X-Custom-Header': 'custom-value'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await transport.start();
|
||||
|
||||
// SSE GET
|
||||
expect(lastServerRequest.headers.authorization).toBe('Bearer fresh-token');
|
||||
expect(lastServerRequest.headers['x-custom-header']).toBe('custom-value');
|
||||
|
||||
const message: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: '1',
|
||||
method: 'test',
|
||||
params: {}
|
||||
};
|
||||
|
||||
await transport.send(message);
|
||||
|
||||
// POST
|
||||
expect(lastServerRequest.headers.authorization).toBe('Bearer fresh-token');
|
||||
expect(lastServerRequest.headers['x-custom-header']).toBe('custom-value');
|
||||
});
|
||||
|
||||
it('replaces a caller-supplied Authorization header regardless of name casing (Headers instance)', async () => {
|
||||
// #2208 follow-up: `Headers` normalizes names to lowercase; the transport must
|
||||
// still send exactly its own token, not a combined two-token value.
|
||||
mockAuthProvider.tokens.mockResolvedValue({
|
||||
access_token: 'fresh-token',
|
||||
token_type: 'Bearer'
|
||||
});
|
||||
|
||||
transport = new SSEClientTransport(resourceBaseUrl, {
|
||||
authProvider: mockAuthProvider,
|
||||
requestInit: {
|
||||
headers: new Headers({
|
||||
authorization: 'Bearer stale-placeholder',
|
||||
'x-custom-header': 'custom-value'
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
await transport.start();
|
||||
|
||||
expect(lastServerRequest.headers.authorization).toBe('Bearer fresh-token');
|
||||
expect(lastServerRequest.headers['x-custom-header']).toBe('custom-value');
|
||||
|
||||
const message: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: '1',
|
||||
method: 'test',
|
||||
params: {}
|
||||
};
|
||||
|
||||
await transport.send(message);
|
||||
|
||||
expect(lastServerRequest.headers.authorization).toBe('Bearer fresh-token');
|
||||
expect(lastServerRequest.headers['x-custom-header']).toBe('custom-value');
|
||||
});
|
||||
|
||||
it('keeps Fetch Headers combine semantics for a repeated name in a tuple array', async () => {
|
||||
// A repeated name in a tuple array is the one `HeadersInit` form that can express a
|
||||
// multi-valued header, and `fetch(url, { headers: [['x', 'a'], ['x', 'b']] })` sends
|
||||
// "x: a, b". The transport builds its headers with the same `Headers` constructor, so
|
||||
// a caller-supplied repeated name is combined exactly as a direct `fetch` would, while
|
||||
// a repeated *transport-managed* name is still replaced outright by the transport's
|
||||
// own value rather than combined with it.
|
||||
mockAuthProvider.tokens.mockResolvedValue({
|
||||
access_token: 'fresh-token',
|
||||
token_type: 'Bearer'
|
||||
});
|
||||
|
||||
transport = new SSEClientTransport(resourceBaseUrl, {
|
||||
authProvider: mockAuthProvider,
|
||||
requestInit: {
|
||||
headers: [
|
||||
['Authorization', 'Bearer stale-1'],
|
||||
['Authorization', 'Bearer stale-2'],
|
||||
['x-multi', 'a'],
|
||||
['x-multi', 'b']
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
await transport.start();
|
||||
|
||||
expect(lastServerRequest.headers.authorization).toBe('Bearer fresh-token');
|
||||
expect(lastServerRequest.headers['x-multi']).toBe('a, b');
|
||||
|
||||
const message: JSONRPCMessage = {
|
||||
jsonrpc: '2.0',
|
||||
id: '1',
|
||||
method: 'test',
|
||||
params: {}
|
||||
};
|
||||
|
||||
await transport.send(message);
|
||||
|
||||
expect(lastServerRequest.headers.authorization).toBe('Bearer fresh-token');
|
||||
expect(lastServerRequest.headers['x-multi']).toBe('a, b');
|
||||
});
|
||||
|
||||
it('refreshes expired token during SSE connection', async () => {
|
||||
// Mock tokens() to return expired token until saveTokens is called
|
||||
let currentTokens: OAuthTokens = {
|
||||
|
|
|
|||
|
|
@ -702,6 +702,169 @@ describe('StreamableHTTPClientTransport', () => {
|
|||
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('OAuth-derived Authorization overrides a stale caller-supplied Authorization', async () => {
|
||||
// Regression test for #2208: the SDK-derived common headers must be
|
||||
// merged on top of caller-supplied headers, so OAuth-derived tokens
|
||||
// (or any SDK-computed value) win over a stale placeholder the
|
||||
// caller might have set in `requestInit.headers` (e.g. an env-var
|
||||
// API key that's no longer valid).
|
||||
const tokens: OAuthTokens = {
|
||||
access_token: 'oauth-access-token',
|
||||
token_type: 'Bearer'
|
||||
};
|
||||
mockAuthProvider.tokens.mockResolvedValue(tokens);
|
||||
const requestInit = {
|
||||
headers: {
|
||||
Authorization: 'Bearer stale-placeholder',
|
||||
'mcp-protocol-version': 'caller-supplied',
|
||||
'X-Caller-Header': 'preserved'
|
||||
}
|
||||
};
|
||||
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
|
||||
requestInit,
|
||||
authProvider: mockAuthProvider
|
||||
});
|
||||
transport.setProtocolVersion('2025-03-26');
|
||||
|
||||
let actualReqInit: RequestInit = {};
|
||||
(globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => {
|
||||
actualReqInit = reqInit;
|
||||
return new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } });
|
||||
});
|
||||
|
||||
await transport.start();
|
||||
await transport['_startOrAuthSse']({});
|
||||
// On the SSE GET: the OAuth-derived token wins over the stale placeholder, and so
|
||||
// does the transport-managed protocol version.
|
||||
expect((actualReqInit.headers as Headers).get('authorization')).toBe('Bearer oauth-access-token');
|
||||
expect((actualReqInit.headers as Headers).get('mcp-protocol-version')).toBe('2025-03-26');
|
||||
// Caller-supplied non-auth headers still pass through.
|
||||
expect((actualReqInit.headers as Headers).get('x-caller-header')).toBe('preserved');
|
||||
|
||||
// Same precedence on POST.
|
||||
await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
|
||||
expect((actualReqInit.headers as Headers).get('authorization')).toBe('Bearer oauth-access-token');
|
||||
expect((actualReqInit.headers as Headers).get('mcp-protocol-version')).toBe('2025-03-26');
|
||||
expect((actualReqInit.headers as Headers).get('x-caller-header')).toBe('preserved');
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a Headers instance',
|
||||
(): NonNullable<RequestInit['headers']> =>
|
||||
new Headers({
|
||||
authorization: 'Bearer stale-placeholder',
|
||||
'MCP-Protocol-Version': 'caller-supplied',
|
||||
'x-caller-header': 'preserved'
|
||||
})
|
||||
],
|
||||
[
|
||||
'a lowercase plain object',
|
||||
(): NonNullable<RequestInit['headers']> => ({
|
||||
authorization: 'Bearer stale-placeholder',
|
||||
'MCP-Protocol-Version': 'caller-supplied',
|
||||
'x-caller-header': 'preserved'
|
||||
})
|
||||
],
|
||||
[
|
||||
'a tuple array',
|
||||
(): NonNullable<RequestInit['headers']> => [
|
||||
['authorization', 'Bearer stale-placeholder'],
|
||||
['MCP-Protocol-Version', 'caller-supplied'],
|
||||
['x-caller-header', 'preserved']
|
||||
]
|
||||
]
|
||||
])('transport-managed headers replace caller-supplied ones regardless of name casing (%s)', async (_label, makeHeaders) => {
|
||||
// #2208 follow-up: header names compare case-insensitively. A `Headers` instance
|
||||
// normalizes names to lowercase and a caller may spell them any way; a key-exact
|
||||
// object merge would keep both spellings and the Fetch `Headers` constructor would
|
||||
// then combine them into one two-token value. The transport must send exactly its
|
||||
// own value.
|
||||
mockAuthProvider.tokens.mockResolvedValue({ access_token: 'oauth-access-token', token_type: 'Bearer' });
|
||||
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
|
||||
requestInit: { headers: makeHeaders() },
|
||||
authProvider: mockAuthProvider
|
||||
});
|
||||
transport.setProtocolVersion('2025-03-26');
|
||||
|
||||
let actualReqInit: RequestInit = {};
|
||||
(globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => {
|
||||
actualReqInit = reqInit;
|
||||
return new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } });
|
||||
});
|
||||
|
||||
await transport.start();
|
||||
await transport['_startOrAuthSse']({});
|
||||
expect((actualReqInit.headers as Headers).get('authorization')).toBe('Bearer oauth-access-token');
|
||||
expect((actualReqInit.headers as Headers).get('mcp-protocol-version')).toBe('2025-03-26');
|
||||
expect((actualReqInit.headers as Headers).get('x-caller-header')).toBe('preserved');
|
||||
|
||||
await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
|
||||
expect((actualReqInit.headers as Headers).get('authorization')).toBe('Bearer oauth-access-token');
|
||||
expect((actualReqInit.headers as Headers).get('mcp-protocol-version')).toBe('2025-03-26');
|
||||
expect((actualReqInit.headers as Headers).get('x-caller-header')).toBe('preserved');
|
||||
});
|
||||
|
||||
it('keeps Fetch Headers combine semantics for a repeated name in a tuple array', async () => {
|
||||
// A repeated name in a tuple array is the one `HeadersInit` form that can express a
|
||||
// multi-valued header, and `fetch(url, { headers: [['x', 'a'], ['x', 'b']] })` sends
|
||||
// "x: a, b". The transport builds its headers with the same `Headers` constructor, so
|
||||
// a caller-supplied repeated name is combined exactly as a direct `fetch` would, while
|
||||
// a repeated *transport-managed* name is still replaced outright by the transport's
|
||||
// own value rather than combined with it.
|
||||
mockAuthProvider.tokens.mockResolvedValue({ access_token: 'oauth-access-token', token_type: 'Bearer' });
|
||||
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
|
||||
requestInit: {
|
||||
headers: [
|
||||
['Authorization', 'Bearer stale-1'],
|
||||
['Authorization', 'Bearer stale-2'],
|
||||
['x-multi', 'a'],
|
||||
['x-multi', 'b']
|
||||
]
|
||||
},
|
||||
authProvider: mockAuthProvider
|
||||
});
|
||||
|
||||
let actualReqInit: RequestInit = {};
|
||||
(globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => {
|
||||
actualReqInit = reqInit;
|
||||
return new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } });
|
||||
});
|
||||
|
||||
await transport.start();
|
||||
await transport['_startOrAuthSse']({});
|
||||
expect((actualReqInit.headers as Headers).get('authorization')).toBe('Bearer oauth-access-token');
|
||||
expect((actualReqInit.headers as Headers).get('x-multi')).toBe('a, b');
|
||||
|
||||
await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
|
||||
expect((actualReqInit.headers as Headers).get('authorization')).toBe('Bearer oauth-access-token');
|
||||
expect((actualReqInit.headers as Headers).get('x-multi')).toBe('a, b');
|
||||
});
|
||||
|
||||
it('tolerates requestInit.headers set to null by a JavaScript caller', async () => {
|
||||
// The TS type excludes null, but a JS caller or a JSON config forwarded verbatim can
|
||||
// pass it. `new Headers(null)` throws, so the transport must map falsy to undefined.
|
||||
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
|
||||
requestInit: { headers: null as unknown as RequestInit['headers'] }
|
||||
});
|
||||
|
||||
let actualReqInit: RequestInit = {};
|
||||
(globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => {
|
||||
actualReqInit = reqInit;
|
||||
return new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } });
|
||||
});
|
||||
|
||||
await transport.start();
|
||||
await transport['_startOrAuthSse']({});
|
||||
expect(actualReqInit.headers).toBeInstanceOf(Headers);
|
||||
expect((actualReqInit.headers as Headers).get('accept')).toContain('text/event-stream');
|
||||
|
||||
await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage);
|
||||
expect(actualReqInit.headers).toBeInstanceOf(Headers);
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should always send specified custom headers (Headers class)', async () => {
|
||||
const requestInit = {
|
||||
headers: new Headers({
|
||||
|
|
|
|||
|
|
@ -2917,6 +2917,13 @@ export const REQUIREMENTS: Record<string, Requirement> = {
|
|||
transports: ['streamableHttp'],
|
||||
note: "This exercises the HTTP client transport's auth hook; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs."
|
||||
},
|
||||
'client-auth:authprovider:token-overrides-requestinit': {
|
||||
source: 'sdk',
|
||||
behavior:
|
||||
'When an AuthProvider yields a token, the Authorization header the transport derives from it takes precedence over a same-named header configured in requestInit.headers on every HTTP request, so a stale static credential falls back to the provider token; other configured headers still pass through.',
|
||||
transports: ['streamableHttp'],
|
||||
note: "This exercises the HTTP client transport's header merge order (#2208); the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs."
|
||||
},
|
||||
'client-auth:authprovider:onunauthorized-retry': {
|
||||
source: 'sdk',
|
||||
behavior:
|
||||
|
|
|
|||
|
|
@ -2066,6 +2066,73 @@ verifies('client-auth:authprovider:token-attached', async (_args: TestArgs) => {
|
|||
}
|
||||
});
|
||||
|
||||
verifies('client-auth:authprovider:token-overrides-requestinit', async (_args: TestArgs) => {
|
||||
const TOKEN = 'provider-bearer-token';
|
||||
const STALE = 'stale-configured-token';
|
||||
|
||||
// Minimal AuthProvider: token() only. Mirrors a config that pins a static API key in
|
||||
// requestInit.headers but must defer to the provider once it has a token (#2208).
|
||||
const authProvider: AuthProvider = {
|
||||
token: async () => TOKEN
|
||||
};
|
||||
|
||||
const seenByServer: Array<{ authorization: string | null; custom: string | null }> = [];
|
||||
const mcpHost = hostPerSession(() => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('probe', { inputSchema: z.object({}) }, (_a, ctx) => {
|
||||
seenByServer.push({
|
||||
authorization: ctx.http?.req?.headers.get('authorization') ?? null,
|
||||
custom: ctx.http?.req?.headers.get('x-caller-header') ?? null
|
||||
});
|
||||
return { content: [{ type: 'text', text: 'ok' }] };
|
||||
});
|
||||
return s;
|
||||
});
|
||||
|
||||
const requests: Array<{ method: string; authorization: string | null; custom: string | null }> = [];
|
||||
const recordingFetch = async (url: URL | string, init?: RequestInit) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
requests.push({
|
||||
method: init?.method ?? 'GET',
|
||||
authorization: headers.get('authorization'),
|
||||
custom: headers.get('x-caller-header')
|
||||
});
|
||||
return mcpHost.handleRequest(new Request(url, init));
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), {
|
||||
authProvider,
|
||||
fetch: recordingFetch,
|
||||
requestInit: { headers: { Authorization: `Bearer ${STALE}`, 'X-Caller-Header': 'preserved' } }
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const result = await client.callTool({ name: 'probe', arguments: {} });
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'ok' }]);
|
||||
|
||||
// The standalone SSE GET is opened fire-and-forget after initialize; wait for it so it is checked too.
|
||||
await vi.waitFor(() => expect(requests.some(r => r.method === 'GET')).toBe(true));
|
||||
|
||||
// Exactly three POSTs (initialize, notifications/initialized, tools/call) plus the standalone SSE GET,
|
||||
// every one carrying the provider token rather than the configured placeholder, with the other
|
||||
// configured header passing through untouched.
|
||||
expect(requests.filter(r => r.method === 'POST')).toHaveLength(3);
|
||||
expect(requests.filter(r => r.method === 'GET')).toHaveLength(1);
|
||||
for (const req of requests) {
|
||||
expect(req.authorization).toBe(`Bearer ${TOKEN}`);
|
||||
expect(req.custom).toBe('preserved');
|
||||
}
|
||||
|
||||
// The provider token, not the placeholder, is what reached the server on tools/call.
|
||||
expect(seenByServer).toEqual([{ authorization: `Bearer ${TOKEN}`, custom: 'preserved' }]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await mcpHost.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('client-auth:authprovider:onunauthorized-retry', async (_args: TestArgs) => {
|
||||
const STALE = 'stale-bearer-token';
|
||||
const FRESH = 'fresh-bearer-token';
|
||||
|
|
|
|||
Loading…
Reference in a new issue