feat: support uploading multiple files with upload_file (#2257)

Closes #1217. `upload_file` only took a single path, so there was no way
to fill an `input[type=file][multiple]` in one call.

`filePath` now also accepts an array of paths. A plain string still
works for the single-file case, so existing callers don't change —
Puppeteer's `uploadFile`/`fileChooser.accept` already take multiple
paths, so the handler just normalizes to an array and forwards them.

The union (`string | string[]`) needed a bit of plumbing: the CLI
generator threw on non-scalar types, the docs generator rendered it as
`unknown`, and the telemetry transformer didn't know `ZodUnion`. The CLI
exposes the single-value form (it can't pass arrays anyway), the docs
show `string or array`, and metrics count it like an array with scalars
normalized to one. Regenerated artifacts are included.

Added a test that uploads two files to a multi-file input and asserts
both land, plus telemetry coverage for the union. `npm run typecheck`,
lint/prettier, and the upload + transformation tests all pass.

---------

Co-authored-by: Nikolay Vitkov <34244704+Lightning00Blade@users.noreply.github.com>
This commit is contained in:
Serhii Zghama
2026-08-11 17:05:28 +07:00
committed by GitHub
parent 6cb771e45e
commit c8a2393df6
6 changed files with 73 additions and 16 deletions
+1 -1
View File
@@ -169,7 +169,7 @@
**Parameters:**
- **filePath** (string) **(required)**: The local path of the file to upload
- **filePaths** (array) **(required)**: One or more local paths of files to upload.
- **uid** (string) **(required)**: The uid of the file input element or an element that will open file chooser on the page from the page content snapshot
- **includeSnapshot** (boolean) _(optional)_: Whether to include a snapshot in the response. Default is false.
+4 -1
View File
@@ -234,7 +234,10 @@ export class ToolHandler {
if (this.tool.verifyFilesSchema) {
for (const key of this.tool.verifyFilesSchema) {
const filePath = params[key];
await context.validatePath(filePath as string);
const paths = Array.isArray(filePath) ? filePath : [filePath];
for (const path of paths) {
await context.validatePath(path as string);
}
}
}
if (isPageScopedTool(this.tool)) {
+4 -4
View File
@@ -1287,10 +1287,10 @@ export const commands: Commands = {
'The uid of the file input element or an element that will open file chooser on the page from the page content snapshot',
required: true,
},
filePath: {
name: 'filePath',
type: 'string',
description: 'The local path of the file to upload',
filePaths: {
name: 'filePaths',
type: 'array',
description: 'One or more local paths of files to upload.',
required: true,
},
includeSnapshot: {
+6 -1
View File
@@ -551,11 +551,16 @@
"args": [
{
"name": "file_path_length",
"argType": "number"
"argType": "number",
"isDeprecated": true
},
{
"name": "include_snapshot",
"argType": "boolean"
},
{
"name": "file_paths_count",
"argType": "number"
}
]
},
+9 -6
View File
@@ -443,19 +443,22 @@ export const uploadFile = definePageTool({
.describe(
'The uid of the file input element or an element that will open file chooser on the page from the page content snapshot',
),
filePath: zod.string().describe('The local path of the file to upload'),
filePaths: zod
.array(zod.string())
.min(1)
.describe('One or more local paths of files to upload.'),
includeSnapshot: includeSnapshotSchema,
},
blockedByDialog: true,
verifyFilesSchema: ['filePath'],
verifyFilesSchema: ['filePaths'],
handler: async (request, response) => {
const {uid, filePath} = request.params;
const {uid, filePaths} = request.params;
using handle = (await request.page.getElementByUid(
uid,
)) as ElementHandle<HTMLInputElement>;
try {
await handle.uploadFile(filePath);
await handle.uploadFile(...filePaths);
} catch {
// Some sites use a proxy element to trigger file upload instead of
// a type=file element. In this case, we want to default to
@@ -465,7 +468,7 @@ export const uploadFile = definePageTool({
request.page.pptrPage.waitForFileChooser({timeout: 3000}),
handle.asLocator().click(),
]);
await fileChooser.accept([filePath]);
await fileChooser.accept(filePaths);
} catch {
throw new Error(
`Failed to upload file. The element could not accept the file directly, and clicking it did not trigger a file chooser.`,
@@ -475,7 +478,7 @@ export const uploadFile = definePageTool({
if (request.params.includeSnapshot) {
response.includeSnapshot();
}
response.appendResponseLine(`File uploaded from ${filePath}.`);
response.appendResponseLine(`File uploaded from ${filePaths.join(', ')}.`);
},
});
+49 -3
View File
@@ -1231,7 +1231,7 @@ describe('input', () => {
{
params: {
uid: '1_2',
filePath: testFilePath,
filePaths: [testFilePath],
},
page: context.getSelectedMcpPage(),
},
@@ -1248,6 +1248,52 @@ describe('input', () => {
await fs.unlink(testFilePath);
});
it('uploads multiple files to a file input', async () => {
const firstFilePath = path.join(process.cwd(), 'first.txt');
const secondFilePath = path.join(process.cwd(), 'second.txt');
await fs.writeFile(firstFilePath, 'first file content');
await fs.writeFile(secondFilePath, 'second file content');
await withMcpContext(async (response, context) => {
const page = context.getSelectedMcpPage().pptrPage;
await page.setContent(
html`<form>
<input
type="file"
id="file-input"
multiple
/>
</form>`,
);
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
context.getSelectedMcpPage(),
);
await uploadFile.handler(
{
params: {
uid: '1_2',
filePaths: [firstFilePath, secondFilePath],
},
page: context.getSelectedMcpPage(),
},
response,
context,
);
assert.strictEqual(
response.responseLines[0],
`File uploaded from ${firstFilePath}, ${secondFilePath}.`,
);
const uploadedFileNames = await page.$eval('#file-input', el => {
const input = el as HTMLInputElement;
return Array.from(input.files ?? []).map(file => file.name);
});
assert.deepStrictEqual(uploadedFileNames, ['first.txt', 'second.txt']);
});
await fs.unlink(firstFilePath);
await fs.unlink(secondFilePath);
});
it('uploads a file when clicking an element opens a file uploader', async () => {
const testFilePath = path.join(process.cwd(), 'test.txt');
await fs.writeFile(testFilePath, 'test file content');
@@ -1276,7 +1322,7 @@ describe('input', () => {
{
params: {
uid: '1_1',
filePath: testFilePath,
filePaths: [testFilePath],
},
page: context.getSelectedMcpPage(),
},
@@ -1314,7 +1360,7 @@ describe('input', () => {
{
params: {
uid: '1_1',
filePath: testFilePath,
filePaths: [testFilePath],
},
page: context.getSelectedMcpPage(),
},