Skip to content

Commit 08a469d

Browse files
committed
feat(mcp): allow passing secrets
1 parent 7cbda37 commit 08a469d

File tree

9 files changed

+182
-12
lines changed

9 files changed

+182
-12
lines changed

packages/playwright/src/mcp/browser/config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import fs from 'fs';
1818
import os from 'os';
1919
import path from 'path';
2020
import { devices } from 'playwright-core';
21+
import { dotenv } from 'playwright-core/lib/utilsBundle';
2122

2223
import type * as playwright from '../../../types/test';
2324
import type { Config, ToolCapability } from '../config';
@@ -44,6 +45,7 @@ export type CLIOptions = {
4445
proxyServer?: string;
4546
saveSession?: boolean;
4647
saveTrace?: boolean;
48+
secrets?: Record<string, string>;
4749
storageState?: string;
4850
userAgent?: string;
4951
userDataDir?: string;
@@ -189,6 +191,7 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
189191
},
190192
saveSession: cliOptions.saveSession,
191193
saveTrace: cliOptions.saveTrace,
194+
secrets: cliOptions.secrets,
192195
outputDir: cliOptions.outputDir,
193196
imageResponses: cliOptions.imageResponses,
194197
};
@@ -219,6 +222,7 @@ function configFromEnv(): Config {
219222
options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS);
220223
options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER);
221224
options.saveTrace = envToBoolean(process.env.PLAYWRIGHT_MCP_SAVE_TRACE);
225+
options.secrets = dotenvFileLoader(process.env.PLAYWRIGHT_MCP_SECRETS_FILE);
222226
options.storageState = envToString(process.env.PLAYWRIGHT_MCP_STORAGE_STATE);
223227
options.userAgent = envToString(process.env.PLAYWRIGHT_MCP_USER_AGENT);
224228
options.userDataDir = envToString(process.env.PLAYWRIGHT_MCP_USER_DATA_DIR);
@@ -300,6 +304,12 @@ export function commaSeparatedList(value: string | undefined): string[] | undefi
300304
return value.split(',').map(v => v.trim());
301305
}
302306

307+
export function dotenvFileLoader(value: string | undefined): Record<string, string> | undefined {
308+
if (!value)
309+
return undefined;
310+
return dotenv.parse(fs.readFileSync(value, 'utf8'));
311+
}
312+
303313
function envToNumber(value: string | undefined): number | undefined {
304314
if (!value)
305315
return undefined;

packages/playwright/src/mcp/browser/context.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { debug } from 'playwright-core/lib/utilsBundle';
1919
import { logUnhandledError } from '../log';
2020
import { Tab } from './tab';
2121
import { outputFile } from './config';
22+
import * as codegen from './codegen';
2223

2324
import type * as playwright from '../../../types/test';
2425
import type { FullConfig } from './config';
@@ -220,6 +221,15 @@ export class Context {
220221
}
221222
return result;
222223
}
224+
225+
lookupSecret(secretName: string): { value: string, code: string } {
226+
if (!this.config.secrets?.[secretName])
227+
return { value: secretName, code: codegen.quote(secretName) };
228+
return {
229+
value: this.config.secrets[secretName]!,
230+
code: `process.env['${secretName}']`,
231+
};
232+
}
223233
}
224234

225235
export class InputRecorder {

packages/playwright/src/mcp/browser/response.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,21 @@ ${this._code.join('\n')}
136136
content.push({ type: 'image', data: image.data.toString('base64'), mimeType: image.contentType });
137137
}
138138

139+
this._redactSecrets(content);
139140
return { content, isError: this._isError };
140141
}
142+
143+
private _redactSecrets(content: (TextContent | ImageContent)[]) {
144+
if (!this._context.config.secrets)
145+
return;
146+
147+
for (const item of content) {
148+
if (item.type !== 'text')
149+
continue;
150+
for (const [secretName, secretValue] of Object.entries(this._context.config.secrets))
151+
item.text = item.text.replaceAll(secretValue, `<secret>${secretName}</secret>`);
152+
}
153+
}
141154
}
142155

143156
function renderTabSnapshot(tabSnapshot: TabSnapshot): string {

packages/playwright/src/mcp/browser/tools/form.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import { z } from '../../sdk/bundle';
1818
import { defineTabTool } from './tool';
1919
import { generateLocator } from './utils';
20-
import * as javascript from '../codegen';
20+
import * as codegen from '../codegen';
2121

2222
const fillForm = defineTabTool({
2323
capability: 'core',
@@ -42,14 +42,15 @@ const fillForm = defineTabTool({
4242
const locator = await tab.refLocator({ element: field.name, ref: field.ref });
4343
const locatorSource = `await page.${await generateLocator(locator)}`;
4444
if (field.type === 'textbox' || field.type === 'slider') {
45-
await locator.fill(field.value);
46-
response.addCode(`${locatorSource}.fill(${javascript.quote(field.value)});`);
45+
const secret = tab.context.lookupSecret(field.value);
46+
await locator.fill(secret.value);
47+
response.addCode(`${locatorSource}.fill(${secret.code});`);
4748
} else if (field.type === 'checkbox' || field.type === 'radio') {
4849
await locator.setChecked(field.value === 'true');
49-
response.addCode(`${locatorSource}.setChecked(${javascript.quote(field.value)});`);
50+
response.addCode(`${locatorSource}.setChecked(${field.value});`);
5051
} else if (field.type === 'combobox') {
5152
await locator.selectOption({ label: field.value });
52-
response.addCode(`${locatorSource}.selectOption(${javascript.quote(field.value)});`);
53+
response.addCode(`${locatorSource}.selectOption(${codegen.quote(field.value)});`);
5354
}
5455
}
5556
},

packages/playwright/src/mcp/browser/tools/keyboard.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import { z } from '../../sdk/bundle';
1818
import { defineTabTool } from './tool';
1919
import { elementSchema } from './snapshot';
2020
import { generateLocator } from './utils';
21-
import * as javascript from '../codegen';
2221

2322
const pressKey = defineTabTool({
2423
capability: 'core',
@@ -62,15 +61,16 @@ const type = defineTabTool({
6261

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

6666
await tab.waitForCompletion(async () => {
6767
if (params.slowly) {
6868
response.setIncludeSnapshot();
69-
response.addCode(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
70-
await locator.pressSequentially(params.text);
69+
response.addCode(`await page.${await generateLocator(locator)}.pressSequentially(${secret.code});`);
70+
await locator.pressSequentially(secret.value);
7171
} else {
72-
response.addCode(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
73-
await locator.fill(params.text);
72+
response.addCode(`await page.${await generateLocator(locator)}.fill(${secret.code});`);
73+
await locator.fill(secret.value);
7474
}
7575

7676
if (params.submit) {

packages/playwright/src/mcp/config.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,13 @@ export type Config = {
9595
*/
9696
saveTrace?: boolean;
9797

98+
/**
99+
* Secrets are used to prevent LLM from getting sensitive data while
100+
* automating scenarios such as authentication.
101+
* Prefer the browser.contextOptions.storageState over secrets file as a more secure alternative.
102+
*/
103+
secrets?: Record<string, string>;
104+
98105
/**
99106
* The directory to save output files.
100107
*/

packages/playwright/src/mcp/program.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import { program, ProgramOption } from 'playwright-core/lib/utilsBundle';
1818
import * as mcpServer from './sdk/server';
19-
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './browser/config';
19+
import { commaSeparatedList, dotenvFileLoader, resolveCLIConfig, semicolonSeparatedList } from './browser/config';
2020
import { Context } from './browser/context';
2121
import { contextFactory } from './browser/browserContextFactory';
2222
import { ProxyBackend } from './sdk/proxyBackend';
@@ -52,6 +52,7 @@ program
5252
.option('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"')
5353
.option('--save-session', 'Whether to save the Playwright MCP session into the output directory.')
5454
.option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.')
55+
.option('--secrets <path>', 'path to a file containing secrets in the dotenv format', dotenvFileLoader)
5556
.option('--storage-state <path>', 'path to the storage state file for isolated sessions.')
5657
.option('--user-agent <ua string>', 'specify user agent string')
5758
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')

tests/mcp/form.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ test('browser_fill_form (textbox)', async ({ client, server }) => {
9797
await page.getByRole('textbox', { name: 'Email' }).fill('[email protected]');
9898
await page.getByRole('slider', { name: 'Age' }).fill('25');
9999
await page.getByLabel('Choose a country United').selectOption('United States');
100-
await page.getByRole('checkbox', { name: 'Subscribe to newsletter' }).setChecked('true');`,
100+
await page.getByRole('checkbox', { name: 'Subscribe to newsletter' }).setChecked(true);`,
101101
});
102102

103103
const response = await client.callTool({

tests/mcp/secrets.spec.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import fs from 'node:fs';
18+
19+
import { test, expect } from './fixtures';
20+
21+
test('browser_type', async ({ startClient, server }) => {
22+
const secretsFile = test.info().outputPath('secrets.env');
23+
await fs.promises.writeFile(secretsFile, 'X-PASSWORD=password123');
24+
25+
const { client } = await startClient({
26+
args: ['--secrets', secretsFile],
27+
});
28+
29+
server.setContent('/', `
30+
<!DOCTYPE html>
31+
<html>
32+
<input type='keypress' onkeypress="console.log('Key pressed:', event.key, ', Text:', event.target.value)"></input>
33+
</html>
34+
`, 'text/html');
35+
36+
await client.callTool({
37+
name: 'browser_navigate',
38+
arguments: {
39+
url: server.PREFIX,
40+
},
41+
});
42+
43+
{
44+
const response = await client.callTool({
45+
name: 'browser_type',
46+
arguments: {
47+
element: 'textbox',
48+
ref: 'e2',
49+
text: 'X-PASSWORD',
50+
submit: true,
51+
},
52+
});
53+
expect(response).toHaveResponse({
54+
code: `await page.getByRole('textbox').fill(process.env['X-PASSWORD']);
55+
await page.getByRole('textbox').press('Enter');`,
56+
pageState: expect.stringContaining(`- textbox`),
57+
});
58+
}
59+
60+
expect(await client.callTool({
61+
name: 'browser_console_messages',
62+
})).toHaveResponse({
63+
result: expect.stringContaining(`[LOG] Key pressed: Enter , Text: <secret>X-PASSWORD</secret>`),
64+
});
65+
});
66+
67+
68+
test('browser_fill_form', async ({ startClient, server }) => {
69+
const secretsFile = test.info().outputPath('secrets.env');
70+
await fs.promises.writeFile(secretsFile, 'X-PASSWORD=password123');
71+
72+
const { client } = await startClient({
73+
args: ['--secrets', secretsFile],
74+
});
75+
76+
server.setContent('/', `
77+
<!DOCTYPE html>
78+
<html>
79+
<body>
80+
<form>
81+
<label>
82+
<input type="email" id="email" name="email" />
83+
Email
84+
</label>
85+
<label>
86+
<input type="password" id="name" name="password" />
87+
Password
88+
</label>
89+
</form>
90+
</body>
91+
</html>
92+
`, 'text/html');
93+
94+
await client.callTool({
95+
name: 'browser_navigate',
96+
arguments: { url: server.PREFIX },
97+
});
98+
99+
expect(await client.callTool({
100+
name: 'browser_fill_form',
101+
arguments: {
102+
fields: [
103+
{
104+
name: 'Email textbox',
105+
type: 'textbox',
106+
ref: 'e4',
107+
value: 'John Doe'
108+
},
109+
{
110+
name: 'Password textbox',
111+
type: 'textbox',
112+
ref: 'e6',
113+
value: 'X-PASSWORD'
114+
},
115+
]
116+
},
117+
})).toHaveResponse({
118+
code: `await page.getByRole('textbox', { name: 'Email' }).fill('John Doe');
119+
await page.getByRole('textbox', { name: 'Password' }).fill(process.env['X-PASSWORD']);`,
120+
});
121+
122+
expect(await client.callTool({
123+
name: 'browser_snapshot',
124+
arguments: {},
125+
})).toHaveResponse({
126+
pageState: expect.stringContaining(`- textbox \"Password\" [active] [ref=e6]: <secret>X-PASSWORD</secret>`),
127+
});
128+
});

0 commit comments

Comments
 (0)