2025-03-21 10:58:58 -07:00
|
|
|
/**
|
|
|
|
* Copyright (c) Microsoft Corporation.
|
|
|
|
*
|
|
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
* you may not use this file except in compliance with the License.
|
|
|
|
* You may obtain a copy of the License at
|
|
|
|
*
|
|
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
*
|
|
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
* See the License for the specific language governing permissions and
|
|
|
|
* limitations under the License.
|
|
|
|
*/
|
|
|
|
|
2025-04-28 16:14:16 -07:00
|
|
|
import fs from 'fs';
|
|
|
|
import path from 'path';
|
|
|
|
import os from 'os';
|
2025-04-28 13:44:24 -07:00
|
|
|
|
2025-03-21 10:58:58 -07:00
|
|
|
import * as playwright from 'playwright';
|
|
|
|
|
2025-04-02 11:42:39 -07:00
|
|
|
import { waitForCompletion } from './tools/utils';
|
2025-04-17 14:03:13 -07:00
|
|
|
import { ManualPromise } from './manualPromise';
|
2025-04-28 16:14:16 -07:00
|
|
|
import { toBrowserOptions } from './config';
|
|
|
|
import { Tab } from './tab';
|
2025-04-16 15:21:45 -07:00
|
|
|
|
2025-04-16 19:36:48 -07:00
|
|
|
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
|
2025-04-17 14:03:13 -07:00
|
|
|
import type { ModalState, Tool, ToolActionResult } from './tools/tool';
|
2025-04-28 16:14:16 -07:00
|
|
|
import type { Config } from '../config';
|
|
|
|
import type { BrowserOptions } from './config';
|
2025-04-02 11:42:39 -07:00
|
|
|
|
2025-04-17 14:03:13 -07:00
|
|
|
type PendingAction = {
|
|
|
|
dialogShown: ManualPromise<void>;
|
|
|
|
};
|
|
|
|
|
2025-03-21 10:58:58 -07:00
|
|
|
export class Context {
|
2025-04-16 15:21:45 -07:00
|
|
|
readonly tools: Tool[];
|
2025-04-28 16:14:16 -07:00
|
|
|
readonly config: Config;
|
2025-03-25 13:05:28 -07:00
|
|
|
private _browser: playwright.Browser | undefined;
|
2025-04-03 10:30:05 -07:00
|
|
|
private _browserContext: playwright.BrowserContext | undefined;
|
2025-04-28 13:44:24 -07:00
|
|
|
private _createBrowserContextPromise: Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> | undefined;
|
2025-04-03 19:24:17 -07:00
|
|
|
private _tabs: Tab[] = [];
|
|
|
|
private _currentTab: Tab | undefined;
|
2025-04-16 15:21:45 -07:00
|
|
|
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
2025-04-17 14:03:13 -07:00
|
|
|
private _pendingAction: PendingAction | undefined;
|
2025-03-21 10:58:58 -07:00
|
|
|
|
2025-04-28 16:14:16 -07:00
|
|
|
constructor(tools: Tool[], config: Config) {
|
2025-04-16 15:21:45 -07:00
|
|
|
this.tools = tools;
|
2025-04-28 16:14:16 -07:00
|
|
|
this.config = config;
|
2025-03-21 10:58:58 -07:00
|
|
|
}
|
|
|
|
|
2025-04-16 15:21:45 -07:00
|
|
|
modalStates(): ModalState[] {
|
|
|
|
return this._modalStates;
|
|
|
|
}
|
|
|
|
|
|
|
|
setModalState(modalState: ModalState, inTab: Tab) {
|
|
|
|
this._modalStates.push({ ...modalState, tab: inTab });
|
|
|
|
}
|
|
|
|
|
|
|
|
clearModalState(modalState: ModalState) {
|
|
|
|
this._modalStates = this._modalStates.filter(state => state !== modalState);
|
|
|
|
}
|
|
|
|
|
|
|
|
modalStatesMarkdown(): string[] {
|
|
|
|
const result: string[] = ['### Modal state'];
|
|
|
|
for (const state of this._modalStates) {
|
|
|
|
const tool = this.tools.find(tool => tool.clearsModalState === state.type);
|
|
|
|
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
|
|
|
|
}
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
2025-04-03 19:24:17 -07:00
|
|
|
tabs(): Tab[] {
|
|
|
|
return this._tabs;
|
|
|
|
}
|
|
|
|
|
2025-04-16 19:36:48 -07:00
|
|
|
currentTabOrDie(): Tab {
|
2025-04-03 19:24:17 -07:00
|
|
|
if (!this._currentTab)
|
2025-04-14 16:39:58 -07:00
|
|
|
throw new Error('No current snapshot available. Capture a snapshot of navigate to a new location first.');
|
2025-04-03 19:24:17 -07:00
|
|
|
return this._currentTab;
|
|
|
|
}
|
|
|
|
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
|
|
|
async selectTab(index: number) {
|
|
|
|
this._currentTab = this._tabs[index - 1];
|
|
|
|
await this._currentTab.page.bringToFront();
|
|
|
|
}
|
|
|
|
|
|
|
|
async ensureTab(): Promise<Tab> {
|
|
|
|
const context = await this._ensureBrowserContext();
|
2025-04-03 22:39:55 -07:00
|
|
|
if (!this._currentTab)
|
|
|
|
await context.newPage();
|
2025-04-03 19:24:17 -07:00
|
|
|
return this._currentTab!;
|
|
|
|
}
|
|
|
|
|
2025-04-16 19:36:48 -07:00
|
|
|
async listTabsMarkdown(): Promise<string> {
|
2025-04-03 19:24:17 -07:00
|
|
|
if (!this._tabs.length)
|
2025-04-15 12:54:45 -07:00
|
|
|
return '### No tabs open';
|
|
|
|
const lines: string[] = ['### Open tabs'];
|
2025-04-03 19:24:17 -07:00
|
|
|
for (let i = 0; i < this._tabs.length; i++) {
|
|
|
|
const tab = this._tabs[i];
|
|
|
|
const title = await tab.page.title();
|
|
|
|
const url = tab.page.url();
|
|
|
|
const current = tab === this._currentTab ? ' (current)' : '';
|
|
|
|
lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
|
|
|
|
}
|
|
|
|
return lines.join('\n');
|
|
|
|
}
|
|
|
|
|
|
|
|
async closeTab(index: number | undefined) {
|
2025-04-16 19:36:48 -07:00
|
|
|
const tab = index === undefined ? this._currentTab : this._tabs[index - 1];
|
|
|
|
await tab?.page.close();
|
|
|
|
return await this.listTabsMarkdown();
|
|
|
|
}
|
|
|
|
|
|
|
|
async run(tool: Tool, params: Record<string, unknown> | undefined) {
|
|
|
|
// Tab management is done outside of the action() call.
|
2025-04-22 13:24:38 +02:00
|
|
|
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params));
|
2025-04-17 00:58:02 -07:00
|
|
|
const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult;
|
2025-04-17 14:03:13 -07:00
|
|
|
const racingAction = action ? () => this._raceAgainstModalDialogs(action) : undefined;
|
2025-04-17 00:58:02 -07:00
|
|
|
|
|
|
|
if (resultOverride)
|
|
|
|
return resultOverride;
|
2025-04-16 19:36:48 -07:00
|
|
|
|
|
|
|
if (!this._currentTab) {
|
|
|
|
return {
|
|
|
|
content: [{
|
|
|
|
type: 'text',
|
|
|
|
text: 'No open pages available. Use the "browser_navigate" tool to navigate to a page first.',
|
|
|
|
}],
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
const tab = this.currentTabOrDie();
|
|
|
|
// TODO: race against modal dialogs to resolve clicks.
|
2025-04-17 00:58:02 -07:00
|
|
|
let actionResult: { content?: (ImageContent | TextContent)[] } | undefined;
|
2025-04-16 19:36:48 -07:00
|
|
|
try {
|
|
|
|
if (waitForNetwork)
|
2025-04-17 14:03:13 -07:00
|
|
|
actionResult = await waitForCompletion(this, tab.page, async () => racingAction?.()) ?? undefined;
|
2025-04-16 19:36:48 -07:00
|
|
|
else
|
2025-04-17 14:03:13 -07:00
|
|
|
actionResult = await racingAction?.() ?? undefined;
|
2025-04-16 19:36:48 -07:00
|
|
|
} finally {
|
2025-04-17 14:03:13 -07:00
|
|
|
if (captureSnapshot && !this._javaScriptBlocked())
|
2025-04-16 19:36:48 -07:00
|
|
|
await tab.captureSnapshot();
|
|
|
|
}
|
|
|
|
|
|
|
|
const result: string[] = [];
|
|
|
|
result.push(`- Ran Playwright code:
|
|
|
|
\`\`\`js
|
|
|
|
${code.join('\n')}
|
|
|
|
\`\`\`
|
|
|
|
`);
|
|
|
|
|
|
|
|
if (this.modalStates().length) {
|
|
|
|
result.push(...this.modalStatesMarkdown());
|
|
|
|
return {
|
|
|
|
content: [{
|
|
|
|
type: 'text',
|
|
|
|
text: result.join('\n'),
|
|
|
|
}],
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.tabs().length > 1)
|
|
|
|
result.push(await this.listTabsMarkdown(), '');
|
|
|
|
|
2025-04-17 00:58:02 -07:00
|
|
|
if (this.tabs().length > 1)
|
|
|
|
result.push('### Current tab');
|
|
|
|
|
|
|
|
result.push(
|
|
|
|
`- Page URL: ${tab.page.url()}`,
|
|
|
|
`- Page Title: ${await tab.page.title()}`
|
|
|
|
);
|
|
|
|
|
|
|
|
if (captureSnapshot && tab.hasSnapshot())
|
2025-04-16 19:36:48 -07:00
|
|
|
result.push(tab.snapshotOrDie().text());
|
|
|
|
|
|
|
|
const content = actionResult?.content ?? [];
|
|
|
|
|
|
|
|
return {
|
|
|
|
content: [
|
|
|
|
...content,
|
|
|
|
{
|
|
|
|
type: 'text',
|
|
|
|
text: result.join('\n'),
|
|
|
|
}
|
|
|
|
],
|
|
|
|
};
|
2025-03-21 10:58:58 -07:00
|
|
|
}
|
|
|
|
|
2025-04-17 14:03:13 -07:00
|
|
|
async waitForTimeout(time: number) {
|
|
|
|
if (this._currentTab && !this._javaScriptBlocked())
|
2025-04-17 14:25:27 -07:00
|
|
|
await this._currentTab.page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
2025-04-17 14:03:13 -07:00
|
|
|
else
|
|
|
|
await new Promise(f => setTimeout(f, time));
|
|
|
|
}
|
|
|
|
|
|
|
|
private async _raceAgainstModalDialogs(action: () => Promise<ToolActionResult>): Promise<ToolActionResult> {
|
|
|
|
this._pendingAction = {
|
|
|
|
dialogShown: new ManualPromise(),
|
|
|
|
};
|
|
|
|
|
|
|
|
let result: ToolActionResult | undefined;
|
|
|
|
try {
|
|
|
|
await Promise.race([
|
|
|
|
action().then(r => result = r),
|
|
|
|
this._pendingAction.dialogShown,
|
|
|
|
]);
|
|
|
|
} finally {
|
|
|
|
this._pendingAction = undefined;
|
|
|
|
}
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
|
|
|
private _javaScriptBlocked(): boolean {
|
|
|
|
return this._modalStates.some(state => state.type === 'dialog');
|
|
|
|
}
|
|
|
|
|
|
|
|
dialogShown(tab: Tab, dialog: playwright.Dialog) {
|
|
|
|
this.setModalState({
|
|
|
|
type: 'dialog',
|
|
|
|
description: `"${dialog.type()}" dialog with message "${dialog.message()}"`,
|
|
|
|
dialog,
|
|
|
|
}, tab);
|
|
|
|
this._pendingAction?.dialogShown.resolve();
|
|
|
|
}
|
|
|
|
|
2025-04-03 10:30:05 -07:00
|
|
|
private _onPageCreated(page: playwright.Page) {
|
2025-04-03 19:24:17 -07:00
|
|
|
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
|
|
|
|
this._tabs.push(tab);
|
|
|
|
if (!this._currentTab)
|
|
|
|
this._currentTab = tab;
|
2025-04-03 10:30:05 -07:00
|
|
|
}
|
2025-03-26 15:02:45 -07:00
|
|
|
|
2025-04-03 19:24:17 -07:00
|
|
|
private _onPageClosed(tab: Tab) {
|
2025-04-16 15:21:45 -07:00
|
|
|
this._modalStates = this._modalStates.filter(state => state.tab !== tab);
|
2025-04-03 22:39:55 -07:00
|
|
|
const index = this._tabs.indexOf(tab);
|
|
|
|
if (index === -1)
|
|
|
|
return;
|
|
|
|
this._tabs.splice(index, 1);
|
|
|
|
|
2025-04-03 19:24:17 -07:00
|
|
|
if (this._currentTab === tab)
|
2025-04-03 22:39:55 -07:00
|
|
|
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
|
2025-04-15 18:01:59 -07:00
|
|
|
if (this._browserContext && !this._tabs.length)
|
|
|
|
void this.close();
|
2025-03-25 13:05:28 -07:00
|
|
|
}
|
|
|
|
|
2025-04-03 10:30:05 -07:00
|
|
|
async close() {
|
|
|
|
if (!this._browserContext)
|
|
|
|
return;
|
2025-04-15 18:01:59 -07:00
|
|
|
const browserContext = this._browserContext;
|
|
|
|
const browser = this._browser;
|
2025-04-28 13:44:24 -07:00
|
|
|
this._createBrowserContextPromise = undefined;
|
2025-04-15 18:01:59 -07:00
|
|
|
this._browserContext = undefined;
|
|
|
|
this._browser = undefined;
|
|
|
|
|
|
|
|
await browserContext?.close().then(async () => {
|
|
|
|
await browser?.close();
|
|
|
|
}).catch(() => {});
|
2025-04-03 10:30:05 -07:00
|
|
|
}
|
|
|
|
|
2025-04-03 19:24:17 -07:00
|
|
|
private async _ensureBrowserContext() {
|
|
|
|
if (!this._browserContext) {
|
|
|
|
const context = await this._createBrowserContext();
|
|
|
|
this._browser = context.browser;
|
|
|
|
this._browserContext = context.browserContext;
|
2025-04-03 22:39:55 -07:00
|
|
|
for (const page of this._browserContext.pages())
|
|
|
|
this._onPageCreated(page);
|
2025-04-03 19:24:17 -07:00
|
|
|
this._browserContext.on('page', page => this._onPageCreated(page));
|
|
|
|
}
|
|
|
|
return this._browserContext;
|
|
|
|
}
|
|
|
|
|
2025-04-03 10:30:05 -07:00
|
|
|
private async _createBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
|
2025-04-28 13:44:24 -07:00
|
|
|
if (!this._createBrowserContextPromise)
|
|
|
|
this._createBrowserContextPromise = this._innerCreateBrowserContext();
|
|
|
|
return this._createBrowserContextPromise;
|
|
|
|
}
|
|
|
|
|
|
|
|
private async _innerCreateBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
|
2025-04-28 16:14:16 -07:00
|
|
|
const browserOptions = await toBrowserOptions(this.config);
|
|
|
|
|
|
|
|
if (this.config.browser?.remoteEndpoint) {
|
|
|
|
const url = new URL(this.config.browser?.remoteEndpoint);
|
|
|
|
if (browserOptions.browserName)
|
|
|
|
url.searchParams.set('browser', browserOptions.browserName);
|
|
|
|
if (browserOptions.launchOptions)
|
|
|
|
url.searchParams.set('launch-options', JSON.stringify(browserOptions.launchOptions));
|
|
|
|
const browser = await playwright[browserOptions.browserName ?? 'chromium'].connect(String(url));
|
2025-04-03 10:30:05 -07:00
|
|
|
const browserContext = await browser.newContext();
|
|
|
|
return { browser, browserContext };
|
|
|
|
}
|
|
|
|
|
2025-04-28 16:14:16 -07:00
|
|
|
if (this.config.browser?.cdpEndpoint) {
|
|
|
|
const browser = await playwright.chromium.connectOverCDP(this.config.browser?.cdpEndpoint);
|
2025-04-03 10:30:05 -07:00
|
|
|
const browserContext = browser.contexts()[0];
|
|
|
|
return { browser, browserContext };
|
|
|
|
}
|
|
|
|
|
2025-04-28 16:14:16 -07:00
|
|
|
const browserContext = await launchPersistentContext(this.config.browser?.userDataDir, browserOptions);
|
2025-04-03 10:30:05 -07:00
|
|
|
return { browserContext };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-04-28 16:14:16 -07:00
|
|
|
async function createUserDataDir(browserName: 'chromium' | 'firefox' | 'webkit') {
|
|
|
|
let cacheDirectory: string;
|
|
|
|
if (process.platform === 'linux')
|
|
|
|
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
|
|
else if (process.platform === 'darwin')
|
|
|
|
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
|
|
|
|
else if (process.platform === 'win32')
|
|
|
|
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
|
|
else
|
|
|
|
throw new Error('Unsupported platform: ' + process.platform);
|
|
|
|
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserName}-profile`);
|
|
|
|
await fs.promises.mkdir(result, { recursive: true });
|
|
|
|
return result;
|
2025-04-02 11:42:39 -07:00
|
|
|
}
|
|
|
|
|
2025-04-28 16:14:16 -07:00
|
|
|
async function launchPersistentContext(userDataDir: string | undefined, browserOptions: BrowserOptions): Promise<playwright.BrowserContext> {
|
|
|
|
userDataDir = userDataDir ?? await createUserDataDir(browserOptions.browserName);
|
2025-04-02 11:42:39 -07:00
|
|
|
|
2025-04-28 16:14:16 -07:00
|
|
|
try {
|
|
|
|
const browserType = browserOptions.browserName ? playwright[browserOptions.browserName] : playwright.chromium;
|
|
|
|
return await browserType.launchPersistentContext(userDataDir, browserOptions.launchOptions);
|
|
|
|
} catch (error: any) {
|
|
|
|
if (error.message.includes('Executable doesn\'t exist'))
|
|
|
|
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
|
|
|
|
throw error;
|
2025-03-27 20:22:44 +01:00
|
|
|
}
|
2025-03-21 10:58:58 -07:00
|
|
|
}
|
2025-04-15 19:55:20 +02:00
|
|
|
|
|
|
|
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
|
|
|
return (locator as any)._generateLocatorString();
|
|
|
|
}
|