From 521f5b5a5682b18f2d59cf95226fc203cd314240 Mon Sep 17 00:00:00 2001
From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com>
Date: Wed, 7 Oct 2026 19:34:41 +0300
Subject: [PATCH 01/13] fix(web): replace Lineage timers with Stop on hover
(#16791)
---
.../src/components/chat/ThreadDetailsControl.tsx | 1 +
.../components/chat/ThreadRelationshipsControl.tsx | 14 ++++++++++----
2 files changed, 11 insertions(+), 4 deletions(-)
diff --git a/apps/web/src/components/chat/ThreadDetailsControl.tsx b/apps/web/src/components/chat/ThreadDetailsControl.tsx
index f14a52c14496..0b83c9313522 100644
--- a/apps/web/src/components/chat/ThreadDetailsControl.tsx
+++ b/apps/web/src/components/chat/ThreadDetailsControl.tsx
@@ -20,6 +20,7 @@ import { ChevronDownIcon } from "lucide-react";
const parts = {
row: THREAD_DETAILS_PANEL_ROW_CLASS,
+ "group-row": `${THREAD_DETAILS_PANEL_ROW_CLASS} hover:!bg-transparent dark:hover:!bg-transparent`,
select: THREAD_DETAILS_PANEL_SELECT_ROW_CLASS,
primary: THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS,
secondary: THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS,
diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx
index f56c1c92b562..cd1265e46a60 100644
--- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx
+++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx
@@ -388,6 +388,9 @@ export function ThreadRelationshipsPanel(props: {
agent?.origin === "app_owned" &&
agent.startedAt &&
["pending", "running", "waiting"].includes(agent.status);
+ const trailingVisibilityClass = canStop
+ ? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0"
+ : "";
const threadTitle = relationshipThreadTitle({
title: node?.thread?.title ?? agent?.title ?? threadId,
isSubagent,
@@ -444,13 +447,13 @@ export function ThreadRelationshipsPanel(props: {
carries status, so an agent with a known time shows only that. */}
{agent && !failed && deriveSubagentElapsedMs(agent, 0) !== null ? (
) : !isMergeTarget ? (
{threadRelationshipStatusLabel(status)}
@@ -458,7 +461,10 @@ export function ThreadRelationshipsPanel(props: {
>
);
return (
-
+
{isMergeTarget ? (
@@ -528,7 +534,7 @@ export function ThreadRelationshipsPanel(props: {
variant="ghost"
disabled={node?.missing === true}
onClick={() => openThread(threadId)}
- part="row"
+ part={canStop ? "group-row" : "row"}
/>
}
>
From 6ecb82dc42c4c721fd2c03c92316ee5361306cdb Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Wed, 7 Oct 2026 09:43:10 -0700
Subject: [PATCH 02/13] fix(clients): running subagent cards stay visible after
their parent turn settles (#16878)
Co-authored-by: Claude Opus 5.5 (1M context)
---
apps/mobile/src/lib/threadActivity.test.ts | 68 +++++++
apps/mobile/src/lib/threadActivity.ts | 6 +-
.../chat/MessagesTimeline.logic.test.ts | 173 ++++++++++++++++++
.../components/chat/MessagesTimeline.logic.ts | 54 +++++-
.../src/state/subagentRuntime.ts | 11 +-
5 files changed, 307 insertions(+), 5 deletions(-)
diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts
index 1cf78cc29f3f..16be0186e68b 100644
--- a/apps/mobile/src/lib/threadActivity.test.ts
+++ b/apps/mobile/src/lib/threadActivity.test.ts
@@ -975,6 +975,74 @@ describe("buildThreadFeed", () => {
).toBe(true);
});
+ it("keeps a settled run's still-running subagents visible while its other work folds", () => {
+ const subagent = (
+ id: string,
+ updatedAt: string,
+ ordinal: number,
+ status: OrchestrationV2TurnItem["status"],
+ ): OrchestrationV2TurnItem => ({
+ ...base(id, updatedAt, ordinal),
+ providerTurnId: ProviderTurnId.make("provider-turn-1"),
+ status,
+ completedAt: status === "running" ? null : DateTime.makeUnsafe(updatedAt),
+ type: "subagent",
+ subagentId: NodeId.make(id),
+ origin: "app_owned",
+ driver: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ childThreadId: sourceThreadId,
+ prompt: `Inspect ${id}`,
+ result: null,
+ });
+ const latestRun = {
+ runId,
+ status: "completed" as const,
+ startedAt: "2026-06-20T00:00:01.000Z",
+ completedAt: "2026-06-20T00:00:05.000Z",
+ };
+ const present = (children: ReadonlyArray) =>
+ deriveThreadFeedPresentation(
+ buildThreadFeed([
+ projected(userMessage(), 0),
+ projected(command("2026-06-20T00:00:02.000Z"), 1),
+ ...children.map((child, index) => projected(child, index + 2)),
+ projected(assistantMessage("2026-06-20T00:00:05.000Z"), 5),
+ ]),
+ latestRun,
+ new Set(),
+ );
+ const visibleSubagentIds = (presented: ReadonlyArray) =>
+ presented.flatMap((entry) =>
+ entry.type === "activity-group"
+ ? entry.activities.flatMap((activity) =>
+ activity.projectedItem.item.type === "subagent"
+ ? [activity.projectedItem.item.id]
+ : [],
+ )
+ : [],
+ );
+
+ const live = present([subagent("item-live", "2026-06-20T00:00:03.000Z", 2, "running")]);
+ expect(live.map((entry) => entry.type)).toEqual([
+ "message",
+ "run-fold",
+ "activity-group",
+ "message",
+ ]);
+ expect(visibleSubagentIds(live)).toEqual(["item-live"]);
+
+ // A launch batch stays whole while any member is live.
+ const mixed = present([
+ subagent("item-done", "2026-06-20T00:00:03.000Z", 2, "completed"),
+ subagent("item-live", "2026-06-20T00:00:04.000Z", 3, "running"),
+ ]);
+ expect(visibleSubagentIds(mixed)).toEqual(["item-done", "item-live"]);
+
+ const finished = present([subagent("item-done", "2026-06-20T00:00:03.000Z", 2, "completed")]);
+ expect(finished.map((entry) => entry.type)).toEqual(["message", "run-fold", "message"]);
+ });
+
it("folds settled V2 run work while keeping the terminal assistant message visible", () => {
const feed = buildThreadFeed([
projected(userMessage(), 0),
diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts
index 927fd59b7dec..4038fa4982c7 100644
--- a/apps/mobile/src/lib/threadActivity.ts
+++ b/apps/mobile/src/lib/threadActivity.ts
@@ -5,6 +5,7 @@ import type {
} from "@t3tools/client-runtime/state/thread-requests";
import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation";
import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display";
+import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime";
import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
import {
turnItemHasDetail,
@@ -1107,7 +1108,10 @@ function deriveThreadFeedRunFolds(
(activity) =>
activity.prominent ||
activity.projectedItem.item.type === "notification" ||
- activity.projectedItem.item.type === "handoff",
+ activity.projectedItem.item.type === "handoff" ||
+ // A child still working outlives its settled launching run;
+ // its group stays visible while any member is live.
+ isLiveSubagentTurnItem(activity.projectedItem.item),
)
),
)
diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
index 173a964d5efc..0f22324e0ca1 100644
--- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
+++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
@@ -2,10 +2,14 @@ import { ThreadId, type WorktreeSetupSnapshot } from "@t3tools/contracts";
import {
CheckpointRef,
NodeId,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ ProviderTurnId,
RunAttemptId,
TurnItemId,
RuntimeRequestId,
type OrchestrationV2ProjectedTurnItem,
+ type OrchestrationV2TurnItem,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import {
@@ -4889,3 +4893,172 @@ describe("failed turn transcript", () => {
},
);
});
+
+describe("live subagents after their parent turn settles", () => {
+ const runId = RunId.make("parent-run");
+ const threadId = ThreadId.make("parent-thread");
+ const providerTurnId = ProviderTurnId.make("parent-provider-turn");
+ const at = (second: number) =>
+ DateTime.makeUnsafe(`2026-10-06T10:00:${String(second).padStart(2, "0")}Z`);
+ const base = (id: string, second: number) => ({
+ id: TurnItemId.make(id),
+ threadId,
+ runId,
+ nodeId: null,
+ providerThreadId: null,
+ providerTurnId,
+ nativeItemRef: null,
+ parentItemId: null,
+ ordinal: second,
+ title: null,
+ startedAt: at(second),
+ completedAt: at(second),
+ updatedAt: at(second),
+ });
+ const projected = (item: OrchestrationV2TurnItem): OrchestrationV2ProjectedTurnItem => ({
+ position: item.ordinal,
+ visibility: "local",
+ sourceThreadId: threadId,
+ sourceItemId: item.id,
+ item,
+ });
+ const child = (
+ id: string,
+ second: number,
+ status: OrchestrationV2TurnItem["status"],
+ origin: "app_owned" | "provider_native" = "app_owned",
+ ): OrchestrationV2TurnItem => ({
+ ...base(id, second),
+ status,
+ completedAt: status === "running" ? null : at(second),
+ type: "subagent",
+ subagentId: NodeId.make(id),
+ origin,
+ driver: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ childThreadId: origin === "app_owned" ? ThreadId.make(`${id}-thread`) : null,
+ prompt: `Inspect ${id}`,
+ result: null,
+ });
+ const rowsFor = (children: ReadonlyArray) =>
+ deriveMessagesTimelineRows({
+ timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({
+ visibleTurnItems: (
+ [
+ {
+ ...base("user", 0),
+ status: "completed",
+ type: "user_message",
+ messageId: MessageId.make("user"),
+ createdBy: "user",
+ creationSource: "web",
+ inputIntent: "turn_start",
+ text: "Delegate the review",
+ attachments: [],
+ },
+ {
+ ...base("reasoning", 1),
+ status: "completed",
+ type: "reasoning",
+ text: "Two children can split this.",
+ streaming: false,
+ },
+ {
+ ...base("command", 2),
+ status: "completed",
+ type: "command_execution",
+ input: "pwd",
+ output: "/repo",
+ exitCode: 0,
+ },
+ ...children,
+ {
+ ...base("assistant", 8),
+ status: "completed",
+ type: "assistant_message",
+ messageId: MessageId.make("assistant"),
+ text: "Both children are running.",
+ streaming: false,
+ },
+ ] satisfies OrchestrationV2TurnItem[]
+ ).map(projected),
+ optimisticMessages: [],
+ }),
+ latestRun: {
+ runId,
+ status: "completed",
+ startedAt: DateTime.formatIso(at(0)),
+ completedAt: DateTime.formatIso(at(8)),
+ },
+ isWorking: false,
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
+ });
+
+ it.each(["app_owned", "provider_native"] as const)(
+ "keeps a running %s child visible while the rest of the settled turn folds",
+ (origin) => {
+ const rows = rowsFor([child("child", 3, "running", origin)]);
+ // The reasoning and command still fold; only the live card stays out.
+ expect(rows.map((row) => row.id)).toEqual([
+ "user",
+ `turn-fold:${runId}`,
+ "child",
+ "assistant",
+ ]);
+ },
+ );
+
+ it.each(["completed", "failed"] as const)("folds a %s child with its settled turn", (status) => {
+ const rows = rowsFor([child("child", 3, status)]);
+ expect(rows.some((row) => row.id === "child")).toBe(false);
+ expect(rows.some((row) => row.kind === "turn-fold")).toBe(true);
+ });
+
+ it("keeps a launch batch visible while any member is still running", () => {
+ const rows = rowsFor([child("done", 3, "completed"), child("live", 4, "running")]);
+ expect(rows.map((row) => row.id)).toEqual(["user", `turn-fold:${runId}`, "done", "assistant"]);
+ expect(rows.find((row) => row.id === "done")).toMatchObject({
+ subagents: [{ item: { id: "done" } }, { item: { id: "live" } }],
+ });
+ });
+
+ it("keeps a running child visible when a steer supersedes its attempt", () => {
+ const attempt = {
+ id: RunAttemptId.make("superseded-attempt"),
+ runId,
+ attemptOrdinal: 1,
+ rootNodeId: NodeId.make("superseded-root"),
+ status: "superseded" as const,
+ };
+ const entries = deriveTimelineEntriesFromVisibleTurnItems({
+ visibleTurnItems: (
+ [
+ {
+ ...base("command", 2),
+ status: "completed",
+ type: "command_execution",
+ input: "pwd",
+ output: "/repo",
+ exitCode: 0,
+ },
+ child("child", 3, "running"),
+ ] satisfies OrchestrationV2TurnItem[]
+ ).map(projected),
+ optimisticMessages: [],
+ }).map((entry) => ({ ...entry, attempt }));
+ const rows = deriveMessagesTimelineRows({
+ timelineEntries: entries,
+ latestRun: {
+ runId,
+ status: "running",
+ startedAt: DateTime.formatIso(at(0)),
+ completedAt: null,
+ },
+ isWorking: false,
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
+ });
+ expect(rows.map((row) => row.id)).toEqual([`attempt-fold:${attempt.id}`, "child"]);
+ });
+});
diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts
index 7360650a3a99..d403e37c3322 100644
--- a/apps/web/src/components/chat/MessagesTimeline.logic.ts
+++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts
@@ -1,5 +1,6 @@
import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
+import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime";
import * as Equal from "effect/Equal";
import { shallow } from "zustand/vanilla/shallow";
import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";
@@ -709,6 +710,7 @@ interface SupersededAttemptFold {
function deriveSupersededAttemptFolds(
timelineEntries: ReadonlyArray,
unfoldedRunIds: ReadonlySet,
+ liveSubagentEntryIds: ReadonlySet,
): ReadonlyMap {
const entriesByAttemptId = new Map();
for (const entry of timelineEntries) {
@@ -719,6 +721,8 @@ function deriveSupersededAttemptFolds(
// A published page stays visible, as it does when its turn folds.
entry.kind === "html-render" ||
timelineEntryIsPersistentResourceCard(entry) ||
+ // A steer supersedes the attempt but leaves its children running.
+ liveSubagentEntryIds.has(entry.id) ||
(entry.kind === "work" && entry.entry.itemType === "system_notice")
) {
continue;
@@ -767,6 +771,43 @@ function deriveUnsettledRunId(
return isSettled ? null : latestRun.runId;
}
+/**
+ * Subagent cards that stay out of their turn's folds. A child can keep working
+ * after its launching turn settles (or its attempt is superseded), and the
+ * waiting footer counts it, so its card stays visible until the child ends.
+ * Adjacent cards from one provider turn render as a single grouped row; the
+ * whole group stays visible while any member is live, so a launch batch never
+ * shows half of its children.
+ */
+function liveSubagentCardEntryIds(entries: ReadonlyArray): ReadonlySet {
+ const visible = new Set();
+ let batch: Array> = [];
+ const flush = () => {
+ if (batch.length === 0) return;
+ if (batch.some((entry) => isLiveSubagentTurnItem(entry.projectedItem.item))) {
+ for (const entry of batch) visible.add(entry.id);
+ }
+ batch = [];
+ };
+ for (const entry of entries) {
+ if (entry.kind !== "event" || entry.projectedItem.item.type !== "subagent") {
+ flush();
+ continue;
+ }
+ const item = entry.projectedItem.item;
+ const previous = batch.at(-1)?.projectedItem.item;
+ if (
+ previous !== undefined &&
+ (previous.runId !== item.runId || previous.providerTurnId !== item.providerTurnId)
+ ) {
+ flush();
+ }
+ batch.push(entry);
+ }
+ flush();
+ return visible;
+}
+
/** `runlessKey` stands in for the run of entries that have none. */
function timelineEntryFoldRunId(entry: TimelineEntry, runlessKey: RunId | null): RunId | null {
if (entry.kind === "work" && entry.entry.itemType === "system_notice") return null;
@@ -874,6 +915,7 @@ function deriveTurnFolds(input: {
unfoldedRunIds: ReadonlySet;
/** Keeps the latest runless response open; V2 work must not reopen imported turns. */
runlessWorkActive: boolean;
+ liveSubagentEntryIds: ReadonlySet;
}): ReadonlyMap {
const interruptedRunIds = new Set();
for (const entry of input.timelineEntries) {
@@ -986,9 +1028,12 @@ function deriveTurnFolds(input: {
if (!isCompaction && index > terminalEntryIndex && !isFoldableTrailingActivity) {
continue;
}
- // Linked resources can outlive their launching run and stay visible
- // after the surrounding work folds.
- if (timelineEntryIsPersistentResourceCard(entry)) {
+ // Linked resources and still-working children can outlive their
+ // launching run and stay visible after the surrounding work folds.
+ if (
+ timelineEntryIsPersistentResourceCard(entry) ||
+ input.liveSubagentEntryIds.has(entry.id)
+ ) {
continue;
}
if (entry.kind === "work" && entry.entry.itemType === "notification") continue;
@@ -1242,9 +1287,11 @@ export function deriveMessagesTimelineRows(input: {
const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(timelineEntries);
const unsettledRunId = deriveUnsettledRunId(input.latestRun ?? null, input.runningRunId ?? null);
const failedRunIds = failedTimelineRunIds(timelineEntries, input.latestRun ?? null);
+ const liveSubagentEntryIds = liveSubagentCardEntryIds(timelineEntries);
const supersededFoldsByAnchorEntryId = deriveSupersededAttemptFolds(
timelineEntries,
failedRunIds,
+ liveSubagentEntryIds,
);
const activeVisualResponseRunIds = deriveActiveVisualResponseRunIds({
timelineEntries: timelineEntries,
@@ -1258,6 +1305,7 @@ export function deriveMessagesTimelineRows(input: {
latestRun: input.latestRun ?? null,
unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]),
runlessWorkActive,
+ liveSubagentEntryIds,
});
const collapsedEntryIds = new Set();
for (const fold of foldsByAnchorEntryId.values()) {
diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts
index da222ecfef3d..2b1fc96e54ac 100644
--- a/packages/client-runtime/src/state/subagentRuntime.ts
+++ b/packages/client-runtime/src/state/subagentRuntime.ts
@@ -3,7 +3,7 @@
* web agent rows render.
*/
import * as DateTime from "effect/DateTime";
-import type { OrchestrationV2Subagent } from "@t3tools/contracts";
+import type { OrchestrationV2Subagent, OrchestrationV2TurnItem } from "@t3tools/contracts";
import { isOrchestrationV2WorkActive } from "@t3tools/contracts";
export type RuntimeSubagentStatus =
@@ -91,6 +91,15 @@ export function isActiveSubagentStatus(status: RuntimeSubagentStatus): boolean {
return isOrchestrationV2WorkActive(status);
}
+/**
+ * A subagent card whose child is still working. The card outlives its
+ * launching turn: once that turn settles, the waiting footer counts the child,
+ * so the card stays out of the turn's fold until the child finishes.
+ */
+export function isLiveSubagentTurnItem(item: OrchestrationV2TurnItem): boolean {
+ return item.type === "subagent" && isOrchestrationV2WorkActive(item.status);
+}
+
/**
* Projects orchestration-v2 subagent entities into the runtime shape the web
* agent rows render.
From 4d976d1fc233d3aa971fa7dcbf2e7316bfa3ac72 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Wed, 7 Oct 2026 09:44:08 -0700
Subject: [PATCH 03/13] feat: MCP apps render and run inline in threads
(#16236)
Co-authored-by: Claude Opus 5.5 (1M context)
---
apps/mobile/src/Stack.tsx | 10 +
.../threads/McpAppFullscreenScreen.tsx | 168 +++++
.../src/features/threads/McpAppWebView.tsx | 611 ++++++++++++++++++
.../src/features/threads/ThreadFeed.tsx | 16 +
apps/mobile/src/lib/attachmentDownload.ts | 23 +
apps/mobile/src/lib/openExternalUrl.ts | 1 +
apps/mobile/src/lib/threadActivity.test.ts | 32 +
apps/mobile/src/lib/threadActivity.ts | 47 +-
apps/mobile/src/state/mcpApps.ts | 5 +
.../record-codex-app-server-replay-fixture.ts | 1 +
apps/server/src/attachmentStore.test.ts | 20 +
apps/server/src/attachmentStore.ts | 10 +-
apps/server/src/auth/RpcAuthorization.ts | 5 +
.../src/mcpApps/McpAppModelContext.test.ts | 68 ++
apps/server/src/mcpApps/McpAppModelContext.ts | 105 +++
.../server/src/mcpApps/McpAppRequests.test.ts | 284 ++++++++
apps/server/src/mcpApps/McpAppRequests.ts | 240 +++++++
apps/server/src/mcpApps/McpAppSnapshot.ts | 108 ++++
.../src/observability/RpcInstrumentation.ts | 4 +
.../Adapters/CodexAdapterV2.test.ts | 289 +++++++++
.../Adapters/CodexAdapterV2.ts | 323 ++++++++-
.../src/orchestration-v2/ProjectionStore.ts | 6 +-
.../src/orchestration-v2/ProviderAdapter.ts | 45 ++
.../RunExecutionService.test.ts | 99 +++
.../orchestration-v2/RunExecutionService.ts | 21 +
.../src/orchestration-v2/runtimeLayer.ts | 15 +
.../testkit/ProviderReplayHarness.ts | 2 +
.../codex_transcript.ndjson | 2 +-
.../message_steering/codex_transcript.ndjson | 2 +-
.../multi_turn/codex_transcript.ndjson | 2 +-
.../plan_questions/codex_transcript.ndjson | 2 +-
.../proposed_plan/codex_transcript.ndjson | 2 +-
.../codex_transcript.ndjson | 4 +-
.../queued_turn/codex_transcript.ndjson | 2 +-
.../fixtures/simple/codex_transcript.ndjson | 2 +-
.../fixtures/subagent/codex_transcript.ndjson | 2 +-
.../subagent_continue/codex_transcript.ndjson | 2 +-
.../subagent_v2/codex_transcript.ndjson | 2 +-
.../codex_transcript.ndjson | 2 +-
.../codex_transcript.ndjson | 2 +-
.../codex_transcript.ndjson | 2 +-
.../codex_transcript.ndjson | 2 +-
.../codex_transcript.ndjson | 2 +-
.../codex_transcript.ndjson | 2 +-
.../codex_transcript.ndjson | 2 +-
.../codex_transcript.ndjson | 2 +-
.../codex_transcript.ndjson | 2 +-
.../thread_rollback/codex_transcript.ndjson | 2 +-
.../codex_transcript.ndjson | 4 +-
.../codex_transcript.ndjson | 2 +-
.../todo_list/codex_transcript.ndjson | 2 +-
.../codex_transcript.ndjson | 2 +-
.../codex_transcript.ndjson | 2 +-
.../codex_transcript.ndjson | 2 +-
.../turn_interrupt/codex_transcript.ndjson | 2 +-
.../codex_transcript.ndjson | 2 +-
.../web_search/codex_transcript.ndjson | 2 +-
apps/server/src/persistence/Migrations.ts | 2 +
.../Migrations/055_OrchestrationV2.test.ts | 4 +-
.../Migrations/059_McpAppModelContext.ts | 21 +
.../reconcileV2PreviewMigration.test.ts | 2 +
apps/server/src/ws.ts | 6 +
apps/web/src/components/ChatView.tsx | 35 +-
apps/web/src/components/chat/McpAppFrame.tsx | 550 ++++++++++++++++
.../components/chat/MessagesTimeline.logic.ts | 38 +-
.../src/components/chat/MessagesTimeline.tsx | 71 +-
apps/web/src/confirmDialog.test.ts | 4 +
apps/web/src/confirmDialog.ts | 5 +
apps/web/src/session-logic.test.ts | 19 +
apps/web/src/session-logic.ts | 37 +-
apps/web/src/state/mcpApps.ts | 5 +
docs/user/html-renders.md | 8 +
packages/client-runtime/package.json | 8 +
.../client-runtime/src/mcpApps/host.test.ts | 419 ++++++++++++
packages/client-runtime/src/mcpApps/host.ts | 576 +++++++++++++++++
packages/client-runtime/src/state/mcpApps.ts | 33 +
packages/contracts/src/index.ts | 1 +
packages/contracts/src/mcpApps.ts | 114 ++++
packages/contracts/src/rpc.ts | 43 ++
packages/shared/package.json | 4 +
packages/shared/src/mcpApp.test.ts | 150 +++++
packages/shared/src/mcpApp.ts | 236 +++++++
packages/shared/src/toolOutput.ts | 32 +-
83 files changed, 4990 insertions(+), 55 deletions(-)
create mode 100644 apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx
create mode 100644 apps/mobile/src/features/threads/McpAppWebView.tsx
create mode 100644 apps/mobile/src/state/mcpApps.ts
create mode 100644 apps/server/src/mcpApps/McpAppModelContext.test.ts
create mode 100644 apps/server/src/mcpApps/McpAppModelContext.ts
create mode 100644 apps/server/src/mcpApps/McpAppRequests.test.ts
create mode 100644 apps/server/src/mcpApps/McpAppRequests.ts
create mode 100644 apps/server/src/mcpApps/McpAppSnapshot.ts
create mode 100644 apps/server/src/persistence/Migrations/059_McpAppModelContext.ts
create mode 100644 apps/web/src/components/chat/McpAppFrame.tsx
create mode 100644 apps/web/src/state/mcpApps.ts
create mode 100644 packages/client-runtime/src/mcpApps/host.test.ts
create mode 100644 packages/client-runtime/src/mcpApps/host.ts
create mode 100644 packages/client-runtime/src/state/mcpApps.ts
create mode 100644 packages/contracts/src/mcpApps.ts
create mode 100644 packages/shared/src/mcpApp.test.ts
create mode 100644 packages/shared/src/mcpApp.ts
diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx
index 8dd0bea2f550..14fbc8b82f51 100644
--- a/apps/mobile/src/Stack.tsx
+++ b/apps/mobile/src/Stack.tsx
@@ -51,6 +51,7 @@ import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet";
import { ThreadAgentsSheet } from "./features/threads/ThreadAgentsSheet";
import { ThreadQueueSheet } from "./features/threads/ThreadQueueControl";
import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen";
+import { McpAppFullscreenScreen } from "./features/threads/McpAppFullscreenScreen";
import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen";
import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen";
import { HomeRouteScreen } from "./features/home/HomeRouteScreen";
@@ -747,6 +748,15 @@ const RootStackConfig = createNativeStackNavigator({
linking: `${THREAD_LINKING_PREFIX}/files/:path*`,
options: SOLID_HEADER_OPTIONS,
}),
+ ThreadMcpApp: createNativeStackScreen({
+ screen: McpAppFullscreenScreen,
+ linking: `${THREAD_LINKING_PREFIX}/apps/:itemId`,
+ options: {
+ presentation: "fullScreenModal",
+ headerShown: false,
+ gestureEnabled: false,
+ },
+ }),
ThreadAttachment: createNativeStackScreen({
screen: AttachmentFileScreen,
linking: `${THREAD_LINKING_PREFIX}/attachments/:attachmentId`,
diff --git a/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx b/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx
new file mode 100644
index 000000000000..0a09941fe78d
--- /dev/null
+++ b/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx
@@ -0,0 +1,168 @@
+import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
+import { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts";
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
+import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail";
+import { mcpAppFromToolItem } from "@t3tools/shared/toolOutput";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { Pressable, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { AppText as Text } from "../../components/AppText";
+import { SymbolView } from "../../components/AppSymbol";
+import { orchestrationEnvironment } from "../../state/orchestration";
+import { scopeThreadRef } from "@t3tools/client-runtime/environment";
+import { useThreadShell, useThreadShells } from "../../state/entities";
+import { useEnvironmentQuery } from "../../state/query";
+import { ThreadMcpApp } from "./McpAppWebView";
+
+type McpAppFullscreenScreenProps = StaticScreenProps<{
+ readonly environmentId: string;
+ readonly threadId: string;
+ readonly conversationThreadId?: string;
+ readonly itemId: string;
+ readonly revision?: string;
+}>;
+
+/**
+ * The thread an app's messages go to. A link only names it, so it counts only
+ * when it is the app's own thread or a fork of it; anything else falls back to
+ * the app's thread.
+ */
+function useConversationThreadId(
+ environmentId: EnvironmentId,
+ threadId: ThreadId,
+ requested: string | undefined,
+): ThreadId {
+ const shells = useThreadShells();
+ return useMemo(
+ () =>
+ requested !== undefined && descendsFrom(shells, environmentId, requested, threadId)
+ ? ThreadId.make(requested)
+ : threadId,
+ [shells, environmentId, threadId, requested],
+ );
+}
+
+/** True when `threadId` is `ancestorId` or one of its forks, at any depth. */
+function descendsFrom(
+ shells: ReadonlyArray,
+ environmentId: EnvironmentId,
+ threadId: string,
+ ancestorId: ThreadId,
+): boolean {
+ const parents = new Map(
+ shells
+ .filter((shell) => shell.environmentId === environmentId)
+ // Only forks continue a conversation; a subagent is its own.
+ .filter((shell) => shell.lineage.relationshipToParent === "fork")
+ .map((shell) => [shell.id as string, shell.lineage.parentThreadId]),
+ );
+ let current: string | null | undefined = threadId;
+ for (let depth = 0; depth < 64 && current != null; depth += 1) {
+ if (current === ancestorId) return true;
+ current = parents.get(current);
+ }
+ return false;
+}
+
+/**
+ * An MCP App full screen (`ui/request-display-mode`), in a modal over the
+ * thread. It is its own view of the app; closing it returns to the row. The
+ * app is read from its stored tool call, never from the route, so a link can
+ * only open an app that call really produced.
+ */
+export function McpAppFullscreenScreen({ route }: McpAppFullscreenScreenProps) {
+ const navigation = useNavigation();
+ const insets = useSafeAreaInsets();
+ // A plain goBack pops whatever is on top, so it only runs while this modal
+ // is: an exit that lands while another screen covers it (after a teardown
+ // wait) waits for the modal to be on top again, rather than popping that
+ // screen or being lost.
+ const focused = useIsFocused();
+ const [exitRequested, setExitRequested] = useState(false);
+ const onClose = useCallback(() => {
+ if (navigation.isFocused()) navigation.goBack();
+ else setExitRequested(true);
+ }, [navigation]);
+ const [size, setSize] = useState({ width: 0, height: 0 });
+ const params = route.params;
+ const environmentId = EnvironmentId.make(params.environmentId);
+ const threadId = ThreadId.make(params.threadId);
+ const itemId = TurnItemId.make(params.itemId);
+ const conversationThreadId = useConversationThreadId(
+ environmentId,
+ threadId,
+ params.conversationThreadId,
+ );
+ const detail = useEnvironmentQuery(
+ orchestrationEnvironment.turnItem({
+ environmentId,
+ input: {
+ threadId,
+ itemId,
+ ...(params.revision === undefined ? {} : { revision: params.revision }),
+ },
+ }),
+ );
+ const item = detail.data?.item;
+ // One reference per app: the item is a new object on every refetch (a
+ // reconnect, for one), and a new reference would rebuild the app's host.
+ const derived = item?.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined;
+ const [app, setApp] = useState(derived);
+ if (derived?.attachmentId !== app?.attachmentId) setApp(derived);
+ // The approval or question the agent waits on renders on the thread this
+ // modal covers, so the app steps aside for it.
+ const conversation = useThreadShell(scopeThreadRef(environmentId, conversationThreadId));
+ const awaitingUser =
+ conversation?.hasPendingApprovals === true || conversation?.hasPendingUserInput === true;
+ useEffect(() => {
+ if ((awaitingUser || exitRequested) && focused) navigation.goBack();
+ }, [awaitingUser, exitRequested, focused, navigation]);
+
+ return (
+
+
+
+ {app?.server ?? "App"}
+
+
+
+
+
+
+ setSize({
+ width: Math.round(event.nativeEvent.layout.width),
+ height: Math.round(event.nativeEvent.layout.height - insets.bottom),
+ })
+ }
+ >
+ {app === undefined ? (
+ detail.data === undefined ? null : (
+ This app cannot be shown.
+ )
+ ) : size.width > 0 ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/McpAppWebView.tsx b/apps/mobile/src/features/threads/McpAppWebView.tsx
new file mode 100644
index 000000000000..119f5f946817
--- /dev/null
+++ b/apps/mobile/src/features/threads/McpAppWebView.tsx
@@ -0,0 +1,611 @@
+import type { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts";
+import {
+ makeMcpAppHost,
+ McpAppHostRefusal,
+ mcpAppStyleVariables,
+ mcpResourceBytes,
+ type McpAppCallToolResult,
+ type McpAppHost,
+ type McpAppHostContext,
+} from "@t3tools/client-runtime/mcp-apps";
+import { scopeThreadRef } from "@t3tools/client-runtime/environment";
+import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
+import { CommandId, MessageId } from "@t3tools/contracts";
+import {
+ mcpAppAllowAttribute,
+ mcpAppFileName,
+ mcpAppReferencesEqual,
+ type McpAppReference,
+} from "@t3tools/shared/mcpApp";
+import * as Predicate from "effect/Predicate";
+import Constants from "expo-constants";
+import { useNavigation } from "@react-navigation/native";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { WebView, type WebViewMessageEvent } from "react-native-webview";
+
+import { AppText as Text } from "../../components/AppText";
+import { shareGeneratedAttachment } from "../../lib/attachmentDownload";
+import { mobileHtmlRenderTheme } from "../../lib/htmlRenderTheme";
+import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
+import { uuidv4 } from "../../lib/uuid";
+import { useAssetUrlState } from "../../state/assets";
+import { useThreadShell } from "../../state/entities";
+import { mcpAppEnvironment } from "../../state/mcpApps";
+import { orchestrationEnvironment } from "../../state/orchestration";
+import { useEnvironmentQuery } from "../../state/query";
+import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
+
+/** The feed reserves a fixed box for an app; a taller app scrolls inside it. */
+const MCP_APP_ROW_HEIGHT = 420;
+const ROW_BOTTOM_MARGIN = 8;
+
+export function mcpAppRowHeight() {
+ return MCP_APP_ROW_HEIGHT + ROW_BOTTOM_MARGIN;
+}
+
+// The WebView loads a tiny outer page that hosts the app in a real
+// opaque-origin iframe, as web does, so the app's `window.parent` and the
+// `event.source` of host replies are a real window, which the MCP Apps SDK
+// requires. The outer page only relays: app → React Native, and host
+// replies (injected as `__t3McpAppReceive(...)`) → app.
+//
+// Android exposes `ReactNativeWebView` to every frame, so a frame nested in
+// the app could post to React Native directly. The outer page therefore wraps
+// what it relays with a secret only it holds, and React Native drops anything
+// else. The bridge belongs to the captured document: once the app frame loads
+// a second time (it navigated itself), the outer page stops relaying and says
+// so, rather than letting a page T3 never served pose as the app. This is not
+// a confidentiality boundary: a frame can always navigate itself, so the app
+// could carry anything it read out in a URL either way.
+function outerDocument(src: string, allow: string, secret: string) {
+ const attribute = (value: string) =>
+ value.replace(/&/g, "&").replace(/"/g, """).replace(/
+
+
+`;
+}
+
+const commandFailure = (result: {
+ readonly cause: Parameters[0]["cause"];
+}) => {
+ const error = squashAtomCommandFailure(result);
+ return new McpAppHostRefusal(
+ error instanceof Error && error.message.trim() !== "" ? error.message : "Request failed.",
+ );
+};
+
+const confirm = (title: string, message: string, action: string) =>
+ new Promise((resolve) => {
+ Alert.alert(
+ title,
+ message,
+ [
+ { text: "Cancel", style: "cancel", onPress: () => resolve(false) },
+ { text: action, onPress: () => resolve(true) },
+ ],
+ // Android dismisses an open alert when the next one shows; a dismissed
+ // request is declined rather than left holding an in-flight slot.
+ { cancelable: true, onDismiss: () => resolve(false) },
+ );
+ });
+
+/** Largest file an app may hand the user through `ui/download-file`. */
+const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
+
+/**
+ * A captured MCP App, hosted in a WebView over the MCP Apps bridge: inline as
+ * a fixed row of the thread feed, or full screen in its own modal screen. A
+ * WebView cannot move between the two without reloading, so each is its own
+ * view of the app: the inline one is torn down before full screen opens, and
+ * comes back when it closes.
+ */
+export function ThreadMcpApp(props: {
+ readonly environmentId: EnvironmentId;
+ /** The thread that produced the app, which its requests run against. */
+ readonly threadId: ThreadId;
+ /** The thread on screen, which approved app messages are sent to. */
+ readonly conversationThreadId: ThreadId;
+ readonly itemId: TurnItemId;
+ readonly revision: string;
+ readonly app: McpAppReference;
+ readonly width: number;
+ /** Full screen fills its screen; inline is the feed's fixed row. */
+ readonly displayMode?: "inline" | "fullscreen";
+ /** Full screen only: leaves it, back to the inline row. */
+ readonly onExitFullscreen?: () => void;
+ /** Full screen only: the screen's height, which the app is told it fills. */
+ readonly height?: number;
+}) {
+ // One reference per app: a new object with the same content (a refetch, a
+ // rerender) must not rebuild the host of a document that is already live.
+ const [app, setApp] = useState(props.app);
+ if (!mcpAppReferencesEqual(app, props.app)) setApp(props.app);
+ const fullscreen = props.displayMode === "fullscreen";
+ const navigation = useNavigation();
+ const insets = useSafeAreaInsets();
+ // The app asked to be closed; the row falls back to a note that brings it back.
+ const [closed, setClosed] = useState(false);
+ // Full screen is a separate view of the app, so the inline one steps aside
+ // (and is torn down) while it is open, and comes back when the thread is
+ // shown again.
+ const [presentedFullscreen, setPresentedFullscreen] = useState(false);
+ // Counts the documents this row has shown on purpose (back from full screen,
+ // or reopened after closing); each gets its own view and host.
+ const [documentKey, setDocumentKeyState] = useState(0);
+
+ const { themeId, themeAppearance, themeVariables, systemColorsActive } =
+ useAppearancePreferences();
+ const theme = useMemo(
+ () =>
+ mobileHtmlRenderTheme({
+ themeId,
+ appearance: themeAppearance,
+ variables: themeVariables,
+ systemColors: systemColorsActive,
+ platform: Platform.OS,
+ }),
+ [themeId, themeAppearance, themeVariables, systemColorsActive],
+ );
+ const resource = useMemo(
+ () => ({
+ _tag: "attachment" as const,
+ attachmentId: app.attachmentId,
+ fileName: mcpAppFileName(app),
+ mimeType: "text/html",
+ disposition: "inline" as const,
+ }),
+ [app],
+ );
+ const asset = useAssetUrlState(props.environmentId, resource);
+ // The view keeps its first URL: a re-minted one would reload the app.
+ const [uri, setUri] = useState(null);
+ if (uri === null && asset._tag === "Success") setUri(asset.url);
+ const [loaded, setLoaded] = useState(false);
+ const [navigatedAway, setNavigatedAway] = useState(false);
+ // Bumped when the OS reclaims the web process: a fresh view, document and
+ // host, so the reloaded app is initialized and replayed again. Once only, so
+ // an app that keeps crashing its process is not reloaded forever.
+ const [generation, setGeneration] = useState(0);
+ const [crashed, setCrashed] = useState(false);
+ const restart = () => {
+ if (generation > 0) {
+ setCrashed(true);
+ return;
+ }
+ setLoaded(false);
+ // The first URL's token may have expired by now; the asset query keeps
+ // a refreshed one, which this new view can load.
+ if (asset._tag === "Success") setUri(asset.url);
+ setGeneration(1);
+ };
+ // Minted per view, so only this view's outer page can speak for its app.
+ const [secret] = useState(uuidv4);
+ // A new document starts loading, is not yet one that navigated away, and
+ // loads the asset query's current URL: the first one's token may be gone.
+ const setDocumentKey = (next: (value: number) => number) => {
+ setLoaded(false);
+ setNavigatedAway(false);
+ if (asset._tag === "Success") setUri(asset.url);
+ setDocumentKeyState(next);
+ };
+ // Back from full screen: the thread shows a fresh inline view of the app.
+ // The listener reads the latest helper, whose URL comes from this render.
+ const openNewDocument = useRef(setDocumentKey);
+ useEffect(() => {
+ openNewDocument.current = setDocumentKey;
+ });
+ useEffect(() => {
+ if (!presentedFullscreen) return;
+ return navigation.addListener("focus", () => {
+ openNewDocument.current((value) => value + 1);
+ setPresentedFullscreen(false);
+ });
+ }, [navigation, presentedFullscreen]);
+
+ // The feed omits tool input and output; the app needs both.
+ const detail = useEnvironmentQuery(
+ orchestrationEnvironment.turnItem({
+ environmentId: props.environmentId,
+ input: { threadId: props.threadId, itemId: props.itemId, revision: props.revision },
+ }),
+ );
+ const storedItem = detail.data?.item;
+ const toolCall = useMemo(() => {
+ if (storedItem?.type !== "dynamic_tool") return undefined;
+ const output = storedItem.output as { readonly result?: unknown } | undefined;
+ return {
+ arguments: storedItem.input,
+ result: output?.result as McpAppCallToolResult | undefined,
+ };
+ }, [storedItem]);
+
+ const callTool = useAtomCommand(mcpAppEnvironment.callTool, { reportFailure: false });
+ const toolInfo = useAtomCommand(mcpAppEnvironment.toolInfo, { reportFailure: false });
+ const readResource = useAtomCommand(mcpAppEnvironment.readResource, { reportFailure: false });
+ const updateModelContext = useAtomCommand(mcpAppEnvironment.updateModelContext, {
+ reportFailure: false,
+ });
+ // The tool's definition, which the app receives as `toolInfo` at initialize.
+ const [toolDefinition, setToolDefinition] = useState(undefined);
+ // Read by the host on every message, so it always sees current values
+ // without being rebuilt (which would drop the app's session).
+ // The approval or question the agent waits on renders on this thread, and a
+ // full-screen app would cover it.
+ const conversation = useThreadShell(
+ scopeThreadRef(props.environmentId, props.conversationThreadId),
+ );
+ const awaitingUser =
+ conversation?.hasPendingApprovals === true || conversation?.hasPendingUserInput === true;
+ const live = {
+ awaitingUser,
+ theme,
+ props,
+ callTool,
+ toolInfo,
+ readResource,
+ updateModelContext,
+ insets,
+ toolDefinition,
+ navigation,
+ };
+ const latest = useRef(live);
+ useEffect(() => {
+ latest.current = live;
+ });
+ useEffect(() => {
+ let cancelled = false;
+ void latest.current
+ .toolInfo({
+ environmentId: props.environmentId,
+ input: { threadId: props.threadId, itemId: props.itemId, name: app.tool },
+ })
+ .then((info) => {
+ if (!cancelled && info._tag === "Success" && info.value.tool !== undefined) {
+ setToolDefinition(info.value.tool);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [props.environmentId, props.threadId, props.itemId, app.tool]);
+ const webView = useRef>(null);
+ const hostRef = useRef(null);
+
+ // One host per loaded document.
+ useEffect(() => {
+ if (uri === null) return;
+ const scope = () => {
+ const { environmentId, threadId, itemId } = latest.current.props;
+ return { environmentId, input: { threadId, itemId } };
+ };
+ const hostContext = (): McpAppHostContext => {
+ const current = latest.current;
+ const isFullscreen = current.props.displayMode === "fullscreen";
+ return {
+ theme: current.theme.appearance,
+ styles: { variables: mcpAppStyleVariables(current.theme.variables) },
+ displayMode: isFullscreen ? "fullscreen" : "inline",
+ availableDisplayModes: ["inline", "fullscreen"],
+ // Both are fixed boxes, so the app is told its exact size.
+ containerDimensions: {
+ width: current.props.width,
+ height: isFullscreen ? (current.props.height ?? MCP_APP_ROW_HEIGHT) : MCP_APP_ROW_HEIGHT,
+ },
+ platform: "mobile",
+ locale: Intl.DateTimeFormat().resolvedOptions().locale,
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ userAgent: `t3-code/${Constants.expoConfig?.version ?? "0.0.0"}`,
+ deviceCapabilities: { touch: true, hover: false },
+ // The full-screen modal keeps the top and bottom insets for its
+ // header and the home indicator; only the sides reach the app.
+ safeAreaInsets: isFullscreen
+ ? { top: 0, right: current.insets.right, bottom: 0, left: current.insets.left }
+ : { top: 0, right: 0, bottom: 0, left: 0 },
+ ...(current.toolDefinition === undefined
+ ? {}
+ : { toolInfo: { tool: current.toolDefinition } }),
+ };
+ };
+ const next = makeMcpAppHost({
+ app,
+ hostVersion: Constants.expoConfig?.version ?? "0.0.0",
+ post: (message) =>
+ webView.current?.injectJavaScript(
+ `window.__t3McpAppReceive&&window.__t3McpAppReceive(${JSON.stringify(message)});true;`,
+ ),
+ hostContext,
+ callTool: async ({ name, arguments: args }) => {
+ const { environmentId, input } = scope();
+ const info = await latest.current.toolInfo({ environmentId, input: { ...input, name } });
+ if (info._tag !== "Success") throw commandFailure(info);
+ if (!info.value.callable) throw new McpAppHostRefusal("This app cannot call that tool.");
+ if (
+ !info.value.readOnly &&
+ !(await confirm(
+ `Allow ${app.server} to run ${info.value.title ?? name}?`,
+ JSON.stringify(args, null, 2),
+ "Allow",
+ ))
+ ) {
+ throw new McpAppHostRefusal("Declined by the user.");
+ }
+ const result = await latest.current.callTool({
+ environmentId,
+ input: { ...input, name, arguments: args },
+ });
+ if (result._tag !== "Success") throw commandFailure(result);
+ return result.value;
+ },
+ readResource: async ({ uri: resourceUri }) => {
+ const { environmentId, input } = scope();
+ const result = await latest.current.readResource({
+ environmentId,
+ input: { ...input, uri: resourceUri },
+ });
+ if (result._tag !== "Success") throw commandFailure(result);
+ return result.value;
+ },
+ openLink: async (url) => {
+ // A WebView cannot tell whether the reader just tapped the app, so it
+ // asks, rather than letting an app leave T3 on a timer.
+ if (!(await confirm(`Open a link from ${app.server}?`, url, "Open"))) {
+ throw new McpAppHostRefusal("Declined by the user.");
+ }
+ if (!(await tryOpenExternalUrl(url, "mcp-app"))) {
+ throw new McpAppHostRefusal("The link could not be opened.");
+ }
+ },
+ sendMessage: async (text) => {
+ if (!(await confirm(`Send this message from ${app.server}?`, text, "Send"))) {
+ throw new McpAppHostRefusal("Declined by the user.");
+ }
+ // Through the outbox like a typed message, so it survives a dropped
+ // connection; the thread's own settings fill in when it sends.
+ await enqueueThreadOutboxMessage({
+ environmentId: latest.current.props.environmentId,
+ threadId: latest.current.props.conversationThreadId,
+ messageId: MessageId.make(uuidv4()),
+ commandId: CommandId.make(uuidv4()),
+ text,
+ attachments: [],
+ dispatchMode: "queue",
+ createdAt: new Date().toISOString(),
+ });
+ },
+ updateModelContext: async (context) => {
+ const { environmentId, input } = scope();
+ const result = await latest.current.updateModelContext({
+ environmentId,
+ input: {
+ ...input,
+ ...context,
+ conversationThreadId: latest.current.props.conversationThreadId,
+ },
+ });
+ if (result._tag !== "Success") throw commandFailure(result);
+ },
+ requestDisplayMode: async (mode) => {
+ const current = latest.current.props;
+ // Full screen would cover the approval or question the agent waits
+ // on, or a screen the user moved to.
+ if (
+ mode === "fullscreen" &&
+ (latest.current.awaitingUser || !latest.current.navigation.isFocused())
+ ) {
+ return current.displayMode ?? "inline";
+ }
+ if (mode === "fullscreen" && current.displayMode !== "fullscreen") {
+ // The inline view is torn down by the switch (this row unmounts
+ // while the modal covers it); the modal opens a fresh view.
+ // The inline view steps aside for the modal; it gets its teardown first.
+ await hostRef.current?.teardown();
+ // The wait may have let an approval arrive, or the user moved to
+ // another screen; either way the view reloads inline.
+ if (latest.current.awaitingUser || !latest.current.navigation.isFocused()) {
+ openNewDocument.current((value) => value + 1);
+ return "inline";
+ }
+ setPresentedFullscreen(true);
+ latest.current.navigation.navigate("ThreadMcpApp", {
+ environmentId: String(current.environmentId),
+ threadId: String(current.threadId),
+ conversationThreadId: String(current.conversationThreadId),
+ itemId: String(current.itemId),
+ revision: current.revision,
+ });
+ return "fullscreen";
+ }
+ if (mode === "inline" && current.displayMode === "fullscreen") {
+ await hostRef.current?.teardown();
+ current.onExitFullscreen?.();
+ return "inline";
+ }
+ return current.displayMode ?? "inline";
+ },
+ downloadFile: async (files) => {
+ const names = files.map((file) => file.name).join(", ");
+ if (!(await confirm(`Save a file from ${app.server}?`, names, "Save"))) {
+ throw new McpAppHostRefusal("Declined by the user.");
+ }
+ for (const file of files) {
+ // A linked file is read from the app's own server, like its other reads.
+ let bytes: Uint8Array | undefined;
+ let mimeType = file.mimeType ?? "application/octet-stream";
+ if (file._tag === "embedded") {
+ bytes = file.bytes;
+ } else {
+ const { environmentId, input } = scope();
+ const read = await latest.current.readResource({
+ environmentId,
+ input: { ...input, uri: file.uri },
+ });
+ if (read._tag !== "Success") throw commandFailure(read);
+ const content = read.value.contents[0];
+ bytes = mcpResourceBytes(content);
+ const declared = (content as { readonly mimeType?: unknown } | undefined)?.mimeType;
+ if (typeof declared === "string") mimeType = declared;
+ }
+ if (bytes === undefined) throw new McpAppHostRefusal(`${file.name} has no contents.`);
+ if (bytes.byteLength > MAX_DOWNLOAD_BYTES) {
+ throw new McpAppHostRefusal(`${file.name} is too large to save.`);
+ }
+ const shared = await shareGeneratedAttachment({
+ bytes,
+ attachment: { name: file.name, mimeType },
+ signal: new AbortController().signal,
+ });
+ if (!shared) throw new McpAppHostRefusal("Sharing is not available on this device.");
+ }
+ },
+ onRequestTeardown: () => {
+ const current = latest.current.props;
+ if (current.displayMode === "fullscreen") {
+ void hostRef.current?.teardown().then(() => current.onExitFullscreen?.());
+ } else {
+ void hostRef.current?.teardown().then(() => setClosed(true));
+ }
+ },
+ // Both modes are fixed boxes, so the app's own height only decides
+ // whether it scrolls inside one.
+ onSizeChanged: () => undefined,
+ });
+ hostRef.current = next;
+ return () => {
+ // An unmount cannot wait: the request goes out before the view does.
+ void next.teardown();
+ hostRef.current = null;
+ };
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A restarted view needs a new host.
+ }, [uri, app, generation, documentKey]);
+
+ // The host reads the context through `latest`; these only say when to resend.
+ useEffect(() => {
+ hostRef.current?.updateHostContext();
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Context changes trigger a resend.
+ }, [theme, props.width, props.height, insets, toolDefinition]);
+ // A new document gets a new host, which needs the call again.
+ useEffect(() => {
+ if (toolCall !== undefined) hostRef.current?.setToolCall(toolCall);
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Each new document needs the call.
+ }, [toolCall, uri, generation, documentKey]);
+
+ const source = useMemo(
+ () =>
+ uri === null
+ ? null
+ : { html: outerDocument(uri, mcpAppAllowAttribute(app.permissions), secret) },
+ [uri, app.permissions, secret],
+ );
+
+ if (presentedFullscreen) {
+ return (
+
+
+ The {app.server} app is open full screen
+
+
+ );
+ }
+
+ if (closed) {
+ return (
+
+ The {app.server} app was closed
+ {
+ setDocumentKey((value) => value + 1);
+ setClosed(false);
+ }}
+ >
+ Show app
+
+
+ );
+ }
+
+ return (
+
+ {navigatedAway ? (
+
+
+ The {app.server} app left its page and was stopped
+
+
+ ) : uri !== null && !crashed ? (
+
+ key={`${documentKey}:${generation}`}
+ ref={webView}
+ onContentProcessDidTerminate={restart}
+ onRenderProcessGone={restart}
+ source={source!}
+ accessibilityLabel={`${app.server} app`}
+ style={{ flex: 1, backgroundColor: "transparent" }}
+ nestedScrollEnabled
+ originWhitelist={["*"]}
+ // Only the outer page loads at the top; the app opens links through
+ // the bridge. Android reports every navigation as top-frame, frames
+ // the app nests included, so it has no gate to apply: there the
+ // outer page never navigates, and the app frame's sandbox already
+ // forbids top navigation and popups.
+ onShouldStartLoadWithRequest={(request) =>
+ Platform.OS === "android" ||
+ request.isTopFrame === false ||
+ request.url === "about:blank"
+ }
+ setSupportMultipleWindows={false}
+ onLoadEnd={() => setLoaded(true)}
+ // The outer page is inline HTML, so these report a broken view, not
+ // an app page; the row shows the failure instead of a blank box.
+ onError={() => setCrashed(true)}
+ onHttpError={() => setCrashed(true)}
+ onMessage={(event: WebViewMessageEvent) => {
+ let envelope: unknown;
+ try {
+ envelope = JSON.parse(event.nativeEvent.data);
+ } catch {
+ return; // Not JSON: not a bridge message.
+ }
+ if (!Predicate.isObject(envelope) || envelope.secret !== secret) return;
+ const message = envelope.message;
+ if (Predicate.isObject(message) && message.t3 === "navigated") {
+ hostRef.current?.dispose();
+ setNavigatedAway(true);
+ return;
+ }
+ hostRef.current?.receive(message);
+ }}
+ />
+ ) : asset._tag === "Failure" || crashed ? (
+
+ Unable to load the {app.server} app
+
+ ) : null}
+ {uri !== null && !loaded && !crashed && !navigatedAway ? (
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx
index 4d81c6f07063..c18874b1cb03 100644
--- a/apps/mobile/src/features/threads/ThreadFeed.tsx
+++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
@@ -188,6 +188,7 @@ import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size";
import { htmlRenderFrameHeight } from "@t3tools/shared/htmlRender";
import { htmlRenderRowHeight, ThreadHtmlRender } from "./HtmlRenderWebView";
+import { mcpAppRowHeight, ThreadMcpApp } from "./McpAppWebView";
import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
import {
assetEnvironment,
@@ -1597,6 +1598,20 @@ function renderFeedEntry(
);
}
+ if (entry.type === "mcp-app") {
+ return (
+
+ );
+ }
+
if (entry.type === "work-toggle") {
return (
{
+ // An unavailable share sheet is a refusal for the caller, not a failure.
+ const sharing = await availableSharing(input.signal).catch(() => null);
+ if (sharing === null) return false;
+ const cached = await createCachedAttachmentFile(input.attachment);
+ try {
+ if (input.signal.aborted) return false;
+ cached.file.write(input.bytes);
+ await cached.preview.share(input.signal);
+ return true;
+ } finally {
+ cached.preview.dispose();
+ }
+}
diff --git a/apps/mobile/src/lib/openExternalUrl.ts b/apps/mobile/src/lib/openExternalUrl.ts
index 0963044b9ffb..f4feb41dcc66 100644
--- a/apps/mobile/src/lib/openExternalUrl.ts
+++ b/apps/mobile/src/lib/openExternalUrl.ts
@@ -7,6 +7,7 @@ const ExternalUrlTarget = Schema.Literals([
"pull-request",
"provider-auth",
"html-render",
+ "mcp-app",
]);
export type ExternalUrlTarget = typeof ExternalUrlTarget.Type;
diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts
index 16be0186e68b..6743876a2fe7 100644
--- a/apps/mobile/src/lib/threadActivity.test.ts
+++ b/apps/mobile/src/lib/threadActivity.test.ts
@@ -2511,3 +2511,35 @@ describe("html renders", () => {
}
});
});
+
+describe("MCP apps", () => {
+ const app = {
+ attachmentId: "attachment-app",
+ server: "weather",
+ tool: "get_weather",
+ resourceUri: "ui://weather/dashboard",
+ };
+ const appCall = (status: OrchestrationV2TurnItem["status"]): OrchestrationV2TurnItem => ({
+ ...base("item-app", "2026-06-20T00:00:02.500Z", 2),
+ status,
+ type: "dynamic_tool",
+ toolName: "weather.get_weather",
+ input: { city: "Oslo" },
+ output: { t3McpApp: app, result: { content: [] } },
+ });
+ const feed = (status: OrchestrationV2TurnItem["status"]) =>
+ buildThreadFeed([
+ projected(userMessage(), 0),
+ projected(appCall(status), 1),
+ projected(assistantMessage("2026-06-20T00:00:04.000Z"), 2),
+ ]);
+
+ it("hosts a completed app in place of its tool row, owned by its source item", () => {
+ const entry = feed("completed").find((candidate) => candidate.type === "mcp-app");
+ expect(entry).toMatchObject({ type: "mcp-app", app, itemId: "item-app", runId });
+ });
+
+ it("keeps a running app call an ordinary work row", () => {
+ expect(feed("running").some((candidate) => candidate.type === "mcp-app")).toBe(false);
+ });
+});
diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts
index 4038fa4982c7..d88b1dba2ea2 100644
--- a/apps/mobile/src/lib/threadActivity.ts
+++ b/apps/mobile/src/lib/threadActivity.ts
@@ -8,6 +8,7 @@ import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subage
import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime";
import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
import {
+ turnItemDetailRevision,
turnItemHasDetail,
turnItemNeedsDetailFetch,
} from "@t3tools/client-runtime/work-log/item-detail";
@@ -48,6 +49,7 @@ import type {
OrchestrationV2UserMessageInputIntent,
RunAttemptId,
ScheduledTaskId,
+ TurnItemId,
} from "@t3tools/contracts";
import { RunId, ThreadId } from "@t3tools/contracts";
import {
@@ -59,7 +61,12 @@ import {
} from "@t3tools/shared/toolActivity";
import { formatDuration } from "@t3tools/shared/orchestrationTiming";
import type { HtmlRenderReference } from "@t3tools/shared/htmlRender";
-import { compactDynamicToolOutput, htmlRenderFromToolItem } from "@t3tools/shared/toolOutput";
+import type { McpAppReference } from "@t3tools/shared/mcpApp";
+import {
+ compactDynamicToolOutput,
+ htmlRenderFromToolItem,
+ mcpAppFromToolItem,
+} from "@t3tools/shared/toolOutput";
import * as DateTime from "effect/DateTime";
export type PendingApproval = ThreadPendingApproval;
@@ -165,12 +172,24 @@ type RawThreadFeedEntry =
readonly createdAt: string;
readonly runId: RunId | null;
readonly render: HtmlRenderReference;
+ }
+ | {
+ /** An MCP App a completed tool call captured, hosted in place of its work row. */
+ readonly type: "mcp-app";
+ readonly id: string;
+ readonly createdAt: string;
+ readonly runId: RunId | null;
+ /** The thread and item that own the app; a fork's inherited app is its source's. */
+ readonly sourceThreadId: ThreadId;
+ readonly itemId: TurnItemId;
+ readonly revision: string;
+ readonly app: McpAppReference;
};
export type ThreadFeedEntry = ThreadFeedEntryContent & { readonly continuesWorkLog?: boolean };
type ThreadFeedEntryContent =
- | Extract
+ | Extract
| {
readonly type: "activity-group";
readonly id: string;
@@ -1049,7 +1068,9 @@ function deriveThreadFeedRunFolds(
const runId =
entry.type === "message" && entry.message.role === "assistant"
? (entry.message.runId ?? runlessKey)
- : entry.type === "activity-group" || entry.type === "html-render"
+ : entry.type === "activity-group" ||
+ entry.type === "html-render" ||
+ entry.type === "mcp-app"
? (entry.runId ?? runlessKey)
: null;
if (!runId) continue;
@@ -1102,6 +1123,7 @@ function deriveThreadFeedRunFolds(
entry.id !== firstAssistantId &&
entry.id !== terminalAssistantId &&
entry.type !== "html-render" &&
+ entry.type !== "mcp-app" &&
!(
entry.type === "activity-group" &&
entry.activities.some(
@@ -1750,6 +1772,25 @@ export function buildThreadFeed(
entries.push(entry);
continue;
}
+ const app =
+ item.type === "dynamic_tool" && item.status === "completed"
+ ? mcpAppFromToolItem(item)
+ : undefined;
+ if (app) {
+ const entry: RawThreadFeedEntry = {
+ type: "mcp-app",
+ id: `mcp-app:${row.visibility}:${row.sourceThreadId}:${row.sourceItemId}`,
+ createdAt,
+ runId: item.runId,
+ sourceThreadId: row.sourceThreadId,
+ itemId: row.sourceItemId,
+ revision: turnItemDetailRevision(item),
+ app,
+ };
+ projectedEntriesCache.set(row, { attemptId, entry });
+ entries.push(entry);
+ continue;
+ }
if (item.type === "user_message" || item.type === "assistant_message") {
const updatedAt = DateTime.formatIso(item.updatedAt);
const entry: RawThreadFeedEntry = {
diff --git a/apps/mobile/src/state/mcpApps.ts b/apps/mobile/src/state/mcpApps.ts
new file mode 100644
index 000000000000..84dafef26313
--- /dev/null
+++ b/apps/mobile/src/state/mcpApps.ts
@@ -0,0 +1,5 @@
+import { createMcpAppEnvironmentAtoms } from "@t3tools/client-runtime/state/mcp-apps";
+
+import { connectionAtomRuntime } from "../connection/runtime";
+
+export const mcpAppEnvironment = createMcpAppEnvironmentAtoms(connectionAtomRuntime);
diff --git a/apps/server/scripts/record-codex-app-server-replay-fixture.ts b/apps/server/scripts/record-codex-app-server-replay-fixture.ts
index 936a7e8b4e06..4343aec87ba1 100644
--- a/apps/server/scripts/record-codex-app-server-replay-fixture.ts
+++ b/apps/server/scripts/record-codex-app-server-replay-fixture.ts
@@ -72,6 +72,7 @@ const CODEX_REPLAY_PLAN_MODE_DEVELOPER_INSTRUCTIONS =
const CODEX_CLIENT_CAPABILITIES = {
experimentalApi: true,
optOutNotificationMethods: ["turn/diff/updated"],
+ extensions: { "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] } },
} as const;
const CODEX_REPLAY_DEFAULT_MODEL = "gpt-6-luna";
const CODEX_REPLAY_MODEL_OVERRIDE = readArgValue("--model") ?? process.env.T3_CODEX_REPLAY_MODEL;
diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts
index 1a952d1a6344..a8b7927b624d 100644
--- a/apps/server/src/attachmentStore.test.ts
+++ b/apps/server/src/attachmentStore.test.ts
@@ -243,4 +243,24 @@ describe("threadHtmlRenderAttachmentIds", () => {
]),
).toEqual([own]);
});
+
+ it("includes captured MCP App documents from any tool", () => {
+ const app = createAttachmentId("thread-a", "html")!;
+ expect(
+ threadHtmlRenderAttachmentIds("thread-a", [
+ {
+ toolName: "weather.get_weather",
+ output: {
+ t3McpApp: {
+ attachmentId: app,
+ server: "weather",
+ tool: "get_weather",
+ resourceUri: "ui://weather/dashboard",
+ },
+ result: { content: [] },
+ },
+ },
+ ]),
+ ).toEqual([app]);
+ });
});
diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts
index c959e8f3a2d0..0927b03761f7 100644
--- a/apps/server/src/attachmentStore.ts
+++ b/apps/server/src/attachmentStore.ts
@@ -4,7 +4,7 @@ import * as NodeFS from "node:fs";
import * as NodePath from "node:path";
import type { ChatAttachment } from "@t3tools/contracts";
-import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput";
+import { htmlRenderFromToolItem, mcpAppFromToolItem } from "@t3tools/shared/toolOutput";
import {
normalizeAttachmentRelativePath,
@@ -116,8 +116,9 @@ export function parseThreadSegmentFromAttachmentId(attachmentId: string): string
}
/**
- * Attachments a thread's `html_render` tool calls published. Only ids minted
- * for this thread count, so deleting a fork never removes its source's pages.
+ * Attachments a thread's tool calls published: `html_render` pages and
+ * captured MCP App documents. Only ids minted for this thread count, so
+ * deleting a fork never removes its source's pages.
*/
export function threadHtmlRenderAttachmentIds(
threadId: string,
@@ -126,7 +127,8 @@ export function threadHtmlRenderAttachmentIds(
const segment = toSafeThreadAttachmentSegment(threadId);
if (segment === null) return [];
return Array.from(items).flatMap((item) => {
- const attachmentId = htmlRenderFromToolItem(item)?.attachmentId;
+ const attachmentId =
+ htmlRenderFromToolItem(item)?.attachmentId ?? mcpAppFromToolItem(item)?.attachmentId;
return attachmentId !== undefined &&
parseThreadSegmentFromAttachmentId(attachmentId) === segment
? [attachmentId]
diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts
index ee0d24e44d5a..d434ca86a3a5 100644
--- a/apps/server/src/auth/RpcAuthorization.ts
+++ b/apps/server/src/auth/RpcAuthorization.ts
@@ -155,6 +155,11 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope,
[WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope,
[WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope,
+ // An app's tool calls can change things on its server, like a user action.
+ [WS_METHODS.mcpAppsCallTool]: AuthOrchestrationOperateScope,
+ [WS_METHODS.mcpAppsToolInfo]: AuthOrchestrationReadScope,
+ [WS_METHODS.mcpAppsReadResource]: AuthOrchestrationReadScope,
+ [WS_METHODS.mcpAppsUpdateModelContext]: AuthOrchestrationOperateScope,
[WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope,
[WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope,
diff --git a/apps/server/src/mcpApps/McpAppModelContext.test.ts b/apps/server/src/mcpApps/McpAppModelContext.test.ts
new file mode 100644
index 000000000000..f5fd7f331456
--- /dev/null
+++ b/apps/server/src/mcpApps/McpAppModelContext.test.ts
@@ -0,0 +1,68 @@
+import { ThreadId, TurnItemId } from "@t3tools/contracts";
+import { assert, it } from "@effect/vitest";
+import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as SqlClient from "effect/sql/SqlClient";
+
+import { runMigrations } from "../persistence/Migrations.ts";
+import * as McpAppModelContext from "./McpAppModelContext.ts";
+
+const database = NodeSqliteClient.layer({ filename: ":memory:" });
+const layer = it.layer(
+ Layer.merge(database, McpAppModelContext.layer.pipe(Layer.provide(database))),
+);
+
+const threadId = ThreadId.make("thread-context");
+const forkId = ThreadId.make("thread-context-fork");
+
+/** An app's tool call in the projection, in a run with the given status. */
+const insertAppItem = (itemId: string, runId: string, runStatus: string) =>
+ Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ yield* sql`
+ INSERT INTO orchestration_v2_projection_runs
+ (run_id, thread_id, ordinal, provider, status, requested_at, payload_json)
+ VALUES (${runId}, ${threadId}, ${runId.length}, 'codex', ${runStatus}, '2026-01-01', '{}')
+ `;
+ yield* sql`
+ INSERT INTO orchestration_v2_projection_turn_items
+ (turn_item_id, thread_id, run_id, ordinal, type, status, updated_at, payload_json)
+ VALUES (${itemId}, ${threadId}, ${runId}, 0, 'dynamic_tool', 'completed', '2026-01-01', '{}')
+ `;
+ });
+
+layer("McpAppModelContext", (it) => {
+ it.effect("sends only apps still in the thread's history, including a fork's", () =>
+ Effect.gen(function* () {
+ yield* runMigrations();
+ const store = yield* McpAppModelContext.McpAppModelContext;
+ yield* insertAppItem("item-kept", "run-a", "completed");
+ yield* insertAppItem("item-rolled-back", "run-bb", "rolled_back");
+ const set = (thread: ThreadId, itemId: string, text: string) =>
+ store.set({
+ threadId: thread,
+ itemId: TurnItemId.make(itemId),
+ server: "todos",
+ tool: "list_todos",
+ text,
+ });
+ yield* set(threadId, "item-kept", "kept");
+ yield* set(threadId, "item-rolled-back", "from a rolled-back run");
+ yield* set(threadId, "item-deleted", "from an item that is gone");
+ // A fork showing the source thread's app keeps its own context, even
+ // after the source rolls that app's run back.
+ yield* set(forkId, "item-kept", "from the fork");
+ yield* set(forkId, "item-rolled-back", "inherited before the rollback");
+
+ const texts = (thread: ThreadId) =>
+ store.forThread(thread).pipe(Effect.map((entries) => entries.map((entry) => entry.text)));
+ assert.deepEqual(yield* texts(threadId), ["kept"]);
+ assert.deepEqual(yield* texts(forkId), ["from the fork", "inherited before the rollback"]);
+
+ // Clearing removes the app's context.
+ yield* set(threadId, "item-kept", "");
+ assert.deepEqual(yield* texts(threadId), []);
+ }),
+ );
+});
diff --git a/apps/server/src/mcpApps/McpAppModelContext.ts b/apps/server/src/mcpApps/McpAppModelContext.ts
new file mode 100644
index 000000000000..dcdd5cd786bb
--- /dev/null
+++ b/apps/server/src/mcpApps/McpAppModelContext.ts
@@ -0,0 +1,105 @@
+import type { ThreadId, TurnItemId } from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Schema from "effect/Schema";
+import * as SqlClient from "effect/sql/SqlClient";
+
+/** Largest context one app may keep for the agent, in UTF-8 bytes. */
+export const MCP_APP_MODEL_CONTEXT_MAX_BYTES = 16 * 1024;
+
+export class McpAppModelContextError extends Schema.TaggedError()(
+ "McpAppModelContextError",
+ { cause: Schema.optional(Schema.Defect()) },
+) {}
+
+export interface McpAppModelContextEntry {
+ readonly itemId: string;
+ readonly server: string;
+ readonly tool: string;
+ readonly text: string;
+}
+
+/**
+ * What each MCP App told the agent through `ui/update-model-context`. The spec
+ * replaces an app's context on every update, so there is one entry per app (its
+ * tool call); the thread's next turn carries the entries to the provider.
+ */
+export class McpAppModelContext extends Context.Service<
+ McpAppModelContext,
+ {
+ /** Replaces the app's context; empty text removes it. */
+ readonly set: (input: {
+ readonly threadId: ThreadId;
+ readonly itemId: TurnItemId;
+ readonly server: string;
+ readonly tool: string;
+ readonly text: string;
+ }) => Effect.Effect;
+ readonly forThread: (
+ threadId: ThreadId,
+ ) => Effect.Effect, McpAppModelContextError>;
+ }
+>()("t3/mcpApps/McpAppModelContext") {}
+
+export const layer = Layer.effect(
+ McpAppModelContext,
+ Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ const fail = (cause: unknown) => new McpAppModelContextError({ cause });
+
+ const set = Effect.fn("McpAppModelContext.set")(function* (input: {
+ readonly threadId: ThreadId;
+ readonly itemId: TurnItemId;
+ readonly server: string;
+ readonly tool: string;
+ readonly text: string;
+ }) {
+ if (input.text.trim() === "") {
+ yield* sql`
+ DELETE FROM mcp_app_model_context
+ WHERE thread_id = ${input.threadId} AND item_id = ${input.itemId}
+ `.pipe(Effect.mapError(fail));
+ return;
+ }
+ const updatedAt = DateTime.formatIso(yield* DateTime.now);
+ yield* sql`
+ INSERT INTO mcp_app_model_context (thread_id, item_id, server, tool, text, updated_at)
+ VALUES (${input.threadId}, ${input.itemId}, ${input.server}, ${input.tool}, ${input.text}, ${updatedAt})
+ ON CONFLICT(thread_id, item_id) DO UPDATE SET text = excluded.text, updated_at = excluded.updated_at
+ `.pipe(Effect.mapError(fail));
+ });
+
+ const forThread = Effect.fn("McpAppModelContext.forThread")(function* (threadId: ThreadId) {
+ // Only apps still in the thread's history count: an app whose item is
+ // gone, or whose run this thread rolled back, stops informing the agent.
+ // The item can live in a thread this one was forked from, so it is
+ // matched by id alone; that thread rolling the run back later leaves the
+ // fork's copy in place.
+ return yield* sql`
+ SELECT context.item_id AS "itemId", context.server, context.tool, context.text
+ FROM mcp_app_model_context AS context
+ JOIN orchestration_v2_projection_turn_items AS item
+ ON item.turn_item_id = context.item_id
+ LEFT JOIN orchestration_v2_projection_runs AS run
+ ON run.run_id = item.run_id
+ WHERE context.thread_id = ${threadId}
+ AND (
+ run.status IS NULL
+ OR run.status <> 'rolled_back'
+ OR run.thread_id <> context.thread_id
+ )
+ ORDER BY context.updated_at, context.item_id
+ `.pipe(Effect.mapError(fail));
+ });
+
+ return McpAppModelContext.of({ set, forThread });
+ }),
+);
+
+/** No app context: for code paths and tests with no MCP Apps. */
+export const layerEmpty = Layer.succeed(
+ McpAppModelContext,
+ McpAppModelContext.of({ set: () => Effect.void, forThread: () => Effect.succeed([]) }),
+);
diff --git a/apps/server/src/mcpApps/McpAppRequests.test.ts b/apps/server/src/mcpApps/McpAppRequests.test.ts
new file mode 100644
index 000000000000..1fdc8e0c50ca
--- /dev/null
+++ b/apps/server/src/mcpApps/McpAppRequests.test.ts
@@ -0,0 +1,284 @@
+import {
+ ProviderSessionId,
+ ProviderThreadId,
+ ThreadId,
+ TurnItemId,
+ type McpAppRequestError,
+ type OrchestrationV2ProviderThread,
+ type OrchestrationV2TurnItem,
+} from "@t3tools/contracts";
+import { assert, describe, it } from "@effect/vitest";
+import * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+
+import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
+import type {
+ ProviderAdapterV2McpApps,
+ ProviderAdapterV2SessionRuntime,
+} from "../orchestration-v2/ProviderAdapter.ts";
+import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts";
+import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
+import * as McpAppModelContext from "./McpAppModelContext.ts";
+import * as McpAppRequests from "./McpAppRequests.ts";
+
+const threadId = ThreadId.make("thread-app");
+const itemId = TurnItemId.make("item-app");
+const providerThreadId = ProviderThreadId.make("provider-thread-app");
+const providerSessionId = ProviderSessionId.make("provider-session-app");
+const forkId = ThreadId.make("thread-fork");
+const unrelatedId = ThreadId.make("thread-unrelated");
+// A chain of forks deeper than any fixed walk limit: deep-70 → … → deep-1 → app.
+const deepForkId = (depth: number) => ThreadId.make(`thread-deep-${depth}`);
+const deepParent = (id: ThreadId): ThreadId | null => {
+ const match = /^thread-deep-(\d+)$/.exec(id);
+ if (match === null) return null;
+ const depth = Number(match[1]);
+ return depth === 1 ? threadId : deepForkId(depth - 1);
+};
+
+const appItem = (output: unknown): OrchestrationV2TurnItem => ({
+ id: itemId,
+ threadId,
+ runId: null,
+ nodeId: null,
+ providerThreadId,
+ providerTurnId: null,
+ nativeItemRef: null,
+ parentItemId: null,
+ ordinal: 0,
+ status: "completed",
+ title: null,
+ startedAt: null,
+ completedAt: null,
+ updatedAt: DateTime.makeUnsafe(0),
+ type: "dynamic_tool",
+ toolName: "weather.get_weather",
+ input: {},
+ output,
+});
+
+const app = {
+ attachmentId: "thread-app-00000000-0000-0000-0000-000000000000-html",
+ server: "weather",
+ tool: "get_weather",
+ resourceUri: "ui://weather/dashboard",
+};
+
+/** Each app's stored context, keyed by item, as the real store keeps it. */
+const storedContext = new Map();
+
+function makeLayer(input: {
+ readonly item: OrchestrationV2TurnItem | null;
+ readonly mcpApps?: ProviderAdapterV2McpApps;
+ readonly live?: boolean;
+}) {
+ return McpAppRequests.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ Layer.mock(McpAppModelContext.McpAppModelContext)({
+ set: (entry) =>
+ Effect.sync(() => {
+ const key = `${entry.threadId}/${entry.itemId}`;
+ if (entry.text.trim() === "") storedContext.delete(key);
+ else storedContext.set(key, entry.text);
+ }),
+ }),
+ Layer.mock(Orchestrator.OrchestratorV2)({
+ getTurnItem: () => Effect.succeed(input.item),
+ }),
+ Layer.mock(ThreadManagementService.ThreadManagementService)({
+ getThreadRecords: (id: ThreadId) =>
+ Effect.succeed({
+ // A fork of the app's thread, and an unrelated thread.
+ thread: {
+ lineage:
+ deepParent(id) !== null
+ ? {
+ parentThreadId: deepParent(id),
+ relationshipToParent: "fork",
+ rootThreadId: threadId,
+ }
+ : id === forkId
+ ? {
+ parentThreadId: threadId,
+ relationshipToParent: "fork",
+ rootThreadId: threadId,
+ }
+ : { parentThreadId: null, relationshipToParent: null, rootThreadId: id },
+ },
+ providerThreads: [
+ { id: providerThreadId, providerSessionId } as OrchestrationV2ProviderThread,
+ ],
+ } as never),
+ }),
+ Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({
+ get: () =>
+ Effect.succeed(
+ input.live === false
+ ? Option.none()
+ : Option.some(
+ (input.mcpApps === undefined
+ ? {}
+ : { mcpApps: input.mcpApps }) as ProviderAdapterV2SessionRuntime,
+ ),
+ ),
+ }),
+ ),
+ ),
+ );
+}
+
+const reason = (effect: Effect.Effect) =>
+ effect.pipe(
+ Effect.flip,
+ Effect.map((error) => error.reason),
+ );
+
+describe("McpAppRequests", () => {
+ const calls: Array<{ server: string; tool: string }> = [];
+ const mcpApps: ProviderAdapterV2McpApps = {
+ listTools: () =>
+ Effect.succeed([
+ { name: "refresh", annotations: { readOnlyHint: true } },
+ { name: "model_only", _meta: { ui: { visibility: ["model"] } } },
+ ]),
+ callTool: (input) =>
+ Effect.sync(() => {
+ calls.push({ server: input.server, tool: input.tool });
+ return { content: [{ type: "text", text: "ok" }] };
+ }),
+ readResource: () => Effect.succeed({ contents: [] }),
+ };
+
+ it.effect("calls tools on the app's own server and reports their read-only hint", () =>
+ Effect.gen(function* () {
+ const requests = yield* McpAppRequests.McpAppRequests;
+ const result = yield* requests.callTool({
+ threadId,
+ itemId,
+ name: "refresh",
+ arguments: {},
+ });
+ assert.deepEqual(result, { content: [{ type: "text", text: "ok" }] });
+ assert.deepEqual(calls.at(-1), { server: "weather", tool: "refresh" });
+ assert.deepEqual(yield* requests.toolInfo({ threadId, itemId, name: "refresh" }), {
+ callable: true,
+ readOnly: true,
+ tool: { name: "refresh", annotations: { readOnlyHint: true } },
+ });
+ }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps }))),
+ );
+
+ it.effect("refuses tools hidden from apps or missing from the server", () =>
+ Effect.gen(function* () {
+ const requests = yield* McpAppRequests.McpAppRequests;
+ const before = calls.length;
+ for (const name of ["model_only", "unknown"]) {
+ assert.equal(
+ yield* reason(requests.callTool({ threadId, itemId, name, arguments: {} })),
+ "tool-not-callable",
+ );
+ }
+ assert.equal(calls.length, before);
+ }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps }))),
+ );
+
+ it.effect("keeps each app's latest model context, without a live session", () =>
+ Effect.gen(function* () {
+ const requests = yield* McpAppRequests.McpAppRequests;
+ const own = { threadId, itemId, conversationThreadId: threadId };
+ // The app's text is stored as sent, whitespace included.
+ yield* requests.updateModelContext({
+ ...own,
+ content: [{ type: "text", text: " indented value " }],
+ });
+ assert.equal(storedContext.get(`${threadId}/${itemId}`), " indented value ");
+ // An update replaces the app's context rather than adding to it.
+ yield* requests.updateModelContext({
+ ...own,
+ content: [{ type: "text", text: "Filtered to overdue" }],
+ structuredContent: { filter: "overdue" },
+ });
+ assert.equal(
+ storedContext.get(`${threadId}/${itemId}`),
+ 'Filtered to overdue\n{"filter":"overdue"}',
+ );
+ assert.equal(
+ yield* reason(
+ requests.updateModelContext({ ...own, content: [{ type: "image", data: "x" }] }),
+ ),
+ "unsupported-content",
+ );
+ // The cap is in UTF-8 bytes: 6,000 three-byte characters exceed 16 KiB.
+ assert.equal(
+ yield* reason(
+ requests.updateModelContext({
+ ...own,
+ content: [{ type: "text", text: "表".repeat(6000) }],
+ }),
+ ),
+ "request-failed",
+ );
+ yield* requests.updateModelContext(own);
+ assert.isFalse(storedContext.has(`${threadId}/${itemId}`));
+ }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), live: false }))),
+ );
+
+ it.effect("stores a fork's model context under the fork, and refuses unrelated threads", () =>
+ Effect.gen(function* () {
+ const requests = yield* McpAppRequests.McpAppRequests;
+ const content = [{ type: "text", text: "Seen from the fork" }];
+ yield* requests.updateModelContext({
+ threadId,
+ itemId,
+ conversationThreadId: forkId,
+ content,
+ });
+ assert.equal(storedContext.get(`${forkId}/${itemId}`), "Seen from the fork");
+ assert.isFalse(storedContext.has(`${threadId}/${itemId}`));
+ assert.equal(
+ yield* reason(
+ requests.updateModelContext({
+ threadId,
+ itemId,
+ conversationThreadId: unrelatedId,
+ content,
+ }),
+ ),
+ "not-an-app",
+ );
+ // However many forks deep, a descendant still counts.
+ yield* requests.updateModelContext({
+ threadId,
+ itemId,
+ conversationThreadId: deepForkId(70),
+ content,
+ });
+ assert.isTrue(storedContext.has(`${deepForkId(70)}/${itemId}`));
+ }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), live: false }))),
+ );
+
+ it.effect("reports why a request cannot run", () =>
+ Effect.gen(function* () {
+ const run = (layer: ReturnType) =>
+ Effect.gen(function* () {
+ const requests = yield* McpAppRequests.McpAppRequests;
+ return yield* reason(requests.readResource({ threadId, itemId, uri: "ui://weather/x" }));
+ }).pipe(Effect.provide(layer));
+ assert.equal(
+ yield* run(makeLayer({ item: appItem({ result: "plain" }), mcpApps })),
+ "not-an-app",
+ );
+ assert.equal(
+ yield* run(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps, live: false })),
+ "session-stopped",
+ );
+ assert.equal(
+ yield* run(makeLayer({ item: appItem({ t3McpApp: app }) })),
+ "provider-unsupported",
+ );
+ }),
+ );
+});
diff --git a/apps/server/src/mcpApps/McpAppRequests.ts b/apps/server/src/mcpApps/McpAppRequests.ts
new file mode 100644
index 000000000000..c95606ccd215
--- /dev/null
+++ b/apps/server/src/mcpApps/McpAppRequests.ts
@@ -0,0 +1,240 @@
+import {
+ McpAppRequestError,
+ type McpAppCallToolInput,
+ type McpAppCallToolResult,
+ type McpAppReadResourceInput,
+ type McpAppReadResourceResult,
+ type McpAppToolInfo,
+ type McpAppToolInfoInput,
+ type McpAppUpdateModelContextInput,
+ type OrchestrationV2AppThread,
+ type ThreadId,
+ type TurnItemId,
+} from "@t3tools/contracts";
+import { mcpAppToolCallableByApp, type McpAppReference } from "@t3tools/shared/mcpApp";
+import { mcpAppFromToolItem } from "@t3tools/shared/toolOutput";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Predicate from "effect/Predicate";
+import * as Schema from "effect/Schema";
+
+import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
+import type {
+ ProviderAdapterV2McpApps,
+ ProviderAdapterV2McpTool,
+} from "../orchestration-v2/ProviderAdapter.ts";
+import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts";
+import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
+import * as McpAppModelContext from "./McpAppModelContext.ts";
+
+/**
+ * Requests an MCP App makes of its own MCP server. The app is resolved from the
+ * tool call that produced it, so it reaches only that server, and each request
+ * runs through the thread's live provider session, whose MCP client owns the
+ * connection and its credentials. A stopped session is reported, never
+ * started: an app view must not spend a provider turn.
+ */
+export class McpAppRequests extends Context.Service<
+ McpAppRequests,
+ {
+ readonly callTool: (
+ input: McpAppCallToolInput,
+ ) => Effect.Effect;
+ readonly toolInfo: (
+ input: McpAppToolInfoInput,
+ ) => Effect.Effect;
+ readonly readResource: (
+ input: McpAppReadResourceInput,
+ ) => Effect.Effect;
+ readonly updateModelContext: (
+ input: McpAppUpdateModelContextInput,
+ ) => Effect.Effect;
+ }
+>()("t3/mcpApps/McpAppRequests") {}
+
+const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
+const utf8 = new TextEncoder();
+
+const readOnlyHint = (tool: ProviderAdapterV2McpTool) =>
+ Predicate.isObject(tool.annotations) && tool.annotations.readOnlyHint === true;
+
+const toolTitle = (tool: ProviderAdapterV2McpTool) =>
+ Predicate.isObject(tool.annotations) && typeof tool.annotations.title === "string"
+ ? tool.annotations.title
+ : undefined;
+
+const make = Effect.gen(function* () {
+ const orchestrator = yield* Orchestrator.OrchestratorV2;
+ const threadManagement = yield* ThreadManagementService.ThreadManagementService;
+ const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2;
+ const modelContext = yield* McpAppModelContext.McpAppModelContext;
+
+ const fail = (threadId: ThreadId, reason: McpAppRequestError["reason"], cause?: unknown) =>
+ new McpAppRequestError({ threadId, reason, ...(cause === undefined ? {} : { cause }) });
+
+ /** The app a tool call produced, read from the stored item. */
+ const resolveApp = Effect.fn("McpAppRequests.resolveApp")(function* (input: {
+ readonly threadId: ThreadId;
+ readonly itemId: TurnItemId;
+ }) {
+ // The stored item, not the wire projection, so the reference is intact.
+ const item = yield* orchestrator
+ .getTurnItem({ threadId: input.threadId, itemId: input.itemId })
+ .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause)));
+ const app: McpAppReference | undefined =
+ item?.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined;
+ if (item === null || app === undefined || item.providerThreadId === null) {
+ return yield* fail(input.threadId, "not-an-app");
+ }
+ return { app, providerThreadId: item.providerThreadId };
+ });
+
+ /** The app, its provider thread, and the live session's MCP Apps operations. */
+ const resolve = Effect.fn("McpAppRequests.resolve")(function* (input: {
+ readonly threadId: ThreadId;
+ readonly itemId: TurnItemId;
+ }) {
+ const { app, providerThreadId } = yield* resolveApp(input);
+ const projection = yield* threadManagement
+ .getThreadRecords(input.threadId, ["providerThreads"])
+ .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause)));
+ const providerThread = projection.providerThreads.find(
+ (candidate) => candidate.id === providerThreadId,
+ );
+ if (providerThread?.providerSessionId == null) {
+ return yield* fail(input.threadId, "session-stopped");
+ }
+ const runtime = Option.getOrUndefined(
+ yield* providerSessions
+ .get(providerThread.providerSessionId)
+ .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))),
+ );
+ if (runtime === undefined) return yield* fail(input.threadId, "session-stopped");
+ const mcpApps: ProviderAdapterV2McpApps | undefined = runtime.mcpApps;
+ if (mcpApps === undefined) return yield* fail(input.threadId, "provider-unsupported");
+ return { app, providerThread, mcpApps };
+ });
+
+ const findTool = Effect.fn("McpAppRequests.findTool")(function* (
+ threadId: ThreadId,
+ resolved: Effect.Success>,
+ name: string,
+ ) {
+ const tools = yield* resolved.mcpApps
+ .listTools({ providerThread: resolved.providerThread, server: resolved.app.server })
+ .pipe(Effect.mapError((cause) => fail(threadId, "request-failed", cause)));
+ return tools.find((tool) => tool.name === name);
+ });
+
+ const toolInfo = Effect.fn("McpAppRequests.toolInfo")(function* (input: McpAppToolInfoInput) {
+ const resolved = yield* resolve(input);
+ const tool = yield* findTool(input.threadId, resolved, input.name);
+ const title = tool === undefined ? undefined : toolTitle(tool);
+ return {
+ callable: tool !== undefined && mcpAppToolCallableByApp(tool._meta),
+ readOnly: tool !== undefined && readOnlyHint(tool),
+ ...(title === undefined ? {} : { title }),
+ ...(tool === undefined ? {} : { tool }),
+ } satisfies McpAppToolInfo;
+ });
+
+ const callTool = Effect.fn("McpAppRequests.callTool")(function* (input: McpAppCallToolInput) {
+ const resolved = yield* resolve(input);
+ // The spec forbids apps calling tools hidden from them; checked here too,
+ // since a client's own check is only a courtesy to its user.
+ const tool = yield* findTool(input.threadId, resolved, input.name);
+ if (tool === undefined || !mcpAppToolCallableByApp(tool._meta)) {
+ return yield* fail(input.threadId, "tool-not-callable");
+ }
+ return yield* resolved.mcpApps
+ .callTool({
+ providerThread: resolved.providerThread,
+ server: resolved.app.server,
+ tool: input.name,
+ arguments: input.arguments,
+ })
+ .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause)));
+ });
+
+ const readResource = Effect.fn("McpAppRequests.readResource")(function* (
+ input: McpAppReadResourceInput,
+ ) {
+ const resolved = yield* resolve(input);
+ return yield* resolved.mcpApps
+ .readResource({
+ providerThread: resolved.providerThread,
+ server: resolved.app.server,
+ uri: input.uri,
+ })
+ .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause)));
+ });
+
+ // Context is state for the agent's next turn, so it needs the app but not a
+ // live session. Text blocks are kept as written and structured content as
+ // JSON; other content kinds are not declared by the host.
+ /** Whether `threadId` is `ancestorId` or forked from it, however many forks deep. */
+ const descendsFrom = Effect.fn("McpAppRequests.descendsFrom")(function* (
+ threadId: ThreadId,
+ ancestorId: ThreadId,
+ ) {
+ // Walks fork links until the chain ends; a thread seen twice means a
+ // corrupt cycle, which proves nothing.
+ const seen = new Set();
+ let current: ThreadId | null = threadId;
+ while (current !== null && !seen.has(current)) {
+ if (current === ancestorId) return true;
+ seen.add(current);
+ const records: { readonly thread: OrchestrationV2AppThread } = yield* threadManagement
+ .getThreadRecords(current, [])
+ .pipe(Effect.mapError((cause) => fail(threadId, "request-failed", cause)));
+ const { lineage } = records.thread;
+ current = lineage.relationshipToParent === "fork" ? lineage.parentThreadId : null;
+ }
+ return false;
+ });
+
+ const updateModelContext = Effect.fn("McpAppRequests.updateModelContext")(function* (
+ input: McpAppUpdateModelContextInput,
+ ) {
+ const { app } = yield* resolveApp(input);
+ // Context belongs to the conversation on screen, which only shows this app
+ // when it is the app's own thread or one forked from it.
+ if (!(yield* descendsFrom(input.conversationThreadId, input.threadId))) {
+ return yield* fail(input.threadId, "not-an-app");
+ }
+ const texts: Array = [];
+ for (const block of input.content ?? []) {
+ if (!Predicate.isObject(block) || block.type !== "text" || typeof block.text !== "string") {
+ return yield* fail(input.threadId, "unsupported-content");
+ }
+ texts.push(block.text);
+ }
+ if (input.structuredContent !== undefined) {
+ texts.push(encodeJson(input.structuredContent));
+ }
+ // Kept as sent; blank text clears the app's context in the store.
+ const text = texts.join("\n");
+ if (utf8.encode(text).byteLength > McpAppModelContext.MCP_APP_MODEL_CONTEXT_MAX_BYTES) {
+ return yield* fail(
+ input.threadId,
+ "request-failed",
+ new Error("Model context is too large."),
+ );
+ }
+ yield* modelContext
+ .set({
+ threadId: input.conversationThreadId,
+ itemId: input.itemId,
+ server: app.server,
+ tool: app.tool,
+ text,
+ })
+ .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause)));
+ });
+
+ return McpAppRequests.of({ callTool, toolInfo, readResource, updateModelContext });
+});
+
+export const layer = Layer.effect(McpAppRequests, make);
diff --git a/apps/server/src/mcpApps/McpAppSnapshot.ts b/apps/server/src/mcpApps/McpAppSnapshot.ts
new file mode 100644
index 000000000000..12a2a6378401
--- /dev/null
+++ b/apps/server/src/mcpApps/McpAppSnapshot.ts
@@ -0,0 +1,108 @@
+import type { ThreadId } from "@t3tools/contracts";
+import {
+ injectMcpAppCsp,
+ MCP_APP_MAX_HTML_BYTES,
+ MCP_APP_MIME_TYPE,
+ MCP_APP_RESOURCE_SCHEME,
+ readMcpAppCsp,
+ readMcpAppPermissions,
+ type McpAppReference,
+} from "@t3tools/shared/mcpApp";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Predicate from "effect/Predicate";
+
+import { resolveAttachmentRelativePath } from "../attachmentPaths.ts";
+import { createAttachmentId } from "../attachmentStore.ts";
+
+const encoder = new TextEncoder();
+
+/**
+ * The HTML document in a `resources/read` result, with the `_meta.ui` the
+ * resource declared. Only `text/html;profile=mcp-app` content counts; a blob is
+ * base64 per MCP. A null document is one too large to store.
+ */
+function readMcpAppDocument(
+ contents: ReadonlyArray,
+ uri: string,
+): { readonly html: string | null; readonly meta: unknown } | undefined {
+ for (const content of contents) {
+ if (!Predicate.isObject(content)) continue;
+ if (content.uri !== undefined && content.uri !== uri) continue;
+ const mimeType = typeof content.mimeType === "string" ? content.mimeType : "";
+ if (mimeType.replace(/\s+/g, "").toLowerCase() !== MCP_APP_MIME_TYPE) continue;
+ // Sizes are checked before decoding, so an oversized resource is
+ // refused without first being copied: a string's UTF-8 form is at least
+ // its length, and base64 decodes to three quarters of its own.
+ const html =
+ typeof content.text === "string"
+ ? content.text.length > MCP_APP_MAX_HTML_BYTES
+ ? null
+ : content.text
+ : typeof content.blob === "string"
+ ? content.blob.length > Math.ceil(MCP_APP_MAX_HTML_BYTES / 3) * 4
+ ? null
+ : Buffer.from(content.blob, "base64").toString("utf8")
+ : undefined;
+ if (html === null) return { html: null, meta: undefined };
+ if (html === undefined || html.trim() === "") continue;
+ const meta = Predicate.isObject(content._meta) ? content._meta.ui : undefined;
+ return { html, meta };
+ }
+ return undefined;
+}
+
+/**
+ * Stores an app's document as a thread attachment and returns the reference
+ * clients host it from. Saving a snapshot keeps the app viewable after the
+ * provider exits; interaction still goes through a live provider session.
+ * Undefined when the resource is not an MCP App document or is too large.
+ */
+export const snapshotMcpApp = Effect.fn("McpAppSnapshot.snapshot")(function* (input: {
+ readonly attachmentsDir: string;
+ readonly threadId: ThreadId;
+ readonly server: string;
+ readonly tool: string;
+ readonly resourceUri: string;
+ readonly contents: ReadonlyArray;
+}) {
+ if (!input.resourceUri.startsWith(MCP_APP_RESOURCE_SCHEME)) return undefined;
+ const document = readMcpAppDocument(input.contents, input.resourceUri);
+ if (document === undefined) return undefined;
+ if (document.html === null || encoder.encode(document.html).byteLength > MCP_APP_MAX_HTML_BYTES) {
+ yield* Effect.logWarning("MCP app document exceeds the size limit.", {
+ server: input.server,
+ resourceUri: input.resourceUri,
+ });
+ return undefined;
+ }
+ // The `-html` suffix makes the asset route serve it as a sandboxed document.
+ const attachmentId = createAttachmentId(input.threadId, "html");
+ const filePath =
+ attachmentId === null
+ ? null
+ : resolveAttachmentRelativePath({
+ attachmentsDir: input.attachmentsDir,
+ relativePath: `${attachmentId}.html`,
+ });
+ if (attachmentId === null || filePath === null) return undefined;
+ const html = document.html;
+ const ui = Predicate.isObject(document.meta) ? document.meta : {};
+ const csp = readMcpAppCsp(ui.csp);
+ const fileSystem = yield* FileSystem.FileSystem;
+ // Only the returned reference lets thread deletion find the document, so a
+ // write that fails or is interrupted removes it.
+ yield* fileSystem
+ .writeFileString(filePath, injectMcpAppCsp(html, csp))
+ .pipe(Effect.onError(() => fileSystem.remove(filePath, { force: true }).pipe(Effect.ignore)));
+ const permissions = readMcpAppPermissions(ui.permissions);
+ return {
+ attachmentId,
+ server: input.server,
+ tool: input.tool,
+ resourceUri: input.resourceUri,
+ ...(csp === undefined ? {} : { csp }),
+ ...(permissions === undefined ? {} : { permissions }),
+ ...(typeof ui.prefersBorder === "boolean" ? { prefersBorder: ui.prefersBorder } : {}),
+ } satisfies McpAppReference;
+});
diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts
index f0f51f3d969a..d01c7812bc1a 100644
--- a/apps/server/src/observability/RpcInstrumentation.ts
+++ b/apps/server/src/observability/RpcInstrumentation.ts
@@ -142,6 +142,10 @@ const RPC_AGGREGATES = {
[WS_METHODS.attachmentsCreateUploadUrl]: "workspace",
[WS_METHODS.attachmentsDelete]: "workspace",
[WS_METHODS.providerUploadFeedback]: "provider",
+ [WS_METHODS.mcpAppsCallTool]: "provider",
+ [WS_METHODS.mcpAppsToolInfo]: "provider",
+ [WS_METHODS.mcpAppsUpdateModelContext]: "provider",
+ [WS_METHODS.mcpAppsReadResource]: "provider",
[WS_METHODS.subscribeVcsStatus]: "vcs",
[WS_METHODS.subscribeWorktreeSetup]: "vcs",
[WS_METHODS.worktreeSetupCancel]: "vcs",
diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
index 63f0cbdae66b..602052f08ea7 100644
--- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
+++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
@@ -18,6 +18,7 @@ import {
type OrchestrationV2AppThread,
type OrchestrationV2ProviderThread,
type OrchestrationV2ProviderTurn,
+ type OrchestrationV2TurnItem,
ProjectId,
ProviderInstanceId,
ProviderSessionId,
@@ -40,6 +41,8 @@ import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
import * as Predicate from "effect/Predicate";
import * as FileSystem from "effect/FileSystem";
+import { MCP_APP_OUTPUT_KEY, readMcpAppReference } from "@t3tools/shared/mcpApp";
+import { resolveAttachmentPathById } from "../../attachmentStore.ts";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
@@ -463,6 +466,38 @@ describe("CodexAdapterV2 runtime policy", () => {
}),
);
+ it.effect("sends MCP app model context as untrusted Codex context", () =>
+ Effect.gen(function* () {
+ const policy = { runtimeMode: "full-access", interactionMode: "default", cwd: null } as const;
+ const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" };
+ const appContext = [{ key: "mcp_app_todos_list_todos_item-1", text: "Filtered to overdue" }];
+ const alone = yield* CodexAdapterV2.buildCodexTurnStartParams({
+ nativeThreadId: "native-app-context",
+ codexInput: [{ type: "text", text: "what's on my list?" }],
+ runtimePolicy: policy,
+ modelSelection,
+ appContext,
+ });
+ assert.deepEqual(alone.additionalContext, {
+ "mcp_app_todos_list_todos_item-1": { kind: "untrusted", value: "Filtered to overdue" },
+ });
+ // Alongside T3's own context, both are kept.
+ const withT3 = yield* CodexAdapterV2.buildCodexTurnStartParams({
+ nativeThreadId: "native-app-context",
+ codexInput: [{ type: "text", text: "what's on my list?" }],
+ runtimePolicy: policy,
+ modelSelection,
+ hasT3Mcp: true,
+ appContext,
+ });
+ assert.deepEqual(withT3.additionalContext?.["mcp_app_todos_list_todos_item-1"], {
+ kind: "untrusted",
+ value: "Filtered to overdue",
+ });
+ assert.isDefined(withT3.additionalContext?.["t3_code_runtime"]);
+ }),
+ );
+
it.effect("adds default-mode developer instructions when the T3 MCP server is attached", () =>
Effect.gen(function* () {
const params = yield* CodexAdapterV2.buildCodexTurnStartParams({
@@ -1548,6 +1583,9 @@ function codexReplayPreamble(input: {
capabilities: {
experimentalApi: true,
optOutNotificationMethods: ["turn/diff/updated"],
+ extensions: {
+ "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] },
+ },
},
},
},
@@ -1972,6 +2010,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
runtime,
providerThread,
threadId,
+ serverConfig,
events,
continuationRequests,
terminalEvents,
@@ -2113,6 +2152,9 @@ describe("CodexAdapterV2 post-settle continuation", () => {
capabilities: {
experimentalApi: true,
optOutNotificationMethods: ["turn/diff/updated"],
+ extensions: {
+ "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] },
+ },
},
},
]);
@@ -6305,6 +6347,253 @@ describe("CodexAdapterV2 post-settle continuation", () => {
),
);
+ const MCP_APP_SCENARIO = "codex-mcp-app-capture";
+ const MCP_APP_NATIVE_THREAD = "native-codex-mcp-app-thread";
+ const MCP_APP_NATIVE_TURN = "native-codex-mcp-app-turn";
+ const MCP_APP_ITEM = "mcp-weather-call";
+ const MCP_APP_PROMPT = "Show the weather in Oslo.";
+ const MCP_APP_RESOURCE = "ui://weather/dashboard";
+ const MCP_APP_HTML = "Weather
";
+ const mcpAppToolItem = (status: "inProgress" | "completed") => ({
+ type: "mcpToolCall",
+ id: MCP_APP_ITEM,
+ server: "weather",
+ tool: "get_weather",
+ status,
+ arguments: { city: "Oslo" },
+ mcpAppResourceUri: MCP_APP_RESOURCE,
+ ...(status === "completed"
+ ? { result: { content: [{ type: "text", text: "Sunny" }], structuredContent: { temp: 21 } } }
+ : {}),
+ });
+
+ const mcpAppTranscript = makeCodexReplayTranscript({
+ scenario: MCP_APP_SCENARIO,
+ entries: [
+ ...codexReplayPreamble({
+ nativeThreadId: MCP_APP_NATIVE_THREAD,
+ nativeTurnId: MCP_APP_NATIVE_TURN,
+ prompt: MCP_APP_PROMPT,
+ }),
+ {
+ type: "emit_inbound",
+ label: "item/started/app-tool",
+ frame: {
+ method: "item/started",
+ params: {
+ item: mcpAppToolItem("inProgress"),
+ threadId: MCP_APP_NATIVE_THREAD,
+ turnId: MCP_APP_NATIVE_TURN,
+ startedAtMs: 1782622440500,
+ },
+ },
+ },
+ {
+ type: "emit_inbound",
+ label: "item/completed/app-tool",
+ frame: {
+ method: "item/completed",
+ params: {
+ item: mcpAppToolItem("completed"),
+ threadId: MCP_APP_NATIVE_THREAD,
+ turnId: MCP_APP_NATIVE_TURN,
+ completedAtMs: 1782622441500,
+ },
+ },
+ },
+ {
+ type: "emit_inbound",
+ label: "turn/completed",
+ frame: {
+ method: "turn/completed",
+ params: {
+ threadId: MCP_APP_NATIVE_THREAD,
+ turn: makeCodexReplayTurn({ id: MCP_APP_NATIVE_TURN, status: "completed" }),
+ },
+ },
+ },
+ {
+ type: "expect_outbound",
+ label: "mcpServer/resource/read",
+ frame: {
+ id: 4,
+ method: "mcpServer/resource/read",
+ params: { threadId: MCP_APP_NATIVE_THREAD, server: "weather", uri: MCP_APP_RESOURCE },
+ },
+ },
+ {
+ type: "emit_inbound",
+ label: "mcpServer/resource/read",
+ frame: {
+ id: 4,
+ result: {
+ contents: [
+ {
+ uri: MCP_APP_RESOURCE,
+ mimeType: "text/html;profile=mcp-app",
+ text: MCP_APP_HTML,
+ _meta: { ui: { csp: { connectDomains: ["https://api.weather.test"] } } },
+ },
+ ],
+ },
+ },
+ },
+ ],
+ });
+
+ it.effect("captures an MCP app's resource and holds the turn open until it lands", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const appCaptured = yield* Deferred.make();
+ const harness = yield* makeCodexReplayHarness(mcpAppTranscript, (event) =>
+ event.type === "turn_item.updated" &&
+ event.turnItem.type === "dynamic_tool" &&
+ event.turnItem.status === "completed"
+ ? Deferred.succeed(appCaptured, event.turnItem)
+ : Effect.void,
+ );
+ const now = yield* DateTime.now;
+
+ yield* harness.runtime.startTurn(
+ makeCodexTestTurnInput({
+ threadId: harness.threadId,
+ providerThread: harness.providerThread,
+ now,
+ attemptId: RunAttemptId.make("attempt-codex-mcp-app"),
+ text: MCP_APP_PROMPT,
+ }),
+ );
+ const item = yield* Deferred.await(appCaptured);
+ assert.equal(harness.terminalEvents()[0]?.status, "completed");
+
+ // The completed tool row is emitted once, with the app, never as a bare row first.
+ const completedRows = harness.events.filter(
+ (event) =>
+ event.type === "turn_item.updated" &&
+ event.turnItem.type === "dynamic_tool" &&
+ event.turnItem.status === "completed",
+ );
+ assert.lengthOf(completedRows, 1);
+
+ assert.equal(item.type, "dynamic_tool");
+ const output = item.type === "dynamic_tool" ? (item.output as Record) : {};
+ const reference = readMcpAppReference(output[MCP_APP_OUTPUT_KEY]);
+ assert.deepEqual(
+ { ...reference, attachmentId: undefined },
+ {
+ attachmentId: undefined,
+ server: "weather",
+ tool: "get_weather",
+ resourceUri: MCP_APP_RESOURCE,
+ csp: { connectDomains: ["https://api.weather.test"] },
+ },
+ );
+ assert.deepEqual(output.result, {
+ content: [{ type: "text", text: "Sunny" }],
+ structuredContent: { temp: 21 },
+ });
+
+ const fileSystem = yield* FileSystem.FileSystem;
+ const stored = yield* fileSystem.readFileString(
+ resolveAttachmentPathById({
+ attachmentsDir: harness.serverConfig.attachmentsDir,
+ attachmentId: reference!.attachmentId,
+ })!,
+ );
+ // Stored with the app's declared policy ahead of its scripts.
+ assert.include(stored, "connect-src https://api.weather.test");
+ assert.isBelow(stored.indexOf("Content-Security-Policy"), stored.indexOf("