From 64fdb9364f826e72bd61542e56475c1bfa8ebf6d Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 9 Nov 2026 11:23:09 +0100 Subject: [PATCH] feat: serve elicitations on the native v2 core AskUserQuestion as a form elicitation (no longer disallowed with form support), MCP server elicitations (form and URL, cancel when the client cannot render them), the refusal fallback's consent prompt, and MCP OAuth after the setup response. They are open requests of their session (requires_action), withdrawn by a cancel or close like permission requests. The answer logic moves to shared helpers in elicitation.ts and the OAuth flow to mcp-oauth.ts, which the v1 core now calls too. --- docs/v2-native-core.md | 31 +- src/acp-agent.ts | 367 ++++------------------ src/elicitation.ts | 161 +++++++++- src/mcp-oauth.ts | 214 +++++++++++++ src/tests/acp-scenarios-v2-native.test.ts | 7 +- src/tests/v2-native.test.ts | 147 +++++++++ src/v2-native/agent.ts | 65 +++- src/v2-native/elicitation.ts | 166 ++++++++++ src/v2-native/session.ts | 61 +++- src/v2-native/tasks.ts | 31 +- 10 files changed, 906 insertions(+), 344 deletions(-) create mode 100644 src/mcp-oauth.ts create mode 100644 src/v2-native/elicitation.ts diff --git a/docs/v2-native-core.md b/docs/v2-native-core.md index f2803cb1..5825fef1 100644 --- a/docs/v2-native-core.md +++ b/docs/v2-native-core.md @@ -31,13 +31,15 @@ both, so a v2 core can follow Claude Code's own events instead of rebuilding a t | `session/prompt` fails | `command_lifecycle` `cancelled` (-32800), `discarded` or `refused` (-32603) | | `state_update` `running` | a turn starts: a prompt's `started`, or the turn's `system/init` | | `state_update` `idle` | the turn's `result`, with its stop reason, usage and error | -| `state_update` `requires_action` | a turn is open and a permission request of the session is open | +| `state_update` `requires_action` | a turn is open and a permission request or elicitation of the session is open | | `session/cancel` | `interrupt({ cancelQueued: true })`, which also ends the queued prompts | | `session/resume` with `replayFrom` | `getSessionMessages`, mapped like live output | +| `elicitation/create` | AskUserQuestion, `onElicitation`, `onUserDialog`, and MCP OAuth (see below) | +| `elicitation/complete` | `system/elicitation_complete`, and an MCP OAuth sign-in that finished | | output updates | the shared SDK mapping (`toAcpNotifications`), translated by `V2UpdateTranslator` | The session keeps little of its own: a map of prompts that are not inserted yet, whether a turn -is open, and its count of open permission requests (`src/v2-native/session.ts`). +is open, and its count of open permission requests and elicitations (`src/v2-native/session.ts`). Claude Code's `session_state_changed` is not the foreground state. It stays `running` while background subagents work: across the turn's `result`, the next prompts' turns, and the turn @@ -73,7 +75,7 @@ Code's task events (with `forwardSubagentText` and `perTaskStopAffordance`): | incoming `session_message` in the child | the first user text of each run: the delegation | | `cwd` of the child | the `SubagentStart` hook's `cwd`, when it is not the parent's (such as a worktree) | | child `running` / `idle`, mirrored on the parent | `task_started` (or a `running` patch) to `task_notification` | -| child `requires_action` | a permission request whose `agentID` is the child | +| child `requires_action` | a permission request or question whose `agentID` is the child | | `session/cancel` of a child | `stopTask(agentId)` | | `background` of a session | its background subagents, commands (terminals), task tool calls and other tasks | | `terminal/cancel`, `tool_call/cancel` | `stopTask` of the listed item's task | @@ -94,6 +96,14 @@ close, resume, delete and config changes on it fail with -32602. `background` list of each session (see below). - `src/v2-native/permissions.ts`: `canUseTool` as v2 permission requests, on the session of the agent that asks. +- `src/v2-native/elicitation.ts`: elicitations, for the modes the client advertised. A form + for AskUserQuestion (on the session of the agent that asks), for an MCP server's form, and for + the refusal fallback's consent prompt. A URL for an MCP server's URL request and for MCP OAuth. + All but MCP OAuth are open requests of their session, like permission requests: a cancel or a + close withdraws them. MCP OAuth starts after the setup response, and a close withdraws it. + Without form elicitation, AskUserQuestion is disallowed and the refusal fallback is not + declared. An MCP server's request in a mode the client did not advertise, or that the client + answers with an error, is answered `cancel`. - `src/v2-native/config.ts`: the mode and model config options. - `src/v2-native/auth.ts`: `_auth/status_update`, gateway `auth/login`, `auth/logout`, and `providers/*`, on the v1 core's state rules (`AuthStatusReporter`, `providers.ts`). The @@ -120,6 +130,10 @@ These pieces were extracted so that both cores use the same code: - `sdkMcpServers` (`mcp-servers.ts`), taken from `ClaudeAcpAgent.createSession` - `AuthStatusReporter` and `cliLogout` (`auth-status-reporter.ts`): the `authStatus` state, the `claude auth status` probe and the `_auth/status_update` pushes, taken from `ClaudeAcpAgent` +- the elicitation answers (`elicitation.ts`): `answerAskUserQuestion`, `answerMcpElicitation`, + `answerUserDialog` and `elicitUnlessWithdrawn`, taken from `ClaudeAcpAgent`'s handlers +- MCP OAuth (`mcp-oauth.ts`): `startMcpServerAuthentication` and its status polling, taken from + `acp-agent.ts` - the provider routing (`providers.ts`): `ProviderConfig`, the gateway login methods and their validation, the `providers/list` response, `providers/set` validation and model listing, and `createEnvForProvider`, taken from `ClaudeAcpAgent` @@ -130,8 +144,7 @@ These pieces were extracted so that both cores use the same code: insertion, folding, lifecycle failures, cancel, permission states, config, local commands, a crashed query, and replay. - `src/tests/acp-scenarios-v2-native.test.ts` runs every scenario of the shared suite on the - native core. All 40 send only schema-valid v2. 39 pass. `ask-user-question` stops, because - the native core sends no form elicitations. `SAME_AS_BRIDGE` lists the scenarios whose session + native core. All 40 send only schema-valid v2, and all pass. `SAME_AS_BRIDGE` lists the scenarios whose session updates equal the bridge's golden file, which today is only `session-setup`. The differences are listed below. - The scenario double now sends `session_state_changed` when the query asks for it, as the real @@ -157,11 +170,6 @@ These pieces were extracted so that both cores use the same code: - A gateway login, `providers/set`, `providers/disable` or `auth/logout` routes only the sessions started after it. The bridge recreates the queries of live sessions between turns; on the native core they keep their route until they are closed and resumed. -- Elicitation (and so `requires_action` for a question): - - AskUserQuestion (the tool is disallowed) - - MCP elicitation (the SDK declines) - - the refusal-fallback dialog - - MCP OAuth - Subagents: - the child's configuration (`config_option_update` from the `SubagentStart` hook) - the incoming view of a SendMessage, which the child's transcript does not show @@ -230,4 +238,5 @@ Beyond the scenarios, `v2-native.test.ts` covers these requirements of the draft logins are advertised only with `auth.terminal`. `requires_action` comes from the session's own open requests, not Claude Code's: it covers -exactly this session's permission requests, and will cover its elicitations. +exactly this session's permission requests and elicitations. MCP OAuth is not one: it does not +block a turn. diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 292025e7..f7b66f3d 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -63,7 +63,6 @@ import { getSessionMessages, HookCallback, listSessions, - McpServerStatus, ModelInfo, ModelUsage, OnElicitation, @@ -186,17 +185,17 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import packageJson from "../package.json" with { type: "json" }; import { - applyAskElicitationResponse, - askUserQuestionsToCreateRequest, - createElicitationResponseToElicitResult, + answerAskUserQuestion, + answerMcpElicitation, + answerUserDialog, ElicitationSupport, - extractAskUserQuestions, - extractRefusalFallbackPrompt, - mcpElicitationToCreateRequest, + elicitUnlessWithdrawn, REFUSAL_FALLBACK_DIALOG_KIND, - refusalFallbackResultFromResponse, - refusalFallbackToCreateRequest, } from "./elicitation.js"; +import { + startMcpServerAuthentication as startSharedMcpServerAuthentication, + supportsMcpOAuth, +} from "./mcp-oauth.js"; import { forkSession } from "./fork-session.js"; import { clientSystemPrompt, @@ -575,34 +574,6 @@ async function structuredUsageMarkdown( } } -/** Claude Code keeps the OAuth callback listener open in the background after - * `mcpAuthenticate` returns the authorization URL. The SDK does not expose - * that listener's completion promise, so watch the server status while the - * ACP client has the URL elicitation open. */ -const MCP_OAUTH_STATUS_POLL_MS = 1_000; -const MCP_OAUTH_TIMEOUT_MS = 10 * 60_000; - -/** Runtime MCP OAuth control exposed by the pinned Agent SDK. It is not yet in - * the public `Query` declaration, even though the method is present on the - * SDK query object and backed by Claude Code's `mcp_authenticate` control. */ -type McpOAuthQuery = Query & { - mcpAuthenticate( - serverName: string, - redirectUri?: string, - ): Promise<{ - authUrl?: string; - requiresUserAction: boolean; - callbackExpected: boolean; - redirectScheme?: "localhost" | "custom"; - callbackPort?: number; - state?: string; - }>; -}; - -function supportsMcpOAuth(query: Query): query is McpOAuthQuery { - return typeof (query as Partial).mcpAuthenticate === "function"; -} - /** Runtime Remote Control control exposed by the pinned Agent SDK. Like * `mcpAuthenticate`, it is not in the public `Query` declaration yet. The CLI's * own `/remote-control` is terminal-bound and not offered to SDK hosts, so the @@ -663,22 +634,6 @@ function claudeCodeOffersCommand( ); } -/** Wait for a polling interval, resolving false when the session is aborted. */ -function waitUnlessAborted(milliseconds: number, signal: AbortSignal): Promise { - if (signal.aborted) return Promise.resolve(false); - return new Promise((resolve) => { - const timer = setTimeout(() => { - signal.removeEventListener("abort", onAbort); - resolve(true); - }, milliseconds); - const onAbort = () => { - clearTimeout(timer); - resolve(false); - }; - signal.addEventListener("abort", onAbort, { once: true }); - }); -} - /** Error surfaced when the SDK declares a turn over (`session_state_changed: * idle`, its authoritative turn-over signal) without ever emitting the turn's * `result` — a model stream that dropped mid-turn, or an async agent that @@ -2494,10 +2449,6 @@ type McpAuthenticationHost = { logger: Logger; }; -/** Background OAuth work is implementation state, not part of the public - * `Session` shape exposed through `ClaudeAcpAgent.sessions`. */ -const mcpAuthentications = new WeakMap>(); - /** Start OAuth for ACP-provided MCP servers that Claude reported as needing * authentication. This runs after session creation has returned so an * interactive browser flow never delays `session/new`. */ @@ -2511,147 +2462,30 @@ function startMcpAuthentication( } /** Start OAuth in the background for the servers in `requestedServers` that - * Claude reports as needing authentication. Startup and `/mcp` use it. It - * does nothing while another OAuth run of the session is in progress. */ + * Claude reports as needing authentication (see `mcp-oauth.ts`). Startup and + * `/mcp` use it. */ function startMcpServerAuthentication( host: McpAuthenticationHost, sessionId: string, requestedServers: Set, ): void { - if (!host.clientCapabilities?.elicitation?.url) return; - - const session = host.sessions[sessionId]; - if (!session || mcpAuthentications.has(session)) return; - - const authentication = authenticateMcpServers(host, sessionId, session.query, requestedServers) - .catch((error) => { - if (!session.abortController.signal.aborted) { - host.logger.error(`Failed to inspect MCP servers for OAuth: ${error}`); - } - }) - .finally(() => { - if (mcpAuthentications.get(session) === authentication) { - mcpAuthentications.delete(session); - } - }); - mcpAuthentications.set(session, authentication); -} - -async function authenticateMcpServers( - host: McpAuthenticationHost, - sessionId: string, - query: Query, - requestedServers: Set, -): Promise { - if (!supportsMcpOAuth(query)) { - host.logger.error("The Claude Agent SDK does not expose MCP OAuth authentication."); - return; - } - - const statuses = await query.mcpServerStatus(); - for (const status of statuses) { - if (status.status !== "needs-auth" || !requestedServers.has(status.name)) continue; - try { - await authenticateMcpServer(host, sessionId, query, status.name); - } catch (error) { - const session = host.sessions[sessionId]; - if (session && !session.abortController.signal.aborted) { - host.logger.error(`Failed to authenticate MCP server ${status.name}: ${error}`); - } - } - } -} - -/** Bridge Claude Code's startup MCP OAuth control to ACP URL elicitation. - * Claude opens and owns the localhost callback listener; the ACP client only - * needs to present the returned authorization URL. */ -async function authenticateMcpServer( - host: McpAuthenticationHost, - sessionId: string, - query: McpOAuthQuery, - serverName: string, -): Promise { const session = host.sessions[sessionId]; if (!session) return; - - const login = await query.mcpAuthenticate(serverName); - if (!login.requiresUserAction) return; - if (!login.authUrl) { - throw new Error("Claude Code requested user action without returning an authorization URL"); - } - - const elicitationId = `mcp-oauth-${randomUUID()}`; - const flowAbort = new AbortController(); - const abortFlow = () => flowAbort.abort(session.abortController.signal.reason); - session.abortController.signal.addEventListener("abort", abortFlow, { once: true }); - if (session.abortController.signal.aborted) abortFlow(); - - try { - const completed = waitForMcpAuthentication( - host.sessions, + startSharedMcpServerAuthentication( + { + urlElicitation: !!host.clientCapabilities?.elicitation?.url, + createElicitation: (params, signal) => host.client.createElicitation(params, signal), + completeElicitation: (params) => host.client.completeElicitation(params), + logger: host.logger, + }, + { + key: session, sessionId, - query, - serverName, - flowAbort.signal, - ); - const elicitation = host.client.createElicitation( - { - mode: "url", - sessionId, - message: `Authenticate with MCP server ${serverName}`, - url: login.authUrl, - elicitationId, - }, - flowAbort.signal, - ); - const first = await Promise.race([ - completed.then((authenticated) => ({ type: "completed" as const, authenticated })), - elicitation.then((response) => ({ type: "elicitation" as const, response })), - ]); - - // A url-mode accept carries no content to check. The guard - // `CreateElicitationResponse.isAccept` would also reject an accept whose - // `content` holds a v2-only value, such as an object. - if (first.type === "elicitation" && first.response.action !== "accept") { - return; - } - - if (first.type === "elicitation") { - await completed; - } - try { - await host.client.completeElicitation({ elicitationId }); - } catch (error) { - if (!flowAbort.signal.aborted) { - host.logger.error(`Failed to complete MCP OAuth elicitation: ${error}`); - } - } - } finally { - flowAbort.abort(); - session.abortController.signal.removeEventListener("abort", abortFlow); - } -} - -async function waitForMcpAuthentication( - sessions: Record, - sessionId: string, - query: Query, - serverName: string, - signal: AbortSignal, -): Promise { - const deadline = Date.now() + MCP_OAUTH_TIMEOUT_MS; - while (!signal.aborted && Date.now() < deadline) { - const session = sessions[sessionId]; - if (!session || session.query !== query) return false; - - const status: McpServerStatus | undefined = (await query.mcpServerStatus()).find( - (server) => server.name === serverName, - ); - if (!status || status.status === "failed" || status.status === "disabled") return false; - if (status.status === "connected") return true; - if (!(await waitUnlessAborted(MCP_OAUTH_STATUS_POLL_MS, signal))) return false; - } - return false; + currentQuery: () => host.sessions[sessionId]?.query, + closed: session.abortController.signal, + }, + requestedServers, + ); } export class ClaudeAcpAgent { @@ -10612,53 +10446,32 @@ export class ClaudeAcpAgent { /** * Handle elicitation requests that originate from MCP servers by forwarding - * them to the client over ACP. Only the user's own decline reaches the MCP - * server as `decline`. A request the user never saw, because the client did - * not advertise its mode, we can't represent it, or the client answered with - * an error, is a `cancel`. + * them to the client over ACP (see {@link answerMcpElicitation}). */ private handleMcpElicitation(sessionId: string, support: ElicitationSupport): OnElicitation { return async (request, { signal: callbackSignal }) => { const signal = this.untilSessionClosed(sessionId, callbackSignal); - const isUrl = request.mode === "url"; - if ((isUrl && !support.url) || (!isUrl && !support.form)) { - return { action: "cancel" }; - } - - const createRequest = mcpElicitationToCreateRequest(request, sessionId); - if (!createRequest) { - return { action: "cancel" }; - } - - try { - const response = await this.withPendingUserInput(sessionId, signal, () => - this.elicitFromClient(createRequest, signal), - ); - if (signal.aborted) { - return { action: "cancel" }; - } - return createElicitationResponseToElicitResult(response); - } catch (error) { - // A cancellation we requested (signal aborted) settles as a cancel, not - // a hard decline — the elicitation was abandoned, not refused. - if (signal.aborted) { - return { action: "cancel" }; - } - // The client answered with an error, e.g. -32602 for a form it cannot - // render, so the user never saw the request. MCP's `decline` means the - // user refused; `cancel` means they made no explicit choice. - this.logger.error(`Failed to forward MCP elicitation: ${error}`); - return { action: "cancel" }; - } + return answerMcpElicitation(request, { + sessionId, + support, + logger: this.logger, + elicit: (createRequest) => + elicitUnlessWithdrawn(signal, (signal) => + this.withPendingUserInput(sessionId, signal, () => + this.elicitFromClient(createRequest, signal), + ), + ), + }); }; } /** * Present the built-in AskUserQuestion tool's questions as an ACP form - * elicitation and return the answers as the tool's `updatedInput`. Called from - * `canUseTool` since that is where the SDK routes the tool's permission check. - * The elicitation goes to `requestSessionId`, a subagent's child session for - * a question it asks, and blocks the turn of `sessionId`. + * elicitation and return the answers as the tool's `updatedInput` (see + * {@link answerAskUserQuestion}). Called from `canUseTool` since that is + * where the SDK routes the tool's permission check. The elicitation goes to + * `requestSessionId`, a subagent's child session for a question it asks, + * and blocks the turn of `sessionId`. */ private async handleAskUserQuestion( sessionId: string, @@ -10669,89 +10482,43 @@ export class ClaudeAcpAgent { agentID: string | undefined, ): Promise { const signal = this.untilSessionClosed(sessionId, toolSignal); - const questions = extractAskUserQuestions(toolInput); - if (!questions) { - return { behavior: "deny", message: "AskUserQuestion called with no valid questions." }; - } - - const createRequest = askUserQuestionsToCreateRequest( - questions, - requestSessionId, + return answerAskUserQuestion(toolInput, { + sessionId: requestSessionId, toolUseID, - this.toolCallCapabilities.air.client, - ); - let response; - try { - response = await this.withChildAwaitingUser(sessionId, requestSessionId, signal, () => - this.withPendingUserInput( - sessionId, - signal, - () => this.elicitFromClient(createRequest, signal), - agentID, + airClient: this.toolCallCapabilities.air.client, + reportUnanswered: () => this.sessions[sessionId]?.askUserQuestionUnansweredV1 === true, + logger: this.logger, + elicit: (createRequest) => + elicitUnlessWithdrawn(signal, (signal) => + this.withChildAwaitingUser(sessionId, requestSessionId, signal, () => + this.withPendingUserInput( + sessionId, + signal, + () => this.elicitFromClient(createRequest, signal), + agentID, + ), + ), ), - ); - } catch (error) { - // A cancellation we requested (signal aborted) settles as an aborted tool - // use, matching the post-response check below. - if (signal.aborted) { - throw new Error("Tool use aborted", { cause: error }); - } - this.logger.error(`Failed to present AskUserQuestion elicitation: ${error}`); - return { behavior: "deny", message: "Could not present the question to the user." }; - } - if (signal.aborted) { - throw new Error("Tool use aborted"); - } - - const outcome = applyAskElicitationResponse(response, toolInput, questions, { - reportUnanswered: this.sessions[sessionId]?.askUserQuestionUnansweredV1 === true, }); - if (outcome.action === "cancel") { - throw new Error("Tool use aborted"); - } - return { behavior: "allow", updatedInput: outcome.updatedInput }; } /** * Handle `request_user_dialog` control requests — blocking dialogs the CLI - * asks the host to render. Only kinds declared in `supportedDialogKinds` - * are ever emitted; everything unexpected is answered `cancelled` (the - * required answer for unrecognized kinds), which applies the dialog's - * default behavior CLI-side. Today the only declared kind is the - * refusal-fallback consent prompt, rendered as an ACP form elicitation. + * asks the host to render (see {@link answerUserDialog}). */ private handleUserDialog(sessionId: string): OnUserDialog { return async (request, { signal: callbackSignal }) => { const signal = this.untilSessionClosed(sessionId, callbackSignal); - if (request.dialogKind !== REFUSAL_FALLBACK_DIALOG_KIND) { - return { behavior: "cancelled" }; - } - const prompt = extractRefusalFallbackPrompt(request.payload); - if (!prompt) { - this.logger.error( - `refusal_fallback_prompt payload had an unexpected shape; cancelling the dialog: ${JSON.stringify(request.payload)}`, - ); - return { behavior: "cancelled" }; - } - let response: CreateElicitationResponse; - try { - response = await this.withPendingUserInput(sessionId, signal, () => - this.elicitFromClient(refusalFallbackToCreateRequest(prompt, sessionId), signal), - ); - } catch (error) { - // A cancellation we requested (signal aborted) is expected teardown; - // anything else is a client failure. Either way the safe answer is - // `cancelled` — the CLI applies the dialog's default (keep the - // refusal) rather than switching models without consent. - if (!signal.aborted) { - this.logger.error(`Failed to present refusal fallback elicitation: ${error}`); - } - return { behavior: "cancelled" }; - } - if (signal.aborted) { - return { behavior: "cancelled" }; - } - return { behavior: "completed", result: refusalFallbackResultFromResponse(response) }; + return answerUserDialog(request, { + sessionId, + logger: this.logger, + elicit: (createRequest) => + elicitUnlessWithdrawn(signal, (signal) => + this.withPendingUserInput(sessionId, signal, () => + this.elicitFromClient(createRequest, signal), + ), + ), + }); }; } diff --git a/src/elicitation.ts b/src/elicitation.ts index a1fd4e0f..d626c327 100644 --- a/src/elicitation.ts +++ b/src/elicitation.ts @@ -6,7 +6,13 @@ import type { ElicitationSchema, EnumOption, } from "@agentclientprotocol/sdk/unstable"; -import type { ElicitationRequest, ElicitationResult } from "@anthropic-ai/claude-agent-sdk"; +import type { + ElicitationRequest, + ElicitationResult, + PermissionResult, + UserDialogRequest, + UserDialogResult, +} from "@anthropic-ai/claude-agent-sdk"; import type { AskUserQuestionInput, AskUserQuestionOutput, @@ -512,3 +518,156 @@ export function refusalFallbackResultFromResponse(response: CreateElicitationRes const choice = acceptedElicitationContent(response)[REFUSAL_FALLBACK_CHOICE_KEY]; return choice === RETRY_FALLBACK_RESULT ? RETRY_FALLBACK_RESULT : KEEP_REFUSAL_RESULT; } + +/** + * What became of an elicitation: the client's response, or `withdrawn` when + * its signal aborted first (Claude Code withdrew the request, or a cancel or + * close of the session did). A response that comes after the abort counts as + * withdrawn too. + */ +export type ElicitationOutcome = { response: CreateElicitationResponse } | { withdrawn: true }; + +/** + * Sends an elicitation with `send` and reads what became of it. A rejection + * while `signal` is not aborted, such as the client's -32602 for a form it + * cannot render, is rethrown. + */ +export async function elicitUnlessWithdrawn( + signal: AbortSignal, + send: (signal: AbortSignal) => Promise, +): Promise { + try { + const response = await send(signal); + return signal.aborted ? { withdrawn: true } : { response }; + } catch (error) { + if (signal.aborted) return { withdrawn: true }; + throw error; + } +} + +/** Sends a built elicitation to the client (see {@link elicitUnlessWithdrawn}). */ +export type Elicit = (request: CreateElicitationRequest) => Promise; + +type ElicitationLogger = { error: (...args: unknown[]) => void }; + +/** + * Answers an MCP server's elicitation (the SDK's `onElicitation`) through + * `elicit`. Only the user's own decline reaches the MCP server as `decline`. + * A request the user never saw, because the client did not advertise its + * mode, it cannot be represented, the client answered with an error, or it + * was withdrawn, is a `cancel`. + */ +export async function answerMcpElicitation( + request: ElicitationRequest, + { + sessionId, + support, + elicit, + logger, + }: { sessionId: string; support: ElicitationSupport; elicit: Elicit; logger: ElicitationLogger }, +): Promise { + const isUrl = request.mode === "url"; + if ((isUrl && !support.url) || (!isUrl && !support.form)) { + return { action: "cancel" }; + } + const createRequest = mcpElicitationToCreateRequest(request, sessionId); + if (!createRequest) { + return { action: "cancel" }; + } + try { + const outcome = await elicit(createRequest); + if ("withdrawn" in outcome) return { action: "cancel" }; + return createElicitationResponseToElicitResult(outcome.response); + } catch (error) { + // The client answered with an error, e.g. -32602 for a form it cannot + // render, so the user never saw the request. MCP's `decline` means the + // user refused; `cancel` means they made no explicit choice. + logger.error(`Failed to forward MCP elicitation: ${error}`); + return { action: "cancel" }; + } +} + +/** + * Presents the AskUserQuestion tool's questions as a form elicitation of + * `sessionId` through `elicit`, and returns the answers as the tool's + * `updatedInput`. A withdrawn question, or one the user cancelled, aborts the + * tool use. `reportUnanswered` is read once the answer is in (see + * {@link applyAskElicitationResponse}). + */ +export async function answerAskUserQuestion( + toolInput: Record, + { + sessionId, + toolUseID, + airClient = false, + reportUnanswered, + elicit, + logger, + }: { + sessionId: string; + toolUseID: string; + airClient?: boolean; + reportUnanswered: () => boolean; + elicit: Elicit; + logger: ElicitationLogger; + }, +): Promise { + const questions = extractAskUserQuestions(toolInput); + if (!questions) { + return { behavior: "deny", message: "AskUserQuestion called with no valid questions." }; + } + const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID, airClient); + let outcome: ElicitationOutcome; + try { + outcome = await elicit(createRequest); + } catch (error) { + logger.error(`Failed to present AskUserQuestion elicitation: ${error}`); + return { behavior: "deny", message: "Could not present the question to the user." }; + } + if ("withdrawn" in outcome) { + throw new Error("Tool use aborted"); + } + const answer = applyAskElicitationResponse(outcome.response, toolInput, questions, { + reportUnanswered: reportUnanswered(), + }); + if (answer.action === "cancel") { + throw new Error("Tool use aborted"); + } + return { behavior: "allow", updatedInput: answer.updatedInput }; +} + +/** + * Answers a `request_user_dialog` of Claude Code (the SDK's `onUserDialog`). + * Only kinds declared in `supportedDialogKinds` are ever emitted; everything + * unexpected is answered `cancelled` (the required answer for unrecognized + * kinds), which applies the dialog's default behavior CLI-side. The only + * declared kind is the refusal-fallback consent prompt, rendered as a form + * elicitation. A failed or withdrawn elicitation is `cancelled` too: the CLI + * then keeps the refusal rather than switching models without consent. + */ +export async function answerUserDialog( + request: UserDialogRequest, + { sessionId, elicit, logger }: { sessionId: string; elicit: Elicit; logger: ElicitationLogger }, +): Promise { + if (request.dialogKind !== REFUSAL_FALLBACK_DIALOG_KIND) { + return { behavior: "cancelled" }; + } + const prompt = extractRefusalFallbackPrompt(request.payload); + if (!prompt) { + logger.error( + `refusal_fallback_prompt payload had an unexpected shape; cancelling the dialog: ${JSON.stringify(request.payload)}`, + ); + return { behavior: "cancelled" }; + } + let outcome: ElicitationOutcome; + try { + outcome = await elicit(refusalFallbackToCreateRequest(prompt, sessionId)); + } catch (error) { + logger.error(`Failed to present refusal fallback elicitation: ${error}`); + return { behavior: "cancelled" }; + } + if ("withdrawn" in outcome) { + return { behavior: "cancelled" }; + } + return { behavior: "completed", result: refusalFallbackResultFromResponse(outcome.response) }; +} diff --git a/src/mcp-oauth.ts b/src/mcp-oauth.ts new file mode 100644 index 00000000..8082071d --- /dev/null +++ b/src/mcp-oauth.ts @@ -0,0 +1,214 @@ +/** + * OAuth for the MCP servers that Claude Code reports as `needs-auth`, as an + * ACP URL elicitation. Protocol-neutral: the v1 core and the native v2 core + * both start it once a session's setup response is sent. + */ +import { randomUUID } from "node:crypto"; +import type { + CompleteElicitationNotification, + CreateElicitationRequest, + CreateElicitationResponse, +} from "@agentclientprotocol/sdk/unstable"; +import type { McpServerStatus, Query } from "@anthropic-ai/claude-agent-sdk"; + +/** Claude Code keeps the OAuth callback listener open in the background after + * `mcpAuthenticate` returns the authorization URL. The SDK does not expose + * that listener's completion promise, so watch the server status while the + * ACP client has the URL elicitation open. */ +const MCP_OAUTH_STATUS_POLL_MS = 1_000; +const MCP_OAUTH_TIMEOUT_MS = 10 * 60_000; + +/** Runtime MCP OAuth control exposed by the pinned Agent SDK. It is not yet in + * the public `Query` declaration, even though the method is present on the + * SDK query object and backed by Claude Code's `mcp_authenticate` control. */ +type McpOAuthQuery = Query & { + mcpAuthenticate( + serverName: string, + redirectUri?: string, + ): Promise<{ + authUrl?: string; + requiresUserAction: boolean; + callbackExpected: boolean; + redirectScheme?: "localhost" | "custom"; + callbackPort?: number; + state?: string; + }>; +}; + +export function supportsMcpOAuth(query: Query): query is McpOAuthQuery { + return typeof (query as Partial).mcpAuthenticate === "function"; +} + +/** What an OAuth run needs of the client connection. */ +export type McpOAuthClient = { + /** Whether the client advertised URL elicitation. Without it, OAuth does not start. */ + readonly urlElicitation: boolean; + createElicitation( + params: CreateElicitationRequest, + signal: AbortSignal, + ): Promise; + completeElicitation(params: CompleteElicitationNotification): Promise; + readonly logger: { error: (...args: unknown[]) => void }; +}; + +/** What an OAuth run needs of the session. */ +export type McpOAuthSession = { + /** One OAuth run at a time per key: the session object. */ + readonly key: object; + readonly sessionId: string; + /** The session's query now, or `undefined` once the session is gone. */ + currentQuery(): Query | undefined; + /** Aborts when the session closes. */ + readonly closed: AbortSignal; +}; + +const mcpAuthentications = new WeakMap>(); + +/** Start OAuth in the background for the servers in `requestedServers` that + * Claude reports as needing authentication. It does nothing while another + * OAuth run of the session is in progress. */ +export function startMcpServerAuthentication( + client: McpOAuthClient, + session: McpOAuthSession, + requestedServers: Set, +): void { + if (!client.urlElicitation || requestedServers.size === 0) return; + const query = session.currentQuery(); + if (!query || mcpAuthentications.has(session.key)) return; + + const authentication = authenticateMcpServers(client, session, query, requestedServers) + .catch((error) => { + if (!session.closed.aborted) { + client.logger.error(`Failed to inspect MCP servers for OAuth: ${error}`); + } + }) + .finally(() => { + if (mcpAuthentications.get(session.key) === authentication) { + mcpAuthentications.delete(session.key); + } + }); + mcpAuthentications.set(session.key, authentication); +} + +async function authenticateMcpServers( + client: McpOAuthClient, + session: McpOAuthSession, + query: Query, + requestedServers: Set, +): Promise { + if (!supportsMcpOAuth(query)) { + client.logger.error("The Claude Agent SDK does not expose MCP OAuth authentication."); + return; + } + + const statuses = await query.mcpServerStatus(); + for (const status of statuses) { + if (status.status !== "needs-auth" || !requestedServers.has(status.name)) continue; + try { + await authenticateMcpServer(client, session, query, status.name); + } catch (error) { + if (session.currentQuery() && !session.closed.aborted) { + client.logger.error(`Failed to authenticate MCP server ${status.name}: ${error}`); + } + } + } +} + +/** Bridge Claude Code's startup MCP OAuth control to ACP URL elicitation. + * Claude opens and owns the localhost callback listener; the ACP client only + * needs to present the returned authorization URL. */ +async function authenticateMcpServer( + client: McpOAuthClient, + session: McpOAuthSession, + query: McpOAuthQuery, + serverName: string, +): Promise { + if (!session.currentQuery()) return; + + const login = await query.mcpAuthenticate(serverName); + if (!login.requiresUserAction) return; + if (!login.authUrl) { + throw new Error("Claude Code requested user action without returning an authorization URL"); + } + + const elicitationId = `mcp-oauth-${randomUUID()}`; + const flowAbort = new AbortController(); + const abortFlow = () => flowAbort.abort(session.closed.reason); + session.closed.addEventListener("abort", abortFlow, { once: true }); + if (session.closed.aborted) abortFlow(); + + try { + const completed = waitForMcpAuthentication(session, query, serverName, flowAbort.signal); + const elicitation = client.createElicitation( + { + mode: "url", + sessionId: session.sessionId, + message: `Authenticate with MCP server ${serverName}`, + url: login.authUrl, + elicitationId, + }, + flowAbort.signal, + ); + const first = await Promise.race([ + completed.then((authenticated) => ({ type: "completed" as const, authenticated })), + elicitation.then((response) => ({ type: "elicitation" as const, response })), + ]); + + // A url-mode accept carries no content to check. The guard + // `CreateElicitationResponse.isAccept` would also reject an accept whose + // `content` holds a v2-only value, such as an object. + if (first.type === "elicitation" && first.response.action !== "accept") { + return; + } + + if (first.type === "elicitation") { + await completed; + } + try { + await client.completeElicitation({ elicitationId }); + } catch (error) { + if (!flowAbort.signal.aborted) { + client.logger.error(`Failed to complete MCP OAuth elicitation: ${error}`); + } + } + } finally { + flowAbort.abort(); + session.closed.removeEventListener("abort", abortFlow); + } +} + +async function waitForMcpAuthentication( + session: McpOAuthSession, + query: Query, + serverName: string, + signal: AbortSignal, +): Promise { + const deadline = Date.now() + MCP_OAUTH_TIMEOUT_MS; + while (!signal.aborted && Date.now() < deadline) { + if (session.currentQuery() !== query) return false; + + const status: McpServerStatus | undefined = (await query.mcpServerStatus()).find( + (server) => server.name === serverName, + ); + if (!status || status.status === "failed" || status.status === "disabled") return false; + if (status.status === "connected") return true; + if (!(await waitUnlessAborted(MCP_OAUTH_STATUS_POLL_MS, signal))) return false; + } + return false; +} + +/** Wait for a polling interval, resolving false when the session is aborted. */ +export function waitUnlessAborted(milliseconds: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(false); + return new Promise((resolve) => { + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(true); + }, milliseconds); + const onAbort = () => { + clearTimeout(timer); + resolve(false); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/src/tests/acp-scenarios-v2-native.test.ts b/src/tests/acp-scenarios-v2-native.test.ts index bdba8e05..d375821c 100644 --- a/src/tests/acp-scenarios-v2-native.test.ts +++ b/src/tests/acp-scenarios-v2-native.test.ts @@ -59,12 +59,7 @@ const PASSES = "passes"; * The scenarios that stop on the native core, each with the reason, as the * native core reports it. */ -const NATIVE_STOPS: Record = { - // AskUserQuestion needs a form elicitation, which the native core does not - // send: it disallows the tool, and the scenario's script, which calls it - // anyway, then ends the stream. - "ask-user-question": "Internal error: Claude Code stopped. Resume the session to continue.", -}; +const NATIVE_STOPS: Record = {}; /** * The scenarios whose session updates equal the bridge's v2 golden file. diff --git a/src/tests/v2-native.test.ts b/src/tests/v2-native.test.ts index 309b17ef..d5d71dd2 100644 --- a/src/tests/v2-native.test.ts +++ b/src/tests/v2-native.test.ts @@ -46,6 +46,8 @@ const sdk = vi.hoisted(() => ({ { value: "default", displayName: "Default", description: "Recommended" }, { value: "opus", displayName: "Opus", description: "Most capable" }, ], + /** What `mcpServerStatus` reports. */ + mcpServers: [] as Array<{ name: string; status: string }>, })); vi.mock("@anthropic-ai/claude-agent-sdk", async () => { @@ -115,6 +117,13 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => { setModel: fake.setModel, stopTask: fake.stopTask, close: fake.close, + mcpServerStatus: async () => sdk.mcpServers, + // A server that needs OAuth: Claude Code returns its authorization URL. + mcpAuthenticate: async () => ({ + authUrl: "https://mcp.example.com/authorize", + requiresUserAction: true, + callbackExpected: true, + }), }); }, getSessionMessages: vi.fn(async () => []), @@ -144,6 +153,7 @@ beforeEach(async () => { cwd = await mkdtemp(path.join(tempDir, "project-")); vi.stubEnv("CLAUDE_CONFIG_DIR", path.join(tempDir, "claude")); sdk.processes = []; + sdk.mcpServers = []; }); afterEach(async () => { @@ -155,14 +165,23 @@ afterEach(async () => { /** A v2 client of the native core that records the session updates it receives. */ function nativeClient({ answerPermission = () => ({ outcome: { outcome: "cancelled" } }), + answerElicitation = () => ({ action: "cancel" }), }: { answerPermission?: ( request: v2.RequestPermissionRequest, ) => v2.RequestPermissionResponse | Promise; + /** `signal` aborts when the agent cancels the request. */ + answerElicitation?: ( + request: v2.CreateElicitationRequest, + signal: AbortSignal, + ) => v2.CreateElicitationResponse | Promise; } = {}) { const sessionUpdates: v2.SessionUpdate[] = []; const notifications: v2.UpdateSessionNotification[] = []; const permissionRequests: v2.RequestPermissionRequest[] = []; + const elicitations: v2.CreateElicitationRequest[] = []; + /** The ids of the `elicitation/complete` notifications. */ + const completedElicitations: string[] = []; const app = v2 .client({ name: "native-test-client" }) .onNotification(v2.methods.client.session.update, ({ params }) => { @@ -172,6 +191,13 @@ function nativeClient({ .onRequest(v2.methods.client.session.requestPermission, ({ params }) => { permissionRequests.push(params); return answerPermission(params); + }) + .onRequest(v2.methods.client.elicitation.create, ({ params, signal }) => { + elicitations.push(params); + return answerElicitation(params, signal); + }) + .onNotification(v2.methods.client.elicitation.complete, ({ params }) => { + completedElicitations.push(params.elicitationId); }); const toAgent = new TransformStream(); const toClient = new TransformStream(); @@ -194,6 +220,8 @@ function nativeClient({ sessionUpdates, notifications, permissionRequests, + elicitations, + completedElicitations, states, connect: (run: (agent: v2.ClientContext) => Promise) => app.connectWith({ readable: toClient.readable, writable: toAgent.writable }, run), @@ -1117,6 +1145,125 @@ describe("native ACP v2 core", () => { }); }); + describe("elicitations", () => { + it.each([ + ["no elicitation", {}, { ask: false, mcp: false, dialog: false }], + ["forms", { form: {} }, { ask: true, mcp: true, dialog: true }], + ["URLs only", { url: {} }, { ask: false, mcp: true, dialog: false }], + ])( + "asks Claude Code for the requests that a client with %s renders", + async (_, elicitation, expected) => { + const client = nativeClient(); + const { claude } = await client.connect((agent) => openSession(agent, { elicitation })); + + const { options } = claude; + expect(!(options.disallowedTools ?? []).includes("AskUserQuestion")).toBe(expected.ask); + expect(options.onElicitation !== undefined).toBe(expected.mcp); + expect(options.onUserDialog !== undefined).toBe(expected.dialog); + expect(options.supportedDialogKinds).toEqual( + expected.dialog ? ["refusal_fallback_prompt"] : undefined, + ); + }, + ); + + it.each([ + [ + "lists", + ["ask_user_question_unanswered_v1"], + { "Which package?": { unanswered: "skipped" } }, + ], + ["does not list", [], undefined], + ])( + "marks a skipped question as unanswered only when the turn's system/init %s the capability", + async (_, capabilities, annotations) => { + const client = nativeClient({ + answerElicitation: () => ({ action: "accept", content: {} }), + }); + const decision = await client.connect(async (agent) => { + const { claude } = await openSession(agent, { elicitation: { form: {} } }); + claude.send({ ...turnInit(), capabilities }); + await vi.waitFor(() => expect(client.states()).toEqual(["running"])); + return claude.options.canUseTool!( + "AskUserQuestion", + { + questions: [ + { + question: "Which package?", + header: "Package", + options: [{ label: "core" }, { label: "cli" }], + multiSelect: false, + }, + ], + }, + { + signal: new AbortController().signal, + toolUseID: "toolu_ask", + requestId: "r1", + suggestions: [], + }, + ); + }); + + expect(client.elicitations[0]).toMatchObject({ mode: "form", toolCallId: "toolu_ask" }); + expect(decision).toMatchObject({ behavior: "allow", updatedInput: { answers: {} } }); + expect( + (decision as { updatedInput?: { annotations?: unknown } }).updatedInput?.annotations, + ).toEqual(annotations); + }, + ); + + it("completes an MCP server's URL elicitation when Claude Code reports it complete", async () => { + const client = nativeClient(); + await client.connect(async (agent) => { + const { claude } = await openSession(agent, { elicitation: { url: {} } }); + claude.send({ + type: "system", + subtype: "elicitation_complete", + mcp_server_name: "linear", + elicitation_id: "e1", + uuid: randomUUID(), + session_id: "session", + }); + await vi.waitFor(() => expect(client.completedElicitations).toEqual(["e1"])); + }); + }); + + it("cancels an open MCP OAuth elicitation before it answers session/close", async () => { + // The server stays `needs-auth`: the user has not signed in yet. + sdk.mcpServers = [{ name: "linear", status: "needs-auth" }]; + let cancelled = false; + const client = nativeClient({ + answerElicitation: (_, signal) => + new Promise((resolve) => + signal.addEventListener("abort", () => { + cancelled = true; + resolve({ action: "cancel" }); + }), + ), + }); + const cancelledAtClose = await client.connect(async (agent) => { + await agent.request(v2.methods.agent.initialize, { + protocolVersion: v2.PROTOCOL_VERSION, + info: { name: "native-test-client", version: "1.0.0" }, + capabilities: { elicitation: { url: {} } }, + }); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { + cwd, + mcpServers: [{ type: "http", name: "linear", url: "https://mcp.example.com/mcp" }], + }); + await vi.waitFor(() => expect(client.elicitations).toHaveLength(1)); + await agent.request(v2.methods.agent.session.close, { sessionId }); + return cancelled; + }); + + expect(client.elicitations[0]).toMatchObject({ + mode: "url", + url: "https://mcp.example.com/authorize", + }); + expect(cancelledAtClose).toBe(true); + }); + }); + it("reads which v2 core to serve from CLAUDE_AGENT_ACP_V2_CORE", () => { expect(v2CoreFromEnv(undefined)).toBe("bridge"); expect(v2CoreFromEnv("")).toBe("bridge"); diff --git a/src/v2-native/agent.ts b/src/v2-native/agent.ts index 59aedc1f..cb002982 100644 --- a/src/v2-native/agent.ts +++ b/src/v2-native/agent.ts @@ -15,7 +15,12 @@ import { randomUUID } from "node:crypto"; import * as fs from "node:fs/promises"; import * as path from "node:path"; -import { RequestError } from "@agentclientprotocol/sdk/unstable"; +import { + RequestError, + type CompleteElicitationNotification, + type CreateElicitationRequest, + type CreateElicitationResponse, +} from "@agentclientprotocol/sdk/unstable"; import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; import { deleteSession, @@ -29,6 +34,7 @@ import packageJson from "../../package.json" with { type: "json" }; import type { Logger } from "../acp-agent.js"; import { claudeCliPath } from "../claude-cli.js"; import { clientSystemPrompt, sdkSystemPrompt } from "../client-system-prompt.js"; +import type { ElicitationSupport } from "../elicitation.js"; import { sdkMcpServers } from "../mcp-servers.js"; import { ALLOW_BYPASS, autoModeDisabled } from "../permissions/modes.js"; import { buildAvailableModes, resolveInitialPermissionMode } from "../session-mode.js"; @@ -107,6 +113,8 @@ class NativeV2Agent implements AgentHandle, SessionConnection { readonly sessions = new Map(); readonly translator = new V2UpdateTranslator(); readonly auth: NativeAuth; + /** The elicitation modes that the client advertised in `initialize`. */ + elicitation: ElicitationSupport = { form: false, url: false }; constructor( private readonly ctx: v2.AgentContext, @@ -134,6 +142,24 @@ class NativeV2Agent implements AgentHandle, SessionConnection { }); } + createElicitation( + request: CreateElicitationRequest, + signal: AbortSignal, + ): Promise { + // The v1 types accept a property schema with any tag; the v2 types accept + // one only in a received value. An MCP server's schema is relayed + // unchanged, as on the bridge, so this cast does not check it. + return this.ctx.request( + v2.methods.client.elicitation.create, + request as v2.CreateElicitationRequest, + { cancellationSignal: signal, stopWaitingOnCancel: true }, + ); + } + + completeElicitation(params: CompleteElicitationNotification): Promise { + return this.ctx.notify(v2.methods.client.elicitation.complete, params); + } + ended(sessionId: string): void { // A later `session/resume` starts the session again from its transcript. this.sessions.delete(sessionId); @@ -144,6 +170,10 @@ class NativeV2Agent implements AgentHandle, SessionConnection { // --- Requests -------------------------------------------------------------- initialize(request: v2.InitializeRequest): v2.InitializeResponse { + this.elicitation = { + form: request.capabilities?.elicitation?.form != null, + url: request.capabilities?.elicitation?.url != null, + }; const { authMethods, _meta } = this.auth.initialize(request.capabilities); return { protocolVersion: v2.PROTOCOL_VERSION, @@ -164,7 +194,9 @@ class NativeV2Agent implements AgentHandle, SessionConnection { async newSession(params: v2.NewSessionRequest): Promise { const sessionId = randomUUID(); const session = await this.startSession(sessionId, params, undefined); - return { sessionId, ...(await this.setupFields(session)) }; + const response = { sessionId, ...(await this.setupFields(session)) }; + afterSetupResponse(session, params); + return response; } /** @@ -206,7 +238,9 @@ class NativeV2Agent implements AgentHandle, SessionConnection { session.output.replay(await getSessionMessages(sessionId, { dir: session.cwd })); } for (const notification of session.resumeSnapshot()) this.send(notification); - return this.setupFields(session); + const response = await this.setupFields(session); + afterSetupResponse(session, params); + return response; } async listSessions(params: v2.ListSessionsRequest): Promise { @@ -394,12 +428,13 @@ class NativeV2Agent implements AgentHandle, SessionConnection { mcpServers: sdkMcpServers(request.mcpServers), permissionMode: mode, allowDangerouslySkipPermissions: allowBypass, - canUseTool: canUseTool(session), + canUseTool: session.elicitations.canUseTool(canUseTool(session)), + // MCP elicitations and the refusal fallback, for the modes the client + // renders. Without forms, AskUserQuestion is disallowed. + ...session.elicitations.queryOptions(), pathToClaudeCodeExecutable: await claudeCliPath(), // Echoes each prompt that Claude Code takes in (see `NativeSession`). extraArgs: { "replay-user-messages": "" }, - // AskUserQuestion needs a form elicitation, which this core does not send. - disallowedTools: ["AskUserQuestion"], tools: { type: "preset", preset: "claude_code" }, hooks: { SubagentStart: [ @@ -488,6 +523,24 @@ async function validatedSetup(params: v2.NewSessionRequest | v2.ResumeSessionReq return request; } +/** + * Starts OAuth for the setup request's MCP servers that need it, once the + * setup response is sent: v2 asks for no elicitation before it. The handler + * returns the response right after this call, and the SDK writes it before a + * timer runs. Nothing else orders the two. + */ +function afterSetupResponse( + session: NativeSession, + params: v2.NewSessionRequest | v2.ResumeSessionRequest, +): void { + // The setup already rejected servers of an unknown transport. + const names = (params.mcpServers ?? []).flatMap((server) => + typeof server.name === "string" ? [server.name] : [], + ); + if (names.length === 0) return; + setTimeout(() => session.elicitations.authenticateMcpServers(names), 0); +} + function sameDirectories(left: readonly string[], right: readonly string[]): boolean { const normalize = (directories: readonly string[]) => [...new Set(directories.map((directory) => path.resolve(directory)))].sort().join("\n"); diff --git a/src/v2-native/elicitation.ts b/src/v2-native/elicitation.ts new file mode 100644 index 00000000..2d5ae8fe --- /dev/null +++ b/src/v2-native/elicitation.ts @@ -0,0 +1,166 @@ +/** + * Claude Code's requests for user input that are not permission checks, as + * ACP v2 elicitations of one session: + * + * - AskUserQuestion: a form, on the session of the agent that asks (the root + * or a subagent's child session), with the question's `toolCallId`. + * - An MCP server's elicitation (`onElicitation`): a form or a URL. A mode + * that the client did not advertise is answered `cancel`. + * - The refusal fallback's consent prompt (`onUserDialog`): a form. + * - MCP OAuth: a URL, sent after the setup response (see `mcp-oauth.ts`). + * + * The first three are open requests of their session while they wait + * (`requires_action`), and a cancel or close withdraws them with + * `$/cancel_request`, as it does a permission request. Building the + * elicitations and reading the answers is shared with v1 (`elicitation.ts`). + */ +import type { + CompleteElicitationNotification, + CreateElicitationRequest, + CreateElicitationResponse, +} from "@agentclientprotocol/sdk/unstable"; +import type { CanUseTool, Options, Query } from "@anthropic-ai/claude-agent-sdk"; +import type { Logger } from "../acp-agent.js"; +import { + answerAskUserQuestion, + answerMcpElicitation, + answerUserDialog, + elicitUnlessWithdrawn, + REFUSAL_FALLBACK_DIALOG_KIND, + type Elicit, + type ElicitationSupport, +} from "../elicitation.js"; +import { startMcpServerAuthentication } from "../mcp-oauth.js"; +import type { RequestTarget } from "./tasks.js"; + +/** What the elicitations need of the connection and the session. */ +export interface ElicitationHost { + readonly sessionId: string; + readonly logger: Logger; + /** The modes that the client advertised in `initialize`. */ + readonly support: ElicitationSupport; + createElicitation( + request: CreateElicitationRequest, + signal: AbortSignal, + ): Promise; + completeElicitation(params: CompleteElicitationNotification): Promise; + /** The session of the agent `agentId` (see `NativeSession.target`). */ + target(agentId: string | undefined): Promise; + /** The session's query, once it started. */ + query(): Query | undefined; +} + +export class SessionElicitations { + /** Whether Claude Code takes `annotations[question].unanswered` (`ask_user_question_unanswered_v1`). */ + private reportUnanswered = false; + /** + * Aborted by `session/close`: stops the MCP OAuth runs. A query that ended + * on its own fails their next `mcpServerStatus`, which ends them too. + */ + private readonly closed = new AbortController(); + + constructor(private readonly host: ElicitationHost) {} + + /** + * The query options: the elicitation callbacks for the modes the client + * renders. Without form elicitation AskUserQuestion is disallowed, and the + * refusal fallback is not declared, so Claude Code ends the turn with the + * refusal instead. + */ + queryOptions(): Pick< + Options, + "onElicitation" | "onUserDialog" | "supportedDialogKinds" | "disallowedTools" + > { + const { support, sessionId, logger } = this.host; + return { + ...(support.form || support.url + ? { + onElicitation: async (request, { signal }) => + answerMcpElicitation(request, { + sessionId, + support, + logger, + elicit: this.elicit(await this.host.target(undefined), signal), + }), + } + : {}), + ...(support.form + ? { + onUserDialog: async (request, { signal }) => + answerUserDialog(request, { + sessionId, + logger, + elicit: this.elicit(await this.host.target(undefined), signal), + }), + supportedDialogKinds: [REFUSAL_FALLBACK_DIALOG_KIND], + } + : { disallowedTools: ["AskUserQuestion"] }), + }; + } + + /** `canUseTool` that asks AskUserQuestion's questions, and leaves the other tools to `permissions`. */ + canUseTool(permissions: CanUseTool): CanUseTool { + return async (toolName, toolInput, options) => { + if (toolName !== "AskUserQuestion" || !this.host.support.form) { + return permissions(toolName, toolInput, options); + } + const target = await this.host.target(options.agentID); + return answerAskUserQuestion(toolInput, { + sessionId: target.sessionId, + toolUseID: options.toolUseID, + reportUnanswered: () => this.reportUnanswered, + logger: this.host.logger, + elicit: this.elicit(target, options.signal), + }); + }; + } + + /** A `system/init` of the session: it lists Claude Code's capabilities. */ + init(capabilities: readonly string[] | undefined): void { + if (capabilities?.includes("ask_user_question_unanswered_v1")) this.reportUnanswered = true; + } + + /** Claude Code's `elicitation_complete`: a URL elicitation of an MCP server finished. */ + completed(elicitationId: string): void { + if (!this.host.support.url || this.closed.signal.aborted) return; + this.host.completeElicitation({ elicitationId }).catch((error) => { + this.host.logger.error(`Failed to complete elicitation: ${error}`); + }); + } + + /** + * Starts OAuth in the background for the servers of `serverNames` that + * Claude Code reports as `needs-auth`. Call it only once the setup + * response is sent: v2 asks for no elicitation before it. + */ + authenticateMcpServers(serverNames: readonly string[]): void { + startMcpServerAuthentication( + { + urlElicitation: this.host.support.url, + createElicitation: (request, signal) => this.host.createElicitation(request, signal), + completeElicitation: (params) => this.host.completeElicitation(params), + logger: this.host.logger, + }, + { + key: this, + sessionId: this.host.sessionId, + currentQuery: () => (this.closed.signal.aborted ? undefined : this.host.query()), + closed: this.closed.signal, + }, + new Set(serverNames), + ); + } + + /** The session closes: the OAuth runs stop, and withdraw their elicitations. */ + close(): void { + this.closed.abort(); + } + + /** Sends an elicitation as an open request of `target`. */ + private elicit(target: RequestTarget, signal: AbortSignal): Elicit { + return (request) => + target.openRequest(signal, (signal) => + elicitUnlessWithdrawn(signal, (signal) => this.host.createElicitation(request, signal)), + ); + } +} diff --git a/src/v2-native/session.ts b/src/v2-native/session.ts index 9e49f0cb..5dd35d75 100644 --- a/src/v2-native/session.ts +++ b/src/v2-native/session.ts @@ -17,8 +17,8 @@ * background subagents work, which v2 lists as background work instead * (`background`), so it only ends a turn that ended without a result. * - `requires_action` is this session's own: a turn is open, and a - * permission request of this session is open, or of a subagent that the - * turn waits on. + * permission request or elicitation of this session is open, or of a + * subagent that the turn waits on. * - Subagents are child sessions, and background work is listed on every * state (see {@link SessionTasks}). * - `session/cancel` is Claude Code's interrupt with `cancelQueued`, which @@ -33,6 +33,9 @@ import { randomUUID } from "node:crypto"; import { RequestError, type AvailableCommand, + type CompleteElicitationNotification, + type CreateElicitationRequest, + type CreateElicitationResponse, type PromptRequest, } from "@agentclientprotocol/sdk/unstable"; import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; @@ -44,11 +47,13 @@ import type { SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; import { isLoginRequiredText, promptToClaude, type Logger } from "../acp-agent.js"; +import type { ElicitationSupport } from "../elicitation.js"; import { Pushable } from "../utils.js"; import { v1PromptRequest } from "../v2/prompt.js"; import { V2Background } from "../v2/background.js"; import type { V2UpdateTranslator } from "../v2/update-translator.js"; import type { SessionConfig } from "./config.js"; +import { SessionElicitations } from "./elicitation.js"; import { SessionOutput } from "./stream.js"; import { SessionTasks, type RequestTarget } from "./tasks.js"; @@ -62,6 +67,13 @@ export interface SessionConnection { request: v2.RequestPermissionRequest, signal: AbortSignal, ): Promise; + /** The elicitation modes that the client advertised. */ + readonly elicitation: ElicitationSupport; + createElicitation( + request: CreateElicitationRequest, + signal: AbortSignal, + ): Promise; + completeElicitation(params: CompleteElicitationNotification): Promise; /** The session's query ended on its own: the session is no longer active. */ ended(sessionId: string): void; } @@ -92,7 +104,7 @@ export class NativeSession implements RequestTarget { /** The state the client was told last. */ private reportedState: ForegroundState = "idle"; private openRequests = 0; - /** The open permission requests, aborted by `session/cancel`. */ + /** The open permission requests and elicitations, aborted by `session/cancel`. */ private readonly requestAborts = new Set(); /** Whether `session/cancel` stops the open turn: its idle reports `cancelled`. */ private cancelledWork = false; @@ -117,6 +129,7 @@ export class NativeSession implements RequestTarget { /** The `background` lists of the session and its children, stamped on each state. */ private readonly background: V2Background; readonly tasks: SessionTasks; + readonly elicitations: SessionElicitations; config!: SessionConfig; constructor( @@ -152,6 +165,15 @@ export class NativeSession implements RequestTarget { this.output, ); this.tasks.hostRequest = (request, signal) => connection.requestPermission(request, signal); + this.elicitations = new SessionElicitations({ + sessionId, + logger: connection.logger, + support: connection.elicitation, + createElicitation: (request, signal) => connection.createElicitation(request, signal), + completeElicitation: (params) => connection.completeElicitation(params), + target: (agentId) => this.target(agentId), + query: () => this.query, + }); // The session reports its background work: every state carries a list. this.background.changed({ sessionId, items: [], state: undefined }); } @@ -286,8 +308,9 @@ export class NativeSession implements RequestTarget { this.closing ??= (async () => { await this.cancel(); // v2 asks to withdraw every request of the session before the close - // response, also its background subagents'. + // response, also its background subagents' and the MCP OAuth runs'. this.tasks.withdrawAll(); + this.elicitations.close(); await this.settled(CLOSE_TIMEOUT_MS); // Claude Code did not end the work in time: it ends with the query. if (this.foreground) { @@ -317,22 +340,34 @@ export class NativeSession implements RequestTarget { return this.tasks.target(agentId, this); } - async requestPermission( + requestPermission( request: v2.RequestPermissionRequest, toolSignal: AbortSignal, ): Promise { + return this.openRequest(toolSignal, async (signal) => { + try { + // The subject is an upsert of the tool call: the client may see the + // request before the tool use streams. + return await this.connection.requestPermission(request, signal); + } catch (error) { + if (signal.aborted) return { outcome: { outcome: "cancelled" } }; + throw error; + } + }); + } + + /** Runs `send` as an open request of this session (see {@link RequestTarget.openRequest}). */ + async openRequest( + toolSignal: AbortSignal, + send: (signal: AbortSignal) => Promise, + ): Promise { const abort = new AbortController(); const signal = AbortSignal.any([toolSignal, abort.signal]); this.requestAborts.add(abort); this.openRequests++; this.reportState(); try { - // The subject is an upsert of the tool call: the client may see the - // request before the tool use streams. - return await this.connection.requestPermission(request, signal); - } catch (error) { - if (signal.aborted) return { outcome: { outcome: "cancelled" } }; - throw error; + return await send(signal); } finally { this.requestAborts.delete(abort); this.openRequests--; @@ -402,9 +437,13 @@ export class NativeSession implements RequestTarget { case "system": switch (message.subtype) { case "init": + this.elicitations.init(message.capabilities); // Each turn starts with one, also a turn that no prompt started. this.openTurn(undefined); return; + case "elicitation_complete": + this.elicitations.completed(message.elicitation_id); + return; case "session_state_changed": // The turn's `result` ends it. An idle without one ends it too, // such as a turn that an interrupt stopped before it ran. diff --git a/src/v2-native/tasks.ts b/src/v2-native/tasks.ts index 00d93531..7fff9017 100644 --- a/src/v2-native/tasks.ts +++ b/src/v2-native/tasks.ts @@ -63,7 +63,7 @@ export interface TaskHost { stopTask(taskId: string): Promise; } -/** Where a permission request goes: the root session or a child. */ +/** Where a permission request or elicitation goes: the root session or a child. */ export interface RequestTarget { readonly sessionId: string; readonly cwd: string; @@ -73,6 +73,12 @@ export interface RequestTarget { request: v2.RequestPermissionRequest, signal: AbortSignal, ): Promise; + /** + * Runs `send` as an open request of the target (`requires_action`) until it + * settles. Its signal aborts with `signal`, or when a cancel or close + * withdraws the target's requests. + */ + openRequest(signal: AbortSignal, send: (signal: AbortSignal) => Promise): Promise; } type ChildState = "idle" | "running" | "requires_action"; @@ -304,7 +310,16 @@ export class SessionTasks { sessionId: child.sessionId, cwd: child.cwd ?? this.host.cwd, cancelling: () => child.cancelled, - requestPermission: (request, signal) => this.childRequest(child, request, signal), + requestPermission: (request, toolSignal) => + this.childRequest(child, toolSignal, async (signal) => { + try { + return await this.hostRequest(request, signal); + } catch (error) { + if (signal.aborted) return { outcome: { outcome: "cancelled" } }; + throw error; + } + }), + openRequest: (signal, send) => this.childRequest(child, signal, send), }; } @@ -706,11 +721,12 @@ export class SessionTasks { }); } - private async childRequest( + /** Runs `send` as an open request of `child` (see {@link RequestTarget.openRequest}). */ + private async childRequest( child: Child, - request: v2.RequestPermissionRequest, toolSignal: AbortSignal, - ): Promise { + send: (signal: AbortSignal) => Promise, + ): Promise { const abort = new AbortController(); const signal = AbortSignal.any([toolSignal, abort.signal]); child.requestAborts.add(abort); @@ -718,10 +734,7 @@ export class SessionTasks { this.reportChild(child); this.host.foregroundRequestsChanged(); try { - return await this.hostRequest(request, signal); - } catch (error) { - if (signal.aborted) return { outcome: { outcome: "cancelled" } }; - throw error; + return await send(signal); } finally { child.requestAborts.delete(abort); child.openRequests--;