diff --git a/src/tools/inspect.ts b/src/tools/inspect.ts index 00ef92136..85aaa72e6 100644 --- a/src/tools/inspect.ts +++ b/src/tools/inspect.ts @@ -14,6 +14,7 @@ import { TOOL_ANNOTATIONS } from '../types/tool-annotations'; import { getSessionManager } from '../session-manager'; import { withTimeout } from '../utils/with-timeout'; import { getAllShadowRoots, querySelectorInShadowRoots } from '../utils/shadow-dom'; +import { appendMetricsFooter, buildTextMetrics } from '../core/metrics/token-estimate'; import { formatNodeRefToken, getCurrentLoaderId, @@ -39,6 +40,10 @@ const definition: MCPToolDefinition = { enum: ['interactive', 'all', 'visible'], description: 'Element scope. Default: visible', }, + include_metrics: { + type: 'boolean', + description: 'When true, append approximate returned size/token metrics to text output. Default: false.', + }, }, required: ['tabId', 'query'], }, @@ -107,6 +112,7 @@ const handler: ToolHandler = async ( const tabId = args.tabId as string; const query = args.query as string; const scope = (args.scope as string) || 'visible'; + const includeMetrics = args.include_metrics === true; const sessionManager = getSessionManager(); @@ -577,9 +583,15 @@ const handler: ToolHandler = async ( // Footer with page context (always included) lines.push(`[Page] ${inspectResult.url} | "${inspectResult.title}"`); + const text = lines.join('\n'); return { - content: [{ type: 'text', text: lines.join('\n') }], + content: [{ + type: 'text', + text: includeMetrics + ? appendMetricsFooter(text, buildTextMetrics(text, { mode: `inspect:${scope}` })) + : text, + }], }; } catch (error) { return { diff --git a/src/tools/read-page.ts b/src/tools/read-page.ts index f4a743c48..a4aadb5a9 100644 --- a/src/tools/read-page.ts +++ b/src/tools/read-page.ts @@ -6,7 +6,7 @@ import { MCPServer } from '../mcp-server'; import { MCPToolDefinition, MCPResult, ToolHandler, ToolContext, throwIfAborted } from '../types/mcp'; import { TOOL_ANNOTATIONS } from '../types/tool-annotations'; import { getSessionManager } from '../session-manager'; -import { getRefIdManager, REF_TTL_MS } from '../utils/ref-id-manager'; +import { getRefIdManager } from '../utils/ref-id-manager'; import { serializeDOM } from '../dom'; import { detectPagination, PaginationInfo } from '../utils/pagination-detector'; import { MAX_OUTPUT_CHARS } from '../config/defaults'; @@ -15,52 +15,6 @@ import { SnapshotStore } from '../compression/snapshot-store'; import { sanitizeContent } from '../security/content-sanitizer'; import { appendMetricsFooter, buildTextMetrics } from '../core/metrics/token-estimate'; import { getGlobalConfig } from '../config/global'; -import { extractMainContent, toMarkdown } from '../core/extract/html-to-markdown'; -import { getCurrentLoaderId, mintNodeRefSync } from '../core/perception/node-ref'; - -/** - * Build the `[node_refs]` block that surfaces the #844 backend-node uid - * contract in `read_page` DOM mode responses. - * - * P2 contract: this section is **always** present in the response shape so - * `tools/list` parity holds regardless of the `OPENCHROME_NODE_REF` env var. - * When the flag is off (or loaderId resolution fails), every uid is rendered - * as the literal `null`, keeping the field present but the runtime value - * inert. - * - * The format is line-oriented JSON-ish, one `=` per - * line, so a trace-replay parser can reconstruct the registry state without - * bringing along a full JSON parser. - */ -async function formatNodeRefsBlock( - page: import('puppeteer-core').Page, - cdpClient: { send: (page: import('puppeteer-core').Page, method: string, params?: Record) => Promise }, - backendNodeIds: number[], -): Promise { - if (backendNodeIds.length === 0) { - return '\n\n[node_refs]\n(empty)\n'; - } - let loaderId: string | null = null; - try { - loaderId = await getCurrentLoaderId(page, cdpClient as any); - } catch { - loaderId = null; - } - const lines: string[] = ['', '', '[node_refs]']; - for (const backendNodeId of backendNodeIds) { - let uid: string | null = null; - if (loaderId) { - try { - uid = mintNodeRefSync(page, loaderId, backendNodeId); - } catch { - uid = null; - } - } - lines.push(`${backendNodeId}=${uid ?? 'null'}`); - } - lines.push(''); - return lines.join('\n'); -} import { buildSemanticView, type SemanticAXNode, @@ -84,7 +38,7 @@ function formatPaginationSection(pagination: PaginationInfo): string { const definition: MCPToolDefinition = { name: 'read_page', - description: 'Get page as DOM, accessibility tree (ax), CSS diagnostics, semantic summary, or clean Markdown (article-shaped).\n\nWhen to use: Reading page structure, verifying content, extracting the full DOM tree, or reducing article-like pages to Markdown.\nWhen NOT to use: Use inspect for targeted state queries or find to locate a specific element.', + description: 'Get page as DOM, accessibility tree (ax), or CSS diagnostics.\n\nWhen to use: Reading page structure, verifying content, or extracting the full DOM tree.\nWhen NOT to use: Use inspect for targeted state queries or find to locate a specific element.', inputSchema: { type: 'object', properties: { @@ -111,16 +65,8 @@ const definition: MCPToolDefinition = { }, mode: { type: 'string', - enum: ['ax', 'dom', 'css', 'semantic', 'markdown'], - description: 'Output mode: dom (default), ax, css, semantic, or markdown (clean article extraction).', - }, - onlyMainContent: { - type: 'boolean', - description: 'Markdown mode only: strip nav/header/footer/aside/ads. Default: true.', - }, - includeLinks: { - type: 'boolean', - description: 'Markdown mode only: preserve as markdown links. Default: true.', + enum: ['ax', 'dom', 'css', 'semantic'], + description: 'Output mode: dom (default), ax, css, or semantic', }, includePagination: { type: 'boolean', @@ -131,11 +77,6 @@ const definition: MCPToolDefinition = { enum: ['none', 'delta'], description: 'Compression mode. "delta" returns only changes since last read.', }, - planningProfile: { - type: 'string', - enum: ['default', 'stable'], - description: 'DOM mode only: stable omits decorative/noisy serialization details without mutating the live page. Default: default.', - }, fallback: { type: 'string', enum: ['none', 'dom'], @@ -145,65 +86,12 @@ const definition: MCPToolDefinition = { type: 'boolean', description: 'When true, append approximate returned size/token metrics to text output. Default: false.', }, - compact: { - type: 'boolean', - description: 'AX mode only: return a compact AX snapshot that keeps actionable/ref-bearing nodes, value/state nodes, and ancestors. Default: false.', - }, - diagnostics: { - type: 'boolean', - description: 'Include structured read_page timing diagnostics in the MCP result metadata. Default: false.', - }, }, required: ['tabId'], }, annotations: TOOL_ANNOTATIONS.read_page, }; - -function compactAXLines(lines: string[]): string[] { - const keep = new Set(); - const stack: Array<{ indent: number; index: number }> = []; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const indent = line.match(/^ */)?.[0].length ?? 0; - while (stack.length > 0 && stack[stack.length - 1].indent >= indent) { - stack.pop(); - } - - const actionableOrValuable = - line.includes('[ref_') || - line.includes(' = "') || - /\((focused|disabled|checked|selected|expanded)/.test(line); - - if (actionableOrValuable) { - keep.add(i); - for (const ancestor of stack) { - keep.add(ancestor.index); - } - } - - stack.push({ indent, index: i }); - } - - return lines.filter((_, index) => keep.has(index)); -} - -interface ReadPageDiagnostics { - mode: string; - requestedMode?: string; - pageStatsMs?: number; - domGetDocumentMs?: number; - axGetFullTreeMs?: number; - formatMs?: number; - paginationMs?: number; - sanitizeMs?: number; - deltaMs?: number; -} - -type ReadPageDiagnosticTimingKey = Exclude; - - interface AXNode { nodeId: number; backendDOMNodeId?: number; @@ -223,10 +111,7 @@ const handler: ToolHandler = async ( const tabId = args.tabId as string; const filter = (args.filter as string) || 'all'; const defaultDepth = filter === 'interactive' ? 5 : 8; - const requestedDepth = typeof args.depth === 'number' ? args.depth : undefined; - const maxDepth = filter === 'interactive' - ? Math.min(requestedDepth ?? defaultDepth, defaultDepth) - : requestedDepth ?? defaultDepth; + const maxDepth = (args.depth as number) || defaultDepth; const fetchDepth = maxDepth; const sessionManager = getSessionManager(); @@ -255,35 +140,14 @@ const handler: ToolHandler = async ( const cdpClient = sessionManager.getCDPClient(); // Mode dispatch - const requestedMode = args.mode as string | undefined; - const mode = requestedMode || 'dom'; - const isExplicitDomMode = requestedMode === 'dom'; - if (mode !== 'ax' && mode !== 'dom' && mode !== 'css' && mode !== 'semantic' && mode !== 'markdown') { + const mode = (args.mode as string) || 'dom'; + if (mode !== 'ax' && mode !== 'dom' && mode !== 'css' && mode !== 'semantic') { return { - content: [{ type: 'text', text: `Error: Invalid mode "${mode}". Must be "ax", "dom", "css", "semantic", or "markdown".` }], + content: [{ type: 'text', text: `Error: Invalid mode "${mode}". Must be "ax", "dom", "css", or "semantic".` }], isError: true, }; } - const diagnosticsEnabled = args.diagnostics === true; - const diagnostics: ReadPageDiagnostics = { - mode, - ...(requestedMode !== undefined && requestedMode !== mode ? { requestedMode } : {}), - }; - const mark = () => Date.now(); - const measure = async (key: ReadPageDiagnosticTimingKey, fn: () => Promise): Promise => { - const start = mark(); - try { - return await fn(); - } finally { - diagnostics[key] = mark() - start; - } - }; - const withDiagnostics = (result: MCPResult): MCPResult => ( - diagnosticsEnabled ? { ...result, _diagnostics: diagnostics } : result - ); - const axOverflowFallback = (args.fallback as string | undefined) || 'none'; - const compactAX = args.compact === true; if (axOverflowFallback !== 'none' && axOverflowFallback !== 'dom') { return { content: [{ type: 'text', text: `Error: Invalid fallback "${axOverflowFallback}". Must be "none" or "dom".` }], @@ -299,40 +163,6 @@ const handler: ToolHandler = async ( }; } - // Markdown mode — clean HTML→Markdown extraction. - // Keep pagination metadata parity with DOM/AX/CSS modes when requested. - if (mode === 'markdown') { - const onlyMainContent = args.onlyMainContent !== false; - const includeLinks = args.includeLinks !== false; - const includePaginationMarkdown = args.includePagination !== false; - const refIdNote = args.ref_id - ? '[Note: ref_id is not supported in markdown mode — full-page content returned. Use mode "ax" for ref_id subtree scoping.]\n\n' - : ''; - const html = await withTimeout( - page.content(), - 15000, - 'read_page.markdown.content', - context, - ); - const { html: cleaned } = extractMainContent(html, { onlyMainContent }); - let md = refIdNote + toMarkdown(cleaned, { includeLinks }); - const paginationSection = includePaginationMarkdown - ? formatPaginationSection(await detectPagination(page, tabId)) - : ''; - if (paginationSection) { - md += `\n${paginationSection}`; - } - let truncated = false; - if (md.length > MAX_OUTPUT_CHARS) { - md = md.slice(0, MAX_OUTPUT_CHARS); - truncated = true; - } - const suffix = truncated ? '\n\n[Output truncated — exceeded MAX_OUTPUT_CHARS]' : ''; - return { - content: [{ type: 'text', text: md + suffix }], - }; - } - // CSS diagnostic mode — extracts computed styles, CSS variables, and framework info if (mode === 'css') { const targetSelector = args.selector as string | undefined; @@ -692,29 +522,17 @@ const handler: ToolHandler = async ( try { const refId = args.ref_id as string | undefined; const depth = args.depth as number | undefined; - const planningProfile = (args.planningProfile as 'default' | 'stable' | undefined) ?? 'default'; - const result = await measure('domGetDocumentMs', () => serializeDOM(page, cdpClient, { + const result = await serializeDOM(page, cdpClient, { maxDepth: depth ?? -1, filter: filter, interactiveOnly: filter === 'interactive', - planningProfile, - })); - diagnostics.formatMs = diagnostics.domGetDocumentMs; + }); let outputText = result.content; if (refId) { outputText = '[Note: ref_id is ignored in DOM mode. Use mode "ax" for subtree scoping.]\n\n' + outputText; } - // #844: build the [node_refs] block from emitted backendNodeIds. - // P2 contract — block is always present (never gated by the flag); - // the flag only flips uid values to `null` at runtime. - const nodeRefsBlock = await formatNodeRefsBlock( - page, - cdpClient, - result.emittedBackendNodeIds ?? [], - ); - // Delta compression: cache DOM and return diff if applicable const compression = args.compression as string | undefined; if (compression === 'delta') { @@ -723,7 +541,7 @@ const handler: ToolHandler = async ( const previous = snapshotStore.get(sessionId, tabId); if (previous) { - const delta = await measure('deltaMs', async () => snapshotStore.computeDelta(previous, outputText, currentUrl)); + const delta = snapshotStore.computeDelta(previous, outputText, currentUrl); // Always update cache with current content snapshotStore.set(sessionId, tabId, outputText, currentUrl); @@ -731,16 +549,10 @@ const handler: ToolHandler = async ( // Return delta instead of full content, but keep page stats header const statsLine = `[page_stats] url: ${result.pageStats.url} | title: ${result.pageStats.title} | scroll: ${result.pageStats.scrollX},${result.pageStats.scrollY} | viewport: ${result.pageStats.viewportWidth}x${result.pageStats.viewportHeight} | docSize: ${result.pageStats.scrollWidth}x${result.pageStats.scrollHeight}\n\n`; const includePaginationDom = args.includePagination !== false; - const domPaginationSection = includePaginationDom ? await measure('paginationMs', async () => formatPaginationSection(await detectPagination(page, tabId))) : ''; - const compressedText = statsLine + delta.content + nodeRefsBlock + domPaginationSection; - return withDiagnostics({ - content: [{ type: 'text', text: compressedText }], - _compression: { - level: 'delta', - originalChars: outputText.length, - compressedChars: compressedText.length, - }, - }); + const domPaginationSection = includePaginationDom ? formatPaginationSection(await detectPagination(page, tabId)) : ''; + return { + content: [{ type: 'text', text: statsLine + delta.content + domPaginationSection }], + }; } // If not delta (too many changes), fall through to full response } else { @@ -750,24 +562,12 @@ const handler: ToolHandler = async ( } const includePaginationDom = args.includePagination !== false; - const domPaginationSection = includePaginationDom ? await measure('paginationMs', async () => formatPaginationSection(await detectPagination(page, tabId))) : ''; - return withDiagnostics({ - content: [{ type: 'text', text: outputText + nodeRefsBlock + domPaginationSection }], - }); - } catch (error) { - if (isExplicitDomMode) { - return withDiagnostics({ - content: [ - { - type: 'text', - text: `Read page DOM serialization error: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - isError: true, - }); - } + const domPaginationSection = includePaginationDom ? formatPaginationSection(await detectPagination(page, tabId)) : ''; + return { + content: [{ type: 'text', text: outputText + domPaginationSection }], + }; + } catch { // DOM serialization failed — fall through to AX mode as fallback - diagnostics.mode = 'ax'; } } @@ -799,15 +599,15 @@ const handler: ToolHandler = async ( : undefined; // Get the accessibility tree - const { nodes } = await measure('axGetFullTreeMs', () => withTimeout( + const { nodes } = await withTimeout( cdpClient.send<{ nodes: AXNode[] }>(page, 'Accessibility.getFullAXTree', { depth: fetchDepth }), 15000, 'Accessibility.getFullAXTree', context, - )); + ); // Add page stats header for AX mode after the AX snapshot so stats are not older than the tree. - const axPageStats = await measure('pageStatsMs', () => withTimeout(page.evaluate(() => ({ + const axPageStats = await withTimeout(page.evaluate(() => ({ url: window.location.href, title: document.title, scrollX: Math.round(window.scrollX), @@ -816,11 +616,9 @@ const handler: ToolHandler = async ( scrollHeight: document.documentElement.scrollHeight, viewportWidth: window.innerWidth, viewportHeight: window.innerHeight, - })), 15000, 'read_page', context)); + })), 15000, 'read_page', context); const pageStatsLine = `[page_stats] url: ${axPageStats.url} | title: ${axPageStats.title} | scroll: ${axPageStats.scrollX},${axPageStats.scrollY} | viewport: ${axPageStats.viewportWidth}x${axPageStats.viewportHeight} | docSize: ${axPageStats.scrollWidth}x${axPageStats.scrollHeight}\n\n`; - const formatStart = mark(); - // Clear previous refs for this target refIdManager.clearTargetRefs(sessionId, tabId); @@ -865,21 +663,6 @@ const handler: ToolHandler = async ( let charCount = 0; const MAX_OUTPUT = MAX_OUTPUT_CHARS; - /** - * Per-snapshot refs map (#831). Populated as the AX tree is walked so that - * the final response carries a structured `refs` map alongside the textual - * tree. Additive to the existing ax response — `mode='ax'` is unchanged. - */ - const refsMap: Record = {}; - function formatNode(node: AXNode, indent: number): void { if (charCount > MAX_OUTPUT) return; @@ -932,20 +715,6 @@ const handler: ToolHandler = async ( name, tagName ); - - // #831: record the structured ref entry for the response `refs` map. - // Fields mirror the RefEntry contract documented in the issue. - const entry = refIdManager.getRef(sessionId, tabId, refId); - const textContent = value || undefined; - refsMap[refId] = { - role, - ...(name ? { name } : {}), - ...(tagName ? { tag_name: tagName } : {}), - ...(textContent ? { text_content: textContent } : {}), - ...(entry?.frameId ? { frame_id: entry.frameId } : {}), - created_at: entry?.createdAt ?? Date.now(), - stale_after_ms: entry?.staleAfterMs ?? REF_TTL_MS, - }; } // Build line @@ -1001,10 +770,10 @@ const handler: ToolHandler = async ( }); } if (!scopedNode) { - return withDiagnostics({ + return { content: [{ type: 'text', text: `Error: ref_id or node ID "${refIdParam}" not found or expired` }], isError: true, - }); + }; } startNodes = [scopedNode]; } else { @@ -1014,37 +783,16 @@ const handler: ToolHandler = async ( formatNode(root, 0); } - const outputLines = compactAX ? compactAXLines(lines) : lines; - const output = outputLines.join('\n'); - const outputCharCount = output.length; - diagnostics.formatMs = mark() - formatStart; + const output = lines.join('\n'); const includePaginationAx = args.includePagination !== false; - const axPaginationSection = includePaginationAx ? await measure('paginationMs', async () => formatPaginationSection(await detectPagination(page, tabId))) : ''; - - const compression = args.compression as string | undefined; - if (compression === 'delta') { - const snapshotStore = SnapshotStore.getInstance(); - const axCacheTabId = `${tabId}:ax${compactAX ? ':compact' : ''}`; - const previous = snapshotStore.get(sessionId, axCacheTabId); - if (previous) { - const delta = snapshotStore.computeDelta(previous, output, axPageStats.url); - snapshotStore.set(sessionId, axCacheTabId, output, axPageStats.url); - if (delta.isDelta) { - return { - content: [{ type: 'text', text: pageStatsLine + delta.content.replace('[DOM Delta', '[AX Delta') + axPaginationSection }], - }; - } - } else { - snapshotStore.set(sessionId, axCacheTabId, output, axPageStats.url); - } - } + const axPaginationSection = includePaginationAx ? formatPaginationSection(await detectPagination(page, tabId)) : ''; - if (outputCharCount > MAX_OUTPUT) { + if (charCount > MAX_OUTPUT) { // Large AX output should not trigger a second full DOM traversal unless // the caller explicitly opts into that fallback. Otherwise preserve AX // intent and return the bounded/truncated AX representation. if (axOverflowFallback !== 'dom') { - return withDiagnostics({ + return { content: [ { type: 'text', @@ -1055,8 +803,7 @@ const handler: ToolHandler = async ( axPaginationSection, }, ], - refs: refsMap, - }); + }; } // Explicit fallback: DOM mode often produces equivalent page structure at @@ -1068,35 +815,22 @@ const handler: ToolHandler = async ( interactiveOnly: filter === 'interactive', }); - // #844: include the [node_refs] block in the AX-overflow DOM - // fallback path too — P2 contract is unconditional across response - // shapes that ship DOM content. - const fallbackNodeRefsBlock = await formatNodeRefsBlock( - page, - cdpClient, - domResult.emittedBackendNodeIds ?? [], - ); - const fallbackNote = '\n\n[AX tree exceeded output limit (' + charCount + ' chars). ' + 'Switched to DOM mode because fallback: "dom" was requested. ' + 'Use mode: "ax" with smaller depth / ref_id to scope specific subtrees for AX format.]'; - // Update diagnostics to reflect the effective output mode (DOM), not the requested one (AX). - diagnostics.requestedMode = diagnostics.requestedMode ?? diagnostics.mode; - diagnostics.mode = 'dom'; - - return withDiagnostics({ + return { content: [ { type: 'text', - text: domResult.content + fallbackNote + fallbackNodeRefsBlock + axPaginationSection, + text: domResult.content + fallbackNote + axPaginationSection, }, ], - }); + }; } catch { // If DOM serialization fails, fall back to truncated AX (original behavior) - return withDiagnostics({ + return { content: [ { type: 'text', @@ -1107,15 +841,13 @@ const handler: ToolHandler = async ( axPaginationSection, }, ], - refs: refsMap, - }); + }; } } - return withDiagnostics({ + return { content: [{ type: 'text', text: pageStatsLine + output + axPaginationSection }], - refs: refsMap, - }); + }; } catch (error) { return { content: [ @@ -1136,56 +868,12 @@ const handler: ToolHandler = async ( */ const sanitizedHandler: ToolHandler = async (sessionId, args, context) => { const result = await handler(sessionId, args, context); - const includeMetrics = args.include_metrics === true; - const modeForMetrics = typeof args.mode === 'string' ? args.mode : 'dom'; - const addMetricsToText = (text: string): string => { - if (modeForMetrics === 'semantic') { - try { - const payload = JSON.parse(text) as Record; - let rendered = JSON.stringify(payload); - for (let i = 0; i < 20; i++) { - const metrics = buildTextMetrics(rendered, { mode: modeForMetrics }); - payload['_metrics'] = metrics; - const next = JSON.stringify(payload); - const finalMetrics = buildTextMetrics(next, { mode: modeForMetrics }); - if ( - metrics.returned_chars === finalMetrics.returned_chars && - metrics.estimated_tokens === finalMetrics.estimated_tokens - ) { - return next; - } - rendered = next; - } - // Extremely unlikely digit-boundary fallback: return the closest fixed point - // instead of dropping metrics. The bounded loop prevents pathological hangs. - return JSON.stringify(payload); - } catch { - // Fall through to footer metrics for non-JSON semantic error/fallback text. - } - } - return appendMetricsFooter(text, buildTextMetrics(text, { mode: modeForMetrics })); - }; - - // Skip sanitization if disabled, if the result is an error, or if no content. - // Metrics are independent of sanitization and remain available when callers - // intentionally run with --no-sanitize-content. + // Skip sanitization if disabled, if the result is an error, or if no content const config = getGlobalConfig(); - if (result.isError || !result.content) { + if (config.security?.sanitize_content === false || result.isError || !result.content) { return result; } - if (config.security?.sanitize_content === false) { - return includeMetrics - ? { - ...result, - content: result.content.map((block) => ( - block.type === 'text' && typeof block.text === 'string' - ? { ...block, text: addMetricsToText(block.text) } - : block - )), - } - : result; - } // P1 codex fix: semantic mode emits a JSON payload via `JSON.stringify(view)`. // Running the string-level sanitizer over the serialized JSON would let @@ -1233,7 +921,8 @@ const sanitizedHandler: ToolHandler = async (sessionId, args, context) => { return value; } - const sanitizeStart = Date.now(); + const includeMetrics = args.include_metrics === true; + const modeForMetrics = typeof args.mode === 'string' ? args.mode : 'dom'; // Sanitize all text content blocks const sanitizedContent = result.content.map((block) => { @@ -1250,8 +939,10 @@ const sanitizedHandler: ToolHandler = async (sessionId, args, context) => { const unique = Array.from(new Set(notes)); cleaned['_sanitization'] = unique.join('; '); } - const text = JSON.stringify(cleaned); - return { ...block, text: includeMetrics ? addMetricsToText(text) : text }; + if (includeMetrics) { + cleaned['_metrics'] = buildTextMetrics(JSON.stringify(cleaned), { mode: modeForMetrics }); + } + return { ...block, text: JSON.stringify(cleaned) }; } catch { // Parse failed — fall back to string-level sanitization so the // security signal is not silently lost. @@ -1271,36 +962,9 @@ const sanitizedHandler: ToolHandler = async (sessionId, args, context) => { return block; }); - const sanitizedResult: MCPResult = { ...result, content: sanitizedContent }; - if (args.diagnostics === true && sanitizedResult._diagnostics && typeof sanitizedResult._diagnostics === 'object') { - (sanitizedResult._diagnostics as ReadPageDiagnostics).sanitizeMs = Date.now() - sanitizeStart; - } - return sanitizedResult; -}; - -/** - * Snapshot-cache wrapper (#879). - * - * read_page stays uncached for now. AX/semantic responses embed ephemeral - * ref_* ids owned by RefIdManager, and DOM/CSS outputs include scroll-sensitive - * page stats/content. Until cache identity can include scroll state and ref - * mappings can be replayed or made stable, returning cached read_page payloads - * risks stale refs or stale post-scroll snapshots. Keep the wrapper as a - * behavior-preserving seam so the feature can be re-enabled safely later. - */ -const cachedHandler: ToolHandler = async (sessionId, args, context) => { - return sanitizedHandler(sessionId, args, context); + return { ...result, content: sanitizedContent }; }; export function registerReadPageTool(server: MCPServer): void { - server.registerTool('read_page', cachedHandler, definition); + server.registerTool('read_page', sanitizedHandler, definition); } - -/** - * Internal handler exported for in-process reuse (e.g. the - * `returnAfterState` chaining option on input tools). External callers should - * register the tool via `registerReadPageTool` and invoke it through the MCP - * server. This export wraps the sanitized handler so callers get the same - * post-processing the public tool applies. - */ -export const readPageHandlerForReuse: ToolHandler = sanitizedHandler; diff --git a/tests/core/tools/crawl.engine.test.ts b/tests/core/tools/crawl.engine.test.ts index d1197b633..afb6f46c9 100644 --- a/tests/core/tools/crawl.engine.test.ts +++ b/tests/core/tools/crawl.engine.test.ts @@ -226,36 +226,6 @@ describe('crawl engine=static', () => { expect(parsedWithoutMetrics.pages[0].metrics).toBeUndefined(); }); - test('dispatcher=adaptive includes dispatcher stats without changing fixed default', async () => { - const handler = await loadHandler('crawl'); - const adaptive = await handler('s-adaptive', { - url: `${server.origin}/index.html`, - max_pages: 1, - max_depth: 0, - delay_ms: 0, - engine: 'static', - respect_robots: false, - dispatcher: 'adaptive', - dispatcher_options: { min_concurrency: 1, max_concurrency: 3 }, - }); - const parsedAdaptive = parseResult(adaptive); - expect(parsedAdaptive.summary.dispatcher).toMatchObject({ - mode: 'adaptive', - min_concurrency: 1, - }); - - const fixed = await handler('s-fixed', { - url: `${server.origin}/index.html`, - max_pages: 1, - max_depth: 0, - delay_ms: 0, - engine: 'static', - respect_robots: false, - }); - const parsedFixed = parseResult(fixed); - expect(parsedFixed.summary.dispatcher).toBeUndefined(); - }); - test('respect_robots:true does not open a Chrome tab for robots.txt', async () => { const handler = await loadHandler('crawl'); await handler('s2', { @@ -455,67 +425,4 @@ describe('crawl_sitemap engine=static', () => { } expect(mockSessionManager.createTarget).not.toHaveBeenCalled(); }); - - test('size-fallback summary metrics align with emitted per-page metrics', async () => { - // Force the minimal-pages fallback (content omitted) by serving enough - // large pages that the per-page truncation step (2_000 chars each) still - // pushes the aggregate JSON above MAX_OUTPUT_CHARS (50_000). Two big pages - // is not enough — after the 2_000-char truncation pass the output collapses - // to ~5 KB and the second-stage fallback never runs. 30 pages × 2_000 chars - // ≈ 60 KB, which reliably exercises the content-omitted code path. - const BIG = 'x'.repeat(60_000); // each page > MAX_OUTPUT_CHARS / 2 raw - const PAGE_COUNT = 30; - const urlList: string[] = []; - for (let i = 0; i < PAGE_COUNT; i++) { - const slug = `big-${i.toString().padStart(2, '0')}`; - const route = `/${slug}.html`; - server.setRoute(route, { - status: 200, - contentType: 'text/html; charset=utf-8', - body: RICH_HTML(`Big ${i}`, `

Big ${i}

${BIG}

`), - }); - urlList.push(`${server.origin}${route}`); - } - server.setRoute('/sitemap.xml', { - status: 200, - contentType: 'application/xml', - body: - '' + - '' + - urlList.join('') + - '', - }); - - const handler = await loadHandler('crawl_sitemap'); - const result = await handler('s-fallback-metrics', { - url: server.origin, - max_pages: PAGE_COUNT, - concurrency: 4, - engine: 'static', - include_metrics: true, - }); - expect(result.isError).not.toBe(true); - const parsed = JSON.parse(result.content[0].text) as { - summary: { metrics?: Record }; - pages: Array<{ metrics?: Record; content?: string }>; - note?: string; - }; - expect(parsed.note).toBe('Content omitted due to size constraints'); - - // Per-page content is omitted; per-page metrics are derived from empty - // strings — so summary metrics must mirror what is actually emitted. - const perPageCharsSum = parsed.pages.reduce( - (sum, p) => sum + (p.metrics?.returned_chars ?? 0), - 0, - ); - const perPageTokensSum = parsed.pages.reduce( - (sum, p) => sum + (p.metrics?.estimated_tokens ?? 0), - 0, - ); - expect(parsed.summary.metrics).toBeDefined(); - expect(parsed.summary.metrics!.returned_chars).toBe(perPageCharsSum); - expect(parsed.summary.metrics!.estimated_tokens).toBe(perPageTokensSum); - // Per-page metrics built from empty strings yield 0 returned_chars. - expect(parsed.summary.metrics!.returned_chars).toBe(0); - }); }); diff --git a/tests/tools/inspect-metrics.test.ts b/tests/tools/inspect-metrics.test.ts new file mode 100644 index 000000000..7ba8a991c --- /dev/null +++ b/tests/tools/inspect-metrics.test.ts @@ -0,0 +1,96 @@ +/// + +import { createMockSessionManager } from '../utils/mock-session'; + +jest.mock('../../src/session-manager', () => ({ + getSessionManager: jest.fn(), +})); + +jest.mock('../../src/utils/shadow-dom', () => ({ + getAllShadowRoots: jest.fn().mockResolvedValue({ shadowRoots: [], domTree: {} }), + querySelectorInShadowRoots: jest.fn().mockResolvedValue([]), +})); + +import { getSessionManager } from '../../src/session-manager'; + +describe('InspectTool include_metrics', () => { + test('keeps default inspect output unchanged without metrics', async () => { + const mockSessionManager = createMockSessionManager(); + (getSessionManager as jest.Mock).mockReturnValue(mockSessionManager); + + const sessionId = 'inspect-default-metrics-session'; + const { targetId, page } = await mockSessionManager.createTarget(sessionId, 'about:blank'); + (page.evaluate as jest.Mock).mockResolvedValue({ + focusedInfo: null, + tabs: [], + interactiveCounts: { button: 2 }, + formFields: [], + headings: [], + errors: [], + visiblePanels: [], + url: 'https://example.com', + title: 'Example', + }); + + const { registerInspectTool } = await import('../../src/tools/inspect'); + const tools = new Map) => Promise }>(); + registerInspectTool({ + registerTool: (name: string, handler: unknown) => { + tools.set(name, { handler: handler as (sessionId: string, args: Record) => Promise }); + }, + } as unknown as Parameters[0]); + + const result = await tools.get('inspect')!.handler(sessionId, { + tabId: targetId, + query: 'interactive controls', + }); + + expect(result.content[0].text).toContain('[Interactive Elements] 2 buttons'); + expect(result.content[0].text).not.toContain('[openchrome_metrics]'); + }); + + test('appends approximate token metrics only when requested', async () => { + const mockSessionManager = createMockSessionManager(); + (getSessionManager as jest.Mock).mockReturnValue(mockSessionManager); + + const sessionId = 'inspect-include-metrics-session'; + const { targetId, page } = await mockSessionManager.createTarget(sessionId, 'about:blank'); + (page.evaluate as jest.Mock).mockResolvedValue({ + focusedInfo: null, + tabs: [], + interactiveCounts: { button: 1, link: 3 }, + formFields: [], + headings: [{ level: 1, text: 'Visible Heading' }], + errors: [], + visiblePanels: [], + url: 'https://example.com/repo', + title: 'Repository', + }); + + const { registerInspectTool } = await import('../../src/tools/inspect'); + const tools = new Map) => Promise }>(); + registerInspectTool({ + registerTool: (name: string, handler: unknown) => { + tools.set(name, { handler: handler as (sessionId: string, args: Record) => Promise }); + }, + } as unknown as Parameters[0]); + + const result = await tools.get('inspect')!.handler(sessionId, { + tabId: targetId, + query: 'headings and interactive controls', + include_metrics: true, + }); + const text = result.content[0].text as string; + const [body, metricsLine] = text.split('\n\n[openchrome_metrics] '); + const metrics = JSON.parse(metricsLine); + + expect(body).toContain('[Headings] h1: "Visible Heading"'); + expect(body).toContain('[Interactive Elements] 1 buttons, 3 links'); + expect(metrics).toEqual({ + returned_chars: body.length, + estimated_tokens: Math.ceil(body.length / 4), + truncated: false, + mode: 'inspect:visible', + }); + }); +});