Skip to content

Commit 1141b93

Browse files
vercel-ai-sdk[bot]jaydeep-pipaliyaaayush-kapoor
authored
Backport: feat(mcp): allow custom fetch for HTTP and SSE transports (#14159)
This is an automated backport of #14134 to the release-v6.0 branch. FYI @jaydeep-pipaliya This backport has conflicts that need to be resolved manually. ### `git cherry-pick` output ``` Auto-merging packages/mcp/src/tool/mcp-http-transport.test.ts Auto-merging packages/mcp/src/tool/mcp-http-transport.ts CONFLICT (content): Merge conflict in packages/mcp/src/tool/mcp-http-transport.ts Auto-merging packages/mcp/src/tool/mcp-sse-transport.test.ts Auto-merging packages/mcp/src/tool/mcp-sse-transport.ts CONFLICT (content): Merge conflict in packages/mcp/src/tool/mcp-sse-transport.ts Auto-merging packages/mcp/src/tool/mcp-transport.ts error: could not apply a00d1d3... feat(mcp): allow custom fetch for HTTP and SSE transports (#14134) hint: After resolving the conflicts, mark them with hint: "git add/rm <pathspec>", then run hint: "git cherry-pick --continue". hint: You can instead skip this commit with "git cherry-pick --skip". hint: To abort and get back to the state before "git cherry-pick", hint: run "git cherry-pick --abort". hint: Disable this message with "git config set advice.mergeConflict false" ``` --------- Co-authored-by: Jaydeep pipaliya <71074587+jaydeep-pipaliya@users.noreply.github.com> Co-authored-by: Aayush Kapoor <aayushkapoor34@gmail.com>
1 parent e923a24 commit 1141b93

6 files changed

Lines changed: 161 additions & 5 deletions

File tree

.changeset/friendly-mugs-smell.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ai-sdk/mcp": patch
3+
---
4+
5+
feat(mcp): allow custom fetch for HTTP and SSE transports

packages/mcp/src/tool/mcp-http-transport.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,4 +427,62 @@ describe('HttpMCPTransport', () => {
427427
fetchSpy.mockRestore();
428428
});
429429
});
430+
431+
describe('custom fetch', () => {
432+
it('should use provided fetch function instead of globalThis.fetch', async () => {
433+
const customFetch = vi.fn(globalThis.fetch);
434+
435+
transport = new HttpMCPTransport({
436+
url: 'http://localhost:4000/mcp',
437+
fetch: customFetch,
438+
});
439+
440+
server.urls['http://localhost:4000/mcp'].response = {
441+
type: 'error',
442+
status: 405,
443+
};
444+
445+
await transport.start();
446+
447+
await vi.waitFor(() => {
448+
expect(customFetch).toHaveBeenCalled();
449+
});
450+
451+
expect(customFetch).toHaveBeenCalledWith(
452+
'http://localhost:4000/mcp',
453+
expect.objectContaining({ method: 'GET' }),
454+
);
455+
});
456+
457+
it('should use provided fetch function for POST send()', async () => {
458+
const customFetch = vi.fn(globalThis.fetch);
459+
460+
transport = new HttpMCPTransport({
461+
url: 'http://localhost:4000/mcp',
462+
fetch: customFetch,
463+
});
464+
465+
server.urls['http://localhost:4000/mcp'].response = {
466+
type: 'json-value',
467+
body: { jsonrpc: '2.0', id: 1, result: { ok: true } },
468+
headers: { 'mcp-session-id': 'abc123' },
469+
};
470+
471+
await transport.start();
472+
473+
const message = {
474+
jsonrpc: '2.0' as const,
475+
method: 'initialize',
476+
id: 1,
477+
params: {},
478+
};
479+
480+
await transport.send(message);
481+
482+
expect(customFetch).toHaveBeenCalledWith(
483+
'http://localhost:4000/mcp',
484+
expect.objectContaining({ method: 'POST' }),
485+
);
486+
});
487+
});
430488
});

packages/mcp/src/tool/mcp-http-transport.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
EventSourceParserStream,
3+
FetchFunction,
34
withUserAgentSuffix,
45
getRuntimeEnvironmentUserAgent,
56
} from '@ai-sdk/provider-utils';
@@ -31,6 +32,7 @@ export class HttpMCPTransport implements MCPTransport {
3132
private sessionId?: string;
3233
private inboundSseConnection?: { close: () => void };
3334
private redirectMode: RequestRedirect;
35+
private fetchFn: FetchFunction;
3436

3537
// Inbound SSE resumption and reconnection state
3638
private lastInboundEventId?: string;
@@ -51,16 +53,19 @@ export class HttpMCPTransport implements MCPTransport {
5153
headers,
5254
authProvider,
5355
redirect = 'follow',
56+
fetch: fetchFn,
5457
}: {
5558
url: string;
5659
headers?: Record<string, string>;
5760
authProvider?: OAuthClientProvider;
5861
redirect?: 'follow' | 'error';
62+
fetch?: FetchFunction;
5963
}) {
6064
this.url = new URL(url);
6165
this.headers = headers;
6266
this.authProvider = authProvider;
6367
this.redirectMode = redirect;
68+
this.fetchFn = fetchFn ?? globalThis.fetch;
6469
}
6570

6671
private async commonHeaders(
@@ -111,7 +116,7 @@ export class HttpMCPTransport implements MCPTransport {
111116
!this.abortController.signal.aborted
112117
) {
113118
const headers = await this.commonHeaders({});
114-
await fetch(this.url, {
119+
await this.fetchFn(this.url.href, {
115120
method: 'DELETE',
116121
headers,
117122
signal: this.abortController.signal,
@@ -140,7 +145,7 @@ export class HttpMCPTransport implements MCPTransport {
140145
redirect: this.redirectMode,
141146
} satisfies RequestInit;
142147

143-
const response = await fetch(this.url, init);
148+
const response = await this.fetchFn(this.url.href, init);
144149

145150
const sessionId = response.headers.get('mcp-session-id');
146151
if (sessionId) {
@@ -153,6 +158,7 @@ export class HttpMCPTransport implements MCPTransport {
153158
const result = await auth(this.authProvider, {
154159
serverUrl: this.url,
155160
resourceMetadataUrl: this.resourceMetadataUrl,
161+
fetchFn: this.fetchFn,
156162
});
157163
if (result !== 'AUTHORIZED') {
158164
const error = new UnauthorizedError();
@@ -314,7 +320,7 @@ export class HttpMCPTransport implements MCPTransport {
314320
headers['last-event-id'] = resumeToken;
315321
}
316322

317-
const response = await fetch(this.url.href, {
323+
const response = await this.fetchFn(this.url.href, {
318324
method: 'GET',
319325
headers,
320326
signal: this.abortController?.signal,
@@ -332,6 +338,7 @@ export class HttpMCPTransport implements MCPTransport {
332338
const result = await auth(this.authProvider, {
333339
serverUrl: this.url,
334340
resourceMetadataUrl: this.resourceMetadataUrl,
341+
fetchFn: this.fetchFn,
335342
});
336343
if (result !== 'AUTHORIZED') {
337344
const error = new UnauthorizedError();

packages/mcp/src/tool/mcp-sse-transport.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,4 +381,75 @@ describe('SseMCPTransport', () => {
381381
fetchSpy.mockRestore();
382382
});
383383
});
384+
385+
describe('custom fetch', () => {
386+
it('should use provided fetch function for SSE connection', async () => {
387+
const controller = new TestResponseController();
388+
server.urls['http://localhost:3000/sse'].response = {
389+
type: 'controlled-stream',
390+
controller,
391+
};
392+
393+
const customFetch = vi.fn(globalThis.fetch);
394+
395+
transport = new SseMCPTransport({
396+
url: 'http://localhost:3000/sse',
397+
fetch: customFetch,
398+
});
399+
400+
const connectPromise = transport.start();
401+
controller.write(
402+
'event: endpoint\ndata: http://localhost:3000/messages\n\n',
403+
);
404+
await connectPromise;
405+
406+
expect(customFetch).toHaveBeenCalledWith(
407+
'http://localhost:3000/sse',
408+
expect.objectContaining({ headers: expect.anything() }),
409+
);
410+
411+
await transport.close();
412+
});
413+
414+
it('should use provided fetch function for POST send()', async () => {
415+
const controller = new TestResponseController();
416+
server.urls['http://localhost:3000/sse'].response = {
417+
type: 'controlled-stream',
418+
controller,
419+
};
420+
server.urls['http://localhost:3000/messages'].response = {
421+
type: 'empty',
422+
status: 200,
423+
};
424+
425+
const customFetch = vi.fn(globalThis.fetch);
426+
427+
transport = new SseMCPTransport({
428+
url: 'http://localhost:3000/sse',
429+
fetch: customFetch,
430+
});
431+
432+
const connectPromise = transport.start();
433+
controller.write(
434+
'event: endpoint\ndata: http://localhost:3000/messages\n\n',
435+
);
436+
await connectPromise;
437+
438+
customFetch.mockClear();
439+
440+
await transport.send({
441+
jsonrpc: '2.0' as const,
442+
method: 'test',
443+
params: {},
444+
id: '1',
445+
});
446+
447+
expect(customFetch).toHaveBeenCalledWith(
448+
'http://localhost:3000/messages',
449+
expect.objectContaining({ method: 'POST' }),
450+
);
451+
452+
await transport.close();
453+
});
454+
});
384455
});

packages/mcp/src/tool/mcp-sse-transport.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
EventSourceParserStream,
3+
FetchFunction,
34
withUserAgentSuffix,
45
getRuntimeEnvironmentUserAgent,
56
} from '@ai-sdk/provider-utils';
@@ -27,6 +28,7 @@ export class SseMCPTransport implements MCPTransport {
2728
private authProvider?: OAuthClientProvider;
2829
private resourceMetadataUrl?: URL;
2930
private redirectMode: RequestRedirect;
31+
private fetchFn: FetchFunction;
3032

3133
onclose?: () => void;
3234
onerror?: (error: unknown) => void;
@@ -37,16 +39,19 @@ export class SseMCPTransport implements MCPTransport {
3739
headers,
3840
authProvider,
3941
redirect = 'follow',
42+
fetch: fetchFn,
4043
}: {
4144
url: string;
4245
headers?: Record<string, string>;
4346
authProvider?: OAuthClientProvider;
4447
redirect?: 'follow' | 'error';
48+
fetch?: FetchFunction;
4549
}) {
4650
this.url = new URL(url);
4751
this.headers = headers;
4852
this.authProvider = authProvider;
4953
this.redirectMode = redirect;
54+
this.fetchFn = fetchFn ?? globalThis.fetch;
5055
}
5156

5257
private async commonHeaders(
@@ -85,7 +90,7 @@ export class SseMCPTransport implements MCPTransport {
8590
const headers = await this.commonHeaders({
8691
Accept: 'text/event-stream',
8792
});
88-
const response = await fetch(this.url.href, {
93+
const response = await this.fetchFn(this.url.href, {
8994
headers,
9095
signal: this.abortController?.signal,
9196
redirect: this.redirectMode,
@@ -97,6 +102,7 @@ export class SseMCPTransport implements MCPTransport {
97102
const result = await auth(this.authProvider, {
98103
serverUrl: this.url,
99104
resourceMetadataUrl: this.resourceMetadataUrl,
105+
fetchFn: this.fetchFn,
100106
});
101107
if (result !== 'AUTHORIZED') {
102108
const error = new UnauthorizedError();
@@ -235,14 +241,15 @@ export class SseMCPTransport implements MCPTransport {
235241
redirect: this.redirectMode,
236242
};
237243

238-
const response = await fetch(endpoint, init);
244+
const response = await this.fetchFn(endpoint.href, init);
239245

240246
if (response.status === 401 && this.authProvider && !triedAuth) {
241247
this.resourceMetadataUrl = extractResourceMetadataUrl(response);
242248
try {
243249
const result = await auth(this.authProvider, {
244250
serverUrl: this.url,
245251
resourceMetadataUrl: this.resourceMetadataUrl,
252+
fetchFn: this.fetchFn,
246253
});
247254
if (result !== 'AUTHORIZED') {
248255
const error = new UnauthorizedError();

packages/mcp/src/tool/mcp-transport.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { FetchFunction } from '@ai-sdk/provider-utils';
12
import { MCPClientError } from '../error/mcp-client-error';
23
import { JSONRPCMessage } from './json-rpc-message';
34
import { SseMCPTransport } from './mcp-sse-transport';
@@ -66,6 +67,13 @@ export type MCPTransportConfig = {
6667
* @default 'follow'
6768
*/
6869
redirect?: 'follow' | 'error';
70+
71+
/**
72+
* Optional custom fetch implementation to use for HTTP requests.
73+
* Useful for runtimes that need a request-local fetch.
74+
* @default globalThis.fetch
75+
*/
76+
fetch?: FetchFunction;
6977
};
7078

7179
export function createMcpTransport(config: MCPTransportConfig): MCPTransport {

0 commit comments

Comments
 (0)