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.
This commit is contained in:
Anton Pidkuiko MacBook 2026-02-20 12:00:35 +00:00
commit 3757271641
No known key found for this signature in database
10 changed files with 366 additions and 125 deletions

View file

@ -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<ReadResourceResult> => {
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;
}

View file

@ -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 (
<main
className={styles.main}
@ -203,8 +224,11 @@ function GetTimeAppInner({
{canDownload && (
<div className={styles.action}>
<p>Export current server time as JSON file</p>
<button onClick={handleDownloadFile}>Download File</button>
<p>Download file via EmbeddedResource or ResourceLink</p>
<div style={{ display: "flex", gap: "8px" }}>
<button onClick={handleDownloadFile}>Embedded</button>
<button onClick={handleDownloadLink}>Link</button>
</div>
</div>
)}
</main>

View file

@ -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",
];

View file

@ -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

View file

@ -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<McpUiDownloadFileResult>` 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 {};
* };
* ```

View file

@ -939,32 +939,56 @@ export class App extends Protocol<AppRequest, AppNotification, AppResult> {
* 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",
* }],
* });
* ```
*

View file

@ -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
}
},

View file

@ -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<spec.McpUiOpenLinkResult>(
expectType<McpUiOpenLinkResultSchemaInferredType>(
{} as spec.McpUiOpenLinkResult,
);
expectType<spec.McpUiDownloadFileRequest>(
{} as McpUiDownloadFileRequestSchemaInferredType,
);
expectType<McpUiDownloadFileRequestSchemaInferredType>(
{} as spec.McpUiDownloadFileRequest,
);
expectType<spec.McpUiDownloadFileResult>(
{} as McpUiDownloadFileResultSchemaInferredType,
);
@ -307,6 +301,12 @@ expectType<spec.McpUiClientCapabilities>(
expectType<McpUiClientCapabilitiesSchemaInferredType>(
{} as spec.McpUiClientCapabilities,
);
expectType<spec.McpUiDownloadFileRequest>(
{} as McpUiDownloadFileRequestSchemaInferredType,
);
expectType<McpUiDownloadFileRequestSchemaInferredType>(
{} as spec.McpUiDownloadFileRequest,
);
expectType<spec.McpUiMessageRequest>(
{} as McpUiMessageRequestSchemaInferredType,
);

View file

@ -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

View file

@ -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)[];
};
}