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";
6import * as Schema from "effect/Schema";
7
8const MAX_MESSAGE_BYTES = 256 * 1024;
9const MAX_PENDING_REQUESTS = 16;
10const TEARDOWN_TIMEOUT_MS = 2000;
11const encoder = new TextEncoder();
12
13type JsonRpcId = string | number;
14
15export type McpAppDisplayMode = "inline" | "fullscreen" | "pip";
16
17export interface McpAppHostContext {
18 readonly theme: "light" | "dark";
19 readonly styles: { readonly variables: Readonly<Record<string, string>> };
20 readonly displayMode: McpAppDisplayMode;
21 readonly availableDisplayModes: ReadonlyArray<McpAppDisplayMode>;
22 /**
23 * `maxHeight` when the host sizes the frame to the app's reported height,
24 * `height` when the frame is a fixed box the app must fit (spec
25 * "Container Dimensions").
26 */
27 readonly containerDimensions:
28 | { readonly width: number; readonly maxHeight: number }
29 | { readonly width: number; readonly height: number };
30 readonly platform: "web" | "desktop" | "mobile";
31 readonly locale?: string;
32 readonly timeZone?: string;
33 readonly userAgent?: string;
34 readonly deviceCapabilities?: { readonly touch?: boolean; readonly hover?: boolean };
35 readonly safeAreaInsets?: {
36 readonly top: number;
37 readonly right: number;
38 readonly bottom: number;
39 readonly left: number;
40 };
41 readonly toolInfo?: { readonly tool: unknown };
42}
43
44export type McpAppDownload =
45 | {
46 readonly _tag: "embedded";
47 readonly name: string;
48 readonly mimeType: string;
49 readonly bytes: Uint8Array;
50 }
51 | {
52 readonly _tag: "link";
53 readonly name: string;
54 readonly uri: string;
55 readonly mimeType?: string;
56 };
57
58export function mcpResourceBytes(content: unknown): Uint8Array | undefined {
59 if (!Predicate.isObject(content)) return undefined;
60 if (typeof content.text === "string") return encoder.encode(content.text);
61 if (typeof content.blob === "string") {
62 const decoded = Base64.decode(content.blob);
63 return Result.isSuccess(decoded) ? decoded.success : undefined;
64 }
65 return undefined;
66}
67
68export interface McpAppCallToolResult {
69 readonly content: ReadonlyArray<unknown>;
70 readonly structuredContent?: unknown;
71 readonly isError?: boolean | undefined;
72 readonly _meta?: unknown;
73}
74
75export class McpAppHostRefusal extends Schema.TaggedError<McpAppHostRefusal>()(
76 "McpAppHostRefusal",
77 { message: Schema.String },
78) {}
79
80const isMcpAppHostRefusal = Schema.is(McpAppHostRefusal);
81
82export interface McpAppHostOptions {
83 readonly app: McpAppReference;
84 readonly hostVersion: string;
85 readonly post: (message: unknown) => void;
86 readonly hostContext: () => McpAppHostContext;
87
88 readonly callTool: (input: {
89 readonly name: string;
90 readonly arguments: Record<string, unknown>;
91 }) => Promise<McpAppCallToolResult>;
92 readonly readResource: (input: { readonly uri: string }) => Promise<unknown>;
93 readonly openLink: (url: string) => Promise<void>;
94 readonly sendMessage: (text: string) => Promise<void>;
95 /** Replaces what this app tells the agent on its next turn; undefined content clears it. */
96 readonly updateModelContext: (context: {
97 readonly content?: ReadonlyArray<unknown>;
98 readonly structuredContent?: Record<string, unknown>;
99 }) => Promise<void>;
100 readonly requestDisplayMode: (mode: McpAppDisplayMode) => Promise<McpAppDisplayMode>;
101 readonly downloadFile: (files: ReadonlyArray<McpAppDownload>) => Promise<void>;
102 readonly onRequestTeardown: () => void;
103 readonly onSizeChanged: (size: { readonly width?: number; readonly height?: number }) => void;
104}
105
106export interface McpAppHost {
107 /** Handles one message the app posted; the caller has already checked its source. */
108 readonly receive: (data: unknown) => void;
109 readonly setToolCall: (call: {
110 readonly arguments: unknown;
111 readonly result: McpAppCallToolResult | undefined;
112 }) => void;
113 readonly updateHostContext: () => void;
114 /**
115 * Asks the app to wrap up before its document goes away (`ui/resource-teardown`),
116 * resolving once it answers or after a short timeout, then disposes the host.
117 */
118 readonly teardown: () => Promise<void>;
119 readonly dispose: () => void;
120}
121
122function stringifyJson(value: unknown): string | undefined {
123 try {
124 return JSON.stringify(value);
125 } catch {
126 return undefined;
127 }
128}
129
130function isId(value: unknown): value is JsonRpcId {
131 return typeof value === "string" || (typeof value === "number" && Number.isFinite(value));
132}
133
134/**
135 * Providers can report absent fields as null (Codex sends `_meta: null`), which
136 * the MCP Apps SDK rejects, dropping the whole notification or response.
137 */
138function normalizeMcpAppToolResult(result: McpAppCallToolResult): McpAppCallToolResult {
139 return {
140 content: Array.isArray(result.content) ? result.content : [],
141 ...(Predicate.isObject(result.structuredContent)
142 ? { structuredContent: result.structuredContent }
143 : {}),
144 ...(result.isError === true ? { isError: true } : {}),
145 ...(Predicate.isObject(result._meta) ? { _meta: result._meta } : {}),
146 };
147}
148
149const errorMessage = (error: unknown) =>
150 isMcpAppHostRefusal(error)
151 ? error.message
152 : error instanceof Error && error.message.trim() !== ""
153 ? error.message
154 : "Request failed.";
155
156export function makeMcpAppHost(options: McpAppHostOptions): McpAppHost {
157 let initialized = false;
158 let disposed = false;
159 let toolCall:
160 | { readonly arguments: unknown; readonly result: McpAppCallToolResult | undefined }
161 | undefined;
162 let toolCallSent = false;
163 let pending = 0;
164 let sentContext: McpAppHostContext | undefined;
165 let appDisplayModes: ReadonlyArray<string> | undefined;
166 let nextHostRequestId = 0;
167 const hostRequests = new Map<string, () => void>();
168
169 const post = (message: unknown) => {
170 if (!disposed) options.post(message);
171 };
172 const notify = (method: string, params: unknown) => post({ jsonrpc: "2.0", method, params });
173 const respond = (id: JsonRpcId, result: unknown) => post({ jsonrpc: "2.0", id, result });
174 const fail = (id: JsonRpcId, code: number, message: string) =>
175 post({ jsonrpc: "2.0", id, error: { code, message } });
176
177 const sendToolCall = () => {
178 if (!initialized || toolCall === undefined || toolCallSent) return;
179 toolCallSent = true;
180 notify("ui/notifications/tool-input", {
181 arguments: Predicate.isObject(toolCall.arguments) ? toolCall.arguments : {},
182 });
183 if (toolCall.result !== undefined) {
184 notify("ui/notifications/tool-result", normalizeMcpAppToolResult(toolCall.result));
185 }
186 };
187
188 const answer = (id: JsonRpcId, run: () => Promise<unknown>) => {
189 if (pending >= MAX_PENDING_REQUESTS) {
190 fail(id, -32000, "Too many requests in flight.");
191 return;
192 }
193 pending += 1;
194 void run().then(
195 (result) => {
196 pending -= 1;
197 respond(id, result);
198 },
199 (error: unknown) => {
200 pending -= 1;
201 fail(id, -32000, errorMessage(error));
202 },
203 );
204 };
205
206 const handleRequest = (id: JsonRpcId, method: string, params: Record<PropertyKey, unknown>) => {
207 switch (method) {
208 case "ui/initialize": {
209 const requested = params.protocolVersion;
210 const declared = Predicate.isObject(params.appCapabilities)
211 ? params.appCapabilities.availableDisplayModes
212 : undefined;
213 appDisplayModes = Array.isArray(declared)
214 ? declared.filter((mode): mode is string => typeof mode === "string")
215 : undefined;
216 sentContext = options.hostContext();
217 respond(id, {
218 protocolVersion:
219 requested === MCP_APP_PROTOCOL_VERSION ? requested : MCP_APP_PROTOCOL_VERSION,
220 hostInfo: { name: "t3-code", version: options.hostVersion },
221 hostCapabilities: {
222 openLinks: {},
223 serverTools: {},
224 serverResources: {},
225 logging: {},
226 message: { text: {} },
227 updateModelContext: { text: {}, structuredContent: {} },
228 downloadFile: {},
229 sandbox: {
230 ...(options.app.csp === undefined ? {} : { csp: options.app.csp }),
231 ...(options.app.permissions === undefined
232 ? {}
233 : { permissions: options.app.permissions }),
234 },
235 },
236 hostContext: sentContext,
237 });
238 return;
239 }
240 case "ping":
241 respond(id, {});
242 return;
243 case "tools/call": {
244 const name = params.name;
245 const args = params.arguments ?? {};
246 if (typeof name !== "string" || name.trim() === "" || !Predicate.isObject(args)) {
247 fail(id, -32602, "tools/call needs a tool name and object arguments.");
248 return;
249 }
250 answer(id, () =>
251 options
252 .callTool({ name, arguments: args as Record<string, unknown> })
253 .then(normalizeMcpAppToolResult),
254 );
255 return;
256 }
257 case "resources/read": {
258 const uri = params.uri;
259 if (typeof uri !== "string" || uri.trim() === "") {
260 fail(id, -32602, "resources/read needs a uri.");
261 return;
262 }
263 answer(id, () => options.readResource({ uri }));
264 return;
265 }
266 case "ui/open-link": {
267 const url = params.url;
268 if (typeof url !== "string" || !/^https?:\/\//i.test(url)) {
269 fail(id, -32602, "Only http(s) links can be opened.");
270 return;
271 }
272 answer(id, () => options.openLink(url).then(() => ({})));
273 return;
274 }
275 case "ui/message": {
276 // The SDK sends an array of content blocks; the spec text shows one block.
277 const blocks = Array.isArray(params.content) ? params.content : [params.content];
278 const texts = blocks.map((block) =>
279 Predicate.isObject(block) && block.type === "text" && typeof block.text === "string"
280 ? block.text
281 : undefined,
282 );
283 if (params.role !== "user" || texts.length === 0 || texts.includes(undefined)) {
284 fail(id, -32602, "Only text messages from the user role are supported.");
285 return;
286 }
287 const text = texts.join("\n").trim();
288 if (text === "") {
289 fail(id, -32602, "ui/message needs non-empty text.");
290 return;
291 }
292 answer(id, () => options.sendMessage(text).then(() => ({})));
293 return;
294 }
295 case "ui/request-display-mode": {
296 const current = options.hostContext().displayMode;
297 const mode = params.mode;
298 // A mode the host lacks, or the app did not declare, leaves the
299 // current one; the spec has the host answer with whichever applies.
300 const allowed =
301 typeof mode === "string" &&
302 options.hostContext().availableDisplayModes.includes(mode as McpAppDisplayMode) &&
303 (appDisplayModes === undefined || appDisplayModes.includes(mode));
304 if (!allowed || mode === current) {
305 respond(id, { mode: current });
306 return;
307 }
308 answer(id, () =>
309 options
310 .requestDisplayMode(mode as McpAppDisplayMode)
311 .then((result) => ({ mode: result })),
312 );
313 return;
314 }
315 case "ui/update-model-context": {
316 const content = params.content;
317 const structured = params.structuredContent;
318 if (
319 (content !== undefined && !Array.isArray(content)) ||
320 (structured !== undefined && !Predicate.isObject(structured))
321 ) {
322 fail(
323 id,
324 -32602,
325 "Model context needs content blocks or an object of structured content.",
326 );
327 return;
328 }
329 answer(id, () =>
330 options
331 .updateModelContext({
332 ...(content === undefined ? {} : { content }),
333 ...(structured === undefined
334 ? {}
335 : { structuredContent: structured as Record<string, unknown> }),
336 })
337 .then(() => ({})),
338 );
339 return;
340 }
341 case "ui/download-file": {
342 const files = readDownloads(params.contents);
343 if (files === undefined) {
344 fail(id, -32602, "ui/download-file needs embedded resources or resource links.");
345 return;
346 }
347 answer(id, () =>
348 options.downloadFile(files).then(
349 () => ({}),
350 (error: unknown) =>
351 isMcpAppHostRefusal(error) ? { isError: true } : Promise.reject(error),
352 ),
353 );
354 return;
355 }
356 default:
357 fail(id, -32601, `Method not found: ${method}`);
358 }
359 };
360
361 const handleNotification = (method: string, params: Record<PropertyKey, unknown>) => {
362 switch (method) {
363 case "ui/notifications/initialized": {
364 if (initialized) return;
365 initialized = true;
366 sendToolCall();
367 hostHandle.updateHostContext();
368 return;
369 }
370 case "ui/notifications/size-changed": {
371 const width = typeof params.width === "number" ? params.width : undefined;
372 const height = typeof params.height === "number" ? params.height : undefined;
373 if (width !== undefined || height !== undefined) {
374 options.onSizeChanged({
375 ...(width === undefined ? {} : { width }),
376 ...(height === undefined ? {} : { height }),
377 });
378 }
379 return;
380 }
381 case "ui/notifications/request-teardown":
382 options.onRequestTeardown();
383 return;
384 default:
385 }
386 };
387
388 const handleResponse = (id: JsonRpcId) => {
389 const settle = hostRequests.get(String(id));
390 if (settle === undefined) return;
391 hostRequests.delete(String(id));
392 settle();
393 };
394
395 const hostHandle: McpAppHost = {
396 receive: (data) => {
397 if (disposed || !Predicate.isObject(data) || data.jsonrpc !== "2.0") return;
398 const requestId = isId(data.id) && typeof data.method === "string" ? data.id : undefined;
399 const json = stringifyJson(data);
400 if (json === undefined) {
401 if (requestId !== undefined) fail(requestId, -32602, "Params must be JSON.");
402 return;
403 }
404 if (encoder.encode(json).byteLength > MAX_MESSAGE_BYTES) {
405 if (requestId !== undefined) fail(requestId, -32600, "Message too large.");
406 return;
407 }
408 const method = data.method;
409 if (typeof method !== "string") {
410 if (isId(data.id) && ("result" in data || "error" in data)) handleResponse(data.id);
411 return;
412 }
413 // postMessage keeps undefined, which the RPC's JSON codec rejects; pass on the JSON form.
414 const { params: jsonParams }: { params?: unknown } = JSON.parse(json);
415 const params = Predicate.isObject(jsonParams) ? jsonParams : {};
416 if (isId(data.id)) handleRequest(data.id, method, params);
417 else handleNotification(method, params);
418 },
419 setToolCall: (call) => {
420 // A running call has no result yet; only its completion may replace it.
421 if (toolCall === undefined || !toolCallSent) {
422 if (toolCall?.result === undefined) toolCall = call;
423 sendToolCall();
424 return;
425 }
426 if (toolCall.result !== undefined || call.result === undefined) return;
427 // The app already has this call's input; it only needs the result.
428 toolCall = { arguments: toolCall.arguments, result: call.result };
429 notify("ui/notifications/tool-result", normalizeMcpAppToolResult(call.result));
430 },
431 updateHostContext: () => {
432 // The spec forbids messages before the app finishes initializing; it
433 // reads the then-current context from the initialize response anyway.
434 if (!initialized || sentContext === undefined) return;
435 const next = options.hostContext();
436 const changes: Record<string, unknown> = {};
437 for (const key of Object.keys(next) as Array<keyof McpAppHostContext>) {
438 if (JSON.stringify(next[key]) !== JSON.stringify(sentContext[key])) {
439 changes[key] = next[key];
440 }
441 }
442 sentContext = next;
443 if (Object.keys(changes).length > 0) {
444 notify("ui/notifications/host-context-changed", changes);
445 }
446 },
447 teardown: () =>
448 new Promise<void>((resolve) => {
449 let timer: ReturnType<typeof setTimeout> | undefined;
450 const finish = () => {
451 clearTimeout(timer);
452 disposed = true;
453 resolve();
454 };
455 // Before initialization the app has nothing to save and may not be
456 // listening; the spec only asks for teardown after it.
457 if (disposed || !initialized) {
458 finish();
459 return;
460 }
461 const id = `t3-host-${nextHostRequestId++}`;
462 hostRequests.set(id, finish);
463 timer = setTimeout(() => {
464 hostRequests.delete(id);
465 finish();
466 }, TEARDOWN_TIMEOUT_MS);
467 post({ jsonrpc: "2.0", id, method: "ui/resource-teardown", params: {} });
468 }),
469 dispose: () => {
470 disposed = true;
471 },
472 };
473 return hostHandle;
474}
475
476const MAX_DOWNLOAD_NAME = 200;
477
478function downloadName(uri: unknown, name: unknown): string {
479 const raw =
480 typeof name === "string" && name.trim() !== ""
481 ? name
482 : typeof uri === "string"
483 ? (uri.split(/[/?#]/).findLast(Boolean) ?? "download")
484 : "download";
485 let safe = "";
486 for (const char of raw) {
487 const next = /[\\/:*?"<>|]/.test(char) || char.charCodeAt(0) < 32 ? "-" : char;
488 if (safe.length + next.length > MAX_DOWNLOAD_NAME) break;
489 safe += next;
490 }
491 return safe || "download";
492}
493
494function readDownloads(contents: unknown): ReadonlyArray<McpAppDownload> | undefined {
495 if (!Array.isArray(contents) || contents.length === 0) return undefined;
496 const files: Array<McpAppDownload> = [];
497 for (const entry of contents) {
498 if (!Predicate.isObject(entry)) return undefined;
499 if (entry.type === "resource" && Predicate.isObject(entry.resource)) {
500 const resource = entry.resource;
501 const bytes = mcpResourceBytes(resource);
502 if (bytes === undefined) return undefined;
503 files.push({
504 _tag: "embedded",
505 name: downloadName(resource.uri, resource.name),
506 mimeType:
507 typeof resource.mimeType === "string" ? resource.mimeType : "application/octet-stream",
508 bytes,
509 });
510 } else if (entry.type === "resource_link" && typeof entry.uri === "string") {
511 files.push({
512 _tag: "link",
513 name: downloadName(entry.uri, entry.name),
514 uri: entry.uri,
515 ...(typeof entry.mimeType === "string" ? { mimeType: entry.mimeType } : {}),
516 });
517 } else {
518 return undefined;
519 }
520 }
521 return files;
522}
523
524export function mcpAppStyleVariables(
525 theme: Readonly<Record<string, string>>,
526): Record<string, string> {
527 const pick = (name: string) => theme[name];
528 const pairs: ReadonlyArray<readonly [string, string | undefined]> = [
529 ["--color-background-primary", pick("--background")],
530 ["--color-background-secondary", pick("--card")],
531 ["--color-background-tertiary", pick("--muted")],
532 ["--color-background-inverse", pick("--foreground")],
533 ["--color-background-info", pick("--info")],
534 ["--color-background-danger", pick("--destructive-surface")],
535 ["--color-background-warning", pick("--warning-surface")],
536 ["--color-text-primary", pick("--foreground")],
537 ["--color-text-secondary", pick("--muted-foreground")],
538 ["--color-text-tertiary", pick("--muted-foreground")],
539 ["--color-text-inverse", pick("--background")],
540 ["--color-text-info", pick("--info-foreground")],
541 ["--color-text-danger", pick("--destructive-foreground")],
542 ["--color-text-success", pick("--success-foreground")],
543 ["--color-text-warning", pick("--warning-foreground")],
544 ["--color-border-primary", pick("--border")],
545 ["--color-border-secondary", pick("--input")],
546 ["--color-border-danger", pick("--destructive")],
547 ["--color-ring-primary", pick("--ring")],
548 ["--font-sans", pick("--font-sans")],
549 ["--font-mono", pick("--font-mono")],
550 ["--border-radius-md", pick("--radius")],
551 ];
552 const variables: Record<string, string> = {};
553 for (const [name, value] of pairs) {
554 if (value !== undefined && value !== "") variables[name] = value;
555 }
556 return variables;
557}