Skip to content

Commit e4a9766

Browse files
authored
Merge pull request #475 from modelcontextprotocol/feat/download-file
feat: add ui/download-file method for host-mediated file downloads
2 parents 281a26b + 9340bd8 commit e4a9766

File tree

12 files changed

+645
-0
lines changed

12 files changed

+645
-0
lines changed

examples/integration-server/server.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const DIST_DIR = import.meta.filename.endsWith(".ts")
1616
? path.join(import.meta.dirname, "dist")
1717
: import.meta.dirname;
1818
const RESOURCE_URI = "ui://get-time/mcp-app.html";
19+
const SAMPLE_DOWNLOAD_URI = "resource:///sample-report.txt";
1920

2021
/**
2122
* Creates a new MCP server instance with tools and resources registered.
@@ -70,5 +71,28 @@ export function createServer(): McpServer {
7071
},
7172
);
7273

74+
// Sample downloadable resource — used to demo ResourceLink in ui/download-file
75+
server.resource(
76+
SAMPLE_DOWNLOAD_URI,
77+
SAMPLE_DOWNLOAD_URI,
78+
{
79+
mimeType: "text/plain",
80+
},
81+
async (): Promise<ReadResourceResult> => {
82+
const content = [
83+
"Integration Test Server — Sample Report",
84+
`Generated: ${new Date().toISOString()}`,
85+
"",
86+
"This file was downloaded via MCP ResourceLink.",
87+
"The host resolved it by calling resources/read on the server.",
88+
].join("\n");
89+
return {
90+
contents: [
91+
{ uri: SAMPLE_DOWNLOAD_URI, mimeType: "text/plain", text: content },
92+
],
93+
};
94+
},
95+
);
96+
7397
return server;
7498
}

examples/integration-server/src/mcp-app.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,45 @@ function GetTimeAppInner({
137137
log.info("Open link request", isError ? "rejected" : "accepted");
138138
}, [app, linkUrl]);
139139

140+
const canDownload = app.getHostCapabilities()?.downloadFile !== undefined;
141+
142+
const handleDownloadFile = useCallback(async () => {
143+
const sampleContent = JSON.stringify(
144+
{ time: serverTime, exported: new Date().toISOString() },
145+
null,
146+
2,
147+
);
148+
log.info("Requesting file download...");
149+
const { isError } = await app.downloadFile({
150+
contents: [
151+
{
152+
type: "resource",
153+
resource: {
154+
uri: "file:///export.json",
155+
mimeType: "application/json",
156+
text: sampleContent,
157+
},
158+
},
159+
],
160+
});
161+
log.info("Download", isError ? "rejected" : "accepted");
162+
}, [app, serverTime]);
163+
164+
const handleDownloadLink = useCallback(async () => {
165+
log.info("Requesting resource link download...");
166+
const { isError } = await app.downloadFile({
167+
contents: [
168+
{
169+
type: "resource_link",
170+
uri: "resource:///sample-report.txt",
171+
name: "sample-report.txt",
172+
mimeType: "text/plain",
173+
},
174+
],
175+
});
176+
log.info("Resource link download", isError ? "rejected" : "accepted");
177+
}, [app]);
178+
140179
return (
141180
<main
142181
className={styles.main}
@@ -182,6 +221,16 @@ function GetTimeAppInner({
182221
/>
183222
<button onClick={handleOpenLink}>Open Link</button>
184223
</div>
224+
225+
{canDownload && (
226+
<div className={styles.action}>
227+
<p>Download file via EmbeddedResource or ResourceLink</p>
228+
<div style={{ display: "flex", gap: "8px" }}>
229+
<button onClick={handleDownloadFile}>Embedded</button>
230+
<button onClick={handleDownloadLink}>Link</button>
231+
</div>
232+
</div>
233+
)}
185234
</main>
186235
);
187236
}

scripts/generate-schemas.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,10 @@ const JSON_SCHEMA_OUTPUT_FILE = join(GENERATED_DIR, "schema.json");
7070
const EXTERNAL_TYPE_SCHEMAS = [
7171
"ContentBlockSchema",
7272
"CallToolResultSchema",
73+
"EmbeddedResourceSchema",
7374
"ImplementationSchema",
7475
"RequestIdSchema",
76+
"ResourceLinkSchema",
7577
"ToolSchema",
7678
];
7779

specification/draft/apps.mdx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,8 @@ interface HostCapabilities {
648648
experimental?: {};
649649
/** Host supports opening external URLs. */
650650
openLinks?: {};
651+
/** Host supports file downloads via ui/download-file. */
652+
downloadFile?: {};
651653
/** Host can proxy tool calls to the MCP server. */
652654
serverTools?: {
653655
/** Host supports tools/list_changed notifications. */
@@ -1013,6 +1015,73 @@ MCP Apps introduces additional JSON-RPC methods for UI-specific functionality:
10131015

10141016
Host SHOULD open the URL in the user's default browser or a new tab.
10151017

1018+
`ui/download-file` - Request host to download a file
1019+
1020+
```typescript
1021+
// Request (EmbeddedResource — inline content)
1022+
{
1023+
jsonrpc: "2.0",
1024+
id: 1,
1025+
method: "ui/download-file",
1026+
params: {
1027+
contents: [
1028+
{
1029+
type: "resource",
1030+
resource: {
1031+
uri: "file:///export.json", // Used for suggested filename
1032+
mimeType: "application/json",
1033+
text: "{ ... }" // Text content (or `blob` for base64 binary)
1034+
}
1035+
}
1036+
]
1037+
}
1038+
}
1039+
1040+
// Request (ResourceLink — host fetches)
1041+
{
1042+
jsonrpc: "2.0",
1043+
id: 1,
1044+
method: "ui/download-file",
1045+
params: {
1046+
contents: [
1047+
{
1048+
type: "resource_link",
1049+
uri: "https://api.example.com/reports/q4.pdf",
1050+
name: "Q4 Report",
1051+
mimeType: "application/pdf"
1052+
}
1053+
]
1054+
}
1055+
}
1056+
1057+
// Success Response
1058+
{
1059+
jsonrpc: "2.0",
1060+
id: 1,
1061+
result: {} // Empty result on success
1062+
}
1063+
1064+
// Error Response (if denied or failed)
1065+
{
1066+
jsonrpc: "2.0",
1067+
id: 1,
1068+
error: {
1069+
code: -32000, // Implementation-defined error
1070+
message: "Download denied by user" | "Invalid content" | "Policy violation"
1071+
}
1072+
}
1073+
```
1074+
1075+
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.
1076+
1077+
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.
1078+
1079+
Host behavior:
1080+
* Host SHOULD show a confirmation dialog before initiating the download.
1081+
* For `EmbeddedResource`, host SHOULD derive the filename from the last segment of `resource.uri`.
1082+
* Host MAY reject the download based on security policy, file size limits, or user preferences.
1083+
* Host SHOULD sanitize filenames to prevent path traversal.
1084+
10161085
`ui/message` - Send message content to the host's chat interface
10171086

10181087
```typescript

src/app-bridge.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,36 @@ describe("App <-> AppBridge integration", () => {
712712
expect(result.content).toEqual(resultContent);
713713
});
714714

715+
it("ondownloadfile setter registers handler for ui/download-file requests", async () => {
716+
const downloadParams = {
717+
contents: [
718+
{
719+
type: "resource" as const,
720+
resource: {
721+
uri: "file:///export.json",
722+
mimeType: "application/json",
723+
text: '{"key":"value"}',
724+
},
725+
},
726+
],
727+
};
728+
const receivedRequests: unknown[] = [];
729+
730+
bridge.ondownloadfile = async (params) => {
731+
receivedRequests.push(params);
732+
return {};
733+
};
734+
735+
await bridge.connect(bridgeTransport);
736+
await app.connect(appTransport);
737+
738+
const result = await app.downloadFile(downloadParams);
739+
740+
expect(receivedRequests).toHaveLength(1);
741+
expect(receivedRequests[0]).toMatchObject(downloadParams);
742+
expect(result).toEqual({});
743+
});
744+
715745
it("callServerTool throws a helpful error when called with a string instead of params object", async () => {
716746
await bridge.connect(bridgeTransport);
717747
await app.connect(appTransport);

src/app-bridge.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ import {
6969
McpUiOpenLinkRequest,
7070
McpUiOpenLinkRequestSchema,
7171
McpUiOpenLinkResult,
72+
McpUiDownloadFileRequest,
73+
McpUiDownloadFileRequestSchema,
74+
McpUiDownloadFileResult,
7275
McpUiResourceTeardownRequest,
7376
McpUiResourceTeardownResultSchema,
7477
McpUiSandboxProxyReadyNotification,
@@ -614,6 +617,62 @@ export class AppBridge extends Protocol<
614617
);
615618
}
616619

620+
/**
621+
* Register a handler for file download requests from the View.
622+
*
623+
* The View sends `ui/download-file` requests when the user wants to
624+
* download a file. The params contain an array of MCP resource content
625+
* items — either `EmbeddedResource` (inline data) or `ResourceLink`
626+
* (URI the host can fetch). The host should show a confirmation dialog
627+
* and then trigger the download.
628+
*
629+
* @param callback - Handler that receives download params and returns a result
630+
* - `params.contents` - Array of `EmbeddedResource` or `ResourceLink` items
631+
* - `extra` - Request metadata (abort signal, session info)
632+
* - Returns: `Promise<McpUiDownloadFileResult>` with optional `isError` flag
633+
*
634+
* @example
635+
* ```ts
636+
* bridge.ondownloadfile = async ({ contents }, extra) => {
637+
* for (const item of contents) {
638+
* if (item.type === "resource") {
639+
* // EmbeddedResource — inline content
640+
* const res = item.resource;
641+
* const blob = res.blob
642+
* ? new Blob([Uint8Array.from(atob(res.blob), c => c.charCodeAt(0))], { type: res.mimeType })
643+
* : new Blob([res.text ?? ""], { type: res.mimeType });
644+
* const url = URL.createObjectURL(blob);
645+
* const link = document.createElement("a");
646+
* link.href = url;
647+
* link.download = res.uri.split("/").pop() ?? "download";
648+
* link.click();
649+
* URL.revokeObjectURL(url);
650+
* } else if (item.type === "resource_link") {
651+
* // ResourceLink — host fetches or opens directly
652+
* window.open(item.uri, "_blank");
653+
* }
654+
* }
655+
* return {};
656+
* };
657+
* ```
658+
*
659+
* @see {@link McpUiDownloadFileRequest `McpUiDownloadFileRequest`} for the request type
660+
* @see {@link McpUiDownloadFileResult `McpUiDownloadFileResult`} for the result type
661+
*/
662+
set ondownloadfile(
663+
callback: (
664+
params: McpUiDownloadFileRequest["params"],
665+
extra: RequestHandlerExtra,
666+
) => Promise<McpUiDownloadFileResult>,
667+
) {
668+
this.setRequestHandler(
669+
McpUiDownloadFileRequestSchema,
670+
async (request, extra) => {
671+
return callback(request.params, extra);
672+
},
673+
);
674+
}
675+
617676
/**
618677
* Register a handler for display mode change requests from the view.
619678
*

src/app.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import {
3333
McpUiMessageResultSchema,
3434
McpUiOpenLinkRequest,
3535
McpUiOpenLinkResultSchema,
36+
McpUiDownloadFileRequest,
37+
McpUiDownloadFileResultSchema,
3638
McpUiResourceTeardownRequest,
3739
McpUiResourceTeardownRequestSchema,
3840
McpUiResourceTeardownResult,
@@ -930,6 +932,83 @@ export class App extends Protocol<AppRequest, AppNotification, AppResult> {
930932
/** @deprecated Use {@link openLink `openLink`} instead */
931933
sendOpenLink: App["openLink"] = this.openLink;
932934

935+
/**
936+
* Request the host to download a file.
937+
*
938+
* Since MCP Apps run in sandboxed iframes where direct downloads are blocked,
939+
* this provides a host-mediated mechanism for file exports. The host will
940+
* typically show a confirmation dialog before initiating the download.
941+
*
942+
* Uses standard MCP resource types: `EmbeddedResource` for inline content
943+
* and `ResourceLink` for content the host can fetch directly.
944+
*
945+
* @param params - Resource contents to download
946+
* @param options - Request options (timeout, etc.)
947+
* @returns Result with `isError: true` if the host denied the request (e.g., user cancelled)
948+
*
949+
* @throws {Error} If the request times out or the connection is lost
950+
*
951+
* @example Download a JSON file (embedded text resource)
952+
* ```ts
953+
* const data = JSON.stringify({ items: selectedItems }, null, 2);
954+
* const { isError } = await app.downloadFile({
955+
* contents: [{
956+
* type: "resource",
957+
* resource: {
958+
* uri: "file:///export.json",
959+
* mimeType: "application/json",
960+
* text: data,
961+
* },
962+
* }],
963+
* });
964+
* if (isError) {
965+
* console.warn("Download denied or cancelled");
966+
* }
967+
* ```
968+
*
969+
* @example Download binary content (embedded blob resource)
970+
* ```ts
971+
* const { isError } = await app.downloadFile({
972+
* contents: [{
973+
* type: "resource",
974+
* resource: {
975+
* uri: "file:///image.png",
976+
* mimeType: "image/png",
977+
* blob: base64EncodedPng,
978+
* },
979+
* }],
980+
* });
981+
* ```
982+
*
983+
* @example Download via resource link (host fetches)
984+
* ```ts
985+
* const { isError } = await app.downloadFile({
986+
* contents: [{
987+
* type: "resource_link",
988+
* uri: "https://api.example.com/reports/q4.pdf",
989+
* name: "Q4 Report",
990+
* mimeType: "application/pdf",
991+
* }],
992+
* });
993+
* ```
994+
*
995+
* @see {@link McpUiDownloadFileRequest `McpUiDownloadFileRequest`} for request structure
996+
* @see {@link McpUiDownloadFileResult `McpUiDownloadFileResult`} for result structure
997+
*/
998+
downloadFile(
999+
params: McpUiDownloadFileRequest["params"],
1000+
options?: RequestOptions,
1001+
) {
1002+
return this.request(
1003+
<McpUiDownloadFileRequest>{
1004+
method: "ui/download-file",
1005+
params,
1006+
},
1007+
McpUiDownloadFileResultSchema,
1008+
options,
1009+
);
1010+
}
1011+
9331012
/**
9341013
* Request a change to the display mode.
9351014
*

0 commit comments

Comments
 (0)