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
10 changes: 1 addition & 9 deletions docs/src/getting-started-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ playwright-cli video-stop --filename=f # stop video recording

### WebMCP

Pages can register their own tools for agents through the experimental [WebMCP](https://webmachinelearning.github.io/webmcp/) API. When a page has them, the page status after a navigation reports how many, and the tools can be listed and called directly instead of driving the UI:
Pages can register their own tools for agents through the experimental [WebMCP](https://webmachinelearning.github.io/webmcp/) API. The tools a page registers are listed at the top of the page snapshot, and they can be called directly:

```bash
playwright-cli webmcp-list # list tools registered by the page
Expand All @@ -224,14 +224,6 @@ playwright-cli webmcp-call <name> [--params] # call one, passing a JSON obje

Tool names, descriptions, schemas and results are provided by the page, so treat them as untrusted input.

WebMCP is experimental and only available in Chromium and Firefox behind a browser flag, passed through the [configuration file](#configuration-file):

```json
{
"browser": { "launchOptions": { "args": ["--enable-features=WebMCP"] } }
}
```

## Sessions

The CLI keeps the browser profile in memory by default — cookies and storage state are preserved between calls within a session but lost when the browser closes. Use `--persistent` to save the profile to disk.
Expand Down
12 changes: 1 addition & 11 deletions docs/src/getting-started-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,20 +131,10 @@ Save and restore browser state including cookies and localStorage:

### WebMCP tools

Pages can register their own tools for agents through the experimental [WebMCP](https://webmachinelearning.github.io/webmcp/) API. When a page has them, the page status after a navigation reports how many, and `browser_webmcp_list` and `browser_webmcp_call` expose them:

- **List tools**: See the tools the page registers, with their input schemas and annotations.
- **Call a tool**: Invoke one by name, letting the page do the work instead of driving its UI.
Pages can register their own tools for agents through the experimental [WebMCP](https://webmachinelearning.github.io/webmcp/) API. The tools of the current tab are offered as MCP tools named `webmcp_<tool>`.

Tool names, descriptions, schemas and results are provided by the page, so treat them as untrusted input.

WebMCP is experimental and only available in Chromium and Firefox behind a browser flag, passed through the [configuration file](#configuration-file):

```json
{
"browser": { "launchOptions": { "args": ["--enable-features=WebMCP"] } }
}
```

## Configuration

Expand Down
41 changes: 29 additions & 12 deletions packages/playwright-core/src/tools/backend/browserBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export type BrowserBackendOptions = {
idleTimer?: IdleTimer;
};

export class BrowserBackend extends EventEmitter<{ disconnected: [] }> implements ServerBackend {
export class BrowserBackend extends EventEmitter<{ disconnected: [], dynamictoolschange: [] }> implements ServerBackend {
private _tools: Tool[];
private _context: Context | undefined;
private _sessionLog: SessionLog | undefined;
Expand Down Expand Up @@ -70,9 +70,14 @@ export class BrowserBackend extends EventEmitter<{ disconnected: [] }> implement
config: this._config,
sessionLog: this._sessionLog,
cwd: clientInfo.cwd,
onWebMCPToolsChanged: () => this.emit('dynamictoolschange'),
});
}

dynamicTools(): mcpServer.Tool[] {
return this._context?.currentWebMCPTools().map(tool => tool.schema) ?? [];
}

async dispose() {
if (this._disposed)
return;
Expand All @@ -88,25 +93,37 @@ export class BrowserBackend extends EventEmitter<{ disconnected: [] }> implement
content: [{ type: 'text' as const, text: json ? JSON.stringify({ isError: true, error: message }, null, 2) : `### Error\n${message}` }],
isError: true,
});
const tool = this._tools.find(tool => tool.schema.name === name)!;
if (!tool)
return formatError(`Tool "${name}" not found`);
const context = this._context!;

let parsedArguments: any;
try {
parsedArguments = tool.schema.inputSchema.parse(rawArguments);
} catch (error) {
if (error instanceof z.ZodError)
return formatError(`Invalid arguments for tool "${name}":\n${z.prettifyError(error)}`);
throw error;
let handle: (params: any, response: Response) => Promise<void>;
const tool = this._tools.find(tool => tool.schema.name === name);
if (tool) {
try {
parsedArguments = tool.schema.inputSchema.parse(rawArguments);
} catch (error) {
if (error instanceof z.ZodError)
return formatError(`Invalid arguments for tool "${name}":\n${z.prettifyError(error)}`);
throw error;
}
handle = (params, response) => tool.handle(context, params, response, signal);
} else {
const webmcpTool = context.currentWebMCPTools().find(tool => tool.schema.name === name);
if (!webmcpTool)
return formatError(`Tool "${name}" not found`);
// Page-registered WebMCP tools validate their own input, only strip the meta.
parsedArguments = { ...rawArguments };
delete parsedArguments._meta;
handle = webmcpTool.handle;
}

const cwd = rawArguments._meta?.cwd;
const raw = !!rawArguments._meta?.raw;
const context = this._context!;
const response = new Response(context, name, parsedArguments, { relativeTo: cwd, raw, json });
context.setRunningTool(name);
let responseObject: mcpServer.CallToolResult & { isClose?: boolean };
try {
await tool.handle(context, parsedArguments, response, signal);
await handle(parsedArguments, response);
for (const reason of context.drainPendingUnhandledRejections())
response.addError(formatRejectionReason(reason));
responseObject = await response.serialize();
Expand Down
41 changes: 34 additions & 7 deletions packages/playwright-core/src/tools/backend/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type * as playwrightTypes from '../../..';
import type { SessionLog } from './sessionLog';
import type { Disposable } from '@isomorphic/disposable';
import type { ToolCapability } from './tool';
import type { WebMCPToolDefinition } from './webmcp';

const testDebug = debug('pw:mcp:test');

Expand All @@ -58,6 +59,7 @@ export type ContextConfig = {
boxes?: boolean;
};
testIdAttribute?: string;
webmcp?: boolean;
timeouts?: {
action?: number;
navigation?: number;
Expand All @@ -75,6 +77,7 @@ type ContextOptions = {
config: ContextConfig;
sessionLog?: SessionLog;
cwd: string;
onWebMCPToolsChanged?: () => void;
};

export type RouteEntry = {
Expand Down Expand Up @@ -113,6 +116,9 @@ export class Context {
private _recordedActions: string[] | undefined;
private _disposables: Disposable[] = [];

private _webmcpTools: WebMCPToolDefinition[] = [];
private _webmcpToolsSignature = '';

private _runningToolName: string | undefined;
private _pendingUnhandledRejections: unknown[] = [];
private _unhandledRejectionListeners = new Set<(reason: unknown) => void>();
Expand All @@ -138,7 +144,7 @@ export class Context {
for (const tab of this._tabs)
await tab.dispose();
this._tabs.length = 0;
this._currentTab = undefined;
this._setCurrentTab(undefined);
await this.stopVideoRecording();
}

Expand Down Expand Up @@ -174,17 +180,17 @@ export class Context {
async newTab(): Promise<Tab> {
const browserContext = await this.ensureBrowserContext();
const page = await browserContext.newPage();
this._currentTab = this._tabs.find(t => t.page === page)!;
return this._currentTab;
this._setCurrentTab(this._tabs.find(t => t.page === page)!);
return this._currentTab!;
}

async selectTab(index: number) {
const tab = this._tabs[index];
if (!tab)
throw new Error(`Tab ${index} not found`);
await tab.page.bringToFront();
this._currentTab = tab;
await tab.updateWebMCPTools();
this._setCurrentTab(tab);
return tab;
}

Expand All @@ -193,7 +199,7 @@ export class Context {
const crashed = this._currentTab?.crashed;
if (crashed) {
await this._currentTab!.page.close().catch(() => {});
this._currentTab = undefined;
this._setCurrentTab(undefined);
}
if (!this._currentTab)
await this.newTab();
Expand Down Expand Up @@ -299,7 +305,7 @@ export class Context {
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
this._tabs.push(tab);
if (!this._currentTab)
this._currentTab = tab;
this._setCurrentTab(tab);
this._startPageVideo(page).catch(() => {});
}

Expand All @@ -310,7 +316,28 @@ export class Context {
this._tabs.splice(index, 1);

if (this._currentTab === tab)
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
this._setCurrentTab(this._tabs[Math.min(index, this._tabs.length - 1)]);
}

currentWebMCPTools(): WebMCPToolDefinition[] {
return this._webmcpTools;
}

maybeNotifyWebMCPToolsChanged() {
const tools = this._currentTab?.webmcpTools()?.tools.map(tool => tool.mcpTool) ?? [];
const signature = JSON.stringify(tools.map(tool => tool.schema));
if (signature === this._webmcpToolsSignature)
return;
this._webmcpToolsSignature = signature;
this._webmcpTools = tools;
this.options.onWebMCPToolsChanged?.();
}

private _setCurrentTab(tab: Tab | undefined) {
if (this._currentTab === tab)
return;
this._currentTab = tab;
this.maybeNotifyWebMCPToolsChanged();
}

routes(): RouteEntry[] {
Expand Down
38 changes: 24 additions & 14 deletions packages/playwright-core/src/tools/backend/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import path from 'path';
import debug from 'debug';
import { actionInContext, codeframeForLanguage, renderCode, substituteSecrets } from './codegen';
import { renderModalStates } from './tab';
import { renderWebMCPToolsYaml, webmcpToolsJSON } from './webmcp';

import { outputDir as resolveOutputDir } from './context';

Expand All @@ -42,6 +43,7 @@ type SectionContent = string[] | { json: unknown };

type Section = {
title: string;
key?: string; // Property name in the json output, defaults to the lower-cased title.
content: SectionContent;
isError?: boolean;
codeframe?: 'yaml' | 'js' | 'json' | 'python' | 'java' | 'csharp';
Expand Down Expand Up @@ -170,7 +172,7 @@ export class Response {
async serialize(): Promise<CallToolResult> {
const allSections = await this._build();
await this._enforceOutputBudget();
const rawSections = ['Error', 'Result', 'Snapshot'] as const;
const rawSections = ['Error', 'Result', 'WebMCP tools', 'Snapshot'] as const;
const sections = this._raw ? allSections.filter(section => rawSections.includes(section.title as typeof rawSections[number])) : allSections;

const isError = sections.some(section => section.isError);
Expand All @@ -180,7 +182,7 @@ export class Response {
if (isError)
payload.isError = true;
for (const section of sections) {
const key = section.title.toLowerCase();
const key = section.key ?? section.title.toLowerCase();
if (!Array.isArray(section.content)) {
if (section.content.json !== undefined)
payload[key] = section.content.json;
Expand Down Expand Up @@ -264,8 +266,8 @@ export class Response {

private async _build(): Promise<Section[]> {
const sections: Section[] = [];
const addSection = (title: string, content: SectionContent, codeframe?: Section['codeframe']) => {
const section = { title, content, isError: title === 'Error', codeframe };
const addSection = (title: string, content: SectionContent, codeframe?: Section['codeframe'], key?: string) => {
const section = { title, key, content, isError: title === 'Error', codeframe };
sections.push(section);
return content;
};
Expand Down Expand Up @@ -300,17 +302,25 @@ export class Response {
if (tabSnapshot?.modalStates.length)
addSection('Modal state', renderModalStates(this._context.config, tabSnapshot.modalStates));

// Handle tab snapshot
// Handle tab snapshot and WebMCP tools.
if (tabSnapshot && this._includeSnapshot !== 'none') {
if (snapshotToFile) {
const suggestedFilename = this._includeSnapshotFileName === '<auto>' ? undefined : this._includeSnapshotFileName;
const resolvedFile = await this.resolveClientOutputFile({ prefix: 'page', ext: 'yml', suggestedFilename }, 'Snapshot');
await this._writeFile(resolvedFile, tabSnapshot.ariaSnapshot);
addSection('Snapshot', [resolvedFile.printableLink]);
} else if (tabSnapshot.ariaSnapshotJSON !== undefined) {
const webmcpListing = this._context.currentTab()?.webmcpTools();
if (!snapshotToFile && tabSnapshot.ariaSnapshotJSON !== undefined) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dees it make sense to pre-bake it in tabSnapshot next to ariaSnapshot?

if (webmcpListing?.tools.length)
addSection('WebMCP tools', { json: webmcpToolsJSON(webmcpListing) }, 'json', 'webmcpTools');
addSection('Snapshot', { json: tabSnapshot.ariaSnapshotJSON }, 'json');
} else {
addSection('Snapshot', [tabSnapshot.ariaSnapshot], 'yaml');
let ariaSnapshot = tabSnapshot.ariaSnapshot;
if (webmcpListing?.tools.length)
ariaSnapshot = renderWebMCPToolsYaml(webmcpListing).join('\n') + '\n\n' + ariaSnapshot;
if (snapshotToFile) {
const suggestedFilename = this._includeSnapshotFileName === '<auto>' ? undefined : this._includeSnapshotFileName;
const resolvedFile = await this.resolveClientOutputFile({ prefix: 'page', ext: 'yml', suggestedFilename }, 'Snapshot');
await this._writeFile(resolvedFile, ariaSnapshot);
addSection('Snapshot', [resolvedFile.printableLink]);
} else {
addSection('Snapshot', [ariaSnapshot], 'yaml');
}
}
}

Expand Down Expand Up @@ -349,10 +359,10 @@ export function renderTabMarkdown(tab: TabHeader): string[] {
const status = tab.mainDocumentStatus;
if (status && (status.status < 200 || status.status >= 300))
lines.push(`- HTTP status: ${status.status}${status.statusText ? ' ' + status.statusText : ''}`);
if (tab.console.errors || tab.console.warnings)
lines.push(`- Console: ${tab.console.errors} errors, ${tab.console.warnings} warnings`);
if (tab.webmcpToolCount)
lines.push(`- ${tab.webmcpToolCount} webmcp tool${tab.webmcpToolCount === 1 ? '' : 's'} available on the page`);
if (tab.console.errors || tab.console.warnings)
lines.push(`- Console: ${tab.console.errors} errors, ${tab.console.warnings} warnings`);
return lines;
}

Expand Down
8 changes: 6 additions & 2 deletions packages/playwright-core/src/tools/backend/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,13 +479,17 @@ export class Tab extends EventEmitter<TabEventsInterface> {
}

async updateWebMCPTools(): Promise<void> {
if (this.context.config.webmcp === false)
return;
if (this._javaScriptBlocked())
return;
const listing = await listWebMCPTools(this);
// A dialog that opened while probing produces the same empty listing as a page
// with no tools, so keep what we had rather than clobbering the cache with it.
if (!this._javaScriptBlocked())
this._webmcpTools = listing;
if (this._javaScriptBlocked())
return;
this._webmcpTools = listing;
this.context.maybeNotifyWebMCPToolsChanged();
}

private _javaScriptBlocked(): boolean {
Expand Down
Loading
Loading