mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-09-14 19:45:30 +08:00
refactor: wrap Server into a class (#2633)
I tried to keep the lines of things the same to ease the review process. I will see if any follow up patches will be helpful after this is merged.
This commit is contained in:
@@ -9,7 +9,7 @@ import '../utils/polyfill.js';
|
||||
import process from 'node:process';
|
||||
|
||||
import {closeBrowser} from '../browser.js';
|
||||
import {createMcpServer, logDisclaimers} from '../index.js';
|
||||
import {McpServer, logDisclaimers} from '../index.js';
|
||||
import {ClearcutLogger} from '../telemetry/ClearcutLogger.js';
|
||||
import {computeFlagUsage} from '../telemetry/flagUtils.js';
|
||||
import {StdioServerTransport} from '../third_party/index.js';
|
||||
@@ -72,7 +72,7 @@ process.on('SIGHUP', () => {
|
||||
});
|
||||
|
||||
logger?.(`Starting Chrome DevTools MCP Server v${VERSION}`);
|
||||
const {server} = await createMcpServer(args, {
|
||||
const server = await McpServer.from(args, {
|
||||
logFile,
|
||||
});
|
||||
const transport = new StdioServerTransport();
|
||||
|
||||
+187
-120
@@ -6,26 +6,28 @@
|
||||
|
||||
import type fs from 'node:fs';
|
||||
|
||||
import {type ParsedArguments} from './config/mcp-options.js';
|
||||
import type {Channel} from './browser.js';
|
||||
import {ensureBrowserConnected, ensureBrowserLaunched} from './browser.js';
|
||||
import {type ParsedArguments} from './config/mcp-options.js';
|
||||
import {loadIssueDescriptions} from './devtools/issueDescriptions.js';
|
||||
import {McpContext} from './McpContext.js';
|
||||
import {ClearcutLogger} from './telemetry/ClearcutLogger.js';
|
||||
import {FilePersistence} from './telemetry/persistence.js';
|
||||
import {
|
||||
McpServer,
|
||||
McpServer as SdkMcpServer,
|
||||
type CallToolResult,
|
||||
type Root,
|
||||
type Transport,
|
||||
SetLevelRequestSchema,
|
||||
ListRootsResultSchema,
|
||||
RootsListChangedNotificationSchema,
|
||||
Mutex,
|
||||
puppeteer,
|
||||
} from './third_party/index.js';
|
||||
import {ToolHandler} from './ToolHandler.js';
|
||||
import type {DefinedPageTool, ToolDefinition} from './tools/ToolDefinition.js';
|
||||
import {createTools} from './tools/tools.js';
|
||||
import {logger} from './utils/logger.js';
|
||||
import {Mutex, puppeteer} from './third_party/index.js';
|
||||
import {VERSION} from './version.js';
|
||||
|
||||
export {buildFlag} from './ToolHandler.js';
|
||||
@@ -42,174 +44,233 @@ puppeteer.setFollowSymlinks(false);
|
||||
*/
|
||||
const ROOTS_REQUEST_TIMEOUT = 5_000;
|
||||
|
||||
export async function createMcpServer(
|
||||
serverArgs: ParsedArguments,
|
||||
options: {
|
||||
logFile?: fs.WriteStream;
|
||||
},
|
||||
) {
|
||||
if (serverArgs.usageStatistics) {
|
||||
ClearcutLogger.initialize({
|
||||
persistence: new FilePersistence(),
|
||||
logFile: serverArgs.logFile,
|
||||
appVersion: VERSION,
|
||||
clearcutEndpoint: serverArgs.clearcutEndpoint,
|
||||
clearcutForceFlushIntervalMs: serverArgs.clearcutForceFlushIntervalMs,
|
||||
clearcutIncludePidHeader: serverArgs.clearcutIncludePidHeader,
|
||||
export interface McpServerOptions {
|
||||
logFile?: fs.WriteStream;
|
||||
}
|
||||
|
||||
export class McpServer {
|
||||
readonly server: SdkMcpServer;
|
||||
#serverArgs: ParsedArguments;
|
||||
#options: McpServerOptions;
|
||||
#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
|
||||
*/
|
||||
#lastRoots?: Root[];
|
||||
#toolMutex = new Mutex();
|
||||
|
||||
private constructor(
|
||||
serverArgs: ParsedArguments,
|
||||
options: McpServerOptions = {},
|
||||
) {
|
||||
this.#serverArgs = serverArgs;
|
||||
this.#options = options;
|
||||
|
||||
if (this.#serverArgs.usageStatistics) {
|
||||
ClearcutLogger.initialize({
|
||||
persistence: new FilePersistence(),
|
||||
logFile: this.#serverArgs.logFile,
|
||||
appVersion: VERSION,
|
||||
clearcutEndpoint: this.#serverArgs.clearcutEndpoint,
|
||||
clearcutForceFlushIntervalMs:
|
||||
this.#serverArgs.clearcutForceFlushIntervalMs,
|
||||
clearcutIncludePidHeader: this.#serverArgs.clearcutIncludePidHeader,
|
||||
});
|
||||
}
|
||||
|
||||
this.server = new SdkMcpServer(
|
||||
{
|
||||
name: 'chrome_devtools',
|
||||
title: 'Chrome DevTools MCP server',
|
||||
version: VERSION,
|
||||
},
|
||||
{capabilities: {logging: {}}},
|
||||
);
|
||||
|
||||
this.server.server.setRequestHandler(SetLevelRequestSchema, () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
this.server.server.oninitialized = () => {
|
||||
const clientName = this.server.server.getClientVersion()?.name;
|
||||
if (clientName) {
|
||||
ClearcutLogger.get()?.setClientName(clientName);
|
||||
}
|
||||
if (this.server.server.getClientCapabilities()?.roots) {
|
||||
void this.#updateRoots();
|
||||
this.server.server.setNotificationHandler(
|
||||
RootsListChangedNotificationSchema,
|
||||
() => {
|
||||
void this.#updateRoots();
|
||||
},
|
||||
);
|
||||
} else if (!this.#serverArgs.allowUnrestrictedPaths) {
|
||||
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. ' +
|
||||
'To restore the previous unrestricted behavior, start the server with ' +
|
||||
'--allow-unrestricted-paths.',
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async connect(transport: Transport): Promise<void> {
|
||||
return await this.server.connect(transport);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the MCP connection and disposes internal context/listeners.
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
this.#context?.dispose();
|
||||
this.#context = undefined;
|
||||
await this.server.close();
|
||||
}
|
||||
|
||||
[Symbol.dispose](): void {
|
||||
this.close().catch(() => {
|
||||
// TODO: wire up the logger
|
||||
});
|
||||
}
|
||||
|
||||
const server = new McpServer(
|
||||
{
|
||||
name: 'chrome_devtools',
|
||||
title: 'Chrome DevTools MCP server',
|
||||
version: VERSION,
|
||||
},
|
||||
{capabilities: {logging: {}}},
|
||||
);
|
||||
server.server.setRequestHandler(SetLevelRequestSchema, () => {
|
||||
return {};
|
||||
});
|
||||
async [Symbol.asyncDispose](): Promise<void> {
|
||||
await this.close();
|
||||
}
|
||||
|
||||
// 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
|
||||
let lastRoots: Root[] | undefined;
|
||||
static async from(
|
||||
serverArgs: ParsedArguments,
|
||||
options: McpServerOptions = {},
|
||||
): Promise<McpServer> {
|
||||
const server = new McpServer(serverArgs, options);
|
||||
await server.#init();
|
||||
return server;
|
||||
}
|
||||
|
||||
// `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
|
||||
// roots a slow client was about to send
|
||||
const updateRoots = async (timeout?: number) => {
|
||||
if (!server.server.getClientCapabilities()?.roots) {
|
||||
async #init(): Promise<void> {
|
||||
const tools = createTools(this.#serverArgs);
|
||||
for (const tool of tools) {
|
||||
this.#registerTool(tool);
|
||||
}
|
||||
await loadIssueDescriptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* `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
|
||||
* roots a slow client was about to send
|
||||
*/
|
||||
async #updateRoots(timeout?: number): Promise<void> {
|
||||
if (!this.server.server.getClientCapabilities()?.roots) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const roots = await server.server.request(
|
||||
const roots = await this.server.server.request(
|
||||
{method: 'roots/list'},
|
||||
ListRootsResultSchema,
|
||||
timeout === undefined ? undefined : {timeout},
|
||||
);
|
||||
lastRoots = roots.roots;
|
||||
context?.setRoots(lastRoots);
|
||||
this.#lastRoots = roots.roots;
|
||||
this.#context?.setRoots(this.#lastRoots);
|
||||
} catch (e) {
|
||||
logger?.('Failed to list roots', e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
server.server.oninitialized = () => {
|
||||
const clientName = server.server.getClientVersion()?.name;
|
||||
if (clientName) {
|
||||
ClearcutLogger.get()?.setClientName(clientName);
|
||||
}
|
||||
if (server.server.getClientCapabilities()?.roots) {
|
||||
void updateRoots();
|
||||
server.server.setNotificationHandler(
|
||||
RootsListChangedNotificationSchema,
|
||||
() => {
|
||||
void updateRoots();
|
||||
},
|
||||
);
|
||||
} else if (!serverArgs.allowUnrestrictedPaths) {
|
||||
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. ' +
|
||||
'To restore the previous unrestricted behavior, start the server with ' +
|
||||
'--allow-unrestricted-paths.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let context: McpContext;
|
||||
async function getContext(): Promise<McpContext> {
|
||||
const chromeArgs: string[] = (serverArgs.chromeArg ?? []).map(String);
|
||||
async #getContext(): Promise<McpContext> {
|
||||
const chromeArgs: string[] = (this.#serverArgs.chromeArg ?? []).map(String);
|
||||
const ignoreDefaultChromeArgs: string[] = (
|
||||
serverArgs.ignoreDefaultChromeArg ?? []
|
||||
this.#serverArgs.ignoreDefaultChromeArg ?? []
|
||||
).map(String);
|
||||
if (serverArgs.proxyServer) {
|
||||
chromeArgs.push(`--proxy-server=${serverArgs.proxyServer}`);
|
||||
if (this.#serverArgs.proxyServer) {
|
||||
chromeArgs.push(`--proxy-server=${this.#serverArgs.proxyServer}`);
|
||||
}
|
||||
const devtools = serverArgs.experimentalDevtools ?? false;
|
||||
const blocklist = serverArgs.blockedUrlPattern
|
||||
? serverArgs.blockedUrlPattern.map(String)
|
||||
const devtools = this.#serverArgs.experimentalDevtools ?? false;
|
||||
const blocklist = this.#serverArgs.blockedUrlPattern
|
||||
? this.#serverArgs.blockedUrlPattern.map(String)
|
||||
: undefined;
|
||||
const allowlist = serverArgs.allowedUrlPattern
|
||||
? serverArgs.allowedUrlPattern.map(String)
|
||||
const allowlist = this.#serverArgs.allowedUrlPattern
|
||||
? this.#serverArgs.allowedUrlPattern.map(String)
|
||||
: undefined;
|
||||
|
||||
const channel = this.#serverArgs.channel as Channel | undefined;
|
||||
|
||||
const browser =
|
||||
serverArgs.browserUrl || serverArgs.wsEndpoint || serverArgs.autoConnect
|
||||
this.#serverArgs.browserUrl ||
|
||||
this.#serverArgs.wsEndpoint ||
|
||||
this.#serverArgs.autoConnect
|
||||
? await ensureBrowserConnected({
|
||||
browserURL: serverArgs.browserUrl,
|
||||
wsEndpoint: serverArgs.wsEndpoint,
|
||||
wsHeaders: serverArgs.wsHeaders,
|
||||
browserURL: this.#serverArgs.browserUrl,
|
||||
wsEndpoint: this.#serverArgs.wsEndpoint,
|
||||
wsHeaders: this.#serverArgs.wsHeaders,
|
||||
// Important: only pass channel, if autoConnect is true.
|
||||
channel: serverArgs.autoConnect
|
||||
? (serverArgs.channel as Channel)
|
||||
: undefined,
|
||||
userDataDir: serverArgs.userDataDir,
|
||||
channel: this.#serverArgs.autoConnect ? channel : undefined,
|
||||
userDataDir: this.#serverArgs.userDataDir,
|
||||
devtools,
|
||||
blocklist,
|
||||
allowlist,
|
||||
})
|
||||
: await ensureBrowserLaunched({
|
||||
headless: serverArgs.headless,
|
||||
executablePath: serverArgs.executablePath,
|
||||
channel: serverArgs.channel as Channel,
|
||||
isolated: serverArgs.isolated ?? false,
|
||||
userDataDir: serverArgs.userDataDir,
|
||||
logFile: options.logFile,
|
||||
viewport: serverArgs.viewport,
|
||||
headless: this.#serverArgs.headless,
|
||||
executablePath: this.#serverArgs.executablePath,
|
||||
channel,
|
||||
isolated: this.#serverArgs.isolated ?? false,
|
||||
userDataDir: this.#serverArgs.userDataDir,
|
||||
logFile: this.#options.logFile,
|
||||
viewport: this.#serverArgs.viewport,
|
||||
chromeArgs,
|
||||
ignoreDefaultChromeArgs,
|
||||
acceptInsecureCerts: serverArgs.acceptInsecureCerts,
|
||||
acceptInsecureCerts: this.#serverArgs.acceptInsecureCerts,
|
||||
devtools,
|
||||
enableExtensions: serverArgs.categoryExtensions,
|
||||
viaCli: serverArgs.viaCli,
|
||||
enableExtensions: this.#serverArgs.categoryExtensions,
|
||||
viaCli: this.#serverArgs.viaCli,
|
||||
blocklist,
|
||||
allowlist,
|
||||
});
|
||||
|
||||
if (context?.browser !== browser) {
|
||||
context?.dispose();
|
||||
context = await McpContext.from(browser, logger, {
|
||||
if (this.#context?.browser !== browser) {
|
||||
this.#context?.dispose();
|
||||
this.#context = await McpContext.from(browser, logger, {
|
||||
experimentalDevToolsDebugging: devtools,
|
||||
experimentalIncludeAllPages: serverArgs.experimentalIncludeAllPages,
|
||||
performanceCrux: serverArgs.performanceCrux,
|
||||
experimentalIncludeAllPages:
|
||||
this.#serverArgs.experimentalIncludeAllPages,
|
||||
performanceCrux: this.#serverArgs.performanceCrux,
|
||||
allowList: allowlist,
|
||||
blocklist: blocklist,
|
||||
allowUnrestrictedPaths: serverArgs.allowUnrestrictedPaths,
|
||||
allowUnrestrictedPaths: this.#serverArgs.allowUnrestrictedPaths,
|
||||
// Surfaces a one-time note in the next response after a reconnect.
|
||||
reconnected: context !== undefined,
|
||||
reconnected: this.#context !== undefined,
|
||||
});
|
||||
if (lastRoots === undefined) {
|
||||
if (this.#lastRoots === 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 updateRoots(ROOTS_REQUEST_TIMEOUT);
|
||||
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
|
||||
context.setRoots(lastRoots);
|
||||
void updateRoots();
|
||||
this.#context.setRoots(this.#lastRoots);
|
||||
void this.#updateRoots();
|
||||
}
|
||||
}
|
||||
return context;
|
||||
return this.#context;
|
||||
}
|
||||
|
||||
const toolMutex = new Mutex();
|
||||
|
||||
function registerTool(tool: ToolDefinition | DefinedPageTool): void {
|
||||
#registerTool(tool: ToolDefinition | DefinedPageTool): void {
|
||||
const toolHandler = new ToolHandler(
|
||||
tool,
|
||||
serverArgs,
|
||||
getContext,
|
||||
toolMutex,
|
||||
this.#serverArgs,
|
||||
() => this.#getContext(),
|
||||
this.#toolMutex,
|
||||
);
|
||||
|
||||
if (!toolHandler.shouldRegister) {
|
||||
return;
|
||||
}
|
||||
|
||||
server.registerTool(
|
||||
this.server.registerTool(
|
||||
tool.name,
|
||||
{
|
||||
description: tool.description,
|
||||
@@ -221,15 +282,21 @@ export async function createMcpServer(
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const tools = createTools(serverArgs);
|
||||
for (const tool of tools) {
|
||||
registerTool(tool);
|
||||
}
|
||||
|
||||
await loadIssueDescriptions();
|
||||
|
||||
return {server};
|
||||
/**
|
||||
* Creates and initializes a Chrome DevTools MCP server instance.
|
||||
*
|
||||
* Maintained as a public API for backwards compatibility because external
|
||||
* consumers and integrations rely on `createMcpServer()`. For new code,
|
||||
* prefer using `McpServer.from(serverArgs, options)`.
|
||||
*/
|
||||
export async function createMcpServer(
|
||||
serverArgs: ParsedArguments,
|
||||
options: McpServerOptions = {},
|
||||
): Promise<{server: SdkMcpServer}> {
|
||||
const server = await McpServer.from(serverArgs, options);
|
||||
return {server: server.server};
|
||||
}
|
||||
|
||||
export const logDisclaimers = (args: ParsedArguments) => {
|
||||
|
||||
Vendored
+1
@@ -22,6 +22,7 @@ export {type ShapeOutput} from '@modelcontextprotocol/sdk/server/zod-compat.js';
|
||||
export {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
export {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
export {Client} from '@modelcontextprotocol/sdk/client/index.js';
|
||||
export type {Transport} from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
export {
|
||||
type CallToolResult,
|
||||
SetLevelRequestSchema,
|
||||
|
||||
Reference in New Issue
Block a user