| 1 | import * 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 | |
| 12 | export const MCP_APP_PROTOCOL_VERSION = "2026-01-26"; |
| 13 | export const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app"; |
| 14 | /** The MCP extension id a client declares to receive UI resources. */ |
| 15 | export const MCP_APP_EXTENSION_ID = "io.modelcontextprotocol/ui"; |
| 16 | export const MCP_APP_RESOURCE_SCHEME = "ui://"; |
| 17 | /** Where T3 records the app reference inside a tool item's output object. */ |
| 18 | export const MCP_APP_OUTPUT_KEY = "t3McpApp"; |
| 19 | |
| 20 | const MCP_APP_MIN_HEIGHT = 80; |
| 21 | export const MCP_APP_DEFAULT_HEIGHT = 320; |
| 22 | export const MCP_APP_MAX_HEIGHT = 2000; |
| 23 | /** Largest app document T3 stores. */ |
| 24 | export const MCP_APP_MAX_HTML_BYTES = 5 * 1024 * 1024; |
| 25 | const MAX_DOMAINS = 32; |
| 26 | const MAX_DOMAIN_LENGTH = 256; |
| 27 | const MAX_NAME_LENGTH = 256; |
| 28 | // A `ui://` URI can carry a path and query; the compact wire budget still |
| 29 | // bounds the whole reference. |
| 30 | const MAX_RESOURCE_URI_LENGTH = 4096; |
| 31 | |
| 32 | /** `_meta.ui.csp` on a UI resource: origins the app needs, by kind. */ |
| 33 | export 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. */ |
| 41 | export 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. */ |
| 49 | export 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. |
| 63 | const DOMAIN_PATTERN = /^(?:https?|wss?):\/\/(?:\*\.)?[a-z0-9-]+(?:\.[a-z0-9-]+)*(?::\d{1,5})?$/i; |
| 64 | |
| 65 | function 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`. */ |
| 79 | export 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 | |
| 99 | const PERMISSION_KEYS = ["camera", "microphone", "geolocation", "clipboardWrite"] as const; |
| 100 | |
| 101 | export 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 | |
| 110 | const boundedName = (value: unknown) => |
| 111 | typeof value === "string" && value.trim() !== "" && value.length <= MAX_NAME_LENGTH |
| 112 | ? value |
| 113 | : undefined; |
| 114 | |
| 115 | export 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 | |
| 146 | export 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. */ |
| 156 | export 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 | */ |
| 167 | export 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 | */ |
| 178 | export 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 | |
| 197 | const 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 | */ |
| 206 | export 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 | */ |
| 223 | export 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 | */ |
| 257 | export 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. */ |
| 269 | export 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 | |
| 278 | export 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 | |
| 283 | export function mcpAppFileName(reference: McpAppReference): string { |
| 284 | const name = reference.tool.replace(/[^\w.-]+/g, "-").slice(0, 80) || "app"; |
| 285 | return `${name}.html`; |
| 286 | } |