Skip to content

Commit acedc3f

Browse files
authored
chore(mcp): render structured response (microsoft#38419)
1 parent cce2be6 commit acedc3f

12 files changed

Lines changed: 460 additions & 282 deletions

packages/playwright/src/agents/performTask.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,34 @@ import { wrapInClient } from '../mcp/sdk/server';
2525
import type * as playwright from 'playwright-core';
2626
import type * as lowireLoop from '@lowire/loop';
2727

28-
export async function performTask(context: playwright.BrowserContext, task: string) {
28+
export type PerformTaskOptions = {
29+
provider?: 'github' | 'openai' | 'anthropic' | 'google';
30+
model?: string;
31+
maxTokens?: number;
32+
reasoning?: boolean;
33+
temperature?: number;
34+
};
35+
36+
export async function performTask(context: playwright.BrowserContext, task: string, options: PerformTaskOptions) {
2937
const backend = new BrowserServerBackend(defaultConfig, identityBrowserContextFactory(context));
3038
const client = await wrapInClient(backend, { name: 'Internal', version: '0.0.0' });
31-
const loop = new Loop('github', { model: 'claude-sonnet-4.5' });
32-
3339
const callTool: (params: { name: string, arguments: any}) => Promise<lowireLoop.ToolResult> = async params => {
3440
return await client.callTool(params) as lowireLoop.ToolResult;
3541
};
3642

43+
const loop = new Loop(options.provider ?? 'github', {
44+
model: options.model ?? 'claude-sonnet-4.5',
45+
reasoning: options.reasoning,
46+
temperature: options.temperature,
47+
maxTokens: options.maxTokens,
48+
summarize: true,
49+
debug,
50+
callTool,
51+
tools: await backend.listTools(),
52+
});
53+
3754
try {
38-
return await loop.run(task, {
39-
tools: await backend.listTools(),
40-
callTool,
41-
debug,
42-
});
55+
return await loop.run(task);
4356
} finally {
4457
await client.close();
4558
}

packages/playwright/src/index.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import type { APIRequestContext as APIRequestContextImpl } from '../../playwrigh
3636
import type { ChannelOwner } from '../../playwright-core/src/client/channelOwner';
3737
import type { Page as PageImpl } from '../../playwright-core/src/client/page';
3838
import type { BrowserContext, BrowserContextOptions, LaunchOptions, Page, Tracing } from 'playwright-core';
39+
import type { PerformTaskOptions } from './agents/performTask';
3940

4041
export { expect } from './matchers/expect';
4142
export const _baseTest: TestType<{}, {}> = rootTestType.test;
@@ -58,7 +59,7 @@ type TestFixtures = PlaywrightTestArgs & PlaywrightTestOptions & {
5859
_combinedContextOptions: BrowserContextOptions,
5960
_setupContextOptions: void;
6061
_setupArtifacts: void;
61-
_perform: (task: string) => Promise<void>;
62+
_perform: (task: string, options?: PerformTaskOptions) => Promise<void>;
6263
_contextFactory: (options?: BrowserContextOptions) => Promise<{ context: BrowserContext, close: () => Promise<void> }>;
6364
};
6465

@@ -462,8 +463,8 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
462463
},
463464

464465
_perform: async ({ context }, use) => {
465-
await use(async (task: string) => {
466-
await performTask(context, task);
466+
await use(async (task: string, options?: PerformTaskOptions) => {
467+
await performTask(context, task, options ?? {});
467468
});
468469
},
469470
});

packages/playwright/src/mcp/browser/browserServerBackend.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ export class BrowserServerBackend implements ServerBackend {
7373
context.setRunningTool(undefined);
7474
}
7575
response.logEnd();
76-
return response.serialize();
76+
const _meta = rawArguments?._meta as object | undefined;
77+
return response.serialize({ _meta });
7778
}
7879

7980
serverClosed() {

packages/playwright/src/mcp/browser/response.ts

Lines changed: 127 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { renderModalStates } from './tab';
2020
import type { Tab, TabSnapshot } from './tab';
2121
import type { CallToolResult, ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
2222
import type { Context } from './context';
23+
import type { ModalState } from './tools/tool';
2324

2425
export const requestDebug = debug('pw:mcp:request');
2526

@@ -30,6 +31,7 @@ export class Response {
3031
private _context: Context;
3132
private _includeSnapshot: 'none' | 'full' | 'incremental' = 'none';
3233
private _includeTabs = false;
34+
private _includeModalStates: ModalState[] | undefined;
3335
private _tabSnapshot: TabSnapshot | undefined;
3436

3537
readonly toolName: string;
@@ -87,6 +89,10 @@ export class Response {
8789
this._includeTabs = true;
8890
}
8991

92+
setIncludeModalStates(modalStates: ModalState[]) {
93+
this._includeModalStates = modalStates;
94+
}
95+
9096
async finish() {
9197
// All the async snapshotting post-action is happening here.
9298
// Everything below should race against modal states.
@@ -110,42 +116,44 @@ export class Response {
110116
requestDebug(this.serialize({ omitSnapshot: true, omitBlobs: true }));
111117
}
112118

113-
serialize(options: { omitSnapshot?: boolean, omitBlobs?: boolean } = {}): { content: (TextContent | ImageContent)[], isError?: boolean } {
114-
const response: string[] = [];
119+
serialize(options: { omitSnapshot?: boolean, omitBlobs?: boolean, _meta?: Record<string, any> } = {}): { content: (TextContent | ImageContent)[], isError?: boolean, _meta?: Record<string, any> } {
120+
const renderedResponse = new RenderedResponse();
115121

116-
// Start with command result.
117-
if (this._result.length) {
118-
response.push('### Result');
119-
response.push(this._result.join('\n'));
120-
response.push('');
121-
}
122+
if (this._result.length)
123+
renderedResponse.results.push(...this._result);
122124

123125
// Add code if it exists.
124-
if (this._code.length) {
125-
response.push(`### Ran Playwright code
126-
\`\`\`js
127-
${this._code.join('\n')}
128-
\`\`\``);
129-
response.push('');
130-
}
126+
if (this._code.length)
127+
renderedResponse.code.push(...this._code);
131128

132129
// List browser tabs.
133-
if (this._includeSnapshot !== 'none' || this._includeTabs)
134-
response.push(...renderTabsMarkdown(this._context.tabs(), this._includeTabs));
130+
if (this._includeSnapshot !== 'none' || this._includeTabs) {
131+
const tabsMarkdown = renderTabsMarkdown(this._context.tabs(), this._includeTabs);
132+
if (tabsMarkdown.length)
133+
renderedResponse.states.tabs = tabsMarkdown.join('\n');
134+
}
135135

136136
// Add snapshot if provided.
137137
if (this._tabSnapshot?.modalStates.length) {
138-
response.push(...renderModalStates(this._context, this._tabSnapshot.modalStates));
139-
response.push('');
138+
const modalStatesMarkdown = renderModalStates(this._tabSnapshot.modalStates);
139+
renderedResponse.states.modal = modalStatesMarkdown.join('\n');
140140
} else if (this._tabSnapshot) {
141141
const includeSnapshot = options.omitSnapshot ? 'none' : this._includeSnapshot;
142-
response.push(renderTabSnapshot(this._tabSnapshot, includeSnapshot));
143-
response.push('');
142+
renderTabSnapshot(this._tabSnapshot, includeSnapshot, renderedResponse);
143+
} else if (this._includeModalStates) {
144+
const modalStatesMarkdown = renderModalStates(this._includeModalStates);
145+
renderedResponse.states.modal = modalStatesMarkdown.join('\n');
144146
}
145147

148+
const redactedResponse = this._context.config.secrets ? renderedResponse.redact(this._context.config.secrets) : renderedResponse;
149+
150+
// Structured response.
151+
const includeMeta = options._meta && 'dev.lowire/history' in options._meta && 'dev.lowire/state' in options._meta;
152+
const _meta: any = includeMeta ? redactedResponse.asMeta() : undefined;
153+
146154
// Main response part
147155
const content: (TextContent | ImageContent)[] = [
148-
{ type: 'text', text: response.join('\n') },
156+
{ type: 'text', text: redactedResponse.asText() },
149157
];
150158

151159
// Image attachments.
@@ -154,50 +162,39 @@ ${this._code.join('\n')}
154162
content.push({ type: 'image', data: options.omitBlobs ? '<blob>' : image.data.toString('base64'), mimeType: image.contentType });
155163
}
156164

157-
this._redactSecrets(content);
158-
return { content, isError: this._isError };
159-
}
160-
161-
private _redactSecrets(content: (TextContent | ImageContent)[]) {
162-
if (!this._context.config.secrets)
163-
return;
164-
165-
for (const item of content) {
166-
if (item.type !== 'text')
167-
continue;
168-
for (const [secretName, secretValue] of Object.entries(this._context.config.secrets))
169-
item.text = item.text.replaceAll(secretValue, `<secret>${secretName}</secret>`);
170-
}
165+
return {
166+
_meta,
167+
content,
168+
isError: this._isError
169+
};
171170
}
172171
}
173172

174-
function renderTabSnapshot(tabSnapshot: TabSnapshot, includeSnapshot: 'none' | 'full' | 'incremental'): string {
175-
const lines: string[] = [];
176-
173+
function renderTabSnapshot(tabSnapshot: TabSnapshot, includeSnapshot: 'none' | 'full' | 'incremental', response: RenderedResponse) {
177174
if (tabSnapshot.consoleMessages.length) {
178-
lines.push(`### New console messages`);
175+
const lines: string[] = [];
179176
for (const message of tabSnapshot.consoleMessages)
180177
lines.push(`- ${trim(message.toString(), 100)}`);
181-
lines.push('');
178+
response.updates.push({ category: 'console', content: lines.join('\n') });
182179
}
183180

184181
if (tabSnapshot.downloads.length) {
185-
lines.push(`### Downloads`);
182+
const lines: string[] = [];
186183
for (const entry of tabSnapshot.downloads) {
187184
if (entry.finished)
188185
lines.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
189186
else
190187
lines.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
191188
}
192-
lines.push('');
189+
response.updates.push({ category: 'downloads', content: lines.join('\n') });
193190
}
194191

195192
if (includeSnapshot === 'incremental' && tabSnapshot.ariaSnapshotDiff === '') {
196193
// When incremental snapshot is present, but empty, do not render page state altogether.
197-
return lines.join('\n');
194+
return;
198195
}
199196

200-
lines.push(`### Page state`);
197+
const lines: string[] = [];
201198
lines.push(`- Page URL: ${tabSnapshot.url}`);
202199
lines.push(`- Page Title: ${tabSnapshot.title}`);
203200

@@ -210,29 +207,22 @@ function renderTabSnapshot(tabSnapshot: TabSnapshot, includeSnapshot: 'none' | '
210207
lines.push(tabSnapshot.ariaSnapshot);
211208
lines.push('```');
212209
}
213-
214-
return lines.join('\n');
210+
response.states.page = lines.join('\n');
215211
}
216212

217213
function renderTabsMarkdown(tabs: Tab[], force: boolean = false): string[] {
218214
if (tabs.length === 1 && !force)
219215
return [];
220216

221-
if (!tabs.length) {
222-
return [
223-
'### Open tabs',
224-
'No open tabs. Use the "browser_navigate" tool to navigate to a page first.',
225-
'',
226-
];
227-
}
217+
if (!tabs.length)
218+
return ['No open tabs. Use the "browser_navigate" tool to navigate to a page first.'];
228219

229-
const lines: string[] = ['### Open tabs'];
220+
const lines: string[] = [];
230221
for (let i = 0; i < tabs.length; i++) {
231222
const tab = tabs[i];
232223
const current = tab.isCurrentTab() ? ' (current)' : '';
233224
lines.push(`- ${i}:${current} [${tab.lastTitle()}] (${tab.page.url()})`);
234225
}
235-
lines.push('');
236226
return lines;
237227
}
238228

@@ -242,6 +232,86 @@ function trim(text: string, maxLength: number) {
242232
return text.slice(0, maxLength) + '...';
243233
}
244234

235+
export class RenderedResponse {
236+
readonly states: Partial<Record<'page' | 'tabs' | 'modal', string>> = {};
237+
readonly updates: { category: 'console' | 'downloads', content: string }[] = [];
238+
readonly results: string[] = [];
239+
readonly code: string[] = [];
240+
241+
constructor(copy?: { states: Partial<Record<'page' | 'tabs' | 'modal', string>>, updates: { category: 'console' | 'downloads', content: string }[], results: string[], code: string[] }) {
242+
if (copy) {
243+
this.states = copy.states;
244+
this.updates = copy.updates;
245+
this.results = copy.results;
246+
this.code = copy.code;
247+
}
248+
}
249+
250+
asText(): string {
251+
const text: string[] = [];
252+
if (this.results.length)
253+
text.push(`### Result\n${this.results.join('\n')}\n`);
254+
if (this.code.length)
255+
text.push(`### Ran Playwright code\n${this.code.join('\n')}\n`);
256+
257+
for (const { category, content } of this.updates) {
258+
if (!content.trim())
259+
continue;
260+
261+
switch (category) {
262+
case 'console':
263+
text.push(`### New console messages\n${content}\n`);
264+
break;
265+
case 'downloads':
266+
text.push(`### Downloads\n${content}\n`);
267+
break;
268+
}
269+
}
270+
271+
for (const [category, value] of Object.entries(this.states)) {
272+
if (!value.trim())
273+
continue;
274+
275+
switch (category) {
276+
case 'page':
277+
text.push(`### Page state\n${value}\n`);
278+
break;
279+
case 'tabs':
280+
text.push(`### Open tabs\n${value}\n`);
281+
break;
282+
case 'modal':
283+
text.push(`### Modal state\n${value}\n`);
284+
break;
285+
}
286+
}
287+
return text.join('\n');
288+
}
289+
290+
asMeta() {
291+
const codeUpdate = this.code.length ? { category: 'code', content: this.code.join('\n') } : undefined;
292+
const resultUpdate = this.results.length ? { category: 'result', content: this.results.join('\n') } : undefined;
293+
const updates = [resultUpdate, codeUpdate, ...this.updates].filter(Boolean);
294+
return {
295+
'dev.lowire/history': updates,
296+
'dev.lowire/state': { ...this.states },
297+
};
298+
}
299+
300+
redact(secrets: Record<string, string>): RenderedResponse {
301+
const redactText = (text: string) => {
302+
for (const [secretName, secretValue] of Object.entries(secrets))
303+
text = text.replaceAll(secretValue, `<secret>${secretName}</secret>`);
304+
return text;
305+
};
306+
307+
const updates = this.updates.map(update => ({ ...update, content: redactText(update.content) }));
308+
const results = this.results.map(result => redactText(result));
309+
const code = this.code.map(code => redactText(code));
310+
const states = Object.fromEntries(Object.entries(this.states).map(([key, value]) => [key, redactText(value)]));
311+
return new RenderedResponse({ states, updates, results, code });
312+
}
313+
}
314+
245315
function parseSections(text: string): Map<string, string> {
246316
const sections = new Map<string, string>();
247317
const sectionHeaders = text.split(/^### /m).slice(1); // Remove empty first element
@@ -286,5 +356,6 @@ export function parseResponse(response: CallToolResult) {
286356
downloads,
287357
isError,
288358
attachments,
359+
_meta: response._meta,
289360
};
290361
}

packages/playwright/src/mcp/browser/tab.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,6 @@ export class Tab extends EventEmitter<TabEventsInterface> {
132132
this._modalStates = this._modalStates.filter(state => state !== modalState);
133133
}
134134

135-
modalStatesMarkdown(): string[] {
136-
return renderModalStates(this.context, this.modalStates());
137-
}
138-
139135
private _dialogShown(dialog: playwright.Dialog) {
140136
this.setModalState({
141137
type: 'dialog',
@@ -349,8 +345,8 @@ function pageErrorToConsoleMessage(errorOrValue: Error | any): ConsoleMessage {
349345
};
350346
}
351347

352-
export function renderModalStates(context: Context, modalStates: ModalState[]): string[] {
353-
const result: string[] = ['### Modal state'];
348+
export function renderModalStates(modalStates: ModalState[]): string[] {
349+
const result: string[] = [];
354350
if (modalStates.length === 0)
355351
result.push('- There is no modal state present');
356352
for (const state of modalStates)

0 commit comments

Comments
 (0)