Skip to content

Commit 51e3291

Browse files
committed
feat: support client roots feature
1 parent dbddb2e commit 51e3291

14 files changed

Lines changed: 188 additions & 6 deletions

src/McpContext.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import fs from 'node:fs/promises';
88
import path from 'node:path';
9+
import {fileURLToPath} from 'node:url';
910

1011
import type {TargetUniverse} from './DevtoolsUtils.js';
1112
import {UniverseManager} from './DevtoolsUtils.js';
@@ -18,6 +19,8 @@ import {
1819
type ListenerMap,
1920
type UncaughtError,
2021
} from './PageCollector.js';
22+
import {Locator} from './third_party/index.js';
23+
import {PredefinedNetworkConditions} from './third_party/index.js';
2124
import type {
2225
Browser,
2326
BrowserContext,
@@ -31,8 +34,7 @@ import type {
3134
Extension,
3235
} from './third_party/index.js';
3336
import type {DevTools} from './third_party/index.js';
34-
import {Locator} from './third_party/index.js';
35-
import {PredefinedNetworkConditions} from './third_party/index.js';
37+
import type {Root} from './third_party/index.js';
3638
import {listPages} from './tools/pages.js';
3739
import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js';
3840
import type {Context, SupportedExtensions} from './tools/ToolDefinition.js';
@@ -90,6 +92,7 @@ export class McpContext implements Context {
9092
#locatorClass: typeof Locator;
9193
#options: McpContextOptions;
9294
#heapSnapshotManager = new HeapSnapshotManager();
95+
#roots: Root[] = [];
9396

9497
private constructor(
9598
browser: Browser,
@@ -154,6 +157,34 @@ export class McpContext implements Context {
154157
return context;
155158
}
156159

160+
roots(): Root[] {
161+
return this.#roots;
162+
}
163+
164+
setRoots(roots: Root[]): void {
165+
this.#roots = roots;
166+
}
167+
168+
validatePath(filePath: string): void {
169+
const roots = this.roots();
170+
if (roots.length === 0) {
171+
return;
172+
}
173+
const absolutePath = path.resolve(filePath);
174+
for (const root of roots) {
175+
const rootPath = path.resolve(fileURLToPath(root.uri));
176+
if (
177+
absolutePath === rootPath ||
178+
absolutePath.startsWith(rootPath + path.sep)
179+
) {
180+
return;
181+
}
182+
}
183+
throw new Error(
184+
`Access denied: path ${filePath} is not within any of the workspace roots ${JSON.stringify(roots)}.`,
185+
);
186+
}
187+
157188
resolveCdpRequestId(page: McpPage, cdpRequestId: string): number | undefined {
158189
if (!cdpRequestId) {
159190
this.logger('no network request');
@@ -643,13 +674,21 @@ export class McpContext implements Context {
643674
data: Uint8Array<ArrayBufferLike>,
644675
filename: string,
645676
): Promise<{filepath: string}> {
677+
if (
678+
filename.includes('/') ||
679+
filename.includes('\\') ||
680+
filename.includes('..')
681+
) {
682+
throw new Error(`Invalid filename: ${filename}`);
683+
}
646684
return await saveTemporaryFile(data, filename);
647685
}
648686
async saveFile(
649687
data: Uint8Array<ArrayBufferLike>,
650688
clientProvidedFilePath: string,
651689
extension: SupportedExtensions,
652690
): Promise<{filename: string}> {
691+
this.validatePath(clientProvidedFilePath);
653692
try {
654693
const filePath = ensureExtension(
655694
path.resolve(clientProvidedFilePath),
@@ -721,6 +760,7 @@ export class McpContext implements Context {
721760
}
722761

723762
async installExtension(extensionPath: string): Promise<string> {
763+
this.validatePath(extensionPath);
724764
const id = await this.browser.installExtension(extensionPath);
725765
return id;
726766
}
@@ -751,25 +791,29 @@ export class McpContext implements Context {
751791
async getHeapSnapshotAggregates(
752792
filePath: string,
753793
): Promise<Record<string, AggregatedInfoWithUid>> {
794+
this.validatePath(filePath);
754795
return await this.#heapSnapshotManager.getAggregates(filePath);
755796
}
756797

757798
async getHeapSnapshotStats(
758799
filePath: string,
759800
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.Statistics> {
801+
this.validatePath(filePath);
760802
return await this.#heapSnapshotManager.getStats(filePath);
761803
}
762804

763805
async getHeapSnapshotStaticData(
764806
filePath: string,
765807
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.StaticData | null> {
808+
this.validatePath(filePath);
766809
return await this.#heapSnapshotManager.getStaticData(filePath);
767810
}
768811

769812
async getHeapSnapshotNodesByUid(
770813
filePath: string,
771814
uid: number,
772815
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
816+
this.validatePath(filePath);
773817
return await this.#heapSnapshotManager.getNodesByUid(filePath, uid);
774818
}
775819
}

src/index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import {
2121
McpServer,
2222
type CallToolResult,
2323
SetLevelRequestSchema,
24+
ListRootsResultSchema,
25+
RootsListChangedNotificationSchema,
2426
} from './third_party/index.js';
2527
import {ToolCategory} from './tools/categories.js';
2628
import type {DefinedPageTool, ToolDefinition} from './tools/ToolDefinition.js';
@@ -62,6 +64,24 @@ export async function createMcpServer(
6264
if (clientName) {
6365
clearcutLogger?.setClientName(clientName);
6466
}
67+
const updateRoots = async () => {
68+
try {
69+
const roots = await server.server.request(
70+
{method: 'roots/list'},
71+
ListRootsResultSchema,
72+
);
73+
context.setRoots(roots.roots);
74+
} catch (e) {
75+
logger('Failed to list roots', e);
76+
}
77+
};
78+
void updateRoots();
79+
server.server.setNotificationHandler(
80+
RootsListChangedNotificationSchema,
81+
() => {
82+
void updateRoots();
83+
},
84+
);
6585
};
6686

6787
let context: McpContext;
@@ -104,11 +124,13 @@ export async function createMcpServer(
104124
});
105125

106126
if (context?.browser !== browser) {
127+
const roots = context?.roots() ?? [];
107128
context = await McpContext.from(browser, logger, {
108129
experimentalDevToolsDebugging: devtools,
109130
experimentalIncludeAllPages: serverArgs.experimentalIncludeAllPages,
110131
performanceCrux: serverArgs.performanceCrux,
111132
});
133+
context.setRoots(roots);
112134
}
113135
return context;
114136
}

src/third_party/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ export {
3030
SetLevelRequestSchema,
3131
type ImageContent,
3232
type TextContent,
33+
type Root,
34+
ListRootsRequestSchema,
35+
RootsListChangedNotificationSchema,
36+
ListRootsResultSchema,
3337
} from '@modelcontextprotocol/sdk/types.js';
3438
export {z as zod} from 'zod';
3539
export {default as ajv} from 'ajv';

src/tools/ToolDefinition.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
ScreenRecorder,
1717
Viewport,
1818
DevTools,
19+
Root,
1920
} from '../third_party/index.js';
2021
import type {InsightName, TraceResult} from '../trace-processing/parse.js';
2122
import type {
@@ -170,6 +171,9 @@ export type SupportedExtensions =
170171
* Only add methods required by tools/*.
171172
*/
172173
export type Context = Readonly<{
174+
roots(): Root[];
175+
setRoots(roots: Root[]): void;
176+
validatePath(filePath: string): void;
173177
isRunningPerformanceTrace(): boolean;
174178
setIsRunningPerformanceTrace(x: boolean): void;
175179
isCruxEnabled(): boolean;

src/tools/input.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,8 +365,9 @@ export const uploadFile = definePageTool({
365365
filePath: zod.string().describe('The local path of the file to upload'),
366366
includeSnapshot: includeSnapshotSchema,
367367
},
368-
handler: async (request, response) => {
368+
handler: async (request, response, context) => {
369369
const {uid, filePath} = request.params;
370+
context.validatePath(filePath);
370371
const handle = (await request.page.getElementByUid(
371372
uid,
372373
)) as ElementHandle<HTMLInputElement>;

src/tools/lighthouse.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ export const lighthouseAudit = definePageTool({
5353
outputDirPath,
5454
} = request.params;
5555

56+
if (outputDirPath) {
57+
context.validatePath(outputDirPath);
58+
}
59+
5660
const flags: Flags = {
5761
onlyCategories: categories,
5862
output: formats,

src/tools/memory.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ export const takeMemorySnapshot = definePageTool({
2222
.string()
2323
.describe('A path to a .heapsnapshot file to save the heapsnapshot to.'),
2424
},
25-
handler: async (request, response, _context) => {
25+
handler: async (request, response, context) => {
2626
const page = request.page;
27+
context.validatePath(request.params.filePath);
2728

2829
await page.pptrPage.captureHeapSnapshot({
2930
path: ensureExtension(request.params.filePath, '.heapsnapshot'),
@@ -48,6 +49,7 @@ export const exploreMemorySnapshot = defineTool({
4849
filePath: zod.string().describe('A path to a .heapsnapshot file to read.'),
4950
},
5051
handler: async (request, response, context) => {
52+
context.validatePath(request.params.filePath);
5153
const stats = await context.getHeapSnapshotStats(request.params.filePath);
5254
const staticData = await context.getHeapSnapshotStaticData(
5355
request.params.filePath,
@@ -78,6 +80,7 @@ export const getMemorySnapshotDetails = defineTool({
7880
.describe('The page size for pagination of aggregates.'),
7981
},
8082
handler: async (request, response, context) => {
83+
context.validatePath(request.params.filePath);
8184
const aggregates = await context.getHeapSnapshotAggregates(
8285
request.params.filePath,
8386
);
@@ -109,6 +112,7 @@ export const getNodesByClass = defineTool({
109112
pageSize: zod.number().optional().describe('The page size for pagination.'),
110113
},
111114
handler: async (request, response, context) => {
115+
context.validatePath(request.params.filePath);
112116
const nodes = await context.getHeapSnapshotNodesByUid(
113117
request.params.filePath,
114118
request.params.uid,

src/tools/network.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,12 @@ export const getNetworkRequest = definePageTool({
114114
),
115115
},
116116
handler: async (request, response, context) => {
117+
if (request.params.requestFilePath) {
118+
context.validatePath(request.params.requestFilePath);
119+
}
120+
if (request.params.responseFilePath) {
121+
context.validatePath(request.params.responseFilePath);
122+
}
117123
if (request.params.reqid) {
118124
response.attachNetworkRequest(request.params.reqid, {
119125
requestFilePath: request.params.requestFilePath,

src/tools/performance.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ export const startTrace = definePageTool({
4949
filePath: filePathSchema,
5050
},
5151
handler: async (request, response, context) => {
52+
if (request.params.filePath) {
53+
context.validatePath(request.params.filePath);
54+
}
5255
if (context.isRunningPerformanceTrace()) {
5356
response.appendResponseLine(
5457
'Error: a performance trace is already running. Use performance_stop_trace to stop it. Only one trace can be running at any given time.',
@@ -126,6 +129,9 @@ export const stopTrace = definePageTool({
126129
filePath: filePathSchema,
127130
},
128131
handler: async (request, response, context) => {
132+
if (request.params.filePath) {
133+
context.validatePath(request.params.filePath);
134+
}
129135
if (!context.isRunningPerformanceTrace()) {
130136
return;
131137
}

src/tools/screencast.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ export const startScreencast = definePageTool(args => ({
4040
),
4141
},
4242
handler: async (request, response, context) => {
43+
if (request.params.filePath) {
44+
context.validatePath(request.params.filePath);
45+
}
4346
if (context.getScreenRecorder() !== null) {
4447
response.appendResponseLine(
4548
'Error: a screencast recording is already in progress. Use screencast_stop to stop it before starting a new one.',

0 commit comments

Comments
 (0)