mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
feat: expose OpenClaw tools to ACPX
This commit is contained in:
@@ -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 <provider> <modelId>` 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:<id>` DM targets and preserve `channels.discord.guilds.<guild>.channels.<channel>.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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, McpServerConfig>;
|
||||
pluginToolsMcpBridge: boolean;
|
||||
openClawToolsMcpBridge: boolean;
|
||||
moduleUrl?: string;
|
||||
}): Record<string, McpServerConfig> {
|
||||
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,
|
||||
|
||||
@@ -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<ReturnType<typeof waitForAssistantText>> | 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(
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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, " +
|
||||
|
||||
20
src/mcp/openclaw-tools-serve.test.ts
Normal file
20
src/mcp/openclaw-tools-serve.test.ts
Normal file
@@ -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" }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
77
src/mcp/openclaw-tools-serve.ts
Normal file
77
src/mcp/openclaw-tools-serve.ts
Normal file
@@ -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<void> {
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user