Skip to main content
Glama
DevtoolsUtils.ts7.61 kB
/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import {PuppeteerDevToolsConnection} from './DevToolsConnectionAdapter.js'; import {ISSUE_UTILS} from './issue-descriptions.js'; import {logger} from './logger.js'; import {Mutex} from './Mutex.js'; import {DevTools} from './third_party/index.js'; import type { Browser, Page, Target as PuppeteerTarget, } from './third_party/index.js'; export function extractUrlLikeFromDevToolsTitle( title: string, ): string | undefined { const match = title.match(new RegExp(`DevTools - (.*)`)); return match?.[1] ?? undefined; } export function urlsEqual(url1: string, url2: string): boolean { const normalizedUrl1 = normalizeUrl(url1); const normalizedUrl2 = normalizeUrl(url2); return normalizedUrl1 === normalizedUrl2; } /** * For the sake of the MCP server, when we determine if two URLs are equal we * remove some parts: * * 1. We do not care about the protocol. * 2. We do not care about trailing slashes. * 3. We do not care about "www". * 4. We ignore the hash parts. * * For example, if the user types "record a trace on foo.com", we would want to * match a tab in the connected Chrome instance that is showing "www.foo.com/" */ function normalizeUrl(url: string): string { let result = url.trim(); // Remove protocols if (result.startsWith('https://')) { result = result.slice(8); } else if (result.startsWith('http://')) { result = result.slice(7); } // Remove 'www.'. This ensures that we find the right URL regardless of if the user adds `www` or not. if (result.startsWith('www.')) { result = result.slice(4); } // We use target URLs to locate DevTools but those often do // no include hash. const hashIdx = result.lastIndexOf('#'); if (hashIdx !== -1) { result = result.slice(0, hashIdx); } // Remove trailing slash if (result.endsWith('/')) { result = result.slice(0, -1); } return result; } /** * A mock implementation of an issues manager that only implements the methods * that are actually used by the IssuesAggregator */ export class FakeIssuesManager extends DevTools.Common.ObjectWrapper .ObjectWrapper<DevTools.IssuesManagerEventTypes> { issues(): DevTools.Issue[] { return []; } } export function mapIssueToMessageObject(issue: DevTools.AggregatedIssue) { const count = issue.getAggregatedIssuesCount(); const markdownDescription = issue.getDescription(); const filename = markdownDescription?.file; if (!markdownDescription) { logger(`no description found for issue:` + issue.code); return null; } const rawMarkdown = filename ? ISSUE_UTILS.getIssueDescription(filename) : null; if (!rawMarkdown) { logger(`no markdown ${filename} found for issue:` + issue.code); return null; } let processedMarkdown: string; let title: string | null; try { processedMarkdown = DevTools.MarkdownIssueDescription.substitutePlaceholders( rawMarkdown, markdownDescription.substitutions, ); const markdownAst = DevTools.Marked.Marked.lexer(processedMarkdown); title = DevTools.MarkdownIssueDescription.findTitleFromMarkdownAst(markdownAst); } catch { logger('error parsing markdown for issue ' + issue.code()); return null; } if (!title) { logger('cannot read issue title from ' + filename); return null; } return { type: 'issue', item: issue, message: title, count, description: processedMarkdown, }; } // DevTools CDP errors can get noisy. DevTools.ProtocolClient.InspectorBackend.test.suppressRequestErrors = true; DevTools.I18n.DevToolsLocale.DevToolsLocale.instance({ create: true, data: { navigatorLanguage: 'en-US', settingLanguage: 'en-US', lookupClosestDevToolsLocale: l => l, }, }); DevTools.I18n.i18n.registerLocaleDataForTest('en-US', {}); export interface TargetUniverse { /** The DevTools target corresponding to the puppeteer Page */ target: DevTools.SDKTarget; universe: DevTools.Foundation.Universe.Universe; } export type TargetUniverseFactoryFn = (page: Page) => Promise<TargetUniverse>; export class UniverseManager { readonly #browser: Browser; readonly #createUniverseFor: TargetUniverseFactoryFn; readonly #universes = new WeakMap<Page, TargetUniverse>(); /** Guard access to #universes so we don't create unnecessary universes */ readonly #mutex = new Mutex(); constructor( browser: Browser, factory: TargetUniverseFactoryFn = DEFAULT_FACTORY, ) { this.#browser = browser; this.#createUniverseFor = factory; } async init(pages: Page[]) { try { await this.#mutex.acquire(); const promises = []; for (const page of pages) { promises.push( this.#createUniverseFor(page).then(targetUniverse => this.#universes.set(page, targetUniverse), ), ); } this.#browser.on('targetcreated', this.#onTargetCreated); this.#browser.on('targetdestroyed', this.#onTargetDestroyed); await Promise.all(promises); } finally { this.#mutex.release(); } } get(page: Page): TargetUniverse | null { return this.#universes.get(page) ?? null; } dispose() { this.#browser.off('targetcreated', this.#onTargetCreated); this.#browser.off('targetdestroyed', this.#onTargetDestroyed); } #onTargetCreated = async (target: PuppeteerTarget) => { const page = await target.page(); try { await this.#mutex.acquire(); if (!page || this.#universes.has(page)) { return; } this.#universes.set(page, await this.#createUniverseFor(page)); } finally { this.#mutex.release(); } }; #onTargetDestroyed = async (target: PuppeteerTarget) => { const page = await target.page(); try { await this.#mutex.acquire(); if (!page || !this.#universes.has(page)) { return; } this.#universes.delete(page); } finally { this.#mutex.release(); } }; } const DEFAULT_FACTORY: TargetUniverseFactoryFn = async (page: Page) => { const settingStorage = new DevTools.Common.Settings.SettingsStorage({}); const universe = new DevTools.Foundation.Universe.Universe({ settingsCreationOptions: { syncedStorage: settingStorage, globalStorage: settingStorage, localStorage: settingStorage, settingRegistrations: DevTools.Common.SettingRegistration.getRegisteredSettings(), }, overrideAutoStartModels: new Set([DevTools.DebuggerModel]), }); const session = await page.createCDPSession(); const connection = new PuppeteerDevToolsConnection(session); const targetManager = universe.context.get(DevTools.TargetManager); targetManager.observeModels(DevTools.DebuggerModel, SKIP_ALL_PAUSES); const target = targetManager.createTarget( 'main', '', 'frame' as any, // eslint-disable-line @typescript-eslint/no-explicit-any /* parentTarget */ null, session.id(), undefined, connection, ); return {target, universe}; }; // We don't want to pause any DevTools universe session ever on the MCP side. // // Note that calling `setSkipAllPauses` only affects the session on which it was // sent. This means DevTools can still pause, step and do whatever. We just won't // see the `Debugger.paused`/`Debugger.resumed` events on the MCP side. const SKIP_ALL_PAUSES = { modelAdded(model: DevTools.DebuggerModel): void { void model.agent.invoke_setSkipAllPauses({skip: true}); }, modelRemoved(): void { // Do nothing. }, };

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