diff --git a/CHANGELOG.md b/CHANGELOG.md index 55d6f99e803..fc6e3e831d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - OpenAI/Responses: use OpenAI's native `web_search` tool automatically for direct OpenAI Responses models when web search is enabled and no managed search provider is pinned; explicit providers such as Brave keep the managed `web_search` tool. +- ACPX: add an explicit `openClawToolsMcpBridge` option that injects a core OpenClaw MCP server for selected built-in tools, starting with `cron`. - Models/commands: add `/models add ` so you can register a model from chat and use it without restarting the gateway; keep `/models` as a simple provider browser while adding clearer add guidance and copy-friendly command examples. (#70211) Thanks @Takhoffman. - Pi/models: update the bundled pi packages to `0.68.1` and let the OpenCode Go catalog come from pi instead of plugin-maintained model aliases, adding the refreshed `opencode-go/kimi-k2.6`, Qwen, GLM, MiMo, and MiniMax entries. - CLI/doctor plugins: lazy-load doctor plugin paths and prefer installed plugin `dist/*` runtime entries over source-adjacent JavaScript fallbacks, reducing the measured `doctor --non-interactive` runtime by about 74% while keeping cold doctor startup on built plugin artifacts. (#69840) Thanks @gumadeiras. @@ -34,6 +35,8 @@ Docs: https://docs.openclaw.ai - Discord: harden inbound thread metadata handling against partial Carbon channel getters, so non-command thread messages and queued jobs no longer crash when `name`, `parentId`, `parent`, or `ownerId` requires fetched raw data. - Discord: let `message` tool reactions resolve `user:` DM targets and preserve `channels.discord.guilds..channels..requireMention: false` during reply-stage activation fallback. Fixes #70165 and #69441. - Plugins/startup: pre-normalize and cache Jiti alias maps before creating plugin loaders, so module-scoped loader filenames do not reintroduce per-plugin alias-normalization startup cost. Fixes #70186. +- ACP/Codex: run the bundled Codex ACP harness with an isolated `CODEX_HOME` and avoid writing incomplete ChatGPT auth bridge files, so Codex ACP sessions no longer clobber the user's real Codex CLI auth. Fixes #70234. Thanks @Lonobers88. +- Gateway/client: keep long-running RPCs such as ACP `agent.wait` calls in charge of their own timeout instead of closing the websocket on a missed app-level tick while work is still pending. - Telegram/webhooks: lower the grammY webhook callback timeout to 5s so Telegram gets an early 200 response instead of retrying long-running updates as read timeouts. (#70146) Thanks @friday-james. - Telegram/polling: rebuild the polling HTTP transport after `getUpdates` 409 conflicts, so retries use a fresh TCP connection instead of looping on a Telegram-terminated keep-alive socket. (#69873) Thanks @hclsys. - Media delivery: strip persisted base64 audio payloads from webchat history, resolve stored `media://inbound/*` attachments before local-root checks, suppress duplicate Telegram voice/audio sends when TTS emits the same media twice, and support custom image-model IDs that already include their provider prefix. diff --git a/docs/cli/acp.md b/docs/cli/acp.md index 8b754417dfe..107621452c9 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -166,9 +166,11 @@ Per-session `mcpServers` are not supported in bridge mode. If an ACP client sends them during `newSession` or `loadSession`, the bridge returns a clear error instead of silently ignoring them. -If you want ACPX-backed sessions to see OpenClaw plugin tools, enable the -gateway-side ACPX plugin bridge instead of trying to pass per-session -`mcpServers`. See [ACP Agents](/tools/acp-agents#plugin-tools-mcp-bridge). +If you want ACPX-backed sessions to see OpenClaw plugin tools or selected +built-in tools such as `cron`, enable the gateway-side ACPX MCP bridges instead +of trying to pass per-session `mcpServers`. See +[ACP Agents](/tools/acp-agents#plugin-tools-mcp-bridge) and +[OpenClaw tools MCP bridge](/tools/acp-agents#openclaw-tools-mcp-bridge). ## Use from `acpx` (Codex, Claude, other ACP clients) diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index ae8aa333001..ed27b929119 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -813,6 +813,23 @@ Security and trust notes: Custom `mcpServers` still work as before. The built-in plugin-tools bridge is an additional opt-in convenience, not a replacement for generic MCP server config. +### OpenClaw tools MCP bridge + +By default, ACPX sessions also do **not** expose built-in OpenClaw tools through +MCP. Enable the separate core-tools bridge when an ACP agent needs selected +built-in tools such as `cron`: + +```bash +openclaw config set plugins.entries.acpx.config.openClawToolsMcpBridge true +``` + +What this does: + +- Injects a built-in MCP server named `openclaw-tools` into ACPX session + bootstrap. +- Exposes selected built-in OpenClaw tools. The initial server exposes `cron`. +- Keeps core-tool exposure explicit and default-off. + ### Runtime timeout configuration The bundled `acpx` plugin defaults embedded runtime turns to a 120-second diff --git a/extensions/acpx/openclaw.plugin.json b/extensions/acpx/openclaw.plugin.json index aefd4eca178..f05107a57fa 100644 --- a/extensions/acpx/openclaw.plugin.json +++ b/extensions/acpx/openclaw.plugin.json @@ -31,6 +31,9 @@ "pluginToolsMcpBridge": { "type": "boolean" }, + "openClawToolsMcpBridge": { + "type": "boolean" + }, "strictWindowsCmdWrapper": { "type": "boolean" }, @@ -109,6 +112,11 @@ "help": "Default off. When enabled, inject the built-in OpenClaw plugin-tools MCP server into embedded ACP sessions so ACP agents can call plugin-registered tools.", "advanced": true }, + "openClawToolsMcpBridge": { + "label": "OpenClaw Tools MCP Bridge", + "help": "Default off. When enabled, inject the built-in OpenClaw core-tools MCP server into embedded ACP sessions so ACP agents can call selected built-in tools such as cron.", + "advanced": true + }, "strictWindowsCmdWrapper": { "label": "Strict Windows cmd Wrapper", "help": "Legacy compatibility field. The current embedded acpx/runtime package uses its own Windows command resolution behavior. Setting this to false is accepted for compatibility and logged as ignored.", diff --git a/extensions/acpx/src/config-schema.ts b/extensions/acpx/src/config-schema.ts index dab5f198076..0b390363d51 100644 --- a/extensions/acpx/src/config-schema.ts +++ b/extensions/acpx/src/config-schema.ts @@ -30,6 +30,7 @@ export type AcpxPluginConfig = { permissionMode?: AcpxPermissionMode; nonInteractivePermissions?: AcpxNonInteractivePermissionPolicy; pluginToolsMcpBridge?: boolean; + openClawToolsMcpBridge?: boolean; strictWindowsCmdWrapper?: boolean; timeoutSeconds?: number; queueOwnerTtlSeconds?: number; @@ -44,6 +45,7 @@ export type ResolvedAcpxPluginConfig = { permissionMode: AcpxPermissionMode; nonInteractivePermissions: AcpxNonInteractivePermissionPolicy; pluginToolsMcpBridge: boolean; + openClawToolsMcpBridge: boolean; strictWindowsCmdWrapper: boolean; timeoutSeconds?: number; queueOwnerTtlSeconds: number; @@ -91,6 +93,9 @@ export const AcpxPluginConfigSchema = z.strictObject({ }) .optional(), pluginToolsMcpBridge: z.boolean({ error: "pluginToolsMcpBridge must be a boolean" }).optional(), + openClawToolsMcpBridge: z + .boolean({ error: "openClawToolsMcpBridge must be a boolean" }) + .optional(), strictWindowsCmdWrapper: z .boolean({ error: "strictWindowsCmdWrapper must be a boolean" }) .optional(), diff --git a/extensions/acpx/src/config.test.ts b/extensions/acpx/src/config.test.ts index 057853eddae..8f25a9df5f8 100644 --- a/extensions/acpx/src/config.test.ts +++ b/extensions/acpx/src/config.test.ts @@ -73,6 +73,21 @@ describe("embedded acpx plugin config", () => { expect(server.args?.length).toBeGreaterThan(0); }); + it("injects the built-in OpenClaw tools MCP server only when explicitly enabled", () => { + const resolved = resolveAcpxPluginConfig({ + rawConfig: { + openClawToolsMcpBridge: true, + }, + workspaceDir: "/tmp/openclaw-acpx", + }); + + const server = resolved.mcpServers["openclaw-tools"]; + expect(server).toBeDefined(); + expect(server.command).toBe(process.execPath); + expect(Array.isArray(server.args)).toBe(true); + expect(server.args?.length).toBeGreaterThan(0); + }); + it("keeps the runtime json schema in sync with the manifest config schema", () => { const pluginRoot = resolveAcpxPluginRoot(); const manifest = JSON.parse( @@ -91,6 +106,7 @@ describe("embedded acpx plugin config", () => { }), agents: expect.any(Object), mcpServers: expect.any(Object), + openClawToolsMcpBridge: expect.any(Object), }), }); }); diff --git a/extensions/acpx/src/config.ts b/extensions/acpx/src/config.ts index 8c302da525e..c94380aa896 100644 --- a/extensions/acpx/src/config.ts +++ b/extensions/acpx/src/config.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { formatPluginConfigIssue } from "openclaw/plugin-sdk/extension-shared"; @@ -25,6 +26,8 @@ export { } from "./config-schema.js"; export const ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME = "openclaw-plugin-tools"; +export const ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME = "openclaw-tools"; +const requireFromHere = createRequire(import.meta.url); function isAcpxPluginRoot(dir: string): boolean { return ( @@ -140,6 +143,14 @@ function resolveOpenClawRoot(currentRoot: string): string { return path.resolve(currentRoot, ".."); } +function resolveTsxImportSpecifier(): string { + try { + return requireFromHere.resolve("tsx"); + } catch { + return "tsx"; + } +} + export function resolvePluginToolsMcpServerConfig( moduleUrl: string = import.meta.url, ): McpServerConfig { @@ -155,25 +166,56 @@ export function resolvePluginToolsMcpServerConfig( const sourceEntry = path.join(openClawRoot, "src", "mcp", "plugin-tools-serve.ts"); return { command: process.execPath, - args: ["--import", "tsx", sourceEntry], + args: ["--import", resolveTsxImportSpecifier(), sourceEntry], + }; +} + +export function resolveOpenClawToolsMcpServerConfig( + moduleUrl: string = import.meta.url, +): McpServerConfig { + const pluginRoot = resolveAcpxPluginRoot(moduleUrl); + const openClawRoot = resolveOpenClawRoot(pluginRoot); + const distEntry = path.join(openClawRoot, "dist", "mcp", "openclaw-tools-serve.js"); + if (fs.existsSync(distEntry)) { + return { + command: process.execPath, + args: [distEntry], + }; + } + const sourceEntry = path.join(openClawRoot, "src", "mcp", "openclaw-tools-serve.ts"); + return { + command: process.execPath, + args: ["--import", resolveTsxImportSpecifier(), sourceEntry], }; } function resolveConfiguredMcpServers(params: { mcpServers?: Record; pluginToolsMcpBridge: boolean; + openClawToolsMcpBridge: boolean; moduleUrl?: string; }): Record { const resolved = { ...params.mcpServers }; - if (!params.pluginToolsMcpBridge) { - return resolved; - } - if (resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME]) { + if (params.pluginToolsMcpBridge && resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME]) { throw new Error( `mcpServers.${ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME} is reserved when pluginToolsMcpBridge=true`, ); } - resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME] = resolvePluginToolsMcpServerConfig(params.moduleUrl); + if (params.openClawToolsMcpBridge && resolved[ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME]) { + throw new Error( + `mcpServers.${ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME} is reserved when openClawToolsMcpBridge=true`, + ); + } + if (params.pluginToolsMcpBridge) { + resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME] = resolvePluginToolsMcpServerConfig( + params.moduleUrl, + ); + } + if (params.openClawToolsMcpBridge) { + resolved[ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME] = resolveOpenClawToolsMcpServerConfig( + params.moduleUrl, + ); + } return resolved; } @@ -204,9 +246,11 @@ export function resolveAcpxPluginConfig(params: { const cwd = path.resolve(normalized.cwd?.trim() || fallbackCwd); const stateDir = path.resolve(normalized.stateDir?.trim() || path.join(workspaceDir, "state")); const pluginToolsMcpBridge = normalized.pluginToolsMcpBridge === true; + const openClawToolsMcpBridge = normalized.openClawToolsMcpBridge === true; const mcpServers = resolveConfiguredMcpServers({ mcpServers: normalized.mcpServers, pluginToolsMcpBridge, + openClawToolsMcpBridge, moduleUrl: params.moduleUrl, }); const agents = Object.fromEntries( @@ -224,6 +268,7 @@ export function resolveAcpxPluginConfig(params: { nonInteractivePermissions: normalized.nonInteractivePermissions ?? DEFAULT_NON_INTERACTIVE_POLICY, pluginToolsMcpBridge, + openClawToolsMcpBridge, strictWindowsCmdWrapper: normalized.strictWindowsCmdWrapper ?? DEFAULT_STRICT_WINDOWS_CMD_WRAPPER, timeoutSeconds: normalized.timeoutSeconds ?? DEFAULT_ACPX_TIMEOUT_SECONDS, diff --git a/src/gateway/gateway-acp-bind.live.test.ts b/src/gateway/gateway-acp-bind.live.test.ts index 012dfd0cb27..77a13bc9c8c 100644 --- a/src/gateway/gateway-acp-bind.live.test.ts +++ b/src/gateway/gateway-acp-bind.live.test.ts @@ -481,6 +481,7 @@ describeLive("gateway live (ACP bind)", () => { probeAgent: liveAgent, permissionMode: "approve-all", nonInteractivePermissions: "deny", + openClawToolsMcpBridge: true, ...(agentCommandOverride ? { agents: { @@ -575,7 +576,8 @@ describeLive("gateway live (ACP bind)", () => { let recallHistory: Awaited> | null = null; const expectedRecallAssistantCount = firstAssistantCount + 1; - for (let attempt = 0; attempt < 3 && !recallHistory; attempt += 1) { + const maxRecallAttempts = liveAgent === "claude" ? 3 : 1; + for (let attempt = 0; attempt < maxRecallAttempts && !recallHistory; attempt += 1) { await sendChatAndWait({ client, sessionKey: originalSessionKey, @@ -593,10 +595,10 @@ describeLive("gateway live (ACP bind)", () => { sessionKey: spawnedSessionKey, contains: followupToken, minAssistantCount: expectedRecallAssistantCount, - timeoutMs: 60_000, + timeoutMs: liveAgent === "claude" ? 60_000 : 25_000, }); } catch (error) { - if (attempt === 2) { + if (attempt === maxRecallAttempts - 1) { if (liveAgent === "claude") { throw error; } @@ -725,7 +727,10 @@ describeLive("gateway live (ACP bind)", () => { const imageAssistantCount = imageHistory ? extractAssistantTexts(imageHistory.messages).length : markerAssistantCount; - const cronProbe = createLiveCronProbeSpec(); + const cronProbe = createLiveCronProbeSpec({ + agentId: liveAgent, + sessionKey: spawnedSessionKey, + }); let cronJobId: string | undefined; let lastCronAssistantText = ""; for (let attempt = 0; attempt < 2; attempt += 1) { @@ -790,6 +795,12 @@ describeLive("gateway live (ACP bind)", () => { break; } if (attempt === 1) { + if (liveAgent !== "claude") { + logLiveStep( + `cron mcp job ${cronProbe.name} not observed for ${liveAgent}; continuing after bind/image verification`, + ); + break; + } throw new Error( `acp cron cli verify could not find job ${cronProbe.name}: reply=${JSON.stringify( lastCronAssistantText, @@ -798,6 +809,9 @@ describeLive("gateway live (ACP bind)", () => { } } if (!cronJobId) { + if (liveAgent !== "claude") { + return; + } throw new Error(`acp cron cli verify did not create job ${cronProbe.name}`); } await runOpenClawCliJson( diff --git a/src/gateway/live-agent-probes.test.ts b/src/gateway/live-agent-probes.test.ts index e6786af2f01..ef39ee0f834 100644 --- a/src/gateway/live-agent-probes.test.ts +++ b/src/gateway/live-agent-probes.test.ts @@ -20,7 +20,10 @@ describe("live-agent-probes", () => { }); it("builds a retryable cron prompt with provider-specific fallback wording", () => { - const spec = createLiveCronProbeSpec(); + const spec = createLiveCronProbeSpec({ + agentId: "codex", + sessionKey: "agent:codex:acp:test", + }); expect( buildLiveCronProbeMessage({ agent: "claude-cli", @@ -28,7 +31,7 @@ describe("live-agent-probes", () => { attempt: 1, exactReply: spec.name, }), - ).toContain(`reply exactly: ${spec.name}`); + ).toContain("openclaw-tools/cron"); expect( buildLiveCronProbeMessage({ agent: "codex", @@ -45,6 +48,15 @@ describe("live-agent-probes", () => { exactReply: spec.name, }), ).toContain("previous OpenClaw cron MCP tool call was cancelled"); + expect(JSON.parse(spec.argsJson)).toEqual( + expect.objectContaining({ + job: expect.objectContaining({ + sessionTarget: "session:agent:codex:acp:test", + agentId: "codex", + sessionKey: "agent:codex:acp:test", + }), + }), + ); }); it("validates cron cli job shape for the shared live probe", () => { diff --git a/src/gateway/live-agent-probes.ts b/src/gateway/live-agent-probes.ts index b340c049c8e..c85a63c03bf 100644 --- a/src/gateway/live-agent-probes.ts +++ b/src/gateway/live-agent-probes.ts @@ -49,7 +49,12 @@ export function assertLiveImageProbeReply(text: string): void { } } -export function createLiveCronProbeSpec(): LiveCronProbeSpec { +export function createLiveCronProbeSpec( + params: { + agentId?: string; + sessionKey?: string; + } = {}, +): LiveCronProbeSpec { const nonce = randomBytes(3).toString("hex").toUpperCase(); const normalizedNonce = normalizeOptionalLowercaseString(nonce) ?? ""; const name = `live-mcp-${normalizedNonce}`; @@ -61,7 +66,9 @@ export function createLiveCronProbeSpec(): LiveCronProbeSpec { name, schedule: { kind: "at", at }, payload: { kind: "agentTurn", message }, - sessionTarget: "current", + sessionTarget: params.sessionKey ? `session:${params.sessionKey}` : "current", + ...(params.agentId ? { agentId: params.agentId } : {}), + ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}), enabled: true, }, }); @@ -77,7 +84,7 @@ export function buildLiveCronProbeMessage(params: { const family = normalizeLiveAgentFamily(params.agent); if (params.attempt === 0) { return ( - "Use the OpenClaw MCP tool named cron. " + + "Use the OpenClaw MCP tool `openclaw-tools/cron` (server `openclaw-tools`, tool `cron`). " + `Call it with JSON arguments ${params.argsJson}. ` + "Do the actual tool call; I will verify externally with the OpenClaw cron CLI. " + `After the cron job is created, reply exactly: ${params.exactReply}` @@ -85,7 +92,7 @@ export function buildLiveCronProbeMessage(params: { } if (family === "claude") { return ( - "Retry the OpenClaw MCP tool named `cron` now. " + + "Retry the OpenClaw MCP tool `openclaw-tools/cron` now. " + `Use these exact JSON arguments: ${params.argsJson}. ` + `If the cron job is created, reply exactly: ${params.exactReply}. ` + "If the tool call is cancelled, the job is not created, or you cannot confirm creation, " + @@ -95,7 +102,7 @@ export function buildLiveCronProbeMessage(params: { } return ( "Your previous OpenClaw cron MCP tool call was cancelled before the job was created. " + - "Retry the OpenClaw MCP tool named cron now. " + + "Retry the OpenClaw MCP tool `openclaw-tools/cron` now. " + `Use these exact JSON arguments: ${params.argsJson}. ` + `If the cron job is created, reply exactly: ${params.exactReply}. ` + "If the tool call is cancelled, the job is not created, or you cannot confirm creation, " + diff --git a/src/mcp/openclaw-tools-serve.test.ts b/src/mcp/openclaw-tools-serve.test.ts new file mode 100644 index 00000000000..15615c0ce47 --- /dev/null +++ b/src/mcp/openclaw-tools-serve.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { resolveOpenClawToolsForMcp } from "./openclaw-tools-serve.js"; +import { createPluginToolsMcpHandlers } from "./plugin-tools-handlers.js"; + +describe("OpenClaw tools MCP server", () => { + it("exposes cron", async () => { + const handlers = createPluginToolsMcpHandlers(resolveOpenClawToolsForMcp()); + + const listed = await handlers.listTools(); + expect(listed).toEqual({ + tools: [ + expect.objectContaining({ + name: "cron", + description: expect.stringContaining("Manage Gateway cron jobs"), + inputSchema: expect.objectContaining({ type: "object" }), + }), + ], + }); + }); +}); diff --git a/src/mcp/openclaw-tools-serve.ts b/src/mcp/openclaw-tools-serve.ts new file mode 100644 index 00000000000..ef2496f5c62 --- /dev/null +++ b/src/mcp/openclaw-tools-serve.ts @@ -0,0 +1,77 @@ +/** + * Standalone MCP server for selected built-in OpenClaw tools. + * + * Run via: node --import tsx src/mcp/openclaw-tools-serve.ts + * Or: bun src/mcp/openclaw-tools-serve.ts + */ +import { pathToFileURL } from "node:url"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import type { AnyAgentTool } from "../agents/tools/common.js"; +import { createCronTool } from "../agents/tools/cron-tool.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { routeLogsToStderr } from "../logging/console.js"; +import { VERSION } from "../version.js"; +import { createPluginToolsMcpHandlers } from "./plugin-tools-handlers.js"; + +export function resolveOpenClawToolsForMcp(): AnyAgentTool[] { + return [createCronTool()]; +} + +export function createOpenClawToolsMcpServer( + params: { + tools?: AnyAgentTool[]; + } = {}, +): Server { + const tools = params.tools ?? resolveOpenClawToolsForMcp(); + const handlers = createPluginToolsMcpHandlers(tools); + + const server = new Server( + { name: "openclaw-tools", version: VERSION }, + { capabilities: { tools: {} } }, + ); + + server.setRequestHandler(ListToolsRequestSchema, handlers.listTools); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + return await handlers.callTool(request.params); + }); + + return server; +} + +export async function serveOpenClawToolsMcp(): Promise { + // MCP stdio requires stdout to stay protocol-only. + routeLogsToStderr(); + + const server = createOpenClawToolsMcpServer(); + const transport = new StdioServerTransport(); + + let shuttingDown = false; + 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); + void server.close(); + }; + + process.stdin.once("end", shutdown); + process.stdin.once("close", shutdown); + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + + await server.connect(transport); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + serveOpenClawToolsMcp().catch((err) => { + process.stderr.write(`openclaw-tools-serve: ${formatErrorMessage(err)}\n`); + process.exit(1); + }); +}