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}