diff --git a/docs/v2-native-core.md b/docs/v2-native-core.md index 78f1359b..6b31b16b 100644 --- a/docs/v2-native-core.md +++ b/docs/v2-native-core.md @@ -31,11 +31,13 @@ both, so a v2 core can follow Claude Code's own events instead of rebuilding a t | `session/prompt` fails | `session/cancel` or `cancelled` (-32800), `discarded` or `refused` (-32603) | | `state_update` `running` | a turn starts: a prompt's `started` or echo, or the main conversation's output | | `state_update` `idle` | the turn's `result`, with its stop reason, usage, `_meta.quota` 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 })`, also while idle, as on the bridge | | `session/resume` with `replayFrom` | `getSessionMessages`, mapped like live output | | `session/fork` | `forkSession` (`fork-session.ts`), then a resume of the copy | | `availableCommands` | `supportedCommands`, filtered by `getAvailableSlashCommands`, and the adapter's `/mcp` | +| `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 reports that are not Claude's output come from `src/v2-native/reports.ts`, built as on v1: @@ -55,7 +57,7 @@ The reports that are not Claude's output come from `src/v2-native/reports.ts`, b | `running`, then `idle`, no user_message | the adapter's idle `/compact` (`IdleCompaction`) | 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 @@ -103,7 +105,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 | @@ -124,6 +126,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/setup.ts`: what session setup adds on the bridge's rules: the client's `_meta.claudeCode.options`, `CLAUDE_MODEL_CONFIG`, the models (the `availableModels` @@ -164,6 +174,10 @@ These pieces were extracted so that both cores use the same code: - `getAvailableSlashCommands`, exported from `acp-agent.ts` - `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` @@ -180,10 +194,9 @@ 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 - updates equal the bridge's golden file: 25 of them. The others differ in what is listed under - "Not served yet" (an Edit's patch, subagent details, the resume snapshot). + 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. The others differ in what is + listed under "Not served yet" (subagent details, the resume snapshot). - The scenario double now sends `session_state_changed` when the query asks for it, as the real CLI does. The bridge and v1 scenario golden files did not change. - A manual run against the bundled Claude Code CLI checked the following: @@ -211,11 +224,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 @@ -279,4 +287,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 82e92ece..f176b7cb 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -63,7 +63,6 @@ import { getSessionMessages, HookCallback, listSessions, - McpServerStatus, ModelInfo, OnElicitation, OnUserDialog, @@ -184,17 +183,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, @@ -564,34 +563,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 @@ -652,22 +623,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 @@ -2483,10 +2438,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`. */ @@ -2500,147 +2451,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 { @@ -10562,53 +10396,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, @@ -10619,89 +10432,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 3f05a5bb..f54337ba 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. @@ -93,6 +88,7 @@ const SAME_AS_BRIDGE: string[] = [ "goal", "exit-plan-approve", "exit-plan-reject", + "ask-user-question", "skill", "mcp-tool", "task-output-and-stop", diff --git a/src/tests/v2-native.test.ts b/src/tests/v2-native.test.ts index 6e8d5661..5c76b2c9 100644 --- a/src/tests/v2-native.test.ts +++ b/src/tests/v2-native.test.ts @@ -47,6 +47,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 () => { @@ -111,12 +113,18 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => { supportedCommands: async () => [ { name: "review", description: "Review a change", argumentHint: "" }, ], - mcpServerStatus: async () => [{ name: "linear", status: "connected" }], interrupt: fake.interrupt, setPermissionMode: fake.setPermissionMode, 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 () => []), @@ -147,6 +155,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 () => { @@ -158,14 +167,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 }) => { @@ -175,6 +193,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(); @@ -197,6 +222,8 @@ function nativeClient({ sessionUpdates, notifications, permissionRequests, + elicitations, + completedElicitations, states, connect: (run: (agent: v2.ClientContext) => Promise) => app.connectWith({ readable: toClient.readable, writable: toAgent.writable }, run), @@ -700,6 +727,7 @@ describe("native ACP v2 core", () => { it("replaces Claude Code's text of /mcp with the server list, before the turn's idle", async () => { const client = nativeClient(); + sdk.mcpServers = [{ name: "linear", status: "connected" }]; await client.connect(async (agent) => { const { response, claude } = await openSession(agent); const answer = agent.request(v2.methods.agent.session.prompt, { @@ -1411,6 +1439,126 @@ 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({ type: "system", subtype: "init", uuid: randomUUID(), capabilities }); + claude.send(cycleStart()); + 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 86d9462e..abbc0bd0 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, @@ -30,6 +35,7 @@ import packageJson from "../../package.json" with { type: "json" }; import { messageIdForGrouping, type Logger } from "../acp-agent.js"; import { claudeCliPath } from "../claude-cli.js"; import { clientSystemPrompt, sessionSystemPrompt } from "../client-system-prompt.js"; +import type { ElicitationSupport } from "../elicitation.js"; import { forkSession } from "../fork-session.js"; import { sdkMcpServers } from "../mcp-servers.js"; import { ALLOW_BYPASS, autoModeDisabled } from "../permissions/modes.js"; @@ -118,6 +124,8 @@ class NativeV2Agent implements AgentHandle, SessionConnection { readonly auth: NativeAuth; /** What the client declared in `initialize`. */ private clientCapabilities: v2.ClientCapabilities | null | undefined; + /** The elicitation modes that the client advertised in `initialize`. */ + elicitation: ElicitationSupport = { form: false, url: false }; constructor( private readonly ctx: v2.AgentContext, @@ -145,6 +153,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); @@ -160,6 +186,10 @@ class NativeV2Agent implements AgentHandle, SessionConnection { */ initialize(request: v2.InitializeRequest): v2.InitializeResponse { this.clientCapabilities = request.capabilities; + 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, @@ -185,7 +215,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; } /** @@ -256,7 +288,9 @@ class NativeV2Agent implements AgentHandle, SessionConnection { // The current title, after the replay and before the state, as on the bridge. await session.reports.publishTitle(); 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 { @@ -465,6 +499,7 @@ class NativeV2Agent implements AgentHandle, SessionConnection { this, input, ); + const elicitationOptions = session.elicitations.queryOptions(); const options: Options = { systemPrompt: sessionSystemPrompt(clientPrompt, params._meta?.systemPrompt), settingSources: ["user", "project", "local"], @@ -496,12 +531,17 @@ class NativeV2Agent implements AgentHandle, SessionConnection { 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 (below). + ...elicitationOptions, pathToClaudeCodeExecutable: await claudeCliPath(), // Echoes each prompt that Claude Code takes in (see `NativeSession`). extraArgs: { ...client?.extraArgs, "replay-user-messages": "" }, - // AskUserQuestion needs a form elicitation, which this core does not send. - disallowedTools: [...(client?.disallowedTools ?? []), "AskUserQuestion"], + disallowedTools: [ + ...(client?.disallowedTools ?? []), + ...(elicitationOptions.disallowedTools ?? []), + ], tools: client?.tools ?? (params._meta?.disableBuiltInTools === true @@ -628,6 +668,24 @@ async function validatedSetup( 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 96a940f0..034bd5e8 100644 --- a/src/v2-native/session.ts +++ b/src/v2-native/session.ts @@ -18,8 +18,8 @@ * which v2 lists as background work instead (`background`), so its `idle` * 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` answers the prompts that are not inserted yet with @@ -32,7 +32,13 @@ * {@link SessionOutput}). */ import { randomUUID } from "node:crypto"; -import { RequestError, type PromptRequest } from "@agentclientprotocol/sdk/unstable"; +import { + RequestError, + type CompleteElicitationNotification, + type CreateElicitationRequest, + type CreateElicitationResponse, + type PromptRequest, +} from "@agentclientprotocol/sdk/unstable"; import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; import type { PermissionMode, @@ -43,6 +49,7 @@ import type { } from "@anthropic-ai/claude-agent-sdk"; import { promptToClaude, type Logger } from "../acp-agent.js"; import type { McpCommand } from "../mcp-command.js"; +import type { ElicitationSupport } from "../elicitation.js"; import { Pushable } from "../utils.js"; import { v1PromptRequest } from "../v2/prompt.js"; import { V2Background } from "../v2/background.js"; @@ -53,6 +60,7 @@ import type { SessionConfig } from "./config.js"; import { idleOutcome, isPlaceholderResult, ResultUsage, type TurnEnd } from "./outcome.js"; import { StoppedWork } from "./stopped-work.js"; import { SessionReports } from "./reports.js"; +import { SessionElicitations } from "./elicitation.js"; import { SessionOutput } from "./stream.js"; import { SessionTasks, type RequestTarget } from "./tasks.js"; @@ -66,6 +74,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; } @@ -102,7 +117,7 @@ export class NativeSession implements RequestTarget { private turnCommand: string | undefined; /** The state the client was told last. */ private reportedState: ForegroundState = "idle"; - /** The open permission requests of the session. */ + /** The open permission requests and elicitations of the session. */ private readonly requests = new Set(); /** Whether `session/cancel` stops the open turn: its idle reports `cancelled`. */ private cancelledWork = false; @@ -136,6 +151,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; commands!: SessionCommands; /** The `/mcp` prompts not inserted yet, by uuid, and the open turn's (see `SessionCommands`). */ @@ -201,6 +217,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 }); } @@ -398,8 +423,9 @@ export class NativeSession implements RequestTarget { this.closing ??= (async () => { if (!this.closed) this.stopForeground(); // 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(); for (const request of this.requests) { request.coveredByCancel = true; request.abort.abort(); @@ -437,21 +463,33 @@ 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 open: OpenRequest = { abort: new AbortController(), coveredByCancel: false }; const signal = AbortSignal.any([toolSignal, open.abort.signal]); this.requests.add(open); 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.requests.delete(open); // A request withdrawn after a cancel (Claude Code's interrupt aborts @@ -539,6 +577,7 @@ export class NativeSession implements RequestTarget { switch (message.subtype) { case "init": if (message.capabilities?.includes("msg_lifecycle_v1")) this.lifecycleFrames = true; + this.elicitations.init(message.capabilities); this.reports.handle(message); // Its terminal-bound commands leave the list. if (this.commands.init(message)) this.resendCommands(); @@ -559,6 +598,9 @@ export class NativeSession implements RequestTarget { case "commands_changed": this.send(this.commands.changed(message.commands)); return; + case "elicitation_complete": + this.elicitations.completed(message.elicitation_id); + return; case "session_state_changed": if (message.state === "idle") { this.claudeCodeIdle(); diff --git a/src/v2-native/tasks.ts b/src/v2-native/tasks.ts index 530b85d4..e5969ed2 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; @@ -75,6 +75,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"; @@ -307,7 +313,16 @@ export class SessionTasks { cwd: child.cwd ?? this.host.cwd, output: child.output, 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), }; } @@ -709,11 +724,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); @@ -721,10 +737,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--;