From ba722fd1265a201fd2fe50def42008b9817cdf05 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 11:49:11 +0100 Subject: [PATCH] test: speed up channel mcp tests --- src/mcp/channel-bridge.ts | 24 +++++++++++---- ...erver.shutdown-unhandled-rejection.test.ts | 14 +++++++-- src/mcp/channel-server.test.ts | 29 +++++++------------ src/mcp/channel-server.ts | 12 ++++++-- 4 files changed, 50 insertions(+), 29 deletions(-) diff --git a/src/mcp/channel-bridge.ts b/src/mcp/channel-bridge.ts index 684e01b4340..428c14e5d62 100644 --- a/src/mcp/channel-bridge.ts +++ b/src/mcp/channel-bridge.ts @@ -1,10 +1,7 @@ import { randomUUID } from "node:crypto"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { resolveGatewayClientBootstrap } from "../gateway/client-bootstrap.js"; -import { GatewayClient, GatewayClientRequestError } from "../gateway/client.js"; -import { APPROVALS_SCOPE, READ_SCOPE, WRITE_SCOPE } from "../gateway/method-scopes.js"; -import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../gateway/protocol/client-info.js"; +import type { GatewayClient } from "../gateway/client.js"; import type { EventFrame } from "../gateway/protocol/index.js"; import { extractFirstTextBlock } from "../shared/chat-message-content.js"; import { @@ -88,6 +85,17 @@ export class OpenClawChannelBridge { return; } this.started = true; + const [ + { resolveGatewayClientBootstrap }, + { GatewayClient: GatewayClientCtor }, + { APPROVALS_SCOPE, READ_SCOPE, WRITE_SCOPE }, + { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES }, + ] = await Promise.all([ + import("../gateway/client-bootstrap.js"), + import("../gateway/client.js"), + import("../gateway/method-scopes.js"), + import("../gateway/protocol/client-info.js"), + ]); const bootstrap = await resolveGatewayClientBootstrap({ config: this.cfg, gatewayUrl: this.params.gatewayUrl, @@ -102,7 +110,7 @@ export class OpenClawChannelBridge { return; } - this.gateway = new GatewayClient({ + this.gateway = new GatewayClientCtor({ url: bootstrap.url, token: bootstrap.auth.token, password: bootstrap.auth.password, @@ -525,7 +533,11 @@ export class OpenClawChannelBridge { } export function shouldRetryInitialMcpGatewayConnect(error: Error): boolean { - if (error instanceof GatewayClientRequestError) { + if ( + error.name === "GatewayClientRequestError" && + "retryable" in error && + typeof error.retryable === "boolean" + ) { return error.retryable; } const message = error.message.toLowerCase(); diff --git a/src/mcp/channel-server.shutdown-unhandled-rejection.test.ts b/src/mcp/channel-server.shutdown-unhandled-rejection.test.ts index 648051ef1a3..ca0fb4a58cf 100644 --- a/src/mcp/channel-server.shutdown-unhandled-rejection.test.ts +++ b/src/mcp/channel-server.shutdown-unhandled-rejection.test.ts @@ -79,6 +79,16 @@ vi.mock("./channel-tools.js", () => ({ registerChannelMcpTools: vi.fn(), })); +async function waitForTransport(): Promise<{ onclose?: (() => void) | undefined }> { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (transportState.lastTransport) { + return transportState.lastTransport; + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("MCP stdio transport was not created"); +} + describe("serveOpenClawChannelMcp shutdown", () => { const unhandledRejections: unknown[] = []; const onUnhandledRejection = (reason: unknown) => { @@ -102,9 +112,9 @@ describe("serveOpenClawChannelMcp shutdown", () => { const { serveOpenClawChannelMcp } = await import("./channel-server.js"); const servePromise = serveOpenClawChannelMcp({ verbose: false }); - await Promise.resolve(); + const transport = await waitForTransport(); - transportState.lastTransport?.onclose?.(); + transport.onclose?.(); await servePromise; await new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/src/mcp/channel-server.test.ts b/src/mcp/channel-server.test.ts index b48423c4ccd..427db49a7df 100644 --- a/src/mcp/channel-server.test.ts +++ b/src/mcp/channel-server.test.ts @@ -2,7 +2,6 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { describe, expect, test, vi } from "vitest"; import { z } from "zod"; -import { GatewayClientRequestError } from "../gateway/client.js"; import { shouldRetryInitialMcpGatewayConnect } from "./channel-bridge.js"; import { createOpenClawChannelMcpServer, OpenClawChannelBridge } from "./channel-server.js"; import { extractAttachmentsFromMessage } from "./channel-shared.js"; @@ -26,6 +25,7 @@ const ClaudePermissionNotificationSchema = z.object({ async function connectMcpWithoutGateway(params?: { claudeChannelMode?: "auto" | "on" | "off" }) { const serverHarness = await createOpenClawChannelMcpServer({ claudeChannelMode: params?.claudeChannelMode ?? "auto", + config: {} as never, verbose: false, }); const client = new Client({ name: "mcp-test-client", version: "1.0.0" }); @@ -74,29 +74,20 @@ async function flushMcpNotifications() { await Promise.resolve(); } +function gatewayRequestError(retryable: boolean): Error { + return Object.assign(new Error(retryable ? "gateway busy" : "auth failed"), { + name: "GatewayClientRequestError", + retryable, + }); +} + describe("openclaw channel mcp server", () => { test("keeps initial MCP gateway connection alive through transient connect errors", () => { expect( shouldRetryInitialMcpGatewayConnect(new Error("gateway request timeout for connect")), ).toBe(true); - expect( - shouldRetryInitialMcpGatewayConnect( - new GatewayClientRequestError({ - code: "BUSY", - message: "gateway busy", - retryable: true, - }), - ), - ).toBe(true); - expect( - shouldRetryInitialMcpGatewayConnect( - new GatewayClientRequestError({ - code: "UNAUTHORIZED", - message: "auth failed", - retryable: false, - }), - ), - ).toBe(false); + expect(shouldRetryInitialMcpGatewayConnect(gatewayRequestError(true))).toBe(true); + expect(shouldRetryInitialMcpGatewayConnect(gatewayRequestError(false))).toBe(false); }); describe("gateway-backed flows", () => { diff --git a/src/mcp/channel-server.ts b/src/mcp/channel-server.ts index 49798ec4fc1..78624a77a28 100644 --- a/src/mcp/channel-server.ts +++ b/src/mcp/channel-server.ts @@ -1,6 +1,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { VERSION } from "../version.js"; import { OpenClawChannelBridge } from "./channel-bridge.js"; import { ClaudePermissionRequestSchema, type ClaudeChannelMode } from "./channel-shared.js"; @@ -17,13 +17,21 @@ export type OpenClawMcpServeOptions = { verbose?: boolean; }; +async function resolveMcpConfig(config: OpenClawConfig | undefined): Promise { + if (config) { + return config; + } + const { getRuntimeConfig } = await import("../config/config.js"); + return getRuntimeConfig(); +} + export async function createOpenClawChannelMcpServer(opts: OpenClawMcpServeOptions = {}): Promise<{ server: McpServer; bridge: OpenClawChannelBridge; start: () => Promise; close: () => Promise; }> { - const cfg = opts.config ?? getRuntimeConfig(); + const cfg = await resolveMcpConfig(opts.config); const claudeChannelMode = opts.claudeChannelMode ?? "auto"; const capabilities = getChannelMcpCapabilities(claudeChannelMode); const server = new McpServer(