Skip to content

Commit 3fcca02

Browse files
authored
chore: support structured content (#744)
The idea is to turn formatters into instances and support both text and JSON formatting. The structured content is output if the experimental structured content flag is passed. For now, only the snapshots are returned in a structured way. Refs #689
1 parent 14ff400 commit 3fcca02

12 files changed

Lines changed: 451 additions & 214 deletions

GEMINI.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Instructions
2+
3+
- use `npm run build` to run tsc and test build
4+
- use `npm run test` to run tests, run all tests to verify correctness

src/McpResponse.ts

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
getShortDescriptionForRequest,
1818
getStatusFromRequest,
1919
} from './formatters/networkFormatter.js';
20-
import {formatSnapshotNode} from './formatters/snapshotFormatter.js';
20+
import {SnapshotFormatter} from './formatters/SnapshotFormatter.js';
2121
import type {McpContext} from './McpContext.js';
2222
import {DevTools} from './third_party/index.js';
2323
import type {
@@ -181,29 +181,31 @@ export class McpResponse implements Response {
181181
async handle(
182182
toolName: string,
183183
context: McpContext,
184-
): Promise<Array<TextContent | ImageContent>> {
184+
): Promise<{
185+
content: Array<TextContent | ImageContent>;
186+
structuredContent: object;
187+
}> {
185188
if (this.#includePages) {
186189
await context.createPagesSnapshot();
187190
}
188191

189-
let formattedSnapshot: string | undefined;
192+
let snapshot: SnapshotFormatter | string | undefined;
190193
if (this.#snapshotParams) {
191194
await context.createTextSnapshot(
192195
this.#snapshotParams.verbose,
193196
this.#devToolsData,
194197
);
195-
const snapshot = context.getTextSnapshot();
196-
if (snapshot) {
198+
const textSnapshot = context.getTextSnapshot();
199+
if (textSnapshot) {
200+
const formatter = new SnapshotFormatter(textSnapshot);
197201
if (this.#snapshotParams.filePath) {
198202
await context.saveFile(
199-
new TextEncoder().encode(
200-
formatSnapshotNode(snapshot.root, snapshot),
201-
),
203+
new TextEncoder().encode(formatter.toString()),
202204
this.#snapshotParams.filePath,
203205
);
204-
formattedSnapshot = `Saved snapshot to ${this.#snapshotParams.filePath}.`;
206+
snapshot = this.#snapshotParams.filePath;
205207
} else {
206-
formattedSnapshot = formatSnapshotNode(snapshot.root, snapshot);
208+
snapshot = formatter;
207209
}
208210
}
209211
}
@@ -335,7 +337,7 @@ export class McpResponse implements Response {
335337
bodies,
336338
consoleData,
337339
consoleListData,
338-
formattedSnapshot,
340+
snapshot,
339341
});
340342
}
341343

@@ -349,9 +351,9 @@ export class McpResponse implements Response {
349351
};
350352
consoleData: ConsoleMessageData | undefined;
351353
consoleListData: ConsoleMessageData[] | undefined;
352-
formattedSnapshot: string | undefined;
354+
snapshot: SnapshotFormatter | string | undefined;
353355
},
354-
): Array<TextContent | ImageContent> {
356+
): {content: Array<TextContent | ImageContent>; structuredContent: object} {
355357
const response = [`# ${toolName} response`];
356358
for (const line of this.#textResponseLines) {
357359
response.push(line);
@@ -393,9 +395,20 @@ Call ${handleDialog.name} to handle it before continuing.`);
393395
response.push(...parts);
394396
}
395397

396-
if (data.formattedSnapshot) {
397-
response.push('## Latest page snapshot');
398-
response.push(data.formattedSnapshot);
398+
const structuredContent: {
399+
snapshot?: object;
400+
snapshotFilePath?: string;
401+
} = {};
402+
403+
if (data.snapshot) {
404+
if (typeof data.snapshot === 'string') {
405+
response.push(`Saved snapshot to ${data.snapshot}.`);
406+
structuredContent.snapshotFilePath = data.snapshot;
407+
} else {
408+
response.push('## Latest page snapshot');
409+
response.push(data.snapshot.toString());
410+
structuredContent.snapshot = data.snapshot.toJSON();
411+
}
399412
}
400413

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

471-
return [text, ...images];
484+
return {
485+
content: [text, ...images],
486+
structuredContent,
487+
};
472488
}
473489

474490
#dataWithPagination<T>(data: T[], pagination?: PaginationOptions) {

src/cli.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,11 @@ export const cliOptions = {
157157
describe: 'Whether to enable vision tools',
158158
hidden: true,
159159
},
160+
experimentalStructuredContent: {
161+
type: 'boolean',
162+
describe: 'Whether to output structured formatted content.',
163+
hidden: true,
164+
},
160165
experimentalIncludeAllPages: {
161166
type: 'boolean',
162167
describe:
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
import type {TextSnapshot, TextSnapshotNode} from '../McpContext.js';
7+
8+
export class SnapshotFormatter {
9+
#snapshot: TextSnapshot;
10+
11+
constructor(snapshot: TextSnapshot) {
12+
this.#snapshot = snapshot;
13+
}
14+
15+
toString(): string {
16+
const chunks: string[] = [];
17+
const root = this.#snapshot.root;
18+
19+
// Top-level content of the snapshot.
20+
if (
21+
this.#snapshot.verbose &&
22+
this.#snapshot.hasSelectedElement &&
23+
!this.#snapshot.selectedElementUid
24+
) {
25+
chunks.push(`Note: there is a selected element in the DevTools Elements panel but it is not included into the current a11y tree snapshot.
26+
Get a verbose snapshot to include all elements if you are interested in the selected element.\n\n`);
27+
}
28+
29+
chunks.push(this.#formatNode(root, 0));
30+
return chunks.join('');
31+
}
32+
33+
toJSON(): object {
34+
return this.#nodeToJSON(this.#snapshot.root);
35+
}
36+
37+
#formatNode(node: TextSnapshotNode, depth = 0): string {
38+
const chunks: string[] = [];
39+
const attributes = this.#getAttributes(node);
40+
const line =
41+
' '.repeat(depth * 2) +
42+
attributes.join(' ') +
43+
(node.id === this.#snapshot.selectedElementUid
44+
? ' [selected in the DevTools Elements panel]'
45+
: '') +
46+
'\n';
47+
chunks.push(line);
48+
49+
for (const child of node.children) {
50+
chunks.push(this.#formatNode(child, depth + 1));
51+
}
52+
return chunks.join('');
53+
}
54+
55+
#nodeToJSON(node: TextSnapshotNode): object {
56+
const rawAttrs = this.#getAttributesMap(node);
57+
const children = node.children.map(child => this.#nodeToJSON(child));
58+
const result: Record<string, unknown> = structuredClone(rawAttrs);
59+
if (children.length > 0) {
60+
result.children = children;
61+
}
62+
return result;
63+
}
64+
65+
#getAttributes(serializedAXNodeRoot: TextSnapshotNode): string[] {
66+
const attributes = [`uid=${serializedAXNodeRoot.id}`];
67+
68+
if (serializedAXNodeRoot.role) {
69+
attributes.push(
70+
serializedAXNodeRoot.role === 'none'
71+
? 'ignored'
72+
: serializedAXNodeRoot.role,
73+
);
74+
}
75+
if (serializedAXNodeRoot.name) {
76+
attributes.push(`"${serializedAXNodeRoot.name}"`);
77+
}
78+
79+
const simpleAttrs = this.#getAttributesMap(
80+
serializedAXNodeRoot,
81+
/* excludeSpecial */ true,
82+
);
83+
84+
for (const attr of Object.keys(serializedAXNodeRoot).sort()) {
85+
if (excludedAttributes.has(attr)) {
86+
continue;
87+
}
88+
89+
const mapped = booleanPropertyMap[attr];
90+
if (mapped && simpleAttrs[mapped]) {
91+
attributes.push(mapped);
92+
}
93+
94+
const val = simpleAttrs[attr];
95+
if (val === true) {
96+
attributes.push(attr);
97+
} else if (typeof val === 'string' || typeof val === 'number') {
98+
attributes.push(`${attr}="${val}"`);
99+
}
100+
}
101+
102+
return attributes;
103+
}
104+
105+
#getAttributesMap(
106+
node: TextSnapshotNode,
107+
excludeSpecial = false,
108+
): Record<string, unknown> {
109+
const result: Record<string, unknown> = {};
110+
if (!excludeSpecial) {
111+
result.id = node.id;
112+
if (node.role) {
113+
result.role = node.role;
114+
}
115+
if (node.name) {
116+
result.name = node.name;
117+
}
118+
}
119+
120+
// Re-implementing the exact logic from original function for #getAttributes to be safe:
121+
return {
122+
...result,
123+
...this.#extractedAttributes(node),
124+
};
125+
}
126+
127+
#extractedAttributes(node: TextSnapshotNode): Record<string, unknown> {
128+
const result: Record<string, unknown> = {};
129+
130+
for (const attr of Object.keys(node).sort()) {
131+
if (excludedAttributes.has(attr)) {
132+
continue;
133+
}
134+
const value = (node as unknown as Record<string, unknown>)[attr];
135+
if (typeof value === 'boolean') {
136+
if (booleanPropertyMap[attr]) {
137+
result[booleanPropertyMap[attr]] = true;
138+
}
139+
if (value) {
140+
result[attr] = true;
141+
}
142+
} else if (typeof value === 'string' || typeof value === 'number') {
143+
result[attr] = value;
144+
}
145+
}
146+
return result;
147+
}
148+
}
149+
150+
const booleanPropertyMap: Record<string, string> = {
151+
disabled: 'disableable',
152+
expanded: 'expandable',
153+
focused: 'focusable',
154+
selected: 'selectable',
155+
};
156+
157+
const excludedAttributes = new Set([
158+
'id',
159+
'role',
160+
'name',
161+
'elementHandle',
162+
'children',
163+
'backendNodeId',
164+
]);

src/formatters/snapshotFormatter.ts

Lines changed: 0 additions & 94 deletions
This file was deleted.

0 commit comments

Comments
 (0)