UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

817 lines (816 loc) 29.9 kB
import { j as resolveIntegerOption } from "./number-coercion-CLj0HTDM.js"; import { c as normalizeOptionalLowercaseString, l as normalizeOptionalString, o as normalizeLowercaseStringOrEmpty } from "./string-coerce-CIXf7egm.js"; import { At as boolean, Rn as string, Tn as object, dn as literal, wn as number, yt as _enum } from "./schemas-zxit8y5H.js"; import { t as VERSION } from "./version-v1kuAkGj.js"; import { d as readPersistedMediaFacts, s as isMeaningfulMediaFact } from "./media-facts-8Sl_nPCc.js"; import { r as extractFirstTextBlock } from "./chat-message-content-Dws_XUEQ.js"; import { randomUUID } from "node:crypto"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; //#region src/mcp/channel-shared.ts /** Raw MCP notification schema emitted by Claude channel clients for permission prompts. */ const ClaudePermissionRequestSchema = object({ method: literal("notifications/claude/channel/permission_request"), params: object({ request_id: string(), tool_name: string(), description: string(), input_preview: string() }) }); /** Resolve the visible message id, including OpenClaw metadata attached to raw entries. */ function resolveMessageId(entry) { return normalizeOptionalString(entry.id) ?? (entry["__openclaw"] && typeof entry["__openclaw"] === "object" ? normalizeOptionalString(entry["__openclaw"].id) : void 0); } /** Build the text summary format expected by simple MCP tool results. */ function summarizeResult(label, count) { return { content: [{ type: "text", text: `${label}: ${count}` }] }; } /** Build a text summary plus pretty JSON payload for MCP clients without structured rendering. */ function summarizeStructuredResult(label, count, payload) { return { content: [{ type: "text", text: `${label}: ${count}\n\n${JSON.stringify(payload, null, 2)}` }] }; } function resolveConversationChannel(row) { return normalizeOptionalLowercaseString(normalizeOptionalString(row.deliveryContext?.channel) ?? normalizeOptionalString(row.lastChannel) ?? normalizeOptionalString(row.channel) ?? normalizeOptionalString(row.origin?.provider)); } /** Convert a Gateway session row into a reply-capable conversation descriptor. */ function toConversation(row) { const channel = resolveConversationChannel(row); const to = normalizeOptionalString(row.deliveryContext?.to) ?? normalizeOptionalString(row.lastTo); if (!channel || !to) return null; return { sessionKey: row.key, channel, to, accountId: normalizeOptionalString(row.deliveryContext?.accountId) ?? normalizeOptionalString(row.lastAccountId) ?? normalizeOptionalString(row.origin?.accountId), threadId: row.deliveryContext?.threadId ?? row.lastThreadId ?? row.origin?.threadId, label: normalizeOptionalString(row.label), displayName: normalizeOptionalString(row.displayName), derivedTitle: normalizeOptionalString(row.derivedTitle), lastMessagePreview: normalizeOptionalString(row.lastMessagePreview), updatedAt: typeof row.updatedAt === "number" ? row.updatedAt : null }; } /** Check whether a queued event should be visible to a poll or wait call. */ function matchEventFilter(event, filter) { if (event.cursor <= filter.afterCursor) return false; if (!filter.sessionKey) return true; return "sessionKey" in event && event.sessionKey === filter.sessionKey; } /** Return non-text content blocks plus canonical persisted media from a raw message payload. */ function extractAttachmentsFromMessage(message) { if (!message || typeof message !== "object") return []; const content = message.content; const contentAttachments = Array.isArray(content) ? content.filter((entry) => { if (!entry || typeof entry !== "object") return false; return normalizeOptionalString(entry.type) !== "text"; }) : []; const mediaAttachments = (readPersistedMediaFacts(message) ?? []).filter(isMeaningfulMediaFact).map((media) => ({ type: "openclaw_media", media: Object.fromEntries(Object.entries(media).filter(([, value]) => value !== void 0)) })); return [...contentAttachments, ...mediaAttachments]; } /** Normalize approval identifiers before local tracking or resolution. */ function normalizeApprovalId(value) { const id = normalizeOptionalString(value); return id ? id.trim() : void 0; } //#endregion //#region src/mcp/channel-bridge.ts const CLAUDE_PERMISSION_REPLY_RE = /^(yes|no)\s+([a-km-z]{5})$/i; const QUEUE_LIMIT = 1e3; const CONVERSATIONS_LIST_LIMIT = 500; const MESSAGES_READ_LIMIT = 200; const EVENTS_POLL_LIMIT = 200; const EVENTS_WAIT_TIMEOUT_LIMIT_MS = 3e5; const PENDING_CLAUDE_PERMISSION_TTL_MS = 36e5; const PENDING_APPROVAL_DEFAULT_TTL_MS = 18e5; const PENDING_SWEEP_INTERVAL_MS = 3e5; /** Connects the MCP server surface to a Gateway client and queues channel events for polling. */ var OpenClawChannelBridge = class { constructor(cfg, params) { this.cfg = cfg; this.params = params; this.gateway = null; this.queue = []; this.pendingWaiters = /* @__PURE__ */ new Set(); this.pendingClaudePermissions = /* @__PURE__ */ new Map(); this.pendingApprovals = /* @__PURE__ */ new Map(); this.pendingSweepInterval = null; this.server = null; this.cursor = 0; this.closed = false; this.ready = false; this.started = false; this.retryingInitialConnect = false; this.supportsExactMessageLookup = false; this.readySettled = false; this.verbose = params.verbose; this.claudeChannelMode = params.claudeChannelMode; this.readyPromise = new Promise((resolve, reject) => { this.resolveReady = resolve; this.rejectReady = reject; }); } /** Attach the MCP server used for outbound protocol notifications. */ setServer(server) { this.server = server; } /** Start the Gateway connection and resolve only after session subscription succeeds. */ async start() { if (this.started) { await this.readyPromise; return; } this.started = true; const [{ resolveGatewayClientBootstrap }, { GatewayClient: GatewayClientCtor }, { startGatewayClientWhenEventLoopReady }, { APPROVALS_SCOPE, READ_SCOPE, WRITE_SCOPE }, { GATEWAY_CLIENT_CAPS, GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES }] = await Promise.all([ import("./client-bootstrap-CM1IzVvm.js"), import("./client-BT5GVyth.js"), import("./client-start-readiness-grqUg7wf.js"), import("./method-scopes-C71Mmygv.js"), import("./client-info-D2o_Meqy.js") ]); const bootstrap = await resolveGatewayClientBootstrap({ config: this.cfg, gatewayUrl: this.params.gatewayUrl, explicitAuth: { token: this.params.gatewayToken, password: this.params.gatewayPassword }, env: process.env }); if (this.closed) { this.resolveReadyOnce(); return; } this.gateway = new GatewayClientCtor({ url: bootstrap.url, token: bootstrap.auth.token, password: bootstrap.auth.password, preauthHandshakeTimeoutMs: bootstrap.preauthHandshakeTimeoutMs, tlsFingerprint: bootstrap.tlsFingerprint, clientName: GATEWAY_CLIENT_NAMES.CLI, clientDisplayName: "OpenClaw MCP", clientVersion: VERSION, mode: GATEWAY_CLIENT_MODES.CLI, caps: [GATEWAY_CLIENT_CAPS.APPROVALS], scopes: [ READ_SCOPE, WRITE_SCOPE, APPROVALS_SCOPE ], requestTimeoutMs: 18e4, onEvent: (event) => { this.dispatchGatewayEvent(event); }, onHelloOk: (hello) => { this.supportsExactMessageLookup = hello.features.methods.includes("chat.message.get"); this.retryingInitialConnect = false; this.handleHelloOk(); }, onConnectError: (error) => { const normalizedError = error instanceof Error ? error : new Error(String(error)); if (shouldRetryInitialMcpGatewayConnect(normalizedError)) { this.retryingInitialConnect = true; return; } this.rejectReadyOnce(normalizedError); }, onClose: (code, reason) => { if (!this.ready && !this.closed && !this.retryingInitialConnect) this.rejectReadyOnce(/* @__PURE__ */ new Error(`gateway closed before ready (${code}): ${reason}`)); this.retryingInitialConnect = false; } }); if (!(await startGatewayClientWhenEventLoopReady(this.gateway, { clientOptions: { preauthHandshakeTimeoutMs: bootstrap.preauthHandshakeTimeoutMs } })).ready) this.rejectReadyOnce(/* @__PURE__ */ new Error("gateway event loop readiness timeout")); await this.readyPromise; } /** Wait until the bridge has subscribed to Gateway session events. */ async waitUntilReady() { await this.readyPromise; } /** Stop Gateway IO and release waiters so MCP shutdown cannot hang on pending polls. */ async close() { if (this.closed) return; this.closed = true; this.resolveReadyOnce(); if (this.pendingSweepInterval) { clearInterval(this.pendingSweepInterval); this.pendingSweepInterval = null; } this.pendingClaudePermissions.clear(); this.pendingApprovals.clear(); for (const waiter of this.pendingWaiters) waiter.settle(null); const gateway = this.gateway; this.gateway = null; await gateway?.stopAndWait(); } /** List Gateway sessions that have enough routing metadata to be channel conversations. */ async listConversations(params) { await this.waitUntilReady(); const limit = resolveIntegerOption(params?.limit, 50, { min: 1, max: CONVERSATIONS_LIST_LIMIT }); const response = await this.requestGateway("sessions.list", { limit, search: params?.search, includeDerivedTitles: params?.includeDerivedTitles ?? true, includeLastMessage: params?.includeLastMessage ?? true }); const requestedChannel = normalizeOptionalLowercaseString(params?.channel); return (response.sessions ?? []).map(toConversation).filter((conversation) => Boolean(conversation)).filter((conversation) => requestedChannel ? normalizeLowercaseStringOrEmpty(conversation.channel) === requestedChannel : true); } /** Resolve one conversation by its stable session key. */ async getConversation(sessionKey) { const normalizedSessionKey = sessionKey.trim(); if (!normalizedSessionKey) return null; await this.waitUntilReady(); const response = await this.requestGateway("sessions.describe", { key: normalizedSessionKey, includeDerivedTitles: true, includeLastMessage: true }); return response.session ? toConversation(response.session) : null; } /** Read recent history through the Gateway session API. */ async readMessages(sessionKey, limit = 20) { await this.waitUntilReady(); const requestLimit = resolveIntegerOption(limit, 20, { min: 1, max: MESSAGES_READ_LIMIT }); return (await this.requestGateway("sessions.get", { key: sessionKey, limit: requestLimit })).messages ?? []; } async readMessage(sessionKey, messageId, legacyLimit = 100) { await this.waitUntilReady(); if (!this.supportsExactMessageLookup) return (await this.readMessages(sessionKey, legacyLimit)).find((entry) => resolveMessageId(entry) === messageId) ?? null; const result = await this.requestGateway("chat.message.get", { sessionKey, messageId }); return result.ok === true ? result.message ?? null : null; } /** Send a reply using the same channel route stored on the conversation. */ async sendMessage(params) { const conversation = await this.getConversation(params.sessionKey); if (!conversation) throw new Error(`Conversation not found for session ${params.sessionKey}`); return await this.requestGateway("send", { to: conversation.to, channel: conversation.channel, accountId: conversation.accountId, threadId: conversation.threadId == null ? void 0 : String(conversation.threadId), message: params.text, sessionKey: conversation.sessionKey, idempotencyKey: randomUUID() }); } /** Return locally tracked approval requests that are still open. */ listPendingApprovals() { this.sweepPendingExpired(); return [...this.pendingApprovals.values()].map((entry) => entry.approval).toSorted((a, b) => { return (a.createdAtMs ?? 0) - (b.createdAtMs ?? 0); }); } /** Forward an MCP approval decision to the matching Gateway approval resolver. */ async respondToApproval(params) { if (params.kind === "exec") return await this.requestGateway("exec.approval.resolve", { id: params.id, decision: params.decision }); return await this.requestGateway("plugin.approval.resolve", { id: params.id, decision: params.decision }); } /** Poll queued events after a cursor without consuming them. */ pollEvents(filter, limit = 20) { const eventLimit = resolveIntegerOption(limit, 20, { min: 1, max: EVENTS_POLL_LIMIT }); const events = this.queue.filter((event) => matchEventFilter(event, filter)).slice(0, eventLimit); const gap = this.resolveCursorGap(filter.afterCursor); return { events, nextCursor: events.at(-1)?.cursor ?? (gap ? gap.oldest_available_cursor - 1 : filter.afterCursor), ...gap ? { gap } : {} }; } /** Wait for the next matching event, returning a closed outcome on every settle path. */ async waitForEvent(filter, timeoutMs = 3e4, signal) { signal?.throwIfAborted(); const existing = this.queue.find((event) => matchEventFilter(event, filter)); const gap = this.resolveCursorGap(filter.afterCursor); if (existing || gap) return { event: existing ?? null, ...gap ? { gap } : {} }; const waitTimeoutMs = resolveIntegerOption(timeoutMs, 3e4, { min: 1, max: EVENTS_WAIT_TIMEOUT_LIMIT_MS }); return await new Promise((resolve) => { let settled = false; const onAbort = () => waiter.settle(null); const waiter = { filter, settle: (event) => { if (settled) return; settled = true; this.pendingWaiters.delete(waiter); clearTimeout(timeout); signal?.removeEventListener("abort", onAbort); const currentGap = this.resolveCursorGap(filter.afterCursor); resolve({ event, ...currentGap ? { gap: currentGap } : {} }); } }; const timeout = setTimeout(() => { waiter.settle(null); }, waitTimeoutMs); signal?.addEventListener("abort", onAbort, { once: true }); this.pendingWaiters.add(waiter); if (signal?.aborted) waiter.settle(null); }); } /** Accept a Claude channel permission notification and expose it through event polling. */ async handleClaudePermissionRequest(params) { if (this.closed) return; this.pendingClaudePermissions.set(params.requestId, Date.now()); this.ensurePendingSweeper(); this.enqueue({ cursor: this.nextCursor(), type: "claude_permission_request", requestId: params.requestId, toolName: params.toolName, description: params.description, inputPreview: params.inputPreview }); if (this.verbose) process.stderr.write(`openclaw mcp: pending Claude permission ${params.requestId}\n`); } async requestGateway(method, params) { if (!this.gateway) throw new Error("Gateway client is not ready"); return await this.gateway.request(method, params); } async sendNotification(notification) { if (!this.server || this.closed) return "unavailable"; try { await this.server.server.notification(notification); return "delivered"; } catch (error) { if (this.closed) return "unavailable"; process.stderr.write(`openclaw mcp: notification ${notification.method} failed\n`); if (this.verbose) process.stderr.write(`openclaw mcp: notification ${notification.method} error: ${String(error)}\n`); return "failed"; } } async handleHelloOk() { try { await this.requestGateway("sessions.subscribe", {}); this.ready = true; this.resolveReadyOnce(); } catch (error) { this.rejectReadyOnce(error instanceof Error ? error : new Error(String(error))); } } resolveReadyOnce() { if (this.readySettled) return; this.readySettled = true; this.resolveReady(); } rejectReadyOnce(error) { if (this.readySettled) return; this.readySettled = true; this.rejectReady(error); } nextCursor() { this.cursor += 1; return this.cursor; } resolveCursorGap(afterCursor) { const oldestAvailableCursor = this.queue[0]?.cursor; return oldestAvailableCursor !== void 0 && afterCursor < oldestAvailableCursor - 1 ? { requested_after_cursor: afterCursor, oldest_available_cursor: oldestAvailableCursor } : void 0; } enqueue(event) { this.queue.push(event); while (this.queue.length > QUEUE_LIMIT) this.queue.shift(); for (const waiter of this.pendingWaiters) { const matches = matchEventFilter(event, waiter.filter); if (matches || this.resolveCursorGap(waiter.filter.afterCursor)) waiter.settle(matches ? event : null); } } trackApproval(kind, payload) { if (this.closed) return; const id = normalizeApprovalId(payload.id); if (!id) return; this.pendingApprovals.set(id, { approval: { kind, id, request: payload.request && typeof payload.request === "object" ? payload.request : void 0, createdAtMs: typeof payload.createdAtMs === "number" ? payload.createdAtMs : void 0, expiresAtMs: typeof payload.expiresAtMs === "number" ? payload.expiresAtMs : void 0 }, trackedAtMs: Date.now() }); this.ensurePendingSweeper(); } ensurePendingSweeper() { if (this.pendingSweepInterval || this.closed) return; this.pendingSweepInterval = setInterval(() => { this.sweepPendingExpired(); }, PENDING_SWEEP_INTERVAL_MS); this.pendingSweepInterval.unref(); } sweepPendingExpired(now = Date.now()) { for (const [id, createdAtMs] of this.pendingClaudePermissions) if (now - createdAtMs >= PENDING_CLAUDE_PERMISSION_TTL_MS) this.pendingClaudePermissions.delete(id); for (const [id, entry] of this.pendingApprovals) if (now >= (entry.approval.expiresAtMs ?? entry.trackedAtMs + PENDING_APPROVAL_DEFAULT_TTL_MS)) this.pendingApprovals.delete(id); if (this.pendingSweepInterval && this.pendingClaudePermissions.size === 0 && this.pendingApprovals.size === 0) { clearInterval(this.pendingSweepInterval); this.pendingSweepInterval = null; } } resolveTrackedApproval(payload) { const id = normalizeApprovalId(payload.id); if (id) this.pendingApprovals.delete(id); } async dispatchGatewayEvent(event) { try { await this.handleGatewayEvent(event); } catch (error) { process.stderr.write(`openclaw mcp: gateway event ${event.event} failed\n`); if (this.verbose) process.stderr.write(`openclaw mcp: gateway event ${event.event} error: ${String(error)}\n`); } } async handleGatewayEvent(event) { switch (event.event) { case "session.message": await this.handleSessionMessageEvent(event.payload); return; case "exec.approval.requested": { const raw = event.payload ?? {}; this.trackApproval("exec", raw); this.enqueue({ cursor: this.nextCursor(), type: "exec_approval_requested", raw }); return; } case "exec.approval.resolved": { const raw = event.payload ?? {}; this.resolveTrackedApproval(raw); this.enqueue({ cursor: this.nextCursor(), type: "exec_approval_resolved", raw }); return; } case "plugin.approval.requested": { const raw = event.payload ?? {}; this.trackApproval("plugin", raw); this.enqueue({ cursor: this.nextCursor(), type: "plugin_approval_requested", raw }); return; } case "plugin.approval.resolved": { const raw = event.payload ?? {}; this.resolveTrackedApproval(raw); this.enqueue({ cursor: this.nextCursor(), type: "plugin_approval_resolved", raw }); } } } async handleSessionMessageEvent(payload) { const sessionKey = normalizeOptionalString(payload.sessionKey); if (!sessionKey) return; const conversation = toConversation({ key: sessionKey, lastChannel: normalizeOptionalString(payload.lastChannel), lastTo: normalizeOptionalString(payload.lastTo), lastAccountId: normalizeOptionalString(payload.lastAccountId), lastThreadId: payload.lastThreadId }) ?? void 0; const role = normalizeOptionalString(payload.message?.role); const text = extractFirstTextBlock(payload.message); const permissionMatch = text ? CLAUDE_PERMISSION_REPLY_RE.exec(text) : null; if (role === "user" && payload.senderIsOwner === true && permissionMatch) { const requestId = normalizeOptionalLowercaseString(permissionMatch[2]); if (requestId && this.pendingClaudePermissions.has(requestId)) { if (await this.sendNotification({ method: "notifications/claude/channel/permission", params: { request_id: requestId, behavior: normalizeLowercaseStringOrEmpty(permissionMatch[1]).startsWith("y") ? "allow" : "deny" } }) === "delivered") this.pendingClaudePermissions.delete(requestId); return; } } this.enqueue({ cursor: this.nextCursor(), type: "message", sessionKey, conversation, messageId: normalizeOptionalString(payload.messageId), messageSeq: typeof payload.messageSeq === "number" ? payload.messageSeq : void 0, role, text, raw: payload }); if (!this.shouldEmitClaudeChannel(role, conversation)) return; await this.sendNotification({ method: "notifications/claude/channel", params: { content: text ?? "[non-text message]", meta: { session_key: sessionKey, channel: conversation?.channel ?? "", to: conversation?.to ?? "", account_id: conversation?.accountId ?? "", thread_id: conversation?.threadId == null ? "" : String(conversation.threadId), message_id: normalizeOptionalString(payload.messageId) ?? "" } } }); } shouldEmitClaudeChannel(role, conversation) { if (this.claudeChannelMode === "off") return false; if (role !== "user") return false; return Boolean(conversation); } }; function shouldRetryInitialMcpGatewayConnect(error) { if (error.name === "GatewayClientRequestError" && "retryable" in error && typeof error.retryable === "boolean") return error.retryable; const message = error.message.toLowerCase(); return message.includes("gateway request timeout for connect") || message.includes("gateway connect challenge timeout"); } //#endregion //#region src/mcp/channel-tools.ts /** * MCP tool registration for channel conversation access. * * Tool handlers stay thin: schemas validate public inputs and the bridge owns * Gateway readiness, routing, event queueing, and approval resolution. */ /** Return protocol capabilities advertised when Claude channel mode is enabled. */ function getChannelMcpCapabilities(claudeChannelMode) { if (claudeChannelMode === "off") return; return { experimental: { "claude/channel": {}, "claude/channel/permission": {} } }; } /** Register all channel MCP tools against a server instance. */ function registerChannelMcpTools(server, bridge) { server.tool("conversations_list", "List OpenClaw channel-backed conversations available through session routes.", { limit: number().int().min(1).max(500).optional(), search: string().optional(), channel: string().optional(), includeDerivedTitles: boolean().optional(), includeLastMessage: boolean().optional() }, async (args) => { const conversations = await bridge.listConversations(args); return { ...summarizeStructuredResult("conversations", conversations.length, { conversations }), structuredContent: { conversations } }; }); server.tool("conversation_get", "Get one OpenClaw conversation by session key.", { session_key: string().min(1) }, async ({ session_key }) => { const conversation = await bridge.getConversation(session_key); if (!conversation) return { content: [{ type: "text", text: `conversation not found: ${session_key}` }], isError: true }; return { content: [{ type: "text", text: `conversation ${conversation.sessionKey}` }], structuredContent: { conversation } }; }); server.tool("messages_read", "Read recent messages for one OpenClaw conversation.", { session_key: string().min(1), limit: number().int().min(1).max(200).optional() }, async ({ session_key, limit }) => { const messages = await bridge.readMessages(session_key, limit ?? 20); return { ...summarizeStructuredResult("messages", messages.length, { messages }), structuredContent: { messages } }; }); server.tool("attachments_fetch", "List non-text attachments for a message in one OpenClaw conversation.", { session_key: string().min(1), message_id: string().min(1), limit: number().int().min(1).max(200).optional() }, async ({ session_key, message_id, limit }) => { const message = await bridge.readMessage(session_key, message_id, limit ?? 100); if (!message) return { content: [{ type: "text", text: `message not found: ${message_id}` }], isError: true }; const attachments = extractAttachmentsFromMessage(message); return { ...summarizeResult("attachments", attachments.length), structuredContent: { attachments, message } }; }); server.tool("events_poll", "Poll queued OpenClaw conversation events since a cursor.", { after_cursor: number().int().min(0).optional(), session_key: string().optional(), limit: number().int().min(1).max(200).optional() }, async ({ after_cursor, session_key, limit }) => { const { events, nextCursor, gap } = bridge.pollEvents({ afterCursor: after_cursor ?? 0, sessionKey: normalizeOptionalString(session_key) }, limit ?? 20); return { ...summarizeResult("events", events.length), structuredContent: { events, next_cursor: nextCursor, ...gap ? { gap } : {} } }; }); server.tool("events_wait", "Wait for the next queued OpenClaw conversation event.", { after_cursor: number().int().min(0).optional(), session_key: string().optional(), timeout_ms: number().int().min(1).max(3e5).optional() }, async ({ after_cursor, session_key, timeout_ms }, extra) => { const { event, gap } = await bridge.waitForEvent({ afterCursor: after_cursor ?? 0, sessionKey: normalizeOptionalString(session_key) }, timeout_ms ?? 3e4, extra.signal); return { content: [{ type: "text", text: event ? `event ${event.cursor}` : gap ? `event gap before ${gap.oldest_available_cursor}` : "timeout" }], structuredContent: { event, ...gap ? { gap } : {} } }; }); server.tool("messages_send", "Send a message back through the same OpenClaw conversation route.", { session_key: string().min(1), text: string().min(1) }, async ({ session_key, text }) => { return { content: [{ type: "text", text: "sent" }], structuredContent: { result: await bridge.sendMessage({ sessionKey: session_key, text }) } }; }); server.tool("permissions_list_open", "List open OpenClaw exec or plugin approval requests visible through the Gateway.", {}, async () => { const approvals = bridge.listPendingApprovals(); return { ...summarizeResult("approvals", approvals.length), structuredContent: { approvals } }; }); server.tool("permissions_respond", "Allow or deny one pending OpenClaw exec or plugin approval request.", { kind: _enum(["exec", "plugin"]), id: string().min(1), decision: _enum([ "allow-once", "allow-always", "deny" ]) }, async ({ kind, id, decision }) => { return { content: [{ type: "text", text: "approval resolved" }], structuredContent: { result: await bridge.respondToApproval({ kind, id, decision }) } }; }); } //#endregion //#region src/mcp/channel-server-runtime.ts async function resolveMcpConfig(config) { if (config) return config; const { getRuntimeConfig } = await import("./config/config.js"); return getRuntimeConfig(); } async function createChannelMcpRuntime(opts = {}) { const cfg = await resolveMcpConfig(opts.config); const claudeChannelMode = opts.claudeChannelMode ?? "auto"; const capabilities = getChannelMcpCapabilities(claudeChannelMode); const server = new McpServer({ name: "openclaw", version: VERSION }, capabilities ? { capabilities } : void 0); const bridge = new OpenClawChannelBridge(cfg, { gatewayUrl: opts.gatewayUrl, gatewayToken: opts.gatewayToken, gatewayPassword: opts.gatewayPassword, claudeChannelMode, verbose: opts.verbose ?? false }); bridge.setServer(server); server.server.setNotificationHandler(ClaudePermissionRequestSchema, async ({ params }) => { await bridge.handleClaudePermissionRequest({ requestId: params.request_id, toolName: params.tool_name, description: params.description, inputPreview: params.input_preview }); }); registerChannelMcpTools(server, bridge); return { server, bridge, start: async () => { await bridge.start(); }, close: async () => { const errors = (await Promise.allSettled([bridge.close(), server.close()])).flatMap((result) => result.status === "rejected" ? [result.reason] : []); if (errors.length === 1) throw errors[0]; if (errors.length > 1) throw new AggregateError(errors, "OpenClaw channel MCP shutdown failed"); } }; } //#endregion //#region src/mcp/channel-server.ts /** Serve the channel MCP server over stdio until transport or process shutdown. */ async function serveOpenClawChannelMcp(opts = {}) { const { server, start, close } = await createChannelMcpRuntime(opts); const transport = new StdioServerTransport(); let shuttingDown = false; let closePromise; let resolveClosed; const closed = new Promise((resolve) => { resolveClosed = resolve; }); const shutdown = () => { if (shuttingDown) return; shuttingDown = true; process.stdin.off("end", shutdown); process.stdin.off("close", shutdown); process.off("SIGINT", shutdown); process.off("SIGTERM", shutdown); closePromise = Promise.resolve().then(close); closePromise.then(resolveClosed, resolveClosed); }; transport["onclose"] = shutdown; process.stdin.once("end", shutdown); process.stdin.once("close", shutdown); process.once("SIGINT", shutdown); process.once("SIGTERM", shutdown); try { await server.connect(transport); await start(); await closed; await closePromise; } finally { shutdown(); await closed; await closePromise; } } //#endregion export { serveOpenClawChannelMcp };