Skip to content
Merged
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
10 changes: 10 additions & 0 deletions packages/playwright/src/mcp/browser/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { devices } from 'playwright-core';
import { dotenv } from 'playwright-core/lib/utilsBundle';

import type * as playwright from '../../../types/test';
import type { Config, ToolCapability } from '../config';
Expand All @@ -44,6 +45,7 @@ export type CLIOptions = {
proxyServer?: string;
saveSession?: boolean;
saveTrace?: boolean;
secrets?: Record<string, string>;
storageState?: string;
userAgent?: string;
userDataDir?: string;
Expand Down Expand Up @@ -189,6 +191,7 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
},
saveSession: cliOptions.saveSession,
saveTrace: cliOptions.saveTrace,
secrets: cliOptions.secrets,
outputDir: cliOptions.outputDir,
imageResponses: cliOptions.imageResponses,
};
Expand Down Expand Up @@ -219,6 +222,7 @@ function configFromEnv(): Config {
options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS);
options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER);
options.saveTrace = envToBoolean(process.env.PLAYWRIGHT_MCP_SAVE_TRACE);
options.secrets = dotenvFileLoader(process.env.PLAYWRIGHT_MCP_SECRETS_FILE);
options.storageState = envToString(process.env.PLAYWRIGHT_MCP_STORAGE_STATE);
options.userAgent = envToString(process.env.PLAYWRIGHT_MCP_USER_AGENT);
options.userDataDir = envToString(process.env.PLAYWRIGHT_MCP_USER_DATA_DIR);
Expand Down Expand Up @@ -300,6 +304,12 @@ export function commaSeparatedList(value: string | undefined): string[] | undefi
return value.split(',').map(v => v.trim());
}

export function dotenvFileLoader(value: string | undefined): Record<string, string> | undefined {
if (!value)
return undefined;
return dotenv.parse(fs.readFileSync(value, 'utf8'));
}

function envToNumber(value: string | undefined): number | undefined {
if (!value)
return undefined;
Expand Down
10 changes: 10 additions & 0 deletions packages/playwright/src/mcp/browser/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { debug } from 'playwright-core/lib/utilsBundle';
import { logUnhandledError } from '../log';
import { Tab } from './tab';
import { outputFile } from './config';
import * as codegen from './codegen';

import type * as playwright from '../../../types/test';
import type { FullConfig } from './config';
Expand Down Expand Up @@ -220,6 +221,15 @@ export class Context {
}
return result;
}

lookupSecret(secretName: string): { value: string, code: string } {
if (!this.config.secrets?.[secretName])
return { value: secretName, code: codegen.quote(secretName) };
return {
value: this.config.secrets[secretName]!,
code: `process.env['${secretName}']`,
};
}
}

export class InputRecorder {
Expand Down
13 changes: 13 additions & 0 deletions packages/playwright/src/mcp/browser/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,21 @@ ${this._code.join('\n')}
content.push({ type: 'image', data: image.data.toString('base64'), mimeType: image.contentType });
}

this._redactSecrets(content);
return { content, isError: this._isError };
}

private _redactSecrets(content: (TextContent | ImageContent)[]) {
if (!this._context.config.secrets)
return;

for (const item of content) {
if (item.type !== 'text')
continue;
for (const [secretName, secretValue] of Object.entries(this._context.config.secrets))
item.text = item.text.replaceAll(secretValue, `<secret>${secretName}</secret>`);
}
}
}

function renderTabSnapshot(tabSnapshot: TabSnapshot): string {
Expand Down
11 changes: 6 additions & 5 deletions packages/playwright/src/mcp/browser/tools/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import { z } from '../../sdk/bundle';
import { defineTabTool } from './tool';
import { generateLocator } from './utils';
import * as javascript from '../codegen';
import * as codegen from '../codegen';

const fillForm = defineTabTool({
capability: 'core',
Expand All @@ -42,14 +42,15 @@ const fillForm = defineTabTool({
const locator = await tab.refLocator({ element: field.name, ref: field.ref });
const locatorSource = `await page.${await generateLocator(locator)}`;
if (field.type === 'textbox' || field.type === 'slider') {
await locator.fill(field.value);
response.addCode(`${locatorSource}.fill(${javascript.quote(field.value)});`);
const secret = tab.context.lookupSecret(field.value);
await locator.fill(secret.value);
response.addCode(`${locatorSource}.fill(${secret.code});`);
} else if (field.type === 'checkbox' || field.type === 'radio') {
await locator.setChecked(field.value === 'true');
response.addCode(`${locatorSource}.setChecked(${javascript.quote(field.value)});`);
response.addCode(`${locatorSource}.setChecked(${field.value});`);
} else if (field.type === 'combobox') {
await locator.selectOption({ label: field.value });
response.addCode(`${locatorSource}.selectOption(${javascript.quote(field.value)});`);
response.addCode(`${locatorSource}.selectOption(${codegen.quote(field.value)});`);
}
}
},
Expand Down
10 changes: 5 additions & 5 deletions packages/playwright/src/mcp/browser/tools/keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import { z } from '../../sdk/bundle';
import { defineTabTool } from './tool';
import { elementSchema } from './snapshot';
import { generateLocator } from './utils';
import * as javascript from '../codegen';

const pressKey = defineTabTool({
capability: 'core',
Expand Down Expand Up @@ -62,15 +61,16 @@ const type = defineTabTool({

handle: async (tab, params, response) => {
const locator = await tab.refLocator(params);
const secret = tab.context.lookupSecret(params.text);

await tab.waitForCompletion(async () => {
if (params.slowly) {
response.setIncludeSnapshot();
response.addCode(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
await locator.pressSequentially(params.text);
response.addCode(`await page.${await generateLocator(locator)}.pressSequentially(${secret.code});`);
await locator.pressSequentially(secret.value);
} else {
response.addCode(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
await locator.fill(params.text);
response.addCode(`await page.${await generateLocator(locator)}.fill(${secret.code});`);
await locator.fill(secret.value);
}

if (params.submit) {
Expand Down
7 changes: 7 additions & 0 deletions packages/playwright/src/mcp/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ export type Config = {
*/
saveTrace?: boolean;

/**
* Secrets are used to prevent LLM from getting sensitive data while
* automating scenarios such as authentication.
* Prefer the browser.contextOptions.storageState over secrets file as a more secure alternative.
Copy link
Member

Choose a reason for hiding this comment

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

I'd drop the last sentence to avoid confusion.

*/
secrets?: Record<string, string>;

/**
* The directory to save output files.
*/
Expand Down
3 changes: 2 additions & 1 deletion packages/playwright/src/mcp/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import { program, ProgramOption } from 'playwright-core/lib/utilsBundle';
import * as mcpServer from './sdk/server';
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './browser/config';
import { commaSeparatedList, dotenvFileLoader, resolveCLIConfig, semicolonSeparatedList } from './browser/config';
import { Context } from './browser/context';
import { contextFactory } from './browser/browserContextFactory';
import { ProxyBackend } from './sdk/proxyBackend';
Expand Down Expand Up @@ -52,6 +52,7 @@ program
.option('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"')
.option('--save-session', 'Whether to save the Playwright MCP session into the output directory.')
.option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.')
.option('--secrets <path>', 'path to a file containing secrets in the dotenv format', dotenvFileLoader)
.option('--storage-state <path>', 'path to the storage state file for isolated sessions.')
.option('--user-agent <ua string>', 'specify user agent string')
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
Expand Down
2 changes: 1 addition & 1 deletion tests/mcp/form.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ test('browser_fill_form (textbox)', async ({ client, server }) => {
await page.getByRole('textbox', { name: 'Email' }).fill('[email protected]');
await page.getByRole('slider', { name: 'Age' }).fill('25');
await page.getByLabel('Choose a country United').selectOption('United States');
await page.getByRole('checkbox', { name: 'Subscribe to newsletter' }).setChecked('true');`,
await page.getByRole('checkbox', { name: 'Subscribe to newsletter' }).setChecked(true);`,
});

const response = await client.callTool({
Expand Down
128 changes: 128 additions & 0 deletions tests/mcp/secrets.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import fs from 'node:fs';

import { test, expect } from './fixtures';

test('browser_type', async ({ startClient, server }) => {
const secretsFile = test.info().outputPath('secrets.env');
await fs.promises.writeFile(secretsFile, 'X-PASSWORD=password123');

const { client } = await startClient({
args: ['--secrets', secretsFile],
});

server.setContent('/', `
<!DOCTYPE html>
<html>
<input type='keypress' onkeypress="console.log('Key pressed:', event.key, ', Text:', event.target.value)"></input>
</html>
`, 'text/html');

await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX,
},
});

{
const response = await client.callTool({
name: 'browser_type',
arguments: {
element: 'textbox',
ref: 'e2',
text: 'X-PASSWORD',
submit: true,
},
});
expect(response).toHaveResponse({
code: `await page.getByRole('textbox').fill(process.env['X-PASSWORD']);
await page.getByRole('textbox').press('Enter');`,
pageState: expect.stringContaining(`- textbox`),
});
}

expect(await client.callTool({
name: 'browser_console_messages',
})).toHaveResponse({
result: expect.stringContaining(`[LOG] Key pressed: Enter , Text: <secret>X-PASSWORD</secret>`),
});
});


test('browser_fill_form', async ({ startClient, server }) => {
const secretsFile = test.info().outputPath('secrets.env');
await fs.promises.writeFile(secretsFile, 'X-PASSWORD=password123');

const { client } = await startClient({
args: ['--secrets', secretsFile],
});

server.setContent('/', `
<!DOCTYPE html>
<html>
<body>
<form>
<label>
<input type="email" id="email" name="email" />
Email
</label>
<label>
<input type="password" id="name" name="password" />
Password
</label>
</form>
</body>
</html>
`, 'text/html');

await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});

expect(await client.callTool({
name: 'browser_fill_form',
arguments: {
fields: [
{
name: 'Email textbox',
type: 'textbox',
ref: 'e4',
value: 'John Doe'
},
{
name: 'Password textbox',
type: 'textbox',
ref: 'e6',
value: 'X-PASSWORD'
},
]
},
})).toHaveResponse({
code: `await page.getByRole('textbox', { name: 'Email' }).fill('John Doe');
await page.getByRole('textbox', { name: 'Password' }).fill(process.env['X-PASSWORD']);`,
});

expect(await client.callTool({
name: 'browser_snapshot',
arguments: {},
})).toHaveResponse({
pageState: expect.stringContaining(`- textbox \"Password\" [active] [ref=e6]: <secret>X-PASSWORD</secret>`),
});
});
Loading