Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 57 additions & 16 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -154,6 +157,34 @@ 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 {
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');
Expand Down Expand Up @@ -643,13 +674,18 @@ 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);
await fs.writeFile(filepath, data);
Comment on lines +677 to +679
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be wrapped in a try..catch?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think no, why would it need a try/catch?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fs.writeFile might throw an exception. Maybe it's already caught elsewhere, but it's not obvious.

The previous saveTemporaryFile did catch exceptions too.

return {filepath};
}

async saveFile(
data: Uint8Array<ArrayBufferLike>,
clientProvidedFilePath: string,
extension: SupportedExtensions,
): Promise<{filename: string}> {
this.validatePath(clientProvidedFilePath);
try {
const filePath = ensureExtension(
path.resolve(clientProvidedFilePath),
Expand Down Expand Up @@ -721,6 +757,7 @@ export class McpContext implements Context {
}

async installExtension(extensionPath: string): Promise<string> {
this.validatePath(extensionPath);
const id = await this.browser.installExtension(extensionPath);
return id;
}
Expand Down Expand Up @@ -751,25 +788,29 @@ 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);
}

async getHeapSnapshotNodesByUid(
filePath: string,
uid: number,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
this.validatePath(filePath);
return await this.#heapSnapshotManager.getNodesByUid(filePath, uid);
}
}
5 changes: 3 additions & 2 deletions src/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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');
Expand Down
27 changes: 27 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -109,6 +135,7 @@ export async function createMcpServer(
experimentalIncludeAllPages: serverArgs.experimentalIncludeAllPages,
performanceCrux: serverArgs.performanceCrux,
});
await updateRoots();
}
return context;
}
Expand Down
4 changes: 4 additions & 0 deletions src/third_party/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
6 changes: 5 additions & 1 deletion src/tools/ToolDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be

Suggested change
validatePath(filePath: string): void;
validatePath(filePath?: string): void;

So we can ignore some if statements?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, let me re-visit this next week.

isRunningPerformanceTrace(): boolean;
setIsRunningPerformanceTrace(x: boolean): void;
isCruxEnabled(): boolean;
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/tools/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down
4 changes: 4 additions & 0 deletions src/tools/lighthouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ export const lighthouseAudit = definePageTool({
outputDirPath,
} = request.params;

if (outputDirPath) {
context.validatePath(outputDirPath);
}

const flags: Flags = {
onlyCategories: categories,
output: formats,
Expand Down
6 changes: 5 additions & 1 deletion src/tools/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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,
Expand Down Expand Up @@ -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,
);
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/tools/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ export const getNetworkRequest = definePageTool({
),
},
handler: async (request, response, context) => {
if (request.params.requestFilePath) {
context.validatePath(request.params.requestFilePath);
}
if (request.params.responseFilePath) {
context.validatePath(request.params.responseFilePath);
}
if (request.params.reqid) {
response.attachNetworkRequest(request.params.reqid, {
requestFilePath: request.params.requestFilePath,
Expand Down
6 changes: 6 additions & 0 deletions src/tools/performance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ export const startTrace = definePageTool({
filePath: filePathSchema,
},
handler: async (request, response, context) => {
if (request.params.filePath) {
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.',
Expand Down Expand Up @@ -126,6 +129,9 @@ export const stopTrace = definePageTool({
filePath: filePathSchema,
},
handler: async (request, response, context) => {
if (request.params.filePath) {
context.validatePath(request.params.filePath);
}
if (!context.isRunningPerformanceTrace()) {
return;
}
Expand Down
3 changes: 3 additions & 0 deletions src/tools/screencast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export const startScreencast = definePageTool(args => ({
),
},
handler: async (request, response, context) => {
if (request.params.filePath) {
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.',
Expand Down
3 changes: 3 additions & 0 deletions src/tools/screenshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ export const screenshot = definePageTool({
),
},
handler: async (request, response, context) => {
if (request.params.filePath) {
context.validatePath(request.params.filePath);
}
if (request.params.uid && request.params.fullPage) {
throw new Error('Providing both "uid" and "fullPage" is not allowed.');
}
Expand Down
5 changes: 4 additions & 1 deletion src/tools/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ 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) => {
if (request.params.filePath) {
context.validatePath(request.params.filePath);
}
response.includeSnapshot({
verbose: request.params.verbose ?? false,
filePath: request.params.filePath,
Expand Down
Loading
Loading