diff --git a/package.json b/package.json index aec4f44d4..1a4384335 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "MCP server for Chrome DevTools", "type": "module", "bin": "./build/src/index.js", - "main": "index.js", + "main": "./build/src/server.js", "scripts": { "clean": "node -e \"require('fs').rmSync('build', {recursive: true, force: true})\"", "bundle": "npm run clean && npm run build && rollup -c rollup.config.mjs && node -e \"require('fs').rmSync('build/node_modules', {recursive: true, force: true})\"", diff --git a/src/main.ts b/src/main.ts index e646cc96e..4954ae7ea 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,27 +8,11 @@ import './polyfill.js'; import process from 'node:process'; -import type {Channel} from './browser.js'; -import {ensureBrowserConnected, ensureBrowserLaunched} from './browser.js'; import {cliOptions, parseArguments} from './cli.js'; -import {loadIssueDescriptions} from './issue-descriptions.js'; import {logger, saveLogsToFile} from './logger.js'; -import {McpContext} from './McpContext.js'; -import {McpResponse} from './McpResponse.js'; -import {Mutex} from './Mutex.js'; -import {SlimMcpResponse} from './SlimMcpResponse.js'; -import {ClearcutLogger} from './telemetry/ClearcutLogger.js'; +import {createMcpServer} from './server.js'; import {computeFlagUsage} from './telemetry/flagUtils.js'; -import {bucketizeLatency} from './telemetry/metricUtils.js'; -import { - McpServer, - StdioServerTransport, - type CallToolResult, - SetLevelRequestSchema, -} from './third_party/index.js'; -import {ToolCategory} from './tools/categories.js'; -import type {ToolDefinition} from './tools/ToolDefinition.js'; -import {createTools} from './tools/tools.js'; +import {StdioServerTransport} from './third_party/index.js'; import {VERSION} from './version.js'; export const args = parseArguments(VERSION); @@ -44,17 +28,6 @@ if ( args.usageStatistics = false; } -let clearcutLogger: ClearcutLogger | undefined; -if (args.usageStatistics) { - clearcutLogger = new ClearcutLogger({ - logFile: args.logFile, - appVersion: VERSION, - clearcutEndpoint: args.clearcutEndpoint, - clearcutForceFlushIntervalMs: args.clearcutForceFlushIntervalMs, - clearcutIncludePidHeader: args.clearcutIncludePidHeader, - }); -} - if (process.env['CHROME_DEVTOOLS_MCP_CRASH_ON_UNCAUGHT'] !== 'true') { process.on('unhandledRejection', (reason, promise) => { logger('Unhandled promise rejection', promise, reason); @@ -62,63 +35,6 @@ if (process.env['CHROME_DEVTOOLS_MCP_CRASH_ON_UNCAUGHT'] !== 'true') { } logger(`Starting Chrome DevTools MCP Server v${VERSION}`); -const server = new McpServer( - { - name: 'chrome_devtools', - title: 'Chrome DevTools MCP server', - version: VERSION, - }, - {capabilities: {logging: {}}}, -); -server.server.setRequestHandler(SetLevelRequestSchema, () => { - return {}; -}); - -let context: McpContext; -async function getContext(): Promise { - const chromeArgs: string[] = (args.chromeArg ?? []).map(String); - const ignoreDefaultChromeArgs: string[] = ( - args.ignoreDefaultChromeArg ?? [] - ).map(String); - if (args.proxyServer) { - chromeArgs.push(`--proxy-server=${args.proxyServer}`); - } - const devtools = args.experimentalDevtools ?? false; - const browser = - args.browserUrl || args.wsEndpoint || args.autoConnect - ? await ensureBrowserConnected({ - browserURL: args.browserUrl, - wsEndpoint: args.wsEndpoint, - wsHeaders: args.wsHeaders, - // Important: only pass channel, if autoConnect is true. - channel: args.autoConnect ? (args.channel as Channel) : undefined, - userDataDir: args.userDataDir, - devtools, - }) - : await ensureBrowserLaunched({ - headless: args.headless, - executablePath: args.executablePath, - channel: args.channel as Channel, - isolated: args.isolated ?? false, - userDataDir: args.userDataDir, - logFile, - viewport: args.viewport, - chromeArgs, - ignoreDefaultChromeArgs, - acceptInsecureCerts: args.acceptInsecureCerts, - devtools, - enableExtensions: args.categoryExtensions, - }); - - if (context?.browser !== browser) { - context = await McpContext.from(browser, logger, { - experimentalDevToolsDebugging: devtools, - experimentalIncludeAllPages: args.experimentalIncludeAllPages, - performanceCrux: args.performanceCrux, - }); - } - return context; -} const logDisclaimers = () => { console.error( @@ -142,128 +58,9 @@ For more details, visit: https://github.com/ChromeDevTools/chrome-devtools-mcp#u } }; -const toolMutex = new Mutex(); - -function registerTool(tool: ToolDefinition): void { - if ( - tool.annotations.category === ToolCategory.EMULATION && - args.categoryEmulation === false - ) { - return; - } - if ( - tool.annotations.category === ToolCategory.PERFORMANCE && - args.categoryPerformance === false - ) { - return; - } - if ( - tool.annotations.category === ToolCategory.NETWORK && - args.categoryNetwork === false - ) { - return; - } - if ( - tool.annotations.category === ToolCategory.EXTENSIONS && - args.categoryExtensions === false - ) { - return; - } - if ( - tool.annotations.conditions?.includes('computerVision') && - !args.experimentalVision - ) { - return; - } - if ( - tool.annotations.conditions?.includes('experimentalInteropTools') && - !args.experimentalInteropTools - ) { - return; - } - if ( - tool.annotations.conditions?.includes('screencast') && - !args.experimentalScreencast - ) { - return; - } - server.registerTool( - tool.name, - { - description: tool.description, - inputSchema: tool.schema, - annotations: tool.annotations, - }, - async (params): Promise => { - const guard = await toolMutex.acquire(); - const startTime = Date.now(); - let success = false; - try { - logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`); - const context = await getContext(); - logger(`${tool.name} context: resolved`); - await context.detectOpenDevToolsWindows(); - const response = args.slim - ? new SlimMcpResponse(args) - : new McpResponse(args); - - await tool.handler( - { - params, - }, - response, - context, - ); - const {content, structuredContent} = await response.handle( - tool.name, - context, - ); - const result: CallToolResult & { - structuredContent?: Record; - } = { - content, - }; - success = true; - if (args.experimentalStructuredContent) { - result.structuredContent = structuredContent as Record< - string, - unknown - >; - } - return result; - } catch (err) { - logger(`${tool.name} error:`, err, err?.stack); - let errorText = err && 'message' in err ? err.message : String(err); - if ('cause' in err && err.cause) { - errorText += `\nCause: ${err.cause.message}`; - } - return { - content: [ - { - type: 'text', - text: errorText, - }, - ], - isError: true, - }; - } finally { - void clearcutLogger?.logToolInvocation({ - toolName: tool.name, - success, - latencyMs: bucketizeLatency(Date.now() - startTime), - }); - guard.dispose(); - } - }, - ); -} - -const tools = createTools(args); -for (const tool of tools) { - registerTool(tool); -} - -await loadIssueDescriptions(); +const {server, clearcutLogger} = await createMcpServer(args, { + logFile, +}); const transport = new StdioServerTransport(); await server.connect(transport); logger('Chrome DevTools MCP Server connected'); diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 000000000..42d491800 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,231 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type fs from 'node:fs'; + +import type {Channel} from './browser.js'; +import {ensureBrowserConnected, ensureBrowserLaunched} from './browser.js'; +import type {parseArguments} from './cli.js'; +import {loadIssueDescriptions} from './issue-descriptions.js'; +import {logger} from './logger.js'; +import {McpContext} from './McpContext.js'; +import {McpResponse} from './McpResponse.js'; +import {Mutex} from './Mutex.js'; +import {SlimMcpResponse} from './SlimMcpResponse.js'; +import {ClearcutLogger} from './telemetry/ClearcutLogger.js'; +import {bucketizeLatency} from './telemetry/metricUtils.js'; +import { + McpServer, + type CallToolResult, + SetLevelRequestSchema, +} from './third_party/index.js'; +import {ToolCategory} from './tools/categories.js'; +import type {ToolDefinition} from './tools/ToolDefinition.js'; +import {createTools} from './tools/tools.js'; +import {VERSION} from './version.js'; + +export async function createMcpServer( + serverArgs: ReturnType, + options: { + logFile?: fs.WriteStream; + }, +) { + let clearcutLogger: ClearcutLogger | undefined; + if (serverArgs.usageStatistics) { + clearcutLogger = new ClearcutLogger({ + logFile: serverArgs.logFile, + appVersion: VERSION, + clearcutEndpoint: serverArgs.clearcutEndpoint, + clearcutForceFlushIntervalMs: serverArgs.clearcutForceFlushIntervalMs, + clearcutIncludePidHeader: serverArgs.clearcutIncludePidHeader, + }); + } + + const server = new McpServer( + { + name: 'chrome_devtools', + title: 'Chrome DevTools MCP server', + version: VERSION, + }, + {capabilities: {logging: {}}}, + ); + server.server.setRequestHandler(SetLevelRequestSchema, () => { + return {}; + }); + + let context: McpContext; + async function getContext(): Promise { + const chromeArgs: string[] = (serverArgs.chromeArg ?? []).map(String); + const ignoreDefaultChromeArgs: string[] = ( + serverArgs.ignoreDefaultChromeArg ?? [] + ).map(String); + if (serverArgs.proxyServer) { + chromeArgs.push(`--proxy-server=${serverArgs.proxyServer}`); + } + const devtools = serverArgs.experimentalDevtools ?? false; + const browser = + serverArgs.browserUrl || serverArgs.wsEndpoint || serverArgs.autoConnect + ? await ensureBrowserConnected({ + browserURL: serverArgs.browserUrl, + wsEndpoint: serverArgs.wsEndpoint, + wsHeaders: serverArgs.wsHeaders, + // Important: only pass channel, if autoConnect is true. + channel: serverArgs.autoConnect + ? (serverArgs.channel as Channel) + : undefined, + userDataDir: serverArgs.userDataDir, + devtools, + }) + : 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, + chromeArgs, + ignoreDefaultChromeArgs, + acceptInsecureCerts: serverArgs.acceptInsecureCerts, + devtools, + enableExtensions: serverArgs.categoryExtensions, + }); + + if (context?.browser !== browser) { + context = await McpContext.from(browser, logger, { + experimentalDevToolsDebugging: devtools, + experimentalIncludeAllPages: serverArgs.experimentalIncludeAllPages, + performanceCrux: serverArgs.performanceCrux, + }); + } + return context; + } + + const toolMutex = new Mutex(); + + function registerTool(tool: ToolDefinition): void { + if ( + tool.annotations.category === ToolCategory.EMULATION && + serverArgs.categoryEmulation === false + ) { + return; + } + if ( + tool.annotations.category === ToolCategory.PERFORMANCE && + serverArgs.categoryPerformance === false + ) { + return; + } + if ( + tool.annotations.category === ToolCategory.NETWORK && + serverArgs.categoryNetwork === false + ) { + return; + } + if ( + tool.annotations.category === ToolCategory.EXTENSIONS && + serverArgs.categoryExtensions === false + ) { + return; + } + if ( + tool.annotations.conditions?.includes('computerVision') && + !serverArgs.experimentalVision + ) { + return; + } + if ( + tool.annotations.conditions?.includes('experimentalInteropTools') && + !serverArgs.experimentalInteropTools + ) { + return; + } + if ( + tool.annotations.conditions?.includes('screencast') && + !serverArgs.experimentalScreencast + ) { + return; + } + server.registerTool( + tool.name, + { + description: tool.description, + inputSchema: tool.schema, + annotations: tool.annotations, + }, + async (params): Promise => { + const guard = await toolMutex.acquire(); + const startTime = Date.now(); + let success = false; + try { + logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`); + const context = await getContext(); + logger(`${tool.name} context: resolved`); + await context.detectOpenDevToolsWindows(); + const response = serverArgs.slim + ? new SlimMcpResponse(serverArgs) + : new McpResponse(serverArgs); + + await tool.handler( + { + params, + }, + response, + context, + ); + const {content, structuredContent} = await response.handle( + tool.name, + context, + ); + const result: CallToolResult & { + structuredContent?: Record; + } = { + content, + }; + success = true; + if (serverArgs.experimentalStructuredContent) { + result.structuredContent = structuredContent as Record< + string, + unknown + >; + } + return result; + } catch (err) { + logger(`${tool.name} error:`, err, err?.stack); + let errorText = err && 'message' in err ? err.message : String(err); + if ('cause' in err && err.cause) { + errorText += `\nCause: ${err.cause.message}`; + } + return { + content: [ + { + type: 'text', + text: errorText, + }, + ], + isError: true, + }; + } finally { + void clearcutLogger?.logToolInvocation({ + toolName: tool.name, + success, + latencyMs: bucketizeLatency(Date.now() - startTime), + }); + guard.dispose(); + } + }, + ); + } + + const tools = createTools(serverArgs); + for (const tool of tools) { + registerTool(tool); + } + + await loadIssueDescriptions(); + + return {server, clearcutLogger}; +}