From 375727164117800d2e1975a4a4c9259a28f14f21 Mon Sep 17 00:00:00 2001 From: Anton Pidkuiko MacBook Date: Fri, 20 Feb 2026 12:00:35 +0000 Subject: [PATCH] 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 a44c007..e44689d 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 8e4910a..7f0b18c 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 513b2bf..858ab91 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 d12ba12..b5fbb62 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 de7d876..793782b 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 ebf7e81..8e133d6 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({ - * filename: "image.png", - * content: base64EncodedPng, - * mimeType: "image/png", - * encoding: "base64", + * 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({ + * 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 9cc87ae..08e229a 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 4e7c336..47e5978 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 a0cd659..e6ff4ba 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 a1fa81c..20987d3 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)[]; }; }