Files
chrome-devtools-mcp/tests/index.test.ts
Alex Rudenko 8a8d8771c2 feat: run independent tool calls in parallel with a TaskRunner (#3243)
## Summary

`ToolHandler` held one Puppeteer `Mutex` for every tool call. This PR
replaces it with a central `TaskRunner` that creates `TaskQueue` objects
and runs every task from one ordered list. A queue has an optional
parent queue. A task on a queue starts when every earlier task on the
same queue, an ancestor, or a descendant has finished. Tasks on
unrelated queues run at the same time. Waiting tasks count, so a waiting
task blocks later related tasks and no task starves.

```
TaskRunner (root)
├── context queue (McpContext)
│   ├── page 1 queue
│   └── page 2 queue
└── heap queue (HeapSnapshotManager)
    ├── file a queue
    └── file b queue
```

## User-visible changes

- Heap snapshot tools run in parallel with browser tools.
- `close_heapsnapshot` waits for running reads of the same file.
- With `--pageIdRouting`, page tools that declare `concurrency: 'page'`
and target different pages run in parallel. No tool declares it in this
PR; PR 3 of the stack does.
- `get_devtools_comments` runs at the same time as any other tool.
Before, only its handler ran unlocked.
- A tool call that waits on a page that closes fails with `Page N is
closed.`
- Two first tool calls that wait for the initial `roots/list` share one
request. Before, the second call validated paths against no roots and
failed with "Access denied".

## API

`src/utils/TaskRunner.ts` (new, ~170 lines):

| API | Behavior |
| --- | --- |
| `runner.createQueue(parent?)` | Creates a queue. Without `parent`,
only `pause()` relates to it. |
| `runner.pause()` | Enters the list ahead of waiting tasks and relates
to every queue. Resolves with a guard when running tasks have finished.
Disposing the guard resumes. |
| `queue.scheduleTask()` | Resolves with a guard when no earlier task on
a related queue runs. Callers write `using guard = await
queue.scheduleTask();`. Rejects when the queue is disposed while the
task waits. |
| `queue.dispose(reason)` | Rejects waiting tasks with `reason` and
rejects later `scheduleTask()` calls. The current holder keeps the
queue. |

Every hold is a `Disposable`, released by `using`. A second dispose of a
guard throws. There are no lock modes: holding a queue is exclusive on
that queue and implicitly shared on its ancestors.

Tool definitions declare a `concurrency` mode (`ToolConcurrency`):

| Mode | Queue | Tools |
| --- | --- | --- |
| `'exclusive'` (default) | context queue | every tool that touches
browser-global state |
| `'page'` | `pageTaskQueue(pageId)`; the context queue without page id
routing | page tools that touch only the target page |
| `'none'` | none | browserless tools; `get_devtools_comments` |

`yieldsToolLock` is gone. `get_devtools_comments` only reads page state
and waits for comment events, so it runs outside the runner with
`concurrency: 'none'`. The type system limits the modes per definition
kind: `defineTool` tools cannot take `'page'` or `'none'`, browserless
tools cannot take `'page'`.

## Changes

- `ToolHandler.handle()` resolves the context, picks the queue from
`rawArgs.pageId` and `tool.concurrency`, and holds it with `using guard
= await queue.scheduleTask()` for the rest of the upstream body, which
is unchanged. The disabled check, input parsing, `ensureBrowser()`, page
lookup, handler, and response formatting all run while the queue is
held. A rejected `scheduleTask()` (disposed queue) returns a tool error
result.
- `McpContext` takes a `taskRunner` option, owns `taskQueue`, and
manages page queues keyed by page id in `pageTaskQueue(pageId)`.
`#disposeMcpPage()` disposes the page and its queue in one place.
- `McpContext.createPagesSnapshot()` runs under an internal `Mutex`
because two page tasks can call it at the same time.
- `HeapSnapshotManager` takes the `TaskRunner`, owns a heap queue, and
creates one child queue per absolute file path in
`#scheduleTask(filePath?)`. Every read method starts with `using guard =
await this.#scheduleTask(filePath)`; the two-file diff methods hold the
heap queue; `disposeSnapshot()` holds the file queue.
- `McpServer` owns the `TaskRunner`, passes it to `McpContext` and
`HeapSnapshotManager`, and holds `using guard = await
taskRunner.pause()` in `applyConfig()` and in the `--watchConfig` reload
instead of the mutex.
- `McpServer.#getContext()` stores the initial `roots/list` request as a
promise.
- `defineBrowserlessTool` sets `concurrency: 'none'`.
- `McpPage` is unchanged.

No tool schema or description changes, so `npm run gen` is not needed.

## Stack

1. `refactor: extract errorResult() and routedPageId() in ToolHandler`
(#3251).
2. **This PR**: the runner. No tool sets `concurrency: 'page'` yet, so
page tools still serialize on the context queue after this PR alone.
3. `feat: run page-scoped tools on different pages in parallel` (#3252):
adds `concurrency: 'page'` to the page tools.

## Related issues

No open issue is closed by this PR. Two are related:

- #2934 (a stuck page-side call blocks every other tool): once PR 3
lands, a stuck `'page'` tool (for example `execute_webmcp_tool`) no
longer blocks tools on other pages or heap snapshot tools. It still
blocks `'exclusive'` tools such as `list_pages`, and `evaluate_script`
is still `'exclusive'`. Cancellation is not addressed.
- #2939 (`take_snapshot` stalls for 180 s): same partial effect. The
stall itself is not addressed.

## Tests

- `tests/utils/TaskRunner.test.ts` (new): unrelated queues run together,
different roots run together, FIFO on one queue, parent waits for child,
child waits for parent, child waits behind a waiting parent, second
dispose of a guard throws, release on throw; pause (stops new tasks,
resolves after running tasks finish, goes ahead of waiting tasks);
dispose (rejects waiting tasks with the reason, rejects later runs,
parent runs after a rejected child).
- `tests/ToolHandler.test.ts`: `concurrency: 'exclusive'` suite
(serializes exclusive tools; a `'none'` page tool runs while an
exclusive tool holds the context queue), `concurrency: 'page'` suite
(different pages in parallel, same page serialized, exclusive tool
queued FIFO behind page tools, page queue held until the response is
built, call runs on the page queue, page tool without `pageId` runs on
the context queue, page queue released on throw, disposed page queue
fails the waiting call with `Page 1 is closed.`), browserless suite
(does not enter the context queue, runs while an exclusive tool runs),
`update` suite uses `taskRunner.pause()`.
- `tests/McpContext.test.ts`: one page queue per id with the context
queue as parent; disposing a page rejects a waiting task with `Page N is
closed.`; `createPagesSnapshot()` serializes concurrent calls.
- `tests/index.test.ts`: two concurrent first calls share one
`roots/list` request.
- `tests/processors/HeapSnapshotManager.test.ts`: `disposeSnapshot()`
waits for an in-flight read; a read after the dispose loads the file
again.
- `tests/tools/comments.test.ts`: `get_devtools_comments` has
`concurrency: 'none'`.
- `tests/mocks.ts`: `createMockMcpContext()` carries a real
`TaskRunner`, `taskQueue`, and `pageTaskQueue()`.
2026-10-20 12:59:10 +00:00

489 lines
14 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import os from 'node:os';
import path from 'node:path';
import {describe, it} from 'node:test';
import {pathToFileURL} from 'node:url';
import {executablePath} from 'puppeteer';
import {Client, type ClientCapabilities} from '@modelcontextprotocol/client';
import {StdioClientTransport} from '@modelcontextprotocol/client/stdio';
import type {TextContent} from '../src/third_party/index.js';
import {createTempDir} from './utils.js';
// Each test starts its own server process, so the tests run in parallel. Most
// of the test time is server start and waits, not CPU work.
describe('e2e', {concurrency: true}, () => {
async function withClient(
cb: (client: Client) => Promise<void>,
extraArgs: string[] = [],
options: {
capabilities?: ClientCapabilities;
versionNegotiation?: {mode: 'auto' | 'legacy' | {pin: string}};
} = {},
) {
let attempt = 1;
while (attempt <= 3) {
const transport = new StdioClientTransport({
command: 'node',
args: [
'build/src/bin/chrome-devtools-mcp.js',
'--headless',
'--isolated',
'--executable-path',
await executablePath(),
...extraArgs,
],
env: {...process.env, CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true'},
});
const client = new Client(
{
name: 'e2e-test',
version: '1.0.0',
},
{
capabilities: options.capabilities ?? {},
...(options.versionNegotiation
? {versionNegotiation: options.versionNegotiation}
: {}),
},
);
try {
await client.connect(transport);
await cb(client);
return;
} catch (error) {
if (
attempt === 3 ||
!(error instanceof Error) ||
(!error.message.includes('timed out') &&
!error.message.includes('timeout'))
) {
throw error;
}
attempt++;
await new Promise(r => setTimeout(r, 1000));
} finally {
try {
await client.close();
} catch {
// Ignore close errors
}
}
}
}
it('connects and negotiates 2026-07-28 era', async () => {
await withClient(
async client => {
const result = await client.callTool({
name: 'list_pages',
arguments: {},
});
assert.ok(result.content);
},
[],
{versionNegotiation: {mode: 'auto'}},
);
});
it('calls a tool', async t => {
await withClient(async client => {
const result = await client.callTool({
name: 'list_pages',
arguments: {},
});
t.assert.snapshot(JSON.stringify(result.content));
});
});
it('calls a tool multiple times', async t => {
await withClient(async client => {
let result = await client.callTool({
name: 'list_pages',
arguments: {},
});
result = await client.callTool({
name: 'list_pages',
arguments: {},
});
t.assert.snapshot(JSON.stringify(result.content));
});
});
it('updates roots when client notifies', async () => {
const roots = [{uri: 'file:///test-root', name: 'test-root'}];
let resolvePromise: () => void;
const promise = new Promise<void>(resolve => {
resolvePromise = resolve;
});
await withClient(
async client => {
client.setRequestHandler('roots/list', () => {
resolvePromise();
return {roots};
});
await client.notification({
method: 'notifications/roots/list_changed',
});
// Wait for the server to process the notification and request roots
await promise;
},
[],
{
capabilities: {
roots: {listChanged: true},
},
},
);
});
it('combines configured filesystem roots with client roots', async () => {
using configuredRoot = createTempDir('.configured-root-', os.homedir());
using clientRoot = createTempDir('.client-root-', os.homedir());
await withClient(
async client => {
client.setRequestHandler('roots/list', () => {
return {
roots: [
{uri: pathToFileURL(clientRoot.path).href, name: 'client-root'},
],
};
});
for (const outputPath of [
path.join(configuredRoot.path, 'configured.png'),
path.join(clientRoot.path, 'client.png'),
]) {
const result = await client.callTool({
name: 'take_screenshot',
arguments: {pageId: 1, filePath: outputPath},
});
assert.strictEqual(result.isError, undefined);
const content = result.content as TextContent[];
assert.match(content[0].text, /Saved screenshot to/);
}
},
[`--filesystem-root=${configuredRoot.path}`],
{
capabilities: {
roots: {listChanged: true},
},
},
);
});
it('denies file access if roots list is empty', async () => {
await withClient(
async client => {
client.setRequestHandler('roots/list', () => {
return {roots: []};
});
const result = await client.callTool({
name: 'take_screenshot',
arguments: {
pageId: 1,
filePath: path.resolve(os.homedir(), 'test.png'),
},
});
assert.strictEqual(result.isError, true);
const content = result.content as TextContent[];
assert.match(content[0].text, /Access denied/);
},
[],
{
capabilities: {
roots: {listChanged: true},
},
},
);
});
it('allows file access if roots capability is missing', async () => {
using tempDir = createTempDir();
await withClient(
async client => {
// Use os.tmpdir() rather than a hardcoded /tmp path.
// On macOS, os.tmpdir() returns /var/folders/... (not /tmp), so a
// hardcoded /tmp path is outside the allowed root after the
// validatePath fix and would be rejected with Access denied.
const result = await client.callTool({
name: 'take_screenshot',
arguments: {
pageId: 1,
filePath: path.join(tempDir.path, 'test.png'),
},
});
assert.strictEqual(result.isError, undefined);
const content = result.content as TextContent[];
assert.match(content[0].text, /Saved screenshot to/);
},
[],
{
capabilities: {},
},
);
});
it('makes concurrent first calls wait for the same roots/list', async () => {
const fixturesRoot = path.resolve('tests/fixtures');
await withClient(
async client => {
// Answers late enough that both calls start before the roots arrive.
// Browserless tools hold no lock, so without a shared initial roots
// request the second call validates its path against no roots.
client.setRequestHandler('roots/list', async () => {
await new Promise(resolve => setTimeout(resolve, 1_000));
return {
roots: [{uri: pathToFileURL(fixturesRoot).href, name: 'fixtures'}],
};
});
const results = await Promise.all([
client.callTool({
name: 'get_heapsnapshot_summary',
arguments: {
filePath: path.join(fixturesRoot, 'example.heapsnapshot'),
},
}),
client.callTool({
name: 'get_heapsnapshot_summary',
arguments: {
filePath: path.join(fixturesRoot, 'example.heapsnapshot'),
},
}),
]);
for (const result of results) {
const content = result.content as TextContent[];
assert.strictEqual(result.isError, undefined, content[0]?.text);
}
},
['--memoryDebugging'],
{
capabilities: {
roots: {listChanged: true},
},
},
);
});
it('does not block tools if the client never answers roots/list', async () => {
await withClient(
async client => {
// A client that negotiates roots but never responds. getContext()
// awaits updateRoots() while holding the context lock, so an unbounded
// request would stall this call for the SDK default of 60s.
client.setRequestHandler('roots/list', () => {
return new Promise<never>(() => {
// Intentionally never settles
});
});
const start = Date.now();
// Raise the client-side timeout above the SDK default so an unbounded
// roots request surfaces as the assertion below rather than a timeout
const result = await client.callTool(
{
name: 'list_pages',
arguments: {},
},
{timeout: 90_000},
);
const elapsed = Date.now() - start;
assert.strictEqual(result.isError, undefined);
// Bounded roots request plus browser launch settles well under this,
// leaving room for a slow CI runner while still catching the 60s stall
assert.ok(
elapsed < 45_000,
`list_pages took ${elapsed}ms, expected the bounded roots request to settle well before the 60s SDK default`,
);
},
[],
{
capabilities: {
roots: {listChanged: true},
},
},
);
});
it('still applies roots from a client slower than the bound', async () => {
using workspace = createTempDir('.roots-slow-client-', os.homedir());
await withClient(
async client => {
// Answers after the bound the blocking call uses, so the roots only
// arrive via the background listing
client.setRequestHandler('roots/list', async () => {
await new Promise(resolve => setTimeout(resolve, 6_000));
return {
roots: [
{uri: pathToFileURL(workspace.path).href, name: 'workspace'},
],
};
});
await client.callTool({name: 'list_pages', arguments: {}});
// Retries until the background listing delivers the roots, instead of
// a fixed wait.
const deadline = Date.now() + 10_000;
let result: Awaited<ReturnType<Client['callTool']>>;
do {
result = await client.callTool({
name: 'take_screenshot',
arguments: {
pageId: 1,
filePath: path.join(workspace.path, 'shot.png'),
},
});
if (!result.isError) {
break;
}
await new Promise(resolve => setTimeout(resolve, 250));
} while (Date.now() < deadline);
// Asserted before isError so a denial reports the path it rejected
const content = result.content as TextContent[];
assert.match(content[0].text, /Saved screenshot to/);
assert.strictEqual(result.isError, undefined);
},
[],
{
capabilities: {
roots: {listChanged: true},
},
},
);
});
describe('Dialogs', {concurrency: true}, () => {
function getFirstText(
result: Awaited<ReturnType<Client['callTool']>>,
): string {
const first = Array.isArray(result.content)
? result.content[0]
: undefined;
return first && 'text' in first && typeof first.text === 'string'
? first.text
: '';
}
async function createNewPageAndTriggerDialog(client: Client) {
// Navigate to a page with a button that triggers a dialog on click
const newPageResult = await client.callTool({
name: 'new_page',
arguments: {
url: `data:text/html,<button id="test" onclick="alert('test dialog')">Click me</button>`,
},
});
if (newPageResult.isError) {
throw new Error(getFirstText(newPageResult));
}
const snapshotResult = await client.callTool({
name: 'take_snapshot',
arguments: {
pageId: 2,
},
});
if (snapshotResult.isError) {
throw new Error(getFirstText(snapshotResult));
}
const snapshotText = getFirstText(snapshotResult);
const match = snapshotText.match(/uid=(\d+_\d+)\s+button "Click me"/);
const uid = match ? match[1] : '1_1';
// Trigger the dialog
const result = await client.callTool({
name: 'click',
arguments: {
pageId: 2,
uid,
},
});
if (result.isError) {
throw new Error(getFirstText(result));
}
return result;
}
it('returns blocked message when dialog is opened during tool execution', async t => {
await withClient(async client => {
const result = await createNewPageAndTriggerDialog(client);
t.assert.snapshot(JSON.stringify(result));
});
});
it('when dialog is open and tool is blocked, returns an error', async t => {
await withClient(async client => {
await createNewPageAndTriggerDialog(client);
const result = await client.callTool({
name: 'take_screenshot',
arguments: {
pageId: 2,
// Use os.tmpdir() so validatePath passes on macOS/Windows before
// reaching the dialog-blocked check.
filePath: path.join(os.tmpdir(), 'test.png'),
},
});
t.assert.snapshot(JSON.stringify(result));
});
});
it('when dialog is open and tool is not blocked, executes tool', async t => {
await withClient(async client => {
await createNewPageAndTriggerDialog(client);
const result = await client.callTool({
name: 'new_page',
arguments: {
url: `data:text/html,<h1>New</h1>`,
},
});
t.assert.snapshot(JSON.stringify(result));
});
});
it('when dialog is open, evaluate_script returns an error', async () => {
await withClient(async client => {
await createNewPageAndTriggerDialog(client);
const result = await client.callTool(
{
name: 'evaluate_script',
arguments: {
pageId: 2,
function: '() => 1',
},
},
{timeout: 10_000},
);
assert.strictEqual(result.isError, true);
assert.match(
getFirstText(result),
/A dialog is open \(alert: test dialog\)\./,
);
});
});
});
});