openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
947 lines (946 loc) • 37.6 kB
JavaScript
import { p as clampPositiveTimerTimeoutMs } from "./number-coercion-CLj0HTDM.js";
import { l as toErrorObject } from "./error-coercion-D_-xJ90S.js";
import { c as isRecord } from "./record-coerce-DItp3I4t.js";
import { l as normalizeOptionalString } from "./string-coerce-CIXf7egm.js";
import { r as truncateUtf16Safe } from "./utf16-slice-D_ngcYKd.js";
import { n as signalProcessTree } from "./kill-tree-CR2oLt9D.js";
import { h as redactToolPayloadText } from "./redact-BtvPPfTi.js";
import { o as redactSensitiveUrlLikeString } from "./redact-sensitive-url-BN1NZvXG.js";
import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js";
import { r as normalizeJsonSchemaForTypeBox } from "./json-schema-DhpWleXB.js";
import { n as findJsonSchemaShapeError } from "./json-schema-defaults-CEZYvzdq.js";
import { t as mergeProcessEnv } from "./process-env-DlZFJzq6.js";
import { t as logDebug } from "./logger-DwECwNVZ.js";
import { n as truncateUtf8Suffix } from "./utf8-truncate-CCgJrv8A.js";
import { m as requesterMcpOAuthIdentity, p as operatorMcpOAuthIdentity } from "./mcp-oauth-store-rMpCI-eU.js";
import { c as recordMcpOAuthAuthorizationRequired, d as buildMcpHttpFetch, f as withSameOriginMcpHttpHeaders, l as resolveMcpOAuthAccessToken, p as withoutMcpAuthorizationHeader } from "./mcp-oauth-bxRFfdN1.js";
import { n as resolveMcpTransportConfig } from "./mcp-transport-config-mph-T0Ys.js";
import { t as prepareOomScoreAdjustedSpawn } from "./linux-oom-score-YCP0ajt5.js";
import { t as boundedJsonUtf8Bytes } from "./json-utf8-bytes-fm9i4b7G.js";
import { i as withMcpAuthProfileBearer, n as resolveMcpAuthProfileId } from "./mcp-auth-profile--_BtN_eA.js";
import process from "node:process";
import fs from "node:fs/promises";
import { spawn } from "node:child_process";
import { StringDecoder } from "node:string_decoder";
import { PassThrough } from "node:stream";
import { Compile } from "typebox/compile";
import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
import { extractWWWAuthenticateParams } from "@modelcontextprotocol/sdk/client/auth.js";
import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { SSEClientTransport, SseError } from "@modelcontextprotocol/sdk/client/sse.js";
import { ReadBuffer, STDIO_DEFAULT_MAX_BUFFER_SIZE, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js";
import { getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv-provider.js";
//#region src/agents/mcp-http-transport.ts
const STREAM_RETRY_EXHAUSTED_RE = /^Maximum reconnection attempts \(\d+\) exceeded\.$/;
const SESSION_TERMINATION_TIMEOUT_MS = 5e3;
var McpHttpResponseTooLargeError = class extends Error {
constructor(unit) {
super(`MCP ${unit} exceeds ${STDIO_DEFAULT_MAX_BUFFER_SIZE} bytes`);
this.code = "MCP_HTTP_RESPONSE_TOO_LARGE";
this.name = "McpHttpResponseTooLargeError";
}
};
function isMcpSseEventTooLargeError(error) {
return error.message.includes(`MCP SSE event exceeds ${STDIO_DEFAULT_MAX_BUFFER_SIZE} bytes`);
}
function isEventStreamResponse(response) {
return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === "text/event-stream";
}
function limitMcpResponseStream(body, eventStream) {
let messageBytes = 0;
let retainedEventBytes = 0;
let lineBytes = 0;
let lineIsComment = false;
let previousByteWasCr = false;
const checkEventLimit = () => {
if (retainedEventBytes + lineBytes > STDIO_DEFAULT_MAX_BUFFER_SIZE) throw new McpHttpResponseTooLargeError("SSE event");
};
const finishEventLine = () => {
if (lineBytes === 0) retainedEventBytes = 0;
else if (!lineIsComment) retainedEventBytes += lineBytes + 1;
lineBytes = 0;
lineIsComment = false;
checkEventLimit();
};
return body.pipeThrough(new TransformStream({ transform(chunk, controller) {
if (!eventStream) {
messageBytes += chunk.byteLength;
if (messageBytes > STDIO_DEFAULT_MAX_BUFFER_SIZE) throw new McpHttpResponseTooLargeError("HTTP response");
controller.enqueue(chunk);
return;
}
for (const byte of chunk) {
if (previousByteWasCr && byte === 10) {
previousByteWasCr = false;
continue;
}
previousByteWasCr = false;
if (byte === 13 || byte === 10) {
finishEventLine();
previousByteWasCr = byte === 13;
continue;
}
if (lineBytes === 0) lineIsComment = byte === 58;
lineBytes += 1;
checkEventLimit();
}
controller.enqueue(chunk);
} }));
}
function limitMcpHttpResponse(response) {
if (!response.body) return response;
return new Response(limitMcpResponseStream(response.body, isEventStreamResponse(response)), {
status: response.status,
statusText: response.statusText,
headers: response.headers
});
}
function withMcpHttpResponseLimits(fetchFn) {
return async (input, init) => limitMcpHttpResponse(await fetchFn(input, init));
}
function toEventSourceByteStream(body) {
if (body instanceof ReadableStream) return body;
const reader = body.getReader();
return new ReadableStream({
async pull(controller) {
const result = await reader.read();
if (result.done) {
controller.close();
return;
}
if (!(result.value instanceof Uint8Array)) {
await reader.cancel();
throw new TypeError("MCP SSE response body must contain byte chunks");
}
controller.enqueue(new Uint8Array(result.value));
},
async cancel() {
await reader.cancel();
}
});
}
function limitMcpEventSourceResponse(response) {
if (!response.body && response instanceof Response) return response;
const headers = response instanceof Response ? response.headers : new Headers();
if (!(response instanceof Response)) for (const name of ["content-type", "www-authenticate"]) {
const value = response.headers.get(name);
if (value) headers.set(name, value);
}
const body = response.body ? limitMcpResponseStream(toEventSourceByteStream(response.body), true) : null;
const limitedResponse = new Response(body, {
status: response.status,
...response instanceof Response ? { statusText: response.statusText } : {},
headers
});
Object.defineProperties(limitedResponse, {
url: { value: response.url },
redirected: { value: response.redirected }
});
return limitedResponse;
}
var OpenClawMcpHttpTransport = class {
constructor() {
this.closed = false;
this.closeEmitted = false;
}
emitClose() {
if (this.closeEmitted) return;
this.closeEmitted = true;
this.onclose?.();
}
emitError(error) {
if (!this.closed) this.onerror?.(error);
}
};
/** Converts legacy SSE terminal HTTP failures into the lifecycle close the SDK omits. */
var OpenClawSSEClientTransport = class extends OpenClawMcpHttpTransport {
constructor(url, options) {
super();
const baseFetch = options?.fetch ?? fetch;
const limitedFetch = withMcpHttpResponseLimits(baseFetch);
const eventSourceInit = options?.eventSourceInit;
const configuredEventSourceFetch = eventSourceInit?.fetch;
this.transport = new SSEClientTransport(url, {
...options,
fetch: limitedFetch,
eventSourceInit: {
...eventSourceInit,
fetch: async (eventUrl, init) => {
return limitMcpEventSourceResponse(configuredEventSourceFetch ? await configuredEventSourceFetch(eventUrl, init) : await baseFetch(eventUrl, init));
}
}
});
}
async start() {
this.transport.onmessage = (message) => this.onmessage?.(message);
this.transport.onclose = () => this.emitClose();
this.transport.onerror = (error) => {
this.emitError(error);
if (isMcpSseEventTooLargeError(error) || error instanceof SseError && error.code !== void 0) {
this.close();
setTimeout(() => void this.transport.close(), 0).unref?.();
}
};
await this.transport.start();
}
async close() {
if (this.closed) return;
this.closed = true;
await this.transport.close();
this.emitClose();
}
async send(message) {
if (this.closed) throw new Error("MCP SSE transport is closed");
await this.transport.send(message);
}
setProtocolVersion(version) {
this.transport.setProtocolVersion(version);
}
};
/** Owns Streamable HTTP notification recovery and stateful cleanup around SDK 1.30.0. */
var OpenClawStreamableHTTPClientTransport = class extends OpenClawMcpHttpTransport {
constructor(url, options = {}) {
super();
this.pendingExpiredNotificationGet = false;
this.url = url;
this.cleanupFetch = options.fetch ?? fetch;
this.requestInit = options.requestInit;
const runtimeFetch = async (input, init) => {
if (this.closed) throw new Error("MCP Streamable HTTP transport is closed");
const response = limitMcpHttpResponse(await this.cleanupFetch(input, init));
if (init?.method === "GET" && response.status === 404 && this.sessionId !== void 0) this.pendingExpiredNotificationGet = true;
return response;
};
this.transport = new StreamableHTTPClientTransport(url, {
...options,
fetch: runtimeFetch
});
}
get sessionId() {
return this.transport.sessionId;
}
get protocolVersion() {
return this.transport.protocolVersion;
}
async start() {
this.transport.onmessage = (message) => this.onmessage?.(message);
this.transport.onclose = () => this.emitClose();
this.transport.onerror = (error) => {
if (this.closed) {
setTimeout(() => void this.transport.close(), 0).unref?.();
return;
}
this.emitError(error);
const sessionExpired = this.pendingExpiredNotificationGet && error instanceof StreamableHTTPError && error.code === 404;
if (sessionExpired) this.pendingExpiredNotificationGet = false;
if (isMcpSseEventTooLargeError(error) || sessionExpired || STREAM_RETRY_EXHAUSTED_RE.test(error.message)) this.close();
};
await this.transport.start();
}
async close() {
if (this.closed) return;
this.closed = true;
await this.transport.close();
this.emitClose();
}
async send(message, options) {
await this.transport.send(message, options);
}
setProtocolVersion(version) {
this.transport.setProtocolVersion(version);
}
/** Uses a fresh request signal because failed initialization makes the SDK's signal unusable. */
async terminateSession() {
const sessionId = this.sessionId;
if (!sessionId || sessionId === this.terminatedSessionId) return;
const headers = new Headers(this.requestInit?.headers);
headers.set("mcp-session-id", sessionId);
if (this.protocolVersion) headers.set("mcp-protocol-version", this.protocolVersion);
const response = await this.cleanupFetch(this.url, {
...this.requestInit,
method: "DELETE",
headers,
signal: AbortSignal.timeout(SESSION_TERMINATION_TIMEOUT_MS)
});
await response.body?.cancel();
if (!response.ok && response.status !== 405) throw new StreamableHTTPError(response.status, `Failed to terminate session: ${response.statusText}`);
this.terminatedSessionId = sessionId;
}
};
//#endregion
//#region src/agents/mcp-stdio-transport.ts
/**
* OpenClaw stdio transport wrapper for MCP server subprocesses.
*/
const CLOSE_TIMEOUT_MS = 2e3;
const SIGKILL_REAP_TIMEOUT_MS = 500;
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms).unref();
});
}
var OpenClawStdioClientTransport = class {
constructor(serverParams) {
this.serverParams = serverParams;
this.readBuffer = new ReadBuffer();
this.stderrStream = null;
if (serverParams.stderr === "pipe" || serverParams.stderr === "overlapped") this.stderrStream = new PassThrough();
}
async start() {
if (this.process) throw new Error("OpenClawStdioClientTransport already started; Client.connect() starts transports automatically.");
const prepareDataDir = this.serverParams.prepareDataDir?.trim();
if (prepareDataDir) try {
await fs.mkdir(prepareDataDir, { recursive: true });
} catch (error) {
throw new Error(`unable to prepare PLUGIN_DATA directory "${prepareDataDir}": ${formatErrorMessage(error)}`, { cause: error });
}
await new Promise((resolve, reject) => {
const baseEnv = mergeProcessEnv([getDefaultEnvironment(), this.serverParams.env]);
const preparedSpawn = prepareOomScoreAdjustedSpawn(this.serverParams.command, this.serverParams.args ?? [], { env: baseEnv });
const child = spawn(preparedSpawn.command, preparedSpawn.args, {
cwd: this.serverParams.cwd,
detached: process.platform !== "win32",
env: preparedSpawn.env,
shell: false,
stdio: [
"pipe",
"pipe",
this.serverParams.stderr ?? "inherit"
],
windowsHide: process.platform === "win32"
});
this.process = child;
if (process.platform !== "win32" && child.pid) this.ownedProcessGroupId = child.pid;
child.on("error", (error) => {
reject(error);
this.onerror?.(error);
});
child.on("spawn", () => resolve());
child.on("close", () => {
if (this.process === child) this.process = void 0;
if (child.pid && this.ownedProcessGroupId === child.pid) {
signalProcessTree(child.pid, "SIGKILL", { detached: true });
this.ownedProcessGroupId = void 0;
}
this.onclose?.();
});
child.stdin?.on("error", (error) => this.onerror?.(error));
child.stdout?.on("data", (chunk) => {
try {
this.readBuffer.append(chunk);
this.processReadBuffer();
} catch (error) {
this.onerror?.(error instanceof Error ? error : new Error(String(error)));
this.close();
}
});
child.stdout?.on("error", (error) => this.onerror?.(error));
if (this.stderrStream && child.stderr) {
child.stderr.on("error", (error) => this.onerror?.(error));
child.stderr.pipe(this.stderrStream);
}
});
}
get stderr() {
return this.stderrStream ?? this.process?.stderr ?? null;
}
get pid() {
return this.process?.pid ?? this.closingProcess?.pid ?? null;
}
processReadBuffer() {
while (true) try {
const message = this.readBuffer.readMessage();
if (message === null) break;
this.onmessage?.(message);
} catch (error) {
this.onerror?.(error instanceof Error ? error : new Error(String(error)));
}
}
async close() {
const processToClose = this.process ?? this.closingProcess;
const ownedProcessGroupId = this.ownedProcessGroupId;
this.process = void 0;
this.closingProcess = processToClose;
if (processToClose) {
const closePromise = new Promise((resolve) => {
processToClose.once("close", () => resolve());
});
try {
processToClose.stdin?.end();
} catch {}
await Promise.race([closePromise, delay(CLOSE_TIMEOUT_MS)]);
if (processToClose.exitCode === null && processToClose.pid) {
signalProcessTree(processToClose.pid, "SIGTERM", { detached: true });
await Promise.race([closePromise, delay(CLOSE_TIMEOUT_MS)]);
if (processToClose.exitCode === null && processToClose.pid) {
signalProcessTree(processToClose.pid, "SIGKILL", { detached: true });
await Promise.race([closePromise, delay(SIGKILL_REAP_TIMEOUT_MS)]);
}
}
}
if (this.closingProcess === processToClose) this.closingProcess = void 0;
if (this.ownedProcessGroupId === ownedProcessGroupId) this.ownedProcessGroupId = void 0;
this.readBuffer.clear();
}
async forceClose() {
const processToClose = this.process ?? this.closingProcess;
const ownedProcessGroupId = this.ownedProcessGroupId;
this.process = void 0;
if (processToClose?.pid && processToClose.exitCode === null) {
const closePromise = new Promise((resolve) => {
processToClose.once("close", () => resolve());
});
signalProcessTree(processToClose.pid, "SIGKILL", { detached: true });
await Promise.race([closePromise, delay(SIGKILL_REAP_TIMEOUT_MS)]);
} else if (ownedProcessGroupId) signalProcessTree(ownedProcessGroupId, "SIGKILL", { detached: true });
if (this.closingProcess === processToClose) this.closingProcess = void 0;
if (this.ownedProcessGroupId === ownedProcessGroupId) this.ownedProcessGroupId = void 0;
this.readBuffer.clear();
}
send(message) {
return new Promise((resolve, reject) => {
const stdin = this.process?.stdin;
if (!stdin) throw new Error("Not connected");
const json = serializeMessage(message);
try {
if (!stdin.write(json, (err) => {
if (err) reject(err);
else resolve();
})) stdin.once("drain", () => {});
} catch (err) {
reject(err instanceof Error ? err : new Error(String(err)));
}
});
}
};
//#endregion
//#region src/agents/mcp-client-lifecycle.ts
var McpClientConnectTimeoutError = class extends Error {};
/** Matches the SDK's terminal signal for an expired stateful Streamable HTTP session. */
function isStatefulMcpHttpSessionExpired(session, error) {
return session.transportType === "streamable-http" && session.transport instanceof OpenClawStreamableHTTPClientTransport && session.transport.sessionId !== void 0 && error instanceof StreamableHTTPError && error.code === 404;
}
async function connectMcpClient(params) {
const deadline = AbortSignal.timeout(params.timeoutMs);
const signal = params.signal ? AbortSignal.any([params.signal, deadline]) : deadline;
let onAbort;
const aborted = new Promise((_, reject) => {
onAbort = () => reject(signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("MCP startup aborted"));
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
});
try {
await Promise.race([params.client.connect(params.transport, {
signal,
timeout: params.timeoutMs,
maxTotalTimeout: params.timeoutMs
}), aborted]);
} catch (error) {
if (deadline.aborted || isRecord(error) && error.code === ErrorCode.RequestTimeout) {
await disposeMcpClient({
client: params.client,
transport: params.transport,
transportType: params.transport instanceof OpenClawStdioClientTransport ? "stdio" : params.transport instanceof OpenClawStreamableHTTPClientTransport ? "streamable-http" : "sse"
}, Math.min(params.timeoutMs, 1e3));
throw new McpClientConnectTimeoutError(`MCP server connection timed out after ${params.timeoutMs}ms`, { cause: error });
}
throw error;
} finally {
if (onAbort) signal.removeEventListener("abort", onAbort);
}
}
async function settleWithin(promise, timeoutMs) {
let timer;
return await Promise.race([promise.then(() => true, () => true), new Promise((resolve) => {
timer = setTimeout(() => resolve(false), timeoutMs);
timer.unref?.();
})]).finally(() => clearTimeout(timer));
}
async function ignoreCloseFailure(close) {
try {
await close();
} catch {}
}
async function disposeMcpClient(session, timeoutMs = 5e3) {
try {
if (await settleWithin((async () => {
if (session.transportType === "streamable-http") await ignoreCloseFailure(() => session.transport.terminateSession?.());
await ignoreCloseFailure(() => session.transport.close());
await ignoreCloseFailure(() => session.client.close());
})(), timeoutMs)) return;
const { transport } = session;
const closeTransport = session.transportType === "stdio" && transport instanceof OpenClawStdioClientTransport ? () => transport.forceClose() : () => transport.close();
await settleWithin(Promise.all([ignoreCloseFailure(closeTransport), ignoreCloseFailure(() => session.client.close())]), timeoutMs);
} finally {
session.detachStderr?.();
}
}
//#endregion
//#region src/agents/mcp-error.ts
const STREAMABLE_RESPONSE_BODY_MARKER = "Error POSTing to endpoint:";
const LEGACY_RESPONSE_BODY_RE = /Error POSTing to endpoint \(HTTP \d+\):/;
/** Redacts MCP diagnostics, including response bodies the SDK includes in thrown errors. */
function redactMcpDiagnosticError(error) {
let message = formatErrorMessage(error);
const streamableIndex = message.indexOf(STREAMABLE_RESPONSE_BODY_MARKER);
const legacyMatch = LEGACY_RESPONSE_BODY_RE.exec(message);
const prefixEnd = streamableIndex >= 0 ? streamableIndex + 26 : legacyMatch ? legacyMatch.index + legacyMatch[0].length : void 0;
if (prefixEnd !== void 0) message = `${message.slice(0, prefixEnd)} [redacted response body]`;
return redactToolPayloadText(redactSensitiveUrlLikeString(message));
}
//#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";
}
const schemaMapKeywords = /* @__PURE__ */ new Set([
"$defs",
"definitions",
"dependentSchemas",
"patternProperties",
"properties"
]);
const schemaValueKeywords = /* @__PURE__ */ new Set([
"additionalItems",
"additionalProperties",
"contains",
"else",
"if",
"items",
"not",
"propertyNames",
"then",
"unevaluatedItems",
"unevaluatedProperties"
]);
const schemaArrayKeywords = /* @__PURE__ */ new Set([
"allOf",
"anyOf",
"oneOf",
"prefixItems"
]);
function stripSchemaMapFormats(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, stripJsonSchemaFormats(entry)]));
}
function expandJsonSchemaTypeArray(schema) {
const { type, ...rest } = schema;
if (!Array.isArray(type)) return schema;
return { anyOf: type.map((entry) => Object.assign({}, rest, { type: entry })) };
}
function stripJsonSchemaFormats(schema) {
if (Array.isArray(schema)) return schema.map((entry) => stripJsonSchemaFormats(entry));
if (!schema || typeof schema !== "object") return schema;
const normalizedSchema = expandJsonSchemaTypeArray(schema);
return Object.fromEntries(Object.entries(normalizedSchema).filter(([key]) => key !== "format").map(([key, value]) => {
if (schemaMapKeywords.has(key)) return [key, stripSchemaMapFormats(value)];
if (key === "dependencies") return [key, stripSchemaMapFormats(value)];
if (schemaValueKeywords.has(key) || schemaArrayKeywords.has(key)) return [key, stripJsonSchemaFormats(value)];
return [key, value];
}));
}
/** 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(stripJsonSchemaFormats(schema)));
} 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-metadata.ts
const MCP_METADATA_TEXT_LIMIT = 1200;
/** 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\s+(?:all\s+)?(?:previous|prior|above)\s+instructions/gi, "[redacted MCP metadata instruction]").replace(/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-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({
...sourceTool,
name: toolName
});
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 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;
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,
description: resolved.description,
transportType: "stdio",
connectionTimeoutMs: resolved.connectionTimeoutMs,
requestTimeoutMs: resolved.requestTimeoutMs,
supportsParallelToolCalls: resolved.supportsParallelToolCalls,
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: resourceFetch,
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
}),
description: resolved.description,
transportType: "streamable-http",
connectionTimeoutMs: resolved.connectionTimeoutMs,
requestTimeoutMs: resolved.requestTimeoutMs,
supportsParallelToolCalls: resolved.supportsParallelToolCalls
};
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) }
}),
description: resolved.description,
transportType: "sse",
connectionTimeoutMs: resolved.connectionTimeoutMs,
requestTimeoutMs: resolved.requestTimeoutMs,
supportsParallelToolCalls: resolved.supportsParallelToolCalls
};
}
//#endregion
export { createMcpJsonSchemaValidator as a, connectMcpClient as c, sanitizeMcpMetadataText as i, disposeMcpClient as l, normalizeMcpToolCatalog as n, redactMcpDiagnosticError as o, collectMcpPaginatedItems as r, McpClientConnectTimeoutError as s, resolveMcpTransport as t, isStatefulMcpHttpSessionExpired as u };