@actagent/acpx 2026.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Stdio MCP proxy used by ACPX wrappers. It injects ACTAgent-provided MCP
5
+ * servers into session creation/load/fork requests before forwarding to target.
6
+ */
7
+ import { spawn } from "node:child_process";
8
+ import path from "node:path";
9
+ import { createInterface } from "node:readline";
10
+ import { pathToFileURL } from "node:url";
11
+ import { splitCommandLine } from "./mcp-command-line.mjs";
12
+
13
+ function formatErrorMessage(error) {
14
+ if (error instanceof Error) {
15
+ return error.message || error.name || "Error";
16
+ }
17
+ return String(error);
18
+ }
19
+
20
+ function decodePayload(argv) {
21
+ const payloadIndex = argv.indexOf("--payload");
22
+ if (payloadIndex < 0) {
23
+ throw new Error("Missing --payload");
24
+ }
25
+ const encoded = argv[payloadIndex + 1];
26
+ if (!encoded) {
27
+ throw new Error("Missing MCP proxy payload value");
28
+ }
29
+ const parsed = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
30
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
31
+ throw new Error("Invalid MCP proxy payload");
32
+ }
33
+ if (typeof parsed.targetCommand !== "string" || parsed.targetCommand.trim() === "") {
34
+ throw new Error("MCP proxy payload missing targetCommand");
35
+ }
36
+ const mcpServers = Array.isArray(parsed.mcpServers) ? parsed.mcpServers : [];
37
+ return {
38
+ targetCommand: parsed.targetCommand,
39
+ mcpServers,
40
+ };
41
+ }
42
+
43
+ function shouldInject(method) {
44
+ return method === "session/new" || method === "session/load" || method === "session/fork";
45
+ }
46
+
47
+ function rewriteLine(line, mcpServers) {
48
+ if (!line.trim()) {
49
+ return line;
50
+ }
51
+ try {
52
+ const parsed = JSON.parse(line);
53
+ if (
54
+ !parsed ||
55
+ typeof parsed !== "object" ||
56
+ Array.isArray(parsed) ||
57
+ !shouldInject(parsed.method) ||
58
+ !parsed.params ||
59
+ typeof parsed.params !== "object" ||
60
+ Array.isArray(parsed.params)
61
+ ) {
62
+ return line;
63
+ }
64
+ const next = {
65
+ ...parsed,
66
+ params: {
67
+ ...parsed.params,
68
+ mcpServers,
69
+ },
70
+ };
71
+ return JSON.stringify(next);
72
+ } catch {
73
+ return line;
74
+ }
75
+ }
76
+
77
+ /** Build spawn options for the proxied MCP target process. */
78
+ export function createTargetSpawnOptions(platform = process.platform) {
79
+ const options = {
80
+ stdio: ["pipe", "pipe", "inherit"],
81
+ env: process.env,
82
+ };
83
+ if (platform === "win32") {
84
+ options.windowsHide = true;
85
+ }
86
+ return options;
87
+ }
88
+
89
+ function isMainModule() {
90
+ const mainPath = process.argv[1];
91
+ if (!mainPath) {
92
+ return false;
93
+ }
94
+ return import.meta.url === pathToFileURL(path.resolve(mainPath)).href;
95
+ }
96
+
97
+ function main() {
98
+ const { targetCommand, mcpServers } = decodePayload(process.argv.slice(2));
99
+ const target = splitCommandLine(targetCommand);
100
+ const child = spawn(target.command, target.args, createTargetSpawnOptions());
101
+
102
+ if (!child.stdin || !child.stdout) {
103
+ throw new Error("Failed to create MCP proxy stdio pipes");
104
+ }
105
+
106
+ const input = createInterface({ input: process.stdin });
107
+ input.on("line", (line) => {
108
+ child.stdin.write(`${rewriteLine(line, mcpServers)}\n`);
109
+ });
110
+ input.on("close", () => {
111
+ child.stdin.end();
112
+ });
113
+
114
+ child.stdout.pipe(process.stdout);
115
+
116
+ child.on("error", (error) => {
117
+ process.stderr.write(`${formatErrorMessage(error)}\n`);
118
+ process.exit(1);
119
+ });
120
+
121
+ child.on("close", (code, signal) => {
122
+ if (signal) {
123
+ process.kill(process.pid, signal);
124
+ return;
125
+ }
126
+ process.exit(code ?? 0);
127
+ });
128
+ }
129
+
130
+ if (isMainModule()) {
131
+ main();
132
+ }
@@ -0,0 +1,131 @@
1
+ // ACPX tests cover mcp proxy plugin behavior.
2
+ import { spawn } from "node:child_process";
3
+ import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+ import { bundledPluginFile } from "actagent/plugin-sdk/test-fixtures";
8
+ import { afterEach, describe, expect, it } from "vitest";
9
+
10
+ const tempDirs: string[] = [];
11
+ const proxyPath = path.resolve(bundledPluginFile("acpx", "src/runtime-internals/mcp-proxy.mjs"));
12
+
13
+ async function makeTempScript(name: string, content: string): Promise<string> {
14
+ const dir = await mkdtemp(path.join(os.tmpdir(), "actagent-acpx-mcp-proxy-"));
15
+ tempDirs.push(dir);
16
+ const scriptPath = path.join(dir, name);
17
+ await writeFile(scriptPath, content, "utf8");
18
+ await chmod(scriptPath, 0o755);
19
+ return scriptPath;
20
+ }
21
+
22
+ afterEach(async () => {
23
+ while (tempDirs.length > 0) {
24
+ const dir = tempDirs.pop();
25
+ if (!dir) {
26
+ continue;
27
+ }
28
+ await rm(dir, { recursive: true, force: true });
29
+ }
30
+ });
31
+
32
+ describe("mcp-proxy", () => {
33
+ it("hides the target MCP process window on Windows only", async () => {
34
+ const moduleUrl = pathToFileURL(proxyPath).href;
35
+ const { createTargetSpawnOptions } = (await import(moduleUrl)) as {
36
+ createTargetSpawnOptions: (platform?: NodeJS.Platform) => Record<string, unknown>;
37
+ };
38
+
39
+ expect(createTargetSpawnOptions("win32")).toEqual({
40
+ env: process.env,
41
+ stdio: ["pipe", "pipe", "inherit"],
42
+ windowsHide: true,
43
+ });
44
+ expect(createTargetSpawnOptions("darwin")).not.toHaveProperty("windowsHide");
45
+ expect(createTargetSpawnOptions("linux")).not.toHaveProperty("windowsHide");
46
+ });
47
+
48
+ it("injects configured MCP servers into ACP session bootstrap requests", async () => {
49
+ const echoServerPath = await makeTempScript(
50
+ "echo-server.cjs",
51
+ String.raw`#!/usr/bin/env node
52
+ const { createInterface } = require("node:readline");
53
+ const rl = createInterface({ input: process.stdin });
54
+ rl.on("line", (line) => process.stdout.write(line + "\n"));
55
+ `,
56
+ );
57
+
58
+ const payload = Buffer.from(
59
+ JSON.stringify({
60
+ targetCommand: `${process.execPath} ${echoServerPath}`,
61
+ mcpServers: [
62
+ {
63
+ name: "canva",
64
+ command: "npx",
65
+ args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
66
+ env: [{ name: "CANVA_TOKEN", value: "secret" }],
67
+ },
68
+ ],
69
+ }),
70
+ "utf8",
71
+ ).toString("base64url");
72
+
73
+ const child = spawn(process.execPath, [proxyPath, "--payload", payload], {
74
+ stdio: ["pipe", "pipe", "inherit"],
75
+ cwd: process.cwd(),
76
+ });
77
+
78
+ let stdout = "";
79
+ child.stdout.on("data", (chunk) => {
80
+ stdout += String(chunk);
81
+ });
82
+
83
+ child.stdin.write(
84
+ `${JSON.stringify({
85
+ jsonrpc: "2.0",
86
+ id: 1,
87
+ method: "session/new",
88
+ params: { cwd: process.cwd(), mcpServers: [] },
89
+ })}\n`,
90
+ );
91
+ child.stdin.write(
92
+ `${JSON.stringify({
93
+ jsonrpc: "2.0",
94
+ id: 2,
95
+ method: "session/load",
96
+ params: { cwd: process.cwd(), sessionId: "sid-1", mcpServers: [] },
97
+ })}\n`,
98
+ );
99
+ child.stdin.write(
100
+ `${JSON.stringify({
101
+ jsonrpc: "2.0",
102
+ id: 3,
103
+ method: "session/prompt",
104
+ params: { sessionId: "sid-1", prompt: [{ type: "text", text: "hello" }] },
105
+ })}\n`,
106
+ );
107
+ child.stdin.end();
108
+
109
+ const exitCode = await new Promise<number | null>((resolve) => {
110
+ child.once("close", (code) => resolve(code));
111
+ });
112
+
113
+ expect(exitCode).toBe(0);
114
+ const lines = stdout
115
+ .trim()
116
+ .split(/\r?\n/)
117
+ .map((line) => JSON.parse(line) as { method: string; params: Record<string, unknown> });
118
+
119
+ expect(lines[0].params.mcpServers).toEqual([
120
+ {
121
+ name: "canva",
122
+ command: "npx",
123
+ args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
124
+ env: [{ name: "CANVA_TOKEN", value: "secret" }],
125
+ },
126
+ ]);
127
+ expect(lines[1].params.mcpServers).toEqual(lines[0].params.mcpServers);
128
+ expect(lines[2].method).toBe("session/prompt");
129
+ expect(lines[2].params.mcpServers).toBeUndefined();
130
+ });
131
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Lazy ACP runtime proxy for ACPX. It defers resolving the real runtime until
3
+ * the first ACP call while preserving the SDK runtime shape.
4
+ */
5
+ import type { AcpRuntime } from "../runtime-api.js";
6
+ import { lazyStartRuntimeTurn } from "./runtime-turn.js";
7
+
8
+ /** Create an ACP runtime facade backed by an async runtime resolver. */
9
+ export function createLazyAcpRuntimeProxy<T extends AcpRuntime>(
10
+ resolveRuntime: () => Promise<T>,
11
+ ): AcpRuntime {
12
+ return {
13
+ async ensureSession(input) {
14
+ return await (await resolveRuntime()).ensureSession(input);
15
+ },
16
+ startTurn(input) {
17
+ return lazyStartRuntimeTurn(resolveRuntime, input);
18
+ },
19
+ async *runTurn(input) {
20
+ yield* (await resolveRuntime()).runTurn(input);
21
+ },
22
+ async getCapabilities(input) {
23
+ return (await (await resolveRuntime()).getCapabilities?.(input)) ?? { controls: [] };
24
+ },
25
+ async getStatus(input) {
26
+ return (await (await resolveRuntime()).getStatus?.(input)) ?? {};
27
+ },
28
+ async setMode(input) {
29
+ await (await resolveRuntime()).setMode?.(input);
30
+ },
31
+ async setConfigOption(input) {
32
+ await (await resolveRuntime()).setConfigOption?.(input);
33
+ },
34
+ async doctor() {
35
+ return (await (await resolveRuntime()).doctor?.()) ?? { ok: true, message: "ok" };
36
+ },
37
+ async prepareFreshSession(input) {
38
+ await (await resolveRuntime()).prepareFreshSession?.(input);
39
+ },
40
+ async cancel(input) {
41
+ await (await resolveRuntime()).cancel(input);
42
+ },
43
+ async close(input) {
44
+ await (await resolveRuntime()).close(input);
45
+ },
46
+ };
47
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * ACPX turn adapters. Modern runtimes can expose startTurn directly; legacy
3
+ * runtimes that only stream runTurn events are adapted to the newer contract.
4
+ */
5
+ import type {
6
+ AcpRuntime,
7
+ AcpRuntimeEvent,
8
+ AcpRuntimeTurn,
9
+ AcpRuntimeTurnInput,
10
+ AcpRuntimeTurnResult,
11
+ } from "../runtime-api.js";
12
+
13
+ function createDeferredResult<T>() {
14
+ let resolve!: (value: T) => void;
15
+ let reject!: (error: unknown) => void;
16
+ const promise = new Promise<T>((resolvePromise, rejectPromise) => {
17
+ resolve = resolvePromise;
18
+ reject = rejectPromise;
19
+ });
20
+ return { promise, resolve, reject };
21
+ }
22
+
23
+ class LegacyRunTurnEventQueue {
24
+ private readonly items: AcpRuntimeEvent[] = [];
25
+ private readonly waits: Array<{
26
+ resolve: (value: AcpRuntimeEvent | null) => void;
27
+ reject: (error: unknown) => void;
28
+ }> = [];
29
+ private closed = false;
30
+ private error: unknown;
31
+
32
+ push(item: AcpRuntimeEvent): void {
33
+ if (this.closed) {
34
+ return;
35
+ }
36
+ const waiter = this.waits.shift();
37
+ if (waiter) {
38
+ waiter.resolve(item);
39
+ return;
40
+ }
41
+ this.items.push(item);
42
+ }
43
+
44
+ clear(): void {
45
+ this.items.length = 0;
46
+ }
47
+
48
+ close(): void {
49
+ if (this.closed) {
50
+ return;
51
+ }
52
+ this.closed = true;
53
+ for (const waiter of this.waits.splice(0)) {
54
+ waiter.resolve(null);
55
+ }
56
+ }
57
+
58
+ fail(error: unknown): void {
59
+ if (this.closed) {
60
+ return;
61
+ }
62
+ this.error = error;
63
+ this.closed = true;
64
+ for (const waiter of this.waits.splice(0)) {
65
+ waiter.reject(error);
66
+ }
67
+ }
68
+
69
+ private async next(): Promise<AcpRuntimeEvent | null> {
70
+ const item = this.items.shift();
71
+ if (item) {
72
+ return item;
73
+ }
74
+ if (this.error) {
75
+ throw toLintErrorObject(this.error, "Non-Error thrown");
76
+ }
77
+ if (this.closed) {
78
+ return null;
79
+ }
80
+ return await new Promise<AcpRuntimeEvent | null>((resolve, reject) => {
81
+ this.waits.push({ resolve, reject });
82
+ });
83
+ }
84
+
85
+ async *iterate(): AsyncIterable<AcpRuntimeEvent> {
86
+ for (;;) {
87
+ const item = await this.next();
88
+ if (!item) {
89
+ return;
90
+ }
91
+ yield item;
92
+ }
93
+ }
94
+ }
95
+
96
+ function legacyRunTurnAsStartTurn(runtime: AcpRuntime, input: AcpRuntimeTurnInput): AcpRuntimeTurn {
97
+ const result = createDeferredResult<AcpRuntimeTurnResult>();
98
+ result.promise.catch(() => {});
99
+ const queue = new LegacyRunTurnEventQueue();
100
+ let resultSettled = false;
101
+ const settleResult = (next: AcpRuntimeTurnResult) => {
102
+ if (resultSettled) {
103
+ return;
104
+ }
105
+ resultSettled = true;
106
+ result.resolve(next);
107
+ };
108
+ void (async () => {
109
+ try {
110
+ for await (const event of runtime.runTurn(input)) {
111
+ if (event.type === "done") {
112
+ settleResult({
113
+ status: "completed",
114
+ ...(event.stopReason ? { stopReason: event.stopReason } : {}),
115
+ });
116
+ continue;
117
+ }
118
+ if (event.type === "error") {
119
+ settleResult({
120
+ status: "failed",
121
+ error: {
122
+ message: event.message,
123
+ ...(event.code ? { code: event.code } : {}),
124
+ ...(event.detailCode ? { detailCode: event.detailCode } : {}),
125
+ ...(event.retryable === undefined ? {} : { retryable: event.retryable }),
126
+ },
127
+ });
128
+ continue;
129
+ }
130
+ queue.push(event);
131
+ }
132
+ settleResult({
133
+ status: "failed",
134
+ error: {
135
+ code: "ACP_TURN_FAILED",
136
+ message: "ACP turn ended without a terminal done event.",
137
+ },
138
+ });
139
+ } catch (error) {
140
+ result.reject(error);
141
+ queue.fail(error);
142
+ return;
143
+ }
144
+ queue.close();
145
+ })();
146
+ return {
147
+ requestId: input.requestId,
148
+ events: queue.iterate(),
149
+ result: result.promise,
150
+ async cancel(inputArgs) {
151
+ await runtime.cancel({ handle: input.handle, reason: inputArgs?.reason });
152
+ },
153
+ async closeStream() {
154
+ queue.clear();
155
+ queue.close();
156
+ },
157
+ };
158
+ }
159
+
160
+ /** Start an ACP turn, adapting legacy runTurn-only runtimes when needed. */
161
+ export function startRuntimeTurn(runtime: AcpRuntime, input: AcpRuntimeTurnInput): AcpRuntimeTurn {
162
+ return runtime.startTurn?.(input) ?? legacyRunTurnAsStartTurn(runtime, input);
163
+ }
164
+
165
+ /** Start an ACP turn through a lazy runtime resolver. */
166
+ export function lazyStartRuntimeTurn(
167
+ resolveRuntime: () => Promise<AcpRuntime>,
168
+ input: AcpRuntimeTurnInput,
169
+ ): AcpRuntimeTurn {
170
+ const turnPromise = resolveRuntime().then((runtime) => startRuntimeTurn(runtime, input));
171
+ return {
172
+ requestId: input.requestId,
173
+ events: {
174
+ async *[Symbol.asyncIterator]() {
175
+ yield* (await turnPromise).events;
176
+ },
177
+ },
178
+ result: turnPromise.then((turn) => turn.result),
179
+ cancel(inputArgs) {
180
+ return turnPromise.then((turn) => turn.cancel(inputArgs));
181
+ },
182
+ closeStream(inputArgs) {
183
+ return turnPromise.then((turn) => turn.closeStream(inputArgs));
184
+ },
185
+ };
186
+ }
187
+
188
+ function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
189
+ if (value instanceof Error) {
190
+ return value;
191
+ }
192
+ if (typeof value === "string") {
193
+ return new Error(value);
194
+ }
195
+ const error = new Error(fallbackMessage, { cause: value });
196
+ if ((typeof value === "object" && value !== null) || typeof value === "function") {
197
+ Object.assign(error, value);
198
+ }
199
+ return error;
200
+ }