feat: support MCP client roots feature (#1945)
This PR implements https://modelcontextprotocol.io/specification/2025-11-25/client/roots If client specifies roots, all reads and writes to the file system originating in tool calls will denied (including tmp files). The client specified empty list of roots, all filesystem access will be restricted. Closes https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/1860 --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Piotr Paulski <piotrpaulski@chromium.org>
This commit is contained in:
+64
-16
@@ -6,6 +6,7 @@
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
import type {TargetUniverse} from './DevtoolsUtils.js';
|
||||
import {UniverseManager} from './DevtoolsUtils.js';
|
||||
@@ -18,21 +19,22 @@ import {
|
||||
type ListenerMap,
|
||||
type UncaughtError,
|
||||
} from './PageCollector.js';
|
||||
import type {
|
||||
Browser,
|
||||
BrowserContext,
|
||||
ConsoleMessage,
|
||||
Debugger,
|
||||
HTTPRequest,
|
||||
Page,
|
||||
ScreenRecorder,
|
||||
Viewport,
|
||||
Target,
|
||||
Extension,
|
||||
import {
|
||||
Locator,
|
||||
PredefinedNetworkConditions,
|
||||
type Browser,
|
||||
type BrowserContext,
|
||||
type ConsoleMessage,
|
||||
type Debugger,
|
||||
type HTTPRequest,
|
||||
type Page,
|
||||
type ScreenRecorder,
|
||||
type Viewport,
|
||||
type Target,
|
||||
type Extension,
|
||||
type Root,
|
||||
type DevTools,
|
||||
} from './third_party/index.js';
|
||||
import type {DevTools} from './third_party/index.js';
|
||||
import {Locator} from './third_party/index.js';
|
||||
import {PredefinedNetworkConditions} from './third_party/index.js';
|
||||
import {listPages} from './tools/pages.js';
|
||||
import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js';
|
||||
import type {Context, SupportedExtensions} from './tools/ToolDefinition.js';
|
||||
@@ -42,7 +44,7 @@ import type {
|
||||
GeolocationOptions,
|
||||
ExtensionServiceWorker,
|
||||
} from './types.js';
|
||||
import {ensureExtension, saveTemporaryFile} from './utils/files.js';
|
||||
import {ensureExtension, getTempFilePath} from './utils/files.js';
|
||||
import {getNetworkMultiplierFromString} from './WaitForHelper.js';
|
||||
|
||||
interface McpContextOptions {
|
||||
@@ -90,6 +92,7 @@ export class McpContext implements Context {
|
||||
#locatorClass: typeof Locator;
|
||||
#options: McpContextOptions;
|
||||
#heapSnapshotManager = new HeapSnapshotManager();
|
||||
#roots: Root[] | undefined = undefined;
|
||||
|
||||
private constructor(
|
||||
browser: Browser,
|
||||
@@ -154,6 +157,37 @@ export class McpContext implements Context {
|
||||
return context;
|
||||
}
|
||||
|
||||
roots(): Root[] | undefined {
|
||||
return this.#roots;
|
||||
}
|
||||
|
||||
setRoots(roots: Root[] | undefined): void {
|
||||
this.#roots = roots;
|
||||
}
|
||||
|
||||
validatePath(filePath?: string): void {
|
||||
if (filePath === undefined) {
|
||||
return;
|
||||
}
|
||||
const roots = this.roots();
|
||||
if (roots === undefined) {
|
||||
return;
|
||||
}
|
||||
const absolutePath = path.resolve(filePath);
|
||||
for (const root of roots) {
|
||||
const rootPath = path.resolve(fileURLToPath(root.uri));
|
||||
if (
|
||||
absolutePath === rootPath ||
|
||||
absolutePath.startsWith(rootPath + path.sep)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Access denied: path ${filePath} is not within any of the workspace roots ${JSON.stringify(roots)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
resolveCdpRequestId(page: McpPage, cdpRequestId: string): number | undefined {
|
||||
if (!cdpRequestId) {
|
||||
this.logger('no network request');
|
||||
@@ -643,13 +677,22 @@ export class McpContext implements Context {
|
||||
data: Uint8Array<ArrayBufferLike>,
|
||||
filename: string,
|
||||
): Promise<{filepath: string}> {
|
||||
return await saveTemporaryFile(data, filename);
|
||||
const filepath = await getTempFilePath(filename);
|
||||
this.validatePath(filepath);
|
||||
try {
|
||||
await fs.writeFile(filepath, data);
|
||||
} catch (err) {
|
||||
throw new Error('Could not save a file', {cause: err});
|
||||
}
|
||||
return {filepath};
|
||||
}
|
||||
|
||||
async saveFile(
|
||||
data: Uint8Array<ArrayBufferLike>,
|
||||
clientProvidedFilePath: string,
|
||||
extension: SupportedExtensions,
|
||||
): Promise<{filename: string}> {
|
||||
this.validatePath(clientProvidedFilePath);
|
||||
try {
|
||||
const filePath = ensureExtension(
|
||||
path.resolve(clientProvidedFilePath),
|
||||
@@ -721,6 +764,7 @@ export class McpContext implements Context {
|
||||
}
|
||||
|
||||
async installExtension(extensionPath: string): Promise<string> {
|
||||
this.validatePath(extensionPath);
|
||||
const id = await this.browser.installExtension(extensionPath);
|
||||
return id;
|
||||
}
|
||||
@@ -751,18 +795,21 @@ export class McpContext implements Context {
|
||||
async getHeapSnapshotAggregates(
|
||||
filePath: string,
|
||||
): Promise<Record<string, AggregatedInfoWithUid>> {
|
||||
this.validatePath(filePath);
|
||||
return await this.#heapSnapshotManager.getAggregates(filePath);
|
||||
}
|
||||
|
||||
async getHeapSnapshotStats(
|
||||
filePath: string,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.Statistics> {
|
||||
this.validatePath(filePath);
|
||||
return await this.#heapSnapshotManager.getStats(filePath);
|
||||
}
|
||||
|
||||
async getHeapSnapshotStaticData(
|
||||
filePath: string,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.StaticData | null> {
|
||||
this.validatePath(filePath);
|
||||
return await this.#heapSnapshotManager.getStaticData(filePath);
|
||||
}
|
||||
|
||||
@@ -770,6 +817,7 @@ export class McpContext implements Context {
|
||||
filePath: string,
|
||||
uid: number,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
|
||||
this.validatePath(filePath);
|
||||
return await this.#heapSnapshotManager.getNodesByUid(filePath, uid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import net from 'node:net';
|
||||
import {logger} from '../logger.js';
|
||||
import type {CallToolResult} from '../third_party/index.js';
|
||||
import {PipeTransport} from '../third_party/index.js';
|
||||
import {saveTemporaryFile} from '../utils/files.js';
|
||||
import {getTempFilePath} from '../utils/files.js';
|
||||
|
||||
import type {DaemonMessage, DaemonResponse} from './types.js';
|
||||
import {
|
||||
@@ -179,7 +179,8 @@ export async function handleResponse(
|
||||
}
|
||||
const data = Buffer.from(imageData, 'base64');
|
||||
const name = crypto.randomUUID();
|
||||
const {filepath} = await saveTemporaryFile(data, `${name}${extension}`);
|
||||
const filepath = await getTempFilePath(`${name}${extension}`);
|
||||
fs.writeFileSync(filepath, data);
|
||||
chunks.push(`Saved to ${filepath}.`);
|
||||
} else {
|
||||
throw new Error('Not supported response content type');
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
McpServer,
|
||||
type CallToolResult,
|
||||
SetLevelRequestSchema,
|
||||
ListRootsResultSchema,
|
||||
RootsListChangedNotificationSchema,
|
||||
} from './third_party/index.js';
|
||||
import {ToolCategory} from './tools/categories.js';
|
||||
import type {DefinedPageTool, ToolDefinition} from './tools/ToolDefinition.js';
|
||||
@@ -57,11 +59,35 @@ export async function createMcpServer(
|
||||
return {};
|
||||
});
|
||||
|
||||
const updateRoots = async () => {
|
||||
if (!server.server.getClientCapabilities()?.roots) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const roots = await server.server.request(
|
||||
{method: 'roots/list'},
|
||||
ListRootsResultSchema,
|
||||
);
|
||||
context?.setRoots(roots.roots);
|
||||
} catch (e) {
|
||||
logger('Failed to list roots', e);
|
||||
}
|
||||
};
|
||||
|
||||
server.server.oninitialized = () => {
|
||||
const clientName = server.server.getClientVersion()?.name;
|
||||
if (clientName) {
|
||||
clearcutLogger?.setClientName(clientName);
|
||||
}
|
||||
if (server.server.getClientCapabilities()?.roots) {
|
||||
void updateRoots();
|
||||
server.server.setNotificationHandler(
|
||||
RootsListChangedNotificationSchema,
|
||||
() => {
|
||||
void updateRoots();
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let context: McpContext;
|
||||
@@ -109,6 +135,7 @@ export async function createMcpServer(
|
||||
experimentalIncludeAllPages: serverArgs.experimentalIncludeAllPages,
|
||||
performanceCrux: serverArgs.performanceCrux,
|
||||
});
|
||||
await updateRoots();
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
Vendored
+4
@@ -30,6 +30,10 @@ export {
|
||||
SetLevelRequestSchema,
|
||||
type ImageContent,
|
||||
type TextContent,
|
||||
type Root,
|
||||
ListRootsRequestSchema,
|
||||
RootsListChangedNotificationSchema,
|
||||
ListRootsResultSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
export {z as zod} from 'zod';
|
||||
export {default as ajv} from 'ajv';
|
||||
|
||||
@@ -167,9 +167,10 @@ export type SupportedExtensions =
|
||||
| '.json.gz';
|
||||
|
||||
/**
|
||||
* Only add methods required by tools/*.
|
||||
* Only add methods used by tools/*.
|
||||
*/
|
||||
export type Context = Readonly<{
|
||||
validatePath(filePath?: string): void;
|
||||
isRunningPerformanceTrace(): boolean;
|
||||
setIsRunningPerformanceTrace(x: boolean): void;
|
||||
isCruxEnabled(): boolean;
|
||||
@@ -244,6 +245,9 @@ export type Context = Readonly<{
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Only add methods used by tools/*.
|
||||
*/
|
||||
export type ContextPage = Readonly<{
|
||||
readonly pptrPage: Page;
|
||||
getAXNodeByUid(uid: string): TextSnapshotNode | undefined;
|
||||
|
||||
+2
-1
@@ -365,8 +365,9 @@ export const uploadFile = definePageTool({
|
||||
filePath: zod.string().describe('The local path of the file to upload'),
|
||||
includeSnapshot: includeSnapshotSchema,
|
||||
},
|
||||
handler: async (request, response) => {
|
||||
handler: async (request, response, context) => {
|
||||
const {uid, filePath} = request.params;
|
||||
context.validatePath(filePath);
|
||||
const handle = (await request.page.getElementByUid(
|
||||
uid,
|
||||
)) as ElementHandle<HTMLInputElement>;
|
||||
|
||||
@@ -53,6 +53,8 @@ export const lighthouseAudit = definePageTool({
|
||||
outputDirPath,
|
||||
} = request.params;
|
||||
|
||||
context.validatePath(outputDirPath);
|
||||
|
||||
const flags: Flags = {
|
||||
onlyCategories: categories,
|
||||
output: formats,
|
||||
|
||||
+5
-1
@@ -22,8 +22,9 @@ export const takeMemorySnapshot = definePageTool({
|
||||
.string()
|
||||
.describe('A path to a .heapsnapshot file to save the heapsnapshot to.'),
|
||||
},
|
||||
handler: async (request, response, _context) => {
|
||||
handler: async (request, response, context) => {
|
||||
const page = request.page;
|
||||
context.validatePath(request.params.filePath);
|
||||
|
||||
await page.pptrPage.captureHeapSnapshot({
|
||||
path: ensureExtension(request.params.filePath, '.heapsnapshot'),
|
||||
@@ -48,6 +49,7 @@ export const exploreMemorySnapshot = defineTool({
|
||||
filePath: zod.string().describe('A path to a .heapsnapshot file to read.'),
|
||||
},
|
||||
handler: async (request, response, context) => {
|
||||
context.validatePath(request.params.filePath);
|
||||
const stats = await context.getHeapSnapshotStats(request.params.filePath);
|
||||
const staticData = await context.getHeapSnapshotStaticData(
|
||||
request.params.filePath,
|
||||
@@ -78,6 +80,7 @@ export const getMemorySnapshotDetails = defineTool({
|
||||
.describe('The page size for pagination of aggregates.'),
|
||||
},
|
||||
handler: async (request, response, context) => {
|
||||
context.validatePath(request.params.filePath);
|
||||
const aggregates = await context.getHeapSnapshotAggregates(
|
||||
request.params.filePath,
|
||||
);
|
||||
@@ -109,6 +112,7 @@ export const getNodesByClass = defineTool({
|
||||
pageSize: zod.number().optional().describe('The page size for pagination.'),
|
||||
},
|
||||
handler: async (request, response, context) => {
|
||||
context.validatePath(request.params.filePath);
|
||||
const nodes = await context.getHeapSnapshotNodesByUid(
|
||||
request.params.filePath,
|
||||
request.params.uid,
|
||||
|
||||
@@ -114,6 +114,8 @@ export const getNetworkRequest = definePageTool({
|
||||
),
|
||||
},
|
||||
handler: async (request, response, context) => {
|
||||
context.validatePath(request.params.requestFilePath);
|
||||
context.validatePath(request.params.responseFilePath);
|
||||
if (request.params.reqid) {
|
||||
response.attachNetworkRequest(request.params.reqid, {
|
||||
requestFilePath: request.params.requestFilePath,
|
||||
|
||||
@@ -49,6 +49,7 @@ export const startTrace = definePageTool({
|
||||
filePath: filePathSchema,
|
||||
},
|
||||
handler: async (request, response, context) => {
|
||||
context.validatePath(request.params.filePath);
|
||||
if (context.isRunningPerformanceTrace()) {
|
||||
response.appendResponseLine(
|
||||
'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 +127,7 @@ export const stopTrace = definePageTool({
|
||||
filePath: filePathSchema,
|
||||
},
|
||||
handler: async (request, response, context) => {
|
||||
context.validatePath(request.params.filePath);
|
||||
if (!context.isRunningPerformanceTrace()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export const startScreencast = definePageTool(args => ({
|
||||
),
|
||||
},
|
||||
handler: async (request, response, context) => {
|
||||
context.validatePath(request.params.filePath);
|
||||
if (context.getScreenRecorder() !== null) {
|
||||
response.appendResponseLine(
|
||||
'Error: a screencast recording is already in progress. Use screencast_stop to stop it before starting a new one.',
|
||||
|
||||
@@ -51,6 +51,7 @@ export const screenshot = definePageTool({
|
||||
),
|
||||
},
|
||||
handler: async (request, response, context) => {
|
||||
context.validatePath(request.params.filePath);
|
||||
if (request.params.uid && request.params.fullPage) {
|
||||
throw new Error('Providing both "uid" and "fullPage" is not allowed.');
|
||||
}
|
||||
|
||||
@@ -33,7 +33,8 @@ in the DevTools Elements panel (if any).`,
|
||||
'The absolute path, or a path relative to the current working directory, to save the snapshot to instead of attaching it to the response.',
|
||||
),
|
||||
},
|
||||
handler: async (request, response) => {
|
||||
handler: async (request, response, context) => {
|
||||
context.validatePath(request.params.filePath);
|
||||
response.includeSnapshot({
|
||||
verbose: request.params.verbose ?? false,
|
||||
filePath: request.params.filePath,
|
||||
|
||||
+4
-14
@@ -8,21 +8,11 @@ import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
export async function saveTemporaryFile(
|
||||
data: Uint8Array<ArrayBufferLike>,
|
||||
filename: string,
|
||||
): Promise<{filepath: string}> {
|
||||
try {
|
||||
const dir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'chrome-devtools-mcp-'),
|
||||
);
|
||||
export async function getTempFilePath(filename: string) {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'chrome-devtools-mcp-'));
|
||||
|
||||
const filepath = path.join(dir, filename);
|
||||
await fs.writeFile(filepath, data);
|
||||
return {filepath};
|
||||
} catch (err) {
|
||||
throw new Error('Could not save a file', {cause: err});
|
||||
}
|
||||
const filepath = path.join(dir, filename);
|
||||
return filepath;
|
||||
}
|
||||
|
||||
export function ensureExtension(
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
import path from 'node:path';
|
||||
import {afterEach, describe, it} from 'node:test';
|
||||
import {pathToFileURL} from 'node:url';
|
||||
|
||||
import sinon from 'sinon';
|
||||
|
||||
@@ -213,4 +215,46 @@ describe('McpContext', () => {
|
||||
fromStub.restore();
|
||||
});
|
||||
});
|
||||
|
||||
it('can store and retrieve roots', async () => {
|
||||
await withMcpContext(async (_response, context) => {
|
||||
const roots = [{uri: 'file:///test', name: 'test'}];
|
||||
context.setRoots(roots);
|
||||
assert.deepEqual(context.roots(), roots);
|
||||
});
|
||||
});
|
||||
|
||||
it('validatePath allows paths within roots', async () => {
|
||||
await withMcpContext(async (_response, context) => {
|
||||
const workspacePath = path.resolve('/tmp/workspace');
|
||||
const roots = [
|
||||
{uri: pathToFileURL(workspacePath).href, name: 'workspace'},
|
||||
];
|
||||
context.setRoots(roots);
|
||||
// Valid path within root
|
||||
context.validatePath(path.join(workspacePath, 'test.txt'));
|
||||
context.validatePath(workspacePath);
|
||||
|
||||
// Invalid path outside root
|
||||
const outsidePath = path.resolve('/tmp/outside.txt');
|
||||
assert.throws(() => context.validatePath(outsidePath), /Access denied/);
|
||||
});
|
||||
});
|
||||
|
||||
it('validatePath allows all paths if roots are undefined (legacy)', async () => {
|
||||
await withMcpContext(async (_response, context) => {
|
||||
context.setRoots(undefined);
|
||||
context.validatePath(path.resolve('/tmp/anywhere.txt'));
|
||||
});
|
||||
});
|
||||
|
||||
it('validatePath denies all paths if roots list is empty', async () => {
|
||||
await withMcpContext(async (_response, context) => {
|
||||
context.setRoots([]);
|
||||
assert.throws(
|
||||
() => context.validatePath(path.resolve('/tmp/anywhere.txt')),
|
||||
/Access denied/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+86
-1
@@ -10,6 +10,12 @@ import {describe, it} from 'node:test';
|
||||
|
||||
import {Client} from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import {
|
||||
ListRootsRequestSchema,
|
||||
RootsListChangedNotificationSchema,
|
||||
type ClientCapabilities,
|
||||
type TextContent,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import {executablePath} from 'puppeteer';
|
||||
|
||||
import type {ToolCategory} from '../src/tools/categories.js';
|
||||
@@ -20,6 +26,7 @@ describe('e2e', () => {
|
||||
async function withClient(
|
||||
cb: (client: Client) => Promise<void>,
|
||||
extraArgs: string[] = [],
|
||||
options: {capabilities?: ClientCapabilities} = {},
|
||||
) {
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
@@ -38,7 +45,7 @@ describe('e2e', () => {
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {},
|
||||
capabilities: options.capabilities ?? {},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -160,6 +167,84 @@ describe('e2e', () => {
|
||||
['--experimental-webmcp'],
|
||||
);
|
||||
});
|
||||
|
||||
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(ListRootsRequestSchema, () => {
|
||||
resolvePromise();
|
||||
return {roots};
|
||||
});
|
||||
|
||||
await client.notification({
|
||||
method: RootsListChangedNotificationSchema.shape.method.value,
|
||||
});
|
||||
|
||||
// Wait for the server to process the notification and request roots
|
||||
await promise;
|
||||
},
|
||||
[],
|
||||
{
|
||||
capabilities: {
|
||||
roots: {listChanged: true},
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('denies file access if roots list is empty', async () => {
|
||||
await withClient(
|
||||
async client => {
|
||||
client.setRequestHandler(ListRootsRequestSchema, () => {
|
||||
return {roots: []};
|
||||
});
|
||||
|
||||
const result = await client.callTool({
|
||||
name: 'take_screenshot',
|
||||
arguments: {
|
||||
filePath: '/tmp/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 () => {
|
||||
await withClient(
|
||||
async client => {
|
||||
const result = await client.callTool({
|
||||
name: 'take_screenshot',
|
||||
arguments: {
|
||||
filePath: '/tmp/test.png',
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(result.isError, undefined);
|
||||
const content = result.content as TextContent[];
|
||||
assert.match(content[0].text, /Saved screenshot to/);
|
||||
},
|
||||
[],
|
||||
{
|
||||
capabilities: {},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
async function getToolsWithFilteredCategories(
|
||||
|
||||
Reference in New Issue
Block a user