From db91b73d0aad5b8f7f415a230d4943a8cb5e3e2a Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 17 Apr 2025 11:28:38 +0200 Subject: [PATCH 01/10] clicking on link --- src/context.ts | 7 ++++++ src/tools/files.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++- src/tools/tool.ts | 8 +++++- tests/files.spec.ts | 43 ++++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 2 deletions(-) diff --git a/src/context.ts b/src/context.ts index 1feb1d0f..ab04ff03 100644 --- a/src/context.ts +++ b/src/context.ts @@ -293,6 +293,13 @@ export class Tab { fileChooser: chooser, }, this); }); + page.on('download', download => { + this.context.setModalState({ + type: 'download', + description: `Download (${download.suggestedFilename()})`, + download, + }, this); + }); page.setDefaultNavigationTimeout(60000); page.setDefaultTimeout(5000); } diff --git a/src/tools/files.ts b/src/tools/files.ts index 816632f4..852a9054 100644 --- a/src/tools/files.ts +++ b/src/tools/files.ts @@ -16,8 +16,11 @@ import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; +import os from 'os'; +import path from 'path'; -import type { ToolFactory } from './tool'; +import { DownloadModalState, Tool, type ToolFactory } from './tool'; +import { sanitizeForFilePath } from './utils'; const uploadFileSchema = z.object({ paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'), @@ -57,6 +60,61 @@ const uploadFile: ToolFactory = captureSnapshot => ({ clearsModalState: 'fileChooser', }); +const downloadFileSchema = z.object({ + filenames: z.array(z.string()).describe('The filenames to accept. All other files will be canceled.'), +}); + +const downloadFile: Tool = { + capability: 'files', + + schema: { + name: 'browser_file_download', + description: 'Accept file downloads. Only use this if there is a download modal visible.', + inputSchema: zodToJsonSchema(downloadFileSchema), + }, + + handle: async (context, params) => { + const validatedParams = downloadFileSchema.parse(params); + const modals = context.modalStates().filter(state => state.type === 'download'); + if (!modals.length) + throw new Error('No download modal visible'); + + const accepted = new Set(); + for (const filename of validatedParams.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: [`// `], + 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, ]; diff --git a/src/tools/tool.ts b/src/tools/tool.ts index d80fddfc..8759b607 100644 --- a/src/tools/tool.ts +++ b/src/tools/tool.ts @@ -32,7 +32,13 @@ export type FileUploadModalState = { fileChooser: playwright.FileChooser; }; -export type ModalState = FileUploadModalState; +export type DownloadModalState = { + type: 'download'; + description: string; + download: playwright.Download; +}; + +export type ModalState = FileUploadModalState | DownloadModalState; export type ToolResult = { code: string[]; diff --git a/tests/files.spec.ts b/tests/files.spec.ts index b7a16b1a..3d04d4fd 100644 --- a/tests/files.spec.ts +++ b/tests/files.spec.ts @@ -75,3 +75,46 @@ 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 }) => { + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { + url: 'data:text/html,Download', + }, + })).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`, '// ']); + }); +}); From a198e9d657756f2d3268d89180b218f46c6e85eb Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 17 Apr 2025 11:51:41 +0200 Subject: [PATCH 02/10] support individual files --- src/context.ts | 12 +++++++++++- tests/files.spec.ts | 19 +++++++++++++++++++ tests/fixtures.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/context.ts b/src/context.ts index ab04ff03..03b61c81 100644 --- a/src/context.ts +++ b/src/context.ts @@ -58,6 +58,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) { @@ -310,7 +314,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')) + return; + } + // Cap load event to 5 seconds, the page is operational at this point. await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {}); } diff --git a/tests/files.spec.ts b/tests/files.spec.ts index 3d04d4fd..34f1dedb 100644 --- a/tests/files.spec.ts +++ b/tests/files.spec.ts @@ -117,4 +117,23 @@ Tool "browser_snapshot" does not handle the modal state. }, })).toContainTextContent([`Downloaded test.txt to`, '// ']); }); + + test('navigating to downloading link', async ({ client, server }) => { + server.on('request', (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`, '// \n```\n\n- Page URL: about:blank']); + }); }); diff --git a/tests/fixtures.ts b/tests/fixtures.ts index 46012715..7bba5b58 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -16,13 +16,36 @@ import path from 'path'; import { chromium } from 'playwright'; +import http from 'http'; +import net from 'net'; import { test as baseTest, expect as baseExpect } from '@playwright/test'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { spawn } from 'child_process'; +class TestServer extends http.Server { + PREFIX: string; + start() { + return new Promise(resolve => { + super.listen(() => { + const address = this.address() as net.AddressInfo; + this.PREFIX = `http://localhost:${address.port}`; + resolve(); + }); + }); + } + stop() { + return new Promise(resolve => { + this.close(() => { + resolve(); + }); + }); + } +} + type Fixtures = { + server: TestServer; client: Client; visionClient: Client; startClient: (options?: { args?: string[] }) => Promise; @@ -103,6 +126,13 @@ export const test = baseTest.extend({ }, mcpBrowser: ['chromium', { option: true }], + + server: async ({}, use) => { + const server = new TestServer(); + await server.start(); + await use(server); + await server.stop(); + } }); type Response = Awaited>; From 271fbd12e01e47eec84fb8829d74ad6dbdfedca7 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 17 Apr 2025 12:00:28 +0200 Subject: [PATCH 03/10] add pdf test --- tests/files.spec.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/files.spec.ts b/tests/files.spec.ts index 34f1dedb..99613014 100644 --- a/tests/files.spec.ts +++ b/tests/files.spec.ts @@ -136,4 +136,24 @@ Tool "browser_snapshot" does not handle the modal state. }, })).toContainTextContent([`Downloaded test.txt to`, '// \n```\n\n- Page URL: about:blank']); }); + + test('navigating to PDF link', async ({ client, server }) => { + server.on('request', (req, res) => { + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', 'filename="test.pdf"'); + res.end('Hello world!'); + }); + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { + url: server.PREFIX, + }, + })).toContainTextContent('### Modal state\n- [Download (test.pdf)]'); + expect(await client.callTool({ + name: 'browser_file_download', + arguments: { + filenames: ['test.pdf'], + }, + })).toContainTextContent([`Downloaded test.pdf to`, '// \n```\n\n- Page URL: about:blank']); + }); }); From 6426ec510063da9e107ab305f6b0479fd50178c6 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 17 Apr 2025 17:18:53 +0200 Subject: [PATCH 04/10] fix tests --- tests/capabilities.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/capabilities.spec.ts b/tests/capabilities.spec.ts index ea055266..6fbfb8c2 100644 --- a/tests/capabilities.spec.ts +++ b/tests/capabilities.spec.ts @@ -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_hover', 'browser_select_option', @@ -49,6 +50,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_install', 'browser_navigate_back', From 37e692369ea5c494d33aeae1ccdd454e14735654 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 17 Apr 2025 17:21:38 +0200 Subject: [PATCH 05/10] tests --- tests/files.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/files.spec.ts b/tests/files.spec.ts index 99613014..d61381a1 100644 --- a/tests/files.spec.ts +++ b/tests/files.spec.ts @@ -137,7 +137,9 @@ Tool "browser_snapshot" does not handle the modal state. })).toContainTextContent([`Downloaded test.txt to`, '// \n```\n\n- Page URL: about:blank']); }); - test('navigating to PDF link', async ({ client, server }) => { + test('navigating to PDF link', async ({ client, server, mcpBrowser, channel }) => { + test.skip(mcpBrowser === 'msedge', 'msedge behaves differently'); + server.on('request', (req, res) => { res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', 'filename="test.pdf"'); From 899bbada6795352c0253a73041001c4b867b1bc1 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 17 Apr 2025 17:22:17 +0200 Subject: [PATCH 06/10] one more --- tests/files.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/files.spec.ts b/tests/files.spec.ts index d61381a1..24b0d129 100644 --- a/tests/files.spec.ts +++ b/tests/files.spec.ts @@ -118,7 +118,9 @@ Tool "browser_snapshot" does not handle the modal state. })).toContainTextContent([`Downloaded test.txt to`, '// ']); }); - test('navigating to downloading link', async ({ client, server }) => { + test('navigating to downloading link', async ({ client, server, mcpBrowser }) => { + test.skip(mcpBrowser === 'msedge', 'msedge behaves differently'); + server.on('request', (req, res) => { res.setHeader('Content-Disposition', 'attachment; filename="test.txt"'); res.end('Hello world!'); From b050b5b92742db62692a1ac29e118f14f5f43ee4 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 17 Apr 2025 17:29:49 +0200 Subject: [PATCH 07/10] disable one more --- tests/files.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/files.spec.ts b/tests/files.spec.ts index 24b0d129..0fe43823 100644 --- a/tests/files.spec.ts +++ b/tests/files.spec.ts @@ -139,8 +139,9 @@ Tool "browser_snapshot" does not handle the modal state. })).toContainTextContent([`Downloaded test.txt to`, '// \n```\n\n- Page URL: about:blank']); }); - test('navigating to PDF link', async ({ client, server, mcpBrowser, channel }) => { + test('navigating to PDF link', async ({ client, server, mcpBrowser }) => { test.skip(mcpBrowser === 'msedge', 'msedge behaves differently'); + test.skip(mcpBrowser === 'webkit', 'webkit behaves differently'); server.on('request', (req, res) => { res.setHeader('Content-Type', 'application/pdf'); From 8ac6d27743bf92d9db5c2e7d0695304d8c1337f0 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 22 Apr 2025 13:38:09 +0200 Subject: [PATCH 08/10] update readme --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 05ec3687..e882fdcf 100644 --- a/README.md +++ b/README.md @@ -385,6 +385,13 @@ http.createServer(async (req, res) => { +- **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. + + + - **browser_pdf_save** - Description: Save page as PDF - Parameters: None From b32c2e02039f9961cf16084c7995a378a5ac5e86 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 24 Apr 2025 16:01:25 +0200 Subject: [PATCH 09/10] remove pdf test --- tests/files.spec.ts | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/tests/files.spec.ts b/tests/files.spec.ts index 7f1d155e..3287c028 100644 --- a/tests/files.spec.ts +++ b/tests/files.spec.ts @@ -148,27 +148,4 @@ Tool "browser_snapshot" does not handle the modal state. }, })).toContainTextContent([`Downloaded test.txt to`, '// \n```\n\n- Page URL: about:blank']); }); - - test('navigating to PDF link', async ({ client, server, mcpBrowser }) => { - test.skip(mcpBrowser === 'msedge', 'msedge behaves differently'); - test.skip(mcpBrowser === 'webkit', 'webkit behaves differently'); - - server.route('/', (req, res) => { - res.setHeader('Content-Type', 'application/pdf'); - res.setHeader('Content-Disposition', 'filename="test.pdf"'); - res.end('Hello world!'); - }); - expect(await client.callTool({ - name: 'browser_navigate', - arguments: { - url: server.PREFIX, - }, - })).toContainTextContent('### Modal state\n- [Download (test.pdf)]'); - expect(await client.callTool({ - name: 'browser_file_download', - arguments: { - filenames: ['test.pdf'], - }, - })).toContainTextContent([`Downloaded test.pdf to`, '// \n```\n\n- Page URL: about:blank']); - }); }); From a47e49b5fe3315f6510ee63fbc7b66dd83dc3f13 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 24 Apr 2025 16:35:53 +0200 Subject: [PATCH 10/10] disable tests --- tests/files.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/files.spec.ts b/tests/files.spec.ts index 3287c028..91e123c7 100644 --- a/tests/files.spec.ts +++ b/tests/files.spec.ts @@ -87,7 +87,9 @@ test('browser_file_upload', async ({ client }) => { }); test.describe('browser_file_download', () => { - test('after clicking on download link', async ({ client }) => { + test('after clicking on download link', async ({ client, mcpBrowser }) => { + test.skip(mcpBrowser === 'webkit' && process.platform === 'linux'); + expect(await client.callTool({ name: 'browser_navigate', arguments: { @@ -129,7 +131,7 @@ Tool "browser_snapshot" does not handle the modal state. }); test('navigating to downloading link', async ({ client, server, mcpBrowser }) => { - test.skip(mcpBrowser === 'msedge', 'msedge behaves differently'); + test.skip(mcpBrowser === 'msedge' || mcpBrowser === 'chrome'); server.route('/', (req, res) => { res.setHeader('Content-Disposition', 'attachment; filename="test.txt"');