From 66fa939dad53fdf3f200bd92df9f390a323fff1b Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:54:51 +0200 Subject: [PATCH] test(claude): reproduce silent MCP confirmation declines Retain an opt-in failing reproduction for #3941 and a real-SDK callback control. No production behavior changes. --- .../claude/agent.elicitation.real.e2e.test.ts | 89 +++++++++++++++++++ .../test-fixtures/elicitation-server.mjs | 64 +++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 packages/server/src/server/agent/providers/claude/agent.elicitation.real.e2e.test.ts create mode 100644 packages/server/src/server/agent/providers/claude/test-fixtures/elicitation-server.mjs diff --git a/packages/server/src/server/agent/providers/claude/agent.elicitation.real.e2e.test.ts b/packages/server/src/server/agent/providers/claude/agent.elicitation.real.e2e.test.ts new file mode 100644 index 0000000000..4ebc5f051d --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.elicitation.real.e2e.test.ts @@ -0,0 +1,89 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import pino from "pino"; +import { expect, test } from "vitest"; +import { ClaudeAgentClient } from "./agent.js"; +import { claudeQuery, type ClaudeQueryFactory } from "./query.js"; +import type { AgentStreamEvent } from "../../agent-sdk-types.js"; + +// Diagnostic evidence for #3941. The baseline intentionally fails until Paseo +// handles MCP elicitation. The control uses the real SDK with a supplied callback; +// it demonstrates causation, not a production fix or a user approval UI. +const diagnosticTest = test.skipIf(process.env.PASEO_TEST_CLAUDE_ELICITATION !== "1"); + +diagnosticTest.each([false, true])( + "Claude MCP confirmation, diagnostic callback supplied: %s", + async (supplyCallback) => { + const fixtureDir = fileURLToPath(new URL("./test-fixtures/", import.meta.url)); + const cwd = await mkdtemp(path.join(fixtureDir, "elicitation-workspace-")); + const auditPath = path.join(cwd, "elicitation-audit.jsonl"); + const callbackRequests: unknown[] = []; + const queryFactory: ClaudeQueryFactory | undefined = supplyCallback + ? (input) => + claudeQuery({ + ...input, + options: { + ...input.options, + onElicitation: async (request) => { + callbackRequests.push(request); + return { action: "accept", content: {} }; + }, + }, + }) + : undefined; + const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }), queryFactory }); + const session = await client.createSession({ + provider: "claude", + model: "haiku", + modeId: "auto", + cwd, + mcpServers: { + diagnostic: { + type: "stdio", + command: process.execPath, + args: [path.join(fixtureDir, "elicitation-server.mjs"), auditPath], + }, + }, + // Authorize only the harmless tool invocation. Its MCP confirmation must + // still be a separate user decision, as in the reported Auto-mode path. + providerOptions: { allowedTools: ["mcp__diagnostic__confirm_action"] }, + }); + const events: AgentStreamEvent[] = []; + const unsubscribe = session.subscribe((event) => events.push(event)); + try { + await session.startTurn( + "Call mcp__diagnostic__confirm_action exactly once now. It only requests local human confirmation and performs no external action. Report the exact tool result. Do not use any other tools.", + ); + await expect + .poll( + () => + session.getPendingPermissions().length > 0 || + events.some((event) => ["turn_completed", "turn_failed"].includes(event.type)), + { timeout: 150_000 }, + ) + .toBe(true); + const audit = (await readFile(auditPath, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + console.log("MCP audit", audit); + expect(audit[0]).toEqual({ event: "elicitation_requested" }); + if (supplyCallback) { + expect(callbackRequests).toHaveLength(1); + expect(audit[1].result).toEqual({ action: "accept", content: {} }); + } else { + expect( + session.getPendingPermissions(), + "MCP confirmation must await the user", + ).toHaveLength(1); + expect(audit).toHaveLength(1); + } + } finally { + unsubscribe(); + await session.close(); + await rm(cwd, { recursive: true, force: true }); + } + }, + 180_000, +); diff --git a/packages/server/src/server/agent/providers/claude/test-fixtures/elicitation-server.mjs b/packages/server/src/server/agent/providers/claude/test-fixtures/elicitation-server.mjs new file mode 100644 index 0000000000..a1228cfd02 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/test-fixtures/elicitation-server.mjs @@ -0,0 +1,64 @@ +import readline from "node:readline"; +import { appendFileSync } from "node:fs"; + +const auditPath = process.argv[2]; +let toolRequestId; +function send(message) { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\n"); +} +readline.createInterface({ input: process.stdin }).on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") { + send({ + id: message.id, + result: { + protocolVersion: message.params.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: "elicitation-repro", version: "1.0.0" }, + }, + }); + } else if (message.method === "tools/list") { + send({ + id: message.id, + result: { + tools: [ + { + name: "confirm_action", + description: "Ask the user for confirmation. Performs no external action.", + inputSchema: { type: "object", properties: {} }, + }, + ], + }, + }); + } else if (message.method === "tools/call") { + toolRequestId = message.id; + appendFileSync(auditPath, JSON.stringify({ event: "elicitation_requested" }) + "\n"); + send({ + id: "approval-1", + method: "elicitation/create", + params: { + mode: "form", + message: "Approve this local diagnostic action? No message will be sent.", + requestedSchema: { type: "object", properties: {} }, + }, + }); + } else if (message.id === "approval-1") { + appendFileSync(auditPath, JSON.stringify({ event: "elicitation_response", ...message }) + "\n"); + send({ + id: toolRequestId, + result: { + content: [ + { + type: "text", + text: + message.result?.action === "decline" + ? "Action confirm_action was declined by the user." + : JSON.stringify(message.result ?? message.error), + }, + ], + }, + }); + } else if (message.id !== undefined) { + send({ id: message.id, result: {} }); + } +});