Skip to content

Commit c4943e1

Browse files
committed
Merge origin/main into screencast fps branch
2 parents 704f59d + 6e56c02 commit c4943e1

13 files changed

Lines changed: 121 additions & 76 deletions

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,11 @@ The Chrome DevTools MCP server supports the following configuration option:
759759
- **Type:** boolean
760760
- **Default:** `false`
761761

762+
- **`--allowUnrestrictedPaths`/ `--allow-unrestricted-paths`**
763+
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.
764+
- **Type:** boolean
765+
- **Default:** `false`
766+
762767
<!-- END AUTO GENERATED OPTIONS -->
763768

764769
Pass them via the `args` property in the JSON configuration. For example:

package-lock.json

Lines changed: 0 additions & 39 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/HeapSnapshotManager.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,9 @@ export class HeapSnapshotManager {
4444
{
4545
snapshot: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy;
4646
worker: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy;
47-
// TODO: use a multimap
48-
idToClassKey: Map<number, string>;
47+
// 1-indexed array where index is the class ID.
48+
idToClassKey: string[];
4949
classKeyToId: Map<string, number>;
50-
idGenerator: () => number;
5150
}
5251
>();
5352

@@ -65,9 +64,8 @@ export class HeapSnapshotManager {
6564
this.#snapshots.set(absolutePath, {
6665
snapshot,
6766
worker,
68-
idToClassKey: new Map<number, string>(),
67+
idToClassKey: [''],
6968
classKeyToId: new Map<string, number>(),
70-
idGenerator: createIdGenerator(),
7169
});
7270

7371
return snapshot;
@@ -114,9 +112,9 @@ export class HeapSnapshotManager {
114112
const cached = this.#getCachedSnapshot(filePath);
115113
let id = cached.classKeyToId.get(classKey);
116114
if (!id) {
117-
id = cached.idGenerator();
115+
id = cached.idToClassKey.length;
118116
cached.classKeyToId.set(classKey, id);
119-
cached.idToClassKey.set(id, classKey);
117+
cached.idToClassKey.push(classKey);
120118
}
121119
return id;
122120
}
@@ -287,7 +285,7 @@ export class HeapSnapshotManager {
287285
id: number,
288286
): Promise<string | undefined> {
289287
const cached = this.#getCachedSnapshot(filePath);
290-
return cached.idToClassKey.get(id);
288+
return cached.idToClassKey[id];
291289
}
292290

293291
async #loadSnapshot(

src/McpContext.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ interface McpContextOptions {
6969
allowList?: string[];
7070
// The block list of URL patterns to block loading resources.
7171
blocklist?: string[];
72+
// Whether to skip path validation when the client did not negotiate the roots
73+
// capability. When false (default), file-writing tools are restricted to the
74+
// OS temp directory. When true, the previous permissive behavior is restored.
75+
allowUnrestrictedPaths?: boolean;
7276
}
7377

7478
const DEFAULT_TIMEOUT = 5_000;
@@ -110,6 +114,7 @@ export class McpContext implements Context {
110114
#options: McpContextOptions;
111115
#heapSnapshotManager = new HeapSnapshotManager();
112116
#roots: Root[] | undefined = undefined;
117+
#allowUnrestrictedPaths: boolean;
113118

114119
private constructor(
115120
browser: Browser,
@@ -127,6 +132,7 @@ export class McpContext implements Context {
127132
this.logger = logger;
128133
this.#locatorClass = locatorClass;
129134
this.#options = options;
135+
this.#allowUnrestrictedPaths = options.allowUnrestrictedPaths ?? false;
130136

131137
this.#networkCollector = new NetworkCollector(this.browser);
132138

@@ -185,12 +191,9 @@ export class McpContext implements Context {
185191
return context;
186192
}
187193

188-
roots(): Root[] | undefined {
189-
if (this.#roots === undefined) {
190-
return undefined;
191-
}
194+
roots(): Root[] {
192195
return [
193-
...this.#roots,
196+
...(this.#roots ?? []),
194197
{
195198
uri: pathToFileURL(os.tmpdir()).href,
196199
name: 'temp',
@@ -206,10 +209,17 @@ export class McpContext implements Context {
206209
if (filePath === undefined) {
207210
return;
208211
}
209-
const roots = this.roots();
210-
if (roots === undefined) {
212+
// If the client never negotiated roots and the operator has explicitly
213+
// opted into unrestricted access via --allow-unrestricted-paths, restore
214+
// the previous permissive behavior and skip validation.
215+
if (this.#roots === undefined && this.#allowUnrestrictedPaths) {
211216
return;
212217
}
218+
// roots() always returns at least the temp directory, even if the
219+
// connecting client never negotiated the optional `roots` capability.
220+
// Path validation must not be skipped just because no workspace roots
221+
// were configured.
222+
const roots = this.roots();
213223

214224
let canonicalPath: string;
215225

src/bin/chrome-devtools-mcp-cli-options.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,15 @@ export const cliOptions = {
378378
'If true, redacts some of the network headers considered sensitive before returning to the client.',
379379
default: false,
380380
},
381+
allowUnrestrictedPaths: {
382+
type: 'boolean',
383+
default: false,
384+
describe:
385+
'If set, disables the default path restriction that applies when the MCP client does not negotiate ' +
386+
'the roots capability. By default, file-writing tools are restricted to the OS temp directory when ' +
387+
'no roots are configured. Use this only when connecting a trusted local client that does not implement ' +
388+
'MCP roots and requires access to paths outside the temp directory.',
389+
},
381390
} satisfies Record<string, YargsOptions>;
382391

383392
export type ParsedArguments = ReturnType<typeof parseArguments>;

src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ export async function createMcpServer(
8686
void updateRoots();
8787
},
8888
);
89+
} else if (!serverArgs.allowUnrestrictedPaths) {
90+
console.warn(
91+
'[chrome-devtools-mcp] The connecting client did not negotiate the MCP roots ' +
92+
'capability. File-writing tools will be restricted to the OS temp directory. ' +
93+
'To restore the previous unrestricted behavior, start the server with ' +
94+
'--allow-unrestricted-paths.',
95+
);
8996
}
9097
};
9198

@@ -146,6 +153,7 @@ export async function createMcpServer(
146153
performanceCrux: serverArgs.performanceCrux,
147154
allowList: allowlist,
148155
blocklist: blocklist,
156+
allowUnrestrictedPaths: serverArgs.allowUnrestrictedPaths,
149157
});
150158
await updateRoots();
151159
}

src/telemetry/flag_usage_metrics.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,16 @@
362362
"EXPERIMENTAL_DATA_FORMAT_GCF"
363363
]
364364
},
365+
{
366+
"name": "allow_unrestricted_paths_present",
367+
"flagType": "boolean",
368+
"isDeprecated": true
369+
},
370+
{
371+
"name": "allow_unrestricted_paths",
372+
"flagType": "boolean",
373+
"isDeprecated": true
374+
},
365375
{
366376
"name": "experimental_screencast_fps_present",
367377
"flagType": "boolean"

tests/McpContext.test.ts

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -248,17 +248,20 @@ describe('McpContext', () => {
248248
sinon.stub(context, 'getNetworkRequestById').returns(mockRequest);
249249
sinon.stub(context, 'getNetworkRequestStableId').returns(789);
250250

251+
// Use os.tmpdir() so validatePath passes on all platforms (macOS tmpdir
252+
// is /var/folders/..., not /tmp, so hardcoded /tmp paths are rejected).
253+
const reqFilePath = path.join(os.tmpdir(), 'req.txt');
254+
const resFilePath = path.join(os.tmpdir(), 'res.txt');
255+
251256
// We stub NetworkFormatter.from to avoid actual file system writes and verify arguments
252257
const fromStub = sinon
253258
.stub(NetworkFormatter, 'from')
254259
.callsFake(async (_req, opts) => {
255-
// Verify we received the file paths
256-
assert.strictEqual(opts?.requestFilePath, '/tmp/req.txt');
257-
assert.strictEqual(opts?.responseFilePath, '/tmp/res.txt');
258-
// Return a dummy formatter that behaves as if it saved files
259-
// We need to create a real instance or mock one.
260-
// Since constructor is private, we can't easily new it up.
261-
// But we can return a mock object.
260+
// Verify we received the platform-correct file paths
261+
assert.strictEqual(opts?.requestFilePath, reqFilePath);
262+
assert.strictEqual(opts?.responseFilePath, resFilePath);
263+
// Return fixed strings in toJSONDetailed so the snapshot is stable
264+
// across platforms (os.tmpdir() differs on macOS vs Linux/Windows).
262265
return {
263266
toStringDetailed: () => 'Detailed string',
264267
toJSONDetailed: () => ({
@@ -269,8 +272,8 @@ describe('McpContext', () => {
269272
});
270273

271274
response.attachNetworkRequest(789, {
272-
requestFilePath: '/tmp/req.txt',
273-
responseFilePath: '/tmp/res.txt',
275+
requestFilePath: reqFilePath,
276+
responseFilePath: resFilePath,
274277
});
275278
const result = await response.handle('test', context);
276279

@@ -340,10 +343,23 @@ describe('McpContext', () => {
340343
});
341344
});
342345

343-
it('validatePath allows all paths if roots are undefined (legacy)', async () => {
346+
it('validatePath allows all paths if roots are undefined and allowUnrestrictedPaths is set', async () => {
347+
await withMcpContext(
348+
async (_response, context) => {
349+
context.setRoots(undefined);
350+
await context.validatePath(path.resolve(os.homedir(), 'anywhere.txt'));
351+
},
352+
{allowUnrestrictedPaths: true},
353+
);
354+
});
355+
356+
it('validatePath denies paths outside tmpdir if roots are undefined and allowUnrestrictedPaths is not set', async () => {
344357
await withMcpContext(async (_response, context) => {
345-
context.setRoots(undefined);
346-
await context.validatePath(path.resolve(os.homedir(), 'anywhere.txt'));
358+
// setRoots() never called — simulates a client that skips roots capability.
359+
const outsidePath = path.resolve(os.homedir(), 'anywhere.txt');
360+
await assert.rejects(context.validatePath(outsidePath), /Access denied/);
361+
// Temp dir must still be reachable.
362+
await context.validatePath(path.join(os.tmpdir(), 'test.txt'));
347363
});
348364
});
349365

tests/cli.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ describe('cli args parsing', () => {
3232
usageStatistics: true,
3333
'redact-network-headers': false,
3434
redactNetworkHeaders: false,
35+
'allow-unrestricted-paths': false,
36+
allowUnrestrictedPaths: false,
3537
};
3638

3739
it('parses with default args', async () => {

0 commit comments

Comments
 (0)