UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

455 lines (454 loc) • 18.5 kB
import { l as normalizeOptionalString } from "./string-coerce-1VuiLaaR.mjs"; import { p as clampPositiveTimerTimeoutMs } from "./number-coercion-CuVhdCBs.mjs"; import { u as toErrorObject } from "./error-coercion-CvJK4lmH.mjs"; import { r as truncateUtf16Safe } from "./utf16-slice-D_ngcYKd.mjs"; import "./errors-O33D01XW.mjs"; import { n as truncateUtf8Suffix } from "./utf8-truncate-_hf7tp13.mjs"; import { a as normalizeJsonSchemaForTypeBox } from "./json-schema-BLReYZIW.mjs"; import { n as findJsonSchemaShapeError } from "./json-schema-defaults-YR_-N2-u.mjs"; import { t as logDebug } from "./logger-DskmrSVa.mjs"; import { n as resolveMcpTransportConfig } from "./mcp-transport-config-CzrjMN2p.mjs"; import { t as boundedJsonUtf8Bytes } from "./json-utf8-bytes-fm9i4b7G.mjs"; import { c as OpenClawStreamableHTTPClientTransport, o as OpenClawStdioClientTransport, s as OpenClawSSEClientTransport } from "./mcp-client-lifecycle-CTe8dHv_.mjs"; import { a as withoutMcpAuthorizationHeader, i as withSameOriginMcpHttpHeaders, r as buildMcpOAuthHttpFetch, t as buildMcpHttpFetch } from "./mcp-http-fetch-DPSJ01yY.mjs"; import { i as withMcpAuthProfileBearer, n as resolveMcpAuthProfileId } from "./mcp-auth-profile-CEl_bToU.mjs"; import { n as operatorMcpOAuthIdentity, r as requesterMcpOAuthIdentity } from "./mcp-oauth-identity-BAZLWoC_.mjs"; import { StringDecoder } from "node:string_decoder"; import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"; import { Compile } from "typebox/compile"; import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv-provider.js"; import { extractWWWAuthenticateParams } from "@modelcontextprotocol/sdk/client/auth.js"; //#region src/agents/mcp-metadata.ts const MCP_METADATA_TEXT_LIMIT = 1200; const MCP_APPS_CLIENT_EXTENSION = "io.modelcontextprotocol/ui"; const MCP_APP_RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; function buildMcpClientCapabilities(mcpAppsEnabled) { return mcpAppsEnabled ? { elicitation: { form: {}, url: {} }, extensions: { "openai/elicitation": { form: {} }, [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 }; } /** Scrubs untrusted MCP metadata before exposing it to a model. */ function sanitizeMcpMetadataText(value) { const normalized = normalizeOptionalString(value); if (!normalized) return; const scrubbed = normalized.replace(/(?:ignore|disregard)\s+(?:all\s+)?(?:previous|prior|above)\s+instructions/gi, "[redacted MCP metadata instruction]").replace(/system\s+prompt/gi, "system prompt"); return scrubbed.length > MCP_METADATA_TEXT_LIMIT ? `${truncateUtf16Safe(scrubbed, MCP_METADATA_TEXT_LIMIT)}...` : scrubbed; } //#endregion //#region src/agents/mcp-pagination.ts /** Shared bounded pagination for MCP list operations. */ function positiveInteger(value, label) { if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${label} must be a positive safe integer`); return value; } function abortError(signal, label) { return signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error(`${label} aborted`); } async function collectMcpPaginatedItems(params) { const timeoutMs = clampPositiveTimerTimeoutMs(params.timeoutMs); if (timeoutMs === void 0) throw new Error(`${params.label} requires a positive timeout`); const maxPages = positiveInteger(params.maxPages, `${params.label} maxPages`); const maxItems = positiveInteger(params.maxItems, `${params.label} maxItems`); const maxBytes = positiveInteger(params.maxBytes, `${params.label} maxBytes`); const deadlineController = new AbortController(); const signal = params.signal ? AbortSignal.any([params.signal, deadlineController.signal]) : deadlineController.signal; if (signal.aborted) throw abortError(signal, params.label); const deadlineAtMs = performance.now() + timeoutMs; const timeoutError = /* @__PURE__ */ new Error(`${params.label} timed out after ${timeoutMs}ms`); const deadlineTimer = setTimeout(() => deadlineController.abort(timeoutError), timeoutMs); deadlineTimer.unref?.(); const assertActive = () => { if (signal.aborted) throw abortError(signal, params.label); if (performance.now() >= deadlineAtMs) { deadlineController.abort(timeoutError); throw timeoutError; } }; let onAbort; const aborted = new Promise((_resolve, reject) => { onAbort = () => reject(abortError(signal, params.label)); signal.addEventListener("abort", onAbort, { once: true }); }); const items = []; const seenCursors = /* @__PURE__ */ new Set(); let collectedBytes = 0; let cursor; try { for (let pageNumber = 0; pageNumber < maxPages; pageNumber += 1) { assertActive(); const page = await Promise.race([params.loadPage({ cursor, requestTimeoutMs: timeoutMs, signal }), aborted]); assertActive(); const measured = boundedJsonUtf8Bytes(page.serializedValue ?? { items: page.items, nextCursor: page.nextCursor }, maxBytes - collectedBytes); if (!measured.complete || collectedBytes + measured.bytes > maxBytes) throw new Error(`${params.label} exceeded ${maxBytes} bytes`); collectedBytes += measured.bytes; for (const item of page.items) { const mapped = params.mapItem ? params.mapItem(item) : item; if (mapped === void 0) continue; if (items.length >= maxItems) throw new Error(`${params.label} exceeded ${maxItems} ${params.itemLabel}`); items.push(mapped); } const nextCursor = page.nextCursor; assertActive(); if (nextCursor === void 0) return items; if (seenCursors.has(nextCursor)) throw new Error(`${params.label} returned a repeated pagination cursor`); seenCursors.add(nextCursor); cursor = nextCursor; } throw new Error(`${params.label} exceeded ${maxPages} pages`); } finally { clearTimeout(deadlineTimer); if (onAbort) signal.removeEventListener("abort", onAbort); } } //#endregion //#region src/agents/mcp-json-schema-validator.ts const DRAFT_2020_12_SCHEMA = "https://json-schema.org/draft/2020-12/schema"; function isDraft202012Schema(schema) { return schema.$schema === DRAFT_2020_12_SCHEMA; } function formatTypeBoxErrors(errors) { return errors.map((error) => { const message = error.message?.trim() || "schema validation failed"; return error.instancePath ? `${error.instancePath} ${message}` : message; }).join(", ") || "schema validation failed"; } /** MCP SDK validator with draft-2020-12 support for external tool schemas. */ function createMcpJsonSchemaValidator() { const defaultValidator = new AjvJsonSchemaValidator(); return { getValidator(schema) { if (!isDraft202012Schema(schema)) return defaultValidator.getValidator(schema); let validator; try { const schemaError = findJsonSchemaShapeError(schema); if (schemaError) throw new Error(schemaError); validator = Compile(normalizeJsonSchemaForTypeBox(schema, { format: "annotation" })); } catch (error) { const setupError = toErrorObject(error, "schema setup failed"); throw new Error(`Invalid MCP draft-2020-12 JSON Schema: ${setupError.message}`, { cause: error }); } return (input) => { if (validator.Check(input)) return { valid: true, data: input, errorMessage: void 0 }; return { valid: false, data: void 0, errorMessage: formatTypeBoxErrors([...validator.Errors(input)]) }; }; } }; } //#endregion //#region src/agents/mcp-tool-metadata.ts /** Canonicalizes one server catalog before policy, publication, and call metadata diverge. */ function normalizeMcpToolCatalog(tools, schemaValidator, classify = () => "include") { const canonicalNames = tools.map((tool) => tool.name.trim()); const nameCounts = /* @__PURE__ */ new Map(); for (const toolName of canonicalNames) if (toolName) nameCounts.set(toolName, (nameCounts.get(toolName) ?? 0) + 1); const included = []; const deniedTools = []; const excludedTools = []; const resultValidators = /* @__PURE__ */ new Map(); for (const [index, sourceTool] of tools.entries()) { const toolName = canonicalNames[index] ?? ""; if (!toolName) continue; const tool = { ...sourceTool, name: toolName }; if (nameCounts.get(toolName) !== 1 || sourceTool.execution?.taskSupport === "required") { excludedTools.push(tool); continue; } const disposition = classify(toolName); if (disposition === "exclude") { excludedTools.push(tool); continue; } if (disposition === "include") { included.push(tool); if (tool.outputSchema) { const validator = schemaValidator.getValidator(tool.outputSchema); resultValidators.set(toolName, (result) => { if (result.structuredContent === void 0 && result.isError !== true) throw new McpError(ErrorCode.InvalidRequest, `Tool ${toolName} has an output schema but did not return structured content`); if (result.structuredContent === void 0) return; const validation = validator(result.structuredContent); if (!validation.valid) throw new McpError(ErrorCode.InvalidParams, `Structured content does not match the tool's output schema: ${validation.errorMessage}`); }); } } else deniedTools.push(tool); } return { tools: included, excludedTools, metadata: { validatorForCall(toolName) { return resultValidators.get(toolName); } }, deniedTools }; } //#endregion //#region src/agents/mcp-oauth-fetch.ts function withBearerHeader(init, accessToken) { const headers = new Headers(init.headers); headers.set("authorization", `Bearer ${accessToken}`); return { ...init, headers }; } async function toFetchInit(request) { const body = request.body ? await request.arrayBuffer() : void 0; return { method: request.method, headers: request.headers, body, cache: request.cache, credentials: request.credentials, integrity: request.integrity, keepalive: request.keepalive, mode: request.mode, redirect: request.redirect, referrer: request.referrer, referrerPolicy: request.referrerPolicy, signal: request.signal }; } /** * Own native OAuth retries above the MCP SDK transport. The SDK otherwise runs * refresh outside OpenClaw's cross-process OAuth lease on every 401/403. */ function withMcpOAuthBearer(params) { const resourceOrigin = new URL(params.identity.serverUrl).origin; return async (input, init) => { const source = input instanceof Request ? input.clone() : input; const request = new Request(source, init); const requestUrl = request.url; if (new URL(requestUrl).origin !== resourceOrigin) return await params.fetchFn(requestUrl, await toFetchInit(request)); const { recordMcpOAuthAuthorizationRequired, resolveMcpOAuthAccessToken } = await import("./mcp-oauth-DtfTyNJ_.mjs"); const accessToken = await resolveMcpOAuthAccessToken({ identity: params.identity, config: params.config, fetchFn: params.authFetchFn, acceptUnknownExpiry: true, allowMissingToken: true, signal: request.signal }); const fetchInit = await toFetchInit(request); const firstInit = accessToken ? withBearerHeader(fetchInit, accessToken) : fetchInit; const response = await params.fetchFn(requestUrl, firstInit); const challenge = extractWWWAuthenticateParams(response); const insufficientScope = response.status === 403 && challenge.error === "insufficient_scope"; if (!(response.status === 401 || insufficientScope)) return response; await response.body?.cancel().catch(() => void 0); const nextAccessToken = await resolveMcpOAuthAccessToken({ identity: params.identity, config: params.config, fetchFn: params.authFetchFn, acceptUnknownExpiry: true, authorizationChallenge: true, interactiveAuthorizationRequired: insufficientScope, rejectedAccessToken: accessToken, resourceMetadataUrl: challenge.resourceMetadataUrl, signal: request.signal, scope: challenge.scope }); const retryInit = withBearerHeader(fetchInit, nextAccessToken); const retryResponse = await params.fetchFn(requestUrl, retryInit); const retryChallenge = extractWWWAuthenticateParams(retryResponse); const retryInsufficientScope = retryResponse.status === 403 && retryChallenge.error === "insufficient_scope"; if (retryResponse.status === 401 || retryInsufficientScope) { const rejectedAccessToken = nextAccessToken; await recordMcpOAuthAuthorizationRequired({ identity: params.identity, rejectedAccessToken, resourceMetadataUrl: retryChallenge.resourceMetadataUrl ?? challenge.resourceMetadataUrl, scope: retryChallenge.scope ?? challenge.scope, signal: request.signal }); } return retryResponse; }; } //#endregion //#region src/agents/mcp-transport.ts /** * MCP client transport factory. * * This module turns normalized MCP server config into stdio, SSE, or * streamable-HTTP SDK transports with OpenClaw auth, redirect, and logging rules. */ const MAX_MCP_STDERR_LINE_BYTES = 8192; function attachStderrLogging(serverName, transport) { const stderr = transport.stderr; if (!stderr) return; const decoder = new StringDecoder("utf8"); let pending = ""; let truncated = false; let progressTimer; const emit = (text) => { const tail = truncateUtf8Suffix(text, MAX_MCP_STDERR_LINE_BYTES); const message = `${truncated || tail !== text ? "[stderr line truncated] " : ""}${tail}`.trim(); truncated = false; if (message) logDebug(`bundle-mcp:${serverName}: ${message}`); }; const flushProgress = () => { progressTimer = void 0; const text = pending; pending = ""; emit(text); }; const onData = (chunk) => { const decoded = decoder.write(typeof chunk === "string" ? Buffer.from(chunk) : chunk); const lines = (pending + decoded).split(/[\r\n]/); pending = lines.pop() ?? ""; for (const line of lines) emit(line); const tail = truncateUtf8Suffix(pending, MAX_MCP_STDERR_LINE_BYTES); truncated ||= tail !== pending; pending = tail; if (pending && !progressTimer) { progressTimer = setTimeout(flushProgress, 250); progressTimer.unref(); } else if (!pending) { clearTimeout(progressTimer); progressTimer = void 0; } }; const finalize = () => { stderr.off("data", onData); stderr.off("end", finalize); stderr.off("close", finalize); clearTimeout(progressTimer); pending += decoder.end(); flushProgress(); }; stderr.on("data", onData); stderr.on("end", finalize); stderr.on("close", finalize); return finalize; } function buildSseEventSourceFetch(headers, baseFetch) { return (url, init) => { const mergedHeaders = {}; for (const [key, value] of new Headers(init?.headers)) mergedHeaders[key.toLowerCase()] = value; for (const [key, value] of Object.entries(headers)) mergedHeaders[key.toLowerCase()] = value; return baseFetch(url, { ...init, headers: mergedHeaders }); }; } /** Resolves a configured MCP server into a live SDK transport instance. */ function resolveMcpTransport(serverName, rawServer, options) { const resolved = resolveMcpTransportConfig(serverName, rawServer); if (!resolved) return null; const metadata = { description: resolved.description, transportType: resolved.transportType, connectionTimeoutMs: resolved.connectionTimeoutMs, requestTimeoutMs: resolved.requestTimeoutMs, supportsParallelToolCalls: resolved.supportsParallelToolCalls }; if (resolved.kind === "stdio") { const transport = new OpenClawStdioClientTransport({ command: resolved.command, args: resolved.args, env: resolved.env, cwd: resolved.cwd, prepareDataDir: options?.prepareDataDir, stderr: "pipe" }); return { transport, ...metadata, detachStderr: attachStderrLogging(serverName, transport) }; } const authProfileId = resolveMcpAuthProfileId(rawServer); const requesterScope = options?.requesterScope; let oauthIdentity; if (resolved.oauth?.identity === "per-requester") { if (!requesterScope) return null; oauthIdentity = requesterMcpOAuthIdentity(serverName, resolved.url, requesterScope); } else oauthIdentity = operatorMcpOAuthIdentity(serverName, resolved.url); const baseFetch = buildMcpHttpFetch({ sslVerify: resolved.sslVerify, clientCert: resolved.clientCert, clientKey: resolved.clientKey, resourceUrl: resolved.url }); const headers = resolved.auth === "oauth" || authProfileId ? withoutMcpAuthorizationHeader(resolved.headers) : resolved.headers; const resourceFetch = withSameOriginMcpHttpHeaders({ fetchFn: baseFetch, headers, resourceUrl: resolved.url }); const httpFetch = authProfileId ? withMcpAuthProfileBearer({ fetchFn: baseFetch, serverName, resourceUrl: resolved.url, headers, authProfileId, cfg: options?.cfg, agentDir: options?.agentDir }) : resolved.auth === "oauth" ? withMcpOAuthBearer({ fetchFn: resourceFetch, authFetchFn: buildMcpOAuthHttpFetch({ sslVerify: resolved.sslVerify, clientCert: resolved.clientCert, clientKey: resolved.clientKey, resourceUrl: resolved.url, timeoutMs: resolved.requestTimeoutMs, headers }), identity: oauthIdentity, config: resolved.oauth }) : baseFetch; if (resolved.transportType === "streamable-http") return { transport: new OpenClawStreamableHTTPClientTransport(new URL(resolved.url), { requestInit: resolved.auth === "oauth" || !headers ? void 0 : { headers }, fetch: httpFetch }), ...metadata }; const sseHeaders = { ...headers }; const hasHeaders = Object.keys(sseHeaders).length > 0; return { transport: new OpenClawSSEClientTransport(new URL(resolved.url), { requestInit: resolved.auth === "oauth" || !hasHeaders ? void 0 : { headers: sseHeaders }, fetch: httpFetch, eventSourceInit: { fetch: buildSseEventSourceFetch(resolved.auth === "oauth" ? {} : sseHeaders, httpFetch) } }), ...metadata }; } //#endregion export { buildMcpClientCapabilities as a, summarizeServerCapabilities as c, collectMcpPaginatedItems as i, normalizeMcpToolCatalog as n, normalizeToolUiVisibility as o, createMcpJsonSchemaValidator as r, sanitizeMcpMetadataText as s, resolveMcpTransport as t };