Skip to content

feat: browser_file_download #209

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 14 commits into from
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,13 @@ http.createServer(async (req, res) => {

<!-- NOTE: This has been generated via update-readme.js -->

- **browser_file_download**
- Description: Accept file downloads. Only use this if there is a download modal visible.
- Parameters:
- `filenames` (array): The filenames to accept. All other files will be canceled.

<!-- NOTE: This has been generated via update-readme.js -->

- **browser_pdf_save**
- Description: Save page as PDF
- Parameters: None
Expand Down
19 changes: 18 additions & 1 deletion src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ export class Context {
this._modalStates = this._modalStates.filter(state => state !== modalState);
}

hasModalState(type: ModalState['type']) {
return this._modalStates.some(state => state.type === type);
}

modalStatesMarkdown(): string[] {
const result: string[] = ['### Modal state'];
for (const state of this._modalStates) {
Expand Down Expand Up @@ -340,6 +344,13 @@ export class Tab {
fileChooser: chooser,
}, this);
});
page.on('download', download => {
this.context.setModalState({
Copy link
Member

Choose a reason for hiding this comment

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

I am not sure this is a modal state we'll be requiring to clear. Sounds to me like downloads is more of resources, so maybe just tell user once that download took place and teach it to list the downloads as a tool?

I.e. let's just save it here!

type: 'download',
description: `Download (${download.suggestedFilename()})`,
download,
}, this);
});
page.on('dialog', dialog => this.context.dialogShown(this, dialog));
page.setDefaultNavigationTimeout(60000);
page.setDefaultTimeout(5000);
Expand All @@ -356,7 +367,13 @@ export class Tab {
}

async navigate(url: string) {
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
try {
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
} catch (error) {
if (error instanceof Error && error.message.includes('net::ERR_ABORTED') && this.context.hasModalState('download'))
Copy link
Member

Choose a reason for hiding this comment

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

That's be a very Chrome-centric view on the matter.

return;
}

// Cap load event to 5 seconds, the page is operational at this point.
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
}
Expand Down
60 changes: 58 additions & 2 deletions src/tools/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@
*/

import { z } from 'zod';
import { defineTool, type ToolFactory } from './tool';
import os from 'os';
import path from 'path';

const uploadFile: ToolFactory = captureSnapshot => defineTool({
import { defineTool, DownloadModalState, type ToolFactory } from './tool';
import { sanitizeForFilePath } from './utils';

const uploadFile: ToolFactory = captureSnapshot => ({
capability: 'files',

schema: {
Expand Down Expand Up @@ -52,6 +56,58 @@ const uploadFile: ToolFactory = captureSnapshot => defineTool({
clearsModalState: 'fileChooser',
});

const downloadFile = defineTool({
capability: 'files',

schema: {
name: 'browser_file_download',
description: 'Accept file downloads. Only use this if there is a download modal visible.',
inputSchema: z.object({
filenames: z.array(z.string()).describe('The filenames to accept. All other files will be canceled.'),
}),
},

handle: async (context, params) => {
const modals = context.modalStates().filter(state => state.type === 'download');
if (!modals.length)
throw new Error('No download modal visible');

const accepted = new Set<DownloadModalState>();
for (const filename of params.filenames) {
const download = modals.find(modal => modal.download.suggestedFilename() === filename);
if (!download)
throw new Error(`No download modal visible for file ${filename}`);
accepted.add(download);
}

return {
code: [`// <internal code to accept and cancel files>`],
action: async () => {
const text: string[] = [];
await Promise.all(modals.map(async modal => {
context.clearModalState(modal);

if (!accepted.has(modal))
return modal.download.cancel();

const filePath = path.join(os.tmpdir(), sanitizeForFilePath(`download-${new Date().toISOString()}`), modal.download.suggestedFilename());
try {
await modal.download.saveAs(filePath);
text.push(`Downloaded ${modal.download.suggestedFilename()} to ${filePath}`);
} catch {
text.push(`Failed to download ${modal.download.suggestedFilename()}`);
}
}));
return { content: [{ type: 'text', text: text.join('\n') }] };
},
captureSnapshot: false,
waitForNetwork: true,
};
},
clearsModalState: 'download',
});

export default (captureSnapshot: boolean) => [
uploadFile(captureSnapshot),
downloadFile,
];
8 changes: 7 additions & 1 deletion src/tools/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,19 @@ export type FileUploadModalState = {
fileChooser: playwright.FileChooser;
};

export type DownloadModalState = {
type: 'download';
description: string;
download: playwright.Download;
};

export type DialogModalState = {
type: 'dialog';
description: string;
dialog: playwright.Dialog;
};

export type ModalState = FileUploadModalState | DialogModalState;
export type ModalState = FileUploadModalState | DialogModalState | DownloadModalState;

export type ToolActionResult = { content?: (ImageContent | TextContent)[] } | undefined | void;

Expand Down
2 changes: 2 additions & 0 deletions tests/capabilities.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ test('test snapshot tool list', async ({ client }) => {
'browser_click',
'browser_console_messages',
'browser_drag',
'browser_file_download',
'browser_file_upload',
'browser_handle_dialog',
'browser_hover',
Expand Down Expand Up @@ -51,6 +52,7 @@ test('test vision tool list', async ({ visionClient }) => {
expect(new Set(visionTools.map(t => t.name))).toEqual(new Set([
'browser_close',
'browser_console_messages',
'browser_file_download',
'browser_file_upload',
'browser_handle_dialog',
'browser_install',
Expand Down
66 changes: 66 additions & 0 deletions tests/files.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,69 @@ test('browser_file_upload', async ({ client }) => {
- [File chooser]: can be handled by the "browser_file_upload" tool`);
}
});

test.describe('browser_file_download', () => {
test('after clicking on download link', async ({ client, mcpBrowser }) => {
test.skip(mcpBrowser === 'webkit' && process.platform === 'linux');

expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<a href="data:text/plain,Hello world!" download="test.txt">Download</a>',
},
})).toContainTextContent('- link "Download" [ref=s1e3]');

expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Download link',
ref: 's1e3',
},
})).toContainTextContent(`
### Modal state
- [Download (test.txt)]: can be handled by the "browser_file_download" tool`);

expect(await client.callTool({
name: 'browser_snapshot',
arguments: {},
})).toContainTextContent(`
Tool "browser_snapshot" does not handle the modal state.
### Modal state
- [Download (test.txt)]: can be handled by the "browser_file_download" tool`.trim());

expect(await client.callTool({
name: 'browser_file_download',
arguments: {
filenames: ['wrong_file.txt'],
},
})).toContainTextContent(`Error: No download modal visible for file wrong_file.txt`);

expect(await client.callTool({
name: 'browser_file_download',
arguments: {
filenames: ['test.txt'],
},
})).toContainTextContent([`Downloaded test.txt to`, '// <internal code to accept and cancel files>']);
});

test('navigating to downloading link', async ({ client, server, mcpBrowser }) => {
test.skip(mcpBrowser === 'msedge' || mcpBrowser === 'chrome');

server.route('/', (req, res) => {
res.setHeader('Content-Disposition', 'attachment; filename="test.txt"');
res.end('Hello world!');
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
})).toContainTextContent('### Modal state\n- [Download (test.txt)]');
expect(await client.callTool({
name: 'browser_file_download',
arguments: {
filenames: ['test.txt'],
},
})).toContainTextContent([`Downloaded test.txt to`, '// <internal code to accept and cancel files>\n```\n\n- Page URL: about:blank']);
});
});
Loading