From 494c73e7ca41ec6b7504f6861f78bd4bb659321a Mon Sep 17 00:00:00 2001 From: Anton Pidkuiko MacBook Date: Thu, 19 Feb 2026 21:25:48 +0000 Subject: [PATCH 1/2] feat: add ui/download-file method for host-mediated file downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new ui/download-file JSON-RPC request that allows MCP App views to request file downloads through the host. Since MCP Apps run in sandboxed iframes where direct downloads are blocked (allow-downloads not set), this provides a host-mediated mechanism for file exports. Spec changes (draft): - Add ui/download-file request to Requests (View → Host) section - Add downloadFile capability to HostCapabilities - Document host behavior requirements (confirmation dialog, filename sanitization, base64 decoding, size limits) SDK changes: - Add McpUiDownloadFileRequest/Result interfaces to spec.types.ts - Add downloadFile() method to App class - Add ondownloadfile setter to AppBridge class - Add DOWNLOAD_FILE_METHOD constant - Generate Zod schemas Example: - Add download demo to integration-server (exports JSON with server time) - Capability check: only shows download button when host advertises downloadFile capability Based on developer feedback requesting file export capabilities for visualization tools, document editors, and data analysis apps. --- examples/integration-server/src/mcp-app.tsx | 25 ++++++++ specification/draft/apps.mdx | 45 ++++++++++++++ src/app-bridge.ts | 57 ++++++++++++++++++ src/app.ts | 55 +++++++++++++++++ src/generated/schema.json | 67 +++++++++++++++++++++ src/generated/schema.test.ts | 20 ++++++ src/generated/schema.ts | 56 +++++++++++++++++ src/spec.types.ts | 42 +++++++++++++ src/types.ts | 9 +++ 9 files changed, 376 insertions(+) diff --git a/examples/integration-server/src/mcp-app.tsx b/examples/integration-server/src/mcp-app.tsx index ef280f3a7..8e4910a7d 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 cb73cabe1..d12ba12c0 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 6e1c02172..de7d87664 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 16f8da67f..ebf7e8123 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 d9e4b582c..9cc87ae5a 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 95ec2f216..4e7c336ec 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 9c75c3632..a0cd659ef 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 469ca1908..a1fa81cd4 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 a4770fdaf..739da6faf 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 From 375727164117800d2e1975a4a4c9259a28f14f21 Mon Sep 17 00:00:00 2001 From: Anton Pidkuiko MacBook Date: Fri, 20 Feb 2026 12:00:35 +0000 Subject: [PATCH 2/2] refactor: use MCP resource types for ui/download-file params Replace custom {filename, content, mimeType, encoding} params with standard MCP resource types: EmbeddedResource for inline content and ResourceLink for host-resolved references. New params shape: params: { contents: (EmbeddedResource | ResourceLink)[] } This reuses existing MCP primitives instead of inventing new ones, and enables hosts to show resource 'bubbles' or resolve ResourceLink URIs via resources/read on the MCP server. --- examples/integration-server/server.ts | 24 +++ examples/integration-server/src/mcp-app.tsx | 38 +++- scripts/generate-schemas.ts | 2 + specification/draft/apps.mdx | 42 +++- src/app-bridge.ts | 46 ++--- src/app.ts | 44 ++++- src/generated/schema.json | 204 +++++++++++++++++--- src/generated/schema.test.ts | 20 +- src/generated/schema.ts | 59 +++--- src/spec.types.ts | 12 +- 10 files changed, 366 insertions(+), 125 deletions(-) diff --git a/examples/integration-server/server.ts b/examples/integration-server/server.ts index a44c007a2..e44689d24 100644 --- a/examples/integration-server/server.ts +++ b/examples/integration-server/server.ts @@ -16,6 +16,7 @@ const DIST_DIR = import.meta.filename.endsWith(".ts") ? path.join(import.meta.dirname, "dist") : import.meta.dirname; const RESOURCE_URI = "ui://get-time/mcp-app.html"; +const SAMPLE_DOWNLOAD_URI = "resource:///sample-report.txt"; /** * Creates a new MCP server instance with tools and resources registered. @@ -70,5 +71,28 @@ export function createServer(): McpServer { }, ); + // Sample downloadable resource — used to demo ResourceLink in ui/download-file + server.resource( + SAMPLE_DOWNLOAD_URI, + SAMPLE_DOWNLOAD_URI, + { + mimeType: "text/plain", + }, + async (): Promise => { + const content = [ + "Integration Test Server — Sample Report", + `Generated: ${new Date().toISOString()}`, + "", + "This file was downloaded via MCP ResourceLink.", + "The host resolved it by calling resources/read on the server.", + ].join("\n"); + return { + contents: [ + { uri: SAMPLE_DOWNLOAD_URI, mimeType: "text/plain", text: content }, + ], + }; + }, + ); + return server; } diff --git a/examples/integration-server/src/mcp-app.tsx b/examples/integration-server/src/mcp-app.tsx index 8e4910a7d..7f0b18c27 100644 --- a/examples/integration-server/src/mcp-app.tsx +++ b/examples/integration-server/src/mcp-app.tsx @@ -137,8 +137,7 @@ function GetTimeAppInner({ log.info("Open link request", isError ? "rejected" : "accepted"); }, [app, linkUrl]); - const canDownload = - app.getHostCapabilities()?.downloadFile !== undefined; + const canDownload = app.getHostCapabilities()?.downloadFile !== undefined; const handleDownloadFile = useCallback(async () => { const sampleContent = JSON.stringify( @@ -148,13 +147,35 @@ function GetTimeAppInner({ ); log.info("Requesting file download..."); const { isError } = await app.downloadFile({ - filename: "export.json", - content: sampleContent, - mimeType: "application/json", + contents: [ + { + type: "resource", + resource: { + uri: "file:///export.json", + mimeType: "application/json", + text: sampleContent, + }, + }, + ], }); log.info("Download", isError ? "rejected" : "accepted"); }, [app, serverTime]); + const handleDownloadLink = useCallback(async () => { + log.info("Requesting resource link download..."); + const { isError } = await app.downloadFile({ + contents: [ + { + type: "resource_link", + uri: "resource:///sample-report.txt", + name: "sample-report.txt", + mimeType: "text/plain", + }, + ], + }); + log.info("Resource link download", isError ? "rejected" : "accepted"); + }, [app]); + return (
-

Export current server time as JSON file

- +

Download file via EmbeddedResource or ResourceLink

+
+ + +
)}
diff --git a/scripts/generate-schemas.ts b/scripts/generate-schemas.ts index 513b2bf07..858ab9145 100644 --- a/scripts/generate-schemas.ts +++ b/scripts/generate-schemas.ts @@ -70,8 +70,10 @@ const JSON_SCHEMA_OUTPUT_FILE = join(GENERATED_DIR, "schema.json"); const EXTERNAL_TYPE_SCHEMAS = [ "ContentBlockSchema", "CallToolResultSchema", + "EmbeddedResourceSchema", "ImplementationSchema", "RequestIdSchema", + "ResourceLinkSchema", "ToolSchema", ]; diff --git a/specification/draft/apps.mdx b/specification/draft/apps.mdx index d12ba12c0..b5fbb6208 100644 --- a/specification/draft/apps.mdx +++ b/specification/draft/apps.mdx @@ -1018,16 +1018,39 @@ 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 +// Request (EmbeddedResource — inline content) { 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" + contents: [ + { + type: "resource", + resource: { + uri: "file:///export.json", // Used for suggested filename + mimeType: "application/json", + text: "{ ... }" // Text content (or `blob` for base64 binary) + } + } + ] + } +} + +// Request (ResourceLink — host fetches) +{ + jsonrpc: "2.0", + id: 1, + method: "ui/download-file", + params: { + contents: [ + { + type: "resource_link", + uri: "https://api.example.com/reports/q4.pdf", + name: "Q4 Report", + mimeType: "application/pdf" + } + ] } } @@ -1051,12 +1074,15 @@ Host SHOULD open the URL in the user's default browser or a new tab. 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. +The `contents` array uses standard MCP resource types (`EmbeddedResource` and `ResourceLink`), avoiding custom content formats. For `EmbeddedResource`, content is inline via `text` (UTF-8) or `blob` (base64). For `ResourceLink`, the host can retrieve the content directly from the URI. + 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. +* For `EmbeddedResource`, host SHOULD derive the filename from the last segment of `resource.uri`. +* For `EmbeddedResource` with `blob`, host MUST decode the content from base64 before creating the file. +* For `ResourceLink`, host MAY fetch the resource on behalf of the app or open the URI directly. * Host MAY reject the download based on security policy, file size limits, or user preferences. -* Host SHOULD sanitize the filename to prevent path traversal. +* Host SHOULD sanitize filenames to prevent path traversal. `ui/message` - Send message content to the host's chat interface diff --git a/src/app-bridge.ts b/src/app-bridge.ts index de7d87664..793782be2 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -621,35 +621,37 @@ 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. + * download a file. The params contain an array of MCP resource content + * items — either `EmbeddedResource` (inline data) or `ResourceLink` + * (URI the host can fetch). 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") + * - `params.contents` - Array of `EmbeddedResource` or `ResourceLink` items * - `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); + * bridge.ondownloadfile = async ({ contents }, extra) => { + * for (const item of contents) { + * if (item.type === "resource") { + * // EmbeddedResource — inline content + * const res = item.resource; + * const blob = res.blob + * ? new Blob([Uint8Array.from(atob(res.blob), c => c.charCodeAt(0))], { type: res.mimeType }) + * : new Blob([res.text ?? ""], { type: res.mimeType }); + * const url = URL.createObjectURL(blob); + * const link = document.createElement("a"); + * link.href = url; + * link.download = res.uri.split("/").pop() ?? "download"; + * link.click(); + * URL.revokeObjectURL(url); + * } else if (item.type === "resource_link") { + * // ResourceLink — host fetches or opens directly + * window.open(item.uri, "_blank"); + * } + * } * return {}; * }; * ``` diff --git a/src/app.ts b/src/app.ts index ebf7e8123..8e133d64c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -939,32 +939,56 @@ export class App extends Protocol { * 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 + * Uses standard MCP resource types: `EmbeddedResource` for inline content + * and `ResourceLink` for content the host can fetch directly. + * + * @param params - Resource contents to download * @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 + * @example Download a JSON file (embedded text resource) * ```ts * const data = JSON.stringify({ items: selectedItems }, null, 2); * const { isError } = await app.downloadFile({ - * filename: "export.json", - * content: data, - * mimeType: "application/json", + * contents: [{ + * type: "resource", + * resource: { + * uri: "file:///export.json", + * mimeType: "application/json", + * text: data, + * }, + * }], * }); * if (isError) { * console.warn("Download denied or cancelled"); * } * ``` * - * @example Download binary content (base64) + * @example Download binary content (embedded blob resource) + * ```ts + * const { isError } = await app.downloadFile({ + * contents: [{ + * type: "resource", + * resource: { + * uri: "file:///image.png", + * mimeType: "image/png", + * blob: base64EncodedPng, + * }, + * }], + * }); + * ``` + * + * @example Download via resource link (host fetches) * ```ts * const { isError } = await app.downloadFile({ - * filename: "image.png", - * content: base64EncodedPng, - * mimeType: "image/png", - * encoding: "base64", + * contents: [{ + * type: "resource_link", + * uri: "https://api.example.com/reports/q4.pdf", + * name: "Q4 Report", + * mimeType: "application/pdf", + * }], * }); * ``` * diff --git a/src/generated/schema.json b/src/generated/schema.json index 9cc87ae5a..08e229a79 100644 --- a/src/generated/schema.json +++ b/src/generated/schema.json @@ -92,33 +92,187 @@ "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" - } - ] + "contents": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource" + }, + "resource": { + "anyOf": [ + { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "_meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "text": { + "type": "string" + } + }, + "required": ["uri", "text"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "_meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "blob": { + "type": "string" + } + }, + "required": ["uri", "blob"], + "additionalProperties": false + } + ] + }, + "annotations": { + "type": "object", + "properties": { + "audience": { + "type": "array", + "items": { + "type": "string", + "enum": ["user", "assistant"] + } + }, + "priority": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "lastModified": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + } + }, + "additionalProperties": false + }, + "_meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["type", "resource"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "title": { + "type": "string" + }, + "icons": { + "type": "array", + "items": { + "type": "object", + "properties": { + "src": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "sizes": { + "type": "array", + "items": { + "type": "string" + } + }, + "theme": { + "type": "string", + "enum": ["light", "dark"] + } + }, + "required": ["src"], + "additionalProperties": false + } + }, + "uri": { + "type": "string" + }, + "description": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "annotations": { + "type": "object", + "properties": { + "audience": { + "type": "array", + "items": { + "type": "string", + "enum": ["user", "assistant"] + } + }, + "priority": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "lastModified": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + } + }, + "additionalProperties": false + }, + "_meta": { + "type": "object", + "properties": {}, + "additionalProperties": {} + }, + "type": { + "type": "string", + "const": "resource_link" + } + }, + "required": ["name", "uri", "type"], + "additionalProperties": false + } + ] + }, + "description": "Resource contents to download — embedded (inline data) or linked (host fetches). Uses standard MCP resource types." } }, - "required": ["filename", "content", "mimeType"], + "required": ["contents"], "additionalProperties": false } }, diff --git a/src/generated/schema.test.ts b/src/generated/schema.test.ts index 4e7c336ec..47e59788b 100644 --- a/src/generated/schema.test.ts +++ b/src/generated/schema.test.ts @@ -35,10 +35,6 @@ export type McpUiOpenLinkResultSchemaInferredType = z.infer< typeof generated.McpUiOpenLinkResultSchema >; -export type McpUiDownloadFileRequestSchemaInferredType = z.infer< - typeof generated.McpUiDownloadFileRequestSchema ->; - export type McpUiDownloadFileResultSchemaInferredType = z.infer< typeof generated.McpUiDownloadFileResultSchema >; @@ -131,6 +127,10 @@ export type McpUiClientCapabilitiesSchemaInferredType = z.infer< typeof generated.McpUiClientCapabilitiesSchema >; +export type McpUiDownloadFileRequestSchemaInferredType = z.infer< + typeof generated.McpUiDownloadFileRequestSchema +>; + export type McpUiMessageRequestSchemaInferredType = z.infer< typeof generated.McpUiMessageRequestSchema >; @@ -187,12 +187,6 @@ expectType( expectType( {} as spec.McpUiOpenLinkResult, ); -expectType( - {} as McpUiDownloadFileRequestSchemaInferredType, -); -expectType( - {} as spec.McpUiDownloadFileRequest, -); expectType( {} as McpUiDownloadFileResultSchemaInferredType, ); @@ -307,6 +301,12 @@ expectType( expectType( {} as spec.McpUiClientCapabilities, ); +expectType( + {} as McpUiDownloadFileRequestSchemaInferredType, +); +expectType( + {} as spec.McpUiDownloadFileRequest, +); expectType( {} as McpUiMessageRequestSchemaInferredType, ); diff --git a/src/generated/schema.ts b/src/generated/schema.ts index a0cd659ef..e6ff4ba4c 100644 --- a/src/generated/schema.ts +++ b/src/generated/schema.ts @@ -5,8 +5,10 @@ import { z } from "zod"; import { ContentBlockSchema, CallToolResultSchema, + EmbeddedResourceSchema, ImplementationSchema, RequestIdSchema, + ResourceLinkSchema, ToolSchema, } from "@modelcontextprotocol/sdk/types.js"; @@ -161,41 +163,6 @@ 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`} @@ -755,6 +722,28 @@ export const McpUiClientCapabilitiesSchema = z.object({ ), }); +/** + * @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 Resource contents to download — embedded (inline data) or linked (host fetches). Uses standard MCP resource types. */ + contents: z + .array(z.union([EmbeddedResourceSchema, ResourceLinkSchema])) + .describe( + "Resource contents to download \u2014 embedded (inline data) or linked (host fetches). Uses standard MCP resource types.", + ), + }), +}); + /** * @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 diff --git a/src/spec.types.ts b/src/spec.types.ts index a1fa81cd4..20987d3b9 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -13,8 +13,10 @@ import type { CallToolResult, ContentBlock, + EmbeddedResource, Implementation, RequestId, + ResourceLink, Tool, } from "@modelcontextprotocol/sdk/types.js"; @@ -181,14 +183,8 @@ export interface McpUiOpenLinkResult { 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 Resource contents to download — embedded (inline data) or linked (host fetches). Uses standard MCP resource types. */ + contents: (EmbeddedResource | ResourceLink)[]; }; }