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:
Nikolay Vitkov
2026-09-01 12:25:45 +00:00
committed by GitHub
parent 73ae257b64
commit 1a092ff551
3 changed files with 190 additions and 122 deletions
+2 -2
View File
@@ -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
View File
@@ -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) => {
+1
View File
@@ -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,