From 5a7e8a166a07a0c74700134f88671ac88830a543 Mon Sep 17 00:00:00 2001 From: gongfpp <1360187968@qq.com> Date: Sat, 17 Oct 2026 17:36:32 +0800 Subject: [PATCH] fix(mcp): normalize browser scenario input errors (#4661) Co-authored-by: gongfpp <22683417+gongfpp@users.noreply.github.com> --- src/cliBrowserScenarioCommands.ts | 8 +- src/server/registerBrowserScenarioTool.ts | 16 ++- ...browserScenarioInputDiagnosticsMcp.test.ts | 130 ++++++++++++++++++ 3 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 tests/boundary/mcp/browserScenarioInputDiagnosticsMcp.test.ts diff --git a/src/cliBrowserScenarioCommands.ts b/src/cliBrowserScenarioCommands.ts index 1a8785a664..a18ffe2204 100644 --- a/src/cliBrowserScenarioCommands.ts +++ b/src/cliBrowserScenarioCommands.ts @@ -6,11 +6,10 @@ import { withCommandCancellation } from "./cli/commandCancellation.js"; import { CLI_COMMANDS } from "./cliCommandNames.js"; import { parseCliJsonInput, resolveCliJsonPaths } from "./cliJsonInput.js"; import { logCliCommand } from "./cliLogging.js"; -import { AnalysisInputError } from "./domain/analysisErrorCore.js"; import { AnalysisError } from "./domain/analysisErrorBase.js"; import { projectAnalysisError } from "./domain/analysisErrorProjection.js"; import { BrowserObservationError } from "./domain/browserObservationError.js"; -import { projectInputIssues } from "./domain/inputIssueProjection.js"; +import { analysisInputErrorFromIssues } from "./domain/inputIssueProjection.js"; import { browserScenarioSchema } from "./domain/browserScenario.js"; import type { JsonValue } from "./domain/jsonValue.js"; import type { Logger } from "pino"; @@ -41,10 +40,11 @@ export const registerBrowserScenarioCommands = ( ); if (!scenario.success) return cliError( - new AnalysisInputError( + analysisInputErrorFromIssues( OPERATION, + scenario.error.issues, + input.value, { cause: scenario.error }, - projectInputIssues(scenario.error.issues, input.value), ), ); // Load Playwright only for this command, not during CLI startup. diff --git a/src/server/registerBrowserScenarioTool.ts b/src/server/registerBrowserScenarioTool.ts index 8ecebd5340..8c2015bc4a 100644 --- a/src/server/registerBrowserScenarioTool.ts +++ b/src/server/registerBrowserScenarioTool.ts @@ -9,6 +9,7 @@ import { err } from "../domain/result.js"; import type { BrowserScenarioCapturePort } from "../application/BrowserScenarioCapturePort.js"; import { captureBrowserScenario } from "../application/BrowserScenarioCaptureService.js"; import { toolContract } from "../contracts/toolContracts.js"; +import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; import { browserScenarioSchema } from "../domain/browserScenario.js"; import type { Logger } from "pino"; import { logToolExecution } from "./toolLogging.js"; @@ -35,7 +36,20 @@ export const registerBrowserScenarioTool = ( contract.name, toolRegistrationOptions(contract), async (input, context) => { - const scenario = browserScenarioSchema.parse(input); + const parsedScenario = browserScenarioSchema.safeParse(input); + if (!parsedScenario.success) + return server.delivery.toCallToolResult( + err( + analysisInputErrorFromIssues( + contract.name, + parsedScenario.error.issues, + input, + { cause: parsedScenario.error }, + ), + ), + contract, + ); + const scenario = parsedScenario.data; if (options.loadFailure !== undefined) return server.delivery.toCallToolResult( err(optionalProviderUnavailable(options.loadFailure, contract.name)), diff --git a/tests/boundary/mcp/browserScenarioInputDiagnosticsMcp.test.ts b/tests/boundary/mcp/browserScenarioInputDiagnosticsMcp.test.ts new file mode 100644 index 0000000000..bb8760bcc4 --- /dev/null +++ b/tests/boundary/mcp/browserScenarioInputDiagnosticsMcp.test.ts @@ -0,0 +1,130 @@ +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { join, resolve } from "node:path"; +import { expect } from "vitest"; +import { z } from "zod"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const scenarios = [ + { + name: "undeclared secret references", + issuePath: ["secrets"], + issueMessage: "Secret reference pw is not declared", + actions: [ + { + step_id: "pw", + action: "fill", + locator: { kind: "css", selector: "#pw" }, + value: { source: "secret", secret_id: "pw" }, + }, + ], + }, + { + name: "duplicate action step IDs", + issuePath: ["actions"], + issueMessage: "Action step IDs must be unique", + actions: [ + { step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }, + { step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }, + ], + }, +] as const; + +cliTest( + "projects refined browser scenario validation into CLI-equivalent diagnostics over stdio MCP", + async ({ cli }) => { + const home = await createTestTempDirectory( + "rea-browser-scenario-mcp-home-", + ); + const client = new Client({ + name: "browser-scenario-input-diagnostics", + version: "1", + }); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [resolve("scripts/rea.mjs"), "mcp"], + cwd: process.cwd(), + env: { + PATH: process.env.PATH ?? "", + HOME: home, + REA_LOG_LEVEL: "silent", + }, + stderr: "pipe", + }); + transport.stderr?.on("data", () => undefined); + + try { + await client.connect(transport); + const tool = (await client.listTools()).tools.find( + ({ name }) => name === "capture_browser_scenario", + ); + if (tool === undefined) throw new Error("Missing browser scenario tool"); + const validate = new Ajv2020({ + strict: false, + validateFormats: false, + }).compile(tool.inputSchema); + + for (const scenario of scenarios) { + const input = { + browser: { + mode: "launch", + executable_path: join(home, "missing-chromium"), + }, + start_url: { url: "http://127.0.0.1:1/", query: [] }, + actions: scenario.actions, + secrets: [], + }; + expect( + validate(input), + `${scenario.name}: ${JSON.stringify(validate.errors)}`, + ).toBe(true); + + const cliResult = await cli.run({ + arguments: [ + "capture-browser-scenario", + JSON.stringify(input), + "--json", + ], + environment: { HOME: home, REA_LOG_LEVEL: "silent" }, + }); + expect(cliResult.exitCode).toBe(1); + const cliDiagnostic = z + .record(z.string(), z.unknown()) + .parse(cliResult.json); + expect(typeof cliDiagnostic.error).toBe("string"); + delete cliDiagnostic.error; + expect(cliDiagnostic).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + retryable: true, + remediation: { action: "Correct the listed arguments and retry." }, + details: { + operation: "capture_browser_scenario", + issues: [ + { + path: scenario.issuePath, + reason: "invalid_value", + message: scenario.issueMessage, + }, + ], + }, + }); + + const mcpResult = await client.callTool({ + name: tool.name, + arguments: input, + }); + expect(mcpResult.structuredContent).toBeUndefined(); + expect(parseMcpToolError(mcpResult).error).toEqual(cliDiagnostic); + await expect(client.ping()).resolves.toEqual({}); + } + } finally { + await Promise.all([client.close(), transport.close()]); + } + }, + 60_000, +);