@ai-sdk/mcp
Version:
690 lines (618 loc) • 20.7 kB
text/typescript
import {
EventSourceParserStream,
withUserAgentSuffix,
getRuntimeEnvironmentUserAgent,
type FetchFunction,
} from '@ai-sdk/provider-utils';
import { MCPClientError } from '../error/mcp-client-error';
import {
parseJSONRPCMessage,
validateJSONRPCMessage,
type JSONRPCMessage,
} from './json-rpc-message';
import type { MCPTransport, MCPTransportSendOptions } from './mcp-transport';
import { VERSION } from '../version';
import {
extractWWWAuthenticateParams,
UnauthorizedError,
auth,
type AuthResult,
type OAuthClientProvider,
} from './oauth';
import {
LATEST_LEGACY_PROTOCOL_VERSION,
LATEST_PROTOCOL_VERSION,
} from './types';
import { encodeMCPHeaderValue } from './mcp-http-headers';
function isMessageEvent(event: string | undefined): boolean {
return event === undefined || event === 'message';
}
/**
* HTTP MCP transport implementing the Streamable HTTP style.
*
* Client transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification.
* It will connect to a server using HTTP POST for sending messages and HTTP GET with Server-Sent Events
* for receiving messages.
*/
export class HttpMCPTransport implements MCPTransport {
readonly supportsProtocolVersionDiscovery = true;
readonly waitsForProtocolVersionDiscovery = true;
readonly supportsMcpToolParameterHeaders = true;
private url: URL;
private abortController?: AbortController;
private headers?: Record<string, string>;
private authProvider?: OAuthClientProvider;
private resourceMetadataUrl?: URL;
private sessionId?: string;
private inboundSseConnection?: { close: () => void };
private redirectMode: RequestRedirect;
private fetchFn: FetchFunction;
private customFetch?: FetchFunction;
private authPromise?: Promise<AuthResult>;
private onSessionIdChange?: (sessionId: string | undefined) => void;
private onSessionExpired?: (sessionId: string) => void;
private terminateSessionOnClose: boolean;
// Inbound SSE resumption and reconnection state
private lastInboundEventId?: string;
private inboundReconnectAttempts = 0;
private readonly reconnectionOptions = {
initialReconnectionDelay: 1000,
maxReconnectionDelay: 30000,
reconnectionDelayGrowFactor: 1.5,
maxRetries: 2,
} as const;
onclose?: () => void;
onerror?: (error: unknown) => void;
onmessage?: (message: JSONRPCMessage) => void;
protocolVersion?: string;
constructor({
url,
headers,
authProvider,
redirect = 'error',
initialSessionId,
initialProtocolVersion,
onSessionIdChange,
onSessionExpired,
terminateSessionOnClose = true,
fetch: fetchFn,
}: {
url: string;
headers?: Record<string, string>;
authProvider?: OAuthClientProvider;
redirect?: 'follow' | 'error';
initialSessionId?: string;
initialProtocolVersion?: string;
onSessionIdChange?: (sessionId: string | undefined) => void;
onSessionExpired?: (sessionId: string) => void;
terminateSessionOnClose?: boolean;
fetch?: FetchFunction;
}) {
this.url = new URL(url);
this.headers = headers;
this.authProvider = authProvider;
this.redirectMode = redirect;
this.sessionId = initialSessionId;
this.protocolVersion =
initialProtocolVersion ?? LATEST_LEGACY_PROTOCOL_VERSION;
this.onSessionIdChange = onSessionIdChange;
this.onSessionExpired = onSessionExpired;
this.terminateSessionOnClose = terminateSessionOnClose;
this.fetchFn = fetchFn ?? globalThis.fetch.bind(globalThis);
this.customFetch = fetchFn;
}
setProtocolVersion(version: string): void {
this.protocolVersion = version;
if (!this.abortController) {
return;
}
if (this.isModernProtocol()) {
this.inboundSseConnection?.close();
this.inboundSseConnection = undefined;
return;
}
if (!this.inboundSseConnection) {
this.startInboundSse();
}
}
private isModernProtocol(): boolean {
return (
(this.protocolVersion ?? LATEST_PROTOCOL_VERSION) ===
LATEST_PROTOCOL_VERSION
);
}
private async commonHeaders({
base,
includeSessionId = true,
}: {
base: Record<string, string>;
includeSessionId?: boolean;
}): Promise<Record<string, string>> {
const headers: Record<string, string> = {
...this.headers,
...base,
'mcp-protocol-version':
this.protocolVersion ?? LATEST_LEGACY_PROTOCOL_VERSION,
};
if (!this.isModernProtocol() && includeSessionId && this.sessionId) {
headers['mcp-session-id'] = this.sessionId;
}
if (this.authProvider) {
const tokens = await this.authProvider.tokens();
if (tokens?.access_token) {
headers['Authorization'] = `Bearer ${tokens.access_token}`;
}
}
return withUserAgentSuffix(
headers,
`ai-sdk/${VERSION}`,
getRuntimeEnvironmentUserAgent(),
);
}
private setSessionId(sessionId: string | undefined): void {
if (this.sessionId === sessionId) {
return;
}
this.sessionId = sessionId;
this.onSessionIdChange?.(sessionId);
}
private applySessionIdFromResponse(response: Response): void {
if (this.isModernProtocol()) {
return;
}
const sessionId = response.headers.get('mcp-session-id');
if (sessionId) {
this.setSessionId(sessionId);
}
}
private expireSessionId(sessionId: string): void {
if (this.sessionId === sessionId) {
this.setSessionId(undefined);
}
this.onSessionExpired?.(sessionId);
}
/**
* Runs a single OAuth recovery flow for concurrent 401 responses.
*/
private authorizeOnce(
resourceMetadataUrl?: URL,
scope?: string,
): Promise<AuthResult> {
if (!this.authProvider) {
return Promise.resolve('REDIRECT');
}
if (!this.authPromise) {
this.authPromise = auth(this.authProvider, {
serverUrl: this.url,
resourceMetadataUrl,
scope,
fetchFn: this.customFetch,
}).finally(() => {
this.authPromise = undefined;
});
}
return this.authPromise;
}
async start(): Promise<void> {
if (this.abortController) {
throw new MCPClientError({
message:
'MCP HTTP Transport Error: Transport already started. Note: client.connect() calls start() automatically.',
});
}
this.abortController = new AbortController();
if (
this.protocolVersion != null &&
this.protocolVersion !== LATEST_PROTOCOL_VERSION
) {
this.startInboundSse();
}
}
async close(options?: { signal?: AbortSignal }): Promise<void> {
this.inboundSseConnection?.close();
this.abortController?.abort();
try {
if (
!this.isModernProtocol() &&
this.sessionId &&
this.terminateSessionOnClose &&
this.abortController
) {
options?.signal?.throwIfAborted();
const headers = await this.commonHeaders({ base: {} });
options?.signal?.throwIfAborted();
await this.fetchFn(this.url.href, {
method: 'DELETE',
headers,
signal: options?.signal,
redirect: this.redirectMode,
}).catch(() => undefined);
}
} catch {}
this.onclose?.();
}
async send(
message: JSONRPCMessage,
options?: MCPTransportSendOptions,
): Promise<void> {
options?.signal?.throwIfAborted();
const transportSignal = this.abortController?.signal;
const requestSignal =
options?.signal == null
? transportSignal
: transportSignal == null
? options.signal
: AbortSignal.any([transportSignal, options.signal]);
const attempt = async (triedAuth: boolean = false): Promise<void> => {
try {
const isInitializeRequest =
'method' in message && message.method === 'initialize';
const sessionIdForRequest = isInitializeRequest
? undefined
: this.sessionId;
const headers = await this.commonHeaders({
base: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
...(this.isModernProtocol() ? options?.headers : {}),
...(this.isModernProtocol() &&
'method' in message &&
'id' in message
? this.getStandardRequestHeaders(message)
: {}),
},
includeSessionId: !isInitializeRequest,
});
const init = {
method: 'POST',
headers,
body: JSON.stringify(message),
signal: requestSignal,
redirect: this.redirectMode,
} satisfies RequestInit;
const response = await this.fetchFn(this.url.href, init);
this.applySessionIdFromResponse(response);
if (response.status === 401 && this.authProvider && !triedAuth) {
const { resourceMetadataUrl, scope } =
extractWWWAuthenticateParams(response);
this.resourceMetadataUrl = resourceMetadataUrl;
try {
const result = await this.authorizeOnce(
this.resourceMetadataUrl,
scope,
);
if (result !== 'AUTHORIZED') {
const error = new UnauthorizedError();
throw error;
}
} catch (error) {
this.onerror?.(error);
throw error;
}
return attempt(true);
}
// If server accepted the message (e.g. initialized notification), optionally (re)start inbound SSE
if (response.status === 202) {
// If inbound SSE was not available earlier (e.g. 405 before init), try again now
// Do not await to avoid blocking send()
if (!this.isModernProtocol() && !this.inboundSseConnection) {
this.startInboundSse();
}
return;
}
if (!response.ok) {
const text = await response.text().catch(() => null);
if ('id' in message && text != null) {
const jsonRpcMessage = await parseJSONRPCMessage(text).catch(
() => undefined,
);
if (jsonRpcMessage != null && 'error' in jsonRpcMessage) {
this.onmessage?.(
jsonRpcMessage.id == null
? { ...jsonRpcMessage, id: message.id }
: jsonRpcMessage,
);
return;
}
}
let errorMessage = `MCP HTTP Transport Error: POSTing to endpoint (HTTP ${response.status}): ${text}`;
if (response.status === 404) {
if (!this.isModernProtocol() && sessionIdForRequest) {
this.expireSessionId(sessionIdForRequest);
errorMessage +=
'. The MCP session expired. Create a new client without `initialSessionId` to start a fresh session';
} else if (!this.isModernProtocol()) {
errorMessage +=
'. This server does not support HTTP transport. Try using `sse` transport instead';
}
}
const error = new MCPClientError({
message: errorMessage,
statusCode: response.status,
url: this.url.href,
responseBody: text ?? undefined,
});
this.onerror?.(error);
throw error;
}
// Notifications (messages without 'id') don't expect a JSON-RPC response
// Some servers return 200 with acknowledgment JSON instead of 202
const isNotification = !('id' in message);
if (isNotification) {
return;
}
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const data = await response.json();
const messages: JSONRPCMessage[] = Array.isArray(data)
? data.map((message: unknown) => validateJSONRPCMessage(message))
: [validateJSONRPCMessage(data)];
for (const jsonRpcMessage of messages) {
this.onmessage?.(jsonRpcMessage);
}
return;
}
if (contentType.includes('text/event-stream')) {
if (!response.body) {
const error = new MCPClientError({
message:
'MCP HTTP Transport Error: text/event-stream response without body',
statusCode: response.status,
url: this.url.href,
});
this.onerror?.(error);
throw error;
}
const stream = response.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(new EventSourceParserStream());
const reader = stream.getReader();
const processEvents = async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) return;
const { event, data } = value;
if (isMessageEvent(event)) {
try {
const jsonRpcMessage = await parseJSONRPCMessage(data);
this.onmessage?.(jsonRpcMessage);
} catch (error) {
const e = new MCPClientError({
message:
'MCP HTTP Transport Error: Failed to parse message',
cause: error,
});
this.onerror?.(e);
}
}
}
} catch (error) {
if (
options?.signal?.aborted ||
(error instanceof Error && error.name === 'AbortError')
) {
return;
}
this.onerror?.(error);
}
};
void processEvents().catch(error => {
if (
options?.signal?.aborted ||
(error instanceof Error && error.name === 'AbortError')
) {
return;
}
this.onerror?.(error);
});
return;
}
const error = new MCPClientError({
message: `MCP HTTP Transport Error: Unexpected content type: ${contentType}`,
statusCode: response.status,
url: this.url.href,
});
this.onerror?.(error);
throw error;
} catch (error) {
if (options?.signal?.aborted) {
throw error;
}
this.onerror?.(error);
throw error;
}
};
await attempt();
}
private getStandardRequestHeaders(
message: Extract<JSONRPCMessage, { method: string; id: unknown }>,
): Record<string, string> {
const headers: Record<string, string> = {
'Mcp-Method': message.method,
};
const params = message.params;
const name =
message.method === 'resources/read'
? params?.uri
: message.method === 'tools/call' || message.method === 'prompts/get'
? params?.name
: undefined;
if (typeof name === 'string') {
headers['Mcp-Name'] = encodeMCPHeaderValue(name);
}
return headers;
}
private getNextReconnectionDelay(attempt: number): number {
const {
initialReconnectionDelay,
reconnectionDelayGrowFactor,
maxReconnectionDelay,
} = this.reconnectionOptions;
return Math.min(
initialReconnectionDelay * Math.pow(reconnectionDelayGrowFactor, attempt),
maxReconnectionDelay,
);
}
private scheduleInboundSseReconnection(): void {
const { maxRetries } = this.reconnectionOptions;
if (maxRetries > 0 && this.inboundReconnectAttempts >= maxRetries) {
this.onerror?.(
new MCPClientError({
message: `MCP HTTP Transport Error: Maximum reconnection attempts (${maxRetries}) exceeded.`,
}),
);
return;
}
const delay = this.getNextReconnectionDelay(this.inboundReconnectAttempts);
this.inboundReconnectAttempts += 1;
setTimeout(() => {
if (this.abortController?.signal.aborted) return;
this.startInboundSse(false, this.lastInboundEventId);
}, delay);
}
private startInboundSse(
triedAuth: boolean = false,
resumeToken?: string,
): void {
if (this.isModernProtocol()) {
return;
}
void this.openInboundSse(triedAuth, resumeToken).catch(error => {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
this.onerror?.(error);
});
}
// Open optional inbound SSE stream; best-effort and resumable
private async openInboundSse(
triedAuth: boolean = false,
resumeToken?: string,
): Promise<void> {
if (this.isModernProtocol()) {
return;
}
try {
const sessionIdForRequest = this.sessionId;
const headers = await this.commonHeaders({
base: {
Accept: 'text/event-stream',
},
});
if (resumeToken) {
headers['last-event-id'] = resumeToken;
}
const response = await this.fetchFn(this.url.href, {
method: 'GET',
headers,
signal: this.abortController?.signal,
redirect: this.redirectMode,
});
this.applySessionIdFromResponse(response);
if (response.status === 401 && this.authProvider && !triedAuth) {
const { resourceMetadataUrl, scope } =
extractWWWAuthenticateParams(response);
this.resourceMetadataUrl = resourceMetadataUrl;
try {
const result = await this.authorizeOnce(
this.resourceMetadataUrl,
scope,
);
if (result !== 'AUTHORIZED') {
const error = new UnauthorizedError();
this.onerror?.(error);
return;
}
} catch (error) {
this.onerror?.(error);
return;
}
return this.openInboundSse(true, resumeToken);
}
if (response.status === 405) {
return;
}
if (!response.ok || !response.body) {
if (response.status === 404 && sessionIdForRequest) {
this.expireSessionId(sessionIdForRequest);
}
const error = new MCPClientError({
message: `MCP HTTP Transport Error: GET SSE failed: ${response.status} ${response.statusText}`,
statusCode: response.status,
url: this.url.href,
});
this.onerror?.(error);
return;
}
const stream = response.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(new EventSourceParserStream());
const reader = stream.getReader();
const processEvents = async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) return;
const { event, data, id } = value as {
event?: string;
data: string;
id?: string;
};
if (id) {
this.lastInboundEventId = id;
}
if (isMessageEvent(event)) {
try {
const jsonRpcMessage = await parseJSONRPCMessage(data);
this.onmessage?.(jsonRpcMessage);
} catch (error) {
const e = new MCPClientError({
message: 'MCP HTTP Transport Error: Failed to parse message',
cause: error,
});
this.onerror?.(e);
}
}
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
this.onerror?.(error);
if (!this.abortController?.signal.aborted) {
this.scheduleInboundSseReconnection();
}
}
};
this.inboundSseConnection = {
close: () => {
void reader.cancel().catch(error => {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
this.onerror?.(error);
});
},
};
this.inboundReconnectAttempts = 0;
void processEvents().catch(error => {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
this.onerror?.(error);
if (!this.abortController?.signal.aborted) {
this.scheduleInboundSseReconnection();
}
});
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
this.onerror?.(error);
if (!this.abortController?.signal.aborted) {
this.scheduleInboundSseReconnection();
}
}
}
}