UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

778 lines (777 loc) 32.5 kB
import { c as isRecord } from "../record-coerce-DItp3I4t.js"; import { r as racePromiseWithAbortSignal } from "../abort-signal-D2k14JsD.js"; import { t as assignSafeServerNames } from "../agent-bundle-mcp-names-CP3ugHLh.js"; import { i as logWarn } from "../logger-DwECwNVZ.js"; import { o as getSessionMcpRequestSignal } from "../mcp-app-model-context-dSmaJhyr.js"; import { g as mergeMcpToolCatalogs, h as createCombinedSessionMcpRuntime, i as disposeAllSessionMcpRuntimes, m as sessionMcpRuntimeOwners, o as getSessionMcpRuntimeManagerForTesting } from "../agent-bundle-mcp-manager-api-DCHQm4w1.js"; import { t as loadSessionMcpConfig } from "../agent-bundle-mcp-runtime-config-MN40lQtN.js"; import { i as partitionMcpServersByConnectionScope, r as hashMcpResolvedConnections, t as applyMcpConnectionOverride } from "../mcp-connection-resolver-FyG5JEPz.js"; import { a as createMcpJsonSchemaValidator, c as connectMcpClient, i as sanitizeMcpMetadataText, l as disposeMcpClient, n as normalizeMcpToolCatalog, o as redactMcpDiagnosticError, r as collectMcpPaginatedItems, s as McpClientConnectTimeoutError, t as resolveMcpTransport, u as isStatefulMcpHttpSessionExpired } from "../mcp-transport-DOp-W-3T.js"; import { i as resolveProjectedMcpCodexToolApprovalMode, n as normalizeMcpCodexToolAnnotations } from "../mcp-codex-tool-approval-UMdM31mU.js"; import { n as normalizeMcpToolFilter, t as isMcpToolAllowed } from "../mcp-tool-filter-DHh4eeEi.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { ErrorCode, ListToolsResultSchema, McpError } from "@modelcontextprotocol/sdk/types.js"; //#region src/agents/agent-bundle-mcp-runtime.ts /** Session-scoped MCP runtime catalog loader and transport lifecycle. */ const MCP_APPS_CLIENT_EXTENSION = "io.modelcontextprotocol/ui"; const MCP_APP_RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; const BUNDLE_MCP_FAILURE_THRESHOLD = 3; const BUNDLE_MCP_FAILURE_COOLDOWN_MS = 6e4; const BUNDLE_MCP_CATALOG_FAILURE_RETRY_MS = 5e3; const BUNDLE_MCP_CATALOG_LIST_TIMEOUT_MS = 1500; const BUNDLE_MCP_DISPOSE_TIMEOUT_MS = 5e3; const BUNDLE_MCP_MAX_LIST_PAGES = 128; const BUNDLE_MCP_MAX_LIST_ITEMS = 16384; const BUNDLE_MCP_MAX_LIST_BYTES = 10485760; let bundleMcpCatalogListTimeoutMs; const BUNDLE_MCP_TEST_STATE_KEY = Symbol.for("openclaw.bundleMcpTestState"); function getBundleMcpTestState() { const globalStore = globalThis; const existing = globalStore[BUNDLE_MCP_TEST_STATE_KEY]; if (existing) return existing; const state = {}; globalStore[BUNDLE_MCP_TEST_STATE_KEY] = state; return state; } async function listAllTools(client, timeoutMs, signal) { return await collectMcpPaginatedItems({ label: "MCP tool listing", itemLabel: "tools", timeoutMs, maxPages: BUNDLE_MCP_MAX_LIST_PAGES, maxItems: BUNDLE_MCP_MAX_LIST_ITEMS, maxBytes: BUNDLE_MCP_MAX_LIST_BYTES, signal, loadPage: async ({ cursor, requestTimeoutMs, signal: requestSignal }) => { const requestController = new AbortController(); const onAbort = () => requestController.abort(requestSignal.reason); requestSignal.addEventListener("abort", onAbort, { once: true }); if (requestSignal.aborted) onAbort(); try { const page = await client.request({ method: "tools/list", params: cursor === void 0 ? void 0 : { cursor } }, ListToolsResultSchema, { timeout: requestTimeoutMs, maxTotalTimeout: requestTimeoutMs, signal: requestController.signal }); return { items: page.tools, nextCursor: page.nextCursor, serializedValue: page }; } finally { requestSignal.removeEventListener("abort", onAbort); } } }); } function isMcpMethodNotFoundError(error) { if (isRecord(error) && error.code === ErrorCode.MethodNotFound) return true; const message = String(error); return message.includes("-32601") || /\b(?:method not found|unknown method)\b/i.test(message); } function hasConfiguredMcpRequestTimeout(rawServer) { if (!rawServer || typeof rawServer !== "object") return false; const record = rawServer; for (const key of ["requestTimeoutMs", "timeout"]) { const value = record[key]; if (typeof value === "number" && Number.isFinite(value) && value > 0) return true; } return false; } function getCatalogListTimeoutMs(rawServer, requestTimeoutMs) { if (bundleMcpCatalogListTimeoutMs !== void 0) return bundleMcpCatalogListTimeoutMs; return hasConfiguredMcpRequestTimeout(rawServer) ? requestTimeoutMs : BUNDLE_MCP_CATALOG_LIST_TIMEOUT_MS; } function setBundleMcpCatalogListTimeoutMsForTest(timeoutMs) { bundleMcpCatalogListTimeoutMs = typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.floor(timeoutMs) : void 0; } function setBundleMcpDisposeTimeoutMsForTest(timeoutMs) { getBundleMcpTestState().disposeTimeoutMs = typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.floor(timeoutMs) : void 0; } function disposeBundleMcpSession(session) { return disposeMcpClient(session, getBundleMcpTestState().disposeTimeoutMs ?? BUNDLE_MCP_DISPOSE_TIMEOUT_MS); } function buildMcpClientCapabilities(mcpAppsEnabled) { return mcpAppsEnabled ? { extensions: { [MCP_APPS_CLIENT_EXTENSION]: { mimeTypes: [MCP_APP_RESOURCE_MIME_TYPE] } } } : {}; } function normalizeToolUiVisibility(value) { if (!Array.isArray(value)) return; const normalized = value.filter((entry) => entry === "app" || entry === "model"); return [...new Set(normalized)].toSorted(); } function summarizeServerCapabilities(capabilities) { return { resources: capabilities?.resources ? { listChanged: capabilities.resources.listChanged === true } : void 0, prompts: capabilities?.prompts ? { listChanged: capabilities.prompts.listChanged === true } : void 0, tools: capabilities?.tools ? { listChanged: capabilities.tools.listChanged === true } : void 0 }; } function createDisposedError(sessionId) { return /* @__PURE__ */ new Error(`bundle-mcp runtime disposed for session ${sessionId}`); } function createSessionMcpRuntime(params, previous = /* @__PURE__ */ new Map()) { const declared = loadSessionMcpConfig({ ...params, includeServerNames: void 0, excludeServerNames: void 0, logDiagnostics: true }); const config = loadSessionMcpConfig({ ...params, loaded: declared.loaded, logDiagnostics: false }); const safeNames = params.safeServerNamesByServer ?? assignSafeServerNames(Object.keys(declared.loaded.mcpServers)); const configForServer = (serverName, nextParams = params, loaded = config.loaded) => { const connection = nextParams.connectionOverrides?.get(serverName); const serverConfig = loadSessionMcpConfig({ ...nextParams, loaded, logDiagnostics: false, includeServerNames: /* @__PURE__ */ new Set([serverName]), excludeServerNames: void 0, safeServerNamesByServer: /* @__PURE__ */ new Map([[serverName, safeNames.get(serverName)]]), toolOverrides: { ...nextParams.toolOverrides, mcpToolsDeny: Object.fromEntries(Object.entries(nextParams.toolOverrides?.mcpToolsDeny ?? {}).filter(([name]) => name === serverName)) } }); if (connection) serverConfig.fingerprint += `:${hashMcpResolvedConnections(/* @__PURE__ */ new Map([[serverName, connection]]))}`; return serverConfig; }; const owned = /* @__PURE__ */ new Map(); for (const serverName of Object.keys(config.loaded.mcpServers)) { const serverConfig = configForServer(serverName); const fingerprint = serverConfig.fingerprint; const existing = previous.get(serverName); if (existing?.configFingerprint === fingerprint && existing.workspaceDir === params.workspaceDir && existing.agentDir === params.agentDir) { owned.set(serverName, existing); previous.delete(serverName); } else owned.set(serverName, createServerMcpRuntime({ ...params, serverName, serverConfig, safeServerNamesByServer: safeNames, configFingerprint: fingerprint })); } const requesterConnect = params.requesterConnect; const connectFingerprints = new Map(Object.keys(requesterConnect?.catalog.servers ?? {}).map((name) => [name, configForServer(name, params, declared.loaded).fingerprint])); let invalidated = false; let pendingDisposal = Promise.resolve(); const disposeParts = (parts) => { pendingDisposal = Promise.allSettled([pendingDisposal, ...parts.map((part) => part.dispose())]).then(() => void 0); return pendingDisposal; }; const runtime = createCombinedSessionMcpRuntime({ ...params, parts: [...owned.values()], serverOwners: new Map(owned) }); runtime.configFingerprint = params.configFingerprint ?? config.fingerprint; runtime.requesterScope = params.requesterScope; runtime.requesterConnect = requesterConnect && { ...requesterConnect, get catalog() { const catalog = requesterConnect.catalog; return { ...catalog, servers: Object.fromEntries(Object.entries(catalog.servers).filter(([name]) => connectFingerprints.has(name))), tools: catalog.tools.filter((tool) => connectFingerprints.has(tool.serverName)) }; }, createExecute(serverName) { const execute = requesterConnect.createExecute(serverName); return execute && (async (...args) => { if (!connectFingerprints.has(serverName)) throw createDisposedError(params.sessionId); return await execute(...args); }); } }; runtime.mcpAppsEnabled = params.cfg?.mcp?.apps?.enabled === true; runtime.dispose = async () => { connectFingerprints.clear(); invalidated = true; const retired = [...owned.values()]; owned.clear(); sessionMcpRuntimeOwners.delete(runtime); await disposeParts(retired); }; sessionMcpRuntimeOwners.set(runtime, { isCurrent: () => !invalidated, replace: (nextParams) => createSessionMcpRuntime(nextParams, owned), async reload({ cfg, manifestRegistry, reloadPlugins }) { const nextParams = { ...params, cfg, manifestRegistry }; const nextConfig = loadSessionMcpConfig({ ...nextParams, includeServerNames: void 0, excludeServerNames: void 0, logDiagnostics: false }); const nextSafeNames = assignSafeServerNames(Object.keys(nextConfig.loaded.mcpServers)); const { requesterScopedServerNames } = partitionMcpServersByConnectionScope(nextConfig.loaded.mcpServers); for (const [name, fingerprint] of connectFingerprints) if (reloadPlugins && !Object.hasOwn(params.cfg?.mcp?.servers ?? {}, name) || cfg.gateway?.publicOrigin !== params.cfg?.gateway?.publicOrigin || configForServer(name, nextParams, nextConfig.loaded).fingerprint !== fingerprint || nextSafeNames.get(name) !== safeNames.get(name)) { invalidated = true; connectFingerprints.delete(name); } const retired = []; for (const [serverName, part] of owned) if (reloadPlugins && part.pluginOwned || !Object.hasOwn(nextConfig.loaded.mcpServers, serverName) || requesterScopedServerNames.includes(serverName) !== Boolean(params.requesterScope) || nextSafeNames.get(serverName) !== safeNames.get(serverName) || configForServer(serverName, nextParams, nextConfig.loaded).fingerprint !== part.configFingerprint) { invalidated = true; owned.delete(serverName); retired.push(part); } await disposeParts(retired); } }); return runtime; } function createServerMcpRuntime(params) { const { loaded, fingerprint: computedFingerprint } = params.serverConfig; const serverName = params.serverName; const configFingerprint = params.configFingerprint ?? computedFingerprint; const mcpAppsEnabled = params.cfg?.mcp?.apps?.enabled === true; const createdAt = Date.now(); let lastUsedAt = createdAt; let activeLeases = 0; let retiredCatalog; const lifecycleAbortController = new AbortController(); let catalog = null; let catalogRetryAfterMs; let catalogInFlight; let catalogInvalidationGeneration = 0; const invalidateCatalog = () => { catalogInvalidationGeneration += 1; catalog = null; catalogRetryAfterMs = void 0; }; const scheduleCatalogServerRetry = (message) => { const currentCatalog = catalog; const server = currentCatalog?.servers[serverName]; const existing = currentCatalog?.diagnostics?.[0]; if (!currentCatalog) { invalidateCatalog(); return; } let diagnostic; if (existing) diagnostic = { ...existing, message }; else if (server) diagnostic = { serverName, safeServerName: server.safeServerName ?? serverName, launchSummary: server.launchSummary, message }; else { invalidateCatalog(); return; } catalogInvalidationGeneration += 1; catalog = { ...currentCatalog, diagnostics: [diagnostic] }; catalogRetryAfterMs = Date.now(); }; const catalogRetryIsDue = () => catalogRetryAfterMs !== void 0 && Date.now() >= catalogRetryAfterMs; let currentSession; let serverBackoff; const recordServerToolFailure = (session, nowMs) => { if (currentSession !== session || session.retiring) return; const previous = serverBackoff; const failures = (previous?.session === session ? previous.failures : 0) + 1; const nextBackoff = { session, failures }; if (failures >= BUNDLE_MCP_FAILURE_THRESHOLD) nextBackoff.retryAfterMs = nowMs + BUNDLE_MCP_FAILURE_COOLDOWN_MS; serverBackoff = nextBackoff; return failures; }; const failIfDisposed = () => { if (retiredCatalog) throw createDisposedError(params.sessionId); }; const requireConnectedSession = () => { const session = currentSession; if (!session || !session.connected) throw new Error(session?.disconnectReason ? `bundle-mcp server "${serverName}" is disconnected: ${session.disconnectReason}` : `bundle-mcp server "${serverName}" is not connected`); return session; }; const ensureSessionConnected = async (session, connectionTimeoutMs) => { if (session.retiring) throw new Error(`bundle-mcp server "${session.serverName}" is retiring`); if (session.connected) return; session.connectPromise ??= connectMcpClient({ client: session.client, transport: session.transport, timeoutMs: connectionTimeoutMs }).catch((error) => { if (error instanceof McpClientConnectTimeoutError) throw new Error(`MCP server "${session.serverName}" timed out: did not complete initialize within ${connectionTimeoutMs / 1e3}s`, { cause: error }); throw error; }).then(() => { session.connected = true; }).finally(() => { session.connectPromise = void 0; }); await session.connectPromise; }; const retireSessionIfCurrent = async (session) => { if (currentSession !== session) return false; session.retiring = true; currentSession = void 0; await disposeBundleMcpSession(session); return true; }; const localRequestTimeouts = /* @__PURE__ */ new WeakSet(); const runMcpRequest = async (session, request, parentSignal) => { const requestSignal = parentSignal ?? getSessionMcpRequestSignal(); const abortController = new AbortController(); const onParentAbort = () => abortController.abort(requestSignal?.reason); if (requestSignal?.aborted) onParentAbort(); else requestSignal?.addEventListener("abort", onParentAbort, { once: true }); const timeoutError = new McpError(ErrorCode.RequestTimeout, "Request timed out", { timeout: session.requestTimeoutMs }); const timeout = setTimeout(() => { localRequestTimeouts.add(timeoutError); abortController.abort(timeoutError); }, session.requestTimeoutMs); timeout.unref?.(); try { const signal = abortController.signal; signal.throwIfAborted(); const result = await request(signal); requestSignal?.throwIfAborted(); return result; } catch (error) { requestSignal?.throwIfAborted(); throw error; } finally { requestSignal?.removeEventListener("abort", onParentAbort); clearTimeout(timeout); } }; const runGuardedServerRequest = async (session, request, options) => { const requestSignal = getSessionMcpRequestSignal(); const tracksFailureBackoff = options?.failureBackoff !== "ignore"; const nowMs = Date.now(); const backoff = serverBackoff; if (tracksFailureBackoff && backoff?.session === session && backoff.retryAfterMs && nowMs < backoff.retryAfterMs) throw new Error(`bundle-mcp server "${serverName}" is paused after repeated tool failures; retry after ${new Date(backoff.retryAfterMs).toISOString()}`); if (backoff && backoff.session !== session) serverBackoff = void 0; try { const result = await request(); if (tracksFailureBackoff && serverBackoff?.session === session) serverBackoff = void 0; return result; } catch (error) { const sessionExpired = isStatefulMcpHttpSessionExpired(session, error); let recycleReason; if (sessionExpired && !requestSignal?.aborted) recycleReason = "expired HTTP session"; else if (tracksFailureBackoff && !requestSignal?.aborted) { const failures = recordServerToolFailure(session, nowMs); if (error !== null && typeof error === "object" && localRequestTimeouts.has(error) && failures && failures >= BUNDLE_MCP_FAILURE_THRESHOLD) recycleReason = "repeated request timeouts"; } if (recycleReason) { serverBackoff = void 0; scheduleCatalogServerRetry(recycleReason); const timedOut = recycleReason === "repeated request timeouts"; logWarn(`bundle-mcp: recycling server "${serverName}" after ${timedOut ? "repeated timeouts" : "an expired HTTP session"}`); retireSessionIfCurrent(session).catch((retireError) => { logWarn(`bundle-mcp: failed to retire ${timedOut ? "timed-out" : "expired-session"} server "${serverName}": ${redactMcpDiagnosticError(retireError)}`); }); } throw error; } }; const runGuardedMcpRequest = (session, request, options) => runGuardedServerRequest(session, () => runMcpRequest(session, request), options); const collectServerItems = (session, kind) => { const callerSignal = getSessionMcpRequestSignal(); return collectMcpPaginatedItems({ label: `MCP ${kind === "resources" ? "resource" : "prompt"} listing`, itemLabel: kind, timeoutMs: session.requestTimeoutMs, maxPages: BUNDLE_MCP_MAX_LIST_PAGES, maxItems: BUNDLE_MCP_MAX_LIST_ITEMS, maxBytes: BUNDLE_MCP_MAX_LIST_BYTES, signal: callerSignal ? AbortSignal.any([lifecycleAbortController.signal, callerSignal]) : lifecycleAbortController.signal, loadPage: ({ cursor, requestTimeoutMs: timeout, signal }) => runMcpRequest(session, async (requestSignal) => { const requestParams = cursor === void 0 ? void 0 : { cursor }; const requestOptions = { timeout, maxTotalTimeout: timeout, signal: requestSignal }; const page = kind === "resources" ? await session.client.listResources(requestParams, requestOptions) : await session.client.listPrompts(requestParams, requestOptions); return { items: page[kind], nextCursor: page.nextCursor, serializedValue: page }; }, signal) }); }; const loadCatalog = async () => { failIfDisposed(); if (catalogInFlight) return catalogInFlight; const catalogGeneration = catalogInvalidationGeneration; const inFlight = (async () => { const rawServer = loaded.mcpServers[serverName]; const override = params.connectionOverrides?.get(serverName); const transportSource = override ? applyMcpConnectionOverride(rawServer, override) : rawServer; const resolved = resolveMcpTransport(serverName, transportSource, { cfg: params.cfg, agentDir: params.agentDir, prepareDataDir: loaded.prepareDataDirsByServer?.[serverName]?.dataDir, requesterScope: params.requesterScope }); if (!resolved) return { version: 1, generatedAt: Date.now(), servers: {}, tools: [] }; const safeServerName = params.safeServerNamesByServer?.get(serverName) ?? serverName; const launchDescription = override ? `${serverName}: requester-scoped connection` : resolved.description; failIfDisposed(); let session = currentSession; while (session && !session.retiring && !session.connected && !session.connectPromise) { await retireSessionIfCurrent(session); session = currentSession; } if (session?.retiring) session = void 0; const reusedSession = Boolean(session); const schemaValidator = createMcpJsonSchemaValidator(); if (!session) { const client = new Client({ name: "openclaw-bundle-mcp", version: "0.0.0" }, { capabilities: buildMcpClientCapabilities(mcpAppsEnabled), jsonSchemaValidator: schemaValidator, listChanged: { tools: { autoRefresh: false, debounceMs: 0, onChanged: (error) => { if (error) logWarn(`bundle-mcp: failed to refresh changed tool list for server "${serverName}": ${redactMcpDiagnosticError(error)}`); invalidateCatalog(); } } } }); const createdSession = { serverName, client, transport: resolved.transport, transportType: resolved.transportType, requestTimeoutMs: resolved.requestTimeoutMs, connected: false, retiring: false, detachStderr: resolved.detachStderr }; client.onclose = () => { const wasConnected = createdSession.connected; createdSession.connected = false; createdSession.disconnectReason = "mcp transport closed"; if (wasConnected && !retiredCatalog && !createdSession.retiring && currentSession === createdSession) { scheduleCatalogServerRetry("mcp transport closed"); logWarn(`bundle-mcp: server "${serverName}" closed; next request reconnects`); } }; session = createdSession; currentSession = session; } try { failIfDisposed(); await ensureSessionConnected(session, resolved.connectionTimeoutMs); failIfDisposed(); const capabilities = summarizeServerCapabilities(session.client.getServerCapabilities()); let listedTools; try { listedTools = await listAllTools(session.client, getCatalogListTimeoutMs(rawServer, resolved.requestTimeoutMs), lifecycleAbortController.signal); } catch (error) { if (!capabilities.tools && (capabilities.resources || capabilities.prompts) && isMcpMethodNotFoundError(error)) listedTools = []; else throw error; } failIfDisposed(); const toolFilter = normalizeMcpToolFilter(isRecord(rawServer) ? rawServer.toolFilter : void 0); const denialMap = params.toolOverrides?.mcpToolsDeny; const deniedToolNames = new Set(denialMap && Object.hasOwn(denialMap, serverName) ? denialMap[serverName] : []); const normalizedTools = normalizeMcpToolCatalog(listedTools, schemaValidator, (toolName) => { if (!isMcpToolAllowed(toolFilter, toolName)) return "exclude"; return deniedToolNames.has(toolName) ? "denied" : "include"; }); session.toolMetadata = normalizedTools.metadata; const exposedTools = normalizedTools.tools; const serverEntry = { serverName, safeServerName, launchSummary: launchDescription, toolCount: exposedTools.length, requestTimeoutMs: resolved.requestTimeoutMs, supportsParallelToolCalls: resolved.supportsParallelToolCalls, ...capabilities.resources ? { resources: capabilities.resources } : {}, ...capabilities.prompts ? { prompts: capabilities.prompts } : {}, ...capabilities.tools ? { tools: { ...capabilities.tools, ...exposedTools.length !== listedTools.length ? { filteredCount: listedTools.length - exposedTools.length } : {} } } : {}, ...toolFilter ? { toolFilter } : {}, ...deniedToolNames.size > 0 ? { deniedToolNames: [...deniedToolNames].toSorted() } : {}, codexApprovalMode: resolveProjectedMcpCodexToolApprovalMode(serverName, rawServer) }; const toolEntries = []; const policyToolEntries = []; for (const [tool, excludedFromOpenClawCatalog, deniedBySession] of [ ...normalizedTools.tools.map((entry) => [ entry, false, false ]), ...normalizedTools.deniedTools.map((entry) => [ entry, false, true ]), ...normalizedTools.excludedTools.map((entry) => [ entry, true, deniedToolNames.has(entry.name) ]) ]) { const toolName = tool.name; const { _meta: metadata } = tool; const uiMeta = metadata?.ui && typeof metadata.ui === "object" && !Array.isArray(metadata.ui) ? metadata.ui : void 0; const rawResourceUri = uiMeta?.resourceUri ?? metadata?.["ui/resourceUri"]; const uiResourceUri = typeof rawResourceUri === "string" && rawResourceUri.startsWith("ui://") ? rawResourceUri : void 0; const uiVisibility = normalizeToolUiVisibility(uiMeta?.visibility); const entry = { serverName, safeServerName, toolName, title: tool.title, description: sanitizeMcpMetadataText(tool.description), inputSchema: tool.inputSchema, fallbackDescription: `Provided by bundle MCP server "${serverName}" (${launchDescription}).`, ...uiResourceUri ? { uiResourceUri } : {}, ...uiVisibility ? { uiVisibility } : {}, ...excludedFromOpenClawCatalog ? { excludedFromOpenClawCatalog: true } : {}, ...deniedBySession ? { deniedBySession: true } : {}, codexAnnotations: normalizeMcpCodexToolAnnotations(tool.annotations) }; policyToolEntries.push(entry); if (!entry.excludedFromOpenClawCatalog) toolEntries.push(entry); } return { version: 1, generatedAt: Date.now(), servers: { [serverName]: serverEntry }, tools: toolEntries.filter((tool) => !tool.deniedBySession), policyTools: policyToolEntries, sessionDeniedTools: toolEntries.filter((tool) => tool.deniedBySession) }; } catch (error) { const message = redactMcpDiagnosticError(error); if (!retiredCatalog) logWarn(`bundle-mcp: failed to ${reusedSession ? "refresh" : "start"} server "${serverName}" (${launchDescription}): ${message}`); const diags = [{ serverName, safeServerName, launchSummary: launchDescription, message }]; if (!session.connected) await retireSessionIfCurrent(session); else if (!reusedSession && catalogInvalidationGeneration === catalogGeneration) await retireSessionIfCurrent(session); failIfDisposed(); return { version: 1, generatedAt: Date.now(), servers: {}, tools: [], diagnostics: diags }; } })(); catalogInFlight = inFlight; try { const nextCatalog = await inFlight; failIfDisposed(); if (catalogInvalidationGeneration === catalogGeneration) { catalog = nextCatalog; catalogRetryAfterMs = nextCatalog.diagnostics?.length ? Date.now() + BUNDLE_MCP_CATALOG_FAILURE_RETRY_MS : void 0; } return nextCatalog; } finally { if (catalogInFlight === inFlight) catalogInFlight = void 0; } }; const getCatalog = async () => { failIfDisposed(); if (catalog && !catalogRetryIsDue()) return catalog; if (!catalog) { await loadCatalog(); if (catalog) return catalog; const replayedCatalog = await loadCatalog(); return catalog ?? replayedCatalog; } const staleCatalog = catalog; catalogRetryAfterMs = void 0; loadCatalog().catch(() => { if (!retiredCatalog && catalog === staleCatalog && catalogRetryAfterMs === void 0) catalogRetryAfterMs = Date.now() + BUNDLE_MCP_CATALOG_FAILURE_RETRY_MS; }); return staleCatalog; }; const getActiveSession = async (requestedServer) => { if (requestedServer !== serverName) throw new Error(`bundle-mcp server "${requestedServer}" is not connected`); const signal = getSessionMcpRequestSignal(); signal?.throwIfAborted(); await racePromiseWithAbortSignal(getCatalog(), signal); return requireConnectedSession(); }; return { pluginOwned: !Object.hasOwn(params.cfg?.mcp?.servers ?? {}, serverName) || params.connectionOverrides?.has(serverName) === true, sessionId: params.sessionId, sessionKey: params.sessionKey, workspaceDir: params.workspaceDir, agentDir: params.agentDir, configFingerprint, ...params.requesterScope ? { requesterScope: params.requesterScope } : {}, ...params.requesterConnect ? { requesterConnect: params.requesterConnect } : {}, isRequesterScopedServer: () => params.requesterScope !== void 0, mcpAppsEnabled, createdAt, get lastUsedAt() { return lastUsedAt; }, get activeLeases() { return activeLeases; }, acquireLease() { activeLeases += 1; let released = false; return () => { if (released) return; released = true; activeLeases = Math.max(0, activeLeases - 1); }; }, getCatalog, get retiredCatalog() { return retiredCatalog; }, /** Synchronous catalog snapshot only; must not connect transports or issue tools/list. */ peekCatalog() { return catalog; }, /** Session-owned timeout that survives catalog invalidation. */ getServerRequestTimeoutMs(requestedServer) { return requestedServer === serverName ? currentSession?.requestTimeoutMs : void 0; }, markUsed() { lastUsedAt = Date.now(); }, async callTool(requestedServer, toolName, input) { const session = await getActiveSession(requestedServer); const validateResult = session.toolMetadata?.validatorForCall(toolName); const result = await runGuardedMcpRequest(session, (signal) => session.client.callTool({ name: toolName, arguments: isRecord(input) ? input : {} }, void 0, { timeout: session.requestTimeoutMs, signal })); validateResult?.(result); return result; }, async listTools(requestedServer, requestParams) { const session = await getActiveSession(requestedServer); return await runGuardedMcpRequest(session, (signal) => session.client.request({ method: "tools/list", params: requestParams }, ListToolsResultSchema, { timeout: session.requestTimeoutMs, signal })); }, async listResources(requestedServer, options) { const session = await getActiveSession(requestedServer); return await runGuardedServerRequest(session, async () => collectServerItems(session, "resources"), options); }, async readResource(requestedServer, uri, options) { const session = await getActiveSession(requestedServer); return await runGuardedMcpRequest(session, (signal) => session.client.readResource({ uri }, { timeout: session.requestTimeoutMs, signal }), options); }, async listResourceTemplates(requestedServer, requestParams) { const session = await getActiveSession(requestedServer); return await runGuardedMcpRequest(session, (signal) => session.client.listResourceTemplates(requestParams, { timeout: session.requestTimeoutMs, signal })); }, async listPrompts(requestedServer) { const session = await getActiveSession(requestedServer); return await runGuardedServerRequest(session, async () => collectServerItems(session, "prompts")); }, async getPrompt(requestedServer, name, args) { const session = await getActiveSession(requestedServer); return await runGuardedMcpRequest(session, (signal) => session.client.getPrompt({ name, ...args ? { arguments: args } : {} }, { timeout: session.requestTimeoutMs, signal })); }, async dispose() { if (retiredCatalog) return; retiredCatalog = { version: 1, generatedAt: Date.now(), servers: {}, tools: [], diagnostics: [{ serverName, safeServerName: params.safeServerNamesByServer?.get(serverName) ?? serverName, launchSummary: serverName, message: "MCP server runtime retired; retry discovery on the next turn." }] }; lifecycleAbortController.abort(createDisposedError(params.sessionId)); catalog = null; catalogRetryAfterMs = void 0; catalogInFlight = void 0; const session = currentSession; currentSession = void 0; if (session) await disposeBundleMcpSession(session); } }; } const testing = { buildMcpClientCapabilities, async resetSessionMcpRuntimeManager() { await disposeAllSessionMcpRuntimes(); setBundleMcpCatalogListTimeoutMsForTest(); setBundleMcpDisposeTimeoutMsForTest(); const { testing: resolverTesting } = await import("../mcp-connection-resolver-CrFetkw8.js"); resolverTesting.setMcpServerConnectionResolversForTest(); resolverTesting.setMcpConnectionResolverTimeoutMsForTest(); resolverTesting.setMcpConnectionRevalidateMsForTest(); }, getCachedSessionIds() { return getSessionMcpRuntimeManagerForTesting().listSessionIds(); }, getCachedRuntimeKeys() { return getSessionMcpRuntimeManagerForTesting().listRuntimeKeys(); }, getBookkeepingSizes(manager) { return manager.bookkeepingSizesForTest?.() ?? {}; }, setBundleMcpCatalogListTimeoutMsForTest, setBundleMcpDisposeTimeoutMsForTest, mergeMcpToolCatalogs }; //#endregion export { createMcpJsonSchemaValidator as createBundleMcpJsonSchemaValidator, createSessionMcpRuntime, testing };