Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions apps/mobile/src/features/threads/PendingApprovalCard.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 (
<View className="gap-2.5 rounded-[20px] border border-border bg-card-alt p-4">
<Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-foreground-secondary">
Expand All @@ -50,6 +57,33 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {
{props.approval.detail}
</Text>
) : null}
{elicitationUrl ? (
<View className="gap-2">
<Text className="font-sans text-sm text-foreground-secondary">
{MCP_URL_ELICITATION_HINT}
</Text>
<Text selectable className="font-mono text-xs text-foreground-secondary">
{elicitationUrl.href}
</Text>
<Button
label={`Open ${elicitationUrl.host}`}
fullWidth
tone="secondary"
disabled={disabled || !props.canOperateThread}
onPress={() => {
setLinkError(false);
void tryOpenExternalUrl(elicitationUrl.href, "mcp-elicitation").then((opened) =>
setLinkError(!opened),
);
}}
/>
{linkError ? (
<Text accessibilityRole="alert" className="font-sans text-xs text-danger-foreground">
Could not open the link. Copy it into your browser to continue.
</Text>
) : null}
</View>
) : null}
{props.approval.reason ? (
<Text className="font-sans text-xs leading-normal text-warning-foreground">
{props.approval.reason}
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/lib/openExternalUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const ExternalUrlTarget = Schema.Literals([
"html-render",
"mcp-app",
"secret-request",
"mcp-elicitation",
]);

export type ExternalUrlTarget = typeof ExternalUrlTarget.Type;
Expand Down
36 changes: 32 additions & 4 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
Expand Down Expand Up @@ -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();
Expand All @@ -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: {
Expand Down
19 changes: 16 additions & 3 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -3864,7 +3867,11 @@ const withoutSchemaDefaults = (schema: Record<string, unknown>): Record<string,
export function claudeMcpElicitationAcceptance(
request: ElicitationRequest,
): ElicitationResult | undefined {
if (request.mode === "url" || request.requestedSchema === undefined) return undefined;
if (request.mode === "url") {
const response = toMcpElicitationResponse({ mode: "url", url: request.url }, "accept");
return response.action === "accept" ? { action: "accept" } : undefined;
}
if (request.requestedSchema === undefined) return undefined;
const response = toMcpElicitationResponse(
{ mode: "form", requestedSchema: withoutSchemaDefaults(request.requestedSchema) },
"accept",
Expand Down Expand Up @@ -7698,6 +7705,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
readonly requestKind: OrchestrationV2RuntimeRequest["kind"];
readonly prompt?: string;
readonly appName?: string;
readonly elicitationUrl?: string;
readonly options?: ReadonlyArray<ProviderApprovalOption>;
readonly presentation?: ReturnType<typeof claudeApprovalPresentation>;

Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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<ClaudeApprovalResponse>();
yield* Ref.update(pendingRuntimeRequests, (current) => {
Expand Down
62 changes: 62 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProviderApprovalOption>;
}) {
const createdAt = yield* DateTime.now;
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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<ProviderApprovalDecision, never>();
Expand Down
28 changes: 26 additions & 2 deletions apps/server/src/provider/CodexMcpElicitation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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", () => {
Expand Down
27 changes: 25 additions & 2 deletions apps/server/src/provider/CodexMcpElicitation.ts
Original file line number Diff line number Diff line change
@@ -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<ProviderApprovalOption> = [
{ 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),
Expand Down Expand Up @@ -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;
};

Expand Down Expand Up @@ -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<McpElicitationPersistenceDecision, string>();
const persist = metadata?.persist;
for (const value of typeof persist === "string" ? [persist] : (persist ?? [])) {
Expand Down Expand Up @@ -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 =
Expand Down
Loading
Loading