feat: support configurable filesystem roots (#2605)

Fixes #2598

This lets people set filesystem roots through the MCP server or the
Chrome DevTools CLI. Multiple roots work together with any roots sent by
the MCP client, and temp directory access stays the same.

I added tests for the flags and root handling.

---------

Co-authored-by: Joseph B <289838966+joebasrawi@users.noreply.github.com>
Co-authored-by: Joseph B <joebasrawi@users.noreply.github.com>
This commit is contained in:
Joseph B
2026-09-03 09:42:37 +00:00
committed by GitHub
parent b2007ab866
commit d00e6f8239
10 changed files with 201 additions and 22 deletions
+10
View File
@@ -11,6 +11,16 @@ npm i chrome-devtools-mcp@latest -g
chrome-devtools status # check if install worked.
```
The CLI enables unrestricted filesystem access by default. Use `--workspace`
to limit file tools to specific directories. Repeat the flag for more than one
directory.
`--allow-unrestricted-paths` still works on its own for compatibility, but it
cannot be combined with `--workspace`.
```sh
chrome-devtools start --workspace=/path/to/project --workspace=/path/to/output
```
## How it works
The CLI acts as a client to a background `chrome-devtools-mcp` daemon (uses Unix sockets on Linux/Mac and named pipes on Windows).
+6 -1
View File
@@ -85,7 +85,7 @@ The Chrome DevTools MCP server supports the following configuration option:
- **Type:** boolean
- **Default:** `false`
- **`--memoryDebugging`/ `--memory-debugging`, `-experimentalMemory`**
- **`--memoryDebugging`/ `--memory-debugging`, `--experimentalMemory`**
Whether to enable memory debugging tools.
- **Type:** boolean
- **Default:** `false`
@@ -221,6 +221,11 @@ The Chrome DevTools MCP server supports the following configuration option:
- **Type:** boolean
- **Default:** `false`
- **`--filesystemRoot`/ `--filesystem-root`, `--workspace`**
A directory that filesystem tools are allowed to access. May be specified more than once.
- **Type:** array
- **Default:** `OS temp directory`
<!-- END AUTO GENERATED OPTIONS -->
Pass them via the `args` property in the JSON configuration. For example:
+4 -2
View File
@@ -139,7 +139,9 @@ function generateConfigOptionsMarkdown(): string {
continue;
}
const aliasText = optionConfig.alias ? `, \`-${optionConfig.alias}\`` : '';
const aliasText = optionConfig.alias
? `, \`${optionConfig.alias.length === 1 ? '-' : '--'}${optionConfig.alias}\``
: '';
const description = optionConfig.description || optionConfig.describe || '';
// Convert camelCase to dash-case
@@ -164,7 +166,7 @@ function generateConfigOptionsMarkdown(): string {
}
// Add default if available
markdown += ` - **Default:** \`${optionConfig.default ?? 'false'}\`\n`;
markdown += ` - **Default:** \`${optionConfig.defaultDescription ?? optionConfig.default ?? 'false'}\`\n`;
markdown += '\n';
}
+20 -9
View File
@@ -6,6 +6,9 @@
import type {YargsOptions} from '../third_party/index.js';
import {yargs, hideBin} from '../third_party/index.js';
import os from 'node:os';
export const DEFAULT_FILESYSTEM_ROOT = [os.tmpdir()];
export const mcpOptions = {
autoConnect: {
@@ -392,12 +395,21 @@ export const mcpOptions = {
allowUnrestrictedPaths: {
type: 'boolean',
default: false,
deprecated: 'Use --workspace=/ instead.',
describe:
'If set, disables the default path restriction that applies when the MCP client does not negotiate ' +
'the roots capability. By default, file-writing tools are restricted to the OS temp directory when ' +
'no roots are configured. Use this only when connecting a trusted local client that does not implement ' +
'MCP roots and requires access to paths outside the temp directory.',
},
filesystemRoot: {
type: 'array',
alias: 'workspace',
default: DEFAULT_FILESYSTEM_ROOT,
defaultDescription: 'OS temp directory',
describe:
'A directory that filesystem tools are allowed to access. May be specified more than once.',
},
} satisfies Record<string, YargsOptions>;
export type ParsedArguments = ReturnType<typeof parseArguments>;
@@ -406,11 +418,6 @@ export function getMcpOptionsForViaCli(): typeof mcpOptions {
if (!('default' in mcpOptions.headless)) {
throw new Error('headless cli option unexpectedly does not have a default');
}
if (!('default' in mcpOptions.allowUnrestrictedPaths)) {
throw new Error(
'allowUnrestrictedPaths cli option unexpectedly does not have a default',
);
}
if (!('default' in mcpOptions.experimentalStructuredContent)) {
throw new Error(
'experimentalStructuredContent cli option unexpectedly does not have a default',
@@ -422,10 +429,6 @@ export function getMcpOptionsForViaCli(): typeof mcpOptions {
return {
...mcpOptions,
allowUnrestrictedPaths: {
...mcpOptions.allowUnrestrictedPaths,
default: true,
},
headless: {
...mcpOptions.headless,
default: true,
@@ -470,6 +473,14 @@ export function parser(
.options(options)
.showHelpOnFail(false, 'Specify --help for available options')
.middleware(args => {
if (isViaCli && args.filesystemRoot === DEFAULT_FILESYSTEM_ROOT) {
const cliFilesystemArgs: {
allowUnrestrictedPaths?: boolean;
filesystemRoot?: unknown;
} = args;
cliFilesystemArgs.allowUnrestrictedPaths = true;
cliFilesystemArgs.filesystemRoot = undefined;
}
// We can't set default in the options else
// Yargs will complain
if (
+6
View File
@@ -132,6 +132,12 @@ export function serializeArgs(
continue;
}
const value = argv[key];
const option = options[key];
// Yargs reuses the option `default` object; skip it so the daemon parser
// still sees the original default (needed for filesystemRoot identity).
if (option !== undefined && value === option.default) {
continue;
}
const kebabKey = key.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`);
if (typeof value === 'boolean') {
+32 -9
View File
@@ -5,6 +5,8 @@
*/
import type fs from 'node:fs';
import path from 'node:path';
import {pathToFileURL} from 'node:url';
import type {Channel} from './browser.js';
import {ensureBrowserConnected, ensureBrowserLaunched} from './browser.js';
@@ -55,11 +57,11 @@ export class McpServer {
#context?: McpContext;
/**
* Roots are client state rather than browser state, so the last listing stays
* valid across browser reconnects and only the client can invalidate it, via
* the `roots/list_changed` notification handled below
* Client roots stay valid across browser reconnects and only the client can
* invalidate them through a `roots/list_changed` notification. CLI-configured
* roots are read from `#serverArgs` when combining roots.
*/
#lastRoots?: Root[];
#lastClientRoots?: Root[];
#toolMutex = new Mutex();
private constructor(
@@ -107,7 +109,10 @@ export class McpServer {
void this.#updateRoots();
},
);
} else if (!this.#serverArgs.allowUnrestrictedPaths) {
} else if (
!this.#serverArgs.allowUnrestrictedPaths &&
(this.#serverArgs.filesystemRoot ?? []).length === 0
) {
console.warn(
'[chrome-devtools-mcp] The connecting client did not negotiate the MCP roots ' +
'capability. File-writing tools will be restricted to the OS temp directory. ' +
@@ -158,6 +163,24 @@ export class McpServer {
await loadIssueDescriptions();
}
#combinedRoots(): Root[] | undefined {
const configuredRoots = (
this.#serverArgs.allowUnrestrictedPaths
? []
: (this.#serverArgs.filesystemRoot ?? [])
).map(root => {
const rootPath = path.resolve(String(root));
return {
uri: pathToFileURL(rootPath).href,
name: path.basename(rootPath) || rootPath,
};
});
if (configuredRoots.length === 0 && this.#lastClientRoots === undefined) {
return undefined;
}
return [...configuredRoots, ...(this.#lastClientRoots ?? [])];
}
/**
* `timeout` is only passed where a tool call is waiting on the result the
* background refreshes below block nobody, so bounding them would just discard
@@ -173,8 +196,8 @@ export class McpServer {
ListRootsResultSchema,
timeout === undefined ? undefined : {timeout},
);
this.#lastRoots = roots.roots;
this.#context?.setRoots(this.#lastRoots);
this.#lastClientRoots = roots.roots;
this.#context?.setRoots(this.#combinedRoots());
} catch (e) {
logger?.('Failed to list roots', e);
}
@@ -244,14 +267,14 @@ export class McpServer {
// Surfaces a one-time note in the next response after a reconnect.
reconnected: this.#context !== undefined,
});
if (this.#lastRoots === undefined) {
this.#context.setRoots(this.#combinedRoots());
if (this.#lastClientRoots === undefined) {
// Nothing listed yet, so this call has to wait bounded, since it is
// holding the tool mutex, and a later background refresh still lands
await this.#updateRoots(ROOTS_REQUEST_TIMEOUT);
} else {
// Carry the known roots over and refresh out of band, so a reconnect
// never pays for a client round-trip
this.#context.setRoots(this.#lastRoots);
void this.#updateRoots();
}
}
+4
View File
@@ -401,5 +401,9 @@
{
"name": "experimental_screencast_fps_present",
"flagType": "boolean"
},
{
"name": "filesystem_root_present",
"flagType": "boolean"
}
]
+46 -1
View File
@@ -7,7 +7,11 @@
import assert from 'node:assert';
import {describe, it} from 'node:test';
import {mcpOptions, parser} from '../src/config/mcp-options.js';
import {
DEFAULT_FILESYSTEM_ROOT,
mcpOptions,
parser,
} from '../src/config/mcp-options.js';
function parseArguments(argv: string[], env: NodeJS.ProcessEnv = {}) {
return parser('0.0.0', ['node', 'main.js', ...argv], env)
@@ -28,6 +32,7 @@ describe('cli args parsing', () => {
javascriptEvaluation: true,
redactNetworkHeaders: false,
allowUnrestrictedPaths: false,
filesystemRoot: DEFAULT_FILESYSTEM_ROOT,
memoryDebugging: false,
experimentalStructuredContent: false,
pageIdRouting: true,
@@ -150,6 +155,46 @@ describe('cli args parsing', () => {
});
});
describe('filesystem roots', () => {
it('parses filesystem roots', async () => {
const args = parseArguments([
'--filesystem-root=/tmp/one',
'--filesystem-root=/tmp/two',
]);
assert.deepStrictEqual(args.filesystemRoot, ['/tmp/one', '/tmp/two']);
});
it('parses workspace as an alias for filesystem roots', async () => {
const args = parseArguments([
'--workspace=/tmp/one',
'--workspace=/tmp/two',
]);
assert.deepStrictEqual(args.filesystemRoot, ['/tmp/one', '/tmp/two']);
});
it('still accepts unrestricted paths without an explicit root', async () => {
const args = parseArguments(['--allow-unrestricted-paths']);
assert.strictEqual(args.allowUnrestrictedPaths, true);
});
it('lets an explicit workspace override the CLI unrestricted default', async () => {
const args = parseArguments(['--viaCli', '--workspace=/tmp/one']);
assert.strictEqual(args.allowUnrestrictedPaths, false);
assert.deepStrictEqual(args.filesystemRoot, ['/tmp/one']);
});
it('keeps the CLI unrestricted default when no workspace is set', async () => {
const args = parseArguments(['--viaCli']);
assert.strictEqual(args.allowUnrestrictedPaths, true);
assert.strictEqual(args.filesystemRoot, undefined);
});
it('uses yargs default identity to detect an unset CLI workspace', async () => {
const args = parseArguments([]);
assert.strictEqual(args.filesystemRoot, DEFAULT_FILESYSTEM_ROOT);
});
});
it('parses ignore chrome args', async () => {
const args = parseArguments([
`--ignore-default-chrome-arg='--disable-extensions'`,
@@ -78,4 +78,32 @@ describe('chrome-devtools', () => {
await assertDaemonIsRunning(sessionId);
});
it('can start the daemon with a workspace', async () => {
const workspace = fs.mkdtempSync(
path.join(os.tmpdir(), 'chrome-devtools-workspace-'),
);
try {
const startResult = await runCli(
['start', '--workspace', workspace],
sessionId,
);
assert.strictEqual(
startResult.status,
0,
`start command failed: ${startResult.stderr}`,
);
const statusResult = await runCli(['status'], sessionId);
assert.strictEqual(statusResult.status, 0);
assert.ok(
statusResult.stdout.includes('--filesystem-root=') &&
statusResult.stdout.includes(path.basename(workspace)),
`workspace was not forwarded: ${statusResult.stdout}`,
);
} finally {
fs.rmSync(workspace, {recursive: true, force: true});
}
});
});
+45
View File
@@ -246,6 +246,51 @@ describe('e2e', () => {
);
});
it('combines configured filesystem roots with client roots', async () => {
const configuredRoot = await fs.promises.mkdtemp(
path.join(os.homedir(), '.configured-root-'),
);
const clientRoot = await fs.promises.mkdtemp(
path.join(os.homedir(), '.client-root-'),
);
try {
await withClient(
async client => {
client.setRequestHandler(ListRootsRequestSchema, () => {
return {
roots: [
{uri: pathToFileURL(clientRoot).href, name: 'client-root'},
],
};
});
for (const outputPath of [
path.join(configuredRoot, 'configured.png'),
path.join(clientRoot, '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}`],
{
capabilities: {
roots: {listChanged: true},
},
},
);
} finally {
await fs.promises.rm(configuredRoot, {recursive: true, force: true});
await fs.promises.rm(clientRoot, {recursive: true, force: true});
}
});
it('denies file access if roots list is empty', async () => {
await withClient(
async client => {