Open History to see the last commit
1import * as Predicate from "effect/Predicate";
2
3export const MCP_APP_PROTOCOL_VERSION = "2026-01-26";
4export const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app";
5export const MCP_APP_EXTENSION_ID = "io.modelcontextprotocol/ui";
6export const MCP_APP_RESOURCE_SCHEME = "ui://";
7export const MCP_APP_OUTPUT_KEY = "t3McpApp";
8/** Frame URL query parameter carrying the host's color scheme before `ui/initialize`. */
9export const MCP_APP_COLOR_SCHEME_PARAM = "t3-color-scheme";
10
11const MCP_APP_MIN_HEIGHT = 80;
12export const MCP_APP_DEFAULT_HEIGHT = 320;
13export const MCP_APP_MAX_HEIGHT = 2000;
14export const MCP_APP_MAX_HTML_BYTES = 5 * 1024 * 1024;
15const MAX_DOMAINS = 32;
16const MAX_DOMAIN_LENGTH = 256;
17const MAX_NAME_LENGTH = 256;
18const MAX_RESOURCE_URI_LENGTH = 4096;
19
20export interface McpAppCsp {
21 readonly connectDomains?: ReadonlyArray<string>;
22 readonly resourceDomains?: ReadonlyArray<string>;
23 readonly frameDomains?: ReadonlyArray<string>;
24 readonly baseUriDomains?: ReadonlyArray<string>;
25}
26
27export interface McpAppPermissions {
28 readonly camera?: Record<string, never>;
29 readonly microphone?: Record<string, never>;
30 readonly geolocation?: Record<string, never>;
31 readonly clipboardWrite?: Record<string, never>;
32}
33
34export interface McpAppReference {
35 readonly attachmentId: string;
36 readonly server: string;
37 readonly tool: string;
38 readonly resourceUri: string;
39 readonly csp?: McpAppCsp;
40 readonly permissions?: McpAppPermissions;
41 readonly prefersBorder?: boolean;
42}
43
44// Accept only scheme-qualified origins with optional subdomain wildcards to prevent policy widening.
45const DOMAIN_PATTERN = /^(?:https?|wss?):\/\/(?:\*\.)?[a-z0-9-]+(?:\.[a-z0-9-]+)*(?::\d{1,5})?$/i;
46
47function readDomains(value: unknown): ReadonlyArray<string> | undefined {
48 if (!Array.isArray(value)) return undefined;
49 const domains = value
50 .filter(
51 (domain): domain is string =>
52 typeof domain === "string" &&
53 domain.length <= MAX_DOMAIN_LENGTH &&
54 DOMAIN_PATTERN.test(domain),
55 )
56 .slice(0, MAX_DOMAINS);
57 return domains.length === 0 ? undefined : domains;
58}
59
60export function readMcpAppCsp(value: unknown): McpAppCsp | undefined {
61 if (!Predicate.isObject(value)) return undefined;
62 const csp: {
63 connectDomains?: ReadonlyArray<string>;
64 resourceDomains?: ReadonlyArray<string>;
65 frameDomains?: ReadonlyArray<string>;
66 baseUriDomains?: ReadonlyArray<string>;
67 } = {};
68 for (const key of [
69 "connectDomains",
70 "resourceDomains",
71 "frameDomains",
72 "baseUriDomains",
73 ] as const) {
74 const domains = readDomains(value[key]);
75 if (domains !== undefined) csp[key] = domains;
76 }
77 return Object.keys(csp).length === 0 ? undefined : csp;
78}
79
80const PERMISSION_KEYS = ["camera", "microphone", "geolocation", "clipboardWrite"] as const;
81
82export function readMcpAppPermissions(value: unknown): McpAppPermissions | undefined {
83 if (!Predicate.isObject(value)) return undefined;
84 const permissions: Partial<Record<(typeof PERMISSION_KEYS)[number], Record<string, never>>> = {};
85 for (const key of PERMISSION_KEYS) {
86 if (Predicate.isObject(value[key])) permissions[key] = {};
87 }
88 return Object.keys(permissions).length === 0 ? undefined : permissions;
89}
90
91const boundedName = (value: unknown) =>
92 typeof value === "string" && value.trim() !== "" && value.length <= MAX_NAME_LENGTH
93 ? value
94 : undefined;
95
96export function readMcpAppReference(value: unknown): McpAppReference | undefined {
97 if (!Predicate.isObject(value)) return undefined;
98 const attachmentId = boundedName(value.attachmentId);
99 const server = boundedName(value.server);
100 const tool = boundedName(value.tool);
101 const resourceUri =
102 typeof value.resourceUri === "string" && value.resourceUri.length <= MAX_RESOURCE_URI_LENGTH
103 ? value.resourceUri
104 : undefined;
105 if (
106 attachmentId === undefined ||
107 server === undefined ||
108 tool === undefined ||
109 resourceUri === undefined ||
110 !resourceUri.startsWith(MCP_APP_RESOURCE_SCHEME)
111 ) {
112 return undefined;
113 }
114 const csp = readMcpAppCsp(value.csp);
115 const permissions = readMcpAppPermissions(value.permissions);
116 return {
117 attachmentId,
118 server,
119 tool,
120 resourceUri,
121 ...(csp === undefined ? {} : { csp }),
122 ...(permissions === undefined ? {} : { permissions }),
123 ...(typeof value.prefersBorder === "boolean" ? { prefersBorder: value.prefersBorder } : {}),
124 };
125}
126
127export function mcpAppReferencesEqual(left: McpAppReference, right: McpAppReference): boolean {
128 return (
129 left.attachmentId === right.attachmentId &&
130 left.server === right.server &&
131 left.tool === right.tool &&
132 left.resourceUri === right.resourceUri
133 );
134}
135
136/** Reads a tool's `_meta.ui.resourceUri`, or the deprecated flat `ui/resourceUri`. */
137export function mcpAppToolResourceUri(meta: unknown): string | undefined {
138 if (!Predicate.isObject(meta)) return undefined;
139 const uri =
140 (Predicate.isObject(meta.ui) ? meta.ui.resourceUri : undefined) ?? meta["ui/resourceUri"];
141 return typeof uri === "string" &&
142 uri.length <= MAX_RESOURCE_URI_LENGTH &&
143 uri.startsWith(MCP_APP_RESOURCE_SCHEME)
144 ? uri
145 : undefined;
146}
147
148export const claudeMcpToolNamePart = (name: string) => name.replace(/[^a-zA-Z0-9_-]/g, "_");
149
150/** Claude Code's `mcp__<server>__` prefix; it also collapses and trims `_` in `claude.ai ` connector names. */
151export function claudeMcpToolPrefix(server: string): string {
152 const part = claudeMcpToolNamePart(server);
153 return `mcp__${server.startsWith("claude.ai ") ? part.replace(/_+/g, "_").replace(/^_|_$/g, "") : part}__`;
154}
155
156/** Claude Code names MCP tools `mcp__<server>__<tool>`, replacing characters outside `[a-zA-Z0-9_-]`. */
157export function claudeMcpToolName(server: string, tool: string): string {
158 return `${claudeMcpToolPrefix(server)}${claudeMcpToolNamePart(tool)}`;
159}
160
161export function mcpAppToolCallableByApp(meta: unknown): boolean {
162 const visibility =
163 Predicate.isObject(meta) && Predicate.isObject(meta.ui) ? meta.ui.visibility : undefined;
164 return !Array.isArray(visibility) || visibility.includes("app");
165}
166
167/** Block undeclared origins, including self, which would expose T3's API. */
168export function mcpAppContentSecurityPolicy(csp: McpAppCsp | undefined): string {
169 const resources = csp?.resourceDomains?.join(" ") ?? "";
170 const list = (domains: ReadonlyArray<string> | undefined, fallback: string) =>
171 domains === undefined || domains.length === 0 ? fallback : domains.join(" ");
172 return [
173 "default-src 'none'",
174 `script-src 'unsafe-inline' ${resources}`.trim(),
175 `style-src 'unsafe-inline' ${resources}`.trim(),
176 `img-src data: blob: ${resources}`.trim(),
177 `font-src data: ${resources}`.trim(),
178 `media-src data: blob: ${resources}`.trim(),
179 `connect-src ${list(csp?.connectDomains, "'none'")}`,
180 `frame-src ${list(csp?.frameDomains, "'none'")}`,
181 "object-src 'none'",
182 `base-uri ${list(csp?.baseUriDomains, "'none'")}`,
183 "form-action 'none'",
184 ].join("; ");
185}
186
187const escapeAttribute = (value: string) =>
188 value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
189
190/** The route's sandbox header keeps origins opaque; the document CSP can only tighten it. */
191export function injectMcpAppCsp(html: string, csp: McpAppCsp | undefined): string {
192 const meta = `<meta http-equiv="Content-Security-Policy" content="${escapeAttribute(
193 mcpAppContentSecurityPolicy(csp),
194 )}">`;
195 // Insert after the doctype to preserve standards mode; the parser moves it into the head.
196 const doctype = /^\uFEFF?\s*<!doctype[^>]*>/i.exec(html);
197 return doctype
198 ? `${doctype[0]}${meta}${html.slice(doctype[0].length)}`
199 : `<!doctype html>${meta}${html}`;
200}
201
202/** Opaque origins require authenticating bridge messages by parent window. */
203export function injectMcpAppThemeBootstrap(html: string): string {
204 const script = `<script>
205(() => {
206 try {
207 const initial = new URLSearchParams(location.search).get(${JSON.stringify(MCP_APP_COLOR_SCHEME_PARAM)});
208 if (initial === "light" || initial === "dark") {
209 // A meta tag, unlike an inline style, survives the app's own <html style> attribute.
210 const meta = document.createElement("meta");
211 meta.name = "color-scheme";
212 meta.content = initial;
213 document.head.append(meta);
214 }
215 } catch {}
216 window.addEventListener("message", (event) => {
217 if (event.source !== window.parent) return;
218 const message = event.data;
219 if (!message || message.jsonrpc !== "2.0") return;
220 const theme = message.method === "ui/notifications/host-context-changed"
221 ? message.params?.theme
222 : message.id !== undefined && message.method === undefined
223 ? message.result?.hostContext?.theme
224 : undefined;
225 if (theme === "light" || theme === "dark") {
226 document.documentElement.style.colorScheme = theme;
227 }
228 });
229})();
230</script>`;
231 const prefix =
232 /^\uFEFF?\s*<!doctype[^>]*>\s*(?:<meta\s+http-equiv="Content-Security-Policy"[^>]*>)?/i.exec(
233 html,
234 );
235 return prefix
236 ? `${prefix[0]}${script}${html.slice(prefix[0].length)}`
237 : `<!doctype html>${script}${html}`;
238}
239
240/** Opaque documents throw on cookie reads; analytics startup often expects an empty string instead. */
241export function injectMcpAppCookieBootstrap(html: string): string {
242 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>`;
243 // Preserve captured CSP ahead of executable code, including this bootstrap.
244 const prefix =
245 /^(?:\uFEFF?\s*<!doctype[^>]*>\s*)?(?:<meta http-equiv="Content-Security-Policy" content="[^"]*">)?/i.exec(
246 html,
247 )![0];
248 return `${prefix}${bootstrap}${html.slice(prefix.length)}`;
249}
250
251export function mcpAppAllowAttribute(permissions: McpAppPermissions | undefined): string {
252 const features: Array<string> = [];
253 if (permissions?.camera) features.push("camera");
254 if (permissions?.microphone) features.push("microphone");
255 if (permissions?.geolocation) features.push("geolocation");
256 if (permissions?.clipboardWrite) features.push("clipboard-write");
257 return features.join("; ");
258}
259
260export function clampMcpAppHeight(height: number): number {
261 if (!Number.isFinite(height)) return MCP_APP_DEFAULT_HEIGHT;
262 return Math.round(Math.min(MCP_APP_MAX_HEIGHT, Math.max(MCP_APP_MIN_HEIGHT, height)));
263}
264
265export function mcpAppFileName(reference: McpAppReference): string {
266 const name = reference.tool.replace(/[^\w.-]+/g, "-").slice(0, 80) || "app";
267 return `${name}.html`;
268}