diff --git a/test/e2e/qa-lab/runtime/gateway-mcp-real-transports.test.ts b/test/e2e/qa-lab/runtime/gateway-mcp-real-transports.test.ts new file mode 100644 index 00000000000..e4241ef2605 --- /dev/null +++ b/test/e2e/qa-lab/runtime/gateway-mcp-real-transports.test.ts @@ -0,0 +1,99 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../../helpers/temp-dir.js"; +import { testing } from "./gateway-mcp-real-transports.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +function createRepoRoot() { + return tempDirs.make("openclaw-qalab-cli-entry-"); +} + +async function writeEntry(root: string, relativePath: string) { + const entryPath = path.join(root, relativePath); + await mkdir(path.dirname(entryPath), { recursive: true }); + await writeFile(entryPath, "", "utf8"); + return entryPath; +} + +describe("gateway MCP real transport producer", () => { + it("uses the source CLI entry when build output is absent", async () => { + const root = createRepoRoot(); + const entryPath = await writeEntry(root, "src/entry.ts"); + + const cli = testing.resolveOpenClawCliInvocation(root); + + expect(cli.command).toBe(process.execPath); + expect(cli.argsPrefix).toStrictEqual(["--import", "tsx", entryPath]); + expect(cli.cwd).toBe(root); + expect(cli.gatewayCommand).toMatchObject({ + executablePath: process.execPath, + argsPrefix: ["--import", "tsx", entryPath], + cwd: root, + }); + }); + + it("prefers built package output when it exists", async () => { + const root = createRepoRoot(); + const distEntry = await writeEntry(root, "dist/index.mjs"); + await writeEntry(root, "src/entry.ts"); + + const cli = testing.resolveOpenClawCliInvocation(root); + + expect(cli.argsPrefix).toStrictEqual([distEntry]); + expect(cli.gatewayCommand).toMatchObject({ + argsPrefix: [distEntry], + usePackagedPlugins: true, + }); + }); + + it("uses the source channel MCP module when build output is absent", async () => { + const root = createRepoRoot(); + const channelServerPath = await writeEntry(root, "src/mcp/channel-server.ts"); + + const mcp = testing.resolveChannelMcpInvocation({ + gatewayToken: "secret-token", + gatewayUrl: "ws://127.0.0.1:12345", + repoRoot: root, + tokenFile: "/tmp/token-file", + }); + + expect(mcp.command).toBe(process.execPath); + expect(mcp.args.slice(0, 3)).toStrictEqual(["--import", "tsx", "--eval"]); + expect(mcp.args[3]).toContain(channelServerPath); + expect(mcp.args[3]).toContain("serveOpenClawChannelMcp"); + expect(mcp.cwd).toBe(root); + expect(mcp.envPatch).toStrictEqual({ + OPENCLAW_QA_GATEWAY_TOKEN: "secret-token", + OPENCLAW_QA_GATEWAY_URL: "ws://127.0.0.1:12345", + }); + }); + + it("uses the packaged CLI for channel MCP when build output exists", async () => { + const root = createRepoRoot(); + const distEntry = await writeEntry(root, "dist/index.js"); + await writeEntry(root, "src/mcp/channel-server.ts"); + + const mcp = testing.resolveChannelMcpInvocation({ + gatewayToken: "secret-token", + gatewayUrl: "ws://127.0.0.1:12345", + repoRoot: root, + tokenFile: "/tmp/token-file", + }); + + expect(mcp.args).toStrictEqual([ + distEntry, + "mcp", + "serve", + "--url", + "ws://127.0.0.1:12345", + "--token-file", + "/tmp/token-file", + "--claude-channel-mode", + "off", + "--verbose", + ]); + expect(mcp.envPatch).toStrictEqual({}); + }); +}); diff --git a/test/e2e/qa-lab/runtime/gateway-mcp-real-transports.ts b/test/e2e/qa-lab/runtime/gateway-mcp-real-transports.ts index 48e010a2c23..9e707f844c1 100644 --- a/test/e2e/qa-lab/runtime/gateway-mcp-real-transports.ts +++ b/test/e2e/qa-lab/runtime/gateway-mcp-real-transports.ts @@ -11,6 +11,7 @@ import { WebSocket, WebSocketServer, type RawData } from "ws"; import { QA_EVIDENCE_FILENAME, startQaGatewayChild, + type QaGatewayChildCommand, type QaEvidenceSummaryJson, type QaGatewayChildListeningContext, } from "../../../../extensions/qa-lab/api.js"; @@ -57,6 +58,20 @@ type GatewayProxy = { url: string; }; +type OpenClawCliInvocation = { + argsPrefix: string[]; + command: string; + cwd: string; + gatewayCommand: QaGatewayChildCommand; +}; + +type ChannelMcpInvocation = { + args: string[]; + command: string; + cwd: string; + envPatch: NodeJS.ProcessEnv; +}; + type McpClientHandle = { client: Client; cleanup: () => void; @@ -212,6 +227,104 @@ function emptyTransport() { }; } +function resolveOpenClawCliInvocation(repoRoot: string): OpenClawCliInvocation { + for (const relativePath of ["dist/index.mjs", "dist/index.js"]) { + const entryPath = path.join(repoRoot, relativePath); + if (existsSync(entryPath)) { + const argsPrefix = [entryPath]; + return { + argsPrefix, + command: process.execPath, + cwd: repoRoot, + gatewayCommand: { + executablePath: process.execPath, + argsPrefix, + cwd: repoRoot, + usePackagedPlugins: true, + }, + }; + } + } + + const sourceEntryPath = path.join(repoRoot, "src/entry.ts"); + if (existsSync(sourceEntryPath)) { + const argsPrefix = ["--import", "tsx", sourceEntryPath]; + return { + argsPrefix, + command: process.execPath, + cwd: repoRoot, + gatewayCommand: { + executablePath: process.execPath, + argsPrefix, + cwd: repoRoot, + }, + }; + } + + throw new Error("OpenClaw CLI entry not found: expected dist/index.(m)js or src/entry.ts"); +} + +function resolveChannelMcpInvocation(params: { + gatewayToken: string; + gatewayUrl: string; + repoRoot: string; + tokenFile: string; +}): ChannelMcpInvocation { + for (const relativePath of ["dist/index.mjs", "dist/index.js"]) { + const entryPath = path.join(params.repoRoot, relativePath); + if (existsSync(entryPath)) { + return { + args: [ + entryPath, + "mcp", + "serve", + "--url", + params.gatewayUrl, + "--token-file", + params.tokenFile, + "--claude-channel-mode", + "off", + "--verbose", + ], + command: process.execPath, + cwd: params.repoRoot, + envPatch: {}, + }; + } + } + + const channelServerPath = path.join(params.repoRoot, "src/mcp/channel-server.ts"); + if (existsSync(channelServerPath)) { + const channelServerUrl = pathToFileURL(channelServerPath).href; + return { + args: [ + "--import", + "tsx", + "--eval", + [ + `import(${JSON.stringify(channelServerUrl)})`, + `.then((module) => module.serveOpenClawChannelMcp({`, + `gatewayUrl: process.env.OPENCLAW_QA_GATEWAY_URL,`, + `gatewayToken: process.env.OPENCLAW_QA_GATEWAY_TOKEN,`, + `claudeChannelMode: "off",`, + `verbose: true`, + `}))`, + ].join(""), + ], + command: process.execPath, + cwd: params.repoRoot, + envPatch: { + OPENCLAW_QA_GATEWAY_TOKEN: params.gatewayToken, + OPENCLAW_QA_GATEWAY_URL: params.gatewayUrl, + }, + }; + } + + throw new Error( + "OpenClaw channel MCP entry not found: expected dist/index.(m)js or src/mcp/channel-server.ts", + ); +} + function parseJsonFrame(data: RawData): Record | null { try { const text = Array.isArray(data) @@ -332,24 +445,20 @@ async function connectChannelMcpClient(params: { repoRoot: string; }): Promise { const tempState = createMcpClientTempState({ gatewayToken: params.gatewayToken }); + const mcpInvocation = resolveChannelMcpInvocation({ + gatewayToken: params.gatewayToken, + gatewayUrl: params.gatewayUrl, + repoRoot: params.repoRoot, + tokenFile: tempState.tokenFile, + }); const stderrChunks: Buffer[] = []; const transport = new StdioClientTransport({ - command: process.execPath, - args: [ - path.join(params.repoRoot, "dist/index.js"), - "mcp", - "serve", - "--url", - params.gatewayUrl, - "--token-file", - tempState.tokenFile, - "--claude-channel-mode", - "off", - "--verbose", - ], - cwd: params.repoRoot, + command: mcpInvocation.command, + args: mcpInvocation.args, + cwd: mcpInvocation.cwd, env: { ...process.env, + ...mcpInvocation.envPatch, OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: "1", OPENCLAW_LOG_LEVEL: "debug", OPENCLAW_STATE_DIR: tempState.stateDir, @@ -416,6 +525,7 @@ async function approvePendingMcpPairing(gateway: Awaited { const gateway = await startQaGatewayChild({ repoRoot: options.repoRoot, + command: resolveOpenClawCliInvocation(options.repoRoot).gatewayCommand, transport: emptyTransport(), transportBaseUrl: "http://127.0.0.1", controlUiEnabled: false, @@ -475,6 +585,7 @@ async function runMcpGatewayStartupRetryProof(options: ProducerOptions): Promise }; gateway = await startQaGatewayChild({ repoRoot: options.repoRoot, + command: resolveOpenClawCliInvocation(options.repoRoot).gatewayCommand, transport: emptyTransport(), transportBaseUrl: "http://127.0.0.1", controlUiEnabled: false, @@ -699,3 +810,8 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { process.exitCode = 1; }); } + +export const testing = { + resolveChannelMcpInvocation, + resolveOpenClawCliInvocation, +};