diff --git a/examples/integration-server/src/mcp-app.tsx b/examples/integration-server/src/mcp-app.tsx index ef280f3..8e4910a 100644 --- a/examples/integration-server/src/mcp-app.tsx +++ b/examples/integration-server/src/mcp-app.tsx @@ -137,6 +137,24 @@ function GetTimeAppInner({ log.info("Open link request", isError ? "rejected" : "accepted"); }, [app, linkUrl]); + const canDownload = + app.getHostCapabilities()?.downloadFile !== undefined; + + const handleDownloadFile = useCallback(async () => { + const sampleContent = JSON.stringify( + { time: serverTime, exported: new Date().toISOString() }, + null, + 2, + ); + log.info("Requesting file download..."); + const { isError } = await app.downloadFile({ + filename: "export.json", + content: sampleContent, + mimeType: "application/json", + }); + log.info("Download", isError ? "rejected" : "accepted"); + }, [app, serverTime]); + return (
+ + {canDownload && ( +
+

Export current server time as JSON file

+ +
+ )}
); } diff --git a/specification/draft/apps.mdx b/specification/draft/apps.mdx index cb73cab..d12ba12 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -648,6 +648,8 @@ interface HostCapabilities { experimental?: {}; /** Host supports opening external URLs. */ openLinks?: {}; + /** Host supports file downloads via ui/download-file. */ + downloadFile?: {}; /** Host can proxy tool calls to the MCP server. */ serverTools?: { /** Host supports tools/list_changed notifications. */ @@ -1013,6 +1015,49 @@ MCP Apps introduces additional JSON-RPC methods for UI-specific functionality: Host SHOULD open the URL in the user's default browser or a new tab. +`ui/download-file` - Request host to download a file + +```typescript +// Request +{ + jsonrpc: "2.0", + id: 1, + method: "ui/download-file", + params: { + filename: string, // Suggested filename + content: string, // File content (text or base64-encoded) + mimeType: string, // MIME type (e.g. "image/svg+xml", "application/json") + encoding?: "utf-8" | "base64" // Content encoding, defaults to "utf-8" + } +} + +// Success Response +{ + jsonrpc: "2.0", + id: 1, + result: {} // Empty result on success +} + +// Error Response (if denied or failed) +{ + jsonrpc: "2.0", + id: 1, + error: { + code: -32000, // Implementation-defined error + message: "Download denied by user" | "Invalid content" | "Policy violation" + } +} +``` + +MCP Apps run in sandboxed iframes where direct file downloads are blocked (`allow-downloads` is not set). `ui/download-file` provides a host-mediated mechanism for apps to offer file exports — useful for visualization tools (SVG/PNG export), document editors, data analysis tools, and any app that produces downloadable artifacts. + +Host behavior: +* Host SHOULD show a confirmation dialog before initiating the download. +* Host SHOULD use the `filename` as the suggested filename for the download. +* For `encoding: "base64"`, host MUST decode the content from base64 before creating the file. +* Host MAY reject the download based on security policy, file size limits, or user preferences. +* Host SHOULD sanitize the filename to prevent path traversal. + `ui/message` - Send message content to the host's chat interface ```typescript diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 6e1c021..de7d876 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -69,6 +69,9 @@ import { McpUiOpenLinkRequest, McpUiOpenLinkRequestSchema, McpUiOpenLinkResult, + McpUiDownloadFileRequest, + McpUiDownloadFileRequestSchema, + McpUiDownloadFileResult, McpUiResourceTeardownRequest, McpUiResourceTeardownResultSchema, McpUiSandboxProxyReadyNotification, @@ -614,6 +617,60 @@ export class AppBridge extends Protocol< ); } + /** + * Register a handler for file download requests from the View. + * + * The View sends `ui/download-file` requests when the user wants to + * download a file. The host should show a confirmation dialog and then + * trigger the download. + * + * @param callback - Handler that receives download params and returns a result + * - `params.filename` - Suggested filename for the download + * - `params.content` - File content (text or base64-encoded) + * - `params.mimeType` - MIME type of the file + * - `params.encoding` - Content encoding ("utf-8" or "base64", defaults to "utf-8") + * - `extra` - Request metadata (abort signal, session info) + * - Returns: `Promise` with optional `isError` flag + * + * @example + * ```ts + * bridge.ondownloadfile = async ({ filename, content, mimeType, encoding }, extra) => { + * const confirmed = await showDialog({ + * message: `Download "${filename}"?`, + * buttons: ["Download", "Cancel"], + * }); + * if (!confirmed) return { isError: true }; + * + * const blob = encoding === "base64" + * ? new Blob([Uint8Array.from(atob(content), c => c.charCodeAt(0))], { type: mimeType }) + * : new Blob([content], { type: mimeType }); + * const url = URL.createObjectURL(blob); + * const link = document.createElement("a"); + * link.href = url; + * link.download = filename; + * link.click(); + * URL.revokeObjectURL(url); + * return {}; + * }; + * ``` + * + * @see {@link McpUiDownloadFileRequest `McpUiDownloadFileRequest`} for the request type + * @see {@link McpUiDownloadFileResult `McpUiDownloadFileResult`} for the result type + */ + set ondownloadfile( + callback: ( + params: McpUiDownloadFileRequest["params"], + extra: RequestHandlerExtra, + ) => Promise, + ) { + this.setRequestHandler( + McpUiDownloadFileRequestSchema, + async (request, extra) => { + return callback(request.params, extra); + }, + ); + } + /** * Register a handler for display mode change requests from the view. * diff --git a/src/app.ts b/src/app.ts index 16f8da6..ebf7e81 100644 --- a/src/app.ts +++ b/src/app.ts @@ -33,6 +33,8 @@ import { McpUiMessageResultSchema, McpUiOpenLinkRequest, McpUiOpenLinkResultSchema, + McpUiDownloadFileRequest, + McpUiDownloadFileResultSchema, McpUiResourceTeardownRequest, McpUiResourceTeardownRequestSchema, McpUiResourceTeardownResult, @@ -930,6 +932,59 @@ export class App extends Protocol { /** @deprecated Use {@link openLink `openLink`} instead */ sendOpenLink: App["openLink"] = this.openLink; + /** + * Request the host to download a file. + * + * Since MCP Apps run in sandboxed iframes where direct downloads are blocked, + * this provides a host-mediated mechanism for file exports. The host will + * typically show a confirmation dialog before initiating the download. + * + * @param params - File content, filename, MIME type, and optional encoding + * @param options - Request options (timeout, etc.) + * @returns Result with `isError: true` if the host denied the request (e.g., user cancelled) + * + * @throws {Error} If the request times out or the connection is lost + * + * @example Download a JSON export + * ```ts + * const data = JSON.stringify({ items: selectedItems }, null, 2); + * const { isError } = await app.downloadFile({ + * filename: "export.json", + * content: data, + * mimeType: "application/json", + * }); + * if (isError) { + * console.warn("Download denied or cancelled"); + * } + * ``` + * + * @example Download binary content (base64) + * ```ts + * const { isError } = await app.downloadFile({ + * filename: "image.png", + * content: base64EncodedPng, + * mimeType: "image/png", + * encoding: "base64", + * }); + * ``` + * + * @see {@link McpUiDownloadFileRequest `McpUiDownloadFileRequest`} for request structure + * @see {@link McpUiDownloadFileResult `McpUiDownloadFileResult`} for result structure + */ + downloadFile( + params: McpUiDownloadFileRequest["params"], + options?: RequestOptions, + ) { + return this.request( + { + method: "ui/download-file", + params, + }, + McpUiDownloadFileResultSchema, + options, + ); + } + /** * Request a change to the display mode. * diff --git a/src/generated/schema.json b/src/generated/schema.json index d9e4b58..9cc87ae 100644 --- a/src/generated/schema.json +++ b/src/generated/schema.json @@ -81,6 +81,61 @@ ], "description": "Display mode for UI presentation." }, + "McpUiDownloadFileRequest": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "method": { + "type": "string", + "const": "ui/download-file" + }, + "params": { + "type": "object", + "properties": { + "filename": { + "type": "string", + "description": "Suggested filename for the download." + }, + "content": { + "type": "string", + "description": "File content — text or base64-encoded binary." + }, + "mimeType": { + "type": "string", + "description": "MIME type of the file (e.g. \"image/svg+xml\", \"application/json\")." + }, + "encoding": { + "description": "Content encoding. Defaults to \"utf-8\". Use \"base64\" for binary content.", + "anyOf": [ + { + "type": "string", + "const": "utf-8" + }, + { + "type": "string", + "const": "base64" + } + ] + } + }, + "required": ["filename", "content", "mimeType"], + "additionalProperties": false + } + }, + "required": ["method", "params"], + "additionalProperties": false + }, + "McpUiDownloadFileResult": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "isError": { + "description": "True if the download failed (e.g., user cancelled or host denied).", + "type": "boolean" + } + }, + "additionalProperties": {} + }, "McpUiHostCapabilities": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", @@ -97,6 +152,12 @@ "properties": {}, "additionalProperties": false }, + "downloadFile": { + "description": "Host supports file downloads via ui/download-file.", + "type": "object", + "properties": {}, + "additionalProperties": false + }, "serverTools": { "description": "Host can proxy tool calls to the MCP server.", "type": "object", @@ -2443,6 +2504,12 @@ "properties": {}, "additionalProperties": false }, + "downloadFile": { + "description": "Host supports file downloads via ui/download-file.", + "type": "object", + "properties": {}, + "additionalProperties": false + }, "serverTools": { "description": "Host can proxy tool calls to the MCP server.", "type": "object", diff --git a/src/generated/schema.test.ts b/src/generated/schema.test.ts index 95ec2f2..4e7c336 100644 --- a/src/generated/schema.test.ts +++ b/src/generated/schema.test.ts @@ -35,6 +35,14 @@ export type McpUiOpenLinkResultSchemaInferredType = z.infer< typeof generated.McpUiOpenLinkResultSchema >; +export type McpUiDownloadFileRequestSchemaInferredType = z.infer< + typeof generated.McpUiDownloadFileRequestSchema +>; + +export type McpUiDownloadFileResultSchemaInferredType = z.infer< + typeof generated.McpUiDownloadFileResultSchema +>; + export type McpUiMessageResultSchemaInferredType = z.infer< typeof generated.McpUiMessageResultSchema >; @@ -179,6 +187,18 @@ expectType( expectType( {} as spec.McpUiOpenLinkResult, ); +expectType( + {} as McpUiDownloadFileRequestSchemaInferredType, +); +expectType( + {} as spec.McpUiDownloadFileRequest, +); +expectType( + {} as McpUiDownloadFileResultSchemaInferredType, +); +expectType( + {} as spec.McpUiDownloadFileResult, +); expectType({} as McpUiMessageResultSchemaInferredType); expectType({} as spec.McpUiMessageResult); expectType( diff --git a/src/generated/schema.ts b/src/generated/schema.ts index 9c75c36..a0cd659 100644 --- a/src/generated/schema.ts +++ b/src/generated/schema.ts @@ -161,6 +161,57 @@ export const McpUiOpenLinkResultSchema = z }) .passthrough(); +/** + * @description Request to download a file through the host. + * + * Sent from the View to the Host when the app wants to trigger a file download. + * Since MCP Apps run in sandboxed iframes where direct downloads are blocked, + * this provides a host-mediated mechanism for file exports. + * The host SHOULD show a confirmation dialog before initiating the download. + * + * @see {@link app!App.downloadFile `App.downloadFile`} for the method that sends this request + */ +export const McpUiDownloadFileRequestSchema = z.object({ + method: z.literal("ui/download-file"), + params: z.object({ + /** @description Suggested filename for the download. */ + filename: z.string().describe("Suggested filename for the download."), + /** @description File content — text or base64-encoded binary. */ + content: z + .string() + .describe("File content \u2014 text or base64-encoded binary."), + /** @description MIME type of the file (e.g. "image/svg+xml", "application/json"). */ + mimeType: z + .string() + .describe( + 'MIME type of the file (e.g. "image/svg+xml", "application/json").', + ), + /** @description Content encoding. Defaults to "utf-8". Use "base64" for binary content. */ + encoding: z + .union([z.literal("utf-8"), z.literal("base64")]) + .optional() + .describe( + 'Content encoding. Defaults to "utf-8". Use "base64" for binary content.', + ), + }), +}); + +/** + * @description Result from a file download request. + * @see {@link McpUiDownloadFileRequest `McpUiDownloadFileRequest`} + */ +export const McpUiDownloadFileResultSchema = z + .object({ + /** @description True if the download failed (e.g., user cancelled or host denied). */ + isError: z + .boolean() + .optional() + .describe( + "True if the download failed (e.g., user cancelled or host denied).", + ), + }) + .passthrough(); + /** * @description Result from sending a message. * @see {@link McpUiMessageRequest `McpUiMessageRequest`} @@ -476,6 +527,11 @@ export const McpUiHostCapabilitiesSchema = z.object({ .object({}) .optional() .describe("Host supports opening external URLs."), + /** @description Host supports file downloads via ui/download-file. */ + downloadFile: z + .object({}) + .optional() + .describe("Host supports file downloads via ui/download-file."), /** @description Host can proxy tool calls to the MCP server. */ serverTools: z .object({ diff --git a/src/spec.types.ts b/src/spec.types.ts index 469ca19..a1fa81c 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -168,6 +168,44 @@ export interface McpUiOpenLinkResult { [key: string]: unknown; } +/** + * @description Request to download a file through the host. + * + * Sent from the View to the Host when the app wants to trigger a file download. + * Since MCP Apps run in sandboxed iframes where direct downloads are blocked, + * this provides a host-mediated mechanism for file exports. + * The host SHOULD show a confirmation dialog before initiating the download. + * + * @see {@link app!App.downloadFile `App.downloadFile`} for the method that sends this request + */ +export interface McpUiDownloadFileRequest { + method: "ui/download-file"; + params: { + /** @description Suggested filename for the download. */ + filename: string; + /** @description File content — text or base64-encoded binary. */ + content: string; + /** @description MIME type of the file (e.g. "image/svg+xml", "application/json"). */ + mimeType: string; + /** @description Content encoding. Defaults to "utf-8". Use "base64" for binary content. */ + encoding?: "utf-8" | "base64"; + }; +} + +/** + * @description Result from a file download request. + * @see {@link McpUiDownloadFileRequest `McpUiDownloadFileRequest`} + */ +export interface McpUiDownloadFileResult { + /** @description True if the download failed (e.g., user cancelled or host denied). */ + isError?: boolean; + /** + * Index signature required for MCP SDK `Protocol` class compatibility. + * Note: The generated schema uses passthrough() to allow additional properties. + */ + [key: string]: unknown; +} + /** * @description Request to send a message to the host's chat interface. * @see {@link app!App.sendMessage `App.sendMessage`} for the method that sends this request @@ -450,6 +488,8 @@ export interface McpUiHostCapabilities { experimental?: {}; /** @description Host supports opening external URLs. */ openLinks?: {}; + /** @description Host supports file downloads via ui/download-file. */ + downloadFile?: {}; /** @description Host can proxy tool calls to the MCP server. */ serverTools?: { /** @description Host supports tools/list_changed notifications. */ @@ -746,6 +786,8 @@ export interface McpUiToolMeta { * ``` */ export const OPEN_LINK_METHOD: McpUiOpenLinkRequest["method"] = "ui/open-link"; +export const DOWNLOAD_FILE_METHOD: McpUiDownloadFileRequest["method"] = + "ui/download-file"; export const MESSAGE_METHOD: McpUiMessageRequest["method"] = "ui/message"; export const SANDBOX_PROXY_READY_METHOD: McpUiSandboxProxyReadyNotification["method"] = "ui/notifications/sandbox-proxy-ready"; diff --git a/src/types.ts b/src/types.ts index a4770fd..739da6f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,7 @@ export { LATEST_PROTOCOL_VERSION, OPEN_LINK_METHOD, + DOWNLOAD_FILE_METHOD, MESSAGE_METHOD, SANDBOX_PROXY_READY_METHOD, SANDBOX_RESOURCE_READY_METHOD, @@ -34,6 +35,8 @@ export { type McpUiHostStyles, type McpUiOpenLinkRequest, type McpUiOpenLinkResult, + type McpUiDownloadFileRequest, + type McpUiDownloadFileResult, type McpUiMessageRequest, type McpUiMessageResult, type McpUiUpdateModelContextRequest, @@ -68,6 +71,7 @@ export { import type { McpUiInitializeRequest, McpUiOpenLinkRequest, + McpUiDownloadFileRequest, McpUiMessageRequest, McpUiUpdateModelContextRequest, McpUiResourceTeardownRequest, @@ -83,6 +87,7 @@ import type { McpUiSandboxProxyReadyNotification, McpUiInitializeResult, McpUiOpenLinkResult, + McpUiDownloadFileResult, McpUiMessageResult, McpUiResourceTeardownResult, McpUiRequestDisplayModeResult, @@ -96,6 +101,8 @@ export { McpUiHostStylesSchema, McpUiOpenLinkRequestSchema, McpUiOpenLinkResultSchema, + McpUiDownloadFileRequestSchema, + McpUiDownloadFileResultSchema, McpUiMessageRequestSchema, McpUiMessageResultSchema, McpUiUpdateModelContextRequestSchema, @@ -159,6 +166,7 @@ import { export type AppRequest = | McpUiInitializeRequest | McpUiOpenLinkRequest + | McpUiDownloadFileRequest | McpUiMessageRequest | McpUiUpdateModelContextRequest | McpUiResourceTeardownRequest @@ -207,6 +215,7 @@ export type AppNotification = export type AppResult = | McpUiInitializeResult | McpUiOpenLinkResult + | McpUiDownloadFileResult | McpUiMessageResult | McpUiResourceTeardownResult | McpUiRequestDisplayModeResult