Merge branch 'native-v2/elicitation' into v2-core-exploration

# Conflicts:
#	docs/v2-native-core.md
#	src/v2-native/agent.ts
#	src/v2-native/session.ts
This commit is contained in:
Ben Brandt
2026-11-09 11:58:07 +01:00
10 changed files with 920 additions and 348 deletions

View File

@@ -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.

View File

@@ -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<McpOAuthQuery>).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<boolean> {
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<Session, Promise<void>>();
/** 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<string>,
): 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<string>,
): Promise<void> {
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<void> {
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<string, Session>,
sessionId: string,
query: Query,
serverName: string,
signal: AbortSignal,
): Promise<boolean> {
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<PermissionResult> {
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),
),
),
});
};
}

View File

@@ -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<CreateElicitationResponse>,
): Promise<ElicitationOutcome> {
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<ElicitationOutcome>;
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<ElicitationResult> {
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<string, unknown>,
{
sessionId,
toolUseID,
airClient = false,
reportUnanswered,
elicit,
logger,
}: {
sessionId: string;
toolUseID: string;
airClient?: boolean;
reportUnanswered: () => boolean;
elicit: Elicit;
logger: ElicitationLogger;
},
): Promise<PermissionResult> {
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<UserDialogResult> {
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) };
}

214
src/mcp-oauth.ts Normal file
View File

@@ -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<McpOAuthQuery>).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<CreateElicitationResponse>;
completeElicitation(params: CompleteElicitationNotification): Promise<void>;
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<object, Promise<void>>();
/** 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<string>,
): 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<string>,
): Promise<void> {
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<void> {
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<boolean> {
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<boolean> {
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 });
});
}

View File

@@ -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<string, string> = {
// 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<string, string> = {};
/**
* 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",

View File

@@ -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: "<pull request>" },
],
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<v2.RequestPermissionResponse>;
/** `signal` aborts when the agent cancels the request. */
answerElicitation?: (
request: v2.CreateElicitationRequest,
signal: AbortSignal,
) => v2.CreateElicitationResponse | Promise<v2.CreateElicitationResponse>;
} = {}) {
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<v2.AnyMessage, v2.AnyMessage>();
const toClient = new TransformStream<v2.AnyMessage, v2.AnyMessage>();
@@ -197,6 +222,8 @@ function nativeClient({
sessionUpdates,
notifications,
permissionRequests,
elicitations,
completedElicitations,
states,
connect: <T>(run: (agent: v2.ClientContext) => Promise<T>) =>
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");

View File

@@ -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<CreateElicitationResponse> {
// 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<void> {
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<v2.NewSessionResponse> {
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<v2.ListSessionsResponse> {
@@ -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");

View File

@@ -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<CreateElicitationResponse>;
completeElicitation(params: CompleteElicitationNotification): Promise<void>;
/** The session of the agent `agentId` (see `NativeSession.target`). */
target(agentId: string | undefined): Promise<RequestTarget>;
/** 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)),
);
}
}

View File

@@ -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<v2.RequestPermissionResponse>;
/** The elicitation modes that the client advertised. */
readonly elicitation: ElicitationSupport;
createElicitation(
request: CreateElicitationRequest,
signal: AbortSignal,
): Promise<CreateElicitationResponse>;
completeElicitation(params: CompleteElicitationNotification): Promise<void>;
/** 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<OpenRequest>();
/** 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<v2.RequestPermissionResponse> {
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<T>(
toolSignal: AbortSignal,
send: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
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();

View File

@@ -63,7 +63,7 @@ export interface TaskHost {
stopTask(taskId: string): Promise<void>;
}
/** 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<v2.RequestPermissionResponse>;
/**
* 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<T>(signal: AbortSignal, send: (signal: AbortSignal) => Promise<T>): Promise<T>;
}
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<T>(
child: Child,
request: v2.RequestPermissionRequest,
toolSignal: AbortSignal,
): Promise<v2.RequestPermissionResponse> {
send: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
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--;