Skip to content

Commit b37caaa

Browse files
authored
feat(mcp): allow configuring timeouts (#37311)
1 parent f6e4101 commit b37caaa

File tree

5 files changed

+141
-10
lines changed

5 files changed

+141
-10
lines changed

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

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ export type CLIOptions = {
4848
saveTrace?: boolean;
4949
secrets?: Record<string, string>;
5050
storageState?: string;
51+
timeoutAction?: number;
52+
timeoutNavigation?: number;
5153
userAgent?: string;
5254
userDataDir?: string;
5355
viewportSize?: string;
@@ -71,6 +73,10 @@ const defaultConfig: FullConfig = {
7173
},
7274
server: {},
7375
saveTrace: false,
76+
timeouts: {
77+
action: 5000,
78+
navigation: 60000,
79+
},
7480
};
7581

7682
type BrowserUserConfig = NonNullable<Config['browser']>;
@@ -84,6 +90,10 @@ export type FullConfig = Config & {
8490
network: NonNullable<Config['network']>,
8591
saveTrace: boolean;
8692
server: NonNullable<Config['server']>,
93+
timeouts: {
94+
action: number;
95+
navigation: number;
96+
},
8797
};
8898

8999
export async function resolveConfig(config: Config): Promise<FullConfig> {
@@ -196,6 +206,10 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
196206
secrets: cliOptions.secrets,
197207
outputDir: cliOptions.outputDir,
198208
imageResponses: cliOptions.imageResponses,
209+
timeouts: {
210+
action: cliOptions.timeoutAction,
211+
navigation: cliOptions.timeoutNavigation,
212+
},
199213
};
200214

201215
return result;
@@ -221,12 +235,14 @@ function configFromEnv(): Config {
221235
options.imageResponses = 'omit';
222236
options.sandbox = envToBoolean(process.env.PLAYWRIGHT_MCP_SANDBOX);
223237
options.outputDir = envToString(process.env.PLAYWRIGHT_MCP_OUTPUT_DIR);
224-
options.port = envToNumber(process.env.PLAYWRIGHT_MCP_PORT);
238+
options.port = numberParser(process.env.PLAYWRIGHT_MCP_PORT);
225239
options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS);
226240
options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER);
227241
options.saveTrace = envToBoolean(process.env.PLAYWRIGHT_MCP_SAVE_TRACE);
228242
options.secrets = dotenvFileLoader(process.env.PLAYWRIGHT_MCP_SECRETS_FILE);
229243
options.storageState = envToString(process.env.PLAYWRIGHT_MCP_STORAGE_STATE);
244+
options.timeoutAction = numberParser(process.env.PLAYWRIGHT_MCP_TIMEOUT_ACTION);
245+
options.timeoutNavigation = numberParser(process.env.PLAYWRIGHT_MCP_TIMEOUT_NAVIGATION);
230246
options.userAgent = envToString(process.env.PLAYWRIGHT_MCP_USER_AGENT);
231247
options.userDataDir = envToString(process.env.PLAYWRIGHT_MCP_USER_DATA_DIR);
232248
options.viewportSize = envToString(process.env.PLAYWRIGHT_MCP_VIEWPORT_SIZE);
@@ -292,6 +308,10 @@ function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
292308
...pickDefined(base.server),
293309
...pickDefined(overrides.server),
294310
},
311+
timeouts: {
312+
...pickDefined(base.timeouts),
313+
...pickDefined(overrides.timeouts),
314+
},
295315
} as FullConfig;
296316
}
297317

@@ -313,6 +333,12 @@ export function dotenvFileLoader(value: string | undefined): Record<string, stri
313333
return dotenv.parse(fs.readFileSync(value, 'utf8'));
314334
}
315335

336+
export function numberParser(value: string | undefined): number | undefined {
337+
if (!value)
338+
return undefined;
339+
return +value;
340+
}
341+
316342
export function headerParser(arg: string | undefined, previous?: Record<string, string>): Record<string, string> {
317343
if (!arg)
318344
return previous || {};
@@ -322,12 +348,6 @@ export function headerParser(arg: string | undefined, previous?: Record<string,
322348
return result;
323349
}
324350

325-
function envToNumber(value: string | undefined): number | undefined {
326-
if (!value)
327-
return undefined;
328-
return +value;
329-
}
330-
331351
function envToBoolean(value: string | undefined): boolean | undefined {
332352
if (value === 'true' || value === '1')
333353
return true;

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ export class Tab extends EventEmitter<TabEventsInterface> {
7777
page.on('download', download => {
7878
void this._downloadStarted(download);
7979
});
80-
page.setDefaultNavigationTimeout(60000);
81-
page.setDefaultTimeout(5000);
80+
page.setDefaultNavigationTimeout(this.context.config.timeouts.navigation);
81+
page.setDefaultTimeout(this.context.config.timeouts.action);
8282
(page as any)[tabSymbol] = this;
8383
}
8484

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,18 @@ export type Config = {
124124
blockedOrigins?: string[];
125125
};
126126

127+
timeouts?: {
128+
/*
129+
* Configures default action timeout: https://playwright.dev/docs/api/class-page#page-set-default-timeout. Defaults to 5000ms.
130+
*/
131+
action?: number;
132+
133+
/*
134+
* Configures default navigation timeout: https://playwright.dev/docs/api/class-page#page-set-default-navigation-timeout. Defaults to 60000ms.
135+
*/
136+
navigation?: number;
137+
};
138+
127139
/**
128140
* Whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them.
129141
*/

packages/playwright/src/mcp/program.ts

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

1717
import { ProgramOption } from 'playwright-core/lib/utilsBundle';
1818
import * as mcpServer from './sdk/server';
19-
import { commaSeparatedList, dotenvFileLoader, headerParser, resolveCLIConfig, semicolonSeparatedList } from './browser/config';
19+
import { commaSeparatedList, dotenvFileLoader, headerParser, numberParser, 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,8 @@ export function decorateCommand(command: Command, version: string) {
5252
.option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.')
5353
.option('--secrets <path>', 'path to a file containing secrets in the dotenv format', dotenvFileLoader)
5454
.option('--storage-state <path>', 'path to the storage state file for isolated sessions.')
55+
.option('--timeout-action <timeout>', 'specify action timeout in milliseconds, defaults to 5000ms', numberParser)
56+
.option('--timeout-navigation <timeout>', 'specify navigation timeout in milliseconds, defaults to 60000ms', numberParser)
5557
.option('--user-agent <ua string>', 'specify user agent string')
5658
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
5759
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')

tests/mcp/timeouts.spec.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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 { test, expect } from './fixtures';
18+
19+
test('action timeout (default)', async ({ client, server }) => {
20+
server.setContent('/', `
21+
<!DOCTYPE html>
22+
<html>
23+
<input readonly></input>
24+
</html>
25+
`, 'text/html');
26+
27+
await client.callTool({
28+
name: 'browser_navigate',
29+
arguments: {
30+
url: server.PREFIX,
31+
},
32+
});
33+
34+
expect(await client.callTool({
35+
name: 'browser_type',
36+
arguments: {
37+
element: 'textbox',
38+
ref: 'e2',
39+
text: 'Hi!',
40+
submit: true,
41+
},
42+
})).toHaveResponse({
43+
result: expect.stringContaining(`Timeout 5000ms exceeded.`),
44+
});
45+
});
46+
47+
test('action timeout (custom)', async ({ startClient, server }) => {
48+
const { client } = await startClient({ args: [`--timeout-action=1234`] });
49+
server.setContent('/', `
50+
<!DOCTYPE html>
51+
<html>
52+
<input readonly></input>
53+
</html>
54+
`, 'text/html');
55+
56+
await client.callTool({
57+
name: 'browser_navigate',
58+
arguments: {
59+
url: server.PREFIX,
60+
},
61+
});
62+
63+
expect(await client.callTool({
64+
name: 'browser_type',
65+
arguments: {
66+
element: 'textbox',
67+
ref: 'e2',
68+
text: 'Hi!',
69+
submit: true,
70+
},
71+
})).toHaveResponse({
72+
result: expect.stringContaining(`Timeout 1234ms exceeded.`),
73+
});
74+
});
75+
76+
test('navigation timeout', async ({ startClient, server }) => {
77+
const { client } = await startClient({ args: [`--timeout-navigation=1234`] });
78+
server.setRoute('/slow', async () => {
79+
await new Promise(f => setTimeout(f, 1500));
80+
return new Response('OK');
81+
});
82+
server.setContent('/', `
83+
<!DOCTYPE html>
84+
<html>
85+
<input readonly></input>
86+
</html>
87+
`, 'text/html');
88+
89+
expect(await client.callTool({
90+
name: 'browser_navigate',
91+
arguments: {
92+
url: server.PREFIX + '/slow',
93+
},
94+
})).toHaveResponse({
95+
result: expect.stringContaining(`Timeout 1234ms exceeded.`),
96+
});
97+
});

0 commit comments

Comments
 (0)