Skip to content

Commit 519cc62

Browse files
committed
feat(provider): support MCP URL elicitation for Claude and Codex
1 parent 743bb8c commit 519cc62

21 files changed

Lines changed: 306 additions & 45 deletions

‎apps/mobile/src/features/threads/PendingApprovalCard.tsx‎

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
import { Button } from "../../components/ui/Button";
2-
import type {
3-
ProviderApprovalDecision,
4-
ProviderApprovalOption,
5-
RuntimeRequestId,
2+
import {
3+
httpPageUrl,
4+
type ProviderApprovalDecision,
5+
type ProviderApprovalOption,
6+
type RuntimeRequestId,
67
} from "@t3tools/contracts";
78
import {
89
FULL_ACCESS_OVERRIDE_QUESTION,
910
fullAccessOverrideExplanation,
11+
MCP_URL_ELICITATION_HINT,
1012
} from "@t3tools/client-runtime/state/thread-requests";
1113
import { useState } from "react";
1214
import { Pressable, View } from "react-native";
1315

1416
import { AppText as Text } from "../../components/ui/AppText";
17+
import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
1518
import type { PendingApproval } from "../../lib/threadActivity";
1619

1720
export interface PendingApprovalCardProps {
@@ -37,6 +40,10 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {
3740
const canRespond = props.approval.responseCapability === "live";
3841
const disabled = !canRespond || props.respondingApprovalId === props.approval.requestId;
3942
const [explanationOpen, setExplanationOpen] = useState(false);
43+
const [linkError, setLinkError] = useState(false);
44+
const elicitationUrl = props.approval.elicitationUrl
45+
? httpPageUrl(props.approval.elicitationUrl)
46+
: null;
4047
return (
4148
<View className="gap-2.5 rounded-[20px] border border-border bg-card-alt p-4">
4249
<Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-foreground-secondary">
@@ -50,6 +57,33 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {
5057
{props.approval.detail}
5158
</Text>
5259
) : null}
60+
{elicitationUrl ? (
61+
<View className="gap-2">
62+
<Text className="font-sans text-sm text-foreground-secondary">
63+
{MCP_URL_ELICITATION_HINT}
64+
</Text>
65+
<Text selectable className="font-mono text-xs text-foreground-secondary">
66+
{elicitationUrl.href}
67+
</Text>
68+
<Button
69+
label={`Open ${elicitationUrl.host}`}
70+
fullWidth
71+
tone="secondary"
72+
disabled={disabled || !props.canOperateThread}
73+
onPress={() => {
74+
setLinkError(false);
75+
void tryOpenExternalUrl(elicitationUrl.href, "mcp-elicitation").then((opened) =>
76+
setLinkError(!opened),
77+
);
78+
}}
79+
/>
80+
{linkError ? (
81+
<Text accessibilityRole="alert" className="font-sans text-xs text-danger-foreground">
82+
Could not open the link. Copy it into your browser to continue.
83+
</Text>
84+
) : null}
85+
</View>
86+
) : null}
5387
{props.approval.reason ? (
5488
<Text className="font-sans text-xs leading-normal text-warning-foreground">
5589
{props.approval.reason}

‎apps/mobile/src/lib/openExternalUrl.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const ExternalUrlTarget = Schema.Literals([
99
"html-render",
1010
"mcp-app",
1111
"secret-request",
12+
"mcp-elicitation",
1213
]);
1314

1415
export type ExternalUrlTarget = typeof ExternalUrlTarget.Type;

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts‎

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1331,9 +1331,9 @@ describe("ClaudeAdapterV2 MCP elicitation acceptance", () => {
13311331
);
13321332
});
13331333

1334-
it("has no answer for URL mode or a form asking for a value", () => {
1334+
it("has no answer for unsafe URLs or a form asking for a value", () => {
13351335
for (const request of [
1336-
{ serverName: "deploys", message: "Sign in", mode: "url", url: "https://example.com" },
1336+
{ serverName: "deploys", message: "Sign in", mode: "url", url: "javascript:alert(1)" },
13371337
form({ reason: { type: "string" } }, ["reason"]),
13381338
form({ grant: { type: "string", enum: ["always", "session"] } }, ["grant"]),
13391339
form({ grant: { type: "string", enum: ["deny", "always"], default: "always" } }, ["grant"]),
@@ -5718,6 +5718,34 @@ describe("ClaudeAdapterV2 background wake turns", () => {
57185718
}).pipe(Effect.scoped, Effect.provide(testLayer)),
57195719
);
57205720

5721+
it.effect.each(["accept", "decline", "cancel", "acceptAlways", "acceptForSession"] as const)(
5722+
"shows a URL card and responds to %s without form content",
5723+
(decision) =>
5724+
Effect.gen(function* () {
5725+
const { harness, elicit, awaitCard } = yield* startElicitationTurn();
5726+
const url = "https://example.com/authorize?transfer=one-time";
5727+
const answer = yield* elicit({ ...approvalOnly, mode: "url", url });
5728+
const request = yield* awaitCard(1);
5729+
const card = harness.events.findLast(
5730+
(event) =>
5731+
event.type === "turn_item.updated" && event.turnItem.type === "approval_request",
5732+
);
5733+
assert.deepInclude(card?.type === "turn_item.updated" ? card.turnItem : undefined, {
5734+
elicitationUrl: url,
5735+
options: [
5736+
{ decision: "cancel", label: "Cancel" },
5737+
{ decision: "decline", label: "Decline" },
5738+
{ decision: "accept", label: "Continue" },
5739+
],
5740+
});
5741+
assert.isUndefined(answer.pollUnsafe());
5742+
yield* harness.runtime.respondToRuntimeRequest({ requestId: request.id, decision });
5743+
assert.deepEqual(yield* Fiber.join(answer), {
5744+
action: decision === "accept" || decision === "cancel" ? decision : "decline",
5745+
});
5746+
}).pipe(Effect.scoped, Effect.provide(testLayer)),
5747+
);
5748+
57215749
it.effect.each(["decline", "cancel"] as const)("answers %s with that action", (decision) =>
57225750
Effect.gen(function* () {
57235751
const { harness, elicit, awaitCard } = yield* startElicitationTurn();
@@ -5740,11 +5768,11 @@ describe("ClaudeAdapterV2 background wake turns", () => {
57405768
}).pipe(Effect.scoped, Effect.provide(testLayer)),
57415769
);
57425770

5743-
it.effect("declines URL and value forms without a card", () =>
5771+
it.effect("declines unsafe URL and value forms without a card", () =>
57445772
Effect.gen(function* () {
57455773
const { elicit, runtimeRequests } = yield* startElicitationTurn();
57465774
for (const request of [
5747-
{ ...approvalOnly, mode: "url", url: "https://example.com/auth" },
5775+
{ ...approvalOnly, mode: "url", url: "file:///tmp/auth" },
57485776
{
57495777
...approvalOnly,
57505778
requestedSchema: {

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,10 @@ import {
3535
type ClaudeLedgerUsage,
3636
type ClaudeModelLedger,
3737
} from "../../provider/ClaudeTurnTokenUsage.ts";
38-
import { toMcpElicitationResponse } from "../../provider/CodexMcpElicitation.ts";
38+
import {
39+
MCP_URL_ELICITATION_OPTIONS,
40+
toMcpElicitationResponse,
41+
} from "../../provider/CodexMcpElicitation.ts";
3942
import {
4043
type CanUseTool,
4144
type ElicitationRequest,
@@ -3864,7 +3867,11 @@ const withoutSchemaDefaults = (schema: Record<string, unknown>): Record<string,
38643867
export function claudeMcpElicitationAcceptance(
38653868
request: ElicitationRequest,
38663869
): ElicitationResult | undefined {
3867-
if (request.mode === "url" || request.requestedSchema === undefined) return undefined;
3870+
if (request.mode === "url") {
3871+
const response = toMcpElicitationResponse({ mode: "url", url: request.url }, "accept");
3872+
return response.action === "accept" ? { action: "accept" } : undefined;
3873+
}
3874+
if (request.requestedSchema === undefined) return undefined;
38683875
const response = toMcpElicitationResponse(
38693876
{ mode: "form", requestedSchema: withoutSchemaDefaults(request.requestedSchema) },
38703877
"accept",
@@ -7698,6 +7705,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
76987705
readonly requestKind: OrchestrationV2RuntimeRequest["kind"];
76997706
readonly prompt?: string;
77007707
readonly appName?: string;
7708+
readonly elicitationUrl?: string;
77017709
readonly options?: ReadonlyArray<ProviderApprovalOption>;
77027710
readonly presentation?: ReturnType<typeof claudeApprovalPresentation>;
77037711

@@ -7791,6 +7799,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
77917799
requestKind: input.requestKind as ProviderRequestKind,
77927800
...(input.prompt === undefined ? {} : { prompt: input.prompt }),
77937801
...(input.appName === undefined ? {} : { appName: input.appName }),
7802+
...(input.elicitationUrl === undefined
7803+
? {}
7804+
: { elicitationUrl: input.elicitationUrl }),
77947805
...(input.options === undefined ? {} : { options: input.options }),
77957806
...input.presentation,
77967807
}
@@ -10602,7 +10613,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
1060210613
requestKind: "mcp-elicitation",
1060310614
prompt: request.message,
1060410615
appName: request.displayName ?? request.title ?? request.serverName,
10605-
options: CLAUDE_MCP_ELICITATION_OPTIONS,
10616+
...(request.mode === "url" ? { elicitationUrl: request.url } : {}),
10617+
options:
10618+
request.mode === "url" ? MCP_URL_ELICITATION_OPTIONS : CLAUDE_MCP_ELICITATION_OPTIONS,
1060610619
});
1060710620
const decision = yield* Deferred.make<ClaudeApprovalResponse>();
1060810621
yield* Ref.update(pendingRuntimeRequests, (current) => {

‎apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9727,6 +9727,68 @@ describe("CodexAdapterV2 post-settle continuation", () => {
97279727
),
97289728
);
97299729

9730+
it.effect("shows MCP URL elicitation on its owning turn and accepts without form content", () =>
9731+
Effect.gen(function* () {
9732+
const url = "https://example.com/authorize?transfer=one-time";
9733+
const { harness, answerNext } = yield* startApprovalTurn("url-elicitation", [
9734+
{
9735+
type: "emit_inbound",
9736+
label: "MCP URL request",
9737+
frame: {
9738+
id: 0,
9739+
method: "mcpServer/elicitation/request",
9740+
params: {
9741+
threadId: nativeThreadId,
9742+
turnId: nativeTurnId,
9743+
serverName: "review-service",
9744+
mode: "url",
9745+
message: "Sign in to continue.",
9746+
elicitationId: "login-1",
9747+
url,
9748+
},
9749+
},
9750+
},
9751+
{
9752+
type: "expect_outbound",
9753+
label: "MCP URL accepted",
9754+
frame: { id: 0, result: { action: "accept" } },
9755+
},
9756+
turnCompleted(nativeTurnId),
9757+
]);
9758+
const request = yield* answerNext;
9759+
yield* harness.firstTerminal;
9760+
assert.equal(request.kind, "mcp-elicitation");
9761+
assert.equal(request.nativeRequestRef?.nativeId, "login-1");
9762+
assert.deepInclude(approvalItem(harness.events, request), {
9763+
elicitationUrl: url,
9764+
appName: "review-service",
9765+
prompt: "Sign in to continue.",
9766+
options: [
9767+
{ decision: "cancel", label: "Cancel" },
9768+
{ decision: "decline", label: "Decline" },
9769+
{ decision: "accept", label: "Continue" },
9770+
],
9771+
});
9772+
const late = yield* Effect.flip(
9773+
harness.runtime.respondToRuntimeRequest({
9774+
requestId: request.id,
9775+
decision: "accept",
9776+
}),
9777+
);
9778+
assert.include(String(late.cause), "No pending Codex runtime request");
9779+
}).pipe(
9780+
Effect.scoped,
9781+
Effect.provide(
9782+
Layer.mergeAll(
9783+
IdAllocator.layer,
9784+
McpProviderSessions.layer,
9785+
NodeServices.layer,
9786+
NodeCrypto.layer,
9787+
),
9788+
),
9789+
),
9790+
);
9791+
97309792
it.effect("classifies permission requests that only list sandbox entries", () =>
97319793
Effect.gen(function* () {
97329794
const permissionRequest = (

‎apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4256,6 +4256,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
42564256
readonly requestKind: ProviderRequestKind;
42574257
readonly prompt?: string | null;
42584258
readonly appName?: string;
4259+
readonly elicitationUrl?: string;
42594260
readonly options?: ReadonlyArray<ProviderApprovalOption>;
42604261
}) {
42614262
const createdAt = yield* DateTime.now;
@@ -4339,6 +4340,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
43394340
? {}
43404341
: { prompt: input.prompt }),
43414342
...(input.appName === undefined ? {} : { appName: input.appName }),
4343+
...(input.elicitationUrl === undefined ? {} : { elicitationUrl: input.elicitationUrl }),
43424344
...(input.options === undefined ? {} : { options: input.options }),
43434345
};
43444346
return { node, request, turnItem };
@@ -5597,6 +5599,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
55975599
requestKind: "mcp-elicitation",
55985600
prompt: payload.message,
55995601
appName: described.appName,
5602+
...(payload.mode === "url" ? { elicitationUrl: payload.url } : {}),
56005603
options: described.options,
56015604
});
56025605
const decision = yield* Deferred.make<ProviderApprovalDecision, never>();

‎apps/server/src/provider/CodexMcpElicitation.test.ts‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ describe("Codex MCP elicitation approvals", () => {
150150
});
151151
});
152152

153-
it("does not approve URL elicitations without opening their requested URL", () => {
153+
it("accepts URL elicitation only for an explicit one-time decision", () => {
154154
const urlRequest = {
155155
mode: "url",
156156
message: "Finish signing in to continue.",
@@ -162,8 +162,32 @@ describe("Codex MCP elicitation approvals", () => {
162162
} satisfies EffectCodexSchema.McpServerElicitationRequestParams;
163163

164164
NodeAssert.deepStrictEqual(toMcpElicitationResponse(urlRequest, "accept"), {
165-
action: "decline",
165+
action: "accept",
166166
});
167+
for (const decision of ["acceptAlways", "acceptForSession"] as const) {
168+
NodeAssert.deepStrictEqual(toMcpElicitationResponse(urlRequest, decision), {
169+
action: "decline",
170+
});
171+
}
172+
for (const action of ["decline", "cancel"] as const) {
173+
NodeAssert.deepStrictEqual(toMcpElicitationResponse(urlRequest, action), { action });
174+
}
175+
for (const url of ["javascript:alert(1)", "file:///tmp/login", "invalid", ""]) {
176+
NodeAssert.deepStrictEqual(toMcpElicitationResponse({ ...urlRequest, url }, "accept"), {
177+
action: "decline",
178+
});
179+
}
180+
NodeAssert.deepStrictEqual(
181+
describeMcpElicitation({
182+
...urlRequest,
183+
_meta: { allowPersistentApproval: true, persist: ["session", "always"] },
184+
}).options,
185+
[
186+
{ decision: "cancel", label: "Cancel" },
187+
{ decision: "decline", label: "Decline" },
188+
{ decision: "accept", label: "Continue" },
189+
],
190+
);
167191
});
168192

169193
it("omits persistence choices that cannot satisfy required form fields", () => {

‎apps/server/src/provider/CodexMcpElicitation.ts‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
1-
import type { ProviderApprovalDecision, ProviderApprovalOption } from "@t3tools/contracts";
1+
import {
2+
httpPageUrl,
3+
type ProviderApprovalDecision,
4+
type ProviderApprovalOption,
5+
} from "@t3tools/contracts";
26
import * as Schema from "effect/Schema";
37
import type * as EffectCodexSchema from "effect-codex-app-server/schema";
48

9+
export const MCP_URL_ELICITATION_OPTIONS: ReadonlyArray<ProviderApprovalOption> = [
10+
{ decision: "cancel", label: "Cancel" },
11+
{ decision: "decline", label: "Decline" },
12+
{ decision: "accept", label: "Continue" },
13+
];
14+
515
const NullableMcpElicitationString = Schema.NullOr(Schema.String);
616
const McpElicitationMetadata = Schema.Struct({
717
app: Schema.optionalKey(NullableMcpElicitationString),
@@ -80,6 +90,7 @@ function mcpElicitationPersistenceDecision(
8090
// only fields the response is built from.
8191
type McpElicitationResponseInput = {
8292
readonly mode: string;
93+
readonly url?: string | undefined;
8394
readonly requestedSchema?: unknown;
8495
};
8596

@@ -128,6 +139,12 @@ export function describeMcpElicitation(
128139
metadata?.connector_name ??
129140
metadata?.connectorName ??
130141
payload.serverName;
142+
if (payload.mode === "url") {
143+
return {
144+
appName,
145+
options: MCP_URL_ELICITATION_OPTIONS,
146+
};
147+
}
131148
const persistenceOptions = new Map<McpElicitationPersistenceDecision, string>();
132149
const persist = metadata?.persist;
133150
for (const value of typeof persist === "string" ? [persist] : (persist ?? [])) {
@@ -186,7 +203,13 @@ export function toMcpElicitationResponse(
186203
}
187204

188205
if (payload.mode === "url") {
189-
return { action: "decline" };
206+
const url = payload.url;
207+
return {
208+
action:
209+
decision === "accept" && url !== undefined && httpPageUrl(url) !== null
210+
? "accept"
211+
: "decline",
212+
};
190213
}
191214

192215
const persist =

0 commit comments

Comments
 (0)