UNPKG

@openai/agents-core

Version:

The OpenAI Agents SDK is a lightweight yet powerful framework for building multi-agent workflows.

1,137 lines (1,136 loc) • 49.5 kB
import { DEFAULT_REQUEST_TIMEOUT_MSEC, ProtocolError, ProtocolErrorCode, SdkError, SdkErrorCode, } from '@modelcontextprotocol/client'; import { BaseMCPServerStdio, BaseMCPServerStreamableHttp, BaseMCPServerSSE, attachCallToolResultMetadata, } from "../../mcpShared.mjs"; import { UserError } from "../../errors.mjs"; import { invalidateServerToolsCache } from "../../mcpToolCache.mjs"; import logger, { getSafeErrorType, logToolActionError, logToolActionWarning, } from "../../logger.mjs"; import { sanitizeMcpTransportError } from "../../mcpLogging.mjs"; import { combineAbortSignals } from "../../utils/abortSignals.mjs"; function failedToImport(error) { logger.error(` Failed to load the MCP SDK. Please install the @modelcontextprotocol/client package. npm install @modelcontextprotocol/client `.trim()); throw error; } function attachParsedCallToolResultMetadata(result) { result.content = attachCallToolResultMetadata(result.content, { _meta: result._meta, structuredContent: result.structuredContent, isError: result.isError, }); return result; } function buildRequestOptions(clientSessionTimeoutSeconds, overrides) { const baseOptions = clientSessionTimeoutSeconds === undefined ? undefined : { timeout: clientSessionTimeoutSeconds * 1000 }; const mergedOptions = { ...(baseOptions ?? {}), ...(overrides ?? {}) }; return Object.keys(mergedOptions).length === 0 ? undefined : mergedOptions; } function buildCacheableRequestOptions(clientSessionTimeoutSeconds, cacheMode = 'refresh') { return { ...buildRequestOptions(clientSessionTimeoutSeconds), cacheMode, }; } function validateMaxListPages(value) { if (value !== undefined && (!Number.isInteger(value) || value < 1)) { throw new UserError('maxListPages must be a positive integer.'); } return value ?? 64; } class ToolListPageLimitError extends UserError { constructor() { super('MCP tool listing exceeded maxListPages. Increase maxListPages or check the server pagination.'); } } async function listAllMcpTools(client, requestOptions, maxListPages) { const tools = []; const seenCursors = new Set(); let cursor; let pages = 0; while (true) { let response; try { response = (await client.request({ method: 'tools/list', ...(cursor === undefined ? {} : { params: { cursor } }), }, requestOptions)); } catch (error) { if (cursor === undefined) { throw error; } throw new Error('MCP tool listing failed while fetching a continuation page.'); } pages += 1; tools.push(...response.tools); const nextCursor = response.nextCursor; if (nextCursor === undefined) { return { tools }; } if (seenCursors.has(nextCursor)) { throw new Error('MCP server returned a repeated cursor while listing tools.'); } if (maxListPages !== undefined && pages >= maxListPages) { throw new ToolListPageLimitError(); } seenCursors.add(nextCursor); cursor = nextCursor; } } async function listMcpTools(client, requestOptions, maxListPages) { if (getNegotiatedModernCapabilities(client) !== undefined) { try { const response = await client.listTools(undefined, { ...requestOptions, cacheMode: 'bypass', }); return { tools: response.tools }; } catch (error) { if (error instanceof SdkError && error.code === SdkErrorCode.ListPaginationExceeded) { throw new ToolListPageLimitError(); } throw error; } } return listAllMcpTools(client, requestOptions, maxListPages); } function getNegotiatedModernCapabilities(client) { const capabilities = client.getServerCapabilities(); return client.getProtocolEra() === 'modern' && capabilities !== undefined ? capabilities : undefined; } function getListedToolForCall(tools, toolName) { const tool = tools.find((tool) => tool.name === toolName); if (tool?.execution?.taskSupport === 'required') { throw new ProtocolError(ProtocolErrorCode.InvalidRequest, `Tool "${toolName}" requires task-based execution. Use client.experimental.tasks.callToolStream() instead.`); } return tool; } function listResourcesPage(client, params, requestOptions) { const capabilities = getNegotiatedModernCapabilities(client); if (capabilities !== undefined && capabilities.resources === undefined) { return Promise.resolve({ resources: [] }); } return client.request({ method: 'resources/list', ...(params === undefined ? {} : { params }), }, requestOptions); } function listResourceTemplatesPage(client, params, requestOptions) { const capabilities = getNegotiatedModernCapabilities(client); if (capabilities !== undefined && capabilities.resources === undefined) { return Promise.resolve({ resourceTemplates: [] }); } return client.request({ method: 'resources/templates/list', ...(params === undefined ? {} : { params }), }, requestOptions); } async function callWithMCPRequestSignal(sourceSignal, call) { const { signal: requestSignal, cleanup } = combineAbortSignals(sourceSignal); try { return await call(requestSignal); } catch (error) { sourceSignal?.throwIfAborted(); throw error; } finally { cleanup(); } } function assertMcpToolListingIsCurrent(args) { if (args.currentClient !== args.listedClient || args.currentGeneration !== args.listedGeneration) { throw new Error('MCP tool listing became stale before it completed.'); } } function hasSessionTransport(transport) { return (transport != null && typeof transport.close === 'function' && (typeof transport.terminateSession === 'function' || transport.sessionId !== undefined)); } function getTransportProtocolVersion(transport) { if (transport != null && typeof transport.protocolVersion === 'string') { return transport.protocolVersion; } return undefined; } function attachCause(error, cause) { if (!(error instanceof Error)) { return error; } try { if (error.cause === undefined) { Object.defineProperty(error, 'cause', { value: cause, configurable: true, enumerable: false, writable: true, }); } } catch { // Best effort only. } return error; } function getSessionId(transport) { return hasSessionTransport(transport) ? transport.sessionId : undefined; } function shouldTerminateTransportSession(transportToClose, activeTransport) { const closingSessionId = getSessionId(transportToClose); if (closingSessionId === undefined) { return false; } const activeSessionId = getSessionId(activeTransport); return activeSessionId === undefined || activeSessionId !== closingSessionId; } function withTimeout(promise, timeoutMs, onTimeout) { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { reject(onTimeout()); }, timeoutMs); void promise.then((value) => { clearTimeout(timeoutId); resolve(value); }, (error) => { clearTimeout(timeoutId); reject(error); }); }); } async function runMcpTransportOperation(endpoint, operation, action, sourceSignal) { try { return await action(); } catch (error) { if (error instanceof ToolListPageLimitError || isCallerAbortReason(error, sourceSignal)) { throw error; } throw sanitizeMcpTransportError(error, endpoint, operation); } } function isCallerAbortReason(error, sourceSignal) { return sourceSignal?.aborted === true && error === sourceSignal.reason; } function logMcpTransportWarning(targetLogger, endpoint, operation, message, error) { const redact = targetLogger.dontLogToolData; const logError = redact ? getSafeErrorType(error) : sanitizeMcpTransportError(error, endpoint, operation); targetLogger.warn(message, logError); } export class NodeMCPServerStdio extends BaseMCPServerStdio { session = null; _cacheDirty = true; _toolsList = []; _toolsCacheGeneration = 0; serverInitializeResult = null; clientSessionTimeoutSeconds; timeout; maxListPages; params; _name; transport = null; constructor(params) { super(params); this.maxListPages = validateMaxListPages(params.maxListPages); this.clientSessionTimeoutSeconds = params.clientSessionTimeoutSeconds ?? 5; this.timeout = params.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; if ('fullCommand' in params) { const elements = params.fullCommand.split(' '); const command = elements.shift(); if (!command) { throw new Error('Invalid fullCommand: ' + params.fullCommand); } this.params = { ...params, command: command, args: elements, encoding: params.encoding || 'utf-8', encodingErrorHandler: params.encodingErrorHandler || 'strict', }; } else { this.params = params; } this._name = params.name || `stdio: ${this.params.command}`; } async connect() { this._toolsCacheGeneration += 1; this._cacheDirty = true; this._toolsList = []; try { const { StdioClientTransport } = await import('@modelcontextprotocol/client/stdio').catch(failedToImport); const { Client } = await import('@modelcontextprotocol/client').catch(failedToImport); this.transport = new StdioClientTransport({ command: this.params.command, args: this.params.args, env: this.params.env, cwd: this.params.cwd, }); this.session = new Client({ name: this._name, version: '1.0.0', // You may want to make this configurable }, { versionNegotiation: { mode: 'auto' }, listMaxPages: this.maxListPages ?? 0, }); const requestOptions = buildRequestOptions(this.clientSessionTimeoutSeconds); await this.session.connect(this.transport, requestOptions); this.serverInitializeResult = { serverInfo: { name: this._name, version: '1.0.0' }, }; } catch (e) { logToolActionError(this.logger, 'Error initializing MCP server:', e); await this.close(); throw e; } this.debugLog(() => `Connected to MCP server: ${this._name}`); } async invalidateToolsCache() { this._toolsCacheGeneration += 1; this._cacheDirty = true; this._toolsList = []; await invalidateServerToolsCache(this.name); } async listTools() { if (!this.session) { throw new Error('Server not initialized. Make sure you call connect() first.'); } if (this.cacheToolsList && !this._cacheDirty && this._toolsList) { return this._toolsList; } const requestOptions = buildRequestOptions(this.clientSessionTimeoutSeconds); const session = this.session; const cacheGeneration = this._toolsCacheGeneration; const response = await listMcpTools(session, requestOptions, this.maxListPages); this.debugLog(() => `Listed tools: ${JSON.stringify(response)}`); assertMcpToolListingIsCurrent({ listedClient: session, currentClient: this.session, listedGeneration: cacheGeneration, currentGeneration: this._toolsCacheGeneration, }); const tools = response.tools; this._toolsList = tools; this._cacheDirty = false; return tools; } async callTool(toolName, args, meta, options) { return (await this.callToolResult(toolName, args, meta, options)).content; } async callToolResult(toolName, args, meta, options) { if (!this.session) { throw new Error('Server not initialized. Make sure you call connect() first.'); } const session = this.session; const params = { name: toolName, arguments: args ?? {}, ...(meta != null ? { _meta: meta } : {}), }; const response = await callWithMCPRequestSignal(options?.signal, (requestSignal) => session.callTool(params, { ...buildRequestOptions(this.clientSessionTimeoutSeconds, { timeout: this.timeout, signal: requestSignal, }), toolDefinition: getListedToolForCall(this._toolsList, toolName), })); const result = attachParsedCallToolResultMetadata(response); this.debugLog(() => `Called tool ${toolName} (args: ${JSON.stringify(args)}, result: ${JSON.stringify(result)})`); return result; } async listResources(params) { if (!this.session) { throw new Error('Server not initialized. Make sure you call connect() first.'); } const requestOptions = buildRequestOptions(this.clientSessionTimeoutSeconds); const response = await listResourcesPage(this.session, params, requestOptions); this.debugLog(() => `Listed resources: ${JSON.stringify(response)}`); return response; } async listResourceTemplates(params) { if (!this.session) { throw new Error('Server not initialized. Make sure you call connect() first.'); } const requestOptions = buildRequestOptions(this.clientSessionTimeoutSeconds); const response = await listResourceTemplatesPage(this.session, params, requestOptions); this.debugLog(() => `Listed resource templates: ${JSON.stringify(response)}`); return response; } async readResource(uri) { if (!this.session) { throw new Error('Server not initialized. Make sure you call connect() first.'); } const requestOptions = buildCacheableRequestOptions(this.clientSessionTimeoutSeconds); const response = await this.session.readResource({ uri }, requestOptions); this.debugLog(() => `Read resource ${uri}: ${JSON.stringify(response)}`); return response; } get name() { return this._name; } async close() { this._toolsCacheGeneration += 1; this._cacheDirty = true; this._toolsList = []; const transport = this.transport; if (transport && typeof transport.terminateSession === 'function') { try { // Best-effort cleanup: we do not actively manage session lifecycles, // but if the server supports sessions we terminate to avoid leaks. await transport.terminateSession(); } catch (error) { logToolActionWarning(this.logger, 'Failed to terminate MCP session:', error); } } if (transport) { await transport.close(); this.transport = null; } if (this.session) { await this.session.close(); this.session = null; } } } export class NodeMCPServerSSE extends BaseMCPServerSSE { session = null; _cacheDirty = true; _toolsList = []; _toolsCacheGeneration = 0; serverInitializeResult = null; clientSessionTimeoutSeconds; timeout; maxListPages; params; _name; transport = null; constructor(params) { super(params); this.maxListPages = validateMaxListPages(params.maxListPages); this.clientSessionTimeoutSeconds = params.clientSessionTimeoutSeconds ?? 5; this.params = params; this._name = params.name || `sse: ${this.params.url}`; this.timeout = params.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; } async connect() { this._toolsCacheGeneration += 1; this._cacheDirty = true; this._toolsList = []; try { const { SSEClientTransport } = await import('@modelcontextprotocol/client').catch(failedToImport); const { Client } = await import('@modelcontextprotocol/client').catch(failedToImport); this.transport = new SSEClientTransport(new URL(this.params.url), { authProvider: this.params.authProvider, requestInit: this.params.requestInit, eventSourceInit: this.params.eventSourceInit, fetch: this.params.fetch, }); this.session = new Client({ name: this._name, version: '1.0.0', // You may want to make this configurable }, { listMaxPages: this.maxListPages ?? 0 }); const requestOptions = buildRequestOptions(this.clientSessionTimeoutSeconds); await this.session.connect(this.transport, requestOptions); this.serverInitializeResult = { serverInfo: { name: this._name, version: '1.0.0' }, }; } catch (e) { const error = sanitizeMcpTransportError(e, this.params.url, 'SSE connect'); logToolActionError(this.logger, 'Error initializing MCP server:', error); await this.close(); throw error; } this.debugLog(() => `Connected to MCP server: ${this._name}`); } async invalidateToolsCache() { this._toolsCacheGeneration += 1; this._cacheDirty = true; this._toolsList = []; await invalidateServerToolsCache(this.name); } async listTools() { if (!this.session) { throw new Error('Server not initialized. Make sure you call connect() first.'); } if (this.cacheToolsList && !this._cacheDirty && this._toolsList) { return this._toolsList; } const requestOptions = buildRequestOptions(this.clientSessionTimeoutSeconds); const session = this.session; const cacheGeneration = this._toolsCacheGeneration; const response = await runMcpTransportOperation(this.params.url, 'SSE list tools', () => listMcpTools(session, requestOptions, this.maxListPages)); this.debugLog(() => `Listed tools: ${JSON.stringify(response)}`); assertMcpToolListingIsCurrent({ listedClient: session, currentClient: this.session, listedGeneration: cacheGeneration, currentGeneration: this._toolsCacheGeneration, }); const tools = response.tools; this._toolsList = tools; this._cacheDirty = false; return tools; } async callTool(toolName, args, meta, options) { return (await this.callToolResult(toolName, args, meta, options)).content; } async callToolResult(toolName, args, meta, options) { if (!this.session) { throw new Error('Server not initialized. Make sure you call connect() first.'); } const session = this.session; const params = { name: toolName, arguments: args ?? {}, ...(meta != null ? { _meta: meta } : {}), }; const response = await runMcpTransportOperation(this.params.url, 'SSE tool call', () => callWithMCPRequestSignal(options?.signal, (requestSignal) => session.callTool(params, { ...buildRequestOptions(this.clientSessionTimeoutSeconds, { timeout: this.timeout, signal: requestSignal, }), toolDefinition: getListedToolForCall(this._toolsList, toolName), })), options?.signal); const result = attachParsedCallToolResultMetadata(response); this.debugLog(() => `Called tool ${toolName} (args: ${JSON.stringify(args)}, result: ${JSON.stringify(result)})`); return result; } async listResources(params) { if (!this.session) { throw new Error('Server not initialized. Make sure you call connect() first.'); } const requestOptions = buildRequestOptions(this.clientSessionTimeoutSeconds); const response = await runMcpTransportOperation(this.params.url, 'SSE list resources', () => listResourcesPage(this.session, params, requestOptions)); this.debugLog(() => `Listed resources: ${JSON.stringify(response)}`); return response; } async listResourceTemplates(params) { if (!this.session) { throw new Error('Server not initialized. Make sure you call connect() first.'); } const requestOptions = buildRequestOptions(this.clientSessionTimeoutSeconds); const response = await runMcpTransportOperation(this.params.url, 'SSE list resource templates', () => listResourceTemplatesPage(this.session, params, requestOptions)); this.debugLog(() => `Listed resource templates: ${JSON.stringify(response)}`); return response; } async readResource(uri) { if (!this.session) { throw new Error('Server not initialized. Make sure you call connect() first.'); } const requestOptions = buildCacheableRequestOptions(this.clientSessionTimeoutSeconds); const response = await runMcpTransportOperation(this.params.url, 'SSE read resource', () => this.session.readResource({ uri }, requestOptions)); this.debugLog(() => `Read resource ${uri}: ${JSON.stringify(response)}`); return response; } get name() { return this._name; } async close() { this._toolsCacheGeneration += 1; this._cacheDirty = true; this._toolsList = []; await runMcpTransportOperation(this.params.url, 'SSE cleanup', async () => { const transport = this.transport; if (hasSessionTransport(transport)) { const sessionId = transport.sessionId; if (sessionId && typeof transport.terminateSession === 'function') { try { // Best-effort cleanup: we do not actively manage session lifecycles, // but if the server supports sessions we terminate to avoid leaks. await transport.terminateSession(); } catch (error) { logMcpTransportWarning(this.logger, this.params.url, 'SSE session termination', 'Failed to terminate MCP session:', error); } } } if (transport) { await transport.close(); this.transport = null; } if (this.session) { await this.session.close(); this.session = null; } }); } } export class NodeMCPServerStreamableHttp extends BaseMCPServerStreamableHttp { session = null; _cacheDirty = true; _toolsList = []; _toolsCacheGeneration = 0; serverInitializeResult = null; clientSessionTimeoutSeconds; timeout; maxListPages; params; _name; transport = null; reconnectingClientPromise = null; reconnectingClientTarget = null; isClosed = false; connectionStateVersion = 0; constructor(params) { super(params); this.maxListPages = validateMaxListPages(params.maxListPages); this.clientSessionTimeoutSeconds = params.clientSessionTimeoutSeconds ?? 5; this.params = params; this._name = params.name || `streamable-http: ${this.params.url}`; this.timeout = params.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; } async loadStreamableHttpRuntime() { const clientModule = await import('@modelcontextprotocol/client').catch(failedToImport); return { clientModule, transportModule: clientModule }; } createStreamableHttpTransport(StreamableHTTPClientTransport, options = {}) { const transportOptions = { authProvider: this.params.authProvider, requestInit: this.params.requestInit, fetch: this.params.fetch, reconnectionOptions: this.params.reconnectionOptions, sessionId: options.sessionId, }; const transport = new StreamableHTTPClientTransport(new URL(this.params.url), transportOptions); if (options.protocolVersion !== undefined && typeof transport.setProtocolVersion === 'function') { transport.setProtocolVersion(options.protocolVersion); } return transport; } async createConnectedStreamableHttpClient(options = {}) { const { clientModule, transportModule } = await this.loadStreamableHttpRuntime(); const { Client } = clientModule; const { StreamableHTTPClientTransport } = transportModule; const transport = this.createStreamableHttpTransport(StreamableHTTPClientTransport, options); const client = new Client({ name: this._name, version: '1.0.0', }, { versionNegotiation: { mode: 'auto' }, listMaxPages: this.maxListPages ?? 0, }); try { const requestOptions = buildRequestOptions(this.clientSessionTimeoutSeconds); await client.connect(transport, requestOptions); return { client, transport }; } catch (error) { await this.closeStreamableHttpClient({ client, transport, }, { terminateSession: options.sessionId === undefined, closeWarningMessage: 'Failed to close failed MCP connect client:', terminateWarningMessage: 'Failed to terminate failed MCP connect session:', }); throw error; } } getClientSessionTimeoutMs() { return Math.max(1, (this.clientSessionTimeoutSeconds ?? 5) * 1000); } async publishConnectedStreamableHttpClient(args) { this.transport = args.transport; this.session = args.client; this.connectionStateVersion += 1; this._toolsCacheGeneration += 1; this._cacheDirty = true; if (!args.preserveTools) { this._toolsList = []; } await invalidateServerToolsCache(this.name); } async clearPublishedStreamableHttpClientIfCurrent(args) { if (this.connectionStateVersion !== args.stateVersion || this.session !== args.client || this.transport !== args.transport) { return; } this.session = null; this.transport = null; this._cacheDirty = true; this._toolsList = []; await invalidateServerToolsCache(this.name); } async reopenSharedStreamableHttpSession(client) { await withTimeout(client.notification({ method: 'notifications/initialized' }), this.getClientSessionTimeoutMs(), () => new Error('Timed out reopening shared streamable HTTP MCP session.')); } async terminateDetachedStreamableHttpSession(transport, warningMessage) { const sessionId = getSessionId(transport); if (sessionId === undefined) { return; } try { const { transportModule } = await this.loadStreamableHttpRuntime(); const detachedTransport = this.createStreamableHttpTransport(transportModule.StreamableHTTPClientTransport, { protocolVersion: getTransportProtocolVersion(transport), sessionId, }); try { if (typeof detachedTransport.terminateSession === 'function') { // start() installs the abort controller used by terminateSession(). // Closing this detached transport then cancels a stalled DELETE too. await detachedTransport.start(); await withTimeout(detachedTransport.terminateSession(), this.getClientSessionTimeoutMs(), () => new Error('Timed out terminating streamable HTTP MCP session.')); } } finally { await detachedTransport.close().catch(() => { }); } } catch (error) { logMcpTransportWarning(this.logger, this.params.url, 'streamable HTTP session termination', warningMessage, error); } } async closeCloseOwnedStreamableHttpState(args) { const { client, transport, closeStateVersion, closeWarningMessage, terminateWarningMessage, } = args; if (client && transport) { await this.closeStreamableHttpClient({ client, transport, }, { terminateSession: false, closeWarningMessage, terminateWarningMessage, }); } else if (transport) { await transport.close().catch((error) => { logMcpTransportWarning(this.logger, this.params.url, 'streamable HTTP cleanup', closeWarningMessage, error); }); } else if (client) { await client.close().catch((error) => { logMcpTransportWarning(this.logger, this.params.url, 'streamable HTTP cleanup', closeWarningMessage, error); }); } if (!transport || this.connectionStateVersion !== closeStateVersion || !this.isClosed || !shouldTerminateTransportSession(transport, this.transport)) { return; } await this.terminateDetachedStreamableHttpSession(transport, terminateWarningMessage); } async callToolWithClient(client, toolName, args, meta, options) { const params = { name: toolName, arguments: args ?? {}, ...(meta != null ? { _meta: meta } : {}), }; const response = await callWithMCPRequestSignal(options?.signal, (requestSignal) => client.callTool(params, { ...buildRequestOptions(this.clientSessionTimeoutSeconds, { timeout: this.timeout, signal: requestSignal, }), toolDefinition: getListedToolForCall(this._toolsList, toolName), })); return attachParsedCallToolResultMetadata(response); } async closeStreamableHttpClient(args, options) { const { client, transport } = args; if (options.terminateSession && transport.sessionId) { await this.terminateDetachedStreamableHttpSession(transport, options.terminateWarningMessage); } if (client.transport === transport) { await client.close().catch((error) => { logMcpTransportWarning(this.logger, this.params.url, 'streamable HTTP cleanup', options.closeWarningMessage, error); }); return; } await transport.close().catch((error) => { logMcpTransportWarning(this.logger, this.params.url, 'streamable HTTP cleanup', options.closeWarningMessage, error); }); await client.close().catch((error) => { logMcpTransportWarning(this.logger, this.params.url, 'streamable HTTP cleanup', options.closeWarningMessage, error); }); } async closeStreamableHttpTransport(transport, warningMessage) { await transport.close().catch((error) => { logMcpTransportWarning(this.logger, this.params.url, 'streamable HTTP cleanup', warningMessage, error); }); } async reconnectExistingStreamableHttpClient(args) { const { transportModule } = await this.loadStreamableHttpRuntime(); const { StreamableHTTPClientTransport } = transportModule; const transport = this.createStreamableHttpTransport(StreamableHTTPClientTransport, { protocolVersion: args.protocolVersion, sessionId: args.sessionId, }); try { const requestOptions = buildRequestOptions(this.clientSessionTimeoutSeconds); await args.client.connect(transport, requestOptions); if (args.sessionId !== undefined && args.client.getProtocolEra() === 'legacy') { // Reconnecting with an existing session skips initialize(), so resend // initialized to reopen the shared SSE stream for async responses. await this.reopenSharedStreamableHttpSession(args.client); } return { client: args.client, transport }; } catch (error) { await this.closeStreamableHttpClient({ client: args.client, transport, }, { terminateSession: args.sessionId === undefined, closeWarningMessage: 'Failed to close failed MCP reconnect client:', terminateWarningMessage: 'Failed to terminate failed MCP reconnect session:', }); throw error; } } async reconnectClosedStreamableHttpClient(args) { const { cause, failedClient, failedStateVersion } = args; if (this.isClosed) { throw attachCause(new Error('Cannot reconnect a closed streamable HTTP MCP server.'), cause); } if (this.connectionStateVersion !== failedStateVersion || this.session !== failedClient) { if (this.session) { return this.session; } throw attachCause(new Error('Streamable HTTP MCP server changed before reconnect.'), cause); } // Multiple tool calls can discover the same closed shared session in parallel. // Share one reconnect so later callers do not replace and close the new client. if (this.reconnectingClientPromise) { const reconnectingClientTarget = this.reconnectingClientTarget; if (reconnectingClientTarget?.client === failedClient && reconnectingClientTarget.stateVersion === failedStateVersion) { try { return await this.reconnectingClientPromise; } catch (error) { throw attachCause(error, cause); } } } const reconnectStateVersion = this.connectionStateVersion; const previousClient = this.session; const previousTransport = this.transport; const reconnectPromise = (async () => { const sessionId = previousTransport && hasSessionTransport(previousTransport) ? previousTransport.sessionId : undefined; const protocolVersion = getTransportProtocolVersion(previousTransport); if (!previousClient || !previousTransport) { throw new Error('Cannot reconnect streamable HTTP MCP server without an active client.'); } try { if (sessionId === undefined) { await this.closeStreamableHttpClient({ client: previousClient, transport: previousTransport, }, { terminateSession: false, closeWarningMessage: 'Failed to close stale MCP client:', terminateWarningMessage: 'Failed to terminate stale MCP session:', }); } else { // Keep the v2 Client's negotiated capabilities and tool metadata when // resuming the same server session on a replacement transport. await this.closeStreamableHttpTransport(previousTransport, 'Failed to close stale MCP transport:'); } const recovered = await this.reconnectExistingStreamableHttpClient({ client: previousClient, protocolVersion, sessionId, }); if (this.connectionStateVersion !== reconnectStateVersion || this.isClosed) { await this.closeStreamableHttpClient({ client: recovered.client, transport: recovered.transport, }, { terminateSession: false, closeWarningMessage: 'Failed to close discarded MCP client:', terminateWarningMessage: 'Failed to terminate discarded MCP session:', }); if (this.isClosed) { throw new Error('Streamable HTTP MCP server was closed during reconnect.'); } if (this.session) { return this.session; } throw new Error('Streamable HTTP MCP server changed during reconnect.'); } await this.publishConnectedStreamableHttpClient({ client: recovered.client, transport: recovered.transport, preserveTools: sessionId !== undefined, }); return recovered.client; } catch (error) { await this.clearPublishedStreamableHttpClientIfCurrent({ client: previousClient, transport: previousTransport, stateVersion: reconnectStateVersion, }); throw error; } })(); this.reconnectingClientPromise = reconnectPromise; this.reconnectingClientTarget = { client: failedClient, stateVersion: failedStateVersion, }; try { return await reconnectPromise; } catch (error) { throw attachCause(error, cause); } finally { if (this.reconnectingClientPromise === reconnectPromise) { this.reconnectingClientPromise = null; this.reconnectingClientTarget = null; } } } async shouldReconnectClosedStreamableHttpClient(error, client) { // Explicit session ids are a released contract, so keep callers pinned to the // session they selected instead of silently switching them to a fresh one. if (this.params.sessionId !== undefined) { return 'none'; } const { clientModule } = await this.loadStreamableHttpRuntime(); const { SdkError, SdkErrorCode } = clientModule; if (error instanceof SdkError && error.code === SdkErrorCode.NotConnected) { return client.transport == null ? 'reconnect-and-retry' : 'none'; } // MCP SDK v2 currently throws this plain Error after a transport detaches. // Guard the message match with the actual disconnected client state. if (client.transport == null && error instanceof Error && error.message === 'Not connected') { return 'reconnect-and-retry'; } return error instanceof SdkError && error.code === SdkErrorCode.ConnectionClosed ? 'reconnect-only' : 'none'; } async connect() { const connectStateVersion = this.connectionStateVersion; this.isClosed = false; try { const { client, transport } = await this.createConnectedStreamableHttpClient({ sessionId: this.params.sessionId, }); if (this.isClosed || this.connectionStateVersion !== connectStateVersion) { await this.closeStreamableHttpClient({ client, transport, }, { // A stale overlapping connect can point at the same shared session as // the winner, so only terminate truly discarded sessions. terminateSession: shouldTerminateTransportSession(transport, this.transport), closeWarningMessage: 'Failed to close discarded MCP client:', terminateWarningMessage: 'Failed to terminate discarded MCP session:', }); if (this.isClosed) { throw new Error('Streamable HTTP MCP server was closed during connect.'); } throw new Error('Streamable HTTP MCP server changed during connect.'); } const previousClient = this.session; const previousTransport = this.transport; await this.publishConnectedStreamableHttpClient({ client, transport, }); this.serverInitializeResult = { serverInfo: { name: this._name, version: '1.0.0' }, }; if (previousClient && previousTransport) { await this.closeStreamableHttpClient({ client: previousClient, transport: previousTransport, }, { terminateSession: shouldTerminateTransportSession(previousTransport, transport), closeWarningMessage: 'Failed to close replaced MCP client:', terminateWarningMessage: 'Failed to terminate replaced MCP session:', }); } } catch (e) { // A losing concurrent connect can fail after another connect already // published a healthy shared session, so avoid closing shared state here. const error = sanitizeMcpTransportError(e, this.params.url, 'streamable HTTP connect'); logToolActionError(this.logger, 'Error initializing MCP server:', error); throw error; } this.debugLog(() => `Connected to MCP server: ${this._name}`); } async invalidateToolsCache() { this._toolsCacheGeneration += 1; this._cacheDirty = true; this._toolsList = []; await invalidateServerToolsCache(this.name); } async listTools() { if (!this.session) { throw new Error('Server not initialized. Make sure you call connect() first.'); } if (this.cacheToolsList && !this._cacheDirty && this._toolsList) { return this._toolsList; } const requestOptions = buildRequestOptions(this.clientSessionTimeoutSeconds); const session = this.session; const cacheGeneration = this._toolsCacheGeneration; const response = await runMcpTransportOperation(this.params.url, 'streamable HTTP list tools', () => listMcpTools(session, requestOptions, this.maxListPages)); this.debugLog(() => `Listed tools: ${JSON.stringify(response)}`); assertMcpToolListingIsCurrent({ listedClient: session, currentClient: this.session, listedGeneration: cacheGeneration, currentGeneration: this._toolsCacheGeneration, }); const tools = response.tools; this._toolsList = tools; this._cacheDirty = false; return tools; } async callTool(toolName, args, meta, options) { return (await this.callToolResult(toolName, args, meta, options)).content; } async callToolResult(toolName, args, meta, options) { const client = this.session; if (!client) { throw new Error('Server not initialized. Make sure you call connect() first.'); } const callToolStateVersion = this.connectionStateVersion; let result; try { result = await this.callToolWithClient(client, toolName, args, meta, options); } catch (error) { if (isCallerAbortReason(error, options?.signal)) { throw error; } const recoveryStrategy = await runMcpTransportOperation(this.params.url, 'streamable HTTP tool call recovery classification', () => this.shouldReconnectClosedStreamableHttpClient(error, client)); if (recoveryStrategy === 'none') { throw sanitizeMcpTransportError(error, this.params.url, 'streamable HTTP tool call'); } this.debugLog(() => `Reconnecting closed streamable HTTP MCP session for ${toolName}.`); const recoveredClient = await runMcpTransportOperation(this.params.url, 'streamable HTTP reconnect', () => this.reconnectClosedStreamableHttpClient({ cause: error, failedClient: client, failedStateVersion: callToolStateVersion, })); if (recoveryStrategy === 'reconnect-only') { throw sanitizeMcpTransportError(error, this.params.url, 'streamable HTTP tool call'); } try { result = await this.callToolWithClient(recoveredClient, toolName, args, meta, options); } catch (retryError) { if (isCallerAbortReason(retryError, options?.signal)) { throw retryError; } throw sanitizeMcpTransportError(attachCause(retryError, error), this.params.url, 'streamable HTTP tool call retry'); } } this.debugLog(() => `Called tool ${toolName} (args: ${JSON.stringify(args)}, result: ${JSON.stringify(result)})`); return result; } async listResources(params) { if (!this.session) { throw new Error('Server not initialized. Make sure you call connect() first.'); } const requestOptions = buildRequestOptions(this.clientSessionTimeoutSeconds); const response = await runMcpTransportOperation(this.params.url, 'streamable HTTP list resources', () => listResourcesPage(this.session, params, requestOptions)); this.debugLog(() => `Listed resources: ${JSON.stringify(response)}`); return response; } async listResourceTemplates(params) { if (!this.session) { throw new Error('Server not initialized. Make sure you call connect() first.'); } const requestOptions = buildRequestOptions(this.clientSessionTimeoutSeconds); const response = await runMcpTransportOperation(this.params.url, 'streamable HTTP list resource templates', () => listResourceTemplatesPage(this.session, params, requestOptions)); this.debugLog(() => `Listed resource templates: ${JSON.stringify(response)}`); return response; } async readResource(uri) { if (!this.session) { throw new Error('Server not initialized. Make sure you call connect() first.'); } const requestOptions = buildCacheableRequestOptions(this.clientSessionTimeoutSeconds); const response = await runMcpTransportOperation(this.params.url, 'streamable HTTP read resource', () => this.session.readResource({ uri }, requestOptions)); this.debugLog(() => `Read resource ${uri}: ${JSON.stringify(response)}`); return response; } get name() { return this._name; } get sessionId() { const transport = this.transport; return hasSessionTransport(transport) ? transport.sessionId : undefined; } async close() { this.isClosed = true; this.connectionStateVersion += 1; this._toolsCacheGeneration += 1; this._cacheDirty = true; this._toolsList = []; const closeStateVersion = this.connectionStateVersion; const reconnectPromise = this.reconnectingClientPromise; const client = this.session; const transport = this.transport; this.session = null; this.transport = null; await this.closeCloseOwnedStreamableHttpState({ client, transport, closeStateVersion, closeWarningMessage: client && transport ? 'Failed to close MCP client:' : transport ? 'Failed to close MCP transport:' : 'Failed to close MCP client:', terminateWarningMessage: 'Failed to terminate MCP session:', }); if (reconnectPromise) { await reconnectPromise.catch(() => { }); // A new connect() may have reopened the server while close() was waiting // for the stale reconnect to settle, so only clean up close-owned state. if (this.connectionStateVersion !== closeStateVersion || !this.isClosed) { return; } const recoveredClient = this.session; const recoveredTransport = this.transport; this.session = null; this.transport = null; await this.closeCloseOwnedStreamableHttpState({ client: recoveredClient, transport: recoveredTransport, closeStateVersion, closeWarningMessage: 'Failed to close reconnected MCP client:', terminateWarningMessage: 'Failed to terminate reconnected MCP session:', }); } } } //# sourceMappingURL=node.mjs.map