Skip to content

Commit 5659d7f

Browse files
fix: land openclaw#39337 by @goodspeed-apps for acpx MCP bootstrap
Co-authored-by: Goodspeed App Studio <[email protected]>
1 parent f721141 commit 5659d7f

File tree

11 files changed

+785
-42
lines changed

11 files changed

+785
-42
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@ Docs: https://docs.openclaw.ai
349349
- Control UI/agents-page overrides: auto-create minimal per-agent config entries when editing inherited agents, so model/tool/skill changes enable Save and inherited model fallbacks can be cleared by writing a primary-only override. Landed from contributor PR #39326 by @dunamismax. Thanks @dunamismax.
350350
- Gateway/Telegram webhook-mode recovery: add `webhookCertPath` to re-upload self-signed certificates during webhook registration and skip stale-socket detection for webhook-mode channels, so Telegram webhook setups survive health-monitor restarts. Landed from contributor PR #39313 by @fellanH. Thanks @fellanH.
351351
- Discord/config schema parity: add `channels.discord.agentComponents` to the strict Zod config schema so valid `agentComponents.enabled` settings (root and account-scoped) no longer fail with unrecognized-key validation errors. Landed from contributor PR #39378 by @gambletan. Thanks @gambletan and @thewilloftheshadow.
352+
- ACPX/MCP session bootstrap: inject configured MCP servers into ACP `session/new` and `session/load` for acpx-backed sessions, restoring Canva and other external MCP tools. Landed from contributor PR #39337 by @goodspeed-apps. Thanks @goodspeed-apps.
352353

353354
## 2026.3.2
354355

extensions/acpx/openclaw.plugin.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,29 @@
3434
"queueOwnerTtlSeconds": {
3535
"type": "number",
3636
"minimum": 0
37+
},
38+
"mcpServers": {
39+
"type": "object",
40+
"additionalProperties": {
41+
"type": "object",
42+
"properties": {
43+
"command": {
44+
"type": "string",
45+
"description": "Command to run the MCP server"
46+
},
47+
"args": {
48+
"type": "array",
49+
"items": { "type": "string" },
50+
"description": "Arguments to pass to the command"
51+
},
52+
"env": {
53+
"type": "object",
54+
"additionalProperties": { "type": "string" },
55+
"description": "Environment variables for the MCP server"
56+
}
57+
},
58+
"required": ["command"]
59+
}
3760
}
3861
}
3962
},
@@ -72,6 +95,11 @@
7295
"label": "Queue Owner TTL Seconds",
7396
"help": "Idle queue-owner TTL for acpx prompt turns. Keep this short in OpenClaw to avoid delayed completion after each turn.",
7497
"advanced": true
98+
},
99+
"mcpServers": {
100+
"label": "MCP Servers",
101+
"help": "Named MCP server definitions to inject into ACPX-backed session bootstrap. Each entry needs a command and can include args and env.",
102+
"advanced": true
75103
}
76104
}
77105
}

extensions/acpx/src/config.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
ACPX_PINNED_VERSION,
66
createAcpxPluginConfigSchema,
77
resolveAcpxPluginConfig,
8+
toAcpMcpServers,
89
} from "./config.js";
910

1011
describe("acpx plugin config parsing", () => {
@@ -21,6 +22,7 @@ describe("acpx plugin config parsing", () => {
2122
expect(resolved.allowPluginLocalInstall).toBe(true);
2223
expect(resolved.cwd).toBe(path.resolve("/tmp/workspace"));
2324
expect(resolved.strictWindowsCmdWrapper).toBe(true);
25+
expect(resolved.mcpServers).toEqual({});
2426
});
2527

2628
it("accepts command override and disables plugin-local auto-install", () => {
@@ -132,4 +134,97 @@ describe("acpx plugin config parsing", () => {
132134
}),
133135
).toThrow("strictWindowsCmdWrapper must be a boolean");
134136
});
137+
138+
it("accepts mcp server maps", () => {
139+
const resolved = resolveAcpxPluginConfig({
140+
rawConfig: {
141+
mcpServers: {
142+
canva: {
143+
command: "npx",
144+
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
145+
env: {
146+
CANVA_TOKEN: "secret",
147+
},
148+
},
149+
},
150+
},
151+
workspaceDir: "/tmp/workspace",
152+
});
153+
154+
expect(resolved.mcpServers).toEqual({
155+
canva: {
156+
command: "npx",
157+
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
158+
env: {
159+
CANVA_TOKEN: "secret",
160+
},
161+
},
162+
});
163+
});
164+
165+
it("rejects invalid mcp server definitions", () => {
166+
expect(() =>
167+
resolveAcpxPluginConfig({
168+
rawConfig: {
169+
mcpServers: {
170+
canva: {
171+
command: "npx",
172+
args: ["-y", 1],
173+
},
174+
},
175+
},
176+
workspaceDir: "/tmp/workspace",
177+
}),
178+
).toThrow(
179+
"mcpServers.canva must have a command string, optional args array, and optional env object",
180+
);
181+
});
182+
183+
it("schema accepts mcp server config", () => {
184+
const schema = createAcpxPluginConfigSchema();
185+
if (!schema.safeParse) {
186+
throw new Error("acpx config schema missing safeParse");
187+
}
188+
const parsed = schema.safeParse({
189+
mcpServers: {
190+
canva: {
191+
command: "npx",
192+
args: ["-y", "mcp-remote@latest"],
193+
env: {
194+
CANVA_TOKEN: "secret",
195+
},
196+
},
197+
},
198+
});
199+
200+
expect(parsed.success).toBe(true);
201+
});
202+
});
203+
204+
describe("toAcpMcpServers", () => {
205+
it("converts plugin config maps into ACP stdio MCP entries", () => {
206+
expect(
207+
toAcpMcpServers({
208+
canva: {
209+
command: "npx",
210+
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
211+
env: {
212+
CANVA_TOKEN: "secret",
213+
},
214+
},
215+
}),
216+
).toEqual([
217+
{
218+
name: "canva",
219+
command: "npx",
220+
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
221+
env: [
222+
{
223+
name: "CANVA_TOKEN",
224+
value: "secret",
225+
},
226+
],
227+
},
228+
]);
229+
});
135230
});

extensions/acpx/src/config.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ export function buildAcpxLocalInstallCommand(version: string = ACPX_PINNED_VERSI
1818
}
1919
export const ACPX_LOCAL_INSTALL_COMMAND = buildAcpxLocalInstallCommand();
2020

21+
export type McpServerConfig = {
22+
command: string;
23+
args?: string[];
24+
env?: Record<string, string>;
25+
};
26+
27+
export type AcpxMcpServer = {
28+
name: string;
29+
command: string;
30+
args: string[];
31+
env: Array<{ name: string; value: string }>;
32+
};
33+
2134
export type AcpxPluginConfig = {
2235
command?: string;
2336
expectedVersion?: string;
@@ -27,6 +40,7 @@ export type AcpxPluginConfig = {
2740
strictWindowsCmdWrapper?: boolean;
2841
timeoutSeconds?: number;
2942
queueOwnerTtlSeconds?: number;
43+
mcpServers?: Record<string, McpServerConfig>;
3044
};
3145

3246
export type ResolvedAcpxPluginConfig = {
@@ -40,6 +54,7 @@ export type ResolvedAcpxPluginConfig = {
4054
strictWindowsCmdWrapper: boolean;
4155
timeoutSeconds?: number;
4256
queueOwnerTtlSeconds: number;
57+
mcpServers: Record<string, McpServerConfig>;
4358
};
4459

4560
const DEFAULT_PERMISSION_MODE: AcpxPermissionMode = "approve-reads";
@@ -65,6 +80,36 @@ function isNonInteractivePermissionPolicy(
6580
return ACPX_NON_INTERACTIVE_POLICIES.includes(value as AcpxNonInteractivePermissionPolicy);
6681
}
6782

83+
function isMcpServerConfig(value: unknown): value is McpServerConfig {
84+
if (!isRecord(value)) {
85+
return false;
86+
}
87+
if (typeof value.command !== "string" || value.command.trim() === "") {
88+
return false;
89+
}
90+
if (value.args !== undefined) {
91+
if (!Array.isArray(value.args)) {
92+
return false;
93+
}
94+
for (const arg of value.args) {
95+
if (typeof arg !== "string") {
96+
return false;
97+
}
98+
}
99+
}
100+
if (value.env !== undefined) {
101+
if (!isRecord(value.env)) {
102+
return false;
103+
}
104+
for (const envValue of Object.values(value.env)) {
105+
if (typeof envValue !== "string") {
106+
return false;
107+
}
108+
}
109+
}
110+
return true;
111+
}
112+
68113
function parseAcpxPluginConfig(value: unknown): ParseResult {
69114
if (value === undefined) {
70115
return { ok: true, value: undefined };
@@ -81,6 +126,7 @@ function parseAcpxPluginConfig(value: unknown): ParseResult {
81126
"strictWindowsCmdWrapper",
82127
"timeoutSeconds",
83128
"queueOwnerTtlSeconds",
129+
"mcpServers",
84130
]);
85131
for (const key of Object.keys(value)) {
86132
if (!allowedKeys.has(key)) {
@@ -152,6 +198,21 @@ function parseAcpxPluginConfig(value: unknown): ParseResult {
152198
return { ok: false, message: "queueOwnerTtlSeconds must be a non-negative number" };
153199
}
154200

201+
const mcpServers = value.mcpServers;
202+
if (mcpServers !== undefined) {
203+
if (!isRecord(mcpServers)) {
204+
return { ok: false, message: "mcpServers must be an object" };
205+
}
206+
for (const [key, serverConfig] of Object.entries(mcpServers)) {
207+
if (!isMcpServerConfig(serverConfig)) {
208+
return {
209+
ok: false,
210+
message: `mcpServers.${key} must have a command string, optional args array, and optional env object`,
211+
};
212+
}
213+
}
214+
}
215+
155216
return {
156217
ok: true,
157218
value: {
@@ -166,6 +227,7 @@ function parseAcpxPluginConfig(value: unknown): ParseResult {
166227
timeoutSeconds: typeof timeoutSeconds === "number" ? timeoutSeconds : undefined,
167228
queueOwnerTtlSeconds:
168229
typeof queueOwnerTtlSeconds === "number" ? queueOwnerTtlSeconds : undefined,
230+
mcpServers: mcpServers as Record<string, McpServerConfig> | undefined,
169231
},
170232
};
171233
}
@@ -219,11 +281,41 @@ export function createAcpxPluginConfigSchema(): OpenClawPluginConfigSchema {
219281
strictWindowsCmdWrapper: { type: "boolean" },
220282
timeoutSeconds: { type: "number", minimum: 0.001 },
221283
queueOwnerTtlSeconds: { type: "number", minimum: 0 },
284+
mcpServers: {
285+
type: "object",
286+
additionalProperties: {
287+
type: "object",
288+
properties: {
289+
command: { type: "string" },
290+
args: {
291+
type: "array",
292+
items: { type: "string" },
293+
},
294+
env: {
295+
type: "object",
296+
additionalProperties: { type: "string" },
297+
},
298+
},
299+
required: ["command"],
300+
},
301+
},
222302
},
223303
},
224304
};
225305
}
226306

307+
export function toAcpMcpServers(mcpServers: Record<string, McpServerConfig>): AcpxMcpServer[] {
308+
return Object.entries(mcpServers).map(([name, server]) => ({
309+
name,
310+
command: server.command,
311+
args: [...(server.args ?? [])],
312+
env: Object.entries(server.env ?? {}).map(([envName, value]) => ({
313+
name: envName,
314+
value,
315+
})),
316+
}));
317+
}
318+
227319
export function resolveAcpxPluginConfig(params: {
228320
rawConfig: unknown;
229321
workspaceDir?: string;
@@ -260,5 +352,6 @@ export function resolveAcpxPluginConfig(params: {
260352
normalized.strictWindowsCmdWrapper ?? DEFAULT_STRICT_WINDOWS_CMD_WRAPPER,
261353
timeoutSeconds: normalized.timeoutSeconds,
262354
queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS,
355+
mcpServers: normalized.mcpServers ?? {},
263356
};
264357
}

0 commit comments

Comments
 (0)