Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions GEMINI.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Instructions

- use `npm run build` to run tsc and test build
- use `npm run test` to run tests, run all tests to verify correctness
50 changes: 33 additions & 17 deletions src/McpResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
getShortDescriptionForRequest,
getStatusFromRequest,
} from './formatters/networkFormatter.js';
import {formatSnapshotNode} from './formatters/snapshotFormatter.js';
import {SnapshotFormatter} from './formatters/SnapshotFormatter.js';
import type {McpContext} from './McpContext.js';
import {DevTools} from './third_party/index.js';
import type {
Expand Down Expand Up @@ -181,29 +181,31 @@ export class McpResponse implements Response {
async handle(
toolName: string,
context: McpContext,
): Promise<Array<TextContent | ImageContent>> {
): Promise<{
content: Array<TextContent | ImageContent>;
structuredContent: object;
}> {
if (this.#includePages) {
await context.createPagesSnapshot();
}

let formattedSnapshot: string | undefined;
let snapshot: SnapshotFormatter | string | undefined;
if (this.#snapshotParams) {
await context.createTextSnapshot(
this.#snapshotParams.verbose,
this.#devToolsData,
);
const snapshot = context.getTextSnapshot();
if (snapshot) {
const textSnapshot = context.getTextSnapshot();
if (textSnapshot) {
const formatter = new SnapshotFormatter(textSnapshot);
if (this.#snapshotParams.filePath) {
await context.saveFile(
new TextEncoder().encode(
formatSnapshotNode(snapshot.root, snapshot),
),
new TextEncoder().encode(formatter.toString()),
this.#snapshotParams.filePath,
);
formattedSnapshot = `Saved snapshot to ${this.#snapshotParams.filePath}.`;
snapshot = this.#snapshotParams.filePath;
} else {
formattedSnapshot = formatSnapshotNode(snapshot.root, snapshot);
snapshot = formatter;
}
}
}
Expand Down Expand Up @@ -335,7 +337,7 @@ export class McpResponse implements Response {
bodies,
consoleData,
consoleListData,
formattedSnapshot,
snapshot,
});
}

Expand All @@ -349,9 +351,9 @@ export class McpResponse implements Response {
};
consoleData: ConsoleMessageData | undefined;
consoleListData: ConsoleMessageData[] | undefined;
formattedSnapshot: string | undefined;
snapshot: SnapshotFormatter | string | undefined;
},
): Array<TextContent | ImageContent> {
): {content: Array<TextContent | ImageContent>; structuredContent: object} {
const response = [`# ${toolName} response`];
for (const line of this.#textResponseLines) {
response.push(line);
Expand Down Expand Up @@ -393,9 +395,20 @@ Call ${handleDialog.name} to handle it before continuing.`);
response.push(...parts);
}

if (data.formattedSnapshot) {
response.push('## Latest page snapshot');
response.push(data.formattedSnapshot);
const structuredContent: {
snapshot?: object;
snapshotFilePath?: string;
} = {};

if (data.snapshot) {
if (typeof data.snapshot === 'string') {
response.push(`Saved snapshot to ${data.snapshot}.`);
structuredContent.snapshotFilePath = data.snapshot;
} else {
response.push('## Latest page snapshot');
response.push(data.snapshot.toString());
structuredContent.snapshot = data.snapshot.toJSON();
}
}

response.push(...this.#formatNetworkRequestData(context, data.bodies));
Expand Down Expand Up @@ -468,7 +481,10 @@ Call ${handleDialog.name} to handle it before continuing.`);
} as const;
});

return [text, ...images];
return {
content: [text, ...images],
structuredContent,
};
}

#dataWithPagination<T>(data: T[], pagination?: PaginationOptions) {
Expand Down
5 changes: 5 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,11 @@ export const cliOptions = {
describe: 'Whether to enable vision tools',
hidden: true,
},
experimentalStructuredContent: {
type: 'boolean',
describe: 'Whether to output structured formatted content.',
hidden: true,
},
experimentalIncludeAllPages: {
type: 'boolean',
describe:
Expand Down
164 changes: 164 additions & 0 deletions src/formatters/SnapshotFormatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {TextSnapshot, TextSnapshotNode} from '../McpContext.js';

export class SnapshotFormatter {
#snapshot: TextSnapshot;

constructor(snapshot: TextSnapshot) {
this.#snapshot = snapshot;
}

toString(): string {
const chunks: string[] = [];
const root = this.#snapshot.root;

// Top-level content of the snapshot.
if (
this.#snapshot.verbose &&
this.#snapshot.hasSelectedElement &&
!this.#snapshot.selectedElementUid
) {
chunks.push(`Note: there is a selected element in the DevTools Elements panel but it is not included into the current a11y tree snapshot.
Get a verbose snapshot to include all elements if you are interested in the selected element.\n\n`);
}

chunks.push(this.#formatNode(root, 0));
return chunks.join('');
}

toJSON(): object {
return this.#nodeToJSON(this.#snapshot.root);
}

#formatNode(node: TextSnapshotNode, depth = 0): string {
const chunks: string[] = [];
const attributes = this.#getAttributes(node);
const line =
' '.repeat(depth * 2) +
attributes.join(' ') +
(node.id === this.#snapshot.selectedElementUid
? ' [selected in the DevTools Elements panel]'
: '') +
'\n';
chunks.push(line);

for (const child of node.children) {
chunks.push(this.#formatNode(child, depth + 1));
}
return chunks.join('');
}

#nodeToJSON(node: TextSnapshotNode): object {
const rawAttrs = this.#getAttributesMap(node);
const children = node.children.map(child => this.#nodeToJSON(child));
const result: Record<string, unknown> = structuredClone(rawAttrs);
if (children.length > 0) {
result.children = children;
}
return result;
}

#getAttributes(serializedAXNodeRoot: TextSnapshotNode): string[] {
const attributes = [`uid=${serializedAXNodeRoot.id}`];

if (serializedAXNodeRoot.role) {
attributes.push(
serializedAXNodeRoot.role === 'none'
? 'ignored'
: serializedAXNodeRoot.role,
);
}
if (serializedAXNodeRoot.name) {
attributes.push(`"${serializedAXNodeRoot.name}"`);
}

const simpleAttrs = this.#getAttributesMap(
serializedAXNodeRoot,
/* excludeSpecial */ true,
);

for (const attr of Object.keys(serializedAXNodeRoot).sort()) {
if (excludedAttributes.has(attr)) {
continue;
}

const mapped = booleanPropertyMap[attr];
if (mapped && simpleAttrs[mapped]) {
attributes.push(mapped);
}

const val = simpleAttrs[attr];
if (val === true) {
attributes.push(attr);
} else if (typeof val === 'string' || typeof val === 'number') {
attributes.push(`${attr}="${val}"`);
}
}

return attributes;
}

#getAttributesMap(
node: TextSnapshotNode,
excludeSpecial = false,
): Record<string, unknown> {
const result: Record<string, unknown> = {};
if (!excludeSpecial) {
result.id = node.id;
if (node.role) {
result.role = node.role;
}
if (node.name) {
result.name = node.name;
}
}

// Re-implementing the exact logic from original function for #getAttributes to be safe:
return {
...result,
...this.#extractedAttributes(node),
};
}

#extractedAttributes(node: TextSnapshotNode): Record<string, unknown> {
const result: Record<string, unknown> = {};

for (const attr of Object.keys(node).sort()) {
if (excludedAttributes.has(attr)) {
continue;
}
const value = (node as unknown as Record<string, unknown>)[attr];
if (typeof value === 'boolean') {
if (booleanPropertyMap[attr]) {
result[booleanPropertyMap[attr]] = true;
}
if (value) {
result[attr] = true;
}
} else if (typeof value === 'string' || typeof value === 'number') {
result[attr] = value;
}
}
return result;
}
}

const booleanPropertyMap: Record<string, string> = {
disabled: 'disableable',
expanded: 'expandable',
focused: 'focusable',
selected: 'selectable',
};

const excludedAttributes = new Set([
'id',
'role',
'name',
'elementHandle',
'children',
'backendNodeId',
]);
94 changes: 0 additions & 94 deletions src/formatters/snapshotFormatter.ts

This file was deleted.

Loading