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/**
219 * Installed before vendor scripts so apps that omit their own theme handling
220 * still give their document canvas and native controls the host's appearance.
221 * The bridge uses an opaque origin, so authenticate messages by parent window.
222 */
223export function injectMcpAppThemeBootstrap(html: string): string {
224 const script = `<script>
225(() => {
226 window.addEventListener("message", (event) => {
227 if (event.source !== window.parent) return;
228 const message = event.data;
229 if (!message || message.jsonrpc !== "2.0") return;
230 const theme = message.method === "ui/notifications/host-context-changed"
231 ? message.params?.theme
232 : message.id !== undefined && message.method === undefined
233 ? message.result?.hostContext?.theme
234 : undefined;
235 if (theme === "light" || theme === "dark") {
236 document.documentElement.style.colorScheme = theme;
237 }
238 });
239})();
240</script>`;
241 // Snapshots start with our doctype and CSP meta. Keep that policy ahead of
242 // the bootstrap, rather than letting an injected script precede it.
243 const prefix =
244 /^\uFEFF?\s*<!doctype[^>]*>\s*(?:<meta\s+http-equiv="Content-Security-Policy"[^>]*>)?/i.exec(
245 html,
246 );
247 return prefix
248 ? `${prefix[0]}${script}${html.slice(prefix[0].length)}`
249 : `<!doctype html>${script}${html}`;
250}
251
252/**
253 * Analytics libraries often read cookies during startup. An opaque document
254 * has none, but browsers throw instead of returning an empty cookie string.
255 * Keep the sandbox and make that document-local case behave like no cookies.
256 */
257export function injectMcpAppCookieBootstrap(html: string): string {
258 const bootstrap = `<script>(function(){try{void document.cookie;}catch(error){if(error&&error.name==="SecurityError"){Object.defineProperty(document,"cookie",{configurable:true,get:function(){return "";},set:function(){}});}}})();</script>`;
259 // Saved captures begin with the CSP inserted by injectMcpAppCsp. Keep it
260 // ahead of executable code, including this bootstrap, without rewriting it.
261 const prefix =
262 /^(?:\uFEFF?\s*<!doctype[^>]*>\s*)?(?:<meta http-equiv="Content-Security-Policy" content="[^"]*">)?/i.exec(
263 html,
264 )![0];
265 return `${prefix}${bootstrap}${html.slice(prefix.length)}`;
266}
267
268/** The iframe `allow` attribute for the permissions an app declared. */
269export function mcpAppAllowAttribute(permissions: McpAppPermissions | undefined): string {
270 const features: Array<string> = [];
271 if (permissions?.camera) features.push("camera");
272 if (permissions?.microphone) features.push("microphone");
273 if (permissions?.geolocation) features.push("geolocation");
274 if (permissions?.clipboardWrite) features.push("clipboard-write");
275 return features.join("; ");
276}
277
278export function clampMcpAppHeight(height: number): number {
279 if (!Number.isFinite(height)) return MCP_APP_DEFAULT_HEIGHT;
280 return Math.round(Math.min(MCP_APP_MAX_HEIGHT, Math.max(MCP_APP_MIN_HEIGHT, height)));
281}
282
283export function mcpAppFileName(reference: McpAppReference): string {
284 const name = reference.tool.replace(/[^\w.-]+/g, "-").slice(0, 80) || "app";
285 return `${name}.html`;
286}