authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-07 09:44:08-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 09:44:08-07:00
log4d976d1fc233d3aa971fa7dcbf2e7316bfa3ac72
treee6bff08450862016a740e7b642e4230806b548c7
parent6ecb82dc42c4c721fd2c03c92316ee5361306cdb
signature Signed by PGP key B5690EEEBB952194

feat: MCP apps render and run inline in threads (#16236)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

83 files changed, 4990 insertions(+), 55 deletions(-)

apps/mobile/src/Stack.tsx+10
......@@ -51,6 +51,7 @@ import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet";
5151import { ThreadAgentsSheet } from "./features/threads/ThreadAgentsSheet";
5252import { ThreadQueueSheet } from "./features/threads/ThreadQueueControl";
5353import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen";
54import { McpAppFullscreenScreen } from "./features/threads/McpAppFullscreenScreen";
5455import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen";
5556import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen";
5657import { HomeRouteScreen } from "./features/home/HomeRouteScreen";
......@@ -747,6 +748,15 @@ const RootStackConfig = createNativeStackNavigator({
747748 linking: `${THREAD_LINKING_PREFIX}/files/:path*`,
748749 options: SOLID_HEADER_OPTIONS,
749750 }),
751 ThreadMcpApp: createNativeStackScreen({
752 screen: McpAppFullscreenScreen,
753 linking: `${THREAD_LINKING_PREFIX}/apps/:itemId`,
754 options: {
755 presentation: "fullScreenModal",
756 headerShown: false,
757 gestureEnabled: false,
758 },
759 }),
750760 ThreadAttachment: createNativeStackScreen({
751761 screen: AttachmentFileScreen,
752762 linking: `${THREAD_LINKING_PREFIX}/attachments/:attachmentId`,
apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx created+168
......@@ -0,0 +1,168 @@
1import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
2import { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts";
3import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
4import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail";
5import { mcpAppFromToolItem } from "@t3tools/shared/toolOutput";
6import { useCallback, useEffect, useMemo, useState } from "react";
7import { Pressable, View } from "react-native";
8import { useSafeAreaInsets } from "react-native-safe-area-context";
9
10import { AppText as Text } from "../../components/AppText";
11import { SymbolView } from "../../components/AppSymbol";
12import { orchestrationEnvironment } from "../../state/orchestration";
13import { scopeThreadRef } from "@t3tools/client-runtime/environment";
14import { useThreadShell, useThreadShells } from "../../state/entities";
15import { useEnvironmentQuery } from "../../state/query";
16import { ThreadMcpApp } from "./McpAppWebView";
17
18type McpAppFullscreenScreenProps = StaticScreenProps<{
19 readonly environmentId: string;
20 readonly threadId: string;
21 readonly conversationThreadId?: string;
22 readonly itemId: string;
23 readonly revision?: string;
24}>;
25
26/**
27 * The thread an app's messages go to. A link only names it, so it counts only
28 * when it is the app's own thread or a fork of it; anything else falls back to
29 * the app's thread.
30 */
31function useConversationThreadId(
32 environmentId: EnvironmentId,
33 threadId: ThreadId,
34 requested: string | undefined,
35): ThreadId {
36 const shells = useThreadShells();
37 return useMemo(
38 () =>
39 requested !== undefined && descendsFrom(shells, environmentId, requested, threadId)
40 ? ThreadId.make(requested)
41 : threadId,
42 [shells, environmentId, threadId, requested],
43 );
44}
45
46/** True when `threadId` is `ancestorId` or one of its forks, at any depth. */
47function descendsFrom(
48 shells: ReadonlyArray<EnvironmentThreadShell>,
49 environmentId: EnvironmentId,
50 threadId: string,
51 ancestorId: ThreadId,
52): boolean {
53 const parents = new Map(
54 shells
55 .filter((shell) => shell.environmentId === environmentId)
56 // Only forks continue a conversation; a subagent is its own.
57 .filter((shell) => shell.lineage.relationshipToParent === "fork")
58 .map((shell) => [shell.id as string, shell.lineage.parentThreadId]),
59 );
60 let current: string | null | undefined = threadId;
61 for (let depth = 0; depth < 64 && current != null; depth += 1) {
62 if (current === ancestorId) return true;
63 current = parents.get(current);
64 }
65 return false;
66}
67
68/**
69 * An MCP App full screen (`ui/request-display-mode`), in a modal over the
70 * thread. It is its own view of the app; closing it returns to the row. The
71 * app is read from its stored tool call, never from the route, so a link can
72 * only open an app that call really produced.
73 */
74export function McpAppFullscreenScreen({ route }: McpAppFullscreenScreenProps) {
75 const navigation = useNavigation();
76 const insets = useSafeAreaInsets();
77 // A plain goBack pops whatever is on top, so it only runs while this modal
78 // is: an exit that lands while another screen covers it (after a teardown
79 // wait) waits for the modal to be on top again, rather than popping that
80 // screen or being lost.
81 const focused = useIsFocused();
82 const [exitRequested, setExitRequested] = useState(false);
83 const onClose = useCallback(() => {
84 if (navigation.isFocused()) navigation.goBack();
85 else setExitRequested(true);
86 }, [navigation]);
87 const [size, setSize] = useState({ width: 0, height: 0 });
88 const params = route.params;
89 const environmentId = EnvironmentId.make(params.environmentId);
90 const threadId = ThreadId.make(params.threadId);
91 const itemId = TurnItemId.make(params.itemId);
92 const conversationThreadId = useConversationThreadId(
93 environmentId,
94 threadId,
95 params.conversationThreadId,
96 );
97 const detail = useEnvironmentQuery(
98 orchestrationEnvironment.turnItem({
99 environmentId,
100 input: {
101 threadId,
102 itemId,
103 ...(params.revision === undefined ? {} : { revision: params.revision }),
104 },
105 }),
106 );
107 const item = detail.data?.item;
108 // One reference per app: the item is a new object on every refetch (a
109 // reconnect, for one), and a new reference would rebuild the app's host.
110 const derived = item?.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined;
111 const [app, setApp] = useState(derived);
112 if (derived?.attachmentId !== app?.attachmentId) setApp(derived);
113 // The approval or question the agent waits on renders on the thread this
114 // modal covers, so the app steps aside for it.
115 const conversation = useThreadShell(scopeThreadRef(environmentId, conversationThreadId));
116 const awaitingUser =
117 conversation?.hasPendingApprovals === true || conversation?.hasPendingUserInput === true;
118 useEffect(() => {
119 if ((awaitingUser || exitRequested) && focused) navigation.goBack();
120 }, [awaitingUser, exitRequested, focused, navigation]);
121
122 return (
123 <View className="flex-1 bg-background" style={{ paddingTop: insets.top }}>
124 <View className="h-11 flex-row items-center justify-between px-3">
125 <Text className="text-base font-semibold text-foreground" numberOfLines={1}>
126 {app?.server ?? "App"}
127 </Text>
128 <Pressable
129 accessibilityRole="button"
130 accessibilityLabel="Exit full screen"
131 hitSlop={8}
132 onPress={onClose}
133 >
134 <SymbolView name="xmark" size={18} tintColor="gray" />
135 </Pressable>
136 </View>
137 <View
138 className="flex-1"
139 style={{ paddingBottom: insets.bottom }}
140 onLayout={(event) =>
141 setSize({
142 width: Math.round(event.nativeEvent.layout.width),
143 height: Math.round(event.nativeEvent.layout.height - insets.bottom),
144 })
145 }
146 >
147 {app === undefined ? (
148 detail.data === undefined ? null : (
149 <Text className="m-6 text-sm text-foreground-muted">This app cannot be shown.</Text>
150 )
151 ) : size.width > 0 ? (
152 <ThreadMcpApp
153 environmentId={environmentId}
154 threadId={threadId}
155 conversationThreadId={conversationThreadId}
156 itemId={itemId}
157 revision={params.revision ?? (item == null ? "" : turnItemDetailRevision(item))}
158 app={app}
159 width={size.width}
160 height={size.height}
161 displayMode="fullscreen"
162 onExitFullscreen={onClose}
163 />
164 ) : null}
165 </View>
166 </View>
167 );
168}
apps/mobile/src/features/threads/McpAppWebView.tsx created+611
......@@ -0,0 +1,611 @@
1import type { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts";
2import {
3 makeMcpAppHost,
4 McpAppHostRefusal,
5 mcpAppStyleVariables,
6 mcpResourceBytes,
7 type McpAppCallToolResult,
8 type McpAppHost,
9 type McpAppHostContext,
10} from "@t3tools/client-runtime/mcp-apps";
11import { scopeThreadRef } from "@t3tools/client-runtime/environment";
12import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
13import { CommandId, MessageId } from "@t3tools/contracts";
14import {
15 mcpAppAllowAttribute,
16 mcpAppFileName,
17 mcpAppReferencesEqual,
18 type McpAppReference,
19} from "@t3tools/shared/mcpApp";
20import * as Predicate from "effect/Predicate";
21import Constants from "expo-constants";
22import { useNavigation } from "@react-navigation/native";
23import { useEffect, useMemo, useRef, useState } from "react";
24import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native";
25import { useSafeAreaInsets } from "react-native-safe-area-context";
26import { WebView, type WebViewMessageEvent } from "react-native-webview";
27
28import { AppText as Text } from "../../components/AppText";
29import { shareGeneratedAttachment } from "../../lib/attachmentDownload";
30import { mobileHtmlRenderTheme } from "../../lib/htmlRenderTheme";
31import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
32import { uuidv4 } from "../../lib/uuid";
33import { useAssetUrlState } from "../../state/assets";
34import { useThreadShell } from "../../state/entities";
35import { mcpAppEnvironment } from "../../state/mcpApps";
36import { orchestrationEnvironment } from "../../state/orchestration";
37import { useEnvironmentQuery } from "../../state/query";
38import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
39import { useAtomCommand } from "../../state/use-atom-command";
40import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
41
42/** The feed reserves a fixed box for an app; a taller app scrolls inside it. */
43const MCP_APP_ROW_HEIGHT = 420;
44const ROW_BOTTOM_MARGIN = 8;
45
46export function mcpAppRowHeight() {
47 return MCP_APP_ROW_HEIGHT + ROW_BOTTOM_MARGIN;
48}
49
50// The WebView loads a tiny outer page that hosts the app in a real
51// opaque-origin iframe, as web does, so the app's `window.parent` and the
52// `event.source` of host replies are a real window, which the MCP Apps SDK
53// requires. The outer page only relays: app → React Native, and host
54// replies (injected as `__t3McpAppReceive(...)`) → app.
55//
56// Android exposes `ReactNativeWebView` to every frame, so a frame nested in
57// the app could post to React Native directly. The outer page therefore wraps
58// what it relays with a secret only it holds, and React Native drops anything
59// else. The bridge belongs to the captured document: once the app frame loads
60// a second time (it navigated itself), the outer page stops relaying and says
61// so, rather than letting a page T3 never served pose as the app. This is not
62// a confidentiality boundary: a frame can always navigate itself, so the app
63// could carry anything it read out in a URL either way.
64function outerDocument(src: string, allow: string, secret: string) {
65 const attribute = (value: string) =>
66 value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
67 return `<!doctype html><html><head><meta name="viewport" content="width=device-width, initial-scale=1">
68<style>html,body{margin:0;height:100%;background:transparent}iframe{border:0;display:block;width:100%;height:100%}</style></head>
69<body><iframe id="app" sandbox="allow-scripts allow-forms" allow="${attribute(allow)}"></iframe>
70<script>(function(){var frame=document.getElementById("app"),secret=${JSON.stringify(secret)},loads=0,live=true;
71var send=function(m){window.ReactNativeWebView.postMessage(JSON.stringify({secret:secret,message:m}));};
72frame.addEventListener("load",function(){loads+=1;if(loads>1&&live){live=false;send({t3:"navigated"});}});
73window.addEventListener("message",function(e){if(live&&e.source===frame.contentWindow)send(e.data);});
74window.__t3McpAppReceive=function(m){live&&frame.contentWindow&&frame.contentWindow.postMessage(m,"*");};
75frame.src=${JSON.stringify(src).replace(/</g, "\\u003c")};})();</script></body></html>`;
76}
77
78const commandFailure = (result: {
79 readonly cause: Parameters<typeof squashAtomCommandFailure>[0]["cause"];
80}) => {
81 const error = squashAtomCommandFailure(result);
82 return new McpAppHostRefusal(
83 error instanceof Error && error.message.trim() !== "" ? error.message : "Request failed.",
84 );
85};
86
87const confirm = (title: string, message: string, action: string) =>
88 new Promise<boolean>((resolve) => {
89 Alert.alert(
90 title,
91 message,
92 [
93 { text: "Cancel", style: "cancel", onPress: () => resolve(false) },
94 { text: action, onPress: () => resolve(true) },
95 ],
96 // Android dismisses an open alert when the next one shows; a dismissed
97 // request is declined rather than left holding an in-flight slot.
98 { cancelable: true, onDismiss: () => resolve(false) },
99 );
100 });
101
102/** Largest file an app may hand the user through `ui/download-file`. */
103const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
104
105/**
106 * A captured MCP App, hosted in a WebView over the MCP Apps bridge: inline as
107 * a fixed row of the thread feed, or full screen in its own modal screen. A
108 * WebView cannot move between the two without reloading, so each is its own
109 * view of the app: the inline one is torn down before full screen opens, and
110 * comes back when it closes.
111 */
112export function ThreadMcpApp(props: {
113 readonly environmentId: EnvironmentId;
114 /** The thread that produced the app, which its requests run against. */
115 readonly threadId: ThreadId;
116 /** The thread on screen, which approved app messages are sent to. */
117 readonly conversationThreadId: ThreadId;
118 readonly itemId: TurnItemId;
119 readonly revision: string;
120 readonly app: McpAppReference;
121 readonly width: number;
122 /** Full screen fills its screen; inline is the feed's fixed row. */
123 readonly displayMode?: "inline" | "fullscreen";
124 /** Full screen only: leaves it, back to the inline row. */
125 readonly onExitFullscreen?: () => void;
126 /** Full screen only: the screen's height, which the app is told it fills. */
127 readonly height?: number;
128}) {
129 // One reference per app: a new object with the same content (a refetch, a
130 // rerender) must not rebuild the host of a document that is already live.
131 const [app, setApp] = useState(props.app);
132 if (!mcpAppReferencesEqual(app, props.app)) setApp(props.app);
133 const fullscreen = props.displayMode === "fullscreen";
134 const navigation = useNavigation();
135 const insets = useSafeAreaInsets();
136 // The app asked to be closed; the row falls back to a note that brings it back.
137 const [closed, setClosed] = useState(false);
138 // Full screen is a separate view of the app, so the inline one steps aside
139 // (and is torn down) while it is open, and comes back when the thread is
140 // shown again.
141 const [presentedFullscreen, setPresentedFullscreen] = useState(false);
142 // Counts the documents this row has shown on purpose (back from full screen,
143 // or reopened after closing); each gets its own view and host.
144 const [documentKey, setDocumentKeyState] = useState(0);
145
146 const { themeId, themeAppearance, themeVariables, systemColorsActive } =
147 useAppearancePreferences();
148 const theme = useMemo(
149 () =>
150 mobileHtmlRenderTheme({
151 themeId,
152 appearance: themeAppearance,
153 variables: themeVariables,
154 systemColors: systemColorsActive,
155 platform: Platform.OS,
156 }),
157 [themeId, themeAppearance, themeVariables, systemColorsActive],
158 );
159 const resource = useMemo(
160 () => ({
161 _tag: "attachment" as const,
162 attachmentId: app.attachmentId,
163 fileName: mcpAppFileName(app),
164 mimeType: "text/html",
165 disposition: "inline" as const,
166 }),
167 [app],
168 );
169 const asset = useAssetUrlState(props.environmentId, resource);
170 // The view keeps its first URL: a re-minted one would reload the app.
171 const [uri, setUri] = useState<string | null>(null);
172 if (uri === null && asset._tag === "Success") setUri(asset.url);
173 const [loaded, setLoaded] = useState(false);
174 const [navigatedAway, setNavigatedAway] = useState(false);
175 // Bumped when the OS reclaims the web process: a fresh view, document and
176 // host, so the reloaded app is initialized and replayed again. Once only, so
177 // an app that keeps crashing its process is not reloaded forever.
178 const [generation, setGeneration] = useState(0);
179 const [crashed, setCrashed] = useState(false);
180 const restart = () => {
181 if (generation > 0) {
182 setCrashed(true);
183 return;
184 }
185 setLoaded(false);
186 // The first URL's token may have expired by now; the asset query keeps
187 // a refreshed one, which this new view can load.
188 if (asset._tag === "Success") setUri(asset.url);
189 setGeneration(1);
190 };
191 // Minted per view, so only this view's outer page can speak for its app.
192 const [secret] = useState(uuidv4);
193 // A new document starts loading, is not yet one that navigated away, and
194 // loads the asset query's current URL: the first one's token may be gone.
195 const setDocumentKey = (next: (value: number) => number) => {
196 setLoaded(false);
197 setNavigatedAway(false);
198 if (asset._tag === "Success") setUri(asset.url);
199 setDocumentKeyState(next);
200 };
201 // Back from full screen: the thread shows a fresh inline view of the app.
202 // The listener reads the latest helper, whose URL comes from this render.
203 const openNewDocument = useRef(setDocumentKey);
204 useEffect(() => {
205 openNewDocument.current = setDocumentKey;
206 });
207 useEffect(() => {
208 if (!presentedFullscreen) return;
209 return navigation.addListener("focus", () => {
210 openNewDocument.current((value) => value + 1);
211 setPresentedFullscreen(false);
212 });
213 }, [navigation, presentedFullscreen]);
214
215 // The feed omits tool input and output; the app needs both.
216 const detail = useEnvironmentQuery(
217 orchestrationEnvironment.turnItem({
218 environmentId: props.environmentId,
219 input: { threadId: props.threadId, itemId: props.itemId, revision: props.revision },
220 }),
221 );
222 const storedItem = detail.data?.item;
223 const toolCall = useMemo(() => {
224 if (storedItem?.type !== "dynamic_tool") return undefined;
225 const output = storedItem.output as { readonly result?: unknown } | undefined;
226 return {
227 arguments: storedItem.input,
228 result: output?.result as McpAppCallToolResult | undefined,
229 };
230 }, [storedItem]);
231
232 const callTool = useAtomCommand(mcpAppEnvironment.callTool, { reportFailure: false });
233 const toolInfo = useAtomCommand(mcpAppEnvironment.toolInfo, { reportFailure: false });
234 const readResource = useAtomCommand(mcpAppEnvironment.readResource, { reportFailure: false });
235 const updateModelContext = useAtomCommand(mcpAppEnvironment.updateModelContext, {
236 reportFailure: false,
237 });
238 // The tool's definition, which the app receives as `toolInfo` at initialize.
239 const [toolDefinition, setToolDefinition] = useState<unknown>(undefined);
240 // Read by the host on every message, so it always sees current values
241 // without being rebuilt (which would drop the app's session).
242 // The approval or question the agent waits on renders on this thread, and a
243 // full-screen app would cover it.
244 const conversation = useThreadShell(
245 scopeThreadRef(props.environmentId, props.conversationThreadId),
246 );
247 const awaitingUser =
248 conversation?.hasPendingApprovals === true || conversation?.hasPendingUserInput === true;
249 const live = {
250 awaitingUser,
251 theme,
252 props,
253 callTool,
254 toolInfo,
255 readResource,
256 updateModelContext,
257 insets,
258 toolDefinition,
259 navigation,
260 };
261 const latest = useRef(live);
262 useEffect(() => {
263 latest.current = live;
264 });
265 useEffect(() => {
266 let cancelled = false;
267 void latest.current
268 .toolInfo({
269 environmentId: props.environmentId,
270 input: { threadId: props.threadId, itemId: props.itemId, name: app.tool },
271 })
272 .then((info) => {
273 if (!cancelled && info._tag === "Success" && info.value.tool !== undefined) {
274 setToolDefinition(info.value.tool);
275 }
276 });
277 return () => {
278 cancelled = true;
279 };
280 }, [props.environmentId, props.threadId, props.itemId, app.tool]);
281 const webView = useRef<WebView<object>>(null);
282 const hostRef = useRef<McpAppHost | null>(null);
283
284 // One host per loaded document.
285 useEffect(() => {
286 if (uri === null) return;
287 const scope = () => {
288 const { environmentId, threadId, itemId } = latest.current.props;
289 return { environmentId, input: { threadId, itemId } };
290 };
291 const hostContext = (): McpAppHostContext => {
292 const current = latest.current;
293 const isFullscreen = current.props.displayMode === "fullscreen";
294 return {
295 theme: current.theme.appearance,
296 styles: { variables: mcpAppStyleVariables(current.theme.variables) },
297 displayMode: isFullscreen ? "fullscreen" : "inline",
298 availableDisplayModes: ["inline", "fullscreen"],
299 // Both are fixed boxes, so the app is told its exact size.
300 containerDimensions: {
301 width: current.props.width,
302 height: isFullscreen ? (current.props.height ?? MCP_APP_ROW_HEIGHT) : MCP_APP_ROW_HEIGHT,
303 },
304 platform: "mobile",
305 locale: Intl.DateTimeFormat().resolvedOptions().locale,
306 timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
307 userAgent: `t3-code/${Constants.expoConfig?.version ?? "0.0.0"}`,
308 deviceCapabilities: { touch: true, hover: false },
309 // The full-screen modal keeps the top and bottom insets for its
310 // header and the home indicator; only the sides reach the app.
311 safeAreaInsets: isFullscreen
312 ? { top: 0, right: current.insets.right, bottom: 0, left: current.insets.left }
313 : { top: 0, right: 0, bottom: 0, left: 0 },
314 ...(current.toolDefinition === undefined
315 ? {}
316 : { toolInfo: { tool: current.toolDefinition } }),
317 };
318 };
319 const next = makeMcpAppHost({
320 app,
321 hostVersion: Constants.expoConfig?.version ?? "0.0.0",
322 post: (message) =>
323 webView.current?.injectJavaScript(
324 `window.__t3McpAppReceive&&window.__t3McpAppReceive(${JSON.stringify(message)});true;`,
325 ),
326 hostContext,
327 callTool: async ({ name, arguments: args }) => {
328 const { environmentId, input } = scope();
329 const info = await latest.current.toolInfo({ environmentId, input: { ...input, name } });
330 if (info._tag !== "Success") throw commandFailure(info);
331 if (!info.value.callable) throw new McpAppHostRefusal("This app cannot call that tool.");
332 if (
333 !info.value.readOnly &&
334 !(await confirm(
335 `Allow ${app.server} to run ${info.value.title ?? name}?`,
336 JSON.stringify(args, null, 2),
337 "Allow",
338 ))
339 ) {
340 throw new McpAppHostRefusal("Declined by the user.");
341 }
342 const result = await latest.current.callTool({
343 environmentId,
344 input: { ...input, name, arguments: args },
345 });
346 if (result._tag !== "Success") throw commandFailure(result);
347 return result.value;
348 },
349 readResource: async ({ uri: resourceUri }) => {
350 const { environmentId, input } = scope();
351 const result = await latest.current.readResource({
352 environmentId,
353 input: { ...input, uri: resourceUri },
354 });
355 if (result._tag !== "Success") throw commandFailure(result);
356 return result.value;
357 },
358 openLink: async (url) => {
359 // A WebView cannot tell whether the reader just tapped the app, so it
360 // asks, rather than letting an app leave T3 on a timer.
361 if (!(await confirm(`Open a link from ${app.server}?`, url, "Open"))) {
362 throw new McpAppHostRefusal("Declined by the user.");
363 }
364 if (!(await tryOpenExternalUrl(url, "mcp-app"))) {
365 throw new McpAppHostRefusal("The link could not be opened.");
366 }
367 },
368 sendMessage: async (text) => {
369 if (!(await confirm(`Send this message from ${app.server}?`, text, "Send"))) {
370 throw new McpAppHostRefusal("Declined by the user.");
371 }
372 // Through the outbox like a typed message, so it survives a dropped
373 // connection; the thread's own settings fill in when it sends.
374 await enqueueThreadOutboxMessage({
375 environmentId: latest.current.props.environmentId,
376 threadId: latest.current.props.conversationThreadId,
377 messageId: MessageId.make(uuidv4()),
378 commandId: CommandId.make(uuidv4()),
379 text,
380 attachments: [],
381 dispatchMode: "queue",
382 createdAt: new Date().toISOString(),
383 });
384 },
385 updateModelContext: async (context) => {
386 const { environmentId, input } = scope();
387 const result = await latest.current.updateModelContext({
388 environmentId,
389 input: {
390 ...input,
391 ...context,
392 conversationThreadId: latest.current.props.conversationThreadId,
393 },
394 });
395 if (result._tag !== "Success") throw commandFailure(result);
396 },
397 requestDisplayMode: async (mode) => {
398 const current = latest.current.props;
399 // Full screen would cover the approval or question the agent waits
400 // on, or a screen the user moved to.
401 if (
402 mode === "fullscreen" &&
403 (latest.current.awaitingUser || !latest.current.navigation.isFocused())
404 ) {
405 return current.displayMode ?? "inline";
406 }
407 if (mode === "fullscreen" && current.displayMode !== "fullscreen") {
408 // The inline view is torn down by the switch (this row unmounts
409 // while the modal covers it); the modal opens a fresh view.
410 // The inline view steps aside for the modal; it gets its teardown first.
411 await hostRef.current?.teardown();
412 // The wait may have let an approval arrive, or the user moved to
413 // another screen; either way the view reloads inline.
414 if (latest.current.awaitingUser || !latest.current.navigation.isFocused()) {
415 openNewDocument.current((value) => value + 1);
416 return "inline";
417 }
418 setPresentedFullscreen(true);
419 latest.current.navigation.navigate("ThreadMcpApp", {
420 environmentId: String(current.environmentId),
421 threadId: String(current.threadId),
422 conversationThreadId: String(current.conversationThreadId),
423 itemId: String(current.itemId),
424 revision: current.revision,
425 });
426 return "fullscreen";
427 }
428 if (mode === "inline" && current.displayMode === "fullscreen") {
429 await hostRef.current?.teardown();
430 current.onExitFullscreen?.();
431 return "inline";
432 }
433 return current.displayMode ?? "inline";
434 },
435 downloadFile: async (files) => {
436 const names = files.map((file) => file.name).join(", ");
437 if (!(await confirm(`Save a file from ${app.server}?`, names, "Save"))) {
438 throw new McpAppHostRefusal("Declined by the user.");
439 }
440 for (const file of files) {
441 // A linked file is read from the app's own server, like its other reads.
442 let bytes: Uint8Array | undefined;
443 let mimeType = file.mimeType ?? "application/octet-stream";
444 if (file._tag === "embedded") {
445 bytes = file.bytes;
446 } else {
447 const { environmentId, input } = scope();
448 const read = await latest.current.readResource({
449 environmentId,
450 input: { ...input, uri: file.uri },
451 });
452 if (read._tag !== "Success") throw commandFailure(read);
453 const content = read.value.contents[0];
454 bytes = mcpResourceBytes(content);
455 const declared = (content as { readonly mimeType?: unknown } | undefined)?.mimeType;
456 if (typeof declared === "string") mimeType = declared;
457 }
458 if (bytes === undefined) throw new McpAppHostRefusal(`${file.name} has no contents.`);
459 if (bytes.byteLength > MAX_DOWNLOAD_BYTES) {
460 throw new McpAppHostRefusal(`${file.name} is too large to save.`);
461 }
462 const shared = await shareGeneratedAttachment({
463 bytes,
464 attachment: { name: file.name, mimeType },
465 signal: new AbortController().signal,
466 });
467 if (!shared) throw new McpAppHostRefusal("Sharing is not available on this device.");
468 }
469 },
470 onRequestTeardown: () => {
471 const current = latest.current.props;
472 if (current.displayMode === "fullscreen") {
473 void hostRef.current?.teardown().then(() => current.onExitFullscreen?.());
474 } else {
475 void hostRef.current?.teardown().then(() => setClosed(true));
476 }
477 },
478 // Both modes are fixed boxes, so the app's own height only decides
479 // whether it scrolls inside one.
480 onSizeChanged: () => undefined,
481 });
482 hostRef.current = next;
483 return () => {
484 // An unmount cannot wait: the request goes out before the view does.
485 void next.teardown();
486 hostRef.current = null;
487 };
488 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A restarted view needs a new host.
489 }, [uri, app, generation, documentKey]);
490
491 // The host reads the context through `latest`; these only say when to resend.
492 useEffect(() => {
493 hostRef.current?.updateHostContext();
494 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Context changes trigger a resend.
495 }, [theme, props.width, props.height, insets, toolDefinition]);
496 // A new document gets a new host, which needs the call again.
497 useEffect(() => {
498 if (toolCall !== undefined) hostRef.current?.setToolCall(toolCall);
499 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Each new document needs the call.
500 }, [toolCall, uri, generation, documentKey]);
501
502 const source = useMemo(
503 () =>
504 uri === null
505 ? null
506 : { html: outerDocument(uri, mcpAppAllowAttribute(app.permissions), secret) },
507 [uri, app.permissions, secret],
508 );
509
510 if (presentedFullscreen) {
511 return (
512 <View
513 style={{ height: MCP_APP_ROW_HEIGHT, marginBottom: ROW_BOTTOM_MARGIN }}
514 className="items-center justify-center rounded-lg border border-border"
515 >
516 <Text className="text-sm text-foreground-muted">
517 The {app.server} app is open full screen
518 </Text>
519 </View>
520 );
521 }
522
523 if (closed) {
524 return (
525 <View
526 style={{ height: MCP_APP_ROW_HEIGHT, marginBottom: ROW_BOTTOM_MARGIN }}
527 className="items-center justify-center gap-2 rounded-lg border border-border"
528 >
529 <Text className="text-sm text-foreground-muted">The {app.server} app was closed</Text>
530 <Pressable
531 accessibilityRole="button"
532 onPress={() => {
533 setDocumentKey((value) => value + 1);
534 setClosed(false);
535 }}
536 >
537 <Text className="text-sm text-foreground">Show app</Text>
538 </Pressable>
539 </View>
540 );
541 }
542
543 return (
544 <View
545 style={
546 fullscreen ? { flex: 1 } : { height: MCP_APP_ROW_HEIGHT, marginBottom: ROW_BOTTOM_MARGIN }
547 }
548 >
549 {navigatedAway ? (
550 <View className="flex-1 items-center justify-center">
551 <Text className="text-sm text-foreground-muted">
552 The {app.server} app left its page and was stopped
553 </Text>
554 </View>
555 ) : uri !== null && !crashed ? (
556 <WebView<object>
557 key={`${documentKey}:${generation}`}
558 ref={webView}
559 onContentProcessDidTerminate={restart}
560 onRenderProcessGone={restart}
561 source={source!}
562 accessibilityLabel={`${app.server} app`}
563 style={{ flex: 1, backgroundColor: "transparent" }}
564 nestedScrollEnabled
565 originWhitelist={["*"]}
566 // Only the outer page loads at the top; the app opens links through
567 // the bridge. Android reports every navigation as top-frame, frames
568 // the app nests included, so it has no gate to apply: there the
569 // outer page never navigates, and the app frame's sandbox already
570 // forbids top navigation and popups.
571 onShouldStartLoadWithRequest={(request) =>
572 Platform.OS === "android" ||
573 request.isTopFrame === false ||
574 request.url === "about:blank"
575 }
576 setSupportMultipleWindows={false}
577 onLoadEnd={() => setLoaded(true)}
578 // The outer page is inline HTML, so these report a broken view, not
579 // an app page; the row shows the failure instead of a blank box.
580 onError={() => setCrashed(true)}
581 onHttpError={() => setCrashed(true)}
582 onMessage={(event: WebViewMessageEvent) => {
583 let envelope: unknown;
584 try {
585 envelope = JSON.parse(event.nativeEvent.data);
586 } catch {
587 return; // Not JSON: not a bridge message.
588 }
589 if (!Predicate.isObject(envelope) || envelope.secret !== secret) return;
590 const message = envelope.message;
591 if (Predicate.isObject(message) && message.t3 === "navigated") {
592 hostRef.current?.dispose();
593 setNavigatedAway(true);
594 return;
595 }
596 hostRef.current?.receive(message);
597 }}
598 />
599 ) : asset._tag === "Failure" || crashed ? (
600 <View className="flex-1 items-center justify-center">
601 <Text className="text-sm text-foreground-muted">Unable to load the {app.server} app</Text>
602 </View>
603 ) : null}
604 {uri !== null && !loaded && !crashed && !navigatedAway ? (
605 <View pointerEvents="none" className="absolute inset-0 items-center justify-center">
606 <ActivityIndicator />
607 </View>
608 ) : null}
609 </View>
610 );
611}
apps/mobile/src/features/threads/ThreadFeed.tsx+16
......@@ -188,6 +188,7 @@ import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
188188import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size";
189189import { htmlRenderFrameHeight } from "@t3tools/shared/htmlRender";
190190import { htmlRenderRowHeight, ThreadHtmlRender } from "./HtmlRenderWebView";
191import { mcpAppRowHeight, ThreadMcpApp } from "./McpAppWebView";
191192import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
192193import {
193194 assetEnvironment,
......@@ -1597,6 +1598,20 @@ function renderFeedEntry(
15971598 );
15981599 }
15991600
1601 if (entry.type === "mcp-app") {
1602 return (
1603 <ThreadMcpApp
1604 environmentId={props.environmentId}
1605 threadId={entry.sourceThreadId}
1606 conversationThreadId={props.threadId}
1607 itemId={entry.itemId}
1608 revision={entry.revision}
1609 app={entry.app}
1610 width={props.contentWidth}
1611 />
1612 );
1613 }
1614
16001615 if (entry.type === "work-toggle") {
16011616 return (
16021617 <ThreadWorkGroupToggle
......@@ -2914,6 +2929,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
29142929 if (entry.type === "html-render") {
29152930 return htmlRenderRowHeight(htmlRenderFrameHeight(entry.render, contentWidth));
29162931 }
2932 if (entry.type === "mcp-app") return mcpAppRowHeight();
29172933 if (workRowSizing.fixedRowHeight === undefined) {
29182934 return undefined;
29192935 }
apps/mobile/src/lib/attachmentDownload.ts+23
......@@ -274,3 +274,26 @@ export async function shareLocalAttachment(input: {
274274 cached.preview.dispose();
275275 }
276276}
277
278/**
279 * Shares bytes made on this device (an MCP App's download) through the same
280 * cache and share sheet. False when the share sheet could not be shown.
281 */
282export async function shareGeneratedAttachment(input: {
283 readonly bytes: Uint8Array;
284 readonly attachment: AttachmentFileMetadata;
285 readonly signal: AbortSignal;
286}): Promise<boolean> {
287 // An unavailable share sheet is a refusal for the caller, not a failure.
288 const sharing = await availableSharing(input.signal).catch(() => null);
289 if (sharing === null) return false;
290 const cached = await createCachedAttachmentFile(input.attachment);
291 try {
292 if (input.signal.aborted) return false;
293 cached.file.write(input.bytes);
294 await cached.preview.share(input.signal);
295 return true;
296 } finally {
297 cached.preview.dispose();
298 }
299}
apps/mobile/src/lib/openExternalUrl.ts+1
......@@ -7,6 +7,7 @@ const ExternalUrlTarget = Schema.Literals([
77 "pull-request",
88 "provider-auth",
99 "html-render",
10 "mcp-app",
1011]);
1112
1213export type ExternalUrlTarget = typeof ExternalUrlTarget.Type;
apps/mobile/src/lib/threadActivity.test.ts+32
......@@ -2511,3 +2511,35 @@ describe("html renders", () => {
25112511 }
25122512 });
25132513});
2514
2515describe("MCP apps", () => {
2516 const app = {
2517 attachmentId: "attachment-app",
2518 server: "weather",
2519 tool: "get_weather",
2520 resourceUri: "ui://weather/dashboard",
2521 };
2522 const appCall = (status: OrchestrationV2TurnItem["status"]): OrchestrationV2TurnItem => ({
2523 ...base("item-app", "2026-06-20T00:00:02.500Z", 2),
2524 status,
2525 type: "dynamic_tool",
2526 toolName: "weather.get_weather",
2527 input: { city: "Oslo" },
2528 output: { t3McpApp: app, result: { content: [] } },
2529 });
2530 const feed = (status: OrchestrationV2TurnItem["status"]) =>
2531 buildThreadFeed([
2532 projected(userMessage(), 0),
2533 projected(appCall(status), 1),
2534 projected(assistantMessage("2026-06-20T00:00:04.000Z"), 2),
2535 ]);
2536
2537 it("hosts a completed app in place of its tool row, owned by its source item", () => {
2538 const entry = feed("completed").find((candidate) => candidate.type === "mcp-app");
2539 expect(entry).toMatchObject({ type: "mcp-app", app, itemId: "item-app", runId });
2540 });
2541
2542 it("keeps a running app call an ordinary work row", () => {
2543 expect(feed("running").some((candidate) => candidate.type === "mcp-app")).toBe(false);
2544 });
2545});
apps/mobile/src/lib/threadActivity.ts+44-3
......@@ -8,6 +8,7 @@ import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subage
88import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime";
99import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
1010import {
11 turnItemDetailRevision,
1112 turnItemHasDetail,
1213 turnItemNeedsDetailFetch,
1314} from "@t3tools/client-runtime/work-log/item-detail";
......@@ -48,6 +49,7 @@ import type {
4849 OrchestrationV2UserMessageInputIntent,
4950 RunAttemptId,
5051 ScheduledTaskId,
52 TurnItemId,
5153} from "@t3tools/contracts";
5254import { RunId, ThreadId } from "@t3tools/contracts";
5355import {
......@@ -59,7 +61,12 @@ import {
5961} from "@t3tools/shared/toolActivity";
6062import { formatDuration } from "@t3tools/shared/orchestrationTiming";
6163import type { HtmlRenderReference } from "@t3tools/shared/htmlRender";
62import { compactDynamicToolOutput, htmlRenderFromToolItem } from "@t3tools/shared/toolOutput";
64import type { McpAppReference } from "@t3tools/shared/mcpApp";
65import {
66 compactDynamicToolOutput,
67 htmlRenderFromToolItem,
68 mcpAppFromToolItem,
69} from "@t3tools/shared/toolOutput";
6370import * as DateTime from "effect/DateTime";
6471
6572export type PendingApproval = ThreadPendingApproval;
......@@ -165,12 +172,24 @@ type RawThreadFeedEntry =
165172 readonly createdAt: string;
166173 readonly runId: RunId | null;
167174 readonly render: HtmlRenderReference;
175 }
176 | {
177 /** An MCP App a completed tool call captured, hosted in place of its work row. */
178 readonly type: "mcp-app";
179 readonly id: string;
180 readonly createdAt: string;
181 readonly runId: RunId | null;
182 /** The thread and item that own the app; a fork's inherited app is its source's. */
183 readonly sourceThreadId: ThreadId;
184 readonly itemId: TurnItemId;
185 readonly revision: string;
186 readonly app: McpAppReference;
168187 };
169188
170189export type ThreadFeedEntry = ThreadFeedEntryContent & { readonly continuesWorkLog?: boolean };
171190
172191type ThreadFeedEntryContent =
173 | Extract<RawThreadFeedEntry, { type: "message" | "html-render" }>
192 | Extract<RawThreadFeedEntry, { type: "message" | "html-render" | "mcp-app" }>
174193 | {
175194 readonly type: "activity-group";
176195 readonly id: string;
......@@ -1049,7 +1068,9 @@ function deriveThreadFeedRunFolds(
10491068 const runId =
10501069 entry.type === "message" && entry.message.role === "assistant"
10511070 ? (entry.message.runId ?? runlessKey)
1052 : entry.type === "activity-group" || entry.type === "html-render"
1071 : entry.type === "activity-group" ||
1072 entry.type === "html-render" ||
1073 entry.type === "mcp-app"
10531074 ? (entry.runId ?? runlessKey)
10541075 : null;
10551076 if (!runId) continue;
......@@ -1102,6 +1123,7 @@ function deriveThreadFeedRunFolds(
11021123 entry.id !== firstAssistantId &&
11031124 entry.id !== terminalAssistantId &&
11041125 entry.type !== "html-render" &&
1126 entry.type !== "mcp-app" &&
11051127 !(
11061128 entry.type === "activity-group" &&
11071129 entry.activities.some(
......@@ -1750,6 +1772,25 @@ export function buildThreadFeed(
17501772 entries.push(entry);
17511773 continue;
17521774 }
1775 const app =
1776 item.type === "dynamic_tool" && item.status === "completed"
1777 ? mcpAppFromToolItem(item)
1778 : undefined;
1779 if (app) {
1780 const entry: RawThreadFeedEntry = {
1781 type: "mcp-app",
1782 id: `mcp-app:${row.visibility}:${row.sourceThreadId}:${row.sourceItemId}`,
1783 createdAt,
1784 runId: item.runId,
1785 sourceThreadId: row.sourceThreadId,
1786 itemId: row.sourceItemId,
1787 revision: turnItemDetailRevision(item),
1788 app,
1789 };
1790 projectedEntriesCache.set(row, { attemptId, entry });
1791 entries.push(entry);
1792 continue;
1793 }
17531794 if (item.type === "user_message" || item.type === "assistant_message") {
17541795 const updatedAt = DateTime.formatIso(item.updatedAt);
17551796 const entry: RawThreadFeedEntry = {
apps/mobile/src/state/mcpApps.ts created+5
......@@ -0,0 +1,5 @@
1import { createMcpAppEnvironmentAtoms } from "@t3tools/client-runtime/state/mcp-apps";
2
3import { connectionAtomRuntime } from "../connection/runtime";
4
5export const mcpAppEnvironment = createMcpAppEnvironmentAtoms(connectionAtomRuntime);
apps/server/scripts/record-codex-app-server-replay-fixture.ts+1
......@@ -72,6 +72,7 @@ const CODEX_REPLAY_PLAN_MODE_DEVELOPER_INSTRUCTIONS =
7272const CODEX_CLIENT_CAPABILITIES = {
7373 experimentalApi: true,
7474 optOutNotificationMethods: ["turn/diff/updated"],
75 extensions: { "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] } },
7576} as const;
7677const CODEX_REPLAY_DEFAULT_MODEL = "gpt-6-luna";
7778const CODEX_REPLAY_MODEL_OVERRIDE = readArgValue("--model") ?? process.env.T3_CODEX_REPLAY_MODEL;
apps/server/src/attachmentStore.test.ts+20
......@@ -243,4 +243,24 @@ describe("threadHtmlRenderAttachmentIds", () => {
243243 ]),
244244 ).toEqual([own]);
245245 });
246
247 it("includes captured MCP App documents from any tool", () => {
248 const app = createAttachmentId("thread-a", "html")!;
249 expect(
250 threadHtmlRenderAttachmentIds("thread-a", [
251 {
252 toolName: "weather.get_weather",
253 output: {
254 t3McpApp: {
255 attachmentId: app,
256 server: "weather",
257 tool: "get_weather",
258 resourceUri: "ui://weather/dashboard",
259 },
260 result: { content: [] },
261 },
262 },
263 ]),
264 ).toEqual([app]);
265 });
246266});
apps/server/src/attachmentStore.ts+6-4
......@@ -4,7 +4,7 @@ import * as NodeFS from "node:fs";
44import * as NodePath from "node:path";
55
66import type { ChatAttachment } from "@t3tools/contracts";
7import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput";
7import { htmlRenderFromToolItem, mcpAppFromToolItem } from "@t3tools/shared/toolOutput";
88
99import {
1010 normalizeAttachmentRelativePath,
......@@ -116,8 +116,9 @@ export function parseThreadSegmentFromAttachmentId(attachmentId: string): string
116116}
117117
118118/**
119 * Attachments a thread's `html_render` tool calls published. Only ids minted
120 * for this thread count, so deleting a fork never removes its source's pages.
119 * Attachments a thread's tool calls published: `html_render` pages and
120 * captured MCP App documents. Only ids minted for this thread count, so
121 * deleting a fork never removes its source's pages.
121122 */
122123export function threadHtmlRenderAttachmentIds(
123124 threadId: string,
......@@ -126,7 +127,8 @@ export function threadHtmlRenderAttachmentIds(
126127 const segment = toSafeThreadAttachmentSegment(threadId);
127128 if (segment === null) return [];
128129 return Array.from(items).flatMap((item) => {
129 const attachmentId = htmlRenderFromToolItem(item)?.attachmentId;
130 const attachmentId =
131 htmlRenderFromToolItem(item)?.attachmentId ?? mcpAppFromToolItem(item)?.attachmentId;
130132 return attachmentId !== undefined &&
131133 parseThreadSegmentFromAttachmentId(attachmentId) === segment
132134 ? [attachmentId]
apps/server/src/auth/RpcAuthorization.ts+5
......@@ -155,6 +155,11 @@ export const RPC_REQUIRED_SCOPES = {
155155 [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope,
156156 [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope,
157157 [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope,
158 // An app's tool calls can change things on its server, like a user action.
159 [WS_METHODS.mcpAppsCallTool]: AuthOrchestrationOperateScope,
160 [WS_METHODS.mcpAppsToolInfo]: AuthOrchestrationReadScope,
161 [WS_METHODS.mcpAppsReadResource]: AuthOrchestrationReadScope,
162 [WS_METHODS.mcpAppsUpdateModelContext]: AuthOrchestrationOperateScope,
158163 [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope,
159164 [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope,
160165 [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope,
apps/server/src/mcpApps/McpAppModelContext.test.ts created+68
......@@ -0,0 +1,68 @@
1import { ThreadId, TurnItemId } from "@t3tools/contracts";
2import { assert, it } from "@effect/vitest";
3import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as SqlClient from "effect/sql/SqlClient";
7
8import { runMigrations } from "../persistence/Migrations.ts";
9import * as McpAppModelContext from "./McpAppModelContext.ts";
10
11const database = NodeSqliteClient.layer({ filename: ":memory:" });
12const layer = it.layer(
13 Layer.merge(database, McpAppModelContext.layer.pipe(Layer.provide(database))),
14);
15
16const threadId = ThreadId.make("thread-context");
17const forkId = ThreadId.make("thread-context-fork");
18
19/** An app's tool call in the projection, in a run with the given status. */
20const insertAppItem = (itemId: string, runId: string, runStatus: string) =>
21 Effect.gen(function* () {
22 const sql = yield* SqlClient.SqlClient;
23 yield* sql`
24 INSERT INTO orchestration_v2_projection_runs
25 (run_id, thread_id, ordinal, provider, status, requested_at, payload_json)
26 VALUES (${runId}, ${threadId}, ${runId.length}, 'codex', ${runStatus}, '2026-01-01', '{}')
27 `;
28 yield* sql`
29 INSERT INTO orchestration_v2_projection_turn_items
30 (turn_item_id, thread_id, run_id, ordinal, type, status, updated_at, payload_json)
31 VALUES (${itemId}, ${threadId}, ${runId}, 0, 'dynamic_tool', 'completed', '2026-01-01', '{}')
32 `;
33 });
34
35layer("McpAppModelContext", (it) => {
36 it.effect("sends only apps still in the thread's history, including a fork's", () =>
37 Effect.gen(function* () {
38 yield* runMigrations();
39 const store = yield* McpAppModelContext.McpAppModelContext;
40 yield* insertAppItem("item-kept", "run-a", "completed");
41 yield* insertAppItem("item-rolled-back", "run-bb", "rolled_back");
42 const set = (thread: ThreadId, itemId: string, text: string) =>
43 store.set({
44 threadId: thread,
45 itemId: TurnItemId.make(itemId),
46 server: "todos",
47 tool: "list_todos",
48 text,
49 });
50 yield* set(threadId, "item-kept", "kept");
51 yield* set(threadId, "item-rolled-back", "from a rolled-back run");
52 yield* set(threadId, "item-deleted", "from an item that is gone");
53 // A fork showing the source thread's app keeps its own context, even
54 // after the source rolls that app's run back.
55 yield* set(forkId, "item-kept", "from the fork");
56 yield* set(forkId, "item-rolled-back", "inherited before the rollback");
57
58 const texts = (thread: ThreadId) =>
59 store.forThread(thread).pipe(Effect.map((entries) => entries.map((entry) => entry.text)));
60 assert.deepEqual(yield* texts(threadId), ["kept"]);
61 assert.deepEqual(yield* texts(forkId), ["from the fork", "inherited before the rollback"]);
62
63 // Clearing removes the app's context.
64 yield* set(threadId, "item-kept", "");
65 assert.deepEqual(yield* texts(threadId), []);
66 }),
67 );
68});
apps/server/src/mcpApps/McpAppModelContext.ts created+105
......@@ -0,0 +1,105 @@
1import type { ThreadId, TurnItemId } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Schema from "effect/Schema";
7import * as SqlClient from "effect/sql/SqlClient";
8
9/** Largest context one app may keep for the agent, in UTF-8 bytes. */
10export const MCP_APP_MODEL_CONTEXT_MAX_BYTES = 16 * 1024;
11
12export class McpAppModelContextError extends Schema.TaggedError<McpAppModelContextError>()(
13 "McpAppModelContextError",
14 { cause: Schema.optional(Schema.Defect()) },
15) {}
16
17export interface McpAppModelContextEntry {
18 readonly itemId: string;
19 readonly server: string;
20 readonly tool: string;
21 readonly text: string;
22}
23
24/**
25 * What each MCP App told the agent through `ui/update-model-context`. The spec
26 * replaces an app's context on every update, so there is one entry per app (its
27 * tool call); the thread's next turn carries the entries to the provider.
28 */
29export class McpAppModelContext extends Context.Service<
30 McpAppModelContext,
31 {
32 /** Replaces the app's context; empty text removes it. */
33 readonly set: (input: {
34 readonly threadId: ThreadId;
35 readonly itemId: TurnItemId;
36 readonly server: string;
37 readonly tool: string;
38 readonly text: string;
39 }) => Effect.Effect<void, McpAppModelContextError>;
40 readonly forThread: (
41 threadId: ThreadId,
42 ) => Effect.Effect<ReadonlyArray<McpAppModelContextEntry>, McpAppModelContextError>;
43 }
44>()("t3/mcpApps/McpAppModelContext") {}
45
46export const layer = Layer.effect(
47 McpAppModelContext,
48 Effect.gen(function* () {
49 const sql = yield* SqlClient.SqlClient;
50 const fail = (cause: unknown) => new McpAppModelContextError({ cause });
51
52 const set = Effect.fn("McpAppModelContext.set")(function* (input: {
53 readonly threadId: ThreadId;
54 readonly itemId: TurnItemId;
55 readonly server: string;
56 readonly tool: string;
57 readonly text: string;
58 }) {
59 if (input.text.trim() === "") {
60 yield* sql`
61 DELETE FROM mcp_app_model_context
62 WHERE thread_id = ${input.threadId} AND item_id = ${input.itemId}
63 `.pipe(Effect.mapError(fail));
64 return;
65 }
66 const updatedAt = DateTime.formatIso(yield* DateTime.now);
67 yield* sql`
68 INSERT INTO mcp_app_model_context (thread_id, item_id, server, tool, text, updated_at)
69 VALUES (${input.threadId}, ${input.itemId}, ${input.server}, ${input.tool}, ${input.text}, ${updatedAt})
70 ON CONFLICT(thread_id, item_id) DO UPDATE SET text = excluded.text, updated_at = excluded.updated_at
71 `.pipe(Effect.mapError(fail));
72 });
73
74 const forThread = Effect.fn("McpAppModelContext.forThread")(function* (threadId: ThreadId) {
75 // Only apps still in the thread's history count: an app whose item is
76 // gone, or whose run this thread rolled back, stops informing the agent.
77 // The item can live in a thread this one was forked from, so it is
78 // matched by id alone; that thread rolling the run back later leaves the
79 // fork's copy in place.
80 return yield* sql<McpAppModelContextEntry>`
81 SELECT context.item_id AS "itemId", context.server, context.tool, context.text
82 FROM mcp_app_model_context AS context
83 JOIN orchestration_v2_projection_turn_items AS item
84 ON item.turn_item_id = context.item_id
85 LEFT JOIN orchestration_v2_projection_runs AS run
86 ON run.run_id = item.run_id
87 WHERE context.thread_id = ${threadId}
88 AND (
89 run.status IS NULL
90 OR run.status <> 'rolled_back'
91 OR run.thread_id <> context.thread_id
92 )
93 ORDER BY context.updated_at, context.item_id
94 `.pipe(Effect.mapError(fail));
95 });
96
97 return McpAppModelContext.of({ set, forThread });
98 }),
99);
100
101/** No app context: for code paths and tests with no MCP Apps. */
102export const layerEmpty = Layer.succeed(
103 McpAppModelContext,
104 McpAppModelContext.of({ set: () => Effect.void, forThread: () => Effect.succeed([]) }),
105);
apps/server/src/mcpApps/McpAppRequests.test.ts created+284
......@@ -0,0 +1,284 @@
1import {
2 ProviderSessionId,
3 ProviderThreadId,
4 ThreadId,
5 TurnItemId,
6 type McpAppRequestError,
7 type OrchestrationV2ProviderThread,
8 type OrchestrationV2TurnItem,
9} from "@t3tools/contracts";
10import { assert, describe, it } from "@effect/vitest";
11import * as DateTime from "effect/DateTime";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";
15
16import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
17import type {
18 ProviderAdapterV2McpApps,
19 ProviderAdapterV2SessionRuntime,
20} from "../orchestration-v2/ProviderAdapter.ts";
21import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts";
22import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
23import * as McpAppModelContext from "./McpAppModelContext.ts";
24import * as McpAppRequests from "./McpAppRequests.ts";
25
26const threadId = ThreadId.make("thread-app");
27const itemId = TurnItemId.make("item-app");
28const providerThreadId = ProviderThreadId.make("provider-thread-app");
29const providerSessionId = ProviderSessionId.make("provider-session-app");
30const forkId = ThreadId.make("thread-fork");
31const unrelatedId = ThreadId.make("thread-unrelated");
32// A chain of forks deeper than any fixed walk limit: deep-70 → … → deep-1 → app.
33const deepForkId = (depth: number) => ThreadId.make(`thread-deep-${depth}`);
34const deepParent = (id: ThreadId): ThreadId | null => {
35 const match = /^thread-deep-(\d+)$/.exec(id);
36 if (match === null) return null;
37 const depth = Number(match[1]);
38 return depth === 1 ? threadId : deepForkId(depth - 1);
39};
40
41const appItem = (output: unknown): OrchestrationV2TurnItem => ({
42 id: itemId,
43 threadId,
44 runId: null,
45 nodeId: null,
46 providerThreadId,
47 providerTurnId: null,
48 nativeItemRef: null,
49 parentItemId: null,
50 ordinal: 0,
51 status: "completed",
52 title: null,
53 startedAt: null,
54 completedAt: null,
55 updatedAt: DateTime.makeUnsafe(0),
56 type: "dynamic_tool",
57 toolName: "weather.get_weather",
58 input: {},
59 output,
60});
61
62const app = {
63 attachmentId: "thread-app-00000000-0000-0000-0000-000000000000-html",
64 server: "weather",
65 tool: "get_weather",
66 resourceUri: "ui://weather/dashboard",
67};
68
69/** Each app's stored context, keyed by item, as the real store keeps it. */
70const storedContext = new Map<string, string>();
71
72function makeLayer(input: {
73 readonly item: OrchestrationV2TurnItem | null;
74 readonly mcpApps?: ProviderAdapterV2McpApps;
75 readonly live?: boolean;
76}) {
77 return McpAppRequests.layer.pipe(
78 Layer.provide(
79 Layer.mergeAll(
80 Layer.mock(McpAppModelContext.McpAppModelContext)({
81 set: (entry) =>
82 Effect.sync(() => {
83 const key = `${entry.threadId}/${entry.itemId}`;
84 if (entry.text.trim() === "") storedContext.delete(key);
85 else storedContext.set(key, entry.text);
86 }),
87 }),
88 Layer.mock(Orchestrator.OrchestratorV2)({
89 getTurnItem: () => Effect.succeed(input.item),
90 }),
91 Layer.mock(ThreadManagementService.ThreadManagementService)({
92 getThreadRecords: (id: ThreadId) =>
93 Effect.succeed({
94 // A fork of the app's thread, and an unrelated thread.
95 thread: {
96 lineage:
97 deepParent(id) !== null
98 ? {
99 parentThreadId: deepParent(id),
100 relationshipToParent: "fork",
101 rootThreadId: threadId,
102 }
103 : id === forkId
104 ? {
105 parentThreadId: threadId,
106 relationshipToParent: "fork",
107 rootThreadId: threadId,
108 }
109 : { parentThreadId: null, relationshipToParent: null, rootThreadId: id },
110 },
111 providerThreads: [
112 { id: providerThreadId, providerSessionId } as OrchestrationV2ProviderThread,
113 ],
114 } as never),
115 }),
116 Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({
117 get: () =>
118 Effect.succeed(
119 input.live === false
120 ? Option.none()
121 : Option.some(
122 (input.mcpApps === undefined
123 ? {}
124 : { mcpApps: input.mcpApps }) as ProviderAdapterV2SessionRuntime,
125 ),
126 ),
127 }),
128 ),
129 ),
130 );
131}
132
133const reason = <A, R>(effect: Effect.Effect<A, McpAppRequestError, R>) =>
134 effect.pipe(
135 Effect.flip,
136 Effect.map((error) => error.reason),
137 );
138
139describe("McpAppRequests", () => {
140 const calls: Array<{ server: string; tool: string }> = [];
141 const mcpApps: ProviderAdapterV2McpApps = {
142 listTools: () =>
143 Effect.succeed([
144 { name: "refresh", annotations: { readOnlyHint: true } },
145 { name: "model_only", _meta: { ui: { visibility: ["model"] } } },
146 ]),
147 callTool: (input) =>
148 Effect.sync(() => {
149 calls.push({ server: input.server, tool: input.tool });
150 return { content: [{ type: "text", text: "ok" }] };
151 }),
152 readResource: () => Effect.succeed({ contents: [] }),
153 };
154
155 it.effect("calls tools on the app's own server and reports their read-only hint", () =>
156 Effect.gen(function* () {
157 const requests = yield* McpAppRequests.McpAppRequests;
158 const result = yield* requests.callTool({
159 threadId,
160 itemId,
161 name: "refresh",
162 arguments: {},
163 });
164 assert.deepEqual(result, { content: [{ type: "text", text: "ok" }] });
165 assert.deepEqual(calls.at(-1), { server: "weather", tool: "refresh" });
166 assert.deepEqual(yield* requests.toolInfo({ threadId, itemId, name: "refresh" }), {
167 callable: true,
168 readOnly: true,
169 tool: { name: "refresh", annotations: { readOnlyHint: true } },
170 });
171 }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps }))),
172 );
173
174 it.effect("refuses tools hidden from apps or missing from the server", () =>
175 Effect.gen(function* () {
176 const requests = yield* McpAppRequests.McpAppRequests;
177 const before = calls.length;
178 for (const name of ["model_only", "unknown"]) {
179 assert.equal(
180 yield* reason(requests.callTool({ threadId, itemId, name, arguments: {} })),
181 "tool-not-callable",
182 );
183 }
184 assert.equal(calls.length, before);
185 }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps }))),
186 );
187
188 it.effect("keeps each app's latest model context, without a live session", () =>
189 Effect.gen(function* () {
190 const requests = yield* McpAppRequests.McpAppRequests;
191 const own = { threadId, itemId, conversationThreadId: threadId };
192 // The app's text is stored as sent, whitespace included.
193 yield* requests.updateModelContext({
194 ...own,
195 content: [{ type: "text", text: " indented value " }],
196 });
197 assert.equal(storedContext.get(`${threadId}/${itemId}`), " indented value ");
198 // An update replaces the app's context rather than adding to it.
199 yield* requests.updateModelContext({
200 ...own,
201 content: [{ type: "text", text: "Filtered to overdue" }],
202 structuredContent: { filter: "overdue" },
203 });
204 assert.equal(
205 storedContext.get(`${threadId}/${itemId}`),
206 'Filtered to overdue\n{"filter":"overdue"}',
207 );
208 assert.equal(
209 yield* reason(
210 requests.updateModelContext({ ...own, content: [{ type: "image", data: "x" }] }),
211 ),
212 "unsupported-content",
213 );
214 // The cap is in UTF-8 bytes: 6,000 three-byte characters exceed 16 KiB.
215 assert.equal(
216 yield* reason(
217 requests.updateModelContext({
218 ...own,
219 content: [{ type: "text", text: "表".repeat(6000) }],
220 }),
221 ),
222 "request-failed",
223 );
224 yield* requests.updateModelContext(own);
225 assert.isFalse(storedContext.has(`${threadId}/${itemId}`));
226 }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), live: false }))),
227 );
228
229 it.effect("stores a fork's model context under the fork, and refuses unrelated threads", () =>
230 Effect.gen(function* () {
231 const requests = yield* McpAppRequests.McpAppRequests;
232 const content = [{ type: "text", text: "Seen from the fork" }];
233 yield* requests.updateModelContext({
234 threadId,
235 itemId,
236 conversationThreadId: forkId,
237 content,
238 });
239 assert.equal(storedContext.get(`${forkId}/${itemId}`), "Seen from the fork");
240 assert.isFalse(storedContext.has(`${threadId}/${itemId}`));
241 assert.equal(
242 yield* reason(
243 requests.updateModelContext({
244 threadId,
245 itemId,
246 conversationThreadId: unrelatedId,
247 content,
248 }),
249 ),
250 "not-an-app",
251 );
252 // However many forks deep, a descendant still counts.
253 yield* requests.updateModelContext({
254 threadId,
255 itemId,
256 conversationThreadId: deepForkId(70),
257 content,
258 });
259 assert.isTrue(storedContext.has(`${deepForkId(70)}/${itemId}`));
260 }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), live: false }))),
261 );
262
263 it.effect("reports why a request cannot run", () =>
264 Effect.gen(function* () {
265 const run = (layer: ReturnType<typeof makeLayer>) =>
266 Effect.gen(function* () {
267 const requests = yield* McpAppRequests.McpAppRequests;
268 return yield* reason(requests.readResource({ threadId, itemId, uri: "ui://weather/x" }));
269 }).pipe(Effect.provide(layer));
270 assert.equal(
271 yield* run(makeLayer({ item: appItem({ result: "plain" }), mcpApps })),
272 "not-an-app",
273 );
274 assert.equal(
275 yield* run(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps, live: false })),
276 "session-stopped",
277 );
278 assert.equal(
279 yield* run(makeLayer({ item: appItem({ t3McpApp: app }) })),
280 "provider-unsupported",
281 );
282 }),
283 );
284});
apps/server/src/mcpApps/McpAppRequests.ts created+240
......@@ -0,0 +1,240 @@
1import {
2 McpAppRequestError,
3 type McpAppCallToolInput,
4 type McpAppCallToolResult,
5 type McpAppReadResourceInput,
6 type McpAppReadResourceResult,
7 type McpAppToolInfo,
8 type McpAppToolInfoInput,
9 type McpAppUpdateModelContextInput,
10 type OrchestrationV2AppThread,
11 type ThreadId,
12 type TurnItemId,
13} from "@t3tools/contracts";
14import { mcpAppToolCallableByApp, type McpAppReference } from "@t3tools/shared/mcpApp";
15import { mcpAppFromToolItem } from "@t3tools/shared/toolOutput";
16import * as Context from "effect/Context";
17import * as Effect from "effect/Effect";
18import * as Layer from "effect/Layer";
19import * as Option from "effect/Option";
20import * as Predicate from "effect/Predicate";
21import * as Schema from "effect/Schema";
22
23import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
24import type {
25 ProviderAdapterV2McpApps,
26 ProviderAdapterV2McpTool,
27} from "../orchestration-v2/ProviderAdapter.ts";
28import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts";
29import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
30import * as McpAppModelContext from "./McpAppModelContext.ts";
31
32/**
33 * Requests an MCP App makes of its own MCP server. The app is resolved from the
34 * tool call that produced it, so it reaches only that server, and each request
35 * runs through the thread's live provider session, whose MCP client owns the
36 * connection and its credentials. A stopped session is reported, never
37 * started: an app view must not spend a provider turn.
38 */
39export class McpAppRequests extends Context.Service<
40 McpAppRequests,
41 {
42 readonly callTool: (
43 input: McpAppCallToolInput,
44 ) => Effect.Effect<McpAppCallToolResult, McpAppRequestError>;
45 readonly toolInfo: (
46 input: McpAppToolInfoInput,
47 ) => Effect.Effect<McpAppToolInfo, McpAppRequestError>;
48 readonly readResource: (
49 input: McpAppReadResourceInput,
50 ) => Effect.Effect<McpAppReadResourceResult, McpAppRequestError>;
51 readonly updateModelContext: (
52 input: McpAppUpdateModelContextInput,
53 ) => Effect.Effect<void, McpAppRequestError>;
54 }
55>()("t3/mcpApps/McpAppRequests") {}
56
57const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
58const utf8 = new TextEncoder();
59
60const readOnlyHint = (tool: ProviderAdapterV2McpTool) =>
61 Predicate.isObject(tool.annotations) && tool.annotations.readOnlyHint === true;
62
63const toolTitle = (tool: ProviderAdapterV2McpTool) =>
64 Predicate.isObject(tool.annotations) && typeof tool.annotations.title === "string"
65 ? tool.annotations.title
66 : undefined;
67
68const make = Effect.gen(function* () {
69 const orchestrator = yield* Orchestrator.OrchestratorV2;
70 const threadManagement = yield* ThreadManagementService.ThreadManagementService;
71 const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2;
72 const modelContext = yield* McpAppModelContext.McpAppModelContext;
73
74 const fail = (threadId: ThreadId, reason: McpAppRequestError["reason"], cause?: unknown) =>
75 new McpAppRequestError({ threadId, reason, ...(cause === undefined ? {} : { cause }) });
76
77 /** The app a tool call produced, read from the stored item. */
78 const resolveApp = Effect.fn("McpAppRequests.resolveApp")(function* (input: {
79 readonly threadId: ThreadId;
80 readonly itemId: TurnItemId;
81 }) {
82 // The stored item, not the wire projection, so the reference is intact.
83 const item = yield* orchestrator
84 .getTurnItem({ threadId: input.threadId, itemId: input.itemId })
85 .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause)));
86 const app: McpAppReference | undefined =
87 item?.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined;
88 if (item === null || app === undefined || item.providerThreadId === null) {
89 return yield* fail(input.threadId, "not-an-app");
90 }
91 return { app, providerThreadId: item.providerThreadId };
92 });
93
94 /** The app, its provider thread, and the live session's MCP Apps operations. */
95 const resolve = Effect.fn("McpAppRequests.resolve")(function* (input: {
96 readonly threadId: ThreadId;
97 readonly itemId: TurnItemId;
98 }) {
99 const { app, providerThreadId } = yield* resolveApp(input);
100 const projection = yield* threadManagement
101 .getThreadRecords(input.threadId, ["providerThreads"])
102 .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause)));
103 const providerThread = projection.providerThreads.find(
104 (candidate) => candidate.id === providerThreadId,
105 );
106 if (providerThread?.providerSessionId == null) {
107 return yield* fail(input.threadId, "session-stopped");
108 }
109 const runtime = Option.getOrUndefined(
110 yield* providerSessions
111 .get(providerThread.providerSessionId)
112 .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))),
113 );
114 if (runtime === undefined) return yield* fail(input.threadId, "session-stopped");
115 const mcpApps: ProviderAdapterV2McpApps | undefined = runtime.mcpApps;
116 if (mcpApps === undefined) return yield* fail(input.threadId, "provider-unsupported");
117 return { app, providerThread, mcpApps };
118 });
119
120 const findTool = Effect.fn("McpAppRequests.findTool")(function* (
121 threadId: ThreadId,
122 resolved: Effect.Success<ReturnType<typeof resolve>>,
123 name: string,
124 ) {
125 const tools = yield* resolved.mcpApps
126 .listTools({ providerThread: resolved.providerThread, server: resolved.app.server })
127 .pipe(Effect.mapError((cause) => fail(threadId, "request-failed", cause)));
128 return tools.find((tool) => tool.name === name);
129 });
130
131 const toolInfo = Effect.fn("McpAppRequests.toolInfo")(function* (input: McpAppToolInfoInput) {
132 const resolved = yield* resolve(input);
133 const tool = yield* findTool(input.threadId, resolved, input.name);
134 const title = tool === undefined ? undefined : toolTitle(tool);
135 return {
136 callable: tool !== undefined && mcpAppToolCallableByApp(tool._meta),
137 readOnly: tool !== undefined && readOnlyHint(tool),
138 ...(title === undefined ? {} : { title }),
139 ...(tool === undefined ? {} : { tool }),
140 } satisfies McpAppToolInfo;
141 });
142
143 const callTool = Effect.fn("McpAppRequests.callTool")(function* (input: McpAppCallToolInput) {
144 const resolved = yield* resolve(input);
145 // The spec forbids apps calling tools hidden from them; checked here too,
146 // since a client's own check is only a courtesy to its user.
147 const tool = yield* findTool(input.threadId, resolved, input.name);
148 if (tool === undefined || !mcpAppToolCallableByApp(tool._meta)) {
149 return yield* fail(input.threadId, "tool-not-callable");
150 }
151 return yield* resolved.mcpApps
152 .callTool({
153 providerThread: resolved.providerThread,
154 server: resolved.app.server,
155 tool: input.name,
156 arguments: input.arguments,
157 })
158 .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause)));
159 });
160
161 const readResource = Effect.fn("McpAppRequests.readResource")(function* (
162 input: McpAppReadResourceInput,
163 ) {
164 const resolved = yield* resolve(input);
165 return yield* resolved.mcpApps
166 .readResource({
167 providerThread: resolved.providerThread,
168 server: resolved.app.server,
169 uri: input.uri,
170 })
171 .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause)));
172 });
173
174 // Context is state for the agent's next turn, so it needs the app but not a
175 // live session. Text blocks are kept as written and structured content as
176 // JSON; other content kinds are not declared by the host.
177 /** Whether `threadId` is `ancestorId` or forked from it, however many forks deep. */
178 const descendsFrom = Effect.fn("McpAppRequests.descendsFrom")(function* (
179 threadId: ThreadId,
180 ancestorId: ThreadId,
181 ) {
182 // Walks fork links until the chain ends; a thread seen twice means a
183 // corrupt cycle, which proves nothing.
184 const seen = new Set<ThreadId>();
185 let current: ThreadId | null = threadId;
186 while (current !== null && !seen.has(current)) {
187 if (current === ancestorId) return true;
188 seen.add(current);
189 const records: { readonly thread: OrchestrationV2AppThread } = yield* threadManagement
190 .getThreadRecords(current, [])
191 .pipe(Effect.mapError((cause) => fail(threadId, "request-failed", cause)));
192 const { lineage } = records.thread;
193 current = lineage.relationshipToParent === "fork" ? lineage.parentThreadId : null;
194 }
195 return false;
196 });
197
198 const updateModelContext = Effect.fn("McpAppRequests.updateModelContext")(function* (
199 input: McpAppUpdateModelContextInput,
200 ) {
201 const { app } = yield* resolveApp(input);
202 // Context belongs to the conversation on screen, which only shows this app
203 // when it is the app's own thread or one forked from it.
204 if (!(yield* descendsFrom(input.conversationThreadId, input.threadId))) {
205 return yield* fail(input.threadId, "not-an-app");
206 }
207 const texts: Array<string> = [];
208 for (const block of input.content ?? []) {
209 if (!Predicate.isObject(block) || block.type !== "text" || typeof block.text !== "string") {
210 return yield* fail(input.threadId, "unsupported-content");
211 }
212 texts.push(block.text);
213 }
214 if (input.structuredContent !== undefined) {
215 texts.push(encodeJson(input.structuredContent));
216 }
217 // Kept as sent; blank text clears the app's context in the store.
218 const text = texts.join("\n");
219 if (utf8.encode(text).byteLength > McpAppModelContext.MCP_APP_MODEL_CONTEXT_MAX_BYTES) {
220 return yield* fail(
221 input.threadId,
222 "request-failed",
223 new Error("Model context is too large."),
224 );
225 }
226 yield* modelContext
227 .set({
228 threadId: input.conversationThreadId,
229 itemId: input.itemId,
230 server: app.server,
231 tool: app.tool,
232 text,
233 })
234 .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause)));
235 });
236
237 return McpAppRequests.of({ callTool, toolInfo, readResource, updateModelContext });
238});
239
240export const layer = Layer.effect(McpAppRequests, make);
apps/server/src/mcpApps/McpAppSnapshot.ts created+108
......@@ -0,0 +1,108 @@
1import type { ThreadId } from "@t3tools/contracts";
2import {
3 injectMcpAppCsp,
4 MCP_APP_MAX_HTML_BYTES,
5 MCP_APP_MIME_TYPE,
6 MCP_APP_RESOURCE_SCHEME,
7 readMcpAppCsp,
8 readMcpAppPermissions,
9 type McpAppReference,
10} from "@t3tools/shared/mcpApp";
11import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";
13import * as Predicate from "effect/Predicate";
14
15import { resolveAttachmentRelativePath } from "../attachmentPaths.ts";
16import { createAttachmentId } from "../attachmentStore.ts";
17
18const encoder = new TextEncoder();
19
20/**
21 * The HTML document in a `resources/read` result, with the `_meta.ui` the
22 * resource declared. Only `text/html;profile=mcp-app` content counts; a blob is
23 * base64 per MCP. A null document is one too large to store.
24 */
25function readMcpAppDocument(
26 contents: ReadonlyArray<unknown>,
27 uri: string,
28): { readonly html: string | null; readonly meta: unknown } | undefined {
29 for (const content of contents) {
30 if (!Predicate.isObject(content)) continue;
31 if (content.uri !== undefined && content.uri !== uri) continue;
32 const mimeType = typeof content.mimeType === "string" ? content.mimeType : "";
33 if (mimeType.replace(/\s+/g, "").toLowerCase() !== MCP_APP_MIME_TYPE) continue;
34 // Sizes are checked before decoding, so an oversized resource is
35 // refused without first being copied: a string's UTF-8 form is at least
36 // its length, and base64 decodes to three quarters of its own.
37 const html =
38 typeof content.text === "string"
39 ? content.text.length > MCP_APP_MAX_HTML_BYTES
40 ? null
41 : content.text
42 : typeof content.blob === "string"
43 ? content.blob.length > Math.ceil(MCP_APP_MAX_HTML_BYTES / 3) * 4
44 ? null
45 : Buffer.from(content.blob, "base64").toString("utf8")
46 : undefined;
47 if (html === null) return { html: null, meta: undefined };
48 if (html === undefined || html.trim() === "") continue;
49 const meta = Predicate.isObject(content._meta) ? content._meta.ui : undefined;
50 return { html, meta };
51 }
52 return undefined;
53}
54
55/**
56 * Stores an app's document as a thread attachment and returns the reference
57 * clients host it from. Saving a snapshot keeps the app viewable after the
58 * provider exits; interaction still goes through a live provider session.
59 * Undefined when the resource is not an MCP App document or is too large.
60 */
61export const snapshotMcpApp = Effect.fn("McpAppSnapshot.snapshot")(function* (input: {
62 readonly attachmentsDir: string;
63 readonly threadId: ThreadId;
64 readonly server: string;
65 readonly tool: string;
66 readonly resourceUri: string;
67 readonly contents: ReadonlyArray<unknown>;
68}) {
69 if (!input.resourceUri.startsWith(MCP_APP_RESOURCE_SCHEME)) return undefined;
70 const document = readMcpAppDocument(input.contents, input.resourceUri);
71 if (document === undefined) return undefined;
72 if (document.html === null || encoder.encode(document.html).byteLength > MCP_APP_MAX_HTML_BYTES) {
73 yield* Effect.logWarning("MCP app document exceeds the size limit.", {
74 server: input.server,
75 resourceUri: input.resourceUri,
76 });
77 return undefined;
78 }
79 // The `-html` suffix makes the asset route serve it as a sandboxed document.
80 const attachmentId = createAttachmentId(input.threadId, "html");
81 const filePath =
82 attachmentId === null
83 ? null
84 : resolveAttachmentRelativePath({
85 attachmentsDir: input.attachmentsDir,
86 relativePath: `${attachmentId}.html`,
87 });
88 if (attachmentId === null || filePath === null) return undefined;
89 const html = document.html;
90 const ui = Predicate.isObject(document.meta) ? document.meta : {};
91 const csp = readMcpAppCsp(ui.csp);
92 const fileSystem = yield* FileSystem.FileSystem;
93 // Only the returned reference lets thread deletion find the document, so a
94 // write that fails or is interrupted removes it.
95 yield* fileSystem
96 .writeFileString(filePath, injectMcpAppCsp(html, csp))
97 .pipe(Effect.onError(() => fileSystem.remove(filePath, { force: true }).pipe(Effect.ignore)));
98 const permissions = readMcpAppPermissions(ui.permissions);
99 return {
100 attachmentId,
101 server: input.server,
102 tool: input.tool,
103 resourceUri: input.resourceUri,
104 ...(csp === undefined ? {} : { csp }),
105 ...(permissions === undefined ? {} : { permissions }),
106 ...(typeof ui.prefersBorder === "boolean" ? { prefersBorder: ui.prefersBorder } : {}),
107 } satisfies McpAppReference;
108});
apps/server/src/observability/RpcInstrumentation.ts+4
......@@ -142,6 +142,10 @@ const RPC_AGGREGATES = {
142142 [WS_METHODS.attachmentsCreateUploadUrl]: "workspace",
143143 [WS_METHODS.attachmentsDelete]: "workspace",
144144 [WS_METHODS.providerUploadFeedback]: "provider",
145 [WS_METHODS.mcpAppsCallTool]: "provider",
146 [WS_METHODS.mcpAppsToolInfo]: "provider",
147 [WS_METHODS.mcpAppsUpdateModelContext]: "provider",
148 [WS_METHODS.mcpAppsReadResource]: "provider",
145149 [WS_METHODS.subscribeVcsStatus]: "vcs",
146150 [WS_METHODS.subscribeWorktreeSetup]: "vcs",
147151 [WS_METHODS.worktreeSetupCancel]: "vcs",
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts+289
......@@ -18,6 +18,7 @@ import {
1818 type OrchestrationV2AppThread,
1919 type OrchestrationV2ProviderThread,
2020 type OrchestrationV2ProviderTurn,
21 type OrchestrationV2TurnItem,
2122 ProjectId,
2223 ProviderInstanceId,
2324 ProviderSessionId,
......@@ -40,6 +41,8 @@ import * as Crypto from "effect/Crypto";
4041import * as Effect from "effect/Effect";
4142import * as Predicate from "effect/Predicate";
4243import * as FileSystem from "effect/FileSystem";
44import { MCP_APP_OUTPUT_KEY, readMcpAppReference } from "@t3tools/shared/mcpApp";
45import { resolveAttachmentPathById } from "../../attachmentStore.ts";
4346import * as Fiber from "effect/Fiber";
4447import * as Layer from "effect/Layer";
4548import * as Path from "effect/Path";
......@@ -463,6 +466,38 @@ describe("CodexAdapterV2 runtime policy", () => {
463466 }),
464467 );
465468
469 it.effect("sends MCP app model context as untrusted Codex context", () =>
470 Effect.gen(function* () {
471 const policy = { runtimeMode: "full-access", interactionMode: "default", cwd: null } as const;
472 const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" };
473 const appContext = [{ key: "mcp_app_todos_list_todos_item-1", text: "Filtered to overdue" }];
474 const alone = yield* CodexAdapterV2.buildCodexTurnStartParams({
475 nativeThreadId: "native-app-context",
476 codexInput: [{ type: "text", text: "what's on my list?" }],
477 runtimePolicy: policy,
478 modelSelection,
479 appContext,
480 });
481 assert.deepEqual(alone.additionalContext, {
482 "mcp_app_todos_list_todos_item-1": { kind: "untrusted", value: "Filtered to overdue" },
483 });
484 // Alongside T3's own context, both are kept.
485 const withT3 = yield* CodexAdapterV2.buildCodexTurnStartParams({
486 nativeThreadId: "native-app-context",
487 codexInput: [{ type: "text", text: "what's on my list?" }],
488 runtimePolicy: policy,
489 modelSelection,
490 hasT3Mcp: true,
491 appContext,
492 });
493 assert.deepEqual(withT3.additionalContext?.["mcp_app_todos_list_todos_item-1"], {
494 kind: "untrusted",
495 value: "Filtered to overdue",
496 });
497 assert.isDefined(withT3.additionalContext?.["t3_code_runtime"]);
498 }),
499 );
500
466501 it.effect("adds default-mode developer instructions when the T3 MCP server is attached", () =>
467502 Effect.gen(function* () {
468503 const params = yield* CodexAdapterV2.buildCodexTurnStartParams({
......@@ -1548,6 +1583,9 @@ function codexReplayPreamble(input: {
15481583 capabilities: {
15491584 experimentalApi: true,
15501585 optOutNotificationMethods: ["turn/diff/updated"],
1586 extensions: {
1587 "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] },
1588 },
15511589 },
15521590 },
15531591 },
......@@ -1972,6 +2010,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
19722010 runtime,
19732011 providerThread,
19742012 threadId,
2013 serverConfig,
19752014 events,
19762015 continuationRequests,
19772016 terminalEvents,
......@@ -2113,6 +2152,9 @@ describe("CodexAdapterV2 post-settle continuation", () => {
21132152 capabilities: {
21142153 experimentalApi: true,
21152154 optOutNotificationMethods: ["turn/diff/updated"],
2155 extensions: {
2156 "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] },
2157 },
21162158 },
21172159 },
21182160 ]);
......@@ -6305,6 +6347,253 @@ describe("CodexAdapterV2 post-settle continuation", () => {
63056347 ),
63066348 );
63076349
6350 const MCP_APP_SCENARIO = "codex-mcp-app-capture";
6351 const MCP_APP_NATIVE_THREAD = "native-codex-mcp-app-thread";
6352 const MCP_APP_NATIVE_TURN = "native-codex-mcp-app-turn";
6353 const MCP_APP_ITEM = "mcp-weather-call";
6354 const MCP_APP_PROMPT = "Show the weather in Oslo.";
6355 const MCP_APP_RESOURCE = "ui://weather/dashboard";
6356 const MCP_APP_HTML = "<!doctype html><html><body><p>Weather</p><script>1</script></body></html>";
6357 const mcpAppToolItem = (status: "inProgress" | "completed") => ({
6358 type: "mcpToolCall",
6359 id: MCP_APP_ITEM,
6360 server: "weather",
6361 tool: "get_weather",
6362 status,
6363 arguments: { city: "Oslo" },
6364 mcpAppResourceUri: MCP_APP_RESOURCE,
6365 ...(status === "completed"
6366 ? { result: { content: [{ type: "text", text: "Sunny" }], structuredContent: { temp: 21 } } }
6367 : {}),
6368 });
6369
6370 const mcpAppTranscript = makeCodexReplayTranscript({
6371 scenario: MCP_APP_SCENARIO,
6372 entries: [
6373 ...codexReplayPreamble({
6374 nativeThreadId: MCP_APP_NATIVE_THREAD,
6375 nativeTurnId: MCP_APP_NATIVE_TURN,
6376 prompt: MCP_APP_PROMPT,
6377 }),
6378 {
6379 type: "emit_inbound",
6380 label: "item/started/app-tool",
6381 frame: {
6382 method: "item/started",
6383 params: {
6384 item: mcpAppToolItem("inProgress"),
6385 threadId: MCP_APP_NATIVE_THREAD,
6386 turnId: MCP_APP_NATIVE_TURN,
6387 startedAtMs: 1782622440500,
6388 },
6389 },
6390 },
6391 {
6392 type: "emit_inbound",
6393 label: "item/completed/app-tool",
6394 frame: {
6395 method: "item/completed",
6396 params: {
6397 item: mcpAppToolItem("completed"),
6398 threadId: MCP_APP_NATIVE_THREAD,
6399 turnId: MCP_APP_NATIVE_TURN,
6400 completedAtMs: 1782622441500,
6401 },
6402 },
6403 },
6404 {
6405 type: "emit_inbound",
6406 label: "turn/completed",
6407 frame: {
6408 method: "turn/completed",
6409 params: {
6410 threadId: MCP_APP_NATIVE_THREAD,
6411 turn: makeCodexReplayTurn({ id: MCP_APP_NATIVE_TURN, status: "completed" }),
6412 },
6413 },
6414 },
6415 {
6416 type: "expect_outbound",
6417 label: "mcpServer/resource/read",
6418 frame: {
6419 id: 4,
6420 method: "mcpServer/resource/read",
6421 params: { threadId: MCP_APP_NATIVE_THREAD, server: "weather", uri: MCP_APP_RESOURCE },
6422 },
6423 },
6424 {
6425 type: "emit_inbound",
6426 label: "mcpServer/resource/read",
6427 frame: {
6428 id: 4,
6429 result: {
6430 contents: [
6431 {
6432 uri: MCP_APP_RESOURCE,
6433 mimeType: "text/html;profile=mcp-app",
6434 text: MCP_APP_HTML,
6435 _meta: { ui: { csp: { connectDomains: ["https://api.weather.test"] } } },
6436 },
6437 ],
6438 },
6439 },
6440 },
6441 ],
6442 });
6443
6444 it.effect("captures an MCP app's resource and holds the turn open until it lands", () =>
6445 Effect.scoped(
6446 Effect.gen(function* () {
6447 const appCaptured = yield* Deferred.make<OrchestrationV2TurnItem>();
6448 const harness = yield* makeCodexReplayHarness(mcpAppTranscript, (event) =>
6449 event.type === "turn_item.updated" &&
6450 event.turnItem.type === "dynamic_tool" &&
6451 event.turnItem.status === "completed"
6452 ? Deferred.succeed(appCaptured, event.turnItem)
6453 : Effect.void,
6454 );
6455 const now = yield* DateTime.now;
6456
6457 yield* harness.runtime.startTurn(
6458 makeCodexTestTurnInput({
6459 threadId: harness.threadId,
6460 providerThread: harness.providerThread,
6461 now,
6462 attemptId: RunAttemptId.make("attempt-codex-mcp-app"),
6463 text: MCP_APP_PROMPT,
6464 }),
6465 );
6466 const item = yield* Deferred.await(appCaptured);
6467 assert.equal(harness.terminalEvents()[0]?.status, "completed");
6468
6469 // The completed tool row is emitted once, with the app, never as a bare row first.
6470 const completedRows = harness.events.filter(
6471 (event) =>
6472 event.type === "turn_item.updated" &&
6473 event.turnItem.type === "dynamic_tool" &&
6474 event.turnItem.status === "completed",
6475 );
6476 assert.lengthOf(completedRows, 1);
6477
6478 assert.equal(item.type, "dynamic_tool");
6479 const output = item.type === "dynamic_tool" ? (item.output as Record<string, unknown>) : {};
6480 const reference = readMcpAppReference(output[MCP_APP_OUTPUT_KEY]);
6481 assert.deepEqual(
6482 { ...reference, attachmentId: undefined },
6483 {
6484 attachmentId: undefined,
6485 server: "weather",
6486 tool: "get_weather",
6487 resourceUri: MCP_APP_RESOURCE,
6488 csp: { connectDomains: ["https://api.weather.test"] },
6489 },
6490 );
6491 assert.deepEqual(output.result, {
6492 content: [{ type: "text", text: "Sunny" }],
6493 structuredContent: { temp: 21 },
6494 });
6495
6496 const fileSystem = yield* FileSystem.FileSystem;
6497 const stored = yield* fileSystem.readFileString(
6498 resolveAttachmentPathById({
6499 attachmentsDir: harness.serverConfig.attachmentsDir,
6500 attachmentId: reference!.attachmentId,
6501 })!,
6502 );
6503 // Stored with the app's declared policy ahead of its scripts.
6504 assert.include(stored, "connect-src https://api.weather.test");
6505 assert.isBelow(stored.indexOf("Content-Security-Policy"), stored.indexOf("<script>"));
6506 assert.isFalse(yield* harness.hasPendingBackgroundWork);
6507 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
6508 ),
6509 );
6510
6511 // The turn fails while the app's resource read is still outstanding (Codex
6512 // never answers it), so the capture must be cancelled and its tool row
6513 // settled before the terminal event closes ingestion.
6514 const failedMcpAppTranscript = makeCodexReplayTranscript({
6515 scenario: "codex-mcp-app-capture-failed-turn",
6516 entries: [
6517 ...codexReplayPreamble({
6518 nativeThreadId: MCP_APP_NATIVE_THREAD,
6519 nativeTurnId: MCP_APP_NATIVE_TURN,
6520 prompt: MCP_APP_PROMPT,
6521 }),
6522 {
6523 type: "emit_inbound",
6524 label: "item/completed/app-tool",
6525 frame: {
6526 method: "item/completed",
6527 params: {
6528 item: mcpAppToolItem("completed"),
6529 threadId: MCP_APP_NATIVE_THREAD,
6530 turnId: MCP_APP_NATIVE_TURN,
6531 completedAtMs: 1782622441500,
6532 },
6533 },
6534 },
6535 {
6536 type: "expect_outbound",
6537 label: "mcpServer/resource/read",
6538 frame: {
6539 id: 4,
6540 method: "mcpServer/resource/read",
6541 params: { threadId: MCP_APP_NATIVE_THREAD, server: "weather", uri: MCP_APP_RESOURCE },
6542 },
6543 },
6544 {
6545 type: "emit_inbound",
6546 label: "turn/completed",
6547 frame: {
6548 method: "turn/completed",
6549 params: {
6550 threadId: MCP_APP_NATIVE_THREAD,
6551 turn: {
6552 ...makeCodexReplayTurn({ id: MCP_APP_NATIVE_TURN, status: "failed" }),
6553 error: { message: "provider failed mid-capture" },
6554 },
6555 },
6556 },
6557 },
6558 ],
6559 });
6560
6561 it.effect("settles a pending MCP app capture before a failed turn's terminal event", () =>
6562 Effect.scoped(
6563 Effect.gen(function* () {
6564 const harness = yield* makeCodexReplayHarness(failedMcpAppTranscript);
6565 const now = yield* DateTime.now;
6566
6567 yield* harness.runtime.startTurn(
6568 makeCodexTestTurnInput({
6569 threadId: harness.threadId,
6570 providerThread: harness.providerThread,
6571 now,
6572 attemptId: RunAttemptId.make("attempt-codex-mcp-app-failed"),
6573 text: MCP_APP_PROMPT,
6574 }),
6575 );
6576 yield* awaitUntil(() => harness.terminalEvents().length === 1, "failed terminal");
6577 assert.equal(harness.terminalEvents()[0]?.status, "failed");
6578
6579 const terminalIndex = harness.events.findIndex((event) => event.type === "turn.terminal");
6580 const rows = harness.events.flatMap((event, index) =>
6581 event.type === "turn_item.updated" &&
6582 event.turnItem.type === "dynamic_tool" &&
6583 event.turnItem.status === "completed"
6584 ? [{ index, turnItem: event.turnItem }]
6585 : [],
6586 );
6587 // Settled once, as a plain tool row, ahead of the terminal event.
6588 assert.lengthOf(rows, 1);
6589 assert.isBelow(rows[0]!.index, terminalIndex);
6590 const output = rows[0]!.turnItem.type === "dynamic_tool" ? rows[0]!.turnItem.output : null;
6591 assert.isUndefined((output as Record<string, unknown> | null)?.[MCP_APP_OUTPUT_KEY]);
6592 assert.isFalse(yield* harness.hasPendingBackgroundWork);
6593 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
6594 ),
6595 );
6596
63086597 const ORPHAN_WAIT_SCENARIO = "codex-orphaned-dynamic-tool";
63096598 const ORPHAN_WAIT_NATIVE_THREAD = "native-codex-orphan-wait-thread";
63106599 const ORPHAN_WAIT_NATIVE_TURN = "native-codex-orphan-wait-turn";
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+316-7
......@@ -65,11 +65,13 @@ import type {
6565import * as CodexClient from "effect-codex-app-server/client";
6666import * as CodexErrors from "effect-codex-app-server/errors";
6767import * as CodexSchema from "effect-codex-app-server/schema";
68import * as Cause from "effect/Cause";
6869import * as Context from "effect/Context";
6970import * as Crypto from "effect/Crypto";
7071import * as DateTime from "effect/DateTime";
7172import * as Deferred from "effect/Deferred";
7273import * as Effect from "effect/Effect";
74import * as Fiber from "effect/Fiber";
7375import * as FileSystem from "effect/FileSystem";
7476import * as Layer from "effect/Layer";
7577import * as Option from "effect/Option";
......@@ -82,7 +84,7 @@ import * as Semaphore from "effect/Semaphore";
8284import * as Stream from "effect/Stream";
8385import { ChildProcess, ChildProcessSpawner } from "effect/process";
8486
85import { resolveAttachmentPath } from "../../attachmentStore.ts";
87import { resolveAttachmentPath, resolveAttachmentPathById } from "../../attachmentStore.ts";
8688import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts";
8789import { ServerConfig } from "../../config.ts";
8890import { expandHomePath } from "../../pathExpansion.ts";
......@@ -107,6 +109,13 @@ import { ProviderEventLoggers } from "../../provider/ProviderEventLoggers.ts";
107109import { codexAppServerArgs, resolveCodexLaunchArgs } from "../../provider/codexLaunchArgs.ts";
108110import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
109111import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
112import {
113 MCP_APP_EXTENSION_ID,
114 MCP_APP_MIME_TYPE,
115 MCP_APP_OUTPUT_KEY,
116 MCP_APP_RESOURCE_SCHEME,
117} from "@t3tools/shared/mcpApp";
118import { snapshotMcpApp } from "../../mcpApps/McpAppSnapshot.ts";
110119import {
111120 ProviderAdapterDriverCreateError,
112121 type ProviderAdapterDriver,
......@@ -146,6 +155,7 @@ import {
146155 type ProviderAdapterV2ForkThreadInput,
147156 type ProviderAdapterV2RollbackThreadInput,
148157 type ProviderAdapterV2RuntimePolicy,
158 type ProviderAdapterV2McpApps,
149159 type ProviderAdapterV2SessionRuntime,
150160 type ProviderAdapterV2InterruptInput,
151161 type ProviderAdapterV2SteerInput,
......@@ -232,6 +242,9 @@ const decodeCodexBackgroundTerminalsListResponse = Schema.decodeUnknownEffect(
232242const CODEX_CLIENT_CAPABILITIES = {
233243 experimentalApi: true,
234244 optOutNotificationMethods: ["turn/diff/updated"],
245 // Declares MCP Apps support (SEP-1724 extension negotiation), so servers
246 // attach their UI resources and Codex reports them on tool call items.
247 extensions: { [MCP_APP_EXTENSION_ID]: { mimeTypes: [MCP_APP_MIME_TYPE] } },
235248} as const;
236249
237250export const CodexProviderCapabilitiesV2 = {
......@@ -471,6 +484,17 @@ function codexMcpToolOutput(
471484 : { error: item.error.message, result: resultOutput };
472485}
473486
487// Reading an app's resource is one MCP round trip; a server that hangs must not
488// hold the item (and the run's ingestion) open.
489const MCP_APP_CAPTURE_TIMEOUT = "20 seconds";
490
491/** The UI resource a completed Codex MCP tool call names, under any field Codex reports it in. */
492function codexMcpAppResourceUri(item: CodexDynamicToolItem): string | undefined {
493 if (item.type !== "mcpToolCall") return undefined;
494 const uri = item.mcpAppUi?.resourceUri ?? item.mcpAppResourceUri ?? item.appContext?.resourceUri;
495 return typeof uri === "string" && uri.startsWith(MCP_APP_RESOURCE_SCHEME) ? uri : undefined;
496}
497
474498function codexDynamicToolOutput(
475499 item: Extract<CodexDynamicToolItem, { readonly type: "dynamicToolCall" }>,
476500): unknown | undefined {
......@@ -707,6 +731,8 @@ export function buildCodexTurnStartParams(input: {
707731 readonly deviceToolsAvailable?: boolean;
708732 /** ChatGPT token sharing does not accept service tiers. */
709733 readonly omitServiceTier?: boolean;
734 /** What the thread's MCP Apps want the agent to know (`ui/update-model-context`). */
735 readonly appContext?: ProviderAdapterV2TurnInput["appContext"];
710736}) {
711737 return Effect.gen(function* () {
712738 const runtimeModeDefaults = codexRuntimeModeTurnDefaults(input.runtimePolicy.runtimeMode);
......@@ -732,7 +758,16 @@ export function buildCodexTurnStartParams(input: {
732758 input.hasT3Mcp !== true
733759 ? undefined
734760 : buildCodexDeveloperInstructions(input.runtimePolicy.interactionMode);
735 const additionalContext =
761 // An app's context is text an MCP server wrote, so it goes in as untrusted
762 // context: Codex renders it as quoted user-side input, never as developer
763 // instructions. Codex resends it only when it changes.
764 const appContext = Object.fromEntries(
765 (input.appContext ?? []).map((entry) => [
766 entry.key,
767 { kind: "untrusted" as const, value: entry.text },
768 ]),
769 );
770 const t3Context =
736771 input.hasT3Mcp === true
737772 ? buildCodexAdditionalContext(
738773 { model: input.modelSelection.model, reasoningEffort: effort ?? "medium" },
......@@ -742,6 +777,10 @@ export function buildCodexTurnStartParams(input: {
742777 },
743778 )
744779 : undefined;
780 const additionalContext =
781 t3Context === undefined && Object.keys(appContext).length === 0
782 ? undefined
783 : { ...t3Context, ...appContext };
745784 const collaborationMode: CodexSchema.ClientRequest__CollaborationMode | undefined =
746785 input.runtimePolicy.interactionMode !== "plan" && developerInstructions === undefined
747786 ? undefined
......@@ -1688,11 +1727,26 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
16881727 if (!context) return;
16891728 yield* client.request("thread/inject_items", {
16901729 threadId,
1691 items: Object.entries(context).map(([key, entry]) => ({
1692 type: "message",
1693 role: "developer",
1694 content: [{ type: "input_text", text: `<${key}>${entry.value}</${key}>` }],
1695 })),
1730 // Restored as Codex sent them: application context as developer
1731 // input, untrusted context as quoted user-side input.
1732 items: Object.entries(context).map(([key, entry]) =>
1733 entry.kind === "untrusted"
1734 ? {
1735 type: "message",
1736 role: "user",
1737 content: [
1738 {
1739 type: "input_text",
1740 text: `<external_${key}>${entry.value}</external_${key}>`,
1741 },
1742 ],
1743 }
1744 : {
1745 type: "message",
1746 role: "developer",
1747 content: [{ type: "input_text", text: `<${key}>${entry.value}</${key}>` }],
1748 },
1749 ),
16961750 });
16971751 }).pipe(
16981752 Effect.timeout("10 seconds"),
......@@ -2037,6 +2091,33 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
20372091 return [remaining.size === 0, updated] as const;
20382092 });
20392093
2094 // MCP app captures still reading their resource, by native turn and
2095 // item. Each counts as retained background work, so the turn's context
2096 // and the run's ingestion stay open until its app lands on the item.
2097 // A turn that ends badly cancels its captures and settles their items
2098 // itself (terminalizeMcpAppCaptures).
2099 interface PendingMcpAppCapture {
2100 readonly item: Extract<CodexDynamicToolItem, { readonly type: "mcpToolCall" }>;
2101 readonly fiber: Fiber.Fiber<void>;
2102 }
2103 const pendingMcpAppCaptures = yield* Ref.make(
2104 new Map<string, ReadonlyMap<string, PendingMcpAppCapture>>(),
2105 );
2106 /** Removes a capture; true when this call removed it (each settles once). */
2107 const takePendingMcpAppCapture = (nativeTurnId: string, nativeItemId: string) =>
2108 Ref.modify(pendingMcpAppCaptures, (current) => {
2109 const captures = current.get(nativeTurnId);
2110 if (captures === undefined || !captures.has(nativeItemId)) {
2111 return [false, current] as const;
2112 }
2113 const remaining = new Map(captures);
2114 remaining.delete(nativeItemId);
2115 const updated = new Map(current);
2116 if (remaining.size === 0) updated.delete(nativeTurnId);
2117 else updated.set(nativeTurnId, remaining);
2118 return [true, updated] as const;
2119 });
2120
20402121 const trackRunningDynamicTool = (nativeTurnId: string, item: CodexDynamicToolItem) =>
20412122 Ref.update(runningDynamicToolsByTurn, (current) => {
20422123 const updated = new Map(current);
......@@ -2069,6 +2150,9 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
20692150 if (commands !== undefined && commands.size > 0) {
20702151 return true;
20712152 }
2153 if ((yield* Ref.get(pendingMcpAppCaptures)).has(nativeTurnId)) {
2154 return true;
2155 }
20722156 const tools = (yield* Ref.get(runningDynamicToolsByTurn)).get(nativeTurnId);
20732157 if (tools === undefined) {
20742158 return false;
......@@ -3555,6 +3639,157 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
35553639 return { node, turnItem };
35563640 });
35573641
3642 /**
3643 * Completes an MCP tool call whose tool names a UI resource: reads the
3644 * resource through Codex's MCP client, stores it, and records the app
3645 * in the item's output. The item stays running until then, which keeps
3646 * the run's event ingestion open past turn/completed; it runs off the
3647 * notification reader so a slow server cannot stall other events.
3648 */
3649 const captureMcpApp = (
3650 context: ActiveCodexTurnContext,
3651 nativeTurnId: string,
3652 item: Extract<CodexDynamicToolItem, { readonly type: "mcpToolCall" }>,
3653 resourceUri: string,
3654 ) =>
3655 Effect.gen(function* () {
3656 // Registered before it can run, so a turn ending right away sees it.
3657 const started = yield* Deferred.make<void>();
3658 const fiber = yield* Deferred.await(started).pipe(
3659 Effect.andThen(captureMcpAppItem(context, nativeTurnId, item, resourceUri)),
3660 Effect.forkIn(scope, { startImmediately: true }),
3661 );
3662 yield* Ref.update(pendingMcpAppCaptures, (current) => {
3663 const updated = new Map(current);
3664 updated.set(
3665 nativeTurnId,
3666 new Map(current.get(nativeTurnId) ?? []).set(item.id, { item, fiber }),
3667 );
3668 return updated;
3669 });
3670 yield* Deferred.succeed(started, undefined);
3671 });
3672
3673 const captureMcpAppItem = (
3674 context: ActiveCodexTurnContext,
3675 nativeTurnId: string,
3676 item: Extract<CodexDynamicToolItem, { readonly type: "mcpToolCall" }>,
3677 resourceUri: string,
3678 ) =>
3679 // Only reading and storing the document can be cancelled; a write
3680 // cut short removes its file. Once a document is stored, the capture
3681 // either lands it on the item or, when its turn already settled the
3682 // item, removes it, so no stored app goes unreferenced.
3683 Effect.uninterruptibleMask((restore) =>
3684 Effect.gen(function* () {
3685 const reference =
3686 item.status === "completed" && item.result != null && item.error == null
3687 ? yield* restore(
3688 getNativeThreadId(context.providerThread).pipe(
3689 Effect.flatMap((threadId) =>
3690 client.request("mcpServer/resource/read", {
3691 threadId,
3692 server: item.server,
3693 uri: resourceUri,
3694 }),
3695 ),
3696 Effect.timeout(MCP_APP_CAPTURE_TIMEOUT),
3697 Effect.flatMap((response) =>
3698 snapshotMcpApp({
3699 attachmentsDir: serverConfig.attachmentsDir,
3700 threadId: context.projectionThreadId,
3701 server: item.server,
3702 tool: item.tool,
3703 resourceUri,
3704 contents: response.contents,
3705 }),
3706 ),
3707 Effect.provideService(FileSystem.FileSystem, fileSystem),
3708 // Every failure and defect becomes a plain tool row,
3709 // so a crashed read never holds the turn open; only
3710 // interruption, the turn cancelling the capture, ends
3711 // the fiber here. Nothing typed is left for orDie.
3712 Effect.catchCauseIf(
3713 (cause) => !Cause.hasInterruptsOnly(cause),
3714 (cause) =>
3715 Effect.logWarning(
3716 "Failed to capture an MCP app; showing a plain tool row.",
3717 { server: item.server, tool: item.tool, resourceUri, cause },
3718 ).pipe(Effect.as(undefined)),
3719 ),
3720 Effect.orDie,
3721 ),
3722 )
3723 : undefined;
3724 const artifacts = yield* buildDynamicToolArtifacts(context, item);
3725 // Cleared before the final emit: ingestion re-checks pending work
3726 // after each event, and must see this capture done by then. A turn
3727 // that already ended badly took the capture and settled its item.
3728 if (!(yield* takePendingMcpAppCapture(nativeTurnId, item.id))) {
3729 if (reference !== undefined) {
3730 const filePath = resolveAttachmentPathById({
3731 attachmentsDir: serverConfig.attachmentsDir,
3732 attachmentId: reference.attachmentId,
3733 });
3734 if (filePath !== null) {
3735 yield* fileSystem.remove(filePath, { force: true }).pipe(Effect.ignore);
3736 }
3737 }
3738 return;
3739 }
3740 const turnItem =
3741 reference === undefined || artifacts.turnItem.type !== "dynamic_tool"
3742 ? artifacts.turnItem
3743 : {
3744 ...artifacts.turnItem,
3745 // The full CallToolResult the app replays, beside the app.
3746 output: { [MCP_APP_OUTPUT_KEY]: reference, result: item.result },
3747 };
3748 yield* emitProviderEvent({
3749 type: "node.updated",
3750 driver: CODEX_PROVIDER,
3751 node: artifacts.node,
3752 });
3753 yield* emitProviderEvent({
3754 type: "turn_item.updated",
3755 driver: CODEX_PROVIDER,
3756 turnItem,
3757 });
3758 // Only a turn that already ended is released; an active one
3759 // still needs its final-answer bookkeeping.
3760 if ((yield* Ref.get(settledTurns)).has(nativeTurnId)) {
3761 yield* releaseSettledTurnIfIdle(nativeTurnId);
3762 }
3763 }),
3764 );
3765
3766 /**
3767 * Settles the items of app captures still running when their turn is
3768 * interrupted or fails: the capture is cancelled and its tool call is
3769 * reported as it completed, without the app, before the turn's
3770 * terminal event closes ingestion.
3771 */
3772 const terminalizeMcpAppCaptures = (context: ActiveCodexTurnContext, nativeTurnId: string) =>
3773 Effect.gen(function* () {
3774 const captures = (yield* Ref.get(pendingMcpAppCaptures)).get(nativeTurnId);
3775 if (captures === undefined) return;
3776 for (const { item, fiber } of captures.values()) {
3777 if (!(yield* takePendingMcpAppCapture(nativeTurnId, item.id))) continue;
3778 yield* Fiber.interrupt(fiber);
3779 const artifacts = yield* buildDynamicToolArtifacts(context, item);
3780 yield* emitProviderEvent({
3781 type: "node.updated",
3782 driver: CODEX_PROVIDER,
3783 node: artifacts.node,
3784 });
3785 yield* emitProviderEvent({
3786 type: "turn_item.updated",
3787 driver: CODEX_PROVIDER,
3788 turnItem: artifacts.turnItem,
3789 });
3790 }
3791 });
3792
35583793 const buildProposedPlanArtifacts = (input: {
35593794 readonly context: ActiveCodexTurnContext;
35603795 readonly nativeItemId: string;
......@@ -4531,6 +4766,11 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
45314766
45324767 if (payload.item.type === "mcpToolCall" || payload.item.type === "dynamicToolCall") {
45334768 yield* clearRunningDynamicTool(payload.turnId, payload.item.id);
4769 const appResourceUri = codexMcpAppResourceUri(payload.item);
4770 if (appResourceUri !== undefined && payload.item.type === "mcpToolCall") {
4771 yield* captureMcpApp(context, payload.turnId, payload.item, appResourceUri);
4772 return;
4773 }
45344774 const artifacts = yield* buildDynamicToolArtifacts(context, payload.item);
45354775 yield* emitProviderEvent({
45364776 type: "node.updated",
......@@ -5563,6 +5803,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
55635803 input.status,
55645804 input.completedAt,
55655805 );
5806 yield* terminalizeMcpAppCaptures(input.context, input.nativeTurnId);
55665807 }
55675808 const dynamicToolStatus: "cancelled" | "interrupted" | "failed" =
55685809 input.status === "interrupted" || input.status === "failed"
......@@ -5601,6 +5842,10 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
56015842 updated.set(input.nativeTurnId, input.context);
56025843 return updated;
56035844 });
5845 // Background work that finished between the check above and
5846 // the turn being recorded as settled saw no settled turn to
5847 // release, so it is re-checked once recorded.
5848 yield* releaseSettledTurnIfIdle(input.nativeTurnId);
56045849 }
56055850 yield* Ref.update(activeTurns, (current) => {
56065851 const updated = new Map(current);
......@@ -5874,6 +6119,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
58746119 browserToolsAvailable: mcpSession?.browserToolsAvailable ?? true,
58756120 deviceToolsAvailable: mcpSession?.capabilities?.has("device") ?? false,
58766121 omitServiceTier: adapterOptions.resolveRuntime !== undefined,
6122 ...(turnInput.appContext === undefined ? {} : { appContext: turnInput.appContext }),
58776123 });
58786124 yield* Ref.update(pendingRootTurns, (current) => {
58796125 const updated = new Map(current);
......@@ -6022,6 +6268,64 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
60226268 ),
60236269 );
60246270
6271 // MCP Apps reach their server through Codex's own MCP client, so any
6272 // server the user configured for Codex works without T3 connecting to it.
6273 const mcpAppsError = (detail: string) => (cause: unknown) =>
6274 new ProviderAdapterProtocolError({ driver: CODEX_PROVIDER, detail, payload: cause });
6275 const mcpApps: ProviderAdapterV2McpApps = {
6276 listTools: (toolsInput) =>
6277 Effect.gen(function* () {
6278 const threadId = yield* getNativeThreadId(toolsInput.providerThread);
6279 const response = yield* ensureInitialized.pipe(
6280 Effect.andThen(
6281 client.request("mcpServerStatus/list", {
6282 threadId,
6283 serverName: toolsInput.server,
6284 detail: "toolsAndAuthOnly",
6285 }),
6286 ),
6287 );
6288 const server = response.data.find((entry) => entry.name === toolsInput.server);
6289 return Object.values(server?.tools ?? {});
6290 }).pipe(Effect.mapError(mcpAppsError("Failed to list Codex MCP tools."))),
6291 callTool: (callInput) =>
6292 Effect.gen(function* () {
6293 const threadId = yield* getNativeThreadId(callInput.providerThread);
6294 const response = yield* ensureInitialized.pipe(
6295 Effect.andThen(
6296 client.request("mcpServer/tool/call", {
6297 threadId,
6298 server: callInput.server,
6299 tool: callInput.tool,
6300 arguments: callInput.arguments as Schema.Json,
6301 }),
6302 ),
6303 );
6304 return {
6305 content: response.content,
6306 ...(response.structuredContent === undefined
6307 ? {}
6308 : { structuredContent: response.structuredContent }),
6309 ...(response.isError === true ? { isError: true } : {}),
6310 ...(response._meta === undefined ? {} : { _meta: response._meta }),
6311 };
6312 }).pipe(Effect.mapError(mcpAppsError("Codex MCP tool call failed."))),
6313 readResource: (readInput) =>
6314 Effect.gen(function* () {
6315 const threadId = yield* getNativeThreadId(readInput.providerThread);
6316 const response = yield* ensureInitialized.pipe(
6317 Effect.andThen(
6318 client.request("mcpServer/resource/read", {
6319 threadId,
6320 server: readInput.server,
6321 uri: readInput.uri,
6322 }),
6323 ),
6324 );
6325 return { contents: response.contents };
6326 }).pipe(Effect.mapError(mcpAppsError("Codex MCP resource read failed."))),
6327 };
6328
60256329 const runtime: ProviderAdapterV2SessionRuntime = {
60266330 instanceId: adapterOptions.instanceId,
60276331 driver: CODEX_PROVIDER,
......@@ -6044,6 +6348,9 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
60446348 return true;
60456349 }
60466350 }
6351 if ((yield* Ref.get(pendingMcpAppCaptures)).size > 0) {
6352 return true;
6353 }
60476354 for (const subagent of (yield* Ref.get(subagentThreads)).values()) {
60486355 if (subagent.task.status === "running") {
60496356 return true;
......@@ -6669,6 +6976,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
66696976 completedAt,
66706977 true,
66716978 );
6979 yield* terminalizeMcpAppCaptures(context, context.nativeTurnId);
66726980 yield* Ref.update(runningCommandItemsByTurn, (current) => {
66736981 const updated = new Map(current);
66746982 updated.delete(context.nativeTurnId);
......@@ -6765,6 +7073,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
67657073 }),
67667074 ),
67677075 ),
7076 mcpApps,
67687077 readThreadSnapshot: (threadInput) =>
67697078 Effect.gen(function* () {
67707079 const threadId = yield* getNativeThreadId(threadInput.providerThread);
apps/server/src/orchestration-v2/ProjectionStore.ts+4-2
......@@ -70,6 +70,7 @@ import * as Schema from "effect/Schema";
7070import * as SqlClient from "effect/sql/SqlClient";
7171import type * as Statement from "effect/sql/Statement";
7272
73import { MCP_APP_OUTPUT_KEY } from "@t3tools/shared/mcpApp";
7374import { threadHtmlRenderAttachmentIds } from "../attachmentStore.ts";
7475import {
7576 isThreadHistoryUserTurn,
......@@ -4603,13 +4604,14 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
46034604 json_each(message.payload_json, '$.attachments') AS attachment
46044605 WHERE message.thread_id = ${threadId}
46054606 `,
4606 // Pages published by html_render live in the attachment store too.
4607 // Pages published by html_render, and captured MCP App documents,
4608 // live in the attachment store too.
46074609 sql<{ payload_json: string }>`
46084610 SELECT payload_json
46094611 FROM orchestration_v2_projection_turn_items
46104612 WHERE thread_id = ${threadId}
46114613 AND type = 'dynamic_tool'
4612 AND payload_json LIKE '%htmlRender%'
4614 AND (payload_json LIKE '%htmlRender%' OR payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`})
46134615 `,
46144616 ]).pipe(
46154617 Effect.map(([messages, renders]) => [
apps/server/src/orchestration-v2/ProviderAdapter.ts+45
......@@ -408,6 +408,12 @@ export interface ProviderAdapterV2TurnInput {
408408 readonly message: ProviderAdapterV2TurnMessage;
409409 readonly modelSelection: ModelSelection;
410410 readonly runtimePolicy: ProviderAdapterV2RuntimePolicy;
411 /**
412 * What the thread's MCP Apps want the agent to know (`ui/update-model-context`),
413 * latest per app, keyed stably per app. Adapters that host apps deliver it as
414 * application context; others never receive any.
415 */
416 readonly appContext?: ReadonlyArray<{ readonly key: string; readonly text: string }>;
411417}
412418
413419export interface ProviderAdapterV2SteerInput {
......@@ -483,6 +489,39 @@ export interface ProviderAdapterV2HistoricalContext {
483489 readonly context: string;
484490}
485491
492/** An MCP tool as the provider's MCP client lists it. */
493export interface ProviderAdapterV2McpTool {
494 readonly name: string;
495 readonly _meta?: unknown;
496 readonly annotations?: unknown;
497}
498
499/** MCP `CallToolResult` / `ReadResourceResult` shapes, passed through unchanged. */
500export interface ProviderAdapterV2McpCallToolResult {
501 readonly content: ReadonlyArray<unknown>;
502 readonly structuredContent?: unknown;
503 readonly isError?: boolean;
504 readonly _meta?: unknown;
505}
506
507export interface ProviderAdapterV2McpApps {
508 readonly listTools: (input: {
509 readonly providerThread: OrchestrationV2ProviderThread;
510 readonly server: string;
511 }) => Effect.Effect<ReadonlyArray<ProviderAdapterV2McpTool>, ProviderAdapterV2Error>;
512 readonly callTool: (input: {
513 readonly providerThread: OrchestrationV2ProviderThread;
514 readonly server: string;
515 readonly tool: string;
516 readonly arguments: Record<string, unknown>;
517 }) => Effect.Effect<ProviderAdapterV2McpCallToolResult, ProviderAdapterV2Error>;
518 readonly readResource: (input: {
519 readonly providerThread: OrchestrationV2ProviderThread;
520 readonly server: string;
521 readonly uri: string;
522 }) => Effect.Effect<{ readonly contents: ReadonlyArray<unknown> }, ProviderAdapterV2Error>;
523}
524
486525export interface ProviderAdapterV2SessionRuntime {
487526 readonly instanceId: ProviderInstanceId;
488527 readonly driver: ProviderDriverKind;
......@@ -572,6 +611,12 @@ export interface ProviderAdapterV2SessionRuntime {
572611 readonly providerThread: OrchestrationV2ProviderThread;
573612 readonly reason?: string;
574613 }) => Effect.Effect<{ readonly feedbackId: string }, ProviderAdapterV2Error>;
614 /**
615 * MCP Apps host operations through the provider's own MCP client, for
616 * drivers whose protocol exposes them (Codex). Absent means apps from this
617 * provider are not interactive and their tool calls stay plain tool rows.
618 */
619 readonly mcpApps?: ProviderAdapterV2McpApps;
575620 readonly rollbackThread: (
576621 input: ProviderAdapterV2RollbackThreadInput,
577622 ) => Effect.Effect<ProviderAdapterV2ThreadSnapshot, ProviderAdapterV2Error>;
apps/server/src/orchestration-v2/RunExecutionService.test.ts+99
......@@ -49,9 +49,11 @@ import {
4949 type ProviderAdapterV2Error,
5050 type ProviderAdapterV2Event,
5151 type ProviderAdapterV2SessionRuntime,
52 type ProviderAdapterV2TurnInput,
5253} from "./ProviderAdapter.ts";
5354import * as ProjectionStore from "./ProjectionStore.ts";
5455import * as ProviderEventIngestor from "./ProviderEventIngestor.ts";
56import * as McpAppModelContext from "../mcpApps/McpAppModelContext.ts";
5557import * as RunExecutionService from "./RunExecutionService.ts";
5658import * as RunFinalizationService from "./RunFinalizationService.ts";
5759
......@@ -60,6 +62,7 @@ const driver = ProviderDriverKind.make("codex");
6062const layerRunExecutionTest = RunExecutionService.layer.pipe(
6163 Layer.provide(
6264 Layer.mergeAll(
65 McpAppModelContext.layerEmpty,
6366 Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }),
6467 Layer.mock(EventSink.EventSinkV2)({}),
6568 IdAllocator.layer,
......@@ -591,6 +594,90 @@ it.effect("rechecks run ownership immediately before calling the provider", () =
591594 }).pipe(Effect.provide(layerRunExecutionTest)),
592595);
593596
597it.effect("passes the thread's MCP app context to the provider under a safe key", () =>
598 Effect.gen(function* () {
599 const runExecution = yield* RunExecutionService.RunExecutionServiceV2;
600 const started = yield* Deferred.make<ProviderAdapterV2TurnInput>();
601 const threadId = ThreadId.make("thread:run-execution-app-context");
602 const providerInstanceId = ProviderInstanceId.make("codex");
603 const attemptId = RunAttemptId.make("attempt:run-execution-app-context");
604 const session = {
605 events: Stream.never,
606 startTurn: (input: ProviderAdapterV2TurnInput) => Deferred.succeed(started, input),
607 } as unknown as ProviderAdapterV2SessionRuntime;
608
609 yield* runExecution.startRootRun({
610 commandId: CommandId.make("command:run-execution-app-context"),
611 appThread: { id: threadId } as OrchestrationV2AppThread,
612 providerSessionId: ProviderSessionId.make("session:run-execution-app-context"),
613 session,
614 run: {
615 id: RunId.make("run:run-execution-app-context"),
616 threadId,
617 ordinal: 1,
618 providerInstanceId,
619 } as OrchestrationV2Run,
620 rootNode: {
621 id: NodeId.make("node:run-execution-app-context"),
622 } as OrchestrationV2ExecutionNode,
623 checkpointScope: {
624 id: CheckpointScopeId.make("checkpoint-scope:run-execution-app-context"),
625 } as OrchestrationV2CheckpointScope,
626 providerThread: {
627 id: ProviderThreadId.make("provider-thread:run-execution-app-context"),
628 driver,
629 } as OrchestrationV2ProviderThread,
630 attempt: { id: attemptId, providerTurnId: null } as OrchestrationV2RunAttempt,
631 attemptId,
632 providerTurnOrdinal: 1,
633 message: {
634 messageId: MessageId.make("message:run-execution-app-context"),
635 text: "What is on my list?",
636 attachments: [],
637 createdBy: "user",
638 creationSource: "web",
639 },
640 modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" },
641 runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd() },
642 });
643
644 const input = yield* Deferred.await(started);
645 // Item ids carry colons, and server names are free text; neither reaches the key.
646 assert.deepEqual(input.appContext, [
647 { key: "mcp_app_turn-item_provider_codex_native-item_call-1", text: "2 overdue" },
648 ]);
649 }).pipe(
650 Effect.provide(
651 RunExecutionService.layer.pipe(
652 Layer.provide(
653 Layer.mergeAll(
654 Layer.mock(McpAppModelContext.McpAppModelContext)({
655 forThread: () =>
656 Effect.succeed([
657 {
658 itemId: "turn-item:provider:codex:native-item:call-1",
659 server: "my tools>",
660 tool: "list",
661 text: "2 overdue",
662 },
663 ]),
664 }),
665 Layer.mock(CheckpointService.CheckpointServiceV2)({
666 captureBaseline: () => Effect.void,
667 }),
668 Layer.mock(EventSink.EventSinkV2)({}),
669 IdAllocator.layer,
670 Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({
671 ingestNormalized: () => Effect.succeed([]),
672 }),
673 ServerSettings.layerTest(),
674 ),
675 ),
676 ),
677 ),
678 ),
679);
680
594681it.effect("fails the run when its ownership check cannot be read before calling the provider", () =>
595682 Effect.gen(function* () {
596683 const guardCalls = yield* Ref.make(0);
......@@ -603,6 +690,7 @@ it.effect("fails the run when its ownership check cannot be read before calling
603690 const layerTest = RunExecutionService.layer.pipe(
604691 Layer.provide(
605692 Layer.mergeAll(
693 McpAppModelContext.layerEmpty,
606694 Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }),
607695 Layer.mock(EventSink.EventSinkV2)({
608696 writeIfRunCurrent: (input) =>
......@@ -876,6 +964,7 @@ it.effect("starts the provider when checkpoint baseline capture fails", () =>
876964 const layerTest = RunExecutionService.layer.pipe(
877965 Layer.provide(
878966 Layer.mergeAll(
967 McpAppModelContext.layerEmpty,
879968 Layer.mock(CheckpointService.CheckpointServiceV2)({
880969 captureBaseline: () =>
881970 Effect.fail(
......@@ -993,6 +1082,7 @@ it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as co
9931082 const layerTest = RunExecutionService.layer.pipe(
9941083 Layer.provide(
9951084 Layer.mergeAll(
1085 McpAppModelContext.layerEmpty,
9961086 Layer.mock(CheckpointService.CheckpointServiceV2)({
9971087 captureBaseline: () =>
9981088 scenario === "start-guard" ? Effect.void : Effect.die("not reached"),
......@@ -1163,6 +1253,7 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes",
11631253 const layerTest = RunExecutionService.layer.pipe(
11641254 Layer.provide(
11651255 Layer.mergeAll(
1256 McpAppModelContext.layerEmpty,
11661257 Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }),
11671258 Layer.mock(EventSink.EventSinkV2)({
11681259 write: () => Effect.succeed([]),
......@@ -1588,6 +1679,7 @@ it.effect(
15881679 const layerTest = RunExecutionService.layer.pipe(
15891680 Layer.provide(
15901681 Layer.mergeAll(
1682 McpAppModelContext.layerEmpty,
15911683 Layer.mock(CheckpointService.CheckpointServiceV2)({
15921684 captureBaseline: () => Effect.void,
15931685 }),
......@@ -1805,6 +1897,7 @@ it.effect("drops late root provider-thread writes from a superseded attempt", ()
18051897 const layerTest = RunExecutionService.layer.pipe(
18061898 Layer.provide(
18071899 Layer.mergeAll(
1900 McpAppModelContext.layerEmpty,
18081901 Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }),
18091902 Layer.mock(EventSink.EventSinkV2)({
18101903 write: () => Effect.succeed([]),
......@@ -2009,6 +2102,7 @@ it.effect(
20092102 const layerTest = RunExecutionService.layer.pipe(
20102103 Layer.provide(
20112104 Layer.mergeAll(
2105 McpAppModelContext.layerEmpty,
20122106 Layer.mock(CheckpointService.CheckpointServiceV2)({
20132107 captureBaseline: () => Effect.void,
20142108 }),
......@@ -2180,6 +2274,7 @@ it.effect(
21802274 const layerTest = RunExecutionService.layer.pipe(
21812275 Layer.provide(
21822276 Layer.mergeAll(
2277 McpAppModelContext.layerEmpty,
21832278 Layer.mock(CheckpointService.CheckpointServiceV2)({
21842279 captureBaseline: () => Effect.void,
21852280 }),
......@@ -2349,6 +2444,7 @@ it.effect(
23492444 const layerTest = RunExecutionService.layer.pipe(
23502445 Layer.provide(
23512446 Layer.mergeAll(
2447 McpAppModelContext.layerEmpty,
23522448 Layer.mock(CheckpointService.CheckpointServiceV2)({
23532449 captureBaseline: () => Effect.void,
23542450 }),
......@@ -2722,6 +2818,7 @@ it.effect(
27222818 const layerTest = RunExecutionService.layer.pipe(
27232819 Layer.provide(
27242820 Layer.mergeAll(
2821 McpAppModelContext.layerEmpty,
27252822 Layer.mock(CheckpointService.CheckpointServiceV2)({
27262823 captureBaseline: () => Effect.void,
27272824 }),
......@@ -3444,6 +3541,7 @@ function captureRootRunTermination(input: {
34443541 const layerTest = RunExecutionService.layer.pipe(
34453542 Layer.provide(
34463543 Layer.mergeAll(
3544 McpAppModelContext.layerEmpty,
34473545 Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }),
34483546 Layer.mock(EventSink.EventSinkV2)({
34493547 write: (payload) =>
......@@ -3922,6 +4020,7 @@ function runBackgroundItemScenario(
39224020 const layerTest = RunExecutionService.layer.pipe(
39234021 Layer.provide(
39244022 Layer.mergeAll(
4023 McpAppModelContext.layerEmpty,
39254024 Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }),
39264025 Layer.mock(EventSink.EventSinkV2)({
39274026 write: () => Effect.succeed([]),
apps/server/src/orchestration-v2/RunExecutionService.ts+21
......@@ -36,6 +36,7 @@ import * as Schema from "effect/Schema";
3636import * as Stream from "effect/Stream";
3737
3838import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts";
39import * as McpAppModelContext from "../mcpApps/McpAppModelContext.ts";
3940import * as ServerSettings from "../serverSettings.ts";
4041import * as CheckpointService from "./CheckpointService.ts";
4142import * as EventSink from "./EventSink.ts";
......@@ -544,6 +545,7 @@ export const layer: Layer.Layer<
544545 | IdAllocator.IdAllocatorV2
545546 | ProviderEventIngestor.ProviderEventIngestorV2
546547 | ServerSettings.ServerSettingsService
548 | McpAppModelContext.McpAppModelContext
547549> = Layer.effect(
548550 RunExecutionServiceV2,
549551 Effect.gen(function* () {
......@@ -552,6 +554,7 @@ export const layer: Layer.Layer<
552554 const idAllocator = yield* IdAllocator.IdAllocatorV2;
553555 const providerEventIngestor = yield* ProviderEventIngestor.ProviderEventIngestorV2;
554556 const serverSettings = yield* ServerSettings.ServerSettingsService;
557 const mcpAppModelContext = yield* McpAppModelContext.McpAppModelContext;
555558 const finalizationObserver = yield* RunFinalizationService.RunFinalizationObserver;
556559
557560 const writeFinalRunEvents = (input: {
......@@ -1352,6 +1355,23 @@ export const layer: Layer.Layer<
13521355 // its already-issued MCP credential valid even when the agent goes
13531356 // a long time between browser-tool calls.
13541357 yield* McpSessionRegistry.touchActiveMcpThread(input.run.threadId);
1358 // A context read that fails costs the agent the apps' notes for
1359 // this turn, not the turn itself.
1360 const appContext = (yield* mcpAppModelContext
1361 .forThread(input.run.threadId)
1362 .pipe(
1363 Effect.catch((cause) =>
1364 Effect.logWarning("Failed to read MCP app model context.", { cause }).pipe(
1365 Effect.as([]),
1366 ),
1367 ),
1368 )).map((entry) => ({
1369 // The item id alone is unique and needs no escaping; server and
1370 // tool names are free text that would break the tag Codex wraps
1371 // the context in.
1372 key: `mcp_app_${entry.itemId.replace(/[^\w.-]/g, "_")}`,
1373 text: entry.text,
1374 }));
13551375 const turnInput = {
13561376 appThread: input.appThread,
13571377 threadId: input.run.threadId,
......@@ -1372,6 +1392,7 @@ export const layer: Layer.Layer<
13721392 message: input.message,
13731393 modelSelection: input.modelSelection,
13741394 runtimePolicy: input.runtimePolicy,
1395 ...(appContext.length === 0 ? {} : { appContext }),
13751396 };
13761397 const compact =
13771398 input.message.attachments.length === 0 &&
apps/server/src/orchestration-v2/runtimeLayer.ts+15
......@@ -3,6 +3,8 @@ import * as Scheduler from "../scheduling/Scheduler.ts";
33import * as Layer from "effect/Layer";
44import * as OrchestrationCommandReceipts from "../persistence/OrchestrationCommandReceipts.ts";
55import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts";
6import * as McpAppModelContext from "../mcpApps/McpAppModelContext.ts";
7import * as McpAppRequests from "../mcpApps/McpAppRequests.ts";
68import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts";
79import * as TextGeneration from "../textGeneration/TextGeneration.ts";
810import * as ProviderAuthService from "../provider/ProviderAuthService.ts";
......@@ -141,6 +143,7 @@ const layerProviderAuthServiceProvided = ProviderAuthService.layer.pipe(
141143const layerRunExecutionServiceProvided = RunExecutionService.layer.pipe(
142144 Layer.provide(
143145 Layer.mergeAll(
146 McpAppModelContext.layer,
144147 layerCheckpointServiceProvided,
145148 layerEventSinkProvided,
146149 IdAllocator.layer,
......@@ -314,8 +317,20 @@ const layerProviderRuntimeRecoveryProvided = ProviderRuntimeRecoveryService.laye
314317 ),
315318);
316319
320const layerMcpAppRequestsProvided = McpAppRequests.layer.pipe(
321 Layer.provide(
322 Layer.mergeAll(
323 McpAppModelContext.layer,
324 layerOrchestratorProvided,
325 layerThreadManagementProvided,
326 layerProviderSessionManagerProvided,
327 ),
328 ),
329);
330
317331export const layer = Layer.mergeAll(
318332 layerOrchestratorProvided,
333 layerMcpAppRequestsProvided,
319334 layerThreadManagementProvided,
320335 layerEffectWorkerProvided,
321336 layerProviderSessionManagerProvided,
apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts+2
......@@ -44,6 +44,7 @@ import * as ProviderSwitchService from "../ProviderSwitchService.ts";
4444import * as ProviderTurnControlService from "../ProviderTurnControlService.ts";
4545import * as ProviderTurnStartService from "../ProviderTurnStartService.ts";
4646import * as ProviderTurnStartServiceTestkit from "../ProviderTurnStartService.testkit.ts";
47import * as McpAppModelContext from "../../mcpApps/McpAppModelContext.ts";
4748import * as RunExecutionService from "../RunExecutionService.ts";
4849import * as RunFinalizationService from "../RunFinalizationService.ts";
4950import * as ThreadTitleRegenerationService from "../ThreadTitleRegenerationService.ts";
......@@ -355,6 +356,7 @@ export function layerWithRegistry<Error>(
355356 const layerRunExecutionServiceProvided = RunExecutionService.layer.pipe(
356357 Layer.provide(
357358 Layer.mergeAll(
359 McpAppModelContext.layerEmpty,
358360 layerCheckpointServiceProvided,
359361 layerEventSinkProvided,
360362 IdAllocator.layer,
apps/server/src/orchestration-v2/testkit/fixtures/delegated_task_status/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"delegated_task_status","metadata":{"source":"constructed-codex-app-server-replay-fixture","fileName":"codex_transcript.ndjson","normalization":"Provider-valid frames were composed into a deterministic multi-turn lifecycle; native IDs and timestamps were normalized.","description":"An active parent delegates a child, interrupts a running follow-up, and lets queued work complete."}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"macos"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"parent.thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true}}}}
apps/server/src/orchestration-v2/testkit/fixtures/message_steering/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"message_steering","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"message_steering.ndjson","description":"One active turn receives an immediate turn/steer request.","model":"gpt-6-luna"}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}}
apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"multi_turn","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"multi_turn.ndjson","description":"One thread with two sequential user turns.","model":"gpt-6-luna"}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}}
apps/server/src/orchestration-v2/testkit/fixtures/plan_questions/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"plan_questions","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"plan_questions.ndjson","description":"One plan-mode turn that asks a structured clarifying question.","model":"gpt-6-luna"}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}}
apps/server/src/orchestration-v2/testkit/fixtures/proposed_plan/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"proposed_plan","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"proposed_plan.ndjson","description":"One plan-mode turn that emits a proposed plan document.","model":"gpt-6-luna"}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}}
apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/codex_transcript.ndjson+2-2
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"provider_thread_resume","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"provider_thread_resume.ndjson","description":"One provider-native thread is started, completed, then resumed by thread id in a fresh app-server session.","model":"gpt-6-luna"}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}}
......@@ -32,7 +32,7 @@
3232{"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","status":{"type":"idle"}},"emittedAtMs":1790295635342}}
3333{"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","turn":{"id":"01a0d5ef-637e-7ba2-bf9a-5534a6f98a83","items":[{"type":"agentMessage","id":"msg_0b6c69e800b2ec4c016ab5be52efd887d092b6695452f82b22","text":"provider thread resume fixture first turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790295630,"completedAt":1790295635,"durationMs":4607}},"emittedAtMs":1790295635342}}
3434{"type":"runtime_exit","status":"success"}
35{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
35{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
3636{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
3737{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
3838{"type":"expect_outbound","label":"thread/resume","frame":{"id":2,"method":"thread/resume","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","excludeTurns":true,"model":"gpt-6-luna","threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd"}}}
apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"queued_turn","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"queued_turn.ndjson","description":"A second user turn queued behind an active turn starts after it completes.","model":"gpt-6-luna"}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}}
apps/server/src/orchestration-v2/testkit/fixtures/simple/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"simple","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"simple.ndjson","description":"One thread and one turn with a deterministic text-only response.","model":"gpt-6-luna"}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}}
apps/server/src/orchestration-v2/testkit/fixtures/subagent/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"subagent","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"subagent.ndjson","description":"One root turn that asks Codex to spawn two collab agents.","model":"gpt-5.6-luna","recorderThreadConfigKeys":["skills.include_instructions"]}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true,"skills.include_instructions":false},"cwd":"<workspace>","model":"gpt-5.6-luna"}}}
apps/server/src/orchestration-v2/testkit/fixtures/subagent_continue/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"subagent_continue","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"subagent_continue.ndjson","description":"A root turn spawns one native Codex subagent, then a later root turn resumes and messages the same child thread.","model":"gpt-5.6-luna"}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-5.6-luna"}}}
apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"subagent_v2","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"subagent_v2.ndjson","description":"One root turn on a multi-agent v2 model that spawns one native subagent and waits for it.","model":"gpt-6-luna"}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}}
apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_approval/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"subagent_v2_approval","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"subagent_v2_approval.ndjson","description":"One root turn in approval-required mode whose multi-agent v2 subagent runs a command that needs approval.","model":"gpt-6-luna"}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/tmp/codex-subagent-approval-rec/codex-home","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}}
apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"subagent_v2_nested","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"subagent_v2_nested.ndjson","description":"One root turn on a multi-agent v2 model whose subagent spawns a subagent that spawns a leaf.","model":"gpt-6-luna","recorderThreadConfigKeys":["agents.max_depth"]}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true,"agents.max_depth":3},"cwd":"<workspace>","model":"gpt-6-luna"}}}
apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested_approval/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"subagent_v2_nested_approval","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"subagent_v2_nested_approval.ndjson","description":"One root turn in approval-required mode whose multi-agent v2 subagent spawns a subagent that runs a command that needs approval.","model":"gpt-6-luna","recorderThreadConfigKeys":["agents.max_depth"]}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.cache/codex-nested-approval-rec/codex-home","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true,"agents.max_depth":2},"cwd":"<workspace>","model":"gpt-6-luna"}}}
apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"thread_fork_native","metadata":{"source":"hand-authored-replay","description":"One completed source turn, a native thread/fork, and the first target fork turn.","normalization":"The thread/fork expected request gains lastTurnId (inclusive native boundary) to match the V2 adapter's native-boundary fork."}}
2{"type":"expect_outbound","label":"initialize/source","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize/source","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize/source","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0","codexHome":"/tmp/codex","platformFamily":"unix","platformOs":"macos"}}}
44{"type":"expect_outbound","label":"initialized/source","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start/source","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true}}}}
apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_continue/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"thread_fork_native_continue","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_fork_native_continue.ndjson","description":"One source marker turn, a native thread fork, a fork-local marker turn, and a recall turn that requires both contexts.","model":"gpt-6-luna"}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}}
apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_prior_turn/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.124.0-alpha.3","scenario":"thread_fork_native_prior_turn","metadata":{"source":"codex-app-server-live-probe-normalized-for-orchestrator-replay","description":"Two completed source turns, a native thread/fork through the first source turn, and a fork turn that only sees the first turn.","sourceThreadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","forkThreadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","firstTurnId":"019dd6ba-268b-7f82-8f4c-9fde0edcfd55","secondTurnId":"019dd6ba-416d-7902-95cf-d4179c3f4738","repeatTurnId":"019dd6ba-47eb-7041-ad45-5abe752c28c9","normalization":"Native frames are live. Initialize frames are normalized to the T3 adapter client info, and a fork-session initialize was inserted to match orchestrator provider-session lifecycle. The thread/fork expected request gains lastTurnId (the first source turn, inclusive), the emitted fork response now contains only turns through that boundary, and the thread/rollback pair was removed to match the V2 adapter's native-boundary fork."}}
2{"type":"expect_outbound","label":"initialize/source","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize/source","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize/source","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.124.0-alpha.3","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"macos"}}}
44{"type":"expect_outbound","label":"initialized/source","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true}}}}
apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_siblings/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"thread_fork_native_siblings","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_fork_native_siblings.ndjson","description":"One source marker turn and two independent native forks that each recall the source plus only their own fork marker.","model":"gpt-6-sol"}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-sol"}}}
apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_continue/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"thread_merge_back_continue","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_merge_back_continue.ndjson","description":"A source thread consumes one fork-delta handoff and later recalls source and transferred context.","model":"gpt-6-sol"}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-sol"}}}
apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_siblings/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"thread_merge_back_siblings","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_merge_back_siblings.ndjson","description":"Two sibling fork deltas are merged sequentially into one source provider thread and recalled together.","model":"gpt-6-sol"}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-sol"}}}
apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"thread_rollback","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_rollback.ndjson","description":"One thread completes two turns, rolls back the most recent turn, then starts another turn.","model":"gpt-6-luna"}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}}
apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_restart/codex_transcript.ndjson+2-2
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"thread_rollback_after_restart","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_rollback_after_restart.ndjson","description":"One thread completes two turns, the app-server restarts, then the thread rolls back its latest turn and starts another turn.","model":"gpt-6-luna"}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/tmp/bughunt-codex/codex-home","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}}
......@@ -45,7 +45,7 @@
4545{"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","status":{"type":"idle"}},"emittedAtMs":1790404934811}}
4646{"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","turn":{"id":"01a0dc73-2dc4-7911-a448-c66b9bf3318b","items":[{"type":"agentMessage","id":"msg_0f3ce1c685f16088016ab769447c6087d08bd0018d724b5401","text":"rollback fixture second turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790404931,"completedAt":1790404934,"durationMs":3756}},"emittedAtMs":1790404934811}}
4747{"type":"runtime_exit","status":"success"}
48{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
48{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
4949{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/tmp/bughunt-codex/codex-home","platformFamily":"unix","platformOs":"linux"}}}
5050{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
5151{"type":"expect_outbound","label":"thread/read","frame":{"id":2,"method":"thread/read","params":{"includeTurns":false,"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a"}}}
apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_to_stopped_turn/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"thread_rollback_to_stopped_turn","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_rollback_to_stopped_turn.ndjson","description":"One completed turn, one turn stopped mid-tool, one completed turn, thread/revert back to the stopped turn, then a post-rollback turn.","model":"gpt-6-luna"}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}}
apps/server/src/orchestration-v2/testkit/fixtures/todo_list/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"todo_list","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"todo_list.ndjson","description":"One turn that asks Codex to emit progress through update_plan.","model":"gpt-6-luna"}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}}
apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"tool_call_read_only_on_request","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"tool_call_read_only_on_request.ndjson","description":"Write a small fixture file with read-only full filesystem visibility and on-request approvals.","model":"gpt-6-sol"}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-sol"}}}
apps/server/src/orchestration-v2/testkit/fixtures/tool_call_restricted_granular/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"tool_call_restricted_granular","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"tool_call_restricted_granular.ndjson","description":"Write a small fixture file with restricted read access and granular approval flags enabled.","model":"gpt-5.6-terra"}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-5.6-terra"}}}
apps/server/src/orchestration-v2/testkit/fixtures/tool_call_workspace_never/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"tool_call_workspace_never","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"tool_call_workspace_never.ndjson","description":"Write a small fixture file with workspace-write sandbox policy and never approvals.","model":"gpt-6-luna"}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}}
apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"turn_interrupt","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"turn_interrupt.ndjson","description":"One active turn is interrupted before it finishes naturally.","model":"gpt-6-luna"}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}}
apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"turn_interrupt_mid_tool","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"turn_interrupt_mid_tool.ndjson","description":"One active turn is interrupted after Codex has already executed a local command.","model":"gpt-6-luna"}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}}
apps/server/src/orchestration-v2/testkit/fixtures/web_search/codex_transcript.ndjson+1-1
......@@ -1,5 +1,5 @@
11{"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"web_search","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"web_search.ndjson","description":"One turn with a native Codex webSearch item that should become normalized progress.","model":"gpt-6-luna"}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
2{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}}
33{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}}
44{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}}
apps/server/src/persistence/Migrations.ts+2
......@@ -72,6 +72,7 @@ import Migration0055 from "./Migrations/055_OrchestrationV2.ts";
7272import Migration0056 from "./Migrations/056_RemoveRedundantProjectionIndexes.ts";
7373import Migration0057 from "./Migrations/057_ScheduledTaskWebhooks.ts";
7474import Migration0058 from "./Migrations/058_WebhookRelayDeliveries.ts";
75import Migration0059 from "./Migrations/059_McpAppModelContext.ts";
7576
7677/**
7778 * Migration loader with all migrations defined inline.
......@@ -144,6 +145,7 @@ export const migrationEntries = [
144145 [56, "RemoveRedundantProjectionIndexes", Migration0056],
145146 [57, "ScheduledTaskWebhooks", Migration0057],
146147 [58, "WebhookRelayDeliveries", Migration0058],
148 [59, "McpAppModelContext", Migration0059],
147149] as const;
148150
149151export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts+3-1
......@@ -13,7 +13,7 @@ layer("055_OrchestrationV2", (it) => {
1313 Effect.sync(() => {
1414 assert.deepStrictEqual(
1515 migrationEntries.map(([id]) => id),
16 Array.from({ length: 58 }, (_, index) => index + 1),
16 Array.from({ length: 59 }, (_, index) => index + 1),
1717 );
1818 }),
1919 );
......@@ -30,6 +30,7 @@ layer("055_OrchestrationV2", (it) => {
3030 [56, "RemoveRedundantProjectionIndexes"],
3131 [57, "ScheduledTaskWebhooks"],
3232 [58, "WebhookRelayDeliveries"],
33 [59, "McpAppModelContext"],
3334 ]);
3435 assert.deepStrictEqual(yield* runMigrations(), []);
3536
......@@ -54,6 +55,7 @@ layer("055_OrchestrationV2", (it) => {
5455 { migration_id: 56, name: "RemoveRedundantProjectionIndexes" },
5556 { migration_id: 57, name: "ScheduledTaskWebhooks" },
5657 { migration_id: 58, name: "WebhookRelayDeliveries" },
58 { migration_id: 59, name: "McpAppModelContext" },
5759 ]);
5860
5961 const tables = yield* sql<{ readonly name: string }>`
apps/server/src/persistence/Migrations/059_McpAppModelContext.ts created+21
......@@ -0,0 +1,21 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 // The latest context each MCP App gave the agent (`ui/update-model-context`),
8 // one row per app, replaced on every update and sent with the thread's next
9 // turn.
10 yield* sql`
11 CREATE TABLE IF NOT EXISTS mcp_app_model_context (
12 thread_id TEXT NOT NULL,
13 item_id TEXT NOT NULL,
14 server TEXT NOT NULL,
15 tool TEXT NOT NULL,
16 text TEXT NOT NULL,
17 updated_at TEXT NOT NULL,
18 PRIMARY KEY (thread_id, item_id)
19 )
20 `;
21});
apps/server/src/persistence/reconcileV2PreviewMigration.test.ts+2
......@@ -39,6 +39,7 @@ describe("V2 preview upgrade", () => {
3939 [56, "RemoveRedundantProjectionIndexes"],
4040 [57, "ScheduledTaskWebhooks"],
4141 [58, "WebhookRelayDeliveries"],
42 [59, "McpAppModelContext"],
4243 ]);
4344 assert.deepStrictEqual(yield* runMigrations(), []);
4445 assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports);
......@@ -120,6 +121,7 @@ describe("V2 preview upgrade", () => {
120121 [56, "RemoveRedundantProjectionIndexes"],
121122 [57, "ScheduledTaskWebhooks"],
122123 [58, "WebhookRelayDeliveries"],
124 [59, "McpAppModelContext"],
123125 ]);
124126 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
125127 );
apps/server/src/ws.ts+6
......@@ -116,6 +116,7 @@ import * as EnvironmentTheme from "./environmentTheme.ts";
116116import * as Keybindings from "./keybindings.ts";
117117import * as ExternalLauncher from "./process/externalLauncher.ts";
118118import * as ThreadManagementService from "./orchestration-v2/ThreadManagementService.ts";
119import * as McpAppRequests from "./mcpApps/McpAppRequests.ts";
119120import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts";
120121import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts";
121122import * as ThreadMessageIntake from "./orchestration-v2/ThreadMessageIntake.ts";
......@@ -1203,6 +1204,7 @@ const layerWsRpc = (
12031204 const threadSearch = yield* ThreadSearch.ThreadSearch;
12041205
12051206 const providerSessionsV2 = yield* ProviderSessionManager.ProviderSessionManagerV2;
1207 const mcpAppRequests = yield* McpAppRequests.McpAppRequests;
12061208 const analytics = yield* AnalyticsService.AnalyticsService;
12071209 // Client-origin attribution (#7774): every thread/turn the connecting
12081210 // client starts is credited to its surface + app version. Best-effort:
......@@ -2226,6 +2228,10 @@ const layerWsRpc = (
22262228 }
22272229 return { providers };
22282230 }),
2231 [WS_METHODS.mcpAppsCallTool]: (input) => mcpAppRequests.callTool(input),
2232 [WS_METHODS.mcpAppsToolInfo]: (input) => mcpAppRequests.toolInfo(input),
2233 [WS_METHODS.mcpAppsUpdateModelContext]: (input) => mcpAppRequests.updateModelContext(input),
2234 [WS_METHODS.mcpAppsReadResource]: (input) => mcpAppRequests.readResource(input),
22292235 [WS_METHODS.providerUploadFeedback]: (input) =>
22302236 Effect.gen(function* () {
22312237 const projection = yield* threadManagement.getThreadRecords(input.threadId, [
apps/web/src/components/ChatView.tsx+34-1
......@@ -4606,6 +4606,30 @@ export default function ChatView(props: ChatViewProps) {
46064606 },
46074607 [composerRef, scheduleComposerFocus],
46084608 );
4609 // An MCP App's approved `ui/message`: queued like a typed message, so it
4610 // never steers or interrupts a running turn.
4611 const sendAppMessage = useCallback(
4612 async (text: string) => {
4613 if (!isServerThread || activeThreadId === null) {
4614 throw new Error("Messages from apps need a started thread.");
4615 }
4616 const result = await startThreadTurn({
4617 environmentId,
4618 input: {
4619 threadId: activeThreadId,
4620 message: { messageId: newMessageId(), role: "user", text, attachments: [] },
4621 runtimeMode,
4622 interactionMode,
4623 dispatchMode: "queue",
4624 },
4625 });
4626 if (result._tag === "Failure") {
4627 const error = squashAtomCommandFailure(result);
4628 throw error instanceof Error ? error : new Error("Could not send the app's message.");
4629 }
4630 },
4631 [activeThreadId, environmentId, interactionMode, isServerThread, runtimeMode, startThreadTurn],
4632 );
46094633 const editQueuedRunCommand = useAtomCommand(threadEnvironment.editQueuedRun, {
46104634 reportFailure: false,
46114635 });
......@@ -6823,6 +6847,8 @@ export default function ChatView(props: ChatViewProps) {
68236847 frame = window.requestAnimationFrame(() => {
68246848 frame = window.requestAnimationFrame(() => {
68256849 frame = null;
6850 // A full-screen app owns the page; refocusing the composer would close it.
6851 if (document.querySelector("[data-mcp-app-fullscreen]") !== null) return;
68266852 if (shouldRefocusComposerOnWindowFocus(document.activeElement)) focusComposer();
68276853 });
68286854 });
......@@ -11280,6 +11306,13 @@ export default function ChatView(props: ChatViewProps) {
1128011306 !paintOnlyDisplayedTimeline && (isWorking || !latestRunSettled)
1128111307 }
1128211308 isCompacting={!paintOnlyDisplayedTimeline && isCompacting}
11309 awaitingUser={
11310 !paintOnlyDisplayedTimeline &&
11311 (activePendingApproval !== null ||
11312 activePendingUserInput !== null ||
11313 // A secret request has no runtime request; the shell carries it.
11314 activeThreadShell?.hasPendingUserInput === true)
11315 }
1128311316 activeTurnStartedAt={paintOnlyDisplayedTimeline ? null : activeWorkStartedAt}
1128411317 worktreeSetup={paintOnlyDisplayedTimeline ? null : worktreeSetup}
1128511318 onCancelWorktreeSetup={onCancelWorktreeSetup}
......@@ -11322,7 +11355,7 @@ export default function ChatView(props: ChatViewProps) {
1132211355 paintOnlyDisplayedTimeline ? noopHeldRevert : onRevertTimelineTurn
1132311356 }
1132411357 {...(!paintOnlyDisplayedTimeline
11325 ? { onUseArtifactTemplate: useArtifactTemplate }
11358 ? { onUseArtifactTemplate: useArtifactTemplate, onSendAppMessage: sendAppMessage }
1132611359 : {})}
1132711360 isRevertingCheckpoint={isRevertingCheckpoint}
1132811361 onImageExpand={onExpandTimelineImage}
apps/web/src/components/chat/McpAppFrame.tsx created+550
......@@ -0,0 +1,550 @@
1import type { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts";
2import {
3 makeMcpAppHost,
4 McpAppHostRefusal,
5 mcpAppStyleVariables,
6 mcpResourceBytes,
7 type McpAppCallToolResult,
8 type McpAppDisplayMode,
9 type McpAppHost,
10 type McpAppHostContext,
11} from "@t3tools/client-runtime/mcp-apps";
12import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
13import {
14 clampMcpAppHeight,
15 MCP_APP_DEFAULT_HEIGHT,
16 MCP_APP_MAX_HEIGHT,
17 mcpAppAllowAttribute,
18 mcpAppFileName,
19 type McpAppReference,
20} from "@t3tools/shared/mcpApp";
21import { Minimize2Icon } from "lucide-react";
22import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
23import { flushSync } from "react-dom";
24
25import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls";
26import { APP_VERSION } from "~/branding";
27import { isConfirmDialogActive, requestConfirmDialog } from "~/confirmDialog";
28import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme";
29import { Button } from "~/components/ui/button";
30import { isElectron } from "~/env";
31import { cn } from "~/lib/utils";
32import { useTurnItemDetail } from "~/state/queries";
33import { mcpAppEnvironment } from "~/state/mcpApps";
34import { useAtomCommand } from "~/state/use-atom-command";
35
36const commandFailure = (result: {
37 readonly cause: Parameters<typeof squashAtomCommandFailure>[0]["cause"];
38}) => {
39 const error = squashAtomCommandFailure(result);
40 return new McpAppHostRefusal(
41 error instanceof Error && error.message.trim() !== "" ? error.message : "Request failed.",
42 );
43};
44
45/** Full screen shows the box in the top layer, which needs the Popover API. */
46/** A cached asset URL with less life than this is minted afresh first. */
47const MIN_URL_LIFE_MS = 5 * 60_000;
48const fullscreenSupported =
49 typeof HTMLElement !== "undefined" && "popover" in HTMLElement.prototype;
50
51/** Largest file an app may hand the user through `ui/download-file`. */
52const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
53
54/** Saves a file through the browser's own download, which asks where when it is set to. */
55function saveBlob(blob: Blob, name: string) {
56 const url = URL.createObjectURL(blob);
57 const link = document.createElement("a");
58 link.href = url;
59 link.download = name;
60 link.click();
61 // Long after the browser has read it: some start the download a task or
62 // more after the click, and a revoked URL saves nothing.
63 setTimeout(() => URL.revokeObjectURL(url), 60_000);
64}
65
66/**
67 * An MCP App inline in the thread: the captured document in an opaque-origin
68 * frame, speaking the MCP Apps bridge. Its tool calls and resource reads reach
69 * its own MCP server through the environment; calls to tools the server does
70 * not mark read-only, chat messages, and downloads ask first. Full screen keeps
71 * the same frame (and the app's state) and only restyles its box.
72 */
73export function McpAppFrame(props: {
74 readonly environmentId: EnvironmentId;
75 /** The thread that produced the app, which its requests run against. */
76 readonly threadId: ThreadId;
77 /** The thread on screen, whose next turn the app's model context informs. */
78 readonly conversationThreadId: ThreadId;
79 readonly itemId: TurnItemId;
80 /** The item's revision, so its stored call is fetched once per version. */
81 readonly revision: string;
82 readonly app: McpAppReference;
83 readonly onSendMessage: ((text: string) => Promise<void>) | undefined;
84 /** The agent waits on the user, in a panel a full-screen app would cover. */
85 readonly awaitingUser?: boolean;
86 /** Told when the app enters or leaves full screen. */
87 readonly onFullscreenChange?: (fullscreen: boolean) => void;
88}) {
89 const { app } = props;
90 const theme = useHtmlRenderTheme();
91 const frameRef = useRef<HTMLIFrameElement>(null);
92 const boxRef = useRef<HTMLDivElement>(null);
93 const [width, setWidth] = useState(0);
94 const [height, setHeight] = useState(MCP_APP_DEFAULT_HEIGHT);
95 const [navigatedAway, setNavigatedAway] = useState(false);
96 const [displayMode, setDisplayMode] = useState<McpAppDisplayMode>("inline");
97 // The app asked to be closed; the row falls back to its plain tool call.
98 const [closed, setClosed] = useState(false);
99 // Counts the app documents this row has shown. Reopening a closed app loads
100 // a new one, which needs its own host and its own load tracking.
101 const [documentGeneration, setDocumentGeneration] = useState(0);
102 useLayoutEffect(() => {
103 const box = boxRef.current;
104 if (!box) return;
105 setWidth(box.clientWidth);
106 const observer = new ResizeObserver(([entry]) => {
107 if (entry) setWidth(Math.round(entry.contentRect.width));
108 });
109 observer.observe(box);
110 return () => observer.disconnect();
111 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Closing and reopening replaces the box.
112 }, [closed]);
113
114 const resource = useMemo(
115 () => ({
116 _tag: "attachment" as const,
117 attachmentId: app.attachmentId,
118 fileName: mcpAppFileName(app),
119 mimeType: "text/html",
120 disposition: "inline" as const,
121 }),
122 [app],
123 );
124 const asset = useAssetUrlState(props.environmentId, resource);
125 const refreshAsset = useAssetUrlRefresh(props.environmentId, resource);
126 // The frame keeps its first URL: a re-minted one would reload the app. A
127 // cached URL near expiry is minted afresh first, since a frame cannot
128 // report a failed load. At most one mint per document, so a skewed clock
129 // cannot mint on every update.
130 const [src, setSrc] = useState<string | null>(null);
131 const [mintFailed, setMintFailed] = useState(false);
132 const minting = useRef(false);
133 const cachedUrl = asset._tag === "Success" ? asset.url : null;
134 const cachedExpiresAt = asset._tag === "Success" ? asset.expiresAt : 0;
135 useEffect(() => {
136 if (src !== null || cachedUrl === null || minting.current) return;
137 if (cachedExpiresAt - Date.now() > MIN_URL_LIFE_MS) {
138 // oxlint-disable-next-line react/set-state-in-effect -- Adopts the cached URL once it is known to last.
139 setSrc(cachedUrl);
140 return;
141 }
142 minting.current = true;
143 void refreshAsset().then(
144 (url) => (url === null ? setMintFailed(true) : setSrc(url)),
145 () => setMintFailed(true),
146 );
147 }, [src, cachedUrl, cachedExpiresAt, refreshAsset]);
148
149 // The wire timeline omits tool input and output; the app needs both.
150 const detail = useTurnItemDetail({
151 environmentId: props.environmentId,
152 threadId: props.threadId,
153 itemId: props.itemId,
154 revision: props.revision,
155 });
156 const storedItem = detail.data?.item;
157 const toolCall = useMemo(() => {
158 if (storedItem?.type !== "dynamic_tool") return undefined;
159 const output = storedItem.output as { readonly result?: unknown } | undefined;
160 const result = output?.result as McpAppCallToolResult | undefined;
161 return { arguments: storedItem.input, result };
162 }, [storedItem]);
163
164 const callTool = useAtomCommand(mcpAppEnvironment.callTool, { reportFailure: false });
165 const toolInfo = useAtomCommand(mcpAppEnvironment.toolInfo, { reportFailure: false });
166 const readResource = useAtomCommand(mcpAppEnvironment.readResource, { reportFailure: false });
167 const updateModelContext = useAtomCommand(mcpAppEnvironment.updateModelContext, {
168 reportFailure: false,
169 });
170 // The tool's definition, which the app receives as `toolInfo` at initialize.
171 const [toolDefinition, setToolDefinition] = useState<unknown>(undefined);
172
173 // Read by the host on every message, so it always sees current values
174 // without being rebuilt (which would drop the app's session).
175 const live = {
176 theme,
177 width,
178 props,
179 callTool,
180 toolInfo,
181 readResource,
182 updateModelContext,
183 displayMode,
184 toolDefinition,
185 };
186 const hostRef = useRef<McpAppHost | null>(null);
187 const latest = useRef(live);
188 useEffect(() => {
189 latest.current = live;
190 });
191
192 useEffect(() => {
193 const box = boxRef.current;
194 if (box === null || !fullscreenSupported) return;
195 const shown = box.matches(":popover-open");
196 if (displayMode === "fullscreen" && !shown) box.showPopover();
197 if (displayMode !== "fullscreen" && shown) box.hidePopover();
198 // Reported only on a change, so an inline row never touches another
199 // row's pin.
200 if (displayMode === "fullscreen") {
201 latest.current.props.onFullscreenChange?.(true);
202 return () => latest.current.props.onFullscreenChange?.(false);
203 }
204 }, [displayMode]);
205 // The approval or question the agent waits on renders in the page, so a
206 // full-screen app steps aside for it.
207 const [stepAsideFor, setStepAsideFor] = useState(props.awaitingUser);
208 if (stepAsideFor !== props.awaitingUser) {
209 setStepAsideFor(props.awaitingUser);
210 if (props.awaitingUser === true) setDisplayMode("inline");
211 }
212 // Full screen sits above everything else in the page. Anything that takes
213 // focus outside it (an approval, the command palette, a dialog) returns the
214 // app inline so it can be seen; so does Escape pressed outside the app.
215 // Keys pressed inside the app stay with the app.
216 useEffect(() => {
217 if (displayMode !== "fullscreen") return;
218 const leave = () => setDisplayMode("inline");
219 const onFocus = (event: FocusEvent) => {
220 if (event.target instanceof Node && !boxRef.current?.contains(event.target)) leave();
221 };
222 const onKey = (event: KeyboardEvent) => {
223 if (event.key === "Escape") leave();
224 };
225 const onResize = () => hostRef.current?.updateHostContext();
226 document.addEventListener("focusin", onFocus);
227 window.addEventListener("keydown", onKey);
228 window.addEventListener("resize", onResize);
229 return () => {
230 document.removeEventListener("focusin", onFocus);
231 window.removeEventListener("keydown", onKey);
232 window.removeEventListener("resize", onResize);
233 };
234 }, [displayMode]);
235
236 // The definition is read once per app, before the host is built, so the
237 // initialize response can carry it; a failure only leaves it out.
238 useEffect(() => {
239 let cancelled = false;
240 void latest.current
241 .toolInfo({
242 environmentId: props.environmentId,
243 input: { threadId: props.threadId, itemId: props.itemId, name: app.tool },
244 })
245 .then((info) => {
246 if (!cancelled && info._tag === "Success" && info.value.tool !== undefined) {
247 setToolDefinition(info.value.tool);
248 }
249 });
250 return () => {
251 cancelled = true;
252 };
253 }, [props.environmentId, props.threadId, props.itemId, app.tool]);
254 // The bridge belongs to the captured document. A frame that navigates keeps
255 // its window, so a second load stops the app rather than letting a page T3
256 // never served pose as it. This is not a confidentiality boundary: a frame
257 // can always navigate itself, so the app could carry anything it read out
258 // in a URL either way.
259 // Counted per document: a reopened app is a new document, not a navigation.
260 const loads = useRef({ generation: 0, count: 0 });
261 const onFrameLoad = () => {
262 if (loads.current.generation !== documentGeneration) {
263 loads.current = { generation: documentGeneration, count: 0 };
264 }
265 loads.current.count += 1;
266 if (loads.current.count > 1) {
267 hostRef.current?.dispose();
268 setNavigatedAway(true);
269 }
270 };
271
272 useEffect(() => {
273 if (src === null) return;
274 const hostContext = (): McpAppHostContext => {
275 const current = latest.current;
276 return {
277 theme: current.theme.appearance,
278 styles: { variables: mcpAppStyleVariables(current.theme.variables) },
279 displayMode: current.displayMode,
280 availableDisplayModes: fullscreenSupported ? ["inline", "fullscreen"] : ["inline"],
281 containerDimensions:
282 current.displayMode === "fullscreen"
283 ? {
284 width: frameRef.current?.clientWidth || window.innerWidth,
285 height: frameRef.current?.clientHeight || window.innerHeight,
286 }
287 : { width: current.width, maxHeight: MCP_APP_MAX_HEIGHT },
288 platform: isElectron ? "desktop" : "web",
289 locale: navigator.language,
290 timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
291 userAgent: `t3-code/${APP_VERSION}`,
292 deviceCapabilities: {
293 touch: window.matchMedia("(pointer: coarse)").matches,
294 hover: window.matchMedia("(hover: hover)").matches,
295 },
296 safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 },
297 ...(current.toolDefinition === undefined
298 ? {}
299 : { toolInfo: { tool: current.toolDefinition } }),
300 };
301 };
302 // Approvals render in the page, beneath the top layer a full-screen app
303 // occupies, so the app returns inline before T3 asks.
304 const ask = async (message: string) => {
305 flushSync(() => setDisplayMode("inline"));
306 const approved = await requestConfirmDialog(message);
307 // No dialog host is mounted, so nobody was asked; say so rather than
308 // reporting that the user declined.
309 if (approved === undefined) {
310 throw new McpAppHostRefusal("T3 could not ask for approval here.");
311 }
312 return approved;
313 };
314 const target = () => frameRef.current?.contentWindow ?? null;
315 const scope = () => {
316 const { environmentId, threadId, itemId } = latest.current.props;
317 return { environmentId, input: { threadId, itemId } };
318 };
319 const host = makeMcpAppHost({
320 app,
321 hostVersion: APP_VERSION,
322 // The document's origin is opaque, so "*" is the only target that reaches it;
323 // the window reference itself is what scopes delivery to this frame.
324 post: (message) => target()?.postMessage(message, "*"),
325 hostContext,
326 callTool: async ({ name, arguments: args }) => {
327 const { environmentId, input } = scope();
328 const info = await latest.current.toolInfo({ environmentId, input: { ...input, name } });
329 if (info._tag !== "Success") throw commandFailure(info);
330 if (!info.value.callable) throw new McpAppHostRefusal("This app cannot call that tool.");
331 if (!info.value.readOnly) {
332 const approved = await ask(
333 `Allow ${app.server} to run ${info.value.title ?? name}?\n${JSON.stringify(args, null, 2)}`,
334 );
335 if (approved !== true) throw new McpAppHostRefusal("Declined by the user.");
336 }
337 const result = await latest.current.callTool({
338 environmentId,
339 input: { ...input, name, arguments: args },
340 });
341 if (result._tag !== "Success") throw commandFailure(result);
342 return result.value;
343 },
344 readResource: async ({ uri }) => {
345 const { environmentId, input } = scope();
346 const result = await latest.current.readResource({
347 environmentId,
348 input: { ...input, uri },
349 });
350 if (result._tag !== "Success") throw commandFailure(result);
351 return result.value;
352 },
353 openLink: async (url) => {
354 // Only right after the reader used this frame, as with HTML renders.
355 if (document.activeElement !== frameRef.current || !navigator.userActivation?.isActive) {
356 throw new McpAppHostRefusal("Links open only from a click in the app.");
357 }
358 window.open(url, "_blank", "noopener,noreferrer");
359 },
360 sendMessage: async (text) => {
361 const send = latest.current.props.onSendMessage;
362 if (send === undefined) throw new McpAppHostRefusal("Messages are not available here.");
363 const approved = await ask(`Send this message from ${app.server}?\n${text}`);
364 if (approved !== true) throw new McpAppHostRefusal("Declined by the user.");
365 await send(text);
366 },
367 updateModelContext: async (context) => {
368 const { environmentId, input } = scope();
369 const result = await latest.current.updateModelContext({
370 environmentId,
371 input: {
372 ...input,
373 ...context,
374 conversationThreadId: latest.current.props.conversationThreadId,
375 },
376 });
377 if (result._tag !== "Success") throw commandFailure(result);
378 },
379 requestDisplayMode: async (mode) => {
380 // Full screen would cover the approval or question the agent waits
381 // on, or a confirmation any app is waiting on. Another app already
382 // full screen keeps the page; two would stack.
383 const otherFullscreen = document.querySelector("[data-mcp-app-fullscreen]");
384 if (
385 mode === "fullscreen" &&
386 (latest.current.props.awaitingUser === true ||
387 isConfirmDialogActive() ||
388 (otherFullscreen !== null && otherFullscreen !== boxRef.current))
389 ) {
390 return latest.current.displayMode;
391 }
392 setDisplayMode(mode);
393 return mode;
394 },
395 downloadFile: async (files) => {
396 const names = files.map((file) => file.name).join(", ");
397 const approved = await ask(`Save ${names} from ${app.server}?`);
398 if (approved !== true) throw new McpAppHostRefusal("Declined by the user.");
399 for (const file of files) {
400 // A linked file is read from the app's own server, like its other reads.
401 let bytes: Uint8Array | undefined;
402 let mimeType = file.mimeType ?? "application/octet-stream";
403 if (file._tag === "embedded") {
404 bytes = file.bytes;
405 } else {
406 const { environmentId, input } = scope();
407 const read = await latest.current.readResource({
408 environmentId,
409 input: { ...input, uri: file.uri },
410 });
411 if (read._tag !== "Success") throw commandFailure(read);
412 const content = read.value.contents[0];
413 bytes = mcpResourceBytes(content);
414 const declared = (content as { readonly mimeType?: unknown } | undefined)?.mimeType;
415 if (typeof declared === "string") mimeType = declared;
416 }
417 if (bytes === undefined) throw new McpAppHostRefusal(`${file.name} has no contents.`);
418 if (bytes.byteLength > MAX_DOWNLOAD_BYTES) {
419 throw new McpAppHostRefusal(`${file.name} is too large to save.`);
420 }
421 // A copy backed by a plain ArrayBuffer, which Blob requires.
422 saveBlob(new Blob([bytes.slice()], { type: mimeType }), file.name);
423 }
424 },
425 onRequestTeardown: () => {
426 if (latest.current.displayMode === "fullscreen") {
427 setDisplayMode("inline");
428 return;
429 }
430 // The app asked to go, so it gets its teardown before the frame does.
431 void host.teardown().then(() => setClosed(true));
432 },
433 onSizeChanged: (size) => {
434 // Full screen is a fixed box; what the app reports there would leave
435 // the inline row its full-screen height on the way back.
436 if (latest.current.displayMode === "fullscreen") return;
437 if (size.height !== undefined) setHeight(clampMcpAppHeight(size.height));
438 },
439 });
440 hostRef.current = host;
441 const receive = (event: MessageEvent) => {
442 // Only messages from this frame's own window reach its host.
443 if (event.source !== null && event.source === target()) host.receive(event.data);
444 };
445 window.addEventListener("message", receive);
446 return () => {
447 window.removeEventListener("message", receive);
448 // An unmount cannot wait: the request goes out before the frame does,
449 // which is all a list that recycles rows can offer the app.
450 void host.teardown();
451 hostRef.current = null;
452 };
453 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A reopened document needs a new host.
454 }, [src, app, documentGeneration]);
455
456 // The host reads the context through `latest`; these only say when to resend.
457 useEffect(() => {
458 hostRef.current?.updateHostContext();
459 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Context changes trigger a resend.
460 }, [theme, width, displayMode, toolDefinition]);
461
462 // A new document gets a new host, which needs the call again.
463 useEffect(() => {
464 if (toolCall !== undefined) hostRef.current?.setToolCall(toolCall);
465 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Each new document needs the call.
466 }, [toolCall, src, documentGeneration]);
467
468 // An app that asked to close keeps the box an inline row had, so the
469 // timeline does not jump, and says how to bring it back.
470 if (closed) {
471 return (
472 <div className="flex items-center justify-between gap-2 rounded-lg border border-border px-3 py-2 text-muted-foreground text-xs">
473 <span>The {app.server} app was closed</span>
474 <Button
475 size="xs"
476 variant="ghost"
477 onClick={() => {
478 // The first URL's token may have expired: the new document
479 // takes a fresh one, minted again if it is near expiry.
480 minting.current = false;
481 setSrc(null);
482 setDocumentGeneration((value) => value + 1);
483 setClosed(false);
484 }}
485 >
486 Show app
487 </Button>
488 </div>
489 );
490 }
491
492 const fullscreen = displayMode === "fullscreen";
493 return (
494 // The row keeps its inline height while the app is full screen, so the
495 // timeline does not jump; the box itself is what goes full screen.
496 <div style={{ height }}>
497 <div
498 ref={boxRef}
499 // Full screen is the same box shown in the browser's top layer: moving
500 // the frame to another parent would reload the app, and the timeline's
501 // rows contain fixed positioning, which the top layer escapes.
502 {...(fullscreenSupported ? { popover: "manual" as const } : {})}
503 data-mcp-app-fullscreen={fullscreen ? "" : undefined}
504 className={cn(
505 "relative size-full overflow-hidden",
506 app.prefersBorder === true && !fullscreen && "rounded-lg border border-border",
507 fullscreen
508 ? "fixed inset-0 m-0 flex h-dvh max-h-none w-dvw max-w-none flex-col border-0 bg-background p-0"
509 : // A closed popover is hidden; inline, the box is a plain block.
510 "block! static! m-0 border-0 bg-transparent p-0 text-inherit",
511 )}
512 >
513 {fullscreen ? (
514 <div className="flex h-10 shrink-0 items-center justify-between border-border border-b px-3 text-sm">
515 <span className="truncate">{app.server}</span>
516 <Button
517 aria-label="Exit full screen"
518 size="icon-sm"
519 variant="ghost"
520 onClick={() => setDisplayMode("inline")}
521 >
522 <Minimize2Icon className="size-4" />
523 </Button>
524 </div>
525 ) : null}
526 {navigatedAway ? (
527 <p className="flex size-full items-center justify-center text-muted-foreground text-xs">
528 The {app.server} app left its page and was stopped
529 </p>
530 ) : src !== null ? (
531 <iframe
532 ref={frameRef}
533 src={src}
534 title={`${app.server} app`}
535 // Never allow-same-origin: the opaque origin keeps the app out of the session.
536 sandbox="allow-scripts allow-forms"
537 allow={mcpAppAllowAttribute(app.permissions)}
538 onLoad={onFrameLoad}
539 className={cn("block w-full border-0", fullscreen ? "min-h-0 flex-1" : "h-full")}
540 style={{ colorScheme: theme.appearance }}
541 />
542 ) : asset._tag === "Failure" || mintFailed ? (
543 <p className="flex size-full items-center justify-center text-muted-foreground text-xs">
544 Unable to load the {app.server} app
545 </p>
546 ) : null}
547 </div>
548 </div>
549 );
550}
apps/web/src/components/chat/MessagesTimeline.logic.ts+37-1
......@@ -41,6 +41,8 @@ import {
4141 type OrchestrationV2ProjectedTurnItem,
4242 type RunAttemptId,
4343 RunId,
44 type ThreadId,
45 type TurnItemId,
4446} from "@t3tools/contracts";
4547import type { ThreadRunSummary } from "@t3tools/client-runtime/state/shell";
4648import {
......@@ -50,6 +52,7 @@ import {
5052} from "@t3tools/shared/t3McpToolPresentation";
5153import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput";
5254import { htmlRenderReferencesEqual, type HtmlRenderReference } from "@t3tools/shared/htmlRender";
55import { mcpAppReferencesEqual, type McpAppReference } from "@t3tools/shared/mcpApp";
5356import { dynamicToolTitle } from "@t3tools/shared/toolActivity";
5457import { formatWorkspaceRelativePath } from "../../filePathDisplay";
5558import {
......@@ -66,7 +69,7 @@ function timelineEntryRunId(entry: TimelineEntry): RunId | null {
6669 if (entry.kind === "proposed-plan") {
6770 return entry.proposedPlan.runId;
6871 }
69 if (entry.kind === "html-render") return entry.runId;
72 if (entry.kind === "html-render" || entry.kind === "mcp-app") return entry.runId;
7073 return entry.kind === "work" ? (entry.entry.runId ?? null) : null;
7174}
7275
......@@ -596,6 +599,15 @@ type MessagesTimelineRowContent =
596599 id: string;
597600 createdAt: string;
598601 htmlRender: HtmlRenderReference;
602 }
603 | {
604 kind: "mcp-app";
605 id: string;
606 createdAt: string;
607 sourceThreadId: ThreadId;
608 itemId: TurnItemId;
609 revision: string;
610 mcpApp: McpAppReference;
599611 };
600612
601613export interface StableMessagesTimelineRowsState {
......@@ -720,6 +732,7 @@ function deriveSupersededAttemptFolds(
720732 (entry.kind === "message" && entry.message.role === "user") ||
721733 // A published page stays visible, as it does when its turn folds.
722734 entry.kind === "html-render" ||
735 entry.kind === "mcp-app" ||
723736 timelineEntryIsPersistentResourceCard(entry) ||
724737 // A steer supersedes the attempt but leaves its children running.
725738 liveSubagentEntryIds.has(entry.id) ||
......@@ -1674,6 +1687,19 @@ export function deriveMessagesTimelineRows(input: {
16741687 continue;
16751688 }
16761689
1690 if (timelineEntry.kind === "mcp-app") {
1691 nextRows.push({
1692 kind: "mcp-app",
1693 id: timelineEntry.id,
1694 createdAt: timelineEntry.createdAt,
1695 sourceThreadId: timelineEntry.sourceThreadId,
1696 itemId: timelineEntry.itemId,
1697 revision: timelineEntry.revision,
1698 mcpApp: timelineEntry.mcpApp,
1699 });
1700 continue;
1701 }
1702
16771703 if (timelineEntry.kind === "event") {
16781704 const previous = nextRows.at(-1);
16791705 if (
......@@ -2090,6 +2116,16 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
20902116 return a.createdAt === bh.createdAt && htmlRenderReferencesEqual(a.htmlRender, bh.htmlRender);
20912117 }
20922118
2119 case "mcp-app": {
2120 // Same reason: an equal app keeps its live frame and its state.
2121 const bm = b as typeof a;
2122 return (
2123 a.createdAt === bm.createdAt &&
2124 a.revision === bm.revision &&
2125 mcpAppReferencesEqual(a.mcpApp, bm.mcpApp)
2126 );
2127 }
2128
20932129 case "event":
20942130 return (
20952131 a.projectedItem === (b as typeof a).projectedItem &&
apps/web/src/components/chat/MessagesTimeline.tsx+69-2
......@@ -169,6 +169,7 @@ import {
169169} from "./SnapShotAttachmentDetails";
170170import { ProposedPlanCard } from "./ProposedPlanCard";
171171import { HtmlRenderFrame } from "./HtmlRenderFrame";
172import { McpAppFrame } from "./McpAppFrame";
172173import { ChangedFilesCard } from "./ChangedFilesTree";
173174import { useFileContextMenuHandler } from "../../fileContextMenu";
174175import { useProject, useThreadShell } from "../../state/entities";
......@@ -312,6 +313,14 @@ interface TimelineRowSharedState {
312313 activeThreadEnvironmentId: EnvironmentId;
313314 onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void;
314315 onUseArtifactTemplate: (template: CodexArtifactTemplate) => void;
316 /** Sends text an MCP App asked to post, after the user approved it. */
317 onSendAppMessage: ((text: string) => Promise<void>) | undefined;
318 /**
319 * An MCP App row entering or leaving full screen. The row stays rendered
320 * and the list stops following new output meanwhile, so the app is not
321 * virtualized away while the reader is using it.
322 */
323 onAppFullscreenChange: (rowId: string, fullscreen: boolean) => void;
315324 onRunShellCommand: ((command: string) => void) | undefined;
316325 onImageExpand: (preview: ExpandedImagePreview) => void;
317326 displayThreadKey?: string;
......@@ -345,6 +354,8 @@ interface TimelineRowActivityState {
345354 isCompacting: boolean;
346355 isRevertingCheckpoint: boolean;
347356 activeTurnInProgress: boolean;
357 /** The agent waits on an approval or an answer from the user. */
358 awaitingUser: boolean;
348359 isPreparingWorktree: boolean;
349360 latestRunId: RunId | null;
350361 /**
......@@ -441,6 +452,8 @@ interface MessagesTimelineProps {
441452 onOpenWorktreeSetupTerminal?: (terminalId: string) => void;
442453 isPreparingWorktree?: boolean;
443454 isCompacting?: boolean;
455 /** The agent waits on an approval or an answer from the user. */
456 awaitingUser?: boolean;
444457 /** Thread state shown after the last message, such as a settled or snoozed line. */
445458 footer?: ReactNode;
446459
......@@ -468,6 +481,7 @@ interface MessagesTimelineProps {
468481 supportsConversationRollback: boolean;
469482 onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void;
470483 onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;
484 onSendAppMessage?: (text: string) => Promise<void>;
471485 onRunShellCommand?: (command: string) => void;
472486 isRevertingCheckpoint: boolean;
473487 onImageExpand: (preview: ExpandedImagePreview) => void;
......@@ -529,6 +543,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
529543 onOpenWorktreeSetupTerminal,
530544 isPreparingWorktree = false,
531545 isCompacting = false,
546 awaitingUser = false,
532547 listRef,
533548 timelineEntries,
534549 latestRun,
......@@ -544,6 +559,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
544559 supportsConversationRollback,
545560 onRevertToTurnCount,
546561 onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE,
562 onSendAppMessage,
547563 onRunShellCommand,
548564 isRevertingCheckpoint,
549565 onImageExpand,
......@@ -959,7 +975,26 @@ export const MessagesTimeline = memo(function MessagesTimeline({
959975 onManualNavigation,
960976 });
961977 const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false);
962 const alwaysRender = citationAlwaysRender ?? restoringAlwaysRender;
978 const [fullscreenAppRowId, setFullscreenAppRowId] = useState<string | null>(null);
979 // Only the row that holds the pin can release it.
980 const onAppFullscreenChange = useCallback((rowId: string, fullscreen: boolean) => {
981 setFullscreenAppRowId((current) => (fullscreen ? rowId : current === rowId ? null : current));
982 }, []);
983 // Every pin holds at once, so navigating to a citation or restoring a
984 // position never drops a full-screen app's row. The app is pinned by key,
985 // which stays right as earlier rows load in.
986 const alwaysRender = useMemo(() => {
987 const indices = restoringAlwaysRender?.indices ?? [];
988 const keys = [
989 ...(citationAlwaysRender?.keys ?? []),
990 ...(fullscreenAppRowId === null ? [] : [fullscreenAppRowId]),
991 ];
992 if (indices.length === 0 && keys.length === 0) return undefined;
993 return {
994 ...(indices.length === 0 ? {} : { indices }),
995 ...(keys.length === 0 ? {} : { keys }),
996 };
997 }, [citationAlwaysRender, restoringAlwaysRender, fullscreenAppRowId]);
963998 const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0);
964999 const [minimapCurrentIndex, setMinimapCurrentIndex] = useState<number | null>(null);
9651000 const handleAnchorReady = useCallback(
......@@ -1183,6 +1218,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
11831218 onImageExpand,
11841219 onFileOpen,
11851220 onUseArtifactTemplate,
1221 onSendAppMessage,
1222 onAppFullscreenChange,
11861223 onFileDownload,
11871224 openPullRequest,
11881225 onOpenTurnDiff,
......@@ -1218,6 +1255,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
12181255 onImageExpand,
12191256 onFileOpen,
12201257 onUseArtifactTemplate,
1258 onSendAppMessage,
1259 onAppFullscreenChange,
12211260 onFileDownload,
12221261 openPullRequest,
12231262 onOpenTurnDiff,
......@@ -1252,10 +1291,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({
12521291 isRevertingCheckpoint,
12531292 backgroundWorktreeSetup,
12541293 activeTurnInProgress,
1294 awaitingUser,
12551295 isPreparingWorktree,
12561296 latestRunId: latestRun?.runId ?? null,
12571297 }),
12581298 [
1299 awaitingUser,
12591300 compactionAwaitingRow,
12601301 backgroundWorktreeSetup,
12611302 activeTurnInProgress,
......@@ -1375,6 +1416,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
13751416 (restoringThreadPosition && rememberedPosition?.atEnd === false) ||
13761417 anchoredEndSpace ||
13771418 !liveFollowEnabled ||
1419 fullscreenAppRowId !== null ||
13781420 disclosureToggleSettling
13791421 ? false
13801422 : isWorking && !prefersReducedMotion && settlingListIdentity === null
......@@ -1798,7 +1840,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
17981840 row.kind === "worktree-setup" ||
17991841 row.kind === "event" ||
18001842 row.kind === "attempt-fold" ||
1801 row.kind === "html-render"
1843 row.kind === "html-render" ||
1844 row.kind === "mcp-app"
18021845 ? "pb-2"
18031846 : "pb-4",
18041847 (row.kind === "message" && row.message.role === "assistant") ||
......@@ -1847,6 +1890,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
18471890 {row.kind === "assistant-meta" ? <AssistantMetaTimelineRow row={row} /> : null}
18481891 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}
18491892 {row.kind === "html-render" ? <HtmlRenderTimelineRow row={row} /> : null}
1893 {row.kind === "mcp-app" ? <McpAppTimelineRow row={row} /> : null}
18501894 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
18511895 {row.kind === "worktree-setup" ? <WorktreeSetupTimelineRow row={row} /> : null}
18521896 {row.kind === "event" ? <V2EventTimelineRow row={row} /> : null}
......@@ -2736,6 +2780,29 @@ function HtmlRenderTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "htm
27362780 );
27372781}
27382782
2783function McpAppTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mcp-app" }> }) {
2784 const ctx = use(TimelineRowCtx);
2785 const { awaitingUser } = use(TimelineRowActivityCtx);
2786
2787 return (
2788 <div className="min-w-0 px-1">
2789 <McpAppFrame
2790 // A recycled row must not keep another app's live document.
2791 key={row.mcpApp.attachmentId}
2792 environmentId={ctx.activeThreadEnvironmentId}
2793 threadId={row.sourceThreadId}
2794 conversationThreadId={ctx.threadRef?.threadId ?? row.sourceThreadId}
2795 itemId={row.itemId}
2796 revision={row.revision}
2797 app={row.mcpApp}
2798 onSendMessage={ctx.onSendAppMessage}
2799 awaitingUser={awaitingUser}
2800 onFullscreenChange={(fullscreen) => ctx.onAppFullscreenChange(row.id, fullscreen)}
2801 />
2802 </div>
2803 );
2804}
2805
27392806type V2EventTone = "muted" | "warning" | "danger" | "success";
27402807
27412808function v2EventPresentation(item: OrchestrationV2TurnItem): {
apps/web/src/confirmDialog.test.ts+4
......@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vite-plus/test";
22
33import {
44 completeConfirmDialogClose,
5 isConfirmDialogActive,
56 readConfirmDialogState,
67 registerConfirmDialogHost,
78 requestConfirmDialog,
......@@ -40,6 +41,8 @@ describe("confirm dialog coordinator", () => {
4041
4142 respondToConfirmDialog(true);
4243 await expect(confirmation).resolves.toBe(true);
44 // Still on screen while it closes, so nothing may cover it yet.
45 expect(isConfirmDialogActive()).toBe(true);
4346 expect(readConfirmDialogState()).toEqual({
4447 status: "closing",
4548 message: "Delete this thread?",
......@@ -48,6 +51,7 @@ describe("confirm dialog coordinator", () => {
4851
4952 completeConfirmDialogClose();
5053 expect(readConfirmDialogState()).toEqual({ status: "idle" });
54 expect(isConfirmDialogActive()).toBe(false);
5155 unregister();
5256 });
5357
apps/web/src/confirmDialog.ts+5
......@@ -101,6 +101,11 @@ export function requestConfirmDialog(
101101 return confirmation;
102102}
103103
104/** True while a confirmation is shown, closing, or waiting to be shown. */
105export function isConfirmDialogActive(): boolean {
106 return state.status !== "idle" || activeConfirmation !== null || queuedConfirmations.length > 0;
107}
108
104109export function respondToConfirmDialog(confirmed: boolean): void {
105110 if (state.status !== "confirming" || !activeConfirmation) return;
106111
apps/web/src/session-logic.test.ts+19
......@@ -1319,6 +1319,25 @@ describe("HTML renders in the timeline", () => {
13191319 ]);
13201320 });
13211321
1322 it("hosts a captured MCP app in place, from any provider's tool", () => {
1323 const mcpApp = {
1324 attachmentId: "render-thread-app-html",
1325 server: "weather",
1326 tool: "get_weather",
1327 resourceUri: "ui://weather/dashboard",
1328 };
1329 const call = renderCall("completed", { t3McpApp: mcpApp, result: { content: [] } });
1330 const entries = turn(
1331 call.type === "dynamic_tool" ? { ...call, toolName: "weather.get_weather" } : call,
1332 );
1333 expect(entries.find((entry) => entry.kind === "mcp-app")).toMatchObject({
1334 id: "render",
1335 itemId: "render",
1336 mcpApp,
1337 });
1338 expect(rowsFor(entries, false)).toEqual(["message", "turn-fold", "mcp-app", "message"]);
1339 });
1340
13221341 it("keeps a render visible when its superseded attempt folds", () => {
13231342 const attempt: OrchestrationV2RunAttempt = {
13241343 id: RunAttemptId.make("attempt-superseded"),
apps/web/src/session-logic.ts+36-1
......@@ -12,6 +12,8 @@ import {
1212 type ToolActivitySurface,
1313 type ToolActivityIcon,
1414 type ToolActivitySource,
15 type ThreadId,
16 type TurnItemId,
1517} from "@t3tools/contracts";
1618import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
1719import {
......@@ -21,7 +23,9 @@ import {
2123 formatSearchToolLabel,
2224} from "@t3tools/shared/toolActivity";
2325import type { HtmlRenderReference } from "@t3tools/shared/htmlRender";
24import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput";
26import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail";
27import type { McpAppReference } from "@t3tools/shared/mcpApp";
28import { htmlRenderFromToolItem, mcpAppFromToolItem } from "@t3tools/shared/toolOutput";
2529import {
2630 contextCompactionLabel,
2731 workEntryIndicatesToolFailure,
......@@ -140,6 +144,18 @@ export type TimelineEntry = (
140144 readonly runId: RunId | null;
141145 readonly htmlRender: HtmlRenderReference;
142146 }
147 | {
148 /** An MCP App a completed tool call captured, hosted where the call happened. */
149 readonly id: string;
150 readonly kind: "mcp-app";
151 readonly createdAt: string;
152 readonly runId: RunId | null;
153 /** The thread and item that own the app; a fork's inherited app is its source's. */
154 readonly sourceThreadId: ThreadId;
155 readonly itemId: TurnItemId;
156 readonly revision: string;
157 readonly mcpApp: McpAppReference;
158 }
143159 | {
144160 readonly id: string;
145161 readonly kind: "work";
......@@ -714,6 +730,25 @@ export function deriveTimelineEntriesFromVisibleTurnItems(
714730 continue;
715731 }
716732
733 const mcpApp =
734 item.type === "dynamic_tool" && item.status === "completed"
735 ? mcpAppFromToolItem(item)
736 : undefined;
737 if (mcpApp !== undefined) {
738 entries.push({
739 id: item.id,
740 kind: "mcp-app",
741 createdAt,
742 runId: item.runId,
743 sourceThreadId: row.sourceThreadId,
744 itemId: row.sourceItemId,
745 revision: turnItemDetailRevision(item),
746 mcpApp,
747 ...attemptMetadata,
748 });
749 continue;
750 }
751
717752 if (STANDALONE_V2_ITEM_TYPES.has(item.type)) {
718753 entries.push({
719754 id: item.id,
apps/web/src/state/mcpApps.ts created+5
......@@ -0,0 +1,5 @@
1import { createMcpAppEnvironmentAtoms } from "@t3tools/client-runtime/state/mcp-apps";
2
3import { connectionAtomRuntime } from "../connection/runtime";
4
5export const mcpAppEnvironment = createMcpAppEnvironmentAtoms(connectionAtomRuntime);
docs/user/html-renders.md+8
......@@ -7,3 +7,11 @@ Pages use your current theme, including custom themes, and follow light and dark
77Agents can place local images in a page by file path. T3 Code embeds them when the page is published, so the page keeps working after the original files move or are deleted. Deleting the thread deletes its pages.
88
99Before publishing, agents check their work with screenshots from a small headless browser that T3 Code keeps for itself; it never uses a browser you installed. The first preview on a machine downloads it once (about 120 MB) into T3 Code's data folder, so that preview can take a minute. Browser tabs on a remote environment use the same browser, so whichever comes first downloads it. Some Linux hosts need [setup](remote-access.md#browser-host-setup) before it can start. Pages publish without it.
10
11## MCP apps
12
13Some MCP servers return an interactive app with their tool results, following the [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) standard. When an agent calls one of those tools, the app appears in the thread in place of the tool call, on web, desktop, and mobile. Codex supports this today, for any MCP server you have configured for it; other providers show these calls as ordinary tool calls.
14
15Apps follow your theme. An app can call tools on its own server and post a message to the thread; T3 Code asks first unless the server marks the tool as read-only, and it asks before every message. On mobile, an app also asks before opening a link, and T3 Code asks before saving a file an app offers. An app stays viewable after its agent stops, but using it needs the agent of the thread that created it running, so send a message in that thread first if the app says it is unavailable. An app that navigates away from its own page is stopped.
16
17An app can open full screen; use the button in its top corner, or press Escape outside the app, to return it to the thread. Anything else that needs your attention, such as an approval, also returns it to the thread. On mobile, full screen opens the app again in its own screen, so anything the app does not save itself starts over. An app can also keep the agent informed of what you did in it, such as a filter you picked; T3 Code sends the latest note from each app with your next message.
packages/client-runtime/package.json+8
......@@ -390,6 +390,14 @@
390390 "./device/duo-control": {
391391 "types": "./src/device/duoControl.ts",
392392 "default": "./src/device/duoControl.ts"
393 },
394 "./mcp-apps": {
395 "types": "./src/mcpApps/host.ts",
396 "default": "./src/mcpApps/host.ts"
397 },
398 "./state/mcp-apps": {
399 "types": "./src/state/mcpApps.ts",
400 "default": "./src/state/mcpApps.ts"
393401 }
394402 },
395403 "scripts": {
packages/client-runtime/src/mcpApps/host.test.ts created+419
......@@ -0,0 +1,419 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2
3import { makeMcpAppHost, McpAppHostRefusal, type McpAppHostContext } from "./host.ts";
4
5const app = {
6 attachmentId: "thread-1-app",
7 server: "weather",
8 tool: "get_weather",
9 resourceUri: "ui://weather/dashboard",
10 csp: { connectDomains: ["https://api.weather.test"] },
11};
12
13const context = (
14 theme: "light" | "dark" = "dark",
15 displayMode: "inline" | "fullscreen" = "inline",
16): McpAppHostContext => ({
17 theme,
18 styles: { variables: { "--color-background-primary": theme === "dark" ? "#000" : "#fff" } },
19 displayMode,
20 availableDisplayModes: ["inline", "fullscreen"],
21 containerDimensions: { width: 600, maxHeight: 2000 },
22 platform: "web",
23});
24
25// Lets the host settle the promises its callbacks return.
26const flush = () => new Promise<void>((resolve) => queueMicrotask(resolve)).then(() => undefined);
27
28const initialize = (
29 host: ReturnType<typeof makeMcpAppHost>,
30 appCapabilities: Record<string, unknown> = {},
31) => {
32 host.receive({
33 jsonrpc: "2.0",
34 id: "init",
35 method: "ui/initialize",
36 params: { appCapabilities },
37 });
38 host.receive({ jsonrpc: "2.0", method: "ui/notifications/initialized" });
39};
40
41function setup(overrides: Partial<Parameters<typeof makeMcpAppHost>[0]> = {}) {
42 const sent: Array<Record<string, unknown>> = [];
43 let theme: "light" | "dark" = "dark";
44 let mode: "inline" | "fullscreen" = "inline";
45 const host = makeMcpAppHost({
46 app,
47 hostVersion: "1.0.0",
48 post: (message) => sent.push(message as Record<string, unknown>),
49 hostContext: () => context(theme, mode),
50 callTool: async (input) => ({ content: [{ type: "text", text: `called ${input.name}` }] }),
51 readResource: async () => ({ contents: [] }),
52 openLink: async () => undefined,
53 sendMessage: async () => undefined,
54 updateModelContext: async () => undefined,
55 requestDisplayMode: async (next) => {
56 mode = next === "fullscreen" ? "fullscreen" : "inline";
57 return mode;
58 },
59 downloadFile: async () => undefined,
60 onRequestTeardown: () => undefined,
61 onSizeChanged: () => undefined,
62 ...overrides,
63 });
64 return {
65 host,
66 sent,
67 setTheme: (next: "light" | "dark") => {
68 theme = next;
69 },
70 };
71}
72
73describe("makeMcpAppHost", () => {
74 it("initializes, then replays the tool input and result after initialized", () => {
75 const { host, sent } = setup();
76 host.setToolCall({
77 arguments: { city: "Oslo" },
78 result: { content: [{ type: "text", text: "Sunny" }] },
79 });
80 host.receive({
81 jsonrpc: "2.0",
82 id: 1,
83 method: "ui/initialize",
84 params: { protocolVersion: "2026-01-26", appInfo: { name: "a", version: "1" } },
85 });
86 expect(sent[0]).toMatchObject({
87 id: 1,
88 result: {
89 protocolVersion: "2026-01-26",
90 hostCapabilities: { serverTools: {}, sandbox: { csp: app.csp } },
91 hostContext: { theme: "dark", displayMode: "inline" },
92 },
93 });
94 expect(sent).toHaveLength(1);
95
96 host.receive({ jsonrpc: "2.0", method: "ui/notifications/initialized", params: {} });
97 expect(sent.slice(1)).toEqual([
98 {
99 jsonrpc: "2.0",
100 method: "ui/notifications/tool-input",
101 params: { arguments: { city: "Oslo" } },
102 },
103 {
104 jsonrpc: "2.0",
105 method: "ui/notifications/tool-result",
106 params: { content: [{ type: "text", text: "Sunny" }] },
107 },
108 ]);
109 });
110
111 it("replays a tool call that arrives after initialization, once", () => {
112 const { host, sent } = setup();
113 host.receive({ jsonrpc: "2.0", id: 1, method: "ui/initialize", params: {} });
114 host.receive({ jsonrpc: "2.0", method: "ui/notifications/initialized" });
115 sent.length = 0;
116 host.setToolCall({ arguments: { city: "Oslo" }, result: undefined });
117 host.setToolCall({ arguments: { city: "Bergen" }, result: undefined });
118 expect(sent).toEqual([
119 {
120 jsonrpc: "2.0",
121 method: "ui/notifications/tool-input",
122 params: { arguments: { city: "Oslo" } },
123 },
124 ]);
125 });
126
127 it("drops null fields providers report, which the MCP Apps SDK rejects", async () => {
128 const { host, sent } = setup({
129 callTool: async () => ({ content: [], structuredContent: null, _meta: null }),
130 });
131 host.receive({ jsonrpc: "2.0", id: 1, method: "ui/initialize", params: {} });
132 host.receive({ jsonrpc: "2.0", method: "ui/notifications/initialized" });
133 host.setToolCall({
134 arguments: {},
135 result: {
136 content: [{ type: "text", text: "t" }],
137 structuredContent: { time: "t" },
138 _meta: null,
139 },
140 });
141 host.receive({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "get-time" } });
142 await flush();
143 expect(sent.find((m) => m.method === "ui/notifications/tool-result")?.params).toEqual({
144 content: [{ type: "text", text: "t" }],
145 structuredContent: { time: "t" },
146 });
147 expect(sent.find((m) => m.id === 2)?.result).toEqual({ content: [] });
148 });
149
150 it("accepts ui/message as one text block or the SDK's block array", async () => {
151 const messages: Array<string> = [];
152 const { host, sent } = setup({ sendMessage: async (text) => void messages.push(text) });
153 host.receive({
154 jsonrpc: "2.0",
155 id: 1,
156 method: "ui/message",
157 params: { role: "user", content: { type: "text", text: "one" } },
158 });
159 host.receive({
160 jsonrpc: "2.0",
161 id: 2,
162 method: "ui/message",
163 params: { role: "user", content: [{ type: "text", text: "two" }] },
164 });
165 host.receive({
166 jsonrpc: "2.0",
167 id: 3,
168 method: "ui/message",
169 params: { role: "user", content: [{ type: "image", data: "x" }] },
170 });
171 host.receive({
172 jsonrpc: "2.0",
173 id: 4,
174 method: "ui/message",
175 params: { role: "user", content: { type: "text", text: " " } },
176 });
177 await flush();
178 expect(messages).toEqual(["one", "two"]);
179 expect(sent.find((m) => m.id === 3)?.error).toMatchObject({ code: -32602 });
180 // Blank text would ask to send an empty message.
181 expect(sent.find((m) => m.id === 4)?.error).toMatchObject({ code: -32602 });
182 });
183
184 it("sends only changed context fields, and nothing before initialization", () => {
185 const { host, sent, setTheme } = setup();
186 setTheme("light");
187 host.updateHostContext();
188 expect(sent).toHaveLength(0);
189
190 host.receive({ jsonrpc: "2.0", id: 1, method: "ui/initialize", params: {} });
191 host.receive({ jsonrpc: "2.0", method: "ui/notifications/initialized" });
192 sent.length = 0;
193 setTheme("dark");
194 host.updateHostContext();
195 expect(sent).toEqual([
196 {
197 jsonrpc: "2.0",
198 method: "ui/notifications/host-context-changed",
199 params: { theme: "dark", styles: context("dark").styles },
200 },
201 ]);
202 host.updateHostContext();
203 expect(sent).toHaveLength(1);
204 });
205
206 it("catches the app up on context that changed while it was initializing", () => {
207 const { host, sent, setTheme } = setup();
208 host.receive({ jsonrpc: "2.0", id: 1, method: "ui/initialize", params: {} });
209 setTheme("light");
210 host.receive({ jsonrpc: "2.0", method: "ui/notifications/initialized" });
211 expect(sent.at(-1)).toEqual({
212 jsonrpc: "2.0",
213 method: "ui/notifications/host-context-changed",
214 params: { theme: "light", styles: context("light").styles },
215 });
216 });
217
218 it("proxies tools/call and reports a refusal as a JSON-RPC error", async () => {
219 const { host, sent } = setup({
220 callTool: async (input) => {
221 if (input.name === "delete_everything")
222 throw new McpAppHostRefusal("Declined by the user.");
223 return { content: [], structuredContent: { ok: true } };
224 },
225 });
226 host.receive({ jsonrpc: "2.0", id: 7, method: "tools/call", params: { name: "refresh" } });
227 host.receive({
228 jsonrpc: "2.0",
229 id: 8,
230 method: "tools/call",
231 params: { name: "delete_everything", arguments: {} },
232 });
233 await flush();
234 expect(sent).toContainEqual({
235 jsonrpc: "2.0",
236 id: 7,
237 result: { content: [], structuredContent: { ok: true } },
238 });
239 expect(sent).toContainEqual({
240 jsonrpc: "2.0",
241 id: 8,
242 error: { code: -32000, message: "Declined by the user." },
243 });
244 });
245
246 it("rejects malformed requests and unsupported methods", () => {
247 const { host, sent } = setup();
248 host.receive({
249 jsonrpc: "2.0",
250 id: 1,
251 method: "ui/open-link",
252 params: { url: "javascript:x" },
253 });
254 host.receive({ jsonrpc: "2.0", id: 3, method: "sampling/createMessage", params: {} });
255 host.receive({ jsonrpc: "2.0", id: 4, method: "ui/message", params: { role: "assistant" } });
256 expect(sent.map((message) => (message.error as { code: number }).code)).toEqual([
257 -32602, -32601, -32602,
258 ]);
259 });
260
261 it("refuses requests past the in-flight limit and posts nothing after dispose", async () => {
262 let release: () => void = () => undefined;
263 const gate = new Promise<void>((resolve) => {
264 release = resolve;
265 });
266 const { host, sent } = setup({
267 callTool: async () => {
268 await gate;
269 return { content: [] };
270 },
271 });
272 for (let id = 0; id < 17; id++) {
273 host.receive({ jsonrpc: "2.0", id, method: "tools/call", params: { name: "slow" } });
274 }
275 expect(sent).toEqual([
276 { jsonrpc: "2.0", id: 16, error: { code: -32000, message: "Too many requests in flight." } },
277 ]);
278 host.dispose();
279 release();
280 await flush();
281 expect(sent).toHaveLength(1);
282 });
283
284 it("switches display mode only to one both sides offer, and tells the app", async () => {
285 const { host, sent } = setup();
286 initialize(host, { availableDisplayModes: ["inline", "fullscreen"] });
287 sent.length = 0;
288 host.receive({
289 jsonrpc: "2.0",
290 id: 1,
291 method: "ui/request-display-mode",
292 params: { mode: "pip" },
293 });
294 host.receive({
295 jsonrpc: "2.0",
296 id: 2,
297 method: "ui/request-display-mode",
298 params: { mode: "fullscreen" },
299 });
300 await flush();
301 host.updateHostContext();
302 expect(sent.find((m) => m.id === 1)?.result).toEqual({ mode: "inline" });
303 expect(sent.find((m) => m.id === 2)?.result).toEqual({ mode: "fullscreen" });
304 expect(sent.find((m) => m.method === "ui/notifications/host-context-changed")?.params).toEqual({
305 displayMode: "fullscreen",
306 });
307
308 // An app that declared only inline stays inline.
309 const declined = setup();
310 initialize(declined.host, { availableDisplayModes: ["inline"] });
311 declined.host.receive({
312 jsonrpc: "2.0",
313 id: 3,
314 method: "ui/request-display-mode",
315 params: { mode: "fullscreen" },
316 });
317 await flush();
318 expect(declined.sent.find((m) => m.id === 3)?.result).toEqual({ mode: "inline" });
319 });
320
321 it("forwards model context and download requests, and declares both", async () => {
322 const contexts: Array<unknown> = [];
323 const downloads: Array<unknown> = [];
324 const { host, sent } = setup({
325 updateModelContext: async (update) => void contexts.push(update),
326 downloadFile: async (files) => {
327 downloads.push(files);
328 if (files.length > 1) throw new McpAppHostRefusal("Declined by the user.");
329 },
330 });
331 initialize(host);
332 const capabilities = (
333 sent[0]?.result as { hostCapabilities: Record<string, unknown> } | undefined
334 )?.hostCapabilities;
335 expect(capabilities?.updateModelContext).toEqual({ text: {}, structuredContent: {} });
336 expect(capabilities?.downloadFile).toEqual({});
337
338 host.receive({
339 jsonrpc: "2.0",
340 id: 1,
341 method: "ui/update-model-context",
342 params: { content: [{ type: "text", text: "2 overdue" }], structuredContent: { overdue: 2 } },
343 });
344 const embedded = {
345 type: "resource",
346 resource: { uri: "file:///report.csv", mimeType: "text/csv", text: "a,b" },
347 };
348 host.receive({
349 jsonrpc: "2.0",
350 id: 2,
351 method: "ui/download-file",
352 params: { contents: [embedded] },
353 });
354 host.receive({
355 jsonrpc: "2.0",
356 id: 3,
357 method: "ui/download-file",
358 params: {
359 contents: [embedded, { type: "resource_link", uri: "ui://todos/export", name: "x" }],
360 },
361 });
362 host.receive({ jsonrpc: "2.0", id: 4, method: "ui/download-file", params: { contents: [{}] } });
363 await flush();
364 expect(contexts).toEqual([
365 { content: [{ type: "text", text: "2 overdue" }], structuredContent: { overdue: 2 } },
366 ]);
367 expect(downloads[0]).toEqual([
368 {
369 _tag: "embedded",
370 name: "report.csv",
371 mimeType: "text/csv",
372 bytes: new TextEncoder().encode("a,b"),
373 },
374 ]);
375 expect(sent.find((m) => m.id === 2)?.result).toEqual({});
376 // A refused download is reported in the result, as the draft defines.
377 expect(sent.find((m) => m.id === 3)?.result).toEqual({ isError: true });
378 expect(sent.find((m) => m.id === 4)?.error).toMatchObject({ code: -32602 });
379 });
380
381 it("tears down after the app answers, or after the timeout, and passes on its own request", async () => {
382 vi.useFakeTimers();
383 try {
384 let closeRequested = false;
385 const { host, sent } = setup({ onRequestTeardown: () => (closeRequested = true) });
386 host.receive({ jsonrpc: "2.0", method: "ui/notifications/request-teardown" });
387 expect(closeRequested).toBe(true);
388
389 // Before initialization there is nothing to ask.
390 await host.teardown();
391 expect(sent.some((m) => m.method === "ui/resource-teardown")).toBe(false);
392
393 const answered = setup();
394 initialize(answered.host);
395 let done = false;
396 void answered.host.teardown().then(() => (done = true));
397 const request = answered.sent.find((m) => m.method === "ui/resource-teardown");
398 expect(request).toBeDefined();
399 answered.host.receive({ jsonrpc: "2.0", id: request?.id, result: {} });
400 await vi.runAllTimersAsync();
401 expect(done).toBe(true);
402
403 const silent = setup();
404 initialize(silent.host);
405 let timedOut = false;
406 void silent.host.teardown().then(() => (timedOut = true));
407 await vi.advanceTimersByTimeAsync(1999);
408 expect(timedOut).toBe(false);
409 await vi.advanceTimersByTimeAsync(1);
410 expect(timedOut).toBe(true);
411 // A torn-down host posts nothing more.
412 const before = silent.sent.length;
413 silent.host.updateHostContext();
414 expect(silent.sent).toHaveLength(before);
415 } finally {
416 vi.useRealTimers();
417 }
418 });
419});
packages/client-runtime/src/mcpApps/host.ts created+576
......@@ -0,0 +1,576 @@
1// @effect-diagnostics globalTimers:off - The bridge runs in the client, outside an Effect runtime.
2import { MCP_APP_PROTOCOL_VERSION, type McpAppReference } from "@t3tools/shared/mcpApp";
3import * as Base64 from "effect/encoding/Base64";
4import * as Predicate from "effect/Predicate";
5import * as Result from "effect/Result";
6
7/**
8 * The host side of the MCP Apps bridge (spec 2026-01-26, plus the draft
9 * additions the current SDK sends: `ui/download-file` and
10 * `ui/notifications/request-teardown`): JSON-RPC 2.0 over postMessage between a
11 * client and one app document. It is transport-free so web (an iframe) and
12 * mobile (a WebView) share every protocol rule; each platform supplies how to
13 * post a message and how to reach the environment.
14 */
15
16const MAX_MESSAGE_BYTES = 256 * 1024;
17/** Requests an app may have in flight at once; more are refused, not queued. */
18const MAX_PENDING_REQUESTS = 16;
19/** How long the host waits for an app to answer `ui/resource-teardown`. */
20const TEARDOWN_TIMEOUT_MS = 2000;
21const encoder = new TextEncoder();
22
23type JsonRpcId = string | number;
24
25export type McpAppDisplayMode = "inline" | "fullscreen" | "pip";
26
27export interface McpAppHostContext {
28 readonly theme: "light" | "dark";
29 readonly styles: { readonly variables: Readonly<Record<string, string>> };
30 readonly displayMode: McpAppDisplayMode;
31 readonly availableDisplayModes: ReadonlyArray<McpAppDisplayMode>;
32 /**
33 * `maxHeight` when the host sizes the frame to the app's reported height,
34 * `height` when the frame is a fixed box the app must fit (spec
35 * "Container Dimensions").
36 */
37 readonly containerDimensions:
38 | { readonly width: number; readonly maxHeight: number }
39 | { readonly width: number; readonly height: number };
40 readonly platform: "web" | "desktop" | "mobile";
41 readonly locale?: string;
42 readonly timeZone?: string;
43 readonly userAgent?: string;
44 readonly deviceCapabilities?: { readonly touch?: boolean; readonly hover?: boolean };
45 readonly safeAreaInsets?: {
46 readonly top: number;
47 readonly right: number;
48 readonly bottom: number;
49 readonly left: number;
50 };
51 /** The tool call that created the app: the server's MCP `Tool` definition. */
52 readonly toolInfo?: { readonly tool: unknown };
53}
54
55/** A file an app asked the host to save (`ui/download-file`): embedded, or linked on its server. */
56export type McpAppDownload =
57 | {
58 readonly _tag: "embedded";
59 readonly name: string;
60 readonly mimeType: string;
61 readonly bytes: Uint8Array;
62 }
63 | {
64 readonly _tag: "link";
65 readonly name: string;
66 readonly uri: string;
67 readonly mimeType?: string;
68 };
69
70/**
71 * The bytes of an MCP resource's contents: `text` as UTF-8, `blob` decoded
72 * from base64. Undefined when it has neither or the base64 is malformed.
73 */
74export function mcpResourceBytes(content: unknown): Uint8Array | undefined {
75 if (!Predicate.isObject(content)) return undefined;
76 if (typeof content.text === "string") return encoder.encode(content.text);
77 if (typeof content.blob === "string") {
78 const decoded = Base64.decode(content.blob);
79 return Result.isSuccess(decoded) ? decoded.success : undefined;
80 }
81 return undefined;
82}
83
84export interface McpAppCallToolResult {
85 readonly content: ReadonlyArray<unknown>;
86 readonly structuredContent?: unknown;
87 readonly isError?: boolean | undefined;
88 readonly _meta?: unknown;
89}
90
91/** Thrown by host callbacks to refuse a request with a message the app can show. */
92export class McpAppHostRefusal extends Error {}
93
94export interface McpAppHostOptions {
95 readonly app: McpAppReference;
96 readonly hostVersion: string;
97 /** Posts one JSON-RPC message to the app document. */
98 readonly post: (message: unknown) => void;
99 readonly hostContext: () => McpAppHostContext;
100
101 readonly callTool: (input: {
102 readonly name: string;
103 readonly arguments: Record<string, unknown>;
104 }) => Promise<McpAppCallToolResult>;
105 readonly readResource: (input: { readonly uri: string }) => Promise<unknown>;
106 readonly openLink: (url: string) => Promise<void>;
107 readonly sendMessage: (text: string) => Promise<void>;
108 /** Replaces what this app tells the agent on its next turn; undefined content clears it. */
109 readonly updateModelContext: (context: {
110 readonly content?: ReadonlyArray<unknown>;
111 readonly structuredContent?: Record<string, unknown>;
112 }) => Promise<void>;
113 /**
114 * Switches the app to a mode the host and the app both offer, returning the
115 * resulting mode. The host has already checked both lists.
116 */
117 readonly requestDisplayMode: (mode: McpAppDisplayMode) => Promise<McpAppDisplayMode>;
118 readonly downloadFile: (files: ReadonlyArray<McpAppDownload>) => Promise<void>;
119 /** The app asked to be closed (`ui/notifications/request-teardown`). */
120 readonly onRequestTeardown: () => void;
121 readonly onSizeChanged: (size: { readonly width?: number; readonly height?: number }) => void;
122}
123
124export interface McpAppHost {
125 /** Handles one message the app posted; the caller has already checked its source. */
126 readonly receive: (data: unknown) => void;
127 /**
128 * Supplies the original tool call's arguments and result. They reach the
129 * app once it has initialized, whichever happens last, and only once.
130 */
131 readonly setToolCall: (call: {
132 readonly arguments: unknown;
133 readonly result: McpAppCallToolResult | undefined;
134 }) => void;
135 /** Sends `host-context-changed` with the fields that differ from what the app last saw. */
136 readonly updateHostContext: () => void;
137 /**
138 * Asks the app to wrap up before its document goes away (`ui/resource-teardown`),
139 * resolving once it answers or after a short timeout, then disposes the host.
140 */
141 readonly teardown: () => Promise<void>;
142 readonly dispose: () => void;
143}
144
145function jsonBytes(value: unknown): number {
146 try {
147 return encoder.encode(JSON.stringify(value) ?? "").byteLength;
148 } catch {
149 return Number.POSITIVE_INFINITY;
150 }
151}
152
153function isId(value: unknown): value is JsonRpcId {
154 return typeof value === "string" || (typeof value === "number" && Number.isFinite(value));
155}
156
157/**
158 * A `CallToolResult` as the spec's schema accepts it. Providers can report
159 * absent fields as null (Codex sends `_meta: null`), which the MCP Apps SDK
160 * rejects, dropping the whole notification or response.
161 */
162function normalizeMcpAppToolResult(result: McpAppCallToolResult): McpAppCallToolResult {
163 return {
164 content: Array.isArray(result.content) ? result.content : [],
165 ...(Predicate.isObject(result.structuredContent)
166 ? { structuredContent: result.structuredContent }
167 : {}),
168 ...(result.isError === true ? { isError: true } : {}),
169 ...(Predicate.isObject(result._meta) ? { _meta: result._meta } : {}),
170 };
171}
172
173const errorMessage = (error: unknown) =>
174 error instanceof McpAppHostRefusal
175 ? error.message
176 : error instanceof Error && error.message.trim() !== ""
177 ? error.message
178 : "Request failed.";
179
180export function makeMcpAppHost(options: McpAppHostOptions): McpAppHost {
181 let initialized = false;
182 let disposed = false;
183 let toolCall:
184 | { readonly arguments: unknown; readonly result: McpAppCallToolResult | undefined }
185 | undefined;
186 let toolCallSent = false;
187 let pending = 0;
188 let sentContext: McpAppHostContext | undefined;
189 /** Modes the app declared at initialize; undefined when it declared none. */
190 let appDisplayModes: ReadonlyArray<string> | undefined;
191 let nextHostRequestId = 0;
192 const hostRequests = new Map<string, () => void>();
193
194 const post = (message: unknown) => {
195 if (!disposed) options.post(message);
196 };
197 const notify = (method: string, params: unknown) => post({ jsonrpc: "2.0", method, params });
198 const respond = (id: JsonRpcId, result: unknown) => post({ jsonrpc: "2.0", id, result });
199 const fail = (id: JsonRpcId, code: number, message: string) =>
200 post({ jsonrpc: "2.0", id, error: { code, message } });
201
202 const sendToolCall = () => {
203 if (!initialized || toolCall === undefined || toolCallSent) return;
204 toolCallSent = true;
205 notify("ui/notifications/tool-input", {
206 arguments: Predicate.isObject(toolCall.arguments) ? toolCall.arguments : {},
207 });
208 if (toolCall.result !== undefined) {
209 notify("ui/notifications/tool-result", normalizeMcpAppToolResult(toolCall.result));
210 }
211 };
212
213 const answer = (id: JsonRpcId, run: () => Promise<unknown>) => {
214 if (pending >= MAX_PENDING_REQUESTS) {
215 fail(id, -32000, "Too many requests in flight.");
216 return;
217 }
218 pending += 1;
219 void run().then(
220 (result) => {
221 pending -= 1;
222 respond(id, result);
223 },
224 (error: unknown) => {
225 pending -= 1;
226 fail(id, -32000, errorMessage(error));
227 },
228 );
229 };
230
231 const handleRequest = (id: JsonRpcId, method: string, params: Record<PropertyKey, unknown>) => {
232 switch (method) {
233 case "ui/initialize": {
234 const requested = params.protocolVersion;
235 const declared = Predicate.isObject(params.appCapabilities)
236 ? params.appCapabilities.availableDisplayModes
237 : undefined;
238 appDisplayModes = Array.isArray(declared)
239 ? declared.filter((mode): mode is string => typeof mode === "string")
240 : undefined;
241 sentContext = options.hostContext();
242 respond(id, {
243 protocolVersion:
244 requested === MCP_APP_PROTOCOL_VERSION ? requested : MCP_APP_PROTOCOL_VERSION,
245 hostInfo: { name: "t3-code", version: options.hostVersion },
246 hostCapabilities: {
247 openLinks: {},
248 serverTools: {},
249 serverResources: {},
250 logging: {},
251 message: { text: {} },
252 updateModelContext: { text: {}, structuredContent: {} },
253 downloadFile: {},
254 sandbox: {
255 ...(options.app.csp === undefined ? {} : { csp: options.app.csp }),
256 ...(options.app.permissions === undefined
257 ? {}
258 : { permissions: options.app.permissions }),
259 },
260 },
261 hostContext: sentContext,
262 });
263 return;
264 }
265 case "ping":
266 respond(id, {});
267 return;
268 case "tools/call": {
269 const name = params.name;
270 const args = params.arguments ?? {};
271 if (typeof name !== "string" || name.trim() === "" || !Predicate.isObject(args)) {
272 fail(id, -32602, "tools/call needs a tool name and object arguments.");
273 return;
274 }
275 answer(id, () =>
276 options
277 .callTool({ name, arguments: args as Record<string, unknown> })
278 .then(normalizeMcpAppToolResult),
279 );
280 return;
281 }
282 case "resources/read": {
283 const uri = params.uri;
284 if (typeof uri !== "string" || uri.trim() === "") {
285 fail(id, -32602, "resources/read needs a uri.");
286 return;
287 }
288 answer(id, () => options.readResource({ uri }));
289 return;
290 }
291 case "ui/open-link": {
292 const url = params.url;
293 if (typeof url !== "string" || !/^https?:\/\//i.test(url)) {
294 fail(id, -32602, "Only http(s) links can be opened.");
295 return;
296 }
297 answer(id, () => options.openLink(url).then(() => ({})));
298 return;
299 }
300 case "ui/message": {
301 // The SDK sends an array of content blocks; the spec text shows one block.
302 const blocks = Array.isArray(params.content) ? params.content : [params.content];
303 const texts = blocks.map((block) =>
304 Predicate.isObject(block) && block.type === "text" && typeof block.text === "string"
305 ? block.text
306 : undefined,
307 );
308 if (params.role !== "user" || texts.length === 0 || texts.includes(undefined)) {
309 fail(id, -32602, "Only text messages from the user role are supported.");
310 return;
311 }
312 const text = texts.join("\n").trim();
313 if (text === "") {
314 fail(id, -32602, "ui/message needs non-empty text.");
315 return;
316 }
317 answer(id, () => options.sendMessage(text).then(() => ({})));
318 return;
319 }
320 case "ui/request-display-mode": {
321 const current = options.hostContext().displayMode;
322 const mode = params.mode;
323 // A mode the host lacks, or the app did not declare, leaves the
324 // current one; the spec has the host answer with whichever applies.
325 const allowed =
326 typeof mode === "string" &&
327 options.hostContext().availableDisplayModes.includes(mode as McpAppDisplayMode) &&
328 (appDisplayModes === undefined || appDisplayModes.includes(mode));
329 if (!allowed || mode === current) {
330 respond(id, { mode: current });
331 return;
332 }
333 answer(id, () =>
334 options
335 .requestDisplayMode(mode as McpAppDisplayMode)
336 .then((result) => ({ mode: result })),
337 );
338 return;
339 }
340 case "ui/update-model-context": {
341 const content = params.content;
342 const structured = params.structuredContent;
343 if (
344 (content !== undefined && !Array.isArray(content)) ||
345 (structured !== undefined && !Predicate.isObject(structured))
346 ) {
347 fail(
348 id,
349 -32602,
350 "Model context needs content blocks or an object of structured content.",
351 );
352 return;
353 }
354 answer(id, () =>
355 options
356 .updateModelContext({
357 ...(content === undefined ? {} : { content }),
358 ...(structured === undefined
359 ? {}
360 : { structuredContent: structured as Record<string, unknown> }),
361 })
362 .then(() => ({})),
363 );
364 return;
365 }
366 case "ui/download-file": {
367 const files = readDownloads(params.contents);
368 if (files === undefined) {
369 fail(id, -32602, "ui/download-file needs embedded resources or resource links.");
370 return;
371 }
372 answer(id, () =>
373 options.downloadFile(files).then(
374 () => ({}),
375 // The draft reports a refused or failed download in the result.
376 (error: unknown) =>
377 error instanceof McpAppHostRefusal ? { isError: true } : Promise.reject(error),
378 ),
379 );
380 return;
381 }
382 default:
383 fail(id, -32601, `Method not found: ${method}`);
384 }
385 };
386
387 const handleNotification = (method: string, params: Record<PropertyKey, unknown>) => {
388 switch (method) {
389 case "ui/notifications/initialized": {
390 if (initialized) return;
391 initialized = true;
392 sendToolCall();
393 // The theme or size may have changed since the initialize response.
394 hostHandle.updateHostContext();
395 return;
396 }
397 case "ui/notifications/size-changed": {
398 const width = typeof params.width === "number" ? params.width : undefined;
399 const height = typeof params.height === "number" ? params.height : undefined;
400 if (width !== undefined || height !== undefined) {
401 options.onSizeChanged({
402 ...(width === undefined ? {} : { width }),
403 ...(height === undefined ? {} : { height }),
404 });
405 }
406 return;
407 }
408 case "ui/notifications/request-teardown":
409 options.onRequestTeardown();
410 return;
411 default:
412 // notifications/message (logging) and unknown notifications are ignored.
413 }
414 };
415
416 /** A response to a request the host sent the app. */
417 const handleResponse = (id: JsonRpcId) => {
418 const settle = hostRequests.get(String(id));
419 if (settle === undefined) return;
420 hostRequests.delete(String(id));
421 settle();
422 };
423
424 const hostHandle: McpAppHost = {
425 receive: (data) => {
426 if (disposed || !Predicate.isObject(data) || data.jsonrpc !== "2.0") return;
427 if (jsonBytes(data) > MAX_MESSAGE_BYTES) {
428 if (isId(data.id) && typeof data.method === "string") {
429 fail(data.id, -32600, "Message too large.");
430 }
431 return;
432 }
433 const method = data.method;
434 if (typeof method !== "string") {
435 if (isId(data.id) && ("result" in data || "error" in data)) handleResponse(data.id);
436 return;
437 }
438 const params = Predicate.isObject(data.params) ? data.params : {};
439 if (isId(data.id)) handleRequest(data.id, method, params);
440 else handleNotification(method, params);
441 },
442 setToolCall: (call) => {
443 toolCall ??= call;
444 sendToolCall();
445 },
446 updateHostContext: () => {
447 // The spec forbids messages before the app finishes initializing; it
448 // reads the then-current context from the initialize response anyway.
449 if (!initialized || sentContext === undefined) return;
450 const next = options.hostContext();
451 const changes: Record<string, unknown> = {};
452 for (const key of Object.keys(next) as Array<keyof McpAppHostContext>) {
453 if (JSON.stringify(next[key]) !== JSON.stringify(sentContext[key])) {
454 changes[key] = next[key];
455 }
456 }
457 sentContext = next;
458 if (Object.keys(changes).length > 0) {
459 notify("ui/notifications/host-context-changed", changes);
460 }
461 },
462 teardown: () =>
463 new Promise<void>((resolve) => {
464 let timer: ReturnType<typeof setTimeout> | undefined;
465 const finish = () => {
466 clearTimeout(timer);
467 disposed = true;
468 resolve();
469 };
470 // Before initialization the app has nothing to save and may not be
471 // listening; the spec only asks for teardown after it.
472 if (disposed || !initialized) {
473 finish();
474 return;
475 }
476 const id = `t3-host-${nextHostRequestId++}`;
477 hostRequests.set(id, finish);
478 timer = setTimeout(() => {
479 hostRequests.delete(id);
480 finish();
481 }, TEARDOWN_TIMEOUT_MS);
482 post({ jsonrpc: "2.0", id, method: "ui/resource-teardown", params: {} });
483 }),
484 dispose: () => {
485 disposed = true;
486 },
487 };
488 return hostHandle;
489}
490
491const MAX_DOWNLOAD_NAME = 200;
492
493/** The file name an app gave a download, or the last part of its URI. */
494function downloadName(uri: unknown, name: unknown): string {
495 const raw =
496 typeof name === "string" && name.trim() !== ""
497 ? name
498 : typeof uri === "string"
499 ? (uri.split(/[/?#]/).findLast(Boolean) ?? "download")
500 : "download";
501 // Path separators, reserved characters, and control characters.
502 const safe = Array.from(raw, (char) =>
503 /[\\/:*?"<>|]/.test(char) || char.charCodeAt(0) < 32 ? "-" : char,
504 ).join("");
505 return safe.slice(0, MAX_DOWNLOAD_NAME) || "download";
506}
507
508/** `ui/download-file` contents: MCP embedded resources and resource links. */
509function readDownloads(contents: unknown): ReadonlyArray<McpAppDownload> | undefined {
510 if (!Array.isArray(contents) || contents.length === 0) return undefined;
511 const files: Array<McpAppDownload> = [];
512 for (const entry of contents) {
513 if (!Predicate.isObject(entry)) return undefined;
514 if (entry.type === "resource" && Predicate.isObject(entry.resource)) {
515 const resource = entry.resource;
516 const bytes = mcpResourceBytes(resource);
517 if (bytes === undefined) return undefined;
518 files.push({
519 _tag: "embedded",
520 name: downloadName(resource.uri, resource.name),
521 mimeType:
522 typeof resource.mimeType === "string" ? resource.mimeType : "application/octet-stream",
523 bytes,
524 });
525 } else if (entry.type === "resource_link" && typeof entry.uri === "string") {
526 files.push({
527 _tag: "link",
528 name: downloadName(entry.uri, entry.name),
529 uri: entry.uri,
530 ...(typeof entry.mimeType === "string" ? { mimeType: entry.mimeType } : {}),
531 });
532 } else {
533 return undefined;
534 }
535 }
536 return files;
537}
538
539/**
540 * Maps T3's resolved theme variables to the spec's standardized style names, so
541 * apps written for any MCP Apps host pick up the thread's colors and fonts.
542 */
543export function mcpAppStyleVariables(
544 theme: Readonly<Record<string, string>>,
545): Record<string, string> {
546 const pick = (name: string) => theme[name];
547 const pairs: ReadonlyArray<readonly [string, string | undefined]> = [
548 ["--color-background-primary", pick("--background")],
549 ["--color-background-secondary", pick("--card")],
550 ["--color-background-tertiary", pick("--muted")],
551 ["--color-background-inverse", pick("--foreground")],
552 ["--color-background-info", pick("--info")],
553 ["--color-background-danger", pick("--destructive-surface")],
554 ["--color-background-warning", pick("--warning-surface")],
555 ["--color-text-primary", pick("--foreground")],
556 ["--color-text-secondary", pick("--muted-foreground")],
557 ["--color-text-tertiary", pick("--muted-foreground")],
558 ["--color-text-inverse", pick("--background")],
559 ["--color-text-info", pick("--info-foreground")],
560 ["--color-text-danger", pick("--destructive-foreground")],
561 ["--color-text-success", pick("--success-foreground")],
562 ["--color-text-warning", pick("--warning-foreground")],
563 ["--color-border-primary", pick("--border")],
564 ["--color-border-secondary", pick("--input")],
565 ["--color-border-danger", pick("--destructive")],
566 ["--color-ring-primary", pick("--ring")],
567 ["--font-sans", pick("--font-sans")],
568 ["--font-mono", pick("--font-mono")],
569 ["--border-radius-md", pick("--radius")],
570 ];
571 const variables: Record<string, string> = {};
572 for (const [name, value] of pairs) {
573 if (value !== undefined && value !== "") variables[name] = value;
574 }
575 return variables;
576}
packages/client-runtime/src/state/mcpApps.ts created+33
......@@ -0,0 +1,33 @@
1import { WS_METHODS } from "@t3tools/contracts";
2import type { Atom } from "effect/reactivity";
3
4import type { EnvironmentRegistry } from "../connection/registry.ts";
5import { createEnvironmentRpcCommand } from "./runtime.ts";
6
7/**
8 * RPC commands an MCP App host makes on an app's behalf. Each client
9 * instantiates them with its own connection runtime (`mcpAppEnvironment` in
10 * web and mobile).
11 */
12export function createMcpAppEnvironmentAtoms<R, E>(
13 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
14) {
15 return {
16 callTool: createEnvironmentRpcCommand(runtime, {
17 label: "environment-command:mcp-apps:call-tool",
18 tag: WS_METHODS.mcpAppsCallTool,
19 }),
20 toolInfo: createEnvironmentRpcCommand(runtime, {
21 label: "environment-command:mcp-apps:tool-info",
22 tag: WS_METHODS.mcpAppsToolInfo,
23 }),
24 readResource: createEnvironmentRpcCommand(runtime, {
25 label: "environment-command:mcp-apps:read-resource",
26 tag: WS_METHODS.mcpAppsReadResource,
27 }),
28 updateModelContext: createEnvironmentRpcCommand(runtime, {
29 label: "environment-command:mcp-apps:update-model-context",
30 tag: WS_METHODS.mcpAppsUpdateModelContext,
31 }),
32 };
33}
packages/contracts/src/index.ts+1
......@@ -49,6 +49,7 @@ export * from "./filesystem.ts";
4949export * from "./agentSessions.ts";
5050export * from "./assets.ts";
5151export * from "./review.ts";
52export * from "./mcpApps.ts";
5253export * from "./browserImport.ts";
5354export * from "./browserProfile.ts";
5455export * from "./device.ts";
packages/contracts/src/mcpApps.ts created+114
......@@ -0,0 +1,114 @@
1import * as Schema from "effect/Schema";
2
3import { ThreadId, TrimmedNonEmptyString, TurnItemId } from "./baseSchemas.ts";
4
5/**
6 * Requests an MCP App makes of its own MCP server, through the environment.
7 * Each names the tool call that produced the app; the environment resolves the
8 * app's server from that item, so an app can only reach the server it came from.
9 */
10const McpAppTarget = {
11 threadId: ThreadId,
12 itemId: TurnItemId,
13};
14
15export const McpAppCallToolInput = Schema.Struct({
16 ...McpAppTarget,
17 name: TrimmedNonEmptyString,
18 arguments: Schema.Record(Schema.String, Schema.Unknown),
19});
20export type McpAppCallToolInput = typeof McpAppCallToolInput.Type;
21
22/** MCP `CallToolResult`, passed through to the app unchanged. */
23export const McpAppCallToolResult = Schema.Struct({
24 content: Schema.Array(Schema.Unknown),
25 structuredContent: Schema.optional(Schema.Unknown),
26 isError: Schema.optional(Schema.Boolean),
27 _meta: Schema.optional(Schema.Unknown),
28});
29export type McpAppCallToolResult = typeof McpAppCallToolResult.Type;
30
31export const McpAppToolInfoInput = Schema.Struct({
32 ...McpAppTarget,
33 name: TrimmedNonEmptyString,
34});
35export type McpAppToolInfoInput = typeof McpAppToolInfoInput.Type;
36
37/**
38 * What a client needs to decide whether to ask before a call: whether the tool
39 * exists for apps and whether its server declares it read-only.
40 */
41export const McpAppToolInfo = Schema.Struct({
42 callable: Schema.Boolean,
43 readOnly: Schema.Boolean,
44 title: Schema.optional(Schema.String),
45 /** The server's MCP `Tool` definition, which the host passes to the app as `toolInfo`. */
46 tool: Schema.optional(Schema.Unknown),
47});
48export type McpAppToolInfo = typeof McpAppToolInfo.Type;
49
50export const McpAppReadResourceInput = Schema.Struct({
51 ...McpAppTarget,
52 uri: TrimmedNonEmptyString,
53});
54export type McpAppReadResourceInput = typeof McpAppReadResourceInput.Type;
55
56/** MCP `ReadResourceResult`, passed through to the app unchanged. */
57export const McpAppReadResourceResult = Schema.Struct({
58 contents: Schema.Array(Schema.Unknown),
59});
60export type McpAppReadResourceResult = typeof McpAppReadResourceResult.Type;
61
62/**
63 * What an app wants the agent to know on its next turn (`ui/update-model-context`).
64 * Each update replaces the app's previous one; no content clears it.
65 */
66export const McpAppUpdateModelContextInput = Schema.Struct({
67 ...McpAppTarget,
68 /**
69 * The thread whose next turn gets the context: the one on screen, which is
70 * the app's own thread or a fork that shows it.
71 */
72 conversationThreadId: ThreadId,
73 content: Schema.optional(Schema.Array(Schema.Unknown)),
74 structuredContent: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
75});
76export type McpAppUpdateModelContextInput = typeof McpAppUpdateModelContextInput.Type;
77
78export const McpAppRequestErrorReason = Schema.Literals([
79 "not-an-app",
80 "provider-unsupported",
81 "session-stopped",
82 "tool-not-callable",
83 "unsupported-content",
84 "request-failed",
85]);
86export type McpAppRequestErrorReason = typeof McpAppRequestErrorReason.Type;
87
88export class McpAppRequestError extends Schema.TaggedError<McpAppRequestError>()(
89 "McpAppRequestError",
90 {
91 threadId: ThreadId,
92 reason: McpAppRequestErrorReason,
93 cause: Schema.optional(Schema.Defect()),
94 },
95) {
96 override get message(): string {
97 switch (this.reason) {
98 case "not-an-app":
99 return "This tool call has no MCP app.";
100 case "provider-unsupported":
101 return "This thread's provider cannot run MCP app requests.";
102 case "session-stopped":
103 // A forked thread shows its source's apps, so this names the thread
104 // that made the app rather than the one on screen.
105 return "The app's thread is not running. Send a message in the thread that created it to use the app again.";
106 case "tool-not-callable":
107 return "This app cannot call that tool.";
108 case "unsupported-content":
109 return "Only text and structured content are supported.";
110 case "request-failed":
111 return "The app's MCP server request failed.";
112 }
113 }
114}
packages/contracts/src/rpc.ts+43
......@@ -1,4 +1,14 @@
11import { OrchestrationDispatchCommandError } from "./orchestrationDispatch.ts";
2import {
3 McpAppCallToolInput,
4 McpAppCallToolResult,
5 McpAppReadResourceInput,
6 McpAppReadResourceResult,
7 McpAppRequestError,
8 McpAppToolInfo,
9 McpAppToolInfoInput,
10 McpAppUpdateModelContextInput,
11} from "./mcpApps.ts";
212import {
313 ChatGptReconnectProfileInput,
414 ChatGptReconnectProfile,
......@@ -367,6 +377,12 @@ export const WS_METHODS = {
367377 attachmentsCreateUploadUrl: "attachments.createUploadUrl",
368378 attachmentsDelete: "attachments.delete",
369379
380 // MCP Apps methods
381 mcpAppsCallTool: "mcpApps.callTool",
382 mcpAppsToolInfo: "mcpApps.toolInfo",
383 mcpAppsReadResource: "mcpApps.readResource",
384 mcpAppsUpdateModelContext: "mcpApps.updateModelContext",
385
370386 // Provider methods
371387 providerUploadFeedback: "provider.uploadFeedback",
372388 providerAuthStart: "provider.auth.start",
......@@ -1232,6 +1248,29 @@ const WsAttachmentsDeleteRpc = Rpc.make(WS_METHODS.attachmentsDelete, {
12321248 error: EnvironmentAuthorizationError,
12331249});
12341250
1251const WsMcpAppsCallToolRpc = Rpc.make(WS_METHODS.mcpAppsCallTool, {
1252 payload: McpAppCallToolInput,
1253 success: McpAppCallToolResult,
1254 error: Schema.Union([McpAppRequestError, EnvironmentAuthorizationError]),
1255});
1256
1257const WsMcpAppsToolInfoRpc = Rpc.make(WS_METHODS.mcpAppsToolInfo, {
1258 payload: McpAppToolInfoInput,
1259 success: McpAppToolInfo,
1260 error: Schema.Union([McpAppRequestError, EnvironmentAuthorizationError]),
1261});
1262
1263const WsMcpAppsUpdateModelContextRpc = Rpc.make(WS_METHODS.mcpAppsUpdateModelContext, {
1264 payload: McpAppUpdateModelContextInput,
1265 error: Schema.Union([McpAppRequestError, EnvironmentAuthorizationError]),
1266});
1267
1268const WsMcpAppsReadResourceRpc = Rpc.make(WS_METHODS.mcpAppsReadResource, {
1269 payload: McpAppReadResourceInput,
1270 success: McpAppReadResourceResult,
1271 error: Schema.Union([McpAppRequestError, EnvironmentAuthorizationError]),
1272});
1273
12351274const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback, {
12361275 payload: ProviderUploadFeedbackInput,
12371276 success: ProviderUploadFeedbackResult,
......@@ -1856,6 +1895,10 @@ export const WsRpcGroup = RpcGroup.make(
18561895 WsAssetsPersistChatAttachmentsRpc,
18571896 WsAttachmentsCreateUploadUrlRpc,
18581897 WsAttachmentsDeleteRpc,
1898 WsMcpAppsCallToolRpc,
1899 WsMcpAppsToolInfoRpc,
1900 WsMcpAppsReadResourceRpc,
1901 WsMcpAppsUpdateModelContextRpc,
18591902 WsProviderUploadFeedbackRpc,
18601903 WsSubscribeVcsStatusRpc,
18611904 WsSubscribeWorktreeSetupRpc,
packages/shared/package.json+4
......@@ -402,6 +402,10 @@
402402 "./codexAuthHandoff": {
403403 "types": "./src/codexAuthHandoff.ts",
404404 "import": "./src/codexAuthHandoff.ts"
405 },
406 "./mcpApp": {
407 "types": "./src/mcpApp.ts",
408 "import": "./src/mcpApp.ts"
405409 }
406410 },
407411 "scripts": {
packages/shared/src/mcpApp.test.ts created+150
......@@ -0,0 +1,150 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 injectMcpAppCsp,
5 mcpAppContentSecurityPolicy,
6 mcpAppToolCallableByApp,
7 readMcpAppCsp,
8 readMcpAppReference,
9 readMcpAppResourceUri,
10} from "./mcpApp.ts";
11
12describe("mcpAppContentSecurityPolicy", () => {
13 it("blocks the network and T3's own origin when the app declares nothing", () => {
14 const policy = mcpAppContentSecurityPolicy(undefined);
15 expect(policy).toContain("default-src 'none'");
16 expect(policy).toContain("connect-src 'none'");
17 expect(policy).toContain("frame-src 'none'");
18 expect(policy).not.toContain("'self'");
19 });
20
21 it("allows exactly the declared origins", () => {
22 const policy = mcpAppContentSecurityPolicy({
23 connectDomains: ["https://api.weather.test"],
24 resourceDomains: ["https://*.cdn.test"],
25 });
26 expect(policy).toContain("connect-src https://api.weather.test");
27 expect(policy).toContain("script-src 'unsafe-inline' https://*.cdn.test");
28 expect(policy).toContain("img-src data: blob: https://*.cdn.test");
29 });
30});
31
32describe("injectMcpAppCsp", () => {
33 it("puts the policy after the doctype and ahead of every script", () => {
34 const html = "<!DOCTYPE html><html><head><script>x()</script></head></html>";
35 const injected = injectMcpAppCsp(html, undefined);
36 expect(injected.startsWith('<!DOCTYPE html><meta http-equiv="Content-Security-Policy"')).toBe(
37 true,
38 );
39 expect(injected.indexOf("connect-src 'none'")).toBeLessThan(injected.indexOf("<script>"));
40 expect(injectMcpAppCsp("<p>hi</p>", undefined).startsWith("<!doctype html><meta")).toBe(true);
41 });
42});
43
44describe("readMcpAppCsp", () => {
45 it("drops entries that could widen or break the policy", () => {
46 expect(
47 readMcpAppCsp({
48 connectDomains: [
49 "https://ok.test",
50 "*",
51 "'self'",
52 "https://evil.test; script-src *",
53 "http://localhost:3000",
54 "javascript:alert(1)",
55 ],
56 resourceDomains: "https://not-an-array.test",
57 }),
58 ).toEqual({ connectDomains: ["https://ok.test", "http://localhost:3000"] });
59 expect(readMcpAppCsp({ connectDomains: ["*"] })).toBeUndefined();
60 });
61});
62
63describe("readMcpAppReference", () => {
64 it("requires a ui:// resource and the owning server and tool", () => {
65 const reference = {
66 attachmentId: "thread-1-abc-html",
67 server: "weather",
68 tool: "get_weather",
69 resourceUri: "ui://weather/dashboard",
70 };
71 expect(readMcpAppReference(reference)).toEqual(reference);
72 expect(readMcpAppReference({ ...reference, resourceUri: "https://x.test" })).toBeUndefined();
73 expect(readMcpAppReference({ ...reference, server: "" })).toBeUndefined();
74 });
75});
76
77describe("tool metadata", () => {
78 it("reads the resource URI from either spelling and honors app visibility", () => {
79 expect(readMcpAppResourceUri({ ui: { resourceUri: "ui://a/b" } })).toBe("ui://a/b");
80 expect(readMcpAppResourceUri({ "ui/resourceUri": "ui://a/c" })).toBe("ui://a/c");
81 expect(readMcpAppResourceUri({ ui: { resourceUri: "https://a" } })).toBeUndefined();
82 expect(mcpAppToolCallableByApp(undefined)).toBe(true);
83 expect(mcpAppToolCallableByApp({ ui: { visibility: ["model"] } })).toBe(false);
84 expect(mcpAppToolCallableByApp({ ui: { visibility: ["model", "app"] } })).toBe(true);
85 });
86});
87
88describe("mcpAppFromToolItem", () => {
89 it("reads the app from both the stored output and its compact wire form", async () => {
90 const { compactDynamicToolOutput, mcpAppFromToolItem } = await import("./toolOutput.ts");
91 const app = {
92 attachmentId: "thread-1-abc-html",
93 server: "weather",
94 tool: "get_weather",
95 resourceUri: "ui://weather/dashboard",
96 };
97 const stored = { t3McpApp: app, result: { content: [] } };
98 const toolName = "weather.get_weather";
99 expect(mcpAppFromToolItem({ toolName, output: stored })).toEqual(app);
100 expect(mcpAppFromToolItem({ toolName, output: compactDynamicToolOutput(stored) })).toEqual(app);
101 });
102
103 it("ignores a reference naming a server or tool other than the item's own", async () => {
104 const { mcpAppFromToolItem } = await import("./toolOutput.ts");
105 // A tool result shaped like an app reference, claiming another server.
106 const forged = {
107 t3McpApp: {
108 attachmentId: "thread-1-abc-html",
109 server: "bank",
110 tool: "transfer",
111 resourceUri: "ui://bank/app",
112 },
113 };
114 expect(mcpAppFromToolItem({ toolName: "evil.lookup", output: forged })).toBeUndefined();
115 expect(mcpAppFromToolItem({ toolName: "bank.transfer", output: forged })).toBeDefined();
116 });
117
118 it("keeps an app whose resource URI is longer than a name", async () => {
119 const { compactDynamicToolOutput } = await import("./toolOutput.ts");
120 const resourceUri = `ui://weather/${"segment/".repeat(60)}dashboard`;
121 const output = compactDynamicToolOutput({
122 t3McpApp: {
123 attachmentId: "thread-1-abc-html",
124 server: "weather",
125 tool: "get_weather",
126 resourceUri,
127 },
128 });
129 expect(output?.t3McpApp?.resourceUri).toBe(resourceUri);
130 });
131
132 it("keeps the compact app reference within the wire budget", async () => {
133 const { compactDynamicToolOutput } = await import("./toolOutput.ts");
134 const domains = Array.from(
135 { length: 32 },
136 (_, index) => `https://${"a".repeat(200)}${index}.test`,
137 );
138 const output = compactDynamicToolOutput({
139 t3McpApp: {
140 attachmentId: "thread-1-abc-html",
141 server: "weather",
142 tool: "get_weather",
143 resourceUri: "ui://weather/dashboard",
144 csp: { connectDomains: domains, resourceDomains: domains },
145 },
146 });
147 expect(new TextEncoder().encode(JSON.stringify(output)).byteLength).toBeLessThanOrEqual(8_192);
148 expect(output?.t3McpApp?.server).toBe("weather");
149 });
150});
packages/shared/src/mcpApp.ts created+236
......@@ -0,0 +1,236 @@
1import * as Predicate from "effect/Predicate";
2
3/**
4 * MCP Apps (https://github.com/modelcontextprotocol/ext-apps, spec 2026-01-26):
5 * an MCP tool can name a `ui://` HTML resource that hosts render as an
6 * interactive view of its result. T3 snapshots the resource when the tool call
7 * completes, stores it as a thread attachment, and records an `McpAppReference`
8 * in the tool item's output; clients host it in an opaque-origin frame and
9 * speak the spec's JSON-RPC bridge with it.
10 */
11
12export const MCP_APP_PROTOCOL_VERSION = "2026-01-26";
13export const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app";
14/** The MCP extension id a client declares to receive UI resources. */
15export const MCP_APP_EXTENSION_ID = "io.modelcontextprotocol/ui";
16export const MCP_APP_RESOURCE_SCHEME = "ui://";
17/** Where T3 records the app reference inside a tool item's output object. */
18export const MCP_APP_OUTPUT_KEY = "t3McpApp";
19
20const MCP_APP_MIN_HEIGHT = 80;
21export const MCP_APP_DEFAULT_HEIGHT = 320;
22export const MCP_APP_MAX_HEIGHT = 2000;
23/** Largest app document T3 stores. */
24export const MCP_APP_MAX_HTML_BYTES = 5 * 1024 * 1024;
25const MAX_DOMAINS = 32;
26const MAX_DOMAIN_LENGTH = 256;
27const MAX_NAME_LENGTH = 256;
28// A `ui://` URI can carry a path and query; the compact wire budget still
29// bounds the whole reference.
30const MAX_RESOURCE_URI_LENGTH = 4096;
31
32/** `_meta.ui.csp` on a UI resource: origins the app needs, by kind. */
33export interface McpAppCsp {
34 readonly connectDomains?: ReadonlyArray<string>;
35 readonly resourceDomains?: ReadonlyArray<string>;
36 readonly frameDomains?: ReadonlyArray<string>;
37 readonly baseUriDomains?: ReadonlyArray<string>;
38}
39
40/** `_meta.ui.permissions` on a UI resource. */
41export interface McpAppPermissions {
42 readonly camera?: Record<string, never>;
43 readonly microphone?: Record<string, never>;
44 readonly geolocation?: Record<string, never>;
45 readonly clipboardWrite?: Record<string, never>;
46}
47
48/** What a tool item's output carries so clients can host the app. */
49export interface McpAppReference {
50 readonly attachmentId: string;
51 /** The MCP server the tool belongs to, as the provider names it. */
52 readonly server: string;
53 readonly tool: string;
54 readonly resourceUri: string;
55 readonly csp?: McpAppCsp;
56 readonly permissions?: McpAppPermissions;
57 readonly prefersBorder?: boolean;
58}
59
60// Only scheme-qualified origins, optionally with a leading `*.` subdomain
61// wildcard, survive. Anything else (keywords, paths, quotes, whitespace) could
62// widen or break the generated policy, so it is dropped.
63const DOMAIN_PATTERN = /^(?:https?|wss?):\/\/(?:\*\.)?[a-z0-9-]+(?:\.[a-z0-9-]+)*(?::\d{1,5})?$/i;
64
65function readDomains(value: unknown): ReadonlyArray<string> | undefined {
66 if (!Array.isArray(value)) return undefined;
67 const domains = value
68 .filter(
69 (domain): domain is string =>
70 typeof domain === "string" &&
71 domain.length <= MAX_DOMAIN_LENGTH &&
72 DOMAIN_PATTERN.test(domain),
73 )
74 .slice(0, MAX_DOMAINS);
75 return domains.length === 0 ? undefined : domains;
76}
77
78/** The well-formed part of a resource's declared `_meta.ui.csp`. */
79export function readMcpAppCsp(value: unknown): McpAppCsp | undefined {
80 if (!Predicate.isObject(value)) return undefined;
81 const csp: {
82 connectDomains?: ReadonlyArray<string>;
83 resourceDomains?: ReadonlyArray<string>;
84 frameDomains?: ReadonlyArray<string>;
85 baseUriDomains?: ReadonlyArray<string>;
86 } = {};
87 for (const key of [
88 "connectDomains",
89 "resourceDomains",
90 "frameDomains",
91 "baseUriDomains",
92 ] as const) {
93 const domains = readDomains(value[key]);
94 if (domains !== undefined) csp[key] = domains;
95 }
96 return Object.keys(csp).length === 0 ? undefined : csp;
97}
98
99const PERMISSION_KEYS = ["camera", "microphone", "geolocation", "clipboardWrite"] as const;
100
101export function readMcpAppPermissions(value: unknown): McpAppPermissions | undefined {
102 if (!Predicate.isObject(value)) return undefined;
103 const permissions: Partial<Record<(typeof PERMISSION_KEYS)[number], Record<string, never>>> = {};
104 for (const key of PERMISSION_KEYS) {
105 if (Predicate.isObject(value[key])) permissions[key] = {};
106 }
107 return Object.keys(permissions).length === 0 ? undefined : permissions;
108}
109
110const boundedName = (value: unknown) =>
111 typeof value === "string" && value.trim() !== "" && value.length <= MAX_NAME_LENGTH
112 ? value
113 : undefined;
114
115export function readMcpAppReference(value: unknown): McpAppReference | undefined {
116 if (!Predicate.isObject(value)) return undefined;
117 const attachmentId = boundedName(value.attachmentId);
118 const server = boundedName(value.server);
119 const tool = boundedName(value.tool);
120 const resourceUri =
121 typeof value.resourceUri === "string" && value.resourceUri.length <= MAX_RESOURCE_URI_LENGTH
122 ? value.resourceUri
123 : undefined;
124 if (
125 attachmentId === undefined ||
126 server === undefined ||
127 tool === undefined ||
128 resourceUri === undefined ||
129 !resourceUri.startsWith(MCP_APP_RESOURCE_SCHEME)
130 ) {
131 return undefined;
132 }
133 const csp = readMcpAppCsp(value.csp);
134 const permissions = readMcpAppPermissions(value.permissions);
135 return {
136 attachmentId,
137 server,
138 tool,
139 resourceUri,
140 ...(csp === undefined ? {} : { csp }),
141 ...(permissions === undefined ? {} : { permissions }),
142 ...(typeof value.prefersBorder === "boolean" ? { prefersBorder: value.prefersBorder } : {}),
143 };
144}
145
146export function mcpAppReferencesEqual(left: McpAppReference, right: McpAppReference): boolean {
147 return (
148 left.attachmentId === right.attachmentId &&
149 left.server === right.server &&
150 left.tool === right.tool &&
151 left.resourceUri === right.resourceUri
152 );
153}
154
155/** The tool's `_meta.ui.resourceUri` (or the deprecated flat key), if it names a UI resource. */
156export function readMcpAppResourceUri(meta: unknown): string | undefined {
157 if (!Predicate.isObject(meta)) return undefined;
158 const nested = Predicate.isObject(meta.ui) ? meta.ui.resourceUri : undefined;
159 const uri = nested ?? meta["ui/resourceUri"];
160 return typeof uri === "string" && uri.startsWith(MCP_APP_RESOURCE_SCHEME) ? uri : undefined;
161}
162
163/**
164 * Whether the tool's `_meta.ui.visibility` lets an app call it. The spec
165 * defaults to visible to both the model and apps.
166 */
167export function mcpAppToolCallableByApp(meta: unknown): boolean {
168 const visibility =
169 Predicate.isObject(meta) && Predicate.isObject(meta.ui) ? meta.ui.visibility : undefined;
170 return !Array.isArray(visibility) || visibility.includes("app");
171}
172
173/**
174 * The Content-Security-Policy for an app document, per the spec's
175 * construction: undeclared origins stay blocked, and nothing loads from the
176 * host's own origin ('self' would reach T3's API).
177 */
178export function mcpAppContentSecurityPolicy(csp: McpAppCsp | undefined): string {
179 const resources = csp?.resourceDomains?.join(" ") ?? "";
180 const list = (domains: ReadonlyArray<string> | undefined, fallback: string) =>
181 domains === undefined || domains.length === 0 ? fallback : domains.join(" ");
182 return [
183 "default-src 'none'",
184 `script-src 'unsafe-inline' ${resources}`.trim(),
185 `style-src 'unsafe-inline' ${resources}`.trim(),
186 `img-src data: blob: ${resources}`.trim(),
187 `font-src data: ${resources}`.trim(),
188 `media-src data: blob: ${resources}`.trim(),
189 `connect-src ${list(csp?.connectDomains, "'none'")}`,
190 `frame-src ${list(csp?.frameDomains, "'none'")}`,
191 "object-src 'none'",
192 `base-uri ${list(csp?.baseUriDomains, "'none'")}`,
193 "form-action 'none'",
194 ].join("; ");
195}
196
197const escapeAttribute = (value: string) =>
198 value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
199
200/**
201 * Puts the app's policy at the top of its document as a CSP meta tag, ahead of
202 * any script. The asset route's `sandbox` header already makes the origin
203 * opaque; browsers enforce both policies, so the document can only tighten
204 * this one, never widen it.
205 */
206export function injectMcpAppCsp(html: string, csp: McpAppCsp | undefined): string {
207 const meta = `<meta http-equiv="Content-Security-Policy" content="${escapeAttribute(
208 mcpAppContentSecurityPolicy(csp),
209 )}">`;
210 // After a leading doctype, so the document stays in standards mode; the
211 // parser moves the tag into the head it opens.
212 const doctype = /^\uFEFF?\s*<!doctype[^>]*>/i.exec(html);
213 return doctype
214 ? `${doctype[0]}${meta}${html.slice(doctype[0].length)}`
215 : `<!doctype html>${meta}${html}`;
216}
217
218/** The iframe `allow` attribute for the permissions an app declared. */
219export function mcpAppAllowAttribute(permissions: McpAppPermissions | undefined): string {
220 const features: Array<string> = [];
221 if (permissions?.camera) features.push("camera");
222 if (permissions?.microphone) features.push("microphone");
223 if (permissions?.geolocation) features.push("geolocation");
224 if (permissions?.clipboardWrite) features.push("clipboard-write");
225 return features.join("; ");
226}
227
228export function clampMcpAppHeight(height: number): number {
229 if (!Number.isFinite(height)) return MCP_APP_DEFAULT_HEIGHT;
230 return Math.round(Math.min(MCP_APP_MAX_HEIGHT, Math.max(MCP_APP_MIN_HEIGHT, height)));
231}
232
233export function mcpAppFileName(reference: McpAppReference): string {
234 const name = reference.tool.replace(/[^\w.-]+/g, "-").slice(0, 80) || "app";
235 return `${name}.html`;
236}
packages/shared/src/toolOutput.ts+31-1
......@@ -9,6 +9,7 @@ import {
99 readHtmlRenderReference,
1010 type HtmlRenderReference,
1111} from "./htmlRender.ts";
12import { MCP_APP_OUTPUT_KEY, readMcpAppReference, type McpAppReference } from "./mcpApp.ts";
1213import { resolveT3McpToolId } from "./t3McpToolPresentation.ts";
1314
1415const MAX_PARSED_BYTES = 16_384;
......@@ -39,6 +40,7 @@ interface CompactToolOutput {
3940 scheduledTaskId?: string;
4041 status?: "rolled_back";
4142 htmlRender?: HtmlRenderReference;
43 [MCP_APP_OUTPUT_KEY]?: McpAppReference;
4244 thread?: { threadId: string };
4345 threads?: Array<{ threadId?: string; status?: "rolled_back" }>;
4446}
......@@ -124,6 +126,9 @@ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | un
124126 if (data.status === "rolled_back") output.status = "rolled_back";
125127 const htmlRender = readHtmlRenderReference(data.htmlRender);
126128 if (htmlRender !== undefined) output.htmlRender = htmlRender;
129 const mcpApp = readMcpAppReference(data[MCP_APP_OUTPUT_KEY]);
130 // Kept under its stored key, so the compact wire output reads back the same way.
131 if (mcpApp !== undefined) output[MCP_APP_OUTPUT_KEY] = mcpApp;
127132 const nestedThreadId = Predicate.isObject(data.thread)
128133 ? boundedId(data.thread.threadId)
129134 : undefined;
......@@ -159,11 +164,21 @@ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | un
159164 }
160165 }
161166 }
162 if (encoder.encode(JSON.stringify(output)).byteLength > MAX_METADATA_BYTES) {
167 const oversized = () => encoder.encode(JSON.stringify(output)).byteLength > MAX_METADATA_BYTES;
168 if (oversized()) {
163169 delete output.threads;
164170 delete output.threadId;
165171 delete output.status;
166172 }
173 // An app's declared origins are the only unbounded-ish part of its
174 // reference; hosting works without them (the stored document carries its
175 // own policy), so they go before the app does.
176 const app = output[MCP_APP_OUTPUT_KEY];
177 if (app?.csp !== undefined && oversized()) {
178 const { csp: _csp, ...rest } = app;
179 output[MCP_APP_OUTPUT_KEY] = rest;
180 }
181 if (oversized()) delete output[MCP_APP_OUTPUT_KEY];
167182 return Object.keys(output).length === 0 ? undefined : output;
168183}
169184
......@@ -177,6 +192,21 @@ export function htmlRenderFromToolItem(item: {
177192 return output?.isError ? undefined : output?.htmlRender;
178193}
179194
195/**
196 * The MCP App a completed tool call carries, if any. The adapter that captured
197 * it put the reference in the output, beside the tool's own result. A tool's
198 * result can imitate that shape, so the reference only counts when it names
199 * the very server and tool the item records: a server can then only ever
200 * point at an app of its own.
201 */
202export function mcpAppFromToolItem(item: {
203 readonly toolName: string | null | undefined;
204 readonly output?: unknown;
205}): McpAppReference | undefined {
206 const app = compactDynamicToolOutput(item.output)?.[MCP_APP_OUTPUT_KEY];
207 return app !== undefined && item.toolName === `${app.server}.${app.tool}` ? app : undefined;
208}
209
180210/** Some providers report completion even when command output describes a failure. */
181211export function toolOutputIndicatesFailure(text: string): boolean {
182212 return (