mirror of
https://github.com/microsoft/playwright-mcp.git
synced 2025-07-26 16:42:27 +08:00
chore: split context.ts into files (#284)
This commit is contained in:
parent
26779ceb20
commit
6e76d5e550
5
config.d.ts
vendored
5
config.d.ts
vendored
@ -45,6 +45,11 @@ export type Config = {
|
|||||||
* Chrome DevTools Protocol endpoint to connect to an existing browser instance in case of Chromium family browsers.
|
* Chrome DevTools Protocol endpoint to connect to an existing browser instance in case of Chromium family browsers.
|
||||||
*/
|
*/
|
||||||
cdpEndpoint?: string;
|
cdpEndpoint?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remote endpoint to connect to an existing Playwright server.
|
||||||
|
*/
|
||||||
|
remoteEndpoint?: string;
|
||||||
},
|
},
|
||||||
|
|
||||||
server?: {
|
server?: {
|
||||||
|
86
src/config.ts
Normal file
86
src/config.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import net from 'net';
|
||||||
|
import os from 'os';
|
||||||
|
|
||||||
|
import type { Config } from '../config';
|
||||||
|
import type { LaunchOptions, BrowserContextOptions } from 'playwright';
|
||||||
|
|
||||||
|
export type BrowserOptions = {
|
||||||
|
browserName: 'chromium' | 'firefox' | 'webkit';
|
||||||
|
launchOptions: LaunchOptions;
|
||||||
|
contextOptions: BrowserContextOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function toBrowserOptions(config: Config): Promise<BrowserOptions> {
|
||||||
|
let browserName: 'chromium' | 'firefox' | 'webkit';
|
||||||
|
let channel: string | undefined;
|
||||||
|
switch (config.browser?.type) {
|
||||||
|
case 'chrome':
|
||||||
|
case 'chrome-beta':
|
||||||
|
case 'chrome-canary':
|
||||||
|
case 'chrome-dev':
|
||||||
|
case 'chromium':
|
||||||
|
case 'msedge':
|
||||||
|
case 'msedge-beta':
|
||||||
|
case 'msedge-canary':
|
||||||
|
case 'msedge-dev':
|
||||||
|
browserName = 'chromium';
|
||||||
|
channel = config.browser.type;
|
||||||
|
break;
|
||||||
|
case 'firefox':
|
||||||
|
browserName = 'firefox';
|
||||||
|
break;
|
||||||
|
case 'webkit':
|
||||||
|
browserName = 'webkit';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
browserName = 'chromium';
|
||||||
|
channel = 'chrome';
|
||||||
|
}
|
||||||
|
|
||||||
|
const launchOptions: LaunchOptions = {
|
||||||
|
headless: !!(config.browser?.headless ?? (os.platform() === 'linux' && !process.env.DISPLAY)),
|
||||||
|
channel,
|
||||||
|
executablePath: config.browser?.executablePath,
|
||||||
|
...{ assistantMode: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const contextOptions: BrowserContextOptions = {
|
||||||
|
viewport: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (browserName === 'chromium')
|
||||||
|
(launchOptions as any).webSocketPort = await findFreePort();
|
||||||
|
|
||||||
|
return {
|
||||||
|
browserName,
|
||||||
|
launchOptions,
|
||||||
|
contextOptions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findFreePort() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.listen(0, () => {
|
||||||
|
const { port } = server.address() as net.AddressInfo;
|
||||||
|
server.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
server.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
238
src/context.ts
238
src/context.ts
@ -14,26 +14,21 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import net from 'net';
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
import yaml from 'yaml';
|
|
||||||
|
|
||||||
import { waitForCompletion } from './tools/utils';
|
import { waitForCompletion } from './tools/utils';
|
||||||
import { ManualPromise } from './manualPromise';
|
import { ManualPromise } from './manualPromise';
|
||||||
|
import { toBrowserOptions } from './config';
|
||||||
|
import { Tab } from './tab';
|
||||||
|
|
||||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
|
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
|
||||||
import type { ModalState, Tool, ToolActionResult } from './tools/tool';
|
import type { ModalState, Tool, ToolActionResult } from './tools/tool';
|
||||||
|
import type { Config } from '../config';
|
||||||
export type ContextOptions = {
|
import type { BrowserOptions } from './config';
|
||||||
browserName?: 'chromium' | 'firefox' | 'webkit';
|
|
||||||
userDataDir: string;
|
|
||||||
launchOptions?: playwright.LaunchOptions & playwright.BrowserContextOptions;
|
|
||||||
cdpEndpoint?: string;
|
|
||||||
remoteEndpoint?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PageOrFrameLocator = playwright.Page | playwright.FrameLocator;
|
|
||||||
|
|
||||||
type PendingAction = {
|
type PendingAction = {
|
||||||
dialogShown: ManualPromise<void>;
|
dialogShown: ManualPromise<void>;
|
||||||
@ -41,7 +36,7 @@ type PendingAction = {
|
|||||||
|
|
||||||
export class Context {
|
export class Context {
|
||||||
readonly tools: Tool[];
|
readonly tools: Tool[];
|
||||||
readonly options: ContextOptions;
|
readonly config: Config;
|
||||||
private _browser: playwright.Browser | undefined;
|
private _browser: playwright.Browser | undefined;
|
||||||
private _browserContext: playwright.BrowserContext | undefined;
|
private _browserContext: playwright.BrowserContext | undefined;
|
||||||
private _createBrowserContextPromise: Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> | undefined;
|
private _createBrowserContextPromise: Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> | undefined;
|
||||||
@ -50,9 +45,9 @@ export class Context {
|
|||||||
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
||||||
private _pendingAction: PendingAction | undefined;
|
private _pendingAction: PendingAction | undefined;
|
||||||
|
|
||||||
constructor(tools: Tool[], options: ContextOptions) {
|
constructor(tools: Tool[], config: Config) {
|
||||||
this.tools = tools;
|
this.tools = tools;
|
||||||
this.options = options;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
modalStates(): ModalState[] {
|
modalStates(): ModalState[] {
|
||||||
@ -290,205 +285,58 @@ ${code.join('\n')}
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _innerCreateBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
|
private async _innerCreateBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
|
||||||
if (this.options.browserName === 'chromium')
|
const browserOptions = await toBrowserOptions(this.config);
|
||||||
(this.options.launchOptions as any).webSocketPort = await findFreePort();
|
|
||||||
|
|
||||||
if (this.options.remoteEndpoint) {
|
if (this.config.browser?.remoteEndpoint) {
|
||||||
const url = new URL(this.options.remoteEndpoint);
|
const url = new URL(this.config.browser?.remoteEndpoint);
|
||||||
if (this.options.browserName)
|
if (browserOptions.browserName)
|
||||||
url.searchParams.set('browser', this.options.browserName);
|
url.searchParams.set('browser', browserOptions.browserName);
|
||||||
if (this.options.launchOptions)
|
if (browserOptions.launchOptions)
|
||||||
url.searchParams.set('launch-options', JSON.stringify(this.options.launchOptions));
|
url.searchParams.set('launch-options', JSON.stringify(browserOptions.launchOptions));
|
||||||
const browser = await playwright[this.options.browserName ?? 'chromium'].connect(String(url));
|
const browser = await playwright[browserOptions.browserName ?? 'chromium'].connect(String(url));
|
||||||
const browserContext = await browser.newContext();
|
const browserContext = await browser.newContext();
|
||||||
return { browser, browserContext };
|
return { browser, browserContext };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.options.cdpEndpoint) {
|
if (this.config.browser?.cdpEndpoint) {
|
||||||
const browser = await playwright.chromium.connectOverCDP(this.options.cdpEndpoint);
|
const browser = await playwright.chromium.connectOverCDP(this.config.browser?.cdpEndpoint);
|
||||||
const browserContext = browser.contexts()[0];
|
const browserContext = browser.contexts()[0];
|
||||||
return { browser, browserContext };
|
return { browser, browserContext };
|
||||||
}
|
}
|
||||||
|
|
||||||
const browserContext = await this._launchPersistentContext();
|
const browserContext = await launchPersistentContext(this.config.browser?.userDataDir, browserOptions);
|
||||||
return { browserContext };
|
return { browserContext };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _launchPersistentContext(): Promise<playwright.BrowserContext> {
|
|
||||||
try {
|
|
||||||
const browserType = this.options.browserName ? playwright[this.options.browserName] : playwright.chromium;
|
|
||||||
return await browserType.launchPersistentContext(this.options.userDataDir, this.options.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Tab {
|
async function createUserDataDir(browserName: 'chromium' | 'firefox' | 'webkit') {
|
||||||
readonly context: Context;
|
let cacheDirectory: string;
|
||||||
readonly page: playwright.Page;
|
if (process.platform === 'linux')
|
||||||
private _console: playwright.ConsoleMessage[] = [];
|
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
||||||
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
|
else if (process.platform === 'darwin')
|
||||||
private _snapshot: PageSnapshot | undefined;
|
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
|
||||||
private _onPageClose: (tab: Tab) => void;
|
else if (process.platform === 'win32')
|
||||||
|
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
||||||
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
|
else
|
||||||
this.context = context;
|
throw new Error('Unsupported platform: ' + process.platform);
|
||||||
this.page = page;
|
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserName}-profile`);
|
||||||
this._onPageClose = onPageClose;
|
await fs.promises.mkdir(result, { recursive: true });
|
||||||
page.on('console', event => this._console.push(event));
|
return result;
|
||||||
page.on('request', request => this._requests.set(request, null));
|
|
||||||
page.on('response', response => this._requests.set(response.request(), response));
|
|
||||||
page.on('framenavigated', frame => {
|
|
||||||
if (!frame.parentFrame())
|
|
||||||
this._clearCollectedArtifacts();
|
|
||||||
});
|
|
||||||
page.on('close', () => this._onClose());
|
|
||||||
page.on('filechooser', chooser => {
|
|
||||||
this.context.setModalState({
|
|
||||||
type: 'fileChooser',
|
|
||||||
description: 'File chooser',
|
|
||||||
fileChooser: chooser,
|
|
||||||
}, this);
|
|
||||||
});
|
|
||||||
page.on('dialog', dialog => this.context.dialogShown(this, dialog));
|
|
||||||
page.setDefaultNavigationTimeout(60000);
|
|
||||||
page.setDefaultTimeout(5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _clearCollectedArtifacts() {
|
|
||||||
this._console.length = 0;
|
|
||||||
this._requests.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
private _onClose() {
|
|
||||||
this._clearCollectedArtifacts();
|
|
||||||
this._onPageClose(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
async navigate(url: string) {
|
|
||||||
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
|
||||||
// Cap load event to 5 seconds, the page is operational at this point.
|
|
||||||
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
hasSnapshot(): boolean {
|
|
||||||
return !!this._snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshotOrDie(): PageSnapshot {
|
|
||||||
if (!this._snapshot)
|
|
||||||
throw new Error('No snapshot available');
|
|
||||||
return this._snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
console(): playwright.ConsoleMessage[] {
|
|
||||||
return this._console;
|
|
||||||
}
|
|
||||||
|
|
||||||
requests(): Map<playwright.Request, playwright.Response | null> {
|
|
||||||
return this._requests;
|
|
||||||
}
|
|
||||||
|
|
||||||
async captureSnapshot() {
|
|
||||||
this._snapshot = await PageSnapshot.create(this.page);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class PageSnapshot {
|
async function launchPersistentContext(userDataDir: string | undefined, browserOptions: BrowserOptions): Promise<playwright.BrowserContext> {
|
||||||
private _frameLocators: PageOrFrameLocator[] = [];
|
userDataDir = userDataDir ?? await createUserDataDir(browserOptions.browserName);
|
||||||
private _text!: string;
|
|
||||||
|
|
||||||
constructor() {
|
try {
|
||||||
}
|
const browserType = browserOptions.browserName ? playwright[browserOptions.browserName] : playwright.chromium;
|
||||||
|
return await browserType.launchPersistentContext(userDataDir, browserOptions.launchOptions);
|
||||||
static async create(page: playwright.Page): Promise<PageSnapshot> {
|
} catch (error: any) {
|
||||||
const snapshot = new PageSnapshot();
|
if (error.message.includes('Executable doesn\'t exist'))
|
||||||
await snapshot._build(page);
|
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
|
||||||
return snapshot;
|
throw error;
|
||||||
}
|
|
||||||
|
|
||||||
text(): string {
|
|
||||||
return this._text;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _build(page: playwright.Page) {
|
|
||||||
const yamlDocument = await this._snapshotFrame(page);
|
|
||||||
this._text = [
|
|
||||||
`- Page Snapshot`,
|
|
||||||
'```yaml',
|
|
||||||
yamlDocument.toString({ indentSeq: false }).trim(),
|
|
||||||
'```',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _snapshotFrame(frame: playwright.Page | playwright.FrameLocator) {
|
|
||||||
const frameIndex = this._frameLocators.push(frame) - 1;
|
|
||||||
const snapshotString = await frame.locator('body').ariaSnapshot({ ref: true, emitGeneric: true });
|
|
||||||
const snapshot = yaml.parseDocument(snapshotString);
|
|
||||||
|
|
||||||
const visit = async (node: any): Promise<unknown> => {
|
|
||||||
if (yaml.isPair(node)) {
|
|
||||||
await Promise.all([
|
|
||||||
visit(node.key).then(k => node.key = k),
|
|
||||||
visit(node.value).then(v => node.value = v)
|
|
||||||
]);
|
|
||||||
} else if (yaml.isSeq(node) || yaml.isMap(node)) {
|
|
||||||
node.items = await Promise.all(node.items.map(visit));
|
|
||||||
} else if (yaml.isScalar(node)) {
|
|
||||||
if (typeof node.value === 'string') {
|
|
||||||
const value = node.value;
|
|
||||||
if (frameIndex > 0)
|
|
||||||
node.value = value.replace('[ref=', `[ref=f${frameIndex}`);
|
|
||||||
if (value.startsWith('iframe ')) {
|
|
||||||
const ref = value.match(/\[ref=(.*)\]/)?.[1];
|
|
||||||
if (ref) {
|
|
||||||
try {
|
|
||||||
const childSnapshot = await this._snapshotFrame(frame.frameLocator(`aria-ref=${ref}`));
|
|
||||||
return snapshot.createPair(node.value, childSnapshot);
|
|
||||||
} catch (error) {
|
|
||||||
return snapshot.createPair(node.value, '<could not take iframe snapshot>');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return node;
|
|
||||||
};
|
|
||||||
await visit(snapshot.contents);
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
refLocator(ref: string): playwright.Locator {
|
|
||||||
let frame = this._frameLocators[0];
|
|
||||||
const match = ref.match(/^f(\d+)(.*)/);
|
|
||||||
if (match) {
|
|
||||||
const frameIndex = parseInt(match[1], 10);
|
|
||||||
frame = this._frameLocators[frameIndex];
|
|
||||||
ref = match[2];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!frame)
|
|
||||||
throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
|
|
||||||
|
|
||||||
return frame.locator(`aria-ref=${ref}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
||||||
return (locator as any)._generateLocatorString();
|
return (locator as any)._generateLocatorString();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findFreePort() {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const server = net.createServer();
|
|
||||||
server.listen(0, () => {
|
|
||||||
const { port } = server.address() as net.AddressInfo;
|
|
||||||
server.close(() => resolve(port));
|
|
||||||
});
|
|
||||||
server.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
68
src/index.ts
68
src/index.ts
@ -14,10 +14,6 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import path from 'path';
|
|
||||||
import os from 'os';
|
|
||||||
import fs from 'fs';
|
|
||||||
|
|
||||||
import { createServerWithTools } from './server';
|
import { createServerWithTools } from './server';
|
||||||
import common from './tools/common';
|
import common from './tools/common';
|
||||||
import console from './tools/console';
|
import console from './tools/console';
|
||||||
@ -35,7 +31,6 @@ import screen from './tools/screen';
|
|||||||
import type { Tool } from './tools/tool';
|
import type { Tool } from './tools/tool';
|
||||||
import type { Config } from '../config';
|
import type { Config } from '../config';
|
||||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
import type { LaunchOptions, BrowserContextOptions } from 'playwright';
|
|
||||||
|
|
||||||
const snapshotTools: Tool<any>[] = [
|
const snapshotTools: Tool<any>[] = [
|
||||||
...common(true),
|
...common(true),
|
||||||
@ -67,67 +62,12 @@ const screenshotTools: Tool<any>[] = [
|
|||||||
|
|
||||||
const packageJSON = require('../package.json');
|
const packageJSON = require('../package.json');
|
||||||
|
|
||||||
export async function createServer(config?: Config): Promise<Server> {
|
export async function createServer(config: Config = {}): Promise<Server> {
|
||||||
let browserName: 'chromium' | 'firefox' | 'webkit';
|
const allTools = config.vision ? screenshotTools : snapshotTools;
|
||||||
let channel: string | undefined;
|
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
|
||||||
switch (config?.browser?.type) {
|
|
||||||
case 'chrome':
|
|
||||||
case 'chrome-beta':
|
|
||||||
case 'chrome-canary':
|
|
||||||
case 'chrome-dev':
|
|
||||||
case 'chromium':
|
|
||||||
case 'msedge':
|
|
||||||
case 'msedge-beta':
|
|
||||||
case 'msedge-canary':
|
|
||||||
case 'msedge-dev':
|
|
||||||
browserName = 'chromium';
|
|
||||||
channel = config.browser.type;
|
|
||||||
break;
|
|
||||||
case 'firefox':
|
|
||||||
browserName = 'firefox';
|
|
||||||
break;
|
|
||||||
case 'webkit':
|
|
||||||
browserName = 'webkit';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
browserName = 'chromium';
|
|
||||||
channel = 'chrome';
|
|
||||||
}
|
|
||||||
const userDataDir = config?.browser?.userDataDir ?? await createUserDataDir(browserName);
|
|
||||||
|
|
||||||
const launchOptions: LaunchOptions & BrowserContextOptions = {
|
|
||||||
headless: !!(config?.browser?.headless ?? (os.platform() === 'linux' && !process.env.DISPLAY)),
|
|
||||||
channel,
|
|
||||||
executablePath: config?.browser?.executablePath,
|
|
||||||
viewport: null,
|
|
||||||
...{ assistantMode: true },
|
|
||||||
};
|
|
||||||
|
|
||||||
const allTools = config?.vision ? screenshotTools : snapshotTools;
|
|
||||||
const tools = allTools.filter(tool => !config?.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
|
|
||||||
return createServerWithTools({
|
return createServerWithTools({
|
||||||
name: 'Playwright',
|
name: 'Playwright',
|
||||||
version: packageJSON.version,
|
version: packageJSON.version,
|
||||||
tools,
|
tools,
|
||||||
resources: [],
|
}, config);
|
||||||
browserName,
|
|
||||||
userDataDir,
|
|
||||||
launchOptions,
|
|
||||||
cdpEndpoint: config?.browser?.cdpEndpoint,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
101
src/pageSnapshot.ts
Normal file
101
src/pageSnapshot.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as playwright from 'playwright';
|
||||||
|
import yaml from 'yaml';
|
||||||
|
|
||||||
|
type PageOrFrameLocator = playwright.Page | playwright.FrameLocator;
|
||||||
|
|
||||||
|
export class PageSnapshot {
|
||||||
|
private _frameLocators: PageOrFrameLocator[] = [];
|
||||||
|
private _text!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(page: playwright.Page): Promise<PageSnapshot> {
|
||||||
|
const snapshot = new PageSnapshot();
|
||||||
|
await snapshot._build(page);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
text(): string {
|
||||||
|
return this._text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _build(page: playwright.Page) {
|
||||||
|
const yamlDocument = await this._snapshotFrame(page);
|
||||||
|
this._text = [
|
||||||
|
`- Page Snapshot`,
|
||||||
|
'```yaml',
|
||||||
|
yamlDocument.toString({ indentSeq: false }).trim(),
|
||||||
|
'```',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _snapshotFrame(frame: playwright.Page | playwright.FrameLocator) {
|
||||||
|
const frameIndex = this._frameLocators.push(frame) - 1;
|
||||||
|
const snapshotString = await frame.locator('body').ariaSnapshot({ ref: true, emitGeneric: true });
|
||||||
|
const snapshot = yaml.parseDocument(snapshotString);
|
||||||
|
|
||||||
|
const visit = async (node: any): Promise<unknown> => {
|
||||||
|
if (yaml.isPair(node)) {
|
||||||
|
await Promise.all([
|
||||||
|
visit(node.key).then(k => node.key = k),
|
||||||
|
visit(node.value).then(v => node.value = v)
|
||||||
|
]);
|
||||||
|
} else if (yaml.isSeq(node) || yaml.isMap(node)) {
|
||||||
|
node.items = await Promise.all(node.items.map(visit));
|
||||||
|
} else if (yaml.isScalar(node)) {
|
||||||
|
if (typeof node.value === 'string') {
|
||||||
|
const value = node.value;
|
||||||
|
if (frameIndex > 0)
|
||||||
|
node.value = value.replace('[ref=', `[ref=f${frameIndex}`);
|
||||||
|
if (value.startsWith('iframe ')) {
|
||||||
|
const ref = value.match(/\[ref=(.*)\]/)?.[1];
|
||||||
|
if (ref) {
|
||||||
|
try {
|
||||||
|
const childSnapshot = await this._snapshotFrame(frame.frameLocator(`aria-ref=${ref}`));
|
||||||
|
return snapshot.createPair(node.value, childSnapshot);
|
||||||
|
} catch (error) {
|
||||||
|
return snapshot.createPair(node.value, '<could not take iframe snapshot>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
await visit(snapshot.contents);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
refLocator(ref: string): playwright.Locator {
|
||||||
|
let frame = this._frameLocators[0];
|
||||||
|
const match = ref.match(/^f(\d+)(.*)/);
|
||||||
|
if (match) {
|
||||||
|
const frameIndex = parseInt(match[1], 10);
|
||||||
|
frame = this._frameLocators[frameIndex];
|
||||||
|
ref = match[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!frame)
|
||||||
|
throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
|
||||||
|
|
||||||
|
return frame.locator(`aria-ref=${ref}`);
|
||||||
|
}
|
||||||
|
}
|
@ -15,29 +15,26 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||||
|
|
||||||
import { Context } from './context';
|
import { Context } from './context';
|
||||||
|
|
||||||
import type { Tool } from './tools/tool';
|
import type { Tool } from './tools/tool';
|
||||||
import type { Resource } from './resources/resource';
|
import type { Config } from '../config';
|
||||||
import type { ContextOptions } from './context';
|
|
||||||
|
|
||||||
type Options = ContextOptions & {
|
type MCPServerOptions = {
|
||||||
name: string;
|
name: string;
|
||||||
version: string;
|
version: string;
|
||||||
tools: Tool[];
|
tools: Tool[];
|
||||||
resources: Resource[],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createServerWithTools(options: Options): Server {
|
export function createServerWithTools(serverOptions: MCPServerOptions, config: Config): Server {
|
||||||
const { name, version, tools, resources } = options;
|
const { name, version, tools } = serverOptions;
|
||||||
const context = new Context(tools, options);
|
const context = new Context(tools, config);
|
||||||
const server = new Server({ name, version }, {
|
const server = new Server({ name, version }, {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {},
|
tools: {},
|
||||||
resources: {},
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -51,10 +48,6 @@ export function createServerWithTools(options: Options): Server {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
||||||
return { resources: resources.map(resource => resource.schema) };
|
|
||||||
});
|
|
||||||
|
|
||||||
server.setRequestHandler(CallToolRequestSchema, async request => {
|
server.setRequestHandler(CallToolRequestSchema, async request => {
|
||||||
const tool = tools.find(tool => tool.schema.name === request.params.name);
|
const tool = tools.find(tool => tool.schema.name === request.params.name);
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
@ -87,15 +80,6 @@ export function createServerWithTools(options: Options): Server {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
server.setRequestHandler(ReadResourceRequestSchema, async request => {
|
|
||||||
const resource = resources.find(resource => resource.schema.uri === request.params.uri);
|
|
||||||
if (!resource)
|
|
||||||
return { contents: [] };
|
|
||||||
|
|
||||||
const contents = await resource.read(context, request.params.uri);
|
|
||||||
return { contents };
|
|
||||||
});
|
|
||||||
|
|
||||||
const oldClose = server.close.bind(server);
|
const oldClose = server.close.bind(server);
|
||||||
|
|
||||||
server.close = async () => {
|
server.close = async () => {
|
||||||
|
92
src/tab.ts
Normal file
92
src/tab.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as playwright from 'playwright';
|
||||||
|
|
||||||
|
import { PageSnapshot } from './pageSnapshot';
|
||||||
|
|
||||||
|
import type { Context } from './context';
|
||||||
|
|
||||||
|
export class Tab {
|
||||||
|
readonly context: Context;
|
||||||
|
readonly page: playwright.Page;
|
||||||
|
private _console: playwright.ConsoleMessage[] = [];
|
||||||
|
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
|
||||||
|
private _snapshot: PageSnapshot | undefined;
|
||||||
|
private _onPageClose: (tab: Tab) => void;
|
||||||
|
|
||||||
|
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
|
||||||
|
this.context = context;
|
||||||
|
this.page = page;
|
||||||
|
this._onPageClose = onPageClose;
|
||||||
|
page.on('console', event => this._console.push(event));
|
||||||
|
page.on('request', request => this._requests.set(request, null));
|
||||||
|
page.on('response', response => this._requests.set(response.request(), response));
|
||||||
|
page.on('framenavigated', frame => {
|
||||||
|
if (!frame.parentFrame())
|
||||||
|
this._clearCollectedArtifacts();
|
||||||
|
});
|
||||||
|
page.on('close', () => this._onClose());
|
||||||
|
page.on('filechooser', chooser => {
|
||||||
|
this.context.setModalState({
|
||||||
|
type: 'fileChooser',
|
||||||
|
description: 'File chooser',
|
||||||
|
fileChooser: chooser,
|
||||||
|
}, this);
|
||||||
|
});
|
||||||
|
page.on('dialog', dialog => this.context.dialogShown(this, dialog));
|
||||||
|
page.setDefaultNavigationTimeout(60000);
|
||||||
|
page.setDefaultTimeout(5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _clearCollectedArtifacts() {
|
||||||
|
this._console.length = 0;
|
||||||
|
this._requests.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onClose() {
|
||||||
|
this._clearCollectedArtifacts();
|
||||||
|
this._onPageClose(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
async navigate(url: string) {
|
||||||
|
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||||
|
// Cap load event to 5 seconds, the page is operational at this point.
|
||||||
|
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSnapshot(): boolean {
|
||||||
|
return !!this._snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshotOrDie(): PageSnapshot {
|
||||||
|
if (!this._snapshot)
|
||||||
|
throw new Error('No snapshot available');
|
||||||
|
return this._snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
console(): playwright.ConsoleMessage[] {
|
||||||
|
return this._console;
|
||||||
|
}
|
||||||
|
|
||||||
|
requests(): Map<playwright.Request, playwright.Response | null> {
|
||||||
|
return this._requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
async captureSnapshot() {
|
||||||
|
this._snapshot = await PageSnapshot.create(this.page);
|
||||||
|
}
|
||||||
|
}
|
@ -19,6 +19,7 @@ import path from 'path';
|
|||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool } from './tool';
|
import { defineTool } from './tool';
|
||||||
|
import { toBrowserOptions } from '../config';
|
||||||
|
|
||||||
const install = defineTool({
|
const install = defineTool({
|
||||||
capability: 'install',
|
capability: 'install',
|
||||||
@ -29,7 +30,8 @@ const install = defineTool({
|
|||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
const channel = context.options.launchOptions?.channel ?? context.options.browserName ?? 'chrome';
|
const browserOptions = await toBrowserOptions(context.config);
|
||||||
|
const channel = browserOptions.launchOptions?.channel ?? browserOptions.browserName ?? 'chrome';
|
||||||
const cli = path.join(require.resolve('playwright/package.json'), '..', 'cli.js');
|
const cli = path.join(require.resolve('playwright/package.json'), '..', 'cli.js');
|
||||||
const child = fork(cli, ['install', channel], {
|
const child = fork(cli, ['install', channel], {
|
||||||
stdio: 'pipe',
|
stdio: 'pipe',
|
||||||
|
@ -20,11 +20,10 @@ import os from 'os';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { sanitizeForFilePath } from './utils';
|
import { sanitizeForFilePath } from './utils';
|
||||||
import { generateLocator } from '../context';
|
import { defineTool } from './tool';
|
||||||
import * as javascript from '../javascript';
|
import * as javascript from '../javascript';
|
||||||
|
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
import { defineTool } from './tool';
|
|
||||||
|
|
||||||
const snapshot = defineTool({
|
const snapshot = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
@ -268,6 +267,9 @@ const screenshot = defineTool({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
||||||
|
return (locator as any)._generateLocatorString();
|
||||||
|
}
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
snapshot,
|
snapshot,
|
||||||
|
@ -74,11 +74,6 @@ test('test vision tool list', async ({ visionClient }) => {
|
|||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test resources list', async ({ client }) => {
|
|
||||||
const { resources } = await client.listResources();
|
|
||||||
expect(resources).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('test capabilities', async ({ startClient }) => {
|
test('test capabilities', async ({ startClient }) => {
|
||||||
const client = await startClient({
|
const client = await startClient({
|
||||||
args: ['--caps="core"'],
|
args: ['--caps="core"'],
|
||||||
|
Loading…
x
Reference in New Issue
Block a user