diff --git a/CHANGELOG.md b/CHANGELOG.md index 326dcc38..1d115b37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [Unreleased] + +### Features + +* **inspection:** Add comprehensive element inspection tools for DOM and CSS analysis + * `inspect_element` - Get element info, HTML, attributes, and box model + * `get_element_styles` - Get computed, matched, and inherited CSS styles + * `get_element_box_model` - Get detailed box model layout information + * `query_selector` - Find elements using CSS selectors + * `highlight_element` / `hide_highlight` - Visually highlight elements on the page + * `get_dom_tree` - Get hierarchical DOM tree structure + * `capture_dom_snapshot` - Efficient full-page DOM and styles capture + * `force_element_state` - Force :hover, :focus, :active pseudo-states + * `get_element_event_listeners` - Get event listeners attached to elements + * `get_element_at_position` - Get element at x,y coordinates + * `search_dom` - Search DOM by text, CSS selector, or XPath + * `get_fonts_info` - Get font usage information for elements + * `show_layout_overlay` - Show CSS Grid/Flexbox layout overlays + * `get_accessibility_info` - Get detailed accessibility tree information + * `compare_elements` - Compare styles between two elements + * `get_css_variables` - Get CSS custom properties for elements +* **cli:** Add `--no-category-inspection` flag to disable inspection tools + ## [0.10.2](https://github.com/ChromeDevTools/chrome-devtools-mcp/compare/chrome-devtools-mcp-v0.10.1...chrome-devtools-mcp-v0.10.2) (2025-11-19) diff --git a/README.md b/README.md index 5864b073..200ce946 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,24 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles - [`list_console_messages`](docs/tool-reference.md#list_console_messages) - [`take_screenshot`](docs/tool-reference.md#take_screenshot) - [`take_snapshot`](docs/tool-reference.md#take_snapshot) +- **Element inspection** (17 tools) + - [`capture_dom_snapshot`](docs/tool-reference.md#capture_dom_snapshot) + - [`compare_elements`](docs/tool-reference.md#compare_elements) + - [`force_element_state`](docs/tool-reference.md#force_element_state) + - [`get_accessibility_info`](docs/tool-reference.md#get_accessibility_info) + - [`get_css_variables`](docs/tool-reference.md#get_css_variables) + - [`get_dom_tree`](docs/tool-reference.md#get_dom_tree) + - [`get_element_at_position`](docs/tool-reference.md#get_element_at_position) + - [`get_element_box_model`](docs/tool-reference.md#get_element_box_model) + - [`get_element_event_listeners`](docs/tool-reference.md#get_element_event_listeners) + - [`get_element_styles`](docs/tool-reference.md#get_element_styles) + - [`get_fonts_info`](docs/tool-reference.md#get_fonts_info) + - [`hide_highlight`](docs/tool-reference.md#hide_highlight) + - [`highlight_element`](docs/tool-reference.md#highlight_element) + - [`inspect_element`](docs/tool-reference.md#inspect_element) + - [`query_selector`](docs/tool-reference.md#query_selector) + - [`search_dom`](docs/tool-reference.md#search_dom) + - [`show_layout_overlay`](docs/tool-reference.md#show_layout_overlay) @@ -390,6 +408,11 @@ The Chrome DevTools MCP server supports the following configuration option: - **Type:** boolean - **Default:** `true` +- **`--categoryInspection`** + Set to false to exclude tools related to element inspection. + - **Type:** boolean + - **Default:** `true` + Pass them via the `args` property in the JSON configuration. For example: diff --git a/docs/ELEMENT_INSPECTION_RESEARCH.md b/docs/ELEMENT_INSPECTION_RESEARCH.md new file mode 100644 index 00000000..ceab4742 --- /dev/null +++ b/docs/ELEMENT_INSPECTION_RESEARCH.md @@ -0,0 +1,1110 @@ +# Element Inspection Tools - Comprehensive Research Document + +## Executive Summary + +This document outlines a comprehensive set of tools to add full element inspection capabilities to the Chrome DevTools MCP. These tools will enable AI agents to inspect DOM elements, retrieve their styles, layouts, properties, and more - essentially replicating the functionality of Chrome DevTools' Elements panel. + +## Current Architecture Overview + +The MCP currently uses: +- **Puppeteer** (v24.31.0) for browser automation +- **Accessibility Tree** for element identification (via `page.accessibility.snapshot()`) +- **Tool Definition Pattern** using Zod schemas for validation +- **CDP Sessions** accessible via `page._client().send()` + +## CDP Domains Required + +### Primary Domains + +| Domain | Purpose | Status | +|--------|---------|--------| +| **DOM** | Element structure, attributes, HTML | Required | +| **CSS** | Styles, computed styles, matched rules | Required | +| **DOMSnapshot** | Full DOM with styles in one call | Required | +| **Overlay** | Element highlighting/visualization | Optional | +| **Accessibility** | A11y tree (already used) | Already Used | + +--- + +## Proposed Tools (17 New Tools) + +### Category: INSPECTION (New Category) + +```typescript +enum ToolCategory { + INPUT = 'input', + NAVIGATION = 'navigation', + EMULATION = 'emulation', + PERFORMANCE = 'performance', + NETWORK = 'network', + DEBUGGING = 'debugging', + INSPECTION = 'inspection', // NEW +} +``` + +--- + +## Tool 1: `inspect_element` + +**Purpose**: Get comprehensive information about a specific element including its HTML, attributes, and position. + +**Schema**: +```typescript +{ + uid: zod.string().describe('Element UID from snapshot'), + includeChildren: zod.boolean().optional().default(false) + .describe('Include child elements in the output'), + depth: zod.number().int().min(1).max(10).optional().default(1) + .describe('Depth of children to include'), +} +``` + +**CDP Calls**: +```typescript +// Enable DOM domain +await client.send('DOM.enable'); + +// Get document root +const { root } = await client.send('DOM.getDocument', { depth: -1, pierce: true }); + +// Describe the specific node +const { node } = await client.send('DOM.describeNode', { + backendNodeId: element.backendNodeId, + depth: params.depth, + pierce: true +}); + +// Get outer HTML +const { outerHTML } = await client.send('DOM.getOuterHTML', { + backendNodeId: element.backendNodeId, + includeShadowDOM: true +}); + +// Get attributes +const { attributes } = await client.send('DOM.getAttributes', { + nodeId: node.nodeId +}); + +// Get box model (position, dimensions) +const { model } = await client.send('DOM.getBoxModel', { + backendNodeId: element.backendNodeId +}); +``` + +**Response Example**: +```json +{ + "tagName": "div", + "id": "main-container", + "className": "container flex-row", + "attributes": { + "id": "main-container", + "class": "container flex-row", + "data-testid": "main" + }, + "outerHTML": "
...
", + "boxModel": { + "content": { "x": 100, "y": 50, "width": 800, "height": 600 }, + "padding": { "x": 90, "y": 40, "width": 820, "height": 620 }, + "border": { "x": 88, "y": 38, "width": 824, "height": 624 }, + "margin": { "x": 80, "y": 30, "width": 840, "height": 640 } + } +} +``` + +--- + +## Tool 2: `get_element_styles` + +**Purpose**: Get all CSS styles applied to an element - computed, inline, and matched rules. + +**Schema**: +```typescript +{ + uid: zod.string().describe('Element UID from snapshot'), + includeInherited: zod.boolean().optional().default(true) + .describe('Include inherited styles from ancestors'), + includeComputed: zod.boolean().optional().default(true) + .describe('Include computed (final) style values'), + includePseudo: zod.boolean().optional().default(false) + .describe('Include pseudo-element styles (::before, ::after)'), + properties: zod.array(zod.string()).optional() + .describe('Filter to specific CSS properties (e.g., ["color", "font-size"])'), +} +``` + +**CDP Calls**: +```typescript +// Enable CSS domain +await client.send('CSS.enable'); + +// Get computed styles +const { computedStyle } = await client.send('CSS.getComputedStyleForNode', { + nodeId: nodeId +}); + +// Get matched styles (rules that apply to this element) +const matchedStyles = await client.send('CSS.getMatchedStylesForNode', { + nodeId: nodeId +}); +// Returns: inlineStyle, attributesStyle, matchedCSSRules, inherited, pseudoElements + +// Get inline styles specifically +const { inlineStyle, attributesStyle } = await client.send('CSS.getInlineStylesForNode', { + nodeId: nodeId +}); +``` + +**Response Example**: +```json +{ + "computed": { + "color": "rgb(33, 37, 41)", + "font-size": "16px", + "display": "flex", + "flex-direction": "row", + "width": "800px", + "height": "auto" + }, + "inline": { + "margin-top": "10px" + }, + "matchedRules": [ + { + "selector": ".container", + "source": "styles.css:42", + "properties": { + "display": "flex", + "padding": "20px" + } + }, + { + "selector": ".flex-row", + "source": "utilities.css:156", + "properties": { + "flex-direction": "row" + } + } + ], + "inherited": [ + { + "from": "body", + "properties": { + "font-family": "Arial, sans-serif", + "color": "rgb(33, 37, 41)" + } + } + ] +} +``` + +--- + +## Tool 3: `get_element_box_model` + +**Purpose**: Get detailed layout/box model information for an element (content, padding, border, margin). + +**Schema**: +```typescript +{ + uid: zod.string().describe('Element UID from snapshot'), +} +``` + +**CDP Calls**: +```typescript +const { model } = await client.send('DOM.getBoxModel', { + backendNodeId: element.backendNodeId +}); + +// For more precise quads (handles transforms) +const { quads } = await client.send('DOM.getContentQuads', { + backendNodeId: element.backendNodeId +}); +``` + +**Response Example**: +```json +{ + "content": { + "x": 120, + "y": 80, + "width": 760, + "height": 540 + }, + "padding": { + "top": 20, + "right": 20, + "bottom": 20, + "left": 20 + }, + "border": { + "top": 1, + "right": 1, + "bottom": 1, + "left": 1 + }, + "margin": { + "top": 10, + "right": 0, + "bottom": 10, + "left": 0 + }, + "quads": [[100, 70, 880, 70, 880, 630, 100, 630]] +} +``` + +--- + +## Tool 4: `query_selector` + +**Purpose**: Find elements using CSS selectors and return their UIDs for further inspection. + +**Schema**: +```typescript +{ + selector: zod.string().describe('CSS selector to query (e.g., ".btn-primary", "#header", "div.container > p")'), + all: zod.boolean().optional().default(false) + .describe('Return all matching elements (querySelectorAll) vs first match'), + limit: zod.number().int().min(1).max(100).optional().default(20) + .describe('Maximum number of elements to return when using all=true'), +} +``` + +**CDP Calls**: +```typescript +await client.send('DOM.enable'); +const { root } = await client.send('DOM.getDocument', { depth: 0 }); + +// Single element +const { nodeId } = await client.send('DOM.querySelector', { + nodeId: root.nodeId, + selector: params.selector +}); + +// Multiple elements +const { nodeIds } = await client.send('DOM.querySelectorAll', { + nodeId: root.nodeId, + selector: params.selector +}); +``` + +**Response Example**: +```json +{ + "found": 5, + "elements": [ + { "uid": "42_1", "tagName": "button", "className": "btn-primary", "text": "Submit" }, + { "uid": "42_2", "tagName": "button", "className": "btn-primary", "text": "Cancel" }, + { "uid": "42_3", "tagName": "button", "className": "btn-primary", "text": "Save" } + ] +} +``` + +--- + +## Tool 5: `get_element_at_position` + +**Purpose**: Get the element at a specific x,y coordinate on the page. + +**Schema**: +```typescript +{ + x: zod.number().int().describe('X coordinate on the page'), + y: zod.number().int().describe('Y coordinate on the page'), + includeShadowDOM: zod.boolean().optional().default(false) + .describe('Include elements inside shadow DOM'), +} +``` + +**CDP Calls**: +```typescript +const { backendNodeId, nodeId, frameId } = await client.send('DOM.getNodeForLocation', { + x: params.x, + y: params.y, + includeUserAgentShadowDOM: params.includeShadowDOM, + ignorePointerEventsNone: true +}); +``` + +--- + +## Tool 6: `get_dom_tree` + +**Purpose**: Get the DOM tree structure starting from a specific element or document root. + +**Schema**: +```typescript +{ + uid: zod.string().optional() + .describe('Element UID to start from (omit for document root)'), + depth: zod.number().int().min(1).max(20).optional().default(3) + .describe('How deep to traverse the tree'), + pierceIframes: zod.boolean().optional().default(false) + .describe('Include iframe content in the tree'), + includeShadowDOM: zod.boolean().optional().default(false) + .describe('Include shadow DOM content'), +} +``` + +**CDP Calls**: +```typescript +const { root } = await client.send('DOM.getDocument', { + depth: params.depth, + pierce: params.pierceIframes +}); + +// Request children of a specific node +await client.send('DOM.requestChildNodes', { + nodeId: nodeId, + depth: params.depth, + pierce: params.pierceIframes +}); +``` + +--- + +## Tool 7: `search_dom` + +**Purpose**: Search the DOM for elements matching a text query or XPath. + +**Schema**: +```typescript +{ + query: zod.string().describe('Search query - can be text content, XPath, or CSS selector'), + limit: zod.number().int().min(1).max(100).optional().default(20) + .describe('Maximum results to return'), +} +``` + +**CDP Calls**: +```typescript +// Perform search +const { searchId, resultCount } = await client.send('DOM.performSearch', { + query: params.query, + includeUserAgentShadowDOM: false +}); + +// Get results +const { nodeIds } = await client.send('DOM.getSearchResults', { + searchId: searchId, + fromIndex: 0, + toIndex: Math.min(resultCount, params.limit) +}); + +// Cleanup +await client.send('DOM.discardSearchResults', { searchId }); +``` + +--- + +## Tool 8: `capture_dom_snapshot` + +**Purpose**: Capture a complete DOM snapshot with all styles in a single efficient call. + +**Schema**: +```typescript +{ + computedStyles: zod.array(zod.string()).optional() + .describe('List of CSS properties to capture (e.g., ["display", "color", "font-size"])'), + includePaintOrder: zod.boolean().optional().default(false) + .describe('Include paint order information'), + includeTextColorOpacities: zod.boolean().optional().default(false) + .describe('Include text color opacity calculations'), +} +``` + +**CDP Calls**: +```typescript +await client.send('DOMSnapshot.enable'); + +const snapshot = await client.send('DOMSnapshot.captureSnapshot', { + computedStyles: params.computedStyles || [ + 'display', 'visibility', 'opacity', + 'color', 'background-color', + 'font-family', 'font-size', 'font-weight', + 'width', 'height', 'position', + 'top', 'right', 'bottom', 'left', + 'margin', 'padding', 'border', + 'flex-direction', 'justify-content', 'align-items', + 'grid-template-columns', 'grid-template-rows' + ], + includePaintOrder: params.includePaintOrder, + includeTextColorOpacities: params.includeTextColorOpacities, + includeDOMRects: true, + includeBlendedBackgroundColors: true +}); +``` + +--- + +## Tool 9: `get_fonts_for_element` + +**Purpose**: Get information about fonts used to render text in an element. + +**Schema**: +```typescript +{ + uid: zod.string().describe('Element UID from snapshot'), +} +``` + +**CDP Calls**: +```typescript +const { fonts } = await client.send('CSS.getPlatformFontsForNode', { + nodeId: nodeId +}); +``` + +**Response Example**: +```json +{ + "fonts": [ + { "familyName": "Roboto", "postScriptName": "Roboto-Regular", "glyphCount": 156 }, + { "familyName": "Arial", "postScriptName": "ArialMT", "glyphCount": 12 } + ] +} +``` + +--- + +## Tool 10: `get_css_media_queries` + +**Purpose**: Get all media queries defined in the page's stylesheets. + +**Schema**: +```typescript +{} // No parameters needed +``` + +**CDP Calls**: +```typescript +const { medias } = await client.send('CSS.getMediaQueries'); +``` + +--- + +## Tool 11: `force_element_state` + +**Purpose**: Force an element into a specific CSS pseudo-state for inspection (hover, focus, active, etc.). + +**Schema**: +```typescript +{ + uid: zod.string().describe('Element UID from snapshot'), + states: zod.array(zod.enum(['active', 'focus', 'hover', 'visited', 'focus-within', 'focus-visible'])) + .describe('Pseudo-states to force on the element'), +} +``` + +**CDP Calls**: +```typescript +await client.send('CSS.forcePseudoState', { + nodeId: nodeId, + forcedPseudoClasses: params.states +}); +``` + +--- + +## Tool 12: `highlight_element` + +**Purpose**: Visually highlight an element on the page (useful for verification). + +**Schema**: +```typescript +{ + uid: zod.string().describe('Element UID from snapshot'), + color: zod.object({ + r: zod.number().min(0).max(255).optional().default(255), + g: zod.number().min(0).max(255).optional().default(0), + b: zod.number().min(0).max(255).optional().default(0), + a: zod.number().min(0).max(1).optional().default(0.3), + }).optional().describe('Highlight color (RGBA)'), + duration: zod.number().int().min(0).max(10000).optional().default(2000) + .describe('How long to show highlight in milliseconds (0 = until hideHighlight)'), +} +``` + +**CDP Calls**: +```typescript +await client.send('Overlay.enable'); + +await client.send('Overlay.highlightNode', { + highlightConfig: { + contentColor: { r: params.color.r, g: params.color.g, b: params.color.b, a: params.color.a }, + paddingColor: { r: 0, g: 255, b: 0, a: 0.2 }, + borderColor: { r: 0, g: 0, b: 255, a: 0.5 }, + marginColor: { r: 255, g: 165, b: 0, a: 0.2 }, + showInfo: true, + showStyles: true, + showRulers: true, + showExtensionLines: true, + showAccessibilityInfo: true + }, + backendNodeId: element.backendNodeId +}); + +// Auto-hide after duration +if (params.duration > 0) { + setTimeout(() => client.send('Overlay.hideHighlight'), params.duration); +} +``` + +--- + +## Tool 13: `hide_highlight` + +**Purpose**: Hide any active element highlight. + +**Schema**: +```typescript +{} // No parameters +``` + +**CDP Calls**: +```typescript +await client.send('Overlay.hideHighlight'); +``` + +--- + +## Tool 14: `get_element_accessibility` + +**Purpose**: Get detailed accessibility information for an element. + +**Schema**: +```typescript +{ + uid: zod.string().describe('Element UID from snapshot'), + includeAncestors: zod.boolean().optional().default(false) + .describe('Include accessibility info for ancestor elements'), +} +``` + +**CDP Calls**: +```typescript +await client.send('Accessibility.enable'); + +const { nodes } = await client.send('Accessibility.getAXNodeAndAncestors', { + backendNodeId: element.backendNodeId +}); + +// Or for partial tree +const { nodes } = await client.send('Accessibility.getPartialAXTree', { + backendNodeId: element.backendNodeId, + fetchRelatives: params.includeAncestors +}); +``` + +--- + +## Tool 15: `show_layout_overlays` + +**Purpose**: Show CSS Grid/Flexbox layout overlays for debugging layout. + +**Schema**: +```typescript +{ + uid: zod.string().describe('Element UID from snapshot'), + type: zod.enum(['grid', 'flex', 'container']).describe('Type of layout overlay to show'), + showLineNames: zod.boolean().optional().default(true), + showLineNumbers: zod.boolean().optional().default(true), + showAreaNames: zod.boolean().optional().default(true), +} +``` + +**CDP Calls**: +```typescript +await client.send('Overlay.enable'); + +// For Grid +await client.send('Overlay.setShowGridOverlays', { + gridNodeHighlightConfigs: [{ + nodeId: nodeId, + gridHighlightConfig: { + showGridExtensionLines: true, + showPositiveLineNumbers: params.showLineNumbers, + showNegativeLineNumbers: params.showLineNumbers, + showAreaNames: params.showAreaNames, + showLineNames: params.showLineNames, + gridBorderColor: { r: 255, g: 0, b: 255, a: 0.8 }, + cellBorderColor: { r: 128, g: 128, b: 128, a: 0.4 }, + gridBackgroundColor: { r: 255, g: 0, b: 255, a: 0.1 } + } + }] +}); + +// For Flexbox +await client.send('Overlay.setShowFlexOverlays', { + flexNodeHighlightConfigs: [{ + nodeId: nodeId, + flexContainerHighlightConfig: { + containerBorder: { color: { r: 255, g: 165, b: 0, a: 0.8 } }, + itemSeparator: { color: { r: 255, g: 165, b: 0, a: 0.3 } }, + mainDistributedSpace: { fillColor: { r: 255, g: 165, b: 0, a: 0.1 } }, + crossDistributedSpace: { fillColor: { r: 0, g: 165, b: 255, a: 0.1 } } + } + }] +}); +``` + +--- + +## Tool 16: `get_element_event_listeners` + +**Purpose**: Get all event listeners attached to an element. + +**Schema**: +```typescript +{ + uid: zod.string().describe('Element UID from snapshot'), + depth: zod.number().int().min(-1).max(10).optional().default(-1) + .describe('Depth of subtree to search for listeners (-1 = all)'), +} +``` + +**CDP Calls**: +```typescript +// First resolve the element to a RemoteObject +const { object } = await client.send('DOM.resolveNode', { + backendNodeId: element.backendNodeId +}); + +// Get event listeners via DOMDebugger +const { listeners } = await client.send('DOMDebugger.getEventListeners', { + objectId: object.objectId, + depth: params.depth, + pierce: true +}); +``` + +**Response Example**: +```json +{ + "listeners": [ + { + "type": "click", + "handler": "function onClick(e) { ... }", + "scriptId": "42", + "lineNumber": 156, + "columnNumber": 12, + "useCapture": false, + "passive": false, + "once": false + }, + { + "type": "mouseenter", + "handler": "function onHover(e) { ... }", + "useCapture": false + } + ] +} +``` + +--- + +## Tool 17: `compare_elements` + +**Purpose**: Compare two elements' styles/properties to understand their differences. + +**Schema**: +```typescript +{ + uid1: zod.string().describe('First element UID'), + uid2: zod.string().describe('Second element UID'), + compareStyles: zod.boolean().optional().default(true) + .describe('Compare computed styles'), + compareLayout: zod.boolean().optional().default(true) + .describe('Compare box model/layout'), + compareAttributes: zod.boolean().optional().default(true) + .describe('Compare HTML attributes'), +} +``` + +**Response Example**: +```json +{ + "styleDifferences": { + "color": { "element1": "rgb(0, 0, 0)", "element2": "rgb(255, 0, 0)" }, + "font-size": { "element1": "16px", "element2": "14px" } + }, + "layoutDifferences": { + "width": { "element1": 200, "element2": 180 }, + "padding-left": { "element1": 10, "element2": 20 } + }, + "attributeDifferences": { + "class": { "element1": "btn-primary", "element2": "btn-secondary" } + }, + "sameProperties": ["display", "position", "font-family"] +} +``` + +--- + +## Implementation Architecture + +### New File Structure + +``` +src/tools/ +├── inspection/ +│ ├── index.ts # Export all inspection tools +│ ├── inspectElement.ts # Tool 1 +│ ├── getElementStyles.ts # Tool 2 +│ ├── getBoxModel.ts # Tool 3 +│ ├── querySelector.ts # Tool 4 +│ ├── getElementAtPosition.ts # Tool 5 +│ ├── getDomTree.ts # Tool 6 +│ ├── searchDom.ts # Tool 7 +│ ├── captureDomSnapshot.ts # Tool 8 +│ ├── getFonts.ts # Tool 9 +│ ├── getMediaQueries.ts # Tool 10 +│ ├── forceState.ts # Tool 11 +│ ├── highlight.ts # Tools 12, 13 +│ ├── accessibility.ts # Tool 14 +│ ├── layoutOverlays.ts # Tool 15 +│ ├── eventListeners.ts # Tool 16 +│ └── compareElements.ts # Tool 17 +├── categories.ts # Add INSPECTION category +└── ... +``` + +### CDP Session Helper + +Create a helper to manage CDP sessions: + +```typescript +// src/utils/cdpSession.ts +import type { Page, CDPSession } from 'puppeteer'; + +interface EnabledDomains { + DOM?: boolean; + CSS?: boolean; + DOMSnapshot?: boolean; + Overlay?: boolean; + Accessibility?: boolean; + DOMDebugger?: boolean; +} + +export class CdpSessionManager { + private sessions = new WeakMap(); + private enabledDomains = new WeakMap(); + + async getSession(page: Page): Promise { + let session = this.sessions.get(page); + if (!session) { + // @ts-expect-error _client is internal + session = page._client(); + this.sessions.set(page, session); + } + return session; + } + + async ensureDomainEnabled( + page: Page, + domain: keyof EnabledDomains + ): Promise { + const session = await this.getSession(page); + const domains = this.enabledDomains.get(session) || {}; + + if (!domains[domain]) { + await session.send(`${domain}.enable`); + domains[domain] = true; + this.enabledDomains.set(session, domains); + } + + return session; + } +} +``` + +### NodeId Resolver + +Since the MCP uses backendNodeId from accessibility tree, we need to convert: + +```typescript +// src/utils/nodeResolver.ts +export async function resolveToNodeId( + session: CDPSession, + backendNodeId: number +): Promise { + const { nodeIds } = await session.send('DOM.pushNodesByBackendIdsToFrontend', { + backendNodeIds: [backendNodeId] + }); + + if (!nodeIds || nodeIds.length === 0 || nodeIds[0] === 0) { + throw new Error('Could not resolve backendNodeId to nodeId'); + } + + return nodeIds[0]; +} +``` + +### Context Extensions + +Extend `McpContext` to support inspection: + +```typescript +// Add to McpContext interface +interface Context { + // ... existing methods ... + + // New methods for inspection + getCdpSession(): Promise; + getBackendNodeId(uid: string): Promise; + resolveToNodeId(backendNodeId: number): Promise; +} +``` + +--- + +## Example Implementation: `get_element_styles` + +```typescript +// src/tools/inspection/getElementStyles.ts +import { zod } from '../../third_party/index.js'; +import { ToolCategory } from '../categories.js'; +import { defineTool } from '../ToolDefinition.js'; + +export const getElementStyles = defineTool({ + name: 'get_element_styles', + description: `Get CSS styles for an element including computed styles, matched rules, and inherited styles. +This is equivalent to the "Styles" panel in Chrome DevTools Elements tab.`, + annotations: { + title: 'Get Element Styles', + category: ToolCategory.INSPECTION, + readOnlyHint: true, + }, + schema: { + uid: zod.string().describe('Element UID from snapshot (e.g., "42_5")'), + includeInherited: zod.boolean().optional().default(true) + .describe('Include styles inherited from ancestor elements'), + includeComputed: zod.boolean().optional().default(true) + .describe('Include final computed style values'), + includePseudo: zod.boolean().optional().default(false) + .describe('Include pseudo-element styles (::before, ::after, etc.)'), + properties: zod.array(zod.string()).optional() + .describe('Filter to specific CSS properties. If omitted, returns all properties.'), + }, + handler: async (request, response, context) => { + const page = context.getSelectedPage(); + + // @ts-expect-error _client is internal + const client = page._client(); + + // Enable required domains + await client.send('DOM.enable'); + await client.send('CSS.enable'); + + // Get element by UID + const element = await context.getElementByUid(request.params.uid); + const axNode = context.getAXNodeByUid(request.params.uid); + + if (!axNode?.backendNodeId) { + throw new Error('Could not find element backendNodeId'); + } + + // Push node to frontend to get nodeId + const { nodeIds } = await client.send('DOM.pushNodesByBackendIdsToFrontend', { + backendNodeIds: [axNode.backendNodeId] + }); + + const nodeId = nodeIds[0]; + if (!nodeId) { + throw new Error('Could not resolve element'); + } + + const result: Record = {}; + + // Get computed styles + if (request.params.includeComputed) { + const { computedStyle } = await client.send('CSS.getComputedStyleForNode', { + nodeId + }); + + const computed: Record = {}; + for (const prop of computedStyle) { + if (!request.params.properties || + request.params.properties.includes(prop.name)) { + computed[prop.name] = prop.value; + } + } + result.computed = computed; + } + + // Get matched styles + const matched = await client.send('CSS.getMatchedStylesForNode', { + nodeId + }); + + // Process inline styles + if (matched.inlineStyle) { + const inline: Record = {}; + for (const prop of matched.inlineStyle.cssProperties || []) { + if (prop.value && (!request.params.properties || + request.params.properties.includes(prop.name))) { + inline[prop.name] = prop.value; + } + } + if (Object.keys(inline).length > 0) { + result.inline = inline; + } + } + + // Process matched CSS rules + if (matched.matchedCSSRules) { + result.matchedRules = matched.matchedCSSRules.map(match => ({ + selector: match.rule.selectorList?.selectors + ?.map(s => s.text).join(', '), + source: match.rule.styleSheetId ? + `${match.rule.origin}` : 'user-agent', + properties: Object.fromEntries( + (match.rule.style?.cssProperties || []) + .filter(p => !p.disabled && p.value) + .filter(p => !request.params.properties || + request.params.properties.includes(p.name)) + .map(p => [p.name, p.value]) + ) + })).filter(r => Object.keys(r.properties).length > 0); + } + + // Process inherited styles + if (request.params.includeInherited && matched.inherited) { + result.inherited = matched.inherited.map(inh => ({ + matchedRules: inh.matchedCSSRules?.map(match => ({ + selector: match.rule.selectorList?.selectors + ?.map(s => s.text).join(', '), + properties: Object.fromEntries( + (match.rule.style?.cssProperties || []) + .filter(p => !p.disabled && p.value) + .filter(p => !request.params.properties || + request.params.properties.includes(p.name)) + .map(p => [p.name, p.value]) + ) + })).filter(r => Object.keys(r.properties || {}).length > 0) + })).filter(inh => inh.matchedRules && inh.matchedRules.length > 0); + } + + // Process pseudo-elements + if (request.params.includePseudo && matched.pseudoElements) { + result.pseudoElements = matched.pseudoElements.map(pseudo => ({ + pseudoType: pseudo.pseudoType, + pseudoIdentifier: pseudo.pseudoIdentifier, + rules: pseudo.matches?.map(match => ({ + selector: match.rule.selectorList?.selectors + ?.map(s => s.text).join(', '), + properties: Object.fromEntries( + (match.rule.style?.cssProperties || []) + .filter(p => !p.disabled && p.value) + .map(p => [p.name, p.value]) + ) + })) + })); + } + + response.appendResponseLine(JSON.stringify(result, null, 2)); + }, +}); +``` + +--- + +## Usage Examples for AI Agents + +### Example 1: Copying a Button Style + +**User Request**: "I want to copy the button style from this website" + +**Agent Workflow**: +``` +1. take_snapshot() - Get elements on page +2. query_selector({ selector: "button.primary" }) - Find the button +3. get_element_styles({ uid: "42_5", includeComputed: true }) - Get all styles +4. Agent extracts relevant CSS properties and provides them +``` + +### Example 2: Debugging Layout Issues + +**User Request**: "Why is this element not aligned properly?" + +**Agent Workflow**: +``` +1. take_snapshot() - Get elements +2. inspect_element({ uid: "42_10" }) - Get element info +3. get_element_box_model({ uid: "42_10" }) - Check margins/padding +4. show_layout_overlays({ uid: "42_10", type: "flex" }) - Visualize flexbox +5. get_element_styles({ uid: "42_10", properties: ["display", "flex-direction", "align-items", "justify-content"] }) +6. Agent explains the issue based on gathered data +``` + +### Example 3: Understanding Component Structure + +**User Request**: "How is this navigation menu structured?" + +**Agent Workflow**: +``` +1. take_snapshot() +2. query_selector({ selector: "nav", all: false }) +3. get_dom_tree({ uid: "42_3", depth: 5 }) +4. get_element_styles({ uid: "42_3" }) +5. Agent provides structural analysis +``` + +--- + +## Performance Considerations + +1. **Batch Operations**: Use `DOMSnapshot.captureSnapshot` for bulk style retrieval instead of multiple `CSS.getComputedStyleForNode` calls. + +2. **Lazy Domain Enabling**: Only enable CDP domains when needed, cache enabled state per session. + +3. **Limit Depth**: Always limit DOM tree depth to avoid performance issues on complex pages. + +4. **Cleanup**: Properly disable overlays and release object references when done. + +5. **Caching**: Consider caching nodeId resolutions within a snapshot session. + +--- + +## Security Considerations + +1. **Read-Only by Default**: All inspection tools should be marked as `readOnlyHint: true`. + +2. **No Code Execution**: These tools should never execute arbitrary code, only read data. + +3. **Sensitive Data**: Be aware that computed styles might reveal system fonts or user preferences. + +4. **Cross-Origin**: CDP respects same-origin policy for iframe inspection. + +--- + +## References + +- [Chrome DevTools Protocol - DOM Domain](https://chromedevtools.github.io/devtools-protocol/tot/DOM/) +- [Chrome DevTools Protocol - CSS Domain](https://chromedevtools.github.io/devtools-protocol/tot/CSS/) +- [Chrome DevTools Protocol - DOMSnapshot Domain](https://chromedevtools.github.io/devtools-protocol/tot/DOMSnapshot/) +- [Chrome DevTools Protocol - Overlay Domain](https://chromedevtools.github.io/devtools-protocol/tot/Overlay/) +- [Chrome DevTools Protocol - Accessibility Domain](https://chromedevtools.github.io/devtools-protocol/tot/Accessibility/) +- [chrome-inspector Library](https://github.com/devtoolcss/chrome-inspector) +- [Puppeteer CDP Session](https://pptr.dev/api/puppeteer.cdpsession) + +--- + +## Summary + +This research document outlines **17 new tools** across the following capabilities: + +| Capability | Tools | +|------------|-------| +| Element Info | `inspect_element`, `get_dom_tree`, `search_dom` | +| CSS Styles | `get_element_styles`, `get_fonts_for_element`, `get_css_media_queries`, `force_element_state` | +| Layout | `get_element_box_model`, `show_layout_overlays` | +| Selection | `query_selector`, `get_element_at_position` | +| Snapshots | `capture_dom_snapshot` | +| Visual | `highlight_element`, `hide_highlight` | +| Accessibility | `get_element_accessibility` | +| Events | `get_element_event_listeners` | +| Analysis | `compare_elements` | + +These tools would transform the Chrome DevTools MCP into a comprehensive element inspection toolkit, enabling AI agents to fully understand and replicate UI designs from any website. diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 0e126b8c..c9a5395a 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -34,6 +34,24 @@ - [`list_console_messages`](#list_console_messages) - [`take_screenshot`](#take_screenshot) - [`take_snapshot`](#take_snapshot) +- **[Element inspection](#element-inspection)** (17 tools) + - [`capture_dom_snapshot`](#capture_dom_snapshot) + - [`compare_elements`](#compare_elements) + - [`force_element_state`](#force_element_state) + - [`get_accessibility_info`](#get_accessibility_info) + - [`get_css_variables`](#get_css_variables) + - [`get_dom_tree`](#get_dom_tree) + - [`get_element_at_position`](#get_element_at_position) + - [`get_element_box_model`](#get_element_box_model) + - [`get_element_event_listeners`](#get_element_event_listeners) + - [`get_element_styles`](#get_element_styles) + - [`get_fonts_info`](#get_fonts_info) + - [`hide_highlight`](#hide_highlight) + - [`highlight_element`](#highlight_element) + - [`inspect_element`](#inspect_element) + - [`query_selector`](#query_selector) + - [`search_dom`](#search_dom) + - [`show_layout_overlay`](#show_layout_overlay) ## Input automation @@ -339,3 +357,221 @@ in the DevTools Elements panel (if any). - **verbose** (boolean) _(optional)_: Whether to include all possible information available in the full a11y tree. Default is false. --- + +## Element inspection + +### `capture_dom_snapshot` + +**Description:** Capture a complete DOM snapshot with computed styles for all elements. +This is an efficient way to get DOM structure and styles in a single call. +Useful for analyzing entire page layouts or large sections. + +**Parameters:** + +- **computedStyles** (array) _(optional)_: CSS properties to capture (default: display, color, background-color, font-size, etc.) + +--- + +### `compare_elements` + +**Description:** Compare two elements' styles and attributes to understand their differences. +Returns a diff of computed styles, showing which properties differ between elements. +Useful for debugging inconsistent styling or understanding variations. + +**Parameters:** + +- **properties** (array) _(optional)_: CSS properties to compare (default: common layout/visual properties) +- **uid1** (string) **(required)**: First element UID +- **uid2** (string) **(required)**: Second element UID + +--- + +### `force_element_state` + +**Description:** Force an element into specific CSS pseudo-states for inspection. +Use this to inspect :[`hover`](#hover), :active, :focus styles without actually interacting with the element. +Multiple states can be forced simultaneously. + +**Parameters:** + +- **states** (array) **(required)**: Pseudo-states to force (e.g., ["[`hover`](#hover)", "focus"]) +- **uid** (string) **(required)**: Element UID from snapshot (e.g., "42_5") + +--- + +### `get_accessibility_info` + +**Description:** Get detailed accessibility information for an element. +Returns ARIA roles, states, properties, and the accessibility tree node. +Useful for ensuring proper accessibility implementation. + +**Parameters:** + +- **includeAncestors** (boolean) _(optional)_: Include accessibility info for ancestor elements +- **uid** (string) **(required)**: Element UID from snapshot (e.g., "42_5") + +--- + +### `get_css_variables` + +**Description:** Get CSS custom properties (variables) that apply to an element. +Returns both the variables defined on the element and inherited variables. +Useful for understanding design systems and theming. + +**Parameters:** + +- **uid** (string) **(required)**: Element UID from snapshot (e.g., "42_5") + +--- + +### `get_dom_tree` + +**Description:** Get the DOM tree structure starting from a specific element or document root. +Returns a hierarchical view of elements with their tag names and key attributes. +Useful for understanding the structure of a component or page section. + +**Parameters:** + +- **depth** (integer) _(optional)_: How deep to traverse the tree +- **uid** (string) _(optional)_: Element UID to start from (omit for document root) + +--- + +### `get_element_at_position` + +**Description:** Get the element at specific x,y coordinates on the page. +Returns basic information about the topmost element at that position. +Useful for identifying elements at specific visual locations. + +**Parameters:** + +- **x** (integer) **(required)**: X coordinate on the page +- **y** (integer) **(required)**: Y coordinate on the page + +--- + +### `get_element_box_model` + +**Description:** Get the box model (layout) information for an element. +Returns content, padding, border, and margin dimensions. +This is equivalent to the box model diagram shown in Chrome DevTools. + +**Parameters:** + +- **uid** (string) **(required)**: Element UID from snapshot (e.g., "42_5") + +--- + +### `get_element_event_listeners` + +**Description:** Get all event listeners attached to an element. +Returns the event type, handler function preview, and listener options. +Useful for understanding element interactivity. + +**Parameters:** + +- **uid** (string) **(required)**: Element UID from snapshot (e.g., "42_5") + +--- + +### `get_element_styles` + +**Description:** Get CSS styles for an element including computed styles, matched CSS rules, and inherited styles. +This is equivalent to the "Styles" panel in Chrome DevTools Elements tab. +Use this to understand how an element is styled and copy styles from websites. + +**Parameters:** + +- **includeComputed** (boolean) _(optional)_: Include final computed style values +- **includeInherited** (boolean) _(optional)_: Include styles inherited from ancestor elements +- **properties** (array) _(optional)_: Filter to specific CSS properties (e.g., ["color", "font-size"]). If omitted, returns common properties. +- **uid** (string) **(required)**: Element UID from snapshot (e.g., "42_5") + +--- + +### `get_fonts_info` + +**Description:** Get information about fonts used to render text in an element. +Shows which fonts are actually being used (may differ from CSS font-family). +Useful for understanding typography and font fallbacks. + +**Parameters:** + +- **uid** (string) **(required)**: Element UID from snapshot (e.g., "42_5") + +--- + +### `hide_highlight` + +**Description:** Hide any active element highlight on the page. + +**Parameters:** None + +--- + +### `highlight_element` + +**Description:** Visually highlight an element on the page with a colored overlay. +Useful for verifying you've identified the correct element. +The highlight shows content (blue), padding (green), border (yellow), and margin (orange). + +**Parameters:** + +- **duration** (integer) _(optional)_: How long to show highlight in milliseconds (0 = until [`hide_highlight`](#hide_highlight) is called) +- **uid** (string) **(required)**: Element UID from snapshot (e.g., "42_5") + +--- + +### `inspect_element` + +**Description:** Get comprehensive information about an element including its HTML, attributes, and position. +This is equivalent to inspecting an element in Chrome DevTools Elements panel. +Returns tag name, id, classes, all attributes, outer HTML, and box model dimensions. + +**Parameters:** + +- **includeHtml** (boolean) _(optional)_: Include the outer HTML of the element +- **maxHtmlLength** (integer) _(optional)_: Maximum length of HTML to return (truncated if longer) +- **uid** (string) **(required)**: Element UID from snapshot (e.g., "42_5") + +--- + +### `query_selector` + +**Description:** Find elements using CSS selectors. +Returns element information for matching elements. +Use this to find elements by class, id, tag, or complex CSS selectors. + +**Parameters:** + +- **all** (boolean) _(optional)_: Return all matching elements vs just the first match +- **limit** (integer) _(optional)_: Maximum number of elements to return when all=true +- **selector** (string) **(required)**: CSS selector (e.g., ".btn-primary", "#header", "div.container > p") + +--- + +### `search_dom` + +**Description:** Search the DOM for elements matching a text query, CSS selector, or XPath. +Returns matching elements with their basic information. +Supports plain text search, CSS selectors, and XPath expressions. + +**Parameters:** + +- **limit** (integer) _(optional)_: Maximum results to return +- **query** (string) **(required)**: Search query - text content, CSS selector, or XPath expression + +--- + +### `show_layout_overlay` + +**Description:** Show CSS Grid or Flexbox layout overlay for an element. +Visualizes grid lines, flex containers, gaps, and alignment. +Helps understand and debug complex layouts. + +**Parameters:** + +- **type** (enum: "grid", "flex") **(required)**: Type of layout overlay to show +- **uid** (string) **(required)**: Element UID from snapshot (e.g., "42_5") + +--- diff --git a/src/cli.ts b/src/cli.ts index 15808537..fbea197f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -165,6 +165,11 @@ export const cliOptions = { default: true, describe: 'Set to false to exclude tools related to network.', }, + categoryInspection: { + type: 'boolean', + default: true, + describe: 'Set to false to exclude tools related to element inspection.', + }, } satisfies Record; export function parseArguments(version: string, argv = process.argv) { @@ -217,6 +222,10 @@ export function parseArguments(version: string, argv = process.argv) { 'Disable tools in the performance category', ], ['$0 --no-category-network', 'Disable tools in the network category'], + [ + '$0 --no-category-inspection', + 'Disable tools in the inspection category', + ], [ '$0 --user-data-dir=/tmp/user-data-dir', 'Use a custom user data directory', diff --git a/src/main.ts b/src/main.ts index f0092f33..9649660b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -25,6 +25,7 @@ import {ToolCategory} from './tools/categories.js'; import * as consoleTools from './tools/console.js'; import * as emulationTools from './tools/emulation.js'; import * as inputTools from './tools/input.js'; +import * as inspectionTools from './tools/inspection.js'; import * as networkTools from './tools/network.js'; import * as pagesTools from './tools/pages.js'; import * as performanceTools from './tools/performance.js'; @@ -121,6 +122,12 @@ function registerTool(tool: ToolDefinition): void { ) { return; } + if ( + tool.annotations.category === ToolCategory.INSPECTION && + args.categoryInspection === false + ) { + return; + } server.registerTool( tool.name, { @@ -170,6 +177,7 @@ const tools = [ ...Object.values(consoleTools), ...Object.values(emulationTools), ...Object.values(inputTools), + ...Object.values(inspectionTools), ...Object.values(networkTools), ...Object.values(pagesTools), ...Object.values(performanceTools), diff --git a/src/tools/categories.ts b/src/tools/categories.ts index f27a8036..5a060d79 100644 --- a/src/tools/categories.ts +++ b/src/tools/categories.ts @@ -11,6 +11,7 @@ export enum ToolCategory { PERFORMANCE = 'performance', NETWORK = 'network', DEBUGGING = 'debugging', + INSPECTION = 'inspection', } export const labels = { @@ -20,4 +21,5 @@ export const labels = { [ToolCategory.PERFORMANCE]: 'Performance', [ToolCategory.NETWORK]: 'Network', [ToolCategory.DEBUGGING]: 'Debugging', + [ToolCategory.INSPECTION]: 'Element inspection', }; diff --git a/src/tools/inspection.ts b/src/tools/inspection.ts new file mode 100644 index 00000000..67fecb8f --- /dev/null +++ b/src/tools/inspection.ts @@ -0,0 +1,1608 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {zod} from '../third_party/index.js'; +import { + getCdpSession, + resolveToNodeId, + ensureDomEnabled, + ensureCssEnabled, + ensureOverlayEnabled, + formatQuad, + cssPropertiesToObject, +} from '../utils/cdpHelper.js'; + +import {ToolCategory} from './categories.js'; +import {defineTool} from './ToolDefinition.js'; + +// ============================================================================ +// Tool 1: inspect_element +// ============================================================================ + +export const inspectElement = defineTool({ + name: 'inspect_element', + description: `Get comprehensive information about an element including its HTML, attributes, and position. +This is equivalent to inspecting an element in Chrome DevTools Elements panel. +Returns tag name, id, classes, all attributes, outer HTML, and box model dimensions.`, + annotations: { + title: 'Inspect Element', + category: ToolCategory.INSPECTION, + readOnlyHint: true, + }, + schema: { + uid: zod.string().describe('Element UID from snapshot (e.g., "42_5")'), + includeHtml: zod + .boolean() + .optional() + .default(true) + .describe('Include the outer HTML of the element'), + maxHtmlLength: zod + .number() + .int() + .min(100) + .max(50000) + .optional() + .default(5000) + .describe('Maximum length of HTML to return (truncated if longer)'), + }, + handler: async (request, response, context) => { + const page = context.getSelectedPage(); + const client = await getCdpSession(page); + + await ensureDomEnabled(client); + + const axNode = context.getAXNodeByUid(request.params.uid); + if (!axNode?.backendNodeId) { + throw new Error( + `Could not find element with UID "${request.params.uid}". Make sure to call take_snapshot first.`, + ); + } + + const nodeId = await resolveToNodeId(client, axNode.backendNodeId); + + // Get node description + const {node} = await client.send('DOM.describeNode', { + nodeId, + depth: 1, + pierce: true, + }); + + // Get attributes + const {attributes} = await client.send('DOM.getAttributes', {nodeId}); + const attrMap: Record = {}; + for (let i = 0; i < attributes.length; i += 2) { + attrMap[attributes[i]] = attributes[i + 1]; + } + + // Get box model + let boxModel = null; + try { + const {model} = await client.send('DOM.getBoxModel', {nodeId}); + boxModel = { + content: formatQuad(model.content), + padding: formatQuad(model.padding), + border: formatQuad(model.border), + margin: formatQuad(model.margin), + width: model.width, + height: model.height, + }; + } catch { + // Box model may not be available for all elements + } + + // Get outer HTML + let outerHTML = ''; + if (request.params.includeHtml) { + try { + const result = await client.send('DOM.getOuterHTML', {nodeId}); + outerHTML = result.outerHTML; + if (outerHTML.length > request.params.maxHtmlLength) { + outerHTML = + outerHTML.substring(0, request.params.maxHtmlLength) + + '\n... (truncated)'; + } + } catch { + // HTML may not be available + } + } + + const result = { + uid: request.params.uid, + tagName: node.nodeName.toLowerCase(), + nodeType: node.nodeType, + id: attrMap['id'] || null, + className: attrMap['class'] || null, + attributes: attrMap, + childCount: node.childNodeCount || 0, + boxModel, + outerHTML: request.params.includeHtml ? outerHTML : undefined, + }; + + response.appendResponseLine(JSON.stringify(result, null, 2)); + }, +}); + +// ============================================================================ +// Tool 2: get_element_styles +// ============================================================================ + +export const getElementStyles = defineTool({ + name: 'get_element_styles', + description: `Get CSS styles for an element including computed styles, matched CSS rules, and inherited styles. +This is equivalent to the "Styles" panel in Chrome DevTools Elements tab. +Use this to understand how an element is styled and copy styles from websites.`, + annotations: { + title: 'Get Element Styles', + category: ToolCategory.INSPECTION, + readOnlyHint: true, + }, + schema: { + uid: zod.string().describe('Element UID from snapshot (e.g., "42_5")'), + includeInherited: zod + .boolean() + .optional() + .default(false) + .describe('Include styles inherited from ancestor elements'), + includeComputed: zod + .boolean() + .optional() + .default(true) + .describe('Include final computed style values'), + properties: zod + .array(zod.string()) + .optional() + .describe( + 'Filter to specific CSS properties (e.g., ["color", "font-size"]). If omitted, returns common properties.', + ), + }, + handler: async (request, response, context) => { + const page = context.getSelectedPage(); + const client = await getCdpSession(page); + + await ensureDomEnabled(client); + await ensureCssEnabled(client); + + const axNode = context.getAXNodeByUid(request.params.uid); + if (!axNode?.backendNodeId) { + throw new Error( + `Could not find element with UID "${request.params.uid}". Make sure to call take_snapshot first.`, + ); + } + + const nodeId = await resolveToNodeId(client, axNode.backendNodeId); + + const result: Record = {}; + + // Get computed styles + if (request.params.includeComputed) { + const {computedStyle} = await client.send('CSS.getComputedStyleForNode', { + nodeId, + }); + + const defaultProperties = [ + 'display', + 'position', + 'width', + 'height', + 'margin', + 'margin-top', + 'margin-right', + 'margin-bottom', + 'margin-left', + 'padding', + 'padding-top', + 'padding-right', + 'padding-bottom', + 'padding-left', + 'border', + 'border-width', + 'border-style', + 'border-color', + 'border-radius', + 'color', + 'background-color', + 'background', + 'font-family', + 'font-size', + 'font-weight', + 'line-height', + 'text-align', + 'flex-direction', + 'justify-content', + 'align-items', + 'gap', + 'grid-template-columns', + 'grid-template-rows', + 'opacity', + 'visibility', + 'overflow', + 'z-index', + 'box-shadow', + 'transform', + 'transition', + ]; + + const filterProps = request.params.properties || defaultProperties; + + const computed: Record = {}; + for (const prop of computedStyle) { + if (filterProps.includes(prop.name)) { + computed[prop.name] = prop.value; + } + } + result.computed = computed; + } + + // Get matched styles + const matched = await client.send('CSS.getMatchedStylesForNode', {nodeId}); + + // Process inline styles + if (matched.inlineStyle?.cssProperties) { + const inline = cssPropertiesToObject( + matched.inlineStyle.cssProperties, + request.params.properties, + ); + if (Object.keys(inline).length > 0) { + result.inline = inline; + } + } + + // Process matched CSS rules + if (matched.matchedCSSRules) { + const matchedRules = matched.matchedCSSRules + .map(match => { + const selector = match.rule.selectorList?.selectors + ?.map(s => s.text) + .join(', '); + const properties = cssPropertiesToObject( + match.rule.style?.cssProperties || [], + request.params.properties, + ); + + if (Object.keys(properties).length === 0) return null; + + return { + selector, + origin: match.rule.origin, + properties, + }; + }) + .filter(Boolean); + + if (matchedRules.length > 0) { + result.matchedRules = matchedRules; + } + } + + // Process inherited styles + if (request.params.includeInherited && matched.inherited) { + const inherited = matched.inherited + .map((inh, index) => { + const rules = inh.matchedCSSRules + ?.map(match => { + const selector = match.rule.selectorList?.selectors + ?.map(s => s.text) + .join(', '); + const properties = cssPropertiesToObject( + match.rule.style?.cssProperties || [], + request.params.properties, + ); + + if (Object.keys(properties).length === 0) return null; + + return {selector, properties}; + }) + .filter(Boolean); + + if (!rules || rules.length === 0) return null; + + return { + ancestorLevel: index + 1, + matchedRules: rules, + }; + }) + .filter(Boolean); + + if (inherited.length > 0) { + result.inherited = inherited; + } + } + + response.appendResponseLine(JSON.stringify(result, null, 2)); + }, +}); + +// ============================================================================ +// Tool 3: get_element_box_model +// ============================================================================ + +export const getElementBoxModel = defineTool({ + name: 'get_element_box_model', + description: `Get the box model (layout) information for an element. +Returns content, padding, border, and margin dimensions. +This is equivalent to the box model diagram shown in Chrome DevTools.`, + annotations: { + title: 'Get Element Box Model', + category: ToolCategory.INSPECTION, + readOnlyHint: true, + }, + schema: { + uid: zod.string().describe('Element UID from snapshot (e.g., "42_5")'), + }, + handler: async (request, response, context) => { + const page = context.getSelectedPage(); + const client = await getCdpSession(page); + + await ensureDomEnabled(client); + + const axNode = context.getAXNodeByUid(request.params.uid); + if (!axNode?.backendNodeId) { + throw new Error( + `Could not find element with UID "${request.params.uid}". Make sure to call take_snapshot first.`, + ); + } + + const nodeId = await resolveToNodeId(client, axNode.backendNodeId); + + const {model} = await client.send('DOM.getBoxModel', {nodeId}); + + // Also get content quads for more precise positioning + let quads = null; + try { + const result = await client.send('DOM.getContentQuads', {nodeId}); + quads = result.quads; + } catch { + // Quads may not be available + } + + const result = { + uid: request.params.uid, + content: formatQuad(model.content), + padding: formatQuad(model.padding), + border: formatQuad(model.border), + margin: formatQuad(model.margin), + width: model.width, + height: model.height, + quads, + }; + + response.appendResponseLine(JSON.stringify(result, null, 2)); + }, +}); + +// ============================================================================ +// Tool 4: query_selector +// ============================================================================ + +export const querySelector = defineTool({ + name: 'query_selector', + description: `Find elements using CSS selectors. +Returns element information for matching elements. +Use this to find elements by class, id, tag, or complex CSS selectors.`, + annotations: { + title: 'Query Selector', + category: ToolCategory.INSPECTION, + readOnlyHint: true, + }, + schema: { + selector: zod + .string() + .describe( + 'CSS selector (e.g., ".btn-primary", "#header", "div.container > p")', + ), + all: zod + .boolean() + .optional() + .default(false) + .describe('Return all matching elements vs just the first match'), + limit: zod + .number() + .int() + .min(1) + .max(50) + .optional() + .default(20) + .describe('Maximum number of elements to return when all=true'), + }, + handler: async (request, response, context) => { + const page = context.getSelectedPage(); + const client = await getCdpSession(page); + + await ensureDomEnabled(client); + + const {root} = await client.send('DOM.getDocument', {depth: 0}); + + let nodeIds: number[] = []; + + if (request.params.all) { + const result = await client.send('DOM.querySelectorAll', { + nodeId: root.nodeId, + selector: request.params.selector, + }); + nodeIds = result.nodeIds.slice(0, request.params.limit); + } else { + const result = await client.send('DOM.querySelector', { + nodeId: root.nodeId, + selector: request.params.selector, + }); + if (result.nodeId) { + nodeIds = [result.nodeId]; + } + } + + if (nodeIds.length === 0) { + response.appendResponseLine( + JSON.stringify({ + found: 0, + message: `No elements found matching selector: ${request.params.selector}`, + }), + ); + return; + } + + const elements = []; + for (const nodeId of nodeIds) { + try { + const {node} = await client.send('DOM.describeNode', { + nodeId, + depth: 0, + }); + + const {attributes} = await client.send('DOM.getAttributes', {nodeId}); + const attrMap: Record = {}; + for (let i = 0; i < attributes.length; i += 2) { + attrMap[attributes[i]] = attributes[i + 1]; + } + + elements.push({ + nodeId, + backendNodeId: node.backendNodeId, + tagName: node.nodeName.toLowerCase(), + id: attrMap['id'] || null, + className: attrMap['class'] || null, + }); + } catch { + // Skip nodes that can't be described + } + } + + response.appendResponseLine( + JSON.stringify( + { + found: elements.length, + selector: request.params.selector, + elements, + }, + null, + 2, + ), + ); + }, +}); + +// ============================================================================ +// Tool 5: highlight_element +// ============================================================================ + +export const highlightElement = defineTool({ + name: 'highlight_element', + description: `Visually highlight an element on the page with a colored overlay. +Useful for verifying you've identified the correct element. +The highlight shows content (blue), padding (green), border (yellow), and margin (orange).`, + annotations: { + title: 'Highlight Element', + category: ToolCategory.INSPECTION, + readOnlyHint: true, + }, + schema: { + uid: zod.string().describe('Element UID from snapshot (e.g., "42_5")'), + duration: zod + .number() + .int() + .min(0) + .max(30000) + .optional() + .default(3000) + .describe( + 'How long to show highlight in milliseconds (0 = until hide_highlight is called)', + ), + }, + handler: async (request, response, context) => { + const page = context.getSelectedPage(); + const client = await getCdpSession(page); + + await ensureDomEnabled(client); + await ensureOverlayEnabled(client); + + const axNode = context.getAXNodeByUid(request.params.uid); + if (!axNode?.backendNodeId) { + throw new Error( + `Could not find element with UID "${request.params.uid}". Make sure to call take_snapshot first.`, + ); + } + + await client.send('Overlay.highlightNode', { + highlightConfig: { + contentColor: {r: 111, g: 168, b: 220, a: 0.66}, + paddingColor: {r: 147, g: 196, b: 125, a: 0.55}, + borderColor: {r: 255, g: 229, b: 153, a: 0.66}, + marginColor: {r: 246, g: 178, b: 107, a: 0.66}, + showInfo: true, + showStyles: true, + showRulers: false, + showAccessibilityInfo: true, + }, + backendNodeId: axNode.backendNodeId, + }); + + if (request.params.duration > 0) { + setTimeout(async () => { + try { + await client.send('Overlay.hideHighlight'); + } catch { + // Ignore errors when hiding + } + }, request.params.duration); + } + + response.appendResponseLine( + `Element highlighted for ${request.params.duration}ms. Use hide_highlight to remove early.`, + ); + }, +}); + +// ============================================================================ +// Tool 6: hide_highlight +// ============================================================================ + +export const hideHighlight = defineTool({ + name: 'hide_highlight', + description: `Hide any active element highlight on the page.`, + annotations: { + title: 'Hide Highlight', + category: ToolCategory.INSPECTION, + readOnlyHint: true, + }, + schema: {}, + handler: async (_request, response, context) => { + const page = context.getSelectedPage(); + const client = await getCdpSession(page); + + try { + await client.send('Overlay.hideHighlight'); + response.appendResponseLine('Highlight hidden.'); + } catch { + response.appendResponseLine('No highlight was active.'); + } + }, +}); + +// ============================================================================ +// Tool 7: get_dom_tree +// ============================================================================ + +export const getDomTree = defineTool({ + name: 'get_dom_tree', + description: `Get the DOM tree structure starting from a specific element or document root. +Returns a hierarchical view of elements with their tag names and key attributes. +Useful for understanding the structure of a component or page section.`, + annotations: { + title: 'Get DOM Tree', + category: ToolCategory.INSPECTION, + readOnlyHint: true, + }, + schema: { + uid: zod + .string() + .optional() + .describe( + 'Element UID to start from (omit for document root)', + ), + depth: zod + .number() + .int() + .min(1) + .max(10) + .optional() + .default(3) + .describe('How deep to traverse the tree'), + }, + handler: async (request, response, context) => { + const page = context.getSelectedPage(); + const client = await getCdpSession(page); + + await ensureDomEnabled(client); + + let rootNodeId: number; + + if (request.params.uid) { + const axNode = context.getAXNodeByUid(request.params.uid); + if (!axNode?.backendNodeId) { + throw new Error( + `Could not find element with UID "${request.params.uid}". Make sure to call take_snapshot first.`, + ); + } + rootNodeId = await resolveToNodeId(client, axNode.backendNodeId); + } else { + const {root} = await client.send('DOM.getDocument', { + depth: request.params.depth, + pierce: true, + }); + rootNodeId = root.nodeId; + } + + // Request child nodes to ensure they're loaded + await client.send('DOM.requestChildNodes', { + nodeId: rootNodeId, + depth: request.params.depth, + pierce: true, + }); + + // Give a moment for nodes to be pushed + await new Promise(resolve => setTimeout(resolve, 100)); + + // Get the node with children + const {node} = await client.send('DOM.describeNode', { + nodeId: rootNodeId, + depth: request.params.depth, + pierce: true, + }); + + // Format tree recursively + interface TreeNode { + tag: string; + id?: string; + class?: string; + children?: TreeNode[]; + } + + function formatNode(n: typeof node, currentDepth: number): TreeNode | null { + if (!n || currentDepth > request.params.depth) return null; + + // Skip text nodes and comments for cleaner output + if (n.nodeType === 3 || n.nodeType === 8) return null; + + const attrs = n.attributes || []; + const attrMap: Record = {}; + for (let i = 0; i < attrs.length; i += 2) { + attrMap[attrs[i]] = attrs[i + 1]; + } + + const treeNode: TreeNode = { + tag: n.nodeName.toLowerCase(), + }; + + if (attrMap['id']) treeNode.id = attrMap['id']; + if (attrMap['class']) treeNode.class = attrMap['class']; + + if (n.children && currentDepth < request.params.depth) { + const children = n.children + .map(child => formatNode(child, currentDepth + 1)) + .filter((c): c is TreeNode => c !== null); + + if (children.length > 0) { + treeNode.children = children; + } + } + + return treeNode; + } + + const tree = formatNode(node, 0); + response.appendResponseLine(JSON.stringify(tree, null, 2)); + }, +}); + +// ============================================================================ +// Tool 8: capture_dom_snapshot +// ============================================================================ + +export const captureDomSnapshot = defineTool({ + name: 'capture_dom_snapshot', + description: `Capture a complete DOM snapshot with computed styles for all elements. +This is an efficient way to get DOM structure and styles in a single call. +Useful for analyzing entire page layouts or large sections.`, + annotations: { + title: 'Capture DOM Snapshot', + category: ToolCategory.INSPECTION, + readOnlyHint: true, + }, + schema: { + computedStyles: zod + .array(zod.string()) + .optional() + .describe( + 'CSS properties to capture (default: display, color, background-color, font-size, etc.)', + ), + }, + handler: async (request, response, context) => { + const page = context.getSelectedPage(); + const client = await getCdpSession(page); + + await client.send('DOMSnapshot.enable'); + + const defaultStyles = [ + 'display', + 'visibility', + 'opacity', + 'color', + 'background-color', + 'font-family', + 'font-size', + 'font-weight', + 'width', + 'height', + 'position', + 'top', + 'right', + 'bottom', + 'left', + 'margin-top', + 'margin-right', + 'margin-bottom', + 'margin-left', + 'padding-top', + 'padding-right', + 'padding-bottom', + 'padding-left', + 'border-width', + 'flex-direction', + 'justify-content', + 'align-items', + ]; + + const snapshot = await client.send('DOMSnapshot.captureSnapshot', { + computedStyles: request.params.computedStyles || defaultStyles, + includeDOMRects: true, + includeBlendedBackgroundColors: true, + }); + + // Format the snapshot for readability + const documents = []; + const strings = snapshot.strings; + + for (let docIndex = 0; docIndex < snapshot.documents.length; docIndex++) { + const doc = snapshot.documents[docIndex]; + const nodes: Array<{ + index: number; + name: string; + attributes: Record; + bounds?: {x: number; y: number; width: number; height: number}; + }> = []; + + const nodeNames = doc.nodes?.nodeName; + const nodeAttrs = doc.nodes?.attributes; + const bounds = doc.layout?.bounds || []; + + if (!nodeNames) continue; + + for (let i = 0; i < nodeNames.length && i < 100; i++) { + const nameIdx = nodeNames[i]; + const name = strings[nameIdx]?.toLowerCase() || ''; + + // Skip text nodes and non-element nodes + if (!name || name === '#text' || name === '#comment') continue; + + const attrs: Record = {}; + const attrPairs = nodeAttrs?.[i] || []; + for (let j = 0; j < attrPairs.length; j += 2) { + const keyIdx = attrPairs[j]; + const valIdx = attrPairs[j + 1]; + if (strings[keyIdx] && strings[valIdx]) { + attrs[strings[keyIdx]] = strings[valIdx]; + } + } + + const nodeBounds = bounds[i]; + nodes.push({ + index: i, + name, + attributes: attrs, + bounds: nodeBounds + ? { + x: nodeBounds[0], + y: nodeBounds[1], + width: nodeBounds[2], + height: nodeBounds[3], + } + : undefined, + }); + } + + documents.push({ + documentIndex: docIndex, + url: strings[doc.documentURL] || '', + nodeCount: nodeNames.length, + nodes: nodes.slice(0, 50), // Limit output + }); + } + + response.appendResponseLine( + JSON.stringify( + { + documentCount: documents.length, + documents, + note: + documents[0]?.nodeCount > 50 + ? 'Output limited to first 50 elements per document' + : undefined, + }, + null, + 2, + ), + ); + }, +}); + +// ============================================================================ +// Tool 9: force_element_state +// ============================================================================ + +export const forceElementState = defineTool({ + name: 'force_element_state', + description: `Force an element into specific CSS pseudo-states for inspection. +Use this to inspect :hover, :active, :focus styles without actually interacting with the element. +Multiple states can be forced simultaneously.`, + annotations: { + title: 'Force Element State', + category: ToolCategory.INSPECTION, + readOnlyHint: false, + }, + schema: { + uid: zod.string().describe('Element UID from snapshot (e.g., "42_5")'), + states: zod + .array( + zod.enum([ + 'active', + 'focus', + 'hover', + 'visited', + 'focus-within', + 'focus-visible', + ]), + ) + .describe('Pseudo-states to force (e.g., ["hover", "focus"])'), + }, + handler: async (request, response, context) => { + const page = context.getSelectedPage(); + const client = await getCdpSession(page); + + await ensureDomEnabled(client); + await ensureCssEnabled(client); + + const axNode = context.getAXNodeByUid(request.params.uid); + if (!axNode?.backendNodeId) { + throw new Error( + `Could not find element with UID "${request.params.uid}". Make sure to call take_snapshot first.`, + ); + } + + const nodeId = await resolveToNodeId(client, axNode.backendNodeId); + + await client.send('CSS.forcePseudoState', { + nodeId, + forcedPseudoClasses: request.params.states, + }); + + response.appendResponseLine( + `Forced states [${request.params.states.join(', ')}] on element. Use get_element_styles to see the styles in this state.`, + ); + }, +}); + +// ============================================================================ +// Tool 10: get_element_event_listeners +// ============================================================================ + +export const getElementEventListeners = defineTool({ + name: 'get_element_event_listeners', + description: `Get all event listeners attached to an element. +Returns the event type, handler function preview, and listener options. +Useful for understanding element interactivity.`, + annotations: { + title: 'Get Element Event Listeners', + category: ToolCategory.INSPECTION, + readOnlyHint: true, + }, + schema: { + uid: zod.string().describe('Element UID from snapshot (e.g., "42_5")'), + }, + handler: async (request, response, context) => { + const page = context.getSelectedPage(); + const client = await getCdpSession(page); + + await ensureDomEnabled(client); + + const axNode = context.getAXNodeByUid(request.params.uid); + if (!axNode?.backendNodeId) { + throw new Error( + `Could not find element with UID "${request.params.uid}". Make sure to call take_snapshot first.`, + ); + } + + const nodeId = await resolveToNodeId(client, axNode.backendNodeId); + + // Resolve node to JS object + const {object} = await client.send('DOM.resolveNode', {nodeId}); + + // Get event listeners + const {listeners} = await client.send('DOMDebugger.getEventListeners', { + objectId: object.objectId!, + depth: 1, + pierce: true, + }); + + // Release the object + if (object.objectId) { + await client.send('Runtime.releaseObject', {objectId: object.objectId}); + } + + const formattedListeners = listeners.map(listener => ({ + type: listener.type, + useCapture: listener.useCapture, + passive: listener.passive, + once: listener.once, + handler: listener.handler?.description?.substring(0, 200) || 'unknown', + scriptId: listener.scriptId, + lineNumber: listener.lineNumber, + columnNumber: listener.columnNumber, + })); + + response.appendResponseLine( + JSON.stringify( + { + uid: request.params.uid, + listenerCount: formattedListeners.length, + listeners: formattedListeners, + }, + null, + 2, + ), + ); + }, +}); + +// ============================================================================ +// Tool 11: get_element_at_position +// ============================================================================ + +export const getElementAtPosition = defineTool({ + name: 'get_element_at_position', + description: `Get the element at specific x,y coordinates on the page. +Returns basic information about the topmost element at that position. +Useful for identifying elements at specific visual locations.`, + annotations: { + title: 'Get Element At Position', + category: ToolCategory.INSPECTION, + readOnlyHint: true, + }, + schema: { + x: zod.number().int().describe('X coordinate on the page'), + y: zod.number().int().describe('Y coordinate on the page'), + }, + handler: async (request, response, context) => { + const page = context.getSelectedPage(); + const client = await getCdpSession(page); + + await ensureDomEnabled(client); + + const {backendNodeId, nodeId, frameId} = await client.send( + 'DOM.getNodeForLocation', + { + x: request.params.x, + y: request.params.y, + includeUserAgentShadowDOM: false, + ignorePointerEventsNone: true, + }, + ); + + if (!nodeId && !backendNodeId) { + response.appendResponseLine( + JSON.stringify({ + found: false, + message: `No element found at position (${request.params.x}, ${request.params.y})`, + }), + ); + return; + } + + const resolvedNodeId = + nodeId || (await resolveToNodeId(client, backendNodeId!)); + const {node} = await client.send('DOM.describeNode', { + nodeId: resolvedNodeId, + depth: 0, + }); + + const {attributes} = await client.send('DOM.getAttributes', { + nodeId: resolvedNodeId, + }); + const attrMap: Record = {}; + for (let i = 0; i < attributes.length; i += 2) { + attrMap[attributes[i]] = attributes[i + 1]; + } + + response.appendResponseLine( + JSON.stringify( + { + found: true, + position: {x: request.params.x, y: request.params.y}, + element: { + backendNodeId, + nodeId: resolvedNodeId, + tagName: node.nodeName.toLowerCase(), + id: attrMap['id'] || null, + className: attrMap['class'] || null, + frameId, + }, + }, + null, + 2, + ), + ); + }, +}); + +// ============================================================================ +// Tool 12: search_dom +// ============================================================================ + +export const searchDom = defineTool({ + name: 'search_dom', + description: `Search the DOM for elements matching a text query, CSS selector, or XPath. +Returns matching elements with their basic information. +Supports plain text search, CSS selectors, and XPath expressions.`, + annotations: { + title: 'Search DOM', + category: ToolCategory.INSPECTION, + readOnlyHint: true, + }, + schema: { + query: zod + .string() + .describe( + 'Search query - text content, CSS selector, or XPath expression', + ), + limit: zod + .number() + .int() + .min(1) + .max(50) + .optional() + .default(20) + .describe('Maximum results to return'), + }, + handler: async (request, response, context) => { + const page = context.getSelectedPage(); + const client = await getCdpSession(page); + + await ensureDomEnabled(client); + + // Perform search + const {searchId, resultCount} = await client.send('DOM.performSearch', { + query: request.params.query, + includeUserAgentShadowDOM: false, + }); + + if (resultCount === 0) { + await client.send('DOM.discardSearchResults', {searchId}); + response.appendResponseLine( + JSON.stringify({ + found: 0, + message: `No elements found matching: ${request.params.query}`, + }), + ); + return; + } + + // Get results + const {nodeIds} = await client.send('DOM.getSearchResults', { + searchId, + fromIndex: 0, + toIndex: Math.min(resultCount, request.params.limit), + }); + + // Cleanup search + await client.send('DOM.discardSearchResults', {searchId}); + + const elements = []; + for (const nodeId of nodeIds) { + try { + const {node} = await client.send('DOM.describeNode', { + nodeId, + depth: 0, + }); + + let attrs: string[] = []; + try { + const result = await client.send('DOM.getAttributes', {nodeId}); + attrs = result.attributes; + } catch { + // Some nodes don't have attributes + } + + const attrMap: Record = {}; + for (let i = 0; i < attrs.length; i += 2) { + attrMap[attrs[i]] = attrs[i + 1]; + } + + elements.push({ + nodeId, + backendNodeId: node.backendNodeId, + tagName: node.nodeName.toLowerCase(), + id: attrMap['id'] || null, + className: attrMap['class'] || null, + }); + } catch { + // Skip problematic nodes + } + } + + response.appendResponseLine( + JSON.stringify( + { + query: request.params.query, + found: resultCount, + returned: elements.length, + elements, + }, + null, + 2, + ), + ); + }, +}); + +// ============================================================================ +// Tool 13: get_fonts_info +// ============================================================================ + +export const getFontsInfo = defineTool({ + name: 'get_fonts_info', + description: `Get information about fonts used to render text in an element. +Shows which fonts are actually being used (may differ from CSS font-family). +Useful for understanding typography and font fallbacks.`, + annotations: { + title: 'Get Fonts Info', + category: ToolCategory.INSPECTION, + readOnlyHint: true, + }, + schema: { + uid: zod.string().describe('Element UID from snapshot (e.g., "42_5")'), + }, + handler: async (request, response, context) => { + const page = context.getSelectedPage(); + const client = await getCdpSession(page); + + await ensureDomEnabled(client); + await ensureCssEnabled(client); + + const axNode = context.getAXNodeByUid(request.params.uid); + if (!axNode?.backendNodeId) { + throw new Error( + `Could not find element with UID "${request.params.uid}". Make sure to call take_snapshot first.`, + ); + } + + const nodeId = await resolveToNodeId(client, axNode.backendNodeId); + + const {fonts} = await client.send('CSS.getPlatformFontsForNode', {nodeId}); + + // Also get the CSS font-family for comparison + const {computedStyle} = await client.send('CSS.getComputedStyleForNode', { + nodeId, + }); + const fontFamily = computedStyle.find( + p => p.name === 'font-family', + )?.value; + const fontSize = computedStyle.find(p => p.name === 'font-size')?.value; + const fontWeight = computedStyle.find(p => p.name === 'font-weight')?.value; + + response.appendResponseLine( + JSON.stringify( + { + uid: request.params.uid, + cssStyles: { + fontFamily, + fontSize, + fontWeight, + }, + platformFonts: fonts.map(f => ({ + familyName: f.familyName, + postScriptName: f.postScriptName, + glyphCount: f.glyphCount, + isCustomFont: f.isCustomFont, + })), + }, + null, + 2, + ), + ); + }, +}); + +// ============================================================================ +// Tool 14: show_layout_overlay +// ============================================================================ + +export const showLayoutOverlay = defineTool({ + name: 'show_layout_overlay', + description: `Show CSS Grid or Flexbox layout overlay for an element. +Visualizes grid lines, flex containers, gaps, and alignment. +Helps understand and debug complex layouts.`, + annotations: { + title: 'Show Layout Overlay', + category: ToolCategory.INSPECTION, + readOnlyHint: true, + }, + schema: { + uid: zod.string().describe('Element UID from snapshot (e.g., "42_5")'), + type: zod + .enum(['grid', 'flex']) + .describe('Type of layout overlay to show'), + }, + handler: async (request, response, context) => { + const page = context.getSelectedPage(); + const client = await getCdpSession(page); + + await ensureDomEnabled(client); + await ensureOverlayEnabled(client); + + const axNode = context.getAXNodeByUid(request.params.uid); + if (!axNode?.backendNodeId) { + throw new Error( + `Could not find element with UID "${request.params.uid}". Make sure to call take_snapshot first.`, + ); + } + + const nodeId = await resolveToNodeId(client, axNode.backendNodeId); + + if (request.params.type === 'grid') { + await client.send('Overlay.setShowGridOverlays', { + gridNodeHighlightConfigs: [ + { + nodeId, + gridHighlightConfig: { + showGridExtensionLines: true, + showPositiveLineNumbers: true, + showNegativeLineNumbers: false, + showAreaNames: true, + showLineNames: true, + gridBorderColor: {r: 255, g: 0, b: 255, a: 0.8}, + cellBorderColor: {r: 128, g: 128, b: 128, a: 0.4}, + rowLineColor: {r: 127, g: 32, b: 210, a: 0.8}, + columnLineColor: {r: 127, g: 32, b: 210, a: 0.8}, + gridBackgroundColor: {r: 255, g: 0, b: 255, a: 0.1}, + rowGapColor: {r: 0, g: 255, b: 0, a: 0.2}, + columnGapColor: {r: 0, g: 0, b: 255, a: 0.2}, + }, + }, + ], + }); + response.appendResponseLine( + 'Grid overlay shown. Call hide_highlight or show_layout_overlay with empty configs to hide.', + ); + } else { + await client.send('Overlay.setShowFlexOverlays', { + flexNodeHighlightConfigs: [ + { + nodeId, + flexContainerHighlightConfig: { + containerBorder: { + color: {r: 255, g: 165, b: 0, a: 0.8}, + }, + itemSeparator: { + color: {r: 255, g: 165, b: 0, a: 0.4}, + pattern: 'dotted', + }, + lineSeparator: { + color: {r: 255, g: 165, b: 0, a: 0.4}, + pattern: 'dashed', + }, + mainDistributedSpace: { + fillColor: {r: 255, g: 165, b: 0, a: 0.2}, + hatchColor: {r: 255, g: 165, b: 0, a: 0.4}, + }, + crossDistributedSpace: { + fillColor: {r: 0, g: 165, b: 255, a: 0.2}, + hatchColor: {r: 0, g: 165, b: 255, a: 0.4}, + }, + }, + }, + ], + }); + response.appendResponseLine( + 'Flexbox overlay shown. Call hide_highlight to hide.', + ); + } + }, +}); + +// ============================================================================ +// Tool 15: get_accessibility_info +// ============================================================================ + +export const getAccessibilityInfo = defineTool({ + name: 'get_accessibility_info', + description: `Get detailed accessibility information for an element. +Returns ARIA roles, states, properties, and the accessibility tree node. +Useful for ensuring proper accessibility implementation.`, + annotations: { + title: 'Get Accessibility Info', + category: ToolCategory.INSPECTION, + readOnlyHint: true, + }, + schema: { + uid: zod.string().describe('Element UID from snapshot (e.g., "42_5")'), + includeAncestors: zod + .boolean() + .optional() + .default(false) + .describe('Include accessibility info for ancestor elements'), + }, + handler: async (request, response, context) => { + const page = context.getSelectedPage(); + const client = await getCdpSession(page); + + await ensureDomEnabled(client); + await client.send('Accessibility.enable'); + + const axNode = context.getAXNodeByUid(request.params.uid); + if (!axNode?.backendNodeId) { + throw new Error( + `Could not find element with UID "${request.params.uid}". Make sure to call take_snapshot first.`, + ); + } + + let nodes; + if (request.params.includeAncestors) { + const result = await client.send('Accessibility.getAXNodeAndAncestors', { + backendNodeId: axNode.backendNodeId, + }); + nodes = result.nodes; + } else { + const result = await client.send('Accessibility.getPartialAXTree', { + backendNodeId: axNode.backendNodeId, + fetchRelatives: false, + }); + nodes = result.nodes; + } + + const formatNode = (node: (typeof nodes)[0]) => ({ + nodeId: node.nodeId, + role: node.role?.value, + name: node.name?.value, + description: node.description?.value, + value: node.value?.value, + properties: node.properties?.map(p => ({ + name: p.name, + value: p.value?.value, + })), + childIds: node.childIds, + ignored: node.ignored, + ignoredReasons: node.ignoredReasons?.map(r => ({ + name: r.name, + value: r.value?.value, + })), + }); + + response.appendResponseLine( + JSON.stringify( + { + uid: request.params.uid, + nodes: nodes.map(formatNode), + }, + null, + 2, + ), + ); + }, +}); + +// ============================================================================ +// Tool 16: compare_elements +// ============================================================================ + +export const compareElements = defineTool({ + name: 'compare_elements', + description: `Compare two elements' styles and attributes to understand their differences. +Returns a diff of computed styles, showing which properties differ between elements. +Useful for debugging inconsistent styling or understanding variations.`, + annotations: { + title: 'Compare Elements', + category: ToolCategory.INSPECTION, + readOnlyHint: true, + }, + schema: { + uid1: zod.string().describe('First element UID'), + uid2: zod.string().describe('Second element UID'), + properties: zod + .array(zod.string()) + .optional() + .describe( + 'CSS properties to compare (default: common layout/visual properties)', + ), + }, + handler: async (request, response, context) => { + const page = context.getSelectedPage(); + const client = await getCdpSession(page); + + await ensureDomEnabled(client); + await ensureCssEnabled(client); + + const axNode1 = context.getAXNodeByUid(request.params.uid1); + const axNode2 = context.getAXNodeByUid(request.params.uid2); + + if (!axNode1?.backendNodeId) { + throw new Error(`Could not find element with UID "${request.params.uid1}"`); + } + if (!axNode2?.backendNodeId) { + throw new Error(`Could not find element with UID "${request.params.uid2}"`); + } + + const nodeId1 = await resolveToNodeId(client, axNode1.backendNodeId); + const nodeId2 = await resolveToNodeId(client, axNode2.backendNodeId); + + const defaultProps = [ + 'display', + 'position', + 'width', + 'height', + 'margin', + 'padding', + 'border', + 'color', + 'background-color', + 'font-family', + 'font-size', + 'font-weight', + 'line-height', + 'text-align', + 'flex-direction', + 'justify-content', + 'align-items', + 'gap', + 'border-radius', + 'box-shadow', + 'opacity', + ]; + const propsToCompare = request.params.properties || defaultProps; + + // Get computed styles for both + const [styles1, styles2] = await Promise.all([ + client.send('CSS.getComputedStyleForNode', {nodeId: nodeId1}), + client.send('CSS.getComputedStyleForNode', {nodeId: nodeId2}), + ]); + + const styleMap1: Record = {}; + const styleMap2: Record = {}; + + for (const prop of styles1.computedStyle) { + if (propsToCompare.includes(prop.name)) { + styleMap1[prop.name] = prop.value; + } + } + + for (const prop of styles2.computedStyle) { + if (propsToCompare.includes(prop.name)) { + styleMap2[prop.name] = prop.value; + } + } + + const differences: Record = {}; + const same: string[] = []; + + for (const prop of propsToCompare) { + const val1 = styleMap1[prop] || ''; + const val2 = styleMap2[prop] || ''; + + if (val1 !== val2) { + differences[prop] = {element1: val1, element2: val2}; + } else if (val1) { + same.push(prop); + } + } + + response.appendResponseLine( + JSON.stringify( + { + element1: request.params.uid1, + element2: request.params.uid2, + differenceCount: Object.keys(differences).length, + differences, + sameProperties: same, + }, + null, + 2, + ), + ); + }, +}); + +// ============================================================================ +// Tool 17: get_css_variables +// ============================================================================ + +export const getCssVariables = defineTool({ + name: 'get_css_variables', + description: `Get CSS custom properties (variables) that apply to an element. +Returns both the variables defined on the element and inherited variables. +Useful for understanding design systems and theming.`, + annotations: { + title: 'Get CSS Variables', + category: ToolCategory.INSPECTION, + readOnlyHint: true, + }, + schema: { + uid: zod.string().describe('Element UID from snapshot (e.g., "42_5")'), + }, + handler: async (request, response, context) => { + const page = context.getSelectedPage(); + const client = await getCdpSession(page); + + await ensureDomEnabled(client); + await ensureCssEnabled(client); + + const axNode = context.getAXNodeByUid(request.params.uid); + if (!axNode?.backendNodeId) { + throw new Error( + `Could not find element with UID "${request.params.uid}". Make sure to call take_snapshot first.`, + ); + } + + const nodeId = await resolveToNodeId(client, axNode.backendNodeId); + + // Get all computed styles and filter for CSS variables + const {computedStyle} = await client.send('CSS.getComputedStyleForNode', { + nodeId, + }); + + const cssVariables: Record = {}; + for (const prop of computedStyle) { + if (prop.name.startsWith('--')) { + cssVariables[prop.name] = prop.value; + } + } + + // Get matched rules to find where variables are defined + const matched = await client.send('CSS.getMatchedStylesForNode', {nodeId}); + + const variableDefinitions: Array<{ + variable: string; + value: string; + selector: string; + }> = []; + + if (matched.matchedCSSRules) { + for (const match of matched.matchedCSSRules) { + const selector = match.rule.selectorList?.selectors + ?.map(s => s.text) + .join(', '); + for (const prop of match.rule.style?.cssProperties || []) { + if (prop.name.startsWith('--') && !prop.disabled) { + variableDefinitions.push({ + variable: prop.name, + value: prop.value, + selector: selector || 'unknown', + }); + } + } + } + } + + response.appendResponseLine( + JSON.stringify( + { + uid: request.params.uid, + variableCount: Object.keys(cssVariables).length, + computedVariables: cssVariables, + definitions: variableDefinitions, + }, + null, + 2, + ), + ); + }, +}); diff --git a/src/utils/cdpHelper.ts b/src/utils/cdpHelper.ts new file mode 100644 index 00000000..19125735 --- /dev/null +++ b/src/utils/cdpHelper.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {Page, CDPSession} from '../third_party/index.js'; + +/** + * Helper to get CDP session from a page. + */ +export async function getCdpSession(page: Page): Promise { + // @ts-expect-error _client is internal puppeteer API + return page._client(); +} + +/** + * Convert backendNodeId to nodeId by pushing to frontend. + */ +export async function resolveToNodeId( + client: CDPSession, + backendNodeId: number, +): Promise { + const {nodeIds} = await client.send('DOM.pushNodesByBackendIdsToFrontend', { + backendNodeIds: [backendNodeId], + }); + + if (!nodeIds || nodeIds.length === 0 || nodeIds[0] === 0) { + throw new Error('Could not resolve backendNodeId to nodeId'); + } + + return nodeIds[0]; +} + +/** + * Ensure DOM domain is enabled. + */ +export async function ensureDomEnabled(client: CDPSession): Promise { + await client.send('DOM.enable'); + await client.send('DOM.getDocument', {depth: 0}); +} + +/** + * Ensure CSS domain is enabled. + */ +export async function ensureCssEnabled(client: CDPSession): Promise { + await client.send('CSS.enable'); +} + +/** + * Ensure Overlay domain is enabled. + */ +export async function ensureOverlayEnabled(client: CDPSession): Promise { + await client.send('Overlay.enable'); +} + +/** + * Format box model quad coordinates into readable format. + */ +export function formatQuad(quad: number[]): { + x: number; + y: number; + width: number; + height: number; +} { + // Quad is array of 8 numbers: x1,y1, x2,y2, x3,y3, x4,y4 (corners) + const x = Math.min(quad[0], quad[2], quad[4], quad[6]); + const y = Math.min(quad[1], quad[3], quad[5], quad[7]); + const width = Math.max(quad[0], quad[2], quad[4], quad[6]) - x; + const height = Math.max(quad[1], quad[3], quad[5], quad[7]) - y; + return {x, y, width, height}; +} + +/** + * Parse CSS properties array into object. + */ +export function cssPropertiesToObject( + properties: Array<{name: string; value: string; disabled?: boolean}>, + filterProps?: string[], +): Record { + const result: Record = {}; + for (const prop of properties) { + if (prop.disabled) continue; + if (!prop.value) continue; + if (filterProps && !filterProps.includes(prop.name)) continue; + result[prop.name] = prop.value; + } + return result; +}