From 519cc621306ae71a86e2dfd740f72ccb105f0245 Mon Sep 17 00:00:00 2001
From: Steve Lam
Date: Sun, 8 Nov 2026 23:10:55 -0700
Subject: [PATCH] feat(provider): support MCP URL elicitation for Claude and
Codex
---
.../features/threads/PendingApprovalCard.tsx | 42 +++++++++++--
apps/mobile/src/lib/openExternalUrl.ts | 1 +
.../Adapters/ClaudeAdapterV2.test.ts | 36 +++++++++--
.../Adapters/ClaudeAdapterV2.ts | 19 +++++-
.../Adapters/CodexAdapterV2.test.ts | 62 +++++++++++++++++++
.../Adapters/CodexAdapterV2.ts | 3 +
.../src/provider/CodexMcpElicitation.test.ts | 28 ++++++++-
.../src/provider/CodexMcpElicitation.ts | 27 +++++++-
.../components/chat/ChildThreadApprovals.tsx | 9 +++
.../components/chat/McpElicitationLink.tsx | 37 +++++++++++
.../chat/TimelinePendingApprovalRow.tsx | 9 +++
docs/user/permission-modes.md | 9 +++
packages/client-runtime/src/secretRequest.ts | 6 +-
.../src/state/threadRequests.ts | 6 ++
packages/contracts/src/httpPageUrl.test.ts | 16 +++++
packages/contracts/src/httpPageUrl.ts | 9 +++
packages/contracts/src/index.ts | 1 +
packages/contracts/src/orchestrationV2.ts | 2 +
packages/contracts/src/orchestratorMcp.ts | 4 +-
packages/contracts/src/secretRequest.test.ts | 14 -----
packages/contracts/src/secretRequest.ts | 11 ----
21 files changed, 306 insertions(+), 45 deletions(-)
create mode 100644 apps/web/src/components/chat/McpElicitationLink.tsx
create mode 100644 packages/contracts/src/httpPageUrl.test.ts
create mode 100644 packages/contracts/src/httpPageUrl.ts
delete mode 100644 packages/contracts/src/secretRequest.test.ts
diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx
index f9ec44d5d4ee5..1c2092573c519 100644
--- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx
+++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx
@@ -1,17 +1,20 @@
import { Button } from "../../components/ui/Button";
-import type {
- ProviderApprovalDecision,
- ProviderApprovalOption,
- RuntimeRequestId,
+import {
+ httpPageUrl,
+ type ProviderApprovalDecision,
+ type ProviderApprovalOption,
+ type RuntimeRequestId,
} from "@t3tools/contracts";
import {
FULL_ACCESS_OVERRIDE_QUESTION,
fullAccessOverrideExplanation,
+ MCP_URL_ELICITATION_HINT,
} from "@t3tools/client-runtime/state/thread-requests";
import { useState } from "react";
import { Pressable, View } from "react-native";
import { AppText as Text } from "../../components/ui/AppText";
+import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
import type { PendingApproval } from "../../lib/threadActivity";
export interface PendingApprovalCardProps {
@@ -37,6 +40,10 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {
const canRespond = props.approval.responseCapability === "live";
const disabled = !canRespond || props.respondingApprovalId === props.approval.requestId;
const [explanationOpen, setExplanationOpen] = useState(false);
+ const [linkError, setLinkError] = useState(false);
+ const elicitationUrl = props.approval.elicitationUrl
+ ? httpPageUrl(props.approval.elicitationUrl)
+ : null;
return (
@@ -50,6 +57,33 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {
{props.approval.detail}
) : null}
+ {elicitationUrl ? (
+
+
+ {MCP_URL_ELICITATION_HINT}
+
+
+ {elicitationUrl.href}
+
+
+ ) : null}
{props.approval.reason ? (
{props.approval.reason}
diff --git a/apps/mobile/src/lib/openExternalUrl.ts b/apps/mobile/src/lib/openExternalUrl.ts
index ed8946f53d82e..f064cbcb11bcb 100644
--- a/apps/mobile/src/lib/openExternalUrl.ts
+++ b/apps/mobile/src/lib/openExternalUrl.ts
@@ -9,6 +9,7 @@ const ExternalUrlTarget = Schema.Literals([
"html-render",
"mcp-app",
"secret-request",
+ "mcp-elicitation",
]);
export type ExternalUrlTarget = typeof ExternalUrlTarget.Type;
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
index 4877eed64f146..33e806a63e727 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
@@ -1331,9 +1331,9 @@ describe("ClaudeAdapterV2 MCP elicitation acceptance", () => {
);
});
- it("has no answer for URL mode or a form asking for a value", () => {
+ it("has no answer for unsafe URLs or a form asking for a value", () => {
for (const request of [
- { serverName: "deploys", message: "Sign in", mode: "url", url: "https://example.com" },
+ { serverName: "deploys", message: "Sign in", mode: "url", url: "javascript:alert(1)" },
form({ reason: { type: "string" } }, ["reason"]),
form({ grant: { type: "string", enum: ["always", "session"] } }, ["grant"]),
form({ grant: { type: "string", enum: ["deny", "always"], default: "always" } }, ["grant"]),
@@ -5718,6 +5718,34 @@ describe("ClaudeAdapterV2 background wake turns", () => {
}).pipe(Effect.scoped, Effect.provide(testLayer)),
);
+ it.effect.each(["accept", "decline", "cancel", "acceptAlways", "acceptForSession"] as const)(
+ "shows a URL card and responds to %s without form content",
+ (decision) =>
+ Effect.gen(function* () {
+ const { harness, elicit, awaitCard } = yield* startElicitationTurn();
+ const url = "https://example.com/authorize?transfer=one-time";
+ const answer = yield* elicit({ ...approvalOnly, mode: "url", url });
+ const request = yield* awaitCard(1);
+ const card = harness.events.findLast(
+ (event) =>
+ event.type === "turn_item.updated" && event.turnItem.type === "approval_request",
+ );
+ assert.deepInclude(card?.type === "turn_item.updated" ? card.turnItem : undefined, {
+ elicitationUrl: url,
+ options: [
+ { decision: "cancel", label: "Cancel" },
+ { decision: "decline", label: "Decline" },
+ { decision: "accept", label: "Continue" },
+ ],
+ });
+ assert.isUndefined(answer.pollUnsafe());
+ yield* harness.runtime.respondToRuntimeRequest({ requestId: request.id, decision });
+ assert.deepEqual(yield* Fiber.join(answer), {
+ action: decision === "accept" || decision === "cancel" ? decision : "decline",
+ });
+ }).pipe(Effect.scoped, Effect.provide(testLayer)),
+ );
+
it.effect.each(["decline", "cancel"] as const)("answers %s with that action", (decision) =>
Effect.gen(function* () {
const { harness, elicit, awaitCard } = yield* startElicitationTurn();
@@ -5740,11 +5768,11 @@ describe("ClaudeAdapterV2 background wake turns", () => {
}).pipe(Effect.scoped, Effect.provide(testLayer)),
);
- it.effect("declines URL and value forms without a card", () =>
+ it.effect("declines unsafe URL and value forms without a card", () =>
Effect.gen(function* () {
const { elicit, runtimeRequests } = yield* startElicitationTurn();
for (const request of [
- { ...approvalOnly, mode: "url", url: "https://example.com/auth" },
+ { ...approvalOnly, mode: "url", url: "file:///tmp/auth" },
{
...approvalOnly,
requestedSchema: {
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
index 2824552b32ea7..fdc4d0975ec9e 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
@@ -35,7 +35,10 @@ import {
type ClaudeLedgerUsage,
type ClaudeModelLedger,
} from "../../provider/ClaudeTurnTokenUsage.ts";
-import { toMcpElicitationResponse } from "../../provider/CodexMcpElicitation.ts";
+import {
+ MCP_URL_ELICITATION_OPTIONS,
+ toMcpElicitationResponse,
+} from "../../provider/CodexMcpElicitation.ts";
import {
type CanUseTool,
type ElicitationRequest,
@@ -3864,7 +3867,11 @@ const withoutSchemaDefaults = (schema: Record): Record;
readonly presentation?: ReturnType;
@@ -7791,6 +7799,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
requestKind: input.requestKind as ProviderRequestKind,
...(input.prompt === undefined ? {} : { prompt: input.prompt }),
...(input.appName === undefined ? {} : { appName: input.appName }),
+ ...(input.elicitationUrl === undefined
+ ? {}
+ : { elicitationUrl: input.elicitationUrl }),
...(input.options === undefined ? {} : { options: input.options }),
...input.presentation,
}
@@ -10602,7 +10613,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
requestKind: "mcp-elicitation",
prompt: request.message,
appName: request.displayName ?? request.title ?? request.serverName,
- options: CLAUDE_MCP_ELICITATION_OPTIONS,
+ ...(request.mode === "url" ? { elicitationUrl: request.url } : {}),
+ options:
+ request.mode === "url" ? MCP_URL_ELICITATION_OPTIONS : CLAUDE_MCP_ELICITATION_OPTIONS,
});
const decision = yield* Deferred.make();
yield* Ref.update(pendingRuntimeRequests, (current) => {
diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
index c9b9218ca1f27..31e9c2a6ff16d 100644
--- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
+++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
@@ -9727,6 +9727,68 @@ describe("CodexAdapterV2 post-settle continuation", () => {
),
);
+ it.effect("shows MCP URL elicitation on its owning turn and accepts without form content", () =>
+ Effect.gen(function* () {
+ const url = "https://example.com/authorize?transfer=one-time";
+ const { harness, answerNext } = yield* startApprovalTurn("url-elicitation", [
+ {
+ type: "emit_inbound",
+ label: "MCP URL request",
+ frame: {
+ id: 0,
+ method: "mcpServer/elicitation/request",
+ params: {
+ threadId: nativeThreadId,
+ turnId: nativeTurnId,
+ serverName: "review-service",
+ mode: "url",
+ message: "Sign in to continue.",
+ elicitationId: "login-1",
+ url,
+ },
+ },
+ },
+ {
+ type: "expect_outbound",
+ label: "MCP URL accepted",
+ frame: { id: 0, result: { action: "accept" } },
+ },
+ turnCompleted(nativeTurnId),
+ ]);
+ const request = yield* answerNext;
+ yield* harness.firstTerminal;
+ assert.equal(request.kind, "mcp-elicitation");
+ assert.equal(request.nativeRequestRef?.nativeId, "login-1");
+ assert.deepInclude(approvalItem(harness.events, request), {
+ elicitationUrl: url,
+ appName: "review-service",
+ prompt: "Sign in to continue.",
+ options: [
+ { decision: "cancel", label: "Cancel" },
+ { decision: "decline", label: "Decline" },
+ { decision: "accept", label: "Continue" },
+ ],
+ });
+ const late = yield* Effect.flip(
+ harness.runtime.respondToRuntimeRequest({
+ requestId: request.id,
+ decision: "accept",
+ }),
+ );
+ assert.include(String(late.cause), "No pending Codex runtime request");
+ }).pipe(
+ Effect.scoped,
+ Effect.provide(
+ Layer.mergeAll(
+ IdAllocator.layer,
+ McpProviderSessions.layer,
+ NodeServices.layer,
+ NodeCrypto.layer,
+ ),
+ ),
+ ),
+ );
+
it.effect("classifies permission requests that only list sandbox entries", () =>
Effect.gen(function* () {
const permissionRequest = (
diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
index 831b240d8bba2..2b90028771c0a 100644
--- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
@@ -4256,6 +4256,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
readonly requestKind: ProviderRequestKind;
readonly prompt?: string | null;
readonly appName?: string;
+ readonly elicitationUrl?: string;
readonly options?: ReadonlyArray;
}) {
const createdAt = yield* DateTime.now;
@@ -4339,6 +4340,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
? {}
: { prompt: input.prompt }),
...(input.appName === undefined ? {} : { appName: input.appName }),
+ ...(input.elicitationUrl === undefined ? {} : { elicitationUrl: input.elicitationUrl }),
...(input.options === undefined ? {} : { options: input.options }),
};
return { node, request, turnItem };
@@ -5597,6 +5599,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
requestKind: "mcp-elicitation",
prompt: payload.message,
appName: described.appName,
+ ...(payload.mode === "url" ? { elicitationUrl: payload.url } : {}),
options: described.options,
});
const decision = yield* Deferred.make();
diff --git a/apps/server/src/provider/CodexMcpElicitation.test.ts b/apps/server/src/provider/CodexMcpElicitation.test.ts
index 66dd7ddb6e7d0..a05d2cb34c80e 100644
--- a/apps/server/src/provider/CodexMcpElicitation.test.ts
+++ b/apps/server/src/provider/CodexMcpElicitation.test.ts
@@ -150,7 +150,7 @@ describe("Codex MCP elicitation approvals", () => {
});
});
- it("does not approve URL elicitations without opening their requested URL", () => {
+ it("accepts URL elicitation only for an explicit one-time decision", () => {
const urlRequest = {
mode: "url",
message: "Finish signing in to continue.",
@@ -162,8 +162,32 @@ describe("Codex MCP elicitation approvals", () => {
} satisfies EffectCodexSchema.McpServerElicitationRequestParams;
NodeAssert.deepStrictEqual(toMcpElicitationResponse(urlRequest, "accept"), {
- action: "decline",
+ action: "accept",
});
+ for (const decision of ["acceptAlways", "acceptForSession"] as const) {
+ NodeAssert.deepStrictEqual(toMcpElicitationResponse(urlRequest, decision), {
+ action: "decline",
+ });
+ }
+ for (const action of ["decline", "cancel"] as const) {
+ NodeAssert.deepStrictEqual(toMcpElicitationResponse(urlRequest, action), { action });
+ }
+ for (const url of ["javascript:alert(1)", "file:///tmp/login", "invalid", ""]) {
+ NodeAssert.deepStrictEqual(toMcpElicitationResponse({ ...urlRequest, url }, "accept"), {
+ action: "decline",
+ });
+ }
+ NodeAssert.deepStrictEqual(
+ describeMcpElicitation({
+ ...urlRequest,
+ _meta: { allowPersistentApproval: true, persist: ["session", "always"] },
+ }).options,
+ [
+ { decision: "cancel", label: "Cancel" },
+ { decision: "decline", label: "Decline" },
+ { decision: "accept", label: "Continue" },
+ ],
+ );
});
it("omits persistence choices that cannot satisfy required form fields", () => {
diff --git a/apps/server/src/provider/CodexMcpElicitation.ts b/apps/server/src/provider/CodexMcpElicitation.ts
index eafe09e07e706..d506959825099 100644
--- a/apps/server/src/provider/CodexMcpElicitation.ts
+++ b/apps/server/src/provider/CodexMcpElicitation.ts
@@ -1,7 +1,17 @@
-import type { ProviderApprovalDecision, ProviderApprovalOption } from "@t3tools/contracts";
+import {
+ httpPageUrl,
+ type ProviderApprovalDecision,
+ type ProviderApprovalOption,
+} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
import type * as EffectCodexSchema from "effect-codex-app-server/schema";
+export const MCP_URL_ELICITATION_OPTIONS: ReadonlyArray = [
+ { decision: "cancel", label: "Cancel" },
+ { decision: "decline", label: "Decline" },
+ { decision: "accept", label: "Continue" },
+];
+
const NullableMcpElicitationString = Schema.NullOr(Schema.String);
const McpElicitationMetadata = Schema.Struct({
app: Schema.optionalKey(NullableMcpElicitationString),
@@ -80,6 +90,7 @@ function mcpElicitationPersistenceDecision(
// only fields the response is built from.
type McpElicitationResponseInput = {
readonly mode: string;
+ readonly url?: string | undefined;
readonly requestedSchema?: unknown;
};
@@ -128,6 +139,12 @@ export function describeMcpElicitation(
metadata?.connector_name ??
metadata?.connectorName ??
payload.serverName;
+ if (payload.mode === "url") {
+ return {
+ appName,
+ options: MCP_URL_ELICITATION_OPTIONS,
+ };
+ }
const persistenceOptions = new Map();
const persist = metadata?.persist;
for (const value of typeof persist === "string" ? [persist] : (persist ?? [])) {
@@ -186,7 +203,13 @@ export function toMcpElicitationResponse(
}
if (payload.mode === "url") {
- return { action: "decline" };
+ const url = payload.url;
+ return {
+ action:
+ decision === "accept" && url !== undefined && httpPageUrl(url) !== null
+ ? "accept"
+ : "decline",
+ };
}
const persist =
diff --git a/apps/web/src/components/chat/ChildThreadApprovals.tsx b/apps/web/src/components/chat/ChildThreadApprovals.tsx
index 5a7cb3074b4a6..73bede9aca375 100644
--- a/apps/web/src/components/chat/ChildThreadApprovals.tsx
+++ b/apps/web/src/components/chat/ChildThreadApprovals.tsx
@@ -10,6 +10,7 @@ import { useState } from "react";
import { useEnvironmentScope } from "../../state/session";
import { childThreadApprovalsAtom, threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
+import { McpElicitationLink } from "./McpElicitationLink";
import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";
function ChildApproval({ entry }: { entry: ChildThreadApproval }) {
@@ -33,6 +34,14 @@ function ChildApproval({ entry }: { entry: ChildThreadApproval }) {
Provider process is gone. Interrupt or restart the child thread to respond.
) : null}
+ {approval.elicitationUrl ? (
+
+
+
+ ) : null}
{approval.reason ? {approval.reason}
: null}
{approval.fullAccessOverride ? (
diff --git a/apps/web/src/components/chat/McpElicitationLink.tsx b/apps/web/src/components/chat/McpElicitationLink.tsx
new file mode 100644
index 0000000000000..2662bd38bbf84
--- /dev/null
+++ b/apps/web/src/components/chat/McpElicitationLink.tsx
@@ -0,0 +1,37 @@
+import { httpPageUrl } from "@t3tools/contracts";
+import { MCP_URL_ELICITATION_HINT } from "@t3tools/client-runtime/state/thread-requests";
+import { ExternalLinkIcon } from "lucide-react";
+import { useState } from "react";
+import { ensureLocalApi } from "../../localApi";
+import { InlineButton } from "../ui/button";
+
+export function McpElicitationLink({ url, disabled }: { url: string; disabled: boolean }) {
+ const page = httpPageUrl(url);
+ const [error, setError] = useState(false);
+ if (!page) return null;
+ return (
+
+ {MCP_URL_ELICITATION_HINT}
+
+ {page.href}
+
+ {
+ setError(false);
+ void ensureLocalApi()
+ .shell.openExternal(page.href)
+ .catch(() => setError(true));
+ }}
+ >
+ Open {page.host}
+
+
+ {error ? (
+
+ Could not open the link. Copy it into your browser to continue.
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/components/chat/TimelinePendingApprovalRow.tsx b/apps/web/src/components/chat/TimelinePendingApprovalRow.tsx
index c425d560bf131..7d6801b91babd 100644
--- a/apps/web/src/components/chat/TimelinePendingApprovalRow.tsx
+++ b/apps/web/src/components/chat/TimelinePendingApprovalRow.tsx
@@ -7,6 +7,7 @@ import { memo, use } from "react";
import { type PendingApproval } from "../../session-logic";
import { InlineButton } from "../ui/button";
import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
+import { McpElicitationLink } from "./McpElicitationLink";
import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";
import { ToolActivityIconView, type WorkEntryIconName } from "./TimelineToolActivityIcon";
import { TimelineRowCtx } from "./timelineRowContext";
@@ -68,6 +69,14 @@ export const TimelinePendingApprovalRow = memo(function TimelinePendingApprovalR
{approval.appName ? (
{approval.appName}
) : null}
+ {approval.elicitationUrl ? (
+
+ ) : null}
{approval.reason ? (
{approval.reason}
) : null}
diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md
index 796e3f316122d..eac9325c8f8ac 100644
--- a/docs/user/permission-modes.md
+++ b/docs/user/permission-modes.md
@@ -58,3 +58,12 @@ Kiro offers only **Supervised** and **Full access**. In **Supervised** Kiro also
review a turn's file changes. See [Kiro permissions](./providers-kiro.md#permission-modes).
See the [provider guides](./install.md#providers) for setup and provider-specific limits.
+
+## MCP browser requests
+
+When an MCP server in a Claude or Codex thread asks you to complete steps in a browser, open the link
+in its approval request, complete the steps in your browser, then choose **Continue**.
+The link opens on the device you are using, including when the agent runs remotely.
+A localhost link points to your current device; a remotely running MCP server must provide a link reachable from that device.
+You can decline or cancel instead. Browser requests still need your response in
+Full access mode.
diff --git a/packages/client-runtime/src/secretRequest.ts b/packages/client-runtime/src/secretRequest.ts
index cd7427049f5f3..fd6b2eee7ef0e 100644
--- a/packages/client-runtime/src/secretRequest.ts
+++ b/packages/client-runtime/src/secretRequest.ts
@@ -1,6 +1,6 @@
import {
SecretReference,
- secretRequestPageUrl,
+ httpPageUrl,
type OrchestrationV2TurnItem,
type SecretProviderId,
type SecretRequestAnswerInput,
@@ -65,14 +65,14 @@ function reasonLink(reason: string): string | undefined {
return link;
});
// Two spellings of one page (`https://a.dev` and `https://a.dev/`) count once.
- const links = new Set(raw.map((link) => secretRequestPageUrl(link)?.href ?? link));
+ const links = new Set(raw.map((link) => httpPageUrl(link)?.href ?? link));
return links.size === 1 ? [...links][0] : undefined;
}
/** Only http(s), so an agent cannot make the card open another scheme. */
export function secretRequestLink(item: Pick) {
const raw = item.url ?? reasonLink(item.reason);
- const url = raw === undefined ? null : secretRequestPageUrl(raw);
+ const url = raw === undefined ? null : httpPageUrl(raw);
return url === null ? null : { href: url.href, host: url.host };
}
diff --git a/packages/client-runtime/src/state/threadRequests.ts b/packages/client-runtime/src/state/threadRequests.ts
index 7c3c44c4caa59..6e3661c7142b2 100644
--- a/packages/client-runtime/src/state/threadRequests.ts
+++ b/packages/client-runtime/src/state/threadRequests.ts
@@ -57,11 +57,14 @@ export interface ThreadPendingApproval {
readonly reason?: string;
readonly fullAccessOverride?: ProviderApprovalFullAccessOverride;
readonly appName?: string;
+ readonly elicitationUrl?: string;
readonly options?: ReadonlyArray;
readonly responseCapability: "live" | "not_resumable";
readonly supportsFeedback?: boolean;
}
+export const MCP_URL_ELICITATION_HINT = "Complete the steps in your browser, then choose Continue.";
+
export const FULL_ACCESS_OVERRIDE_QUESTION = "Why am I being asked this?";
export function fullAccessOverrideExplanation(
@@ -268,6 +271,9 @@ export function derivePendingThreadRequests(
? { fullAccessOverride: item.fullAccessOverride }
: {}),
...(item?.type === "approval_request" && item.appName ? { appName: item.appName } : {}),
+ ...(item?.type === "approval_request" && item.elicitationUrl
+ ? { elicitationUrl: item.elicitationUrl }
+ : {}),
...(item?.type === "approval_request" && item.options !== undefined
? { options: item.options }
: {}),
diff --git a/packages/contracts/src/httpPageUrl.test.ts b/packages/contracts/src/httpPageUrl.test.ts
new file mode 100644
index 0000000000000..324814835781b
--- /dev/null
+++ b/packages/contracts/src/httpPageUrl.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { httpPageUrl } from "./httpPageUrl.ts";
+
+describe("httpPageUrl", () => {
+ it("opens only http and https pages", () => {
+ expect(httpPageUrl("https://accounts.google.com/o/oauth2/auth?x=1")?.host).toBe(
+ "accounts.google.com",
+ );
+ expect(httpPageUrl("http://localhost:3000/auth")?.host).toBe("localhost:3000");
+ expect(httpPageUrl("file:///tmp/auth")).toBeNull();
+ expect(httpPageUrl("javascript:alert(1)")).toBeNull();
+ expect(httpPageUrl("https://?state=x")).toBeNull();
+ expect(httpPageUrl("not a url")).toBeNull();
+ });
+});
diff --git a/packages/contracts/src/httpPageUrl.ts b/packages/contracts/src/httpPageUrl.ts
new file mode 100644
index 0000000000000..4dbda7edf123b
--- /dev/null
+++ b/packages/contracts/src/httpPageUrl.ts
@@ -0,0 +1,9 @@
+export function httpPageUrl(value: string): URL | null {
+ // React Native's URL has no `canParse`.
+ try {
+ const url = new URL(value);
+ return (url.protocol === "https:" || url.protocol === "http:") && url.host !== "" ? url : null;
+ } catch {
+ return null;
+ }
+}
diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts
index 8dd47df33a21f..dd0d07de89fdd 100644
--- a/packages/contracts/src/index.ts
+++ b/packages/contracts/src/index.ts
@@ -70,6 +70,7 @@ export * from "./resourceTelemetry.ts";
export * from "./rpc.ts";
export * from "./worktreeSetup.ts";
export * from "./secretRequest.ts";
+export * from "./httpPageUrl.ts";
export * from "./secretReference.ts";
export * from "./settingsSecretValue.ts";
export * from "./clientRpcPermissions.ts";
diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts
index ee75d861daa7e..342d94751cdc4 100644
--- a/packages/contracts/src/orchestrationV2.ts
+++ b/packages/contracts/src/orchestrationV2.ts
@@ -1716,6 +1716,7 @@ export const OrchestrationV2TurnItem = Schema.Union([
reason: Schema.optional(Schema.String),
fullAccessOverride: Schema.optional(ProviderApprovalFullAccessOverride),
appName: Schema.optional(Schema.String),
+ elicitationUrl: Schema.optional(Schema.String),
options: Schema.optional(Schema.Array(ProviderApprovalOption)),
}),
Schema.Struct({
@@ -2557,6 +2558,7 @@ export const OrchestrationV2TurnItemJson = Schema.Union([
reason: Schema.optional(Schema.String),
fullAccessOverride: Schema.optional(ProviderApprovalFullAccessOverride),
appName: Schema.optional(Schema.String),
+ elicitationUrl: Schema.optional(Schema.String),
options: Schema.optional(Schema.Array(ProviderApprovalOption)),
}),
Schema.Struct({
diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts
index 5c57cd1b16fb1..35684d9fcdece 100644
--- a/packages/contracts/src/orchestratorMcp.ts
+++ b/packages/contracts/src/orchestratorMcp.ts
@@ -32,7 +32,7 @@ import {
ThreadPullRequestKey,
ThreadPullRequestLinkSource,
} from "./threadPullRequest.ts";
-import { secretRequestPageUrl } from "./secretRequest.ts";
+import { httpPageUrl } from "./httpPageUrl.ts";
import { hasOnlyPairedSurrogates } from "./threadMetadataMcp.ts";
import { ThreadTitleRegeneration } from "./threadTitle.ts";
import {
@@ -757,7 +757,7 @@ export const OrchestratorMcpRequestSecretInput = Schema.Struct({
}),
url: Schema.optional(
TrimmedNonEmptyString.check(
- Schema.makeFilter((value) => secretRequestPageUrl(value) !== null, {
+ Schema.makeFilter((value) => httpPageUrl(value) !== null, {
expected: "an http(s) URL",
}),
),
diff --git a/packages/contracts/src/secretRequest.test.ts b/packages/contracts/src/secretRequest.test.ts
deleted file mode 100644
index 0d3f6d29498e9..0000000000000
--- a/packages/contracts/src/secretRequest.test.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { describe, expect, it } from "vite-plus/test";
-
-import { secretRequestPageUrl } from "./secretRequest.ts";
-
-describe("secretRequestPageUrl", () => {
- it("opens only http and https pages", () => {
- expect(secretRequestPageUrl("https://accounts.google.com/o/oauth2/auth?x=1")?.host).toBe(
- "accounts.google.com",
- );
- expect(secretRequestPageUrl("javascript:alert(1)")).toBeNull();
- expect(secretRequestPageUrl("https://?state=x")).toBeNull();
- expect(secretRequestPageUrl("not a url")).toBeNull();
- });
-});
diff --git a/packages/contracts/src/secretRequest.ts b/packages/contracts/src/secretRequest.ts
index ccd114c397e38..4c74e9fcca3e7 100644
--- a/packages/contracts/src/secretRequest.ts
+++ b/packages/contracts/src/secretRequest.ts
@@ -3,17 +3,6 @@ import * as Schema from "effect/Schema";
import { ThreadId, TrimmedNonEmptyString, TurnItemId } from "./baseSchemas.ts";
import { SecretReference } from "./secretReference.ts";
-/** The http(s) page a secret card may open, or null for anything else. */
-export function secretRequestPageUrl(value: string): URL | null {
- // React Native's URL has no `canParse`.
- try {
- const url = new URL(value);
- return (url.protocol === "https:" || url.protocol === "http:") && url.host !== "" ? url : null;
- } catch {
- return null;
- }
-}
-
/** Server stores the value under a one-use SecretRef; the thread learns only status. */
export const SecretRequestAnswerInput = Schema.Struct({
threadId: ThreadId,