Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions examples/integration-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
49 changes: 49 additions & 0 deletions examples/integration-server/src/mcp-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,45 @@ 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({
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}
Expand Down Expand Up @@ -182,6 +221,16 @@ function GetTimeAppInner({
/>
<button onClick={handleOpenLink}>Open Link</button>
</div>

{canDownload && (
<div className={styles.action}>
<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>
);
}
Expand Down
2 changes: 2 additions & 0 deletions scripts/generate-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
];

Expand Down
71 changes: 71 additions & 0 deletions specification/draft/apps.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -1013,6 +1015,75 @@ 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 (EmbeddedResource — inline content)
{
jsonrpc: "2.0",
id: 1,
method: "ui/download-file",
params: {
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"
}
]
}
}

// 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.

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.
* 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 filenames to prevent path traversal.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is no longer relevant in the resources world


`ui/message` - Send message content to the host's chat interface

```typescript
Expand Down
59 changes: 59 additions & 0 deletions src/app-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ import {
McpUiOpenLinkRequest,
McpUiOpenLinkRequestSchema,
McpUiOpenLinkResult,
McpUiDownloadFileRequest,
McpUiDownloadFileRequestSchema,
McpUiDownloadFileResult,
McpUiResourceTeardownRequest,
McpUiResourceTeardownResultSchema,
McpUiSandboxProxyReadyNotification,
Expand Down Expand Up @@ -614,6 +617,62 @@ 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 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.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 ({ 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 {};
* };
* ```
*
* @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<McpUiDownloadFileResult>,
) {
this.setRequestHandler(
McpUiDownloadFileRequestSchema,
async (request, extra) => {
return callback(request.params, extra);
},
);
}

/**
* Register a handler for display mode change requests from the view.
*
Expand Down
79 changes: 79 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import {
McpUiMessageResultSchema,
McpUiOpenLinkRequest,
McpUiOpenLinkResultSchema,
McpUiDownloadFileRequest,
McpUiDownloadFileResultSchema,
McpUiResourceTeardownRequest,
McpUiResourceTeardownRequestSchema,
McpUiResourceTeardownResult,
Expand Down Expand Up @@ -930,6 +932,83 @@ export class App extends Protocol<AppRequest, AppNotification, AppResult> {
/** @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.
*
* 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 file (embedded text resource)
* ```ts
* const data = JSON.stringify({ items: selectedItems }, null, 2);
* const { isError } = await app.downloadFile({
* 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 (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({
* contents: [{
* type: "resource_link",
* uri: "https://api.example.com/reports/q4.pdf",
* name: "Q4 Report",
* mimeType: "application/pdf",
* }],
* });
* ```
*
* @see {@link McpUiDownloadFileRequest `McpUiDownloadFileRequest`} for request structure
* @see {@link McpUiDownloadFileResult `McpUiDownloadFileResult`} for result structure
*/
downloadFile(
params: McpUiDownloadFileRequest["params"],
options?: RequestOptions,
) {
return this.request(
<McpUiDownloadFileRequest>{
method: "ui/download-file",
params,
},
McpUiDownloadFileResultSchema,
options,
);
}

/**
* Request a change to the display mode.
*
Expand Down
Loading
Loading