Skip to main content
Glama
McpContext.ts19.5 kB
/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import {extractUrlLikeFromDevToolsTitle, urlsEqual} from './DevtoolsUtils.js'; import type {ListenerMap} from './PageCollector.js'; import {NetworkCollector, ConsoleCollector} from './PageCollector.js'; import {Locator} from './third_party/index.js'; import type {DevTools} from './third_party/index.js'; import type { Browser, ConsoleMessage, Debugger, Dialog, ElementHandle, HTTPRequest, Page, SerializedAXNode, PredefinedNetworkConditions, } from './third_party/index.js'; import {listPages} from './tools/pages.js'; import {takeSnapshot} from './tools/snapshot.js'; import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js'; import type {Context, DevToolsData} from './tools/ToolDefinition.js'; import type {TraceResult} from './trace-processing/parse.js'; import {WaitForHelper} from './WaitForHelper.js'; export interface TextSnapshotNode extends SerializedAXNode { id: string; backendNodeId?: number; children: TextSnapshotNode[]; } export interface GeolocationOptions { latitude: number; longitude: number; } export interface TextSnapshot { root: TextSnapshotNode; idToNode: Map<string, TextSnapshotNode>; snapshotId: string; selectedElementUid?: string; // It might happen that there is a selected element, but it is not part of the // snapshot. This flag indicates if there is any selected element. hasSelectedElement: boolean; verbose: boolean; } interface McpContextOptions { // Whether the DevTools windows are exposed as pages for debugging of DevTools. experimentalDevToolsDebugging: boolean; // Whether all page-like targets are exposed as pages. experimentalIncludeAllPages?: boolean; } const DEFAULT_TIMEOUT = 5_000; const NAVIGATION_TIMEOUT = 10_000; function getNetworkMultiplierFromString(condition: string | null): number { const puppeteerCondition = condition as keyof typeof PredefinedNetworkConditions; switch (puppeteerCondition) { case 'Fast 4G': return 1; case 'Slow 4G': return 2.5; case 'Fast 3G': return 5; case 'Slow 3G': return 10; } return 1; } function getExtensionFromMimeType(mimeType: string) { switch (mimeType) { case 'image/png': return 'png'; case 'image/jpeg': return 'jpeg'; case 'image/webp': return 'webp'; } throw new Error(`No mapping for Mime type ${mimeType}.`); } export class McpContext implements Context { browser: Browser; logger: Debugger; // The most recent page state. #pages: Page[] = []; #pageToDevToolsPage = new Map<Page, Page>(); #selectedPage?: Page; // The most recent snapshot. #textSnapshot: TextSnapshot | null = null; #networkCollector: NetworkCollector; #consoleCollector: ConsoleCollector; #isRunningTrace = false; #networkConditionsMap = new WeakMap<Page, string>(); #cpuThrottlingRateMap = new WeakMap<Page, number>(); #geolocationMap = new WeakMap<Page, GeolocationOptions>(); #dialog?: Dialog; #nextSnapshotId = 1; #traceResults: TraceResult[] = []; #locatorClass: typeof Locator; #options: McpContextOptions; private constructor( browser: Browser, logger: Debugger, options: McpContextOptions, locatorClass: typeof Locator, ) { this.browser = browser; this.logger = logger; this.#locatorClass = locatorClass; this.#options = options; this.#networkCollector = new NetworkCollector(this.browser); this.#consoleCollector = new ConsoleCollector(this.browser, collect => { return { console: event => { collect(event); }, pageerror: event => { if (event instanceof Error) { collect(event); } else { const error = new Error(`${event}`); error.stack = undefined; collect(error); } }, issue: event => { collect(event); }, } as ListenerMap; }); } async #init() { const pages = await this.createPagesSnapshot(); await this.#networkCollector.init(pages); await this.#consoleCollector.init(pages); } dispose() { this.#networkCollector.dispose(); this.#consoleCollector.dispose(); } static async from( browser: Browser, logger: Debugger, opts: McpContextOptions, /* Let tests use unbundled Locator class to avoid overly strict checks within puppeteer that fail when mixing bundled and unbundled class instances */ locatorClass: typeof Locator = Locator, ) { const context = new McpContext(browser, logger, opts, locatorClass); await context.#init(); return context; } resolveCdpRequestId(cdpRequestId: string): number | undefined { const selectedPage = this.getSelectedPage(); if (!cdpRequestId) { this.logger('no network request'); return; } const request = this.#networkCollector.find(selectedPage, request => { // @ts-expect-error id is internal. return request.id === cdpRequestId; }); if (!request) { this.logger('no network request for ' + cdpRequestId); return; } return this.#networkCollector.getIdForResource(request); } resolveCdpElementId(cdpBackendNodeId: number): string | undefined { if (!cdpBackendNodeId) { this.logger('no cdpBackendNodeId'); return; } if (this.#textSnapshot === null) { this.logger('no text snapshot'); return; } // TODO: index by backendNodeId instead. const queue = [this.#textSnapshot.root]; while (queue.length) { const current = queue.pop()!; if (current.backendNodeId === cdpBackendNodeId) { return current.id; } for (const child of current.children) { queue.push(child); } } return; } getNetworkRequests(includePreservedRequests?: boolean): HTTPRequest[] { const page = this.getSelectedPage(); return this.#networkCollector.getData(page, includePreservedRequests); } getConsoleData( includePreservedMessages?: boolean, ): Array<ConsoleMessage | Error | DevTools.AggregatedIssue> { const page = this.getSelectedPage(); return this.#consoleCollector.getData(page, includePreservedMessages); } getConsoleMessageStableId( message: ConsoleMessage | Error | DevTools.AggregatedIssue, ): number { return this.#consoleCollector.getIdForResource(message); } getConsoleMessageById( id: number, ): ConsoleMessage | Error | DevTools.AggregatedIssue { return this.#consoleCollector.getById(this.getSelectedPage(), id); } async newPage(): Promise<Page> { const page = await this.browser.newPage(); await this.createPagesSnapshot(); this.selectPage(page); this.#networkCollector.addPage(page); this.#consoleCollector.addPage(page); return page; } async closePage(pageIdx: number): Promise<void> { if (this.#pages.length === 1) { throw new Error(CLOSE_PAGE_ERROR); } const page = this.getPageByIdx(pageIdx); await page.close({runBeforeUnload: false}); } getNetworkRequestById(reqid: number): HTTPRequest { return this.#networkCollector.getById(this.getSelectedPage(), reqid); } setNetworkConditions(conditions: string | null): void { const page = this.getSelectedPage(); if (conditions === null) { this.#networkConditionsMap.delete(page); } else { this.#networkConditionsMap.set(page, conditions); } this.#updateSelectedPageTimeouts(); } getNetworkConditions(): string | null { const page = this.getSelectedPage(); return this.#networkConditionsMap.get(page) ?? null; } setCpuThrottlingRate(rate: number): void { const page = this.getSelectedPage(); this.#cpuThrottlingRateMap.set(page, rate); this.#updateSelectedPageTimeouts(); } getCpuThrottlingRate(): number { const page = this.getSelectedPage(); return this.#cpuThrottlingRateMap.get(page) ?? 1; } setGeolocation(geolocation: GeolocationOptions | null): void { const page = this.getSelectedPage(); if (geolocation === null) { this.#geolocationMap.delete(page); } else { this.#geolocationMap.set(page, geolocation); } } getGeolocation(): GeolocationOptions | null { const page = this.getSelectedPage(); return this.#geolocationMap.get(page) ?? null; } setIsRunningPerformanceTrace(x: boolean): void { this.#isRunningTrace = x; } isRunningPerformanceTrace(): boolean { return this.#isRunningTrace; } getDialog(): Dialog | undefined { return this.#dialog; } clearDialog(): void { this.#dialog = undefined; } getSelectedPage(): Page { const page = this.#selectedPage; if (!page) { throw new Error('No page selected'); } if (page.isClosed()) { throw new Error( `The selected page has been closed. Call ${listPages.name} to see open pages.`, ); } return page; } getPageByIdx(idx: number): Page { const pages = this.#pages; const page = pages[idx]; if (!page) { throw new Error('No page found'); } return page; } #dialogHandler = (dialog: Dialog): void => { this.#dialog = dialog; }; isPageSelected(page: Page): boolean { return this.#selectedPage === page; } selectPage(newPage: Page): void { const oldPage = this.#selectedPage; if (oldPage) { oldPage.off('dialog', this.#dialogHandler); void oldPage.emulateFocusedPage(false).catch(error => { this.logger('Error turning off focused page emulation', error); }); } this.#selectedPage = newPage; newPage.on('dialog', this.#dialogHandler); this.#updateSelectedPageTimeouts(); void newPage.emulateFocusedPage(true).catch(error => { this.logger('Error turning on focused page emulation', error); }); } #updateSelectedPageTimeouts() { const page = this.getSelectedPage(); // For waiters 5sec timeout should be sufficient. // Increased in case we throttle the CPU const cpuMultiplier = this.getCpuThrottlingRate(); page.setDefaultTimeout(DEFAULT_TIMEOUT * cpuMultiplier); // 10sec should be enough for the load event to be emitted during // navigations. // Increased in case we throttle the network requests const networkMultiplier = getNetworkMultiplierFromString( this.getNetworkConditions(), ); page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT * networkMultiplier); } getNavigationTimeout() { const page = this.getSelectedPage(); return page.getDefaultNavigationTimeout(); } getAXNodeByUid(uid: string) { return this.#textSnapshot?.idToNode.get(uid); } async getElementByUid(uid: string): Promise<ElementHandle<Element>> { if (!this.#textSnapshot?.idToNode.size) { throw new Error( `No snapshot found. Use ${takeSnapshot.name} to capture one.`, ); } const [snapshotId] = uid.split('_'); if (this.#textSnapshot.snapshotId !== snapshotId) { throw new Error( 'This uid is coming from a stale snapshot. Call take_snapshot to get a fresh snapshot.', ); } const node = this.#textSnapshot?.idToNode.get(uid); if (!node) { throw new Error('No such element found in the snapshot'); } const handle = await node.elementHandle(); if (!handle) { throw new Error('No such element found in the snapshot'); } return handle; } /** * Creates a snapshot of the pages. */ async createPagesSnapshot(): Promise<Page[]> { const allPages = await this.browser.pages( this.#options.experimentalIncludeAllPages, ); this.#pages = allPages.filter(page => { // If we allow debugging DevTools windows, return all pages. // If we are in regular mode, the user should only see non-DevTools page. return ( this.#options.experimentalDevToolsDebugging || !page.url().startsWith('devtools://') ); }); if ( (!this.#selectedPage || this.#pages.indexOf(this.#selectedPage) === -1) && this.#pages[0] ) { this.selectPage(this.#pages[0]); } await this.detectOpenDevToolsWindows(); return this.#pages; } async detectOpenDevToolsWindows() { this.logger('Detecting open DevTools windows'); const pages = await this.browser.pages( this.#options.experimentalIncludeAllPages, ); this.#pageToDevToolsPage = new Map<Page, Page>(); for (const devToolsPage of pages) { if (devToolsPage.url().startsWith('devtools://')) { try { this.logger('Calling getTargetInfo for ' + devToolsPage.url()); const data = await devToolsPage // @ts-expect-error no types for _client(). ._client() .send('Target.getTargetInfo'); const devtoolsPageTitle = data.targetInfo.title; const urlLike = extractUrlLikeFromDevToolsTitle(devtoolsPageTitle); if (!urlLike) { continue; } // TODO: lookup without a loop. for (const page of this.#pages) { if (urlsEqual(page.url(), urlLike)) { this.#pageToDevToolsPage.set(page, devToolsPage); } } } catch (error) { this.logger('Issue occurred while trying to find DevTools', error); } } } } getPages(): Page[] { return this.#pages; } getDevToolsPage(page: Page): Page | undefined { return this.#pageToDevToolsPage.get(page); } async getDevToolsData(): Promise<DevToolsData> { try { this.logger('Getting DevTools UI data'); const selectedPage = this.getSelectedPage(); const devtoolsPage = this.getDevToolsPage(selectedPage); if (!devtoolsPage) { this.logger('No DevTools page detected'); return {}; } const {cdpRequestId, cdpBackendNodeId} = await devtoolsPage.evaluate( async () => { // @ts-expect-error no types const UI = await import('/bundled/ui/legacy/legacy.js'); // @ts-expect-error no types const SDK = await import('/bundled/core/sdk/sdk.js'); const request = UI.Context.Context.instance().flavor( SDK.NetworkRequest.NetworkRequest, ); const node = UI.Context.Context.instance().flavor( SDK.DOMModel.DOMNode, ); return { cdpRequestId: request?.requestId(), cdpBackendNodeId: node?.backendNodeId(), }; }, ); return {cdpBackendNodeId, cdpRequestId}; } catch (err) { this.logger('error getting devtools data', err); } return {}; } /** * Creates a text snapshot of a page. */ async createTextSnapshot( verbose = false, devtoolsData: DevToolsData | undefined = undefined, ): Promise<void> { const page = this.getSelectedPage(); const rootNode = await page.accessibility.snapshot({ includeIframes: true, interestingOnly: !verbose, }); if (!rootNode) { return; } const snapshotId = this.#nextSnapshotId++; // Iterate through the whole accessibility node tree and assign node ids that // will be used for the tree serialization and mapping ids back to nodes. let idCounter = 0; const idToNode = new Map<string, TextSnapshotNode>(); const assignIds = (node: SerializedAXNode): TextSnapshotNode => { const nodeWithId: TextSnapshotNode = { ...node, id: `${snapshotId}_${idCounter++}`, children: node.children ? node.children.map(child => assignIds(child)) : [], }; // The AXNode for an option doesn't contain its `value`. // Therefore, set text content of the option as value. if (node.role === 'option') { const optionText = node.name; if (optionText) { nodeWithId.value = optionText.toString(); } } idToNode.set(nodeWithId.id, nodeWithId); return nodeWithId; }; const rootNodeWithId = assignIds(rootNode); this.#textSnapshot = { root: rootNodeWithId, snapshotId: String(snapshotId), idToNode, hasSelectedElement: false, verbose, }; const data = devtoolsData ?? (await this.getDevToolsData()); if (data?.cdpBackendNodeId) { this.#textSnapshot.hasSelectedElement = true; this.#textSnapshot.selectedElementUid = this.resolveCdpElementId( data?.cdpBackendNodeId, ); } } getTextSnapshot(): TextSnapshot | null { return this.#textSnapshot; } async saveTemporaryFile( data: Uint8Array<ArrayBufferLike>, mimeType: 'image/png' | 'image/jpeg' | 'image/webp', ): Promise<{filename: string}> { try { const dir = await fs.mkdtemp( path.join(os.tmpdir(), 'chrome-devtools-mcp-'), ); const filename = path.join( dir, `screenshot.${getExtensionFromMimeType(mimeType)}`, ); await fs.writeFile(filename, data); return {filename}; } catch (err) { this.logger(err); throw new Error('Could not save a screenshot to a file', {cause: err}); } } async saveFile( data: Uint8Array<ArrayBufferLike>, filename: string, ): Promise<{filename: string}> { try { const filePath = path.resolve(filename); await fs.writeFile(filePath, data); return {filename}; } catch (err) { this.logger(err); throw new Error('Could not save a screenshot to a file', {cause: err}); } } storeTraceRecording(result: TraceResult): void { this.#traceResults.push(result); } recordedTraces(): TraceResult[] { return this.#traceResults; } getWaitForHelper( page: Page, cpuMultiplier: number, networkMultiplier: number, ) { return new WaitForHelper(page, cpuMultiplier, networkMultiplier); } waitForEventsAfterAction(action: () => Promise<unknown>): Promise<void> { const page = this.getSelectedPage(); const cpuMultiplier = this.getCpuThrottlingRate(); const networkMultiplier = getNetworkMultiplierFromString( this.getNetworkConditions(), ); const waitForHelper = this.getWaitForHelper( page, cpuMultiplier, networkMultiplier, ); return waitForHelper.waitForEventsAfterAction(action); } getNetworkRequestStableId(request: HTTPRequest): number { return this.#networkCollector.getIdForResource(request); } waitForTextOnPage(text: string, timeout?: number): Promise<Element> { const page = this.getSelectedPage(); const frames = page.frames(); let locator = this.#locatorClass.race( frames.flatMap(frame => [ frame.locator(`aria/${text}`), frame.locator(`text/${text}`), ]), ); if (timeout) { locator = locator.setTimeout(timeout); } return locator.wait(); } /** * We need to ignore favicon request as they make our test flaky */ async setUpNetworkCollectorForTesting() { this.#networkCollector = new NetworkCollector(this.browser, collect => { return { request: req => { if (req.url().includes('favicon.ico')) { return; } collect(req); }, } as ListenerMap; }); await this.#networkCollector.init(await this.browser.pages()); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ChromeDevTools/chrome-devtools-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server