mirror of
https://github.com/microsoft/playwright-mcp.git
synced 2025-07-25 16:02:26 +08:00
chore: use persistent profile by default (#41)
Fixes https://github.com/microsoft/playwright-mcp/issues/29
This commit is contained in:
parent
5345a7b4df
commit
6ff4500211
9
index.d.ts
vendored
9
index.d.ts
vendored
@ -19,7 +19,16 @@ import type { LaunchOptions } from 'playwright';
|
||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
|
||||
type Options = {
|
||||
/**
|
||||
* Path to the user data directory.
|
||||
*/
|
||||
userDataDir?: string;
|
||||
|
||||
/**
|
||||
* Launch options for the browser.
|
||||
*/
|
||||
launchOptions?: LaunchOptions;
|
||||
|
||||
/**
|
||||
* Use screenshots instead of snapshots. Less accurate, reliable and overall
|
||||
* slower, but contains visual representation of the page.
|
||||
|
@ -17,62 +17,75 @@
|
||||
import * as playwright from 'playwright';
|
||||
|
||||
export class Context {
|
||||
private _userDataDir: string;
|
||||
private _launchOptions: playwright.LaunchOptions | undefined;
|
||||
private _browser: playwright.Browser | undefined;
|
||||
private _page: playwright.Page | undefined;
|
||||
private _console: playwright.ConsoleMessage[] = [];
|
||||
private _initializePromise: Promise<void> | undefined;
|
||||
private _createPagePromise: Promise<playwright.Page> | undefined;
|
||||
|
||||
constructor(launchOptions?: playwright.LaunchOptions) {
|
||||
constructor(userDataDir: string, launchOptions?: playwright.LaunchOptions) {
|
||||
this._userDataDir = userDataDir;
|
||||
this._launchOptions = launchOptions;
|
||||
}
|
||||
|
||||
async ensurePage(): Promise<playwright.Page> {
|
||||
await this._initialize();
|
||||
return this._page!;
|
||||
async createPage(): Promise<playwright.Page> {
|
||||
if (this._createPagePromise)
|
||||
return this._createPagePromise;
|
||||
this._createPagePromise = (async () => {
|
||||
const { browser, page } = await this._createPage();
|
||||
page.on('console', event => this._console.push(event));
|
||||
page.on('framenavigated', frame => {
|
||||
if (!frame.parentFrame())
|
||||
this._console.length = 0;
|
||||
});
|
||||
page.on('close', () => this._onPageClose());
|
||||
this._page = page;
|
||||
this._browser = browser;
|
||||
return page;
|
||||
})();
|
||||
return this._createPagePromise;
|
||||
}
|
||||
|
||||
async ensureConsole(): Promise<playwright.ConsoleMessage[]> {
|
||||
await this._initialize();
|
||||
private _onPageClose() {
|
||||
const browser = this._browser;
|
||||
const page = this._page;
|
||||
void page?.context()?.close().then(() => browser?.close()).catch(() => {});
|
||||
|
||||
this._createPagePromise = undefined;
|
||||
this._browser = undefined;
|
||||
this._page = undefined;
|
||||
this._console.length = 0;
|
||||
}
|
||||
|
||||
async existingPage(): Promise<playwright.Page> {
|
||||
if (!this._page)
|
||||
throw new Error('Navigate to a location to create a page');
|
||||
return this._page;
|
||||
}
|
||||
|
||||
async console(): Promise<playwright.ConsoleMessage[]> {
|
||||
return this._console;
|
||||
}
|
||||
|
||||
async close() {
|
||||
const page = await this.ensurePage();
|
||||
await page.close();
|
||||
if (!this._page)
|
||||
return;
|
||||
await this._page.close();
|
||||
}
|
||||
|
||||
private async _initialize() {
|
||||
if (this._initializePromise)
|
||||
return this._initializePromise;
|
||||
this._initializePromise = (async () => {
|
||||
this._browser = await createBrowser(this._launchOptions);
|
||||
this._page = await this._browser.newPage();
|
||||
this._page.on('console', event => this._console.push(event));
|
||||
this._page.on('framenavigated', frame => {
|
||||
if (!frame.parentFrame())
|
||||
this._console.length = 0;
|
||||
});
|
||||
this._page.on('close', () => this._reset());
|
||||
})();
|
||||
return this._initializePromise;
|
||||
}
|
||||
private async _createPage(): Promise<{ browser?: playwright.Browser, page: playwright.Page }> {
|
||||
if (process.env.PLAYWRIGHT_WS_ENDPOINT) {
|
||||
const url = new URL(process.env.PLAYWRIGHT_WS_ENDPOINT);
|
||||
if (this._launchOptions)
|
||||
url.searchParams.set('launch-options', JSON.stringify(this._launchOptions));
|
||||
const browser = await playwright.chromium.connect(String(url));
|
||||
const page = await browser.newPage();
|
||||
return { browser, page };
|
||||
}
|
||||
|
||||
private _reset() {
|
||||
const browser = this._browser;
|
||||
this._initializePromise = undefined;
|
||||
this._browser = undefined;
|
||||
this._page = undefined;
|
||||
this._console.length = 0;
|
||||
void browser?.close();
|
||||
const context = await playwright.chromium.launchPersistentContext(this._userDataDir, this._launchOptions);
|
||||
const [page] = context.pages();
|
||||
return { page };
|
||||
}
|
||||
}
|
||||
|
||||
async function createBrowser(launchOptions?: playwright.LaunchOptions): Promise<playwright.Browser> {
|
||||
if (process.env.PLAYWRIGHT_WS_ENDPOINT) {
|
||||
const url = new URL(process.env.PLAYWRIGHT_WS_ENDPOINT);
|
||||
url.searchParams.set('launch-options', JSON.stringify(launchOptions));
|
||||
return await playwright.chromium.connect(String(url));
|
||||
}
|
||||
return await playwright.chromium.launch({ channel: 'chrome', ...launchOptions });
|
||||
}
|
||||
|
17
src/index.ts
17
src/index.ts
@ -61,18 +61,21 @@ const resources: Resource[] = [
|
||||
];
|
||||
|
||||
type Options = {
|
||||
vision?: boolean;
|
||||
userDataDir?: string;
|
||||
launchOptions?: LaunchOptions;
|
||||
vision?: boolean;
|
||||
};
|
||||
|
||||
const packageJSON = require('../package.json');
|
||||
|
||||
export function createServer(options?: Options): Server {
|
||||
const tools = options?.vision ? screenshotTools : snapshotTools;
|
||||
return createServerWithTools(
|
||||
'Playwright',
|
||||
packageJSON.version,
|
||||
tools,
|
||||
resources,
|
||||
options?.launchOptions);
|
||||
return createServerWithTools({
|
||||
name: 'Playwright',
|
||||
version: packageJSON.version,
|
||||
tools,
|
||||
resources,
|
||||
userDataDir: options?.userDataDir ?? '',
|
||||
launchOptions: options?.launchOptions,
|
||||
});
|
||||
}
|
||||
|
@ -14,6 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { program } from 'commander';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
|
||||
@ -28,12 +32,17 @@ program
|
||||
.version('Version ' + packageJSON.version)
|
||||
.name(packageJSON.name)
|
||||
.option('--headless', 'Run browser in headless mode, headed by default')
|
||||
.option('--user-data-dir <path>', 'Path to the user data directory')
|
||||
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
|
||||
.action(async options => {
|
||||
const launchOptions: LaunchOptions = {
|
||||
headless: !!options.headless,
|
||||
channel: 'chrome',
|
||||
};
|
||||
const server = createServer({ launchOptions });
|
||||
const server = createServer({
|
||||
userDataDir: options.userDataDir ?? await userDataDir(),
|
||||
launchOptions,
|
||||
});
|
||||
setupExitWatchdog(server);
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
@ -49,3 +58,18 @@ function setupExitWatchdog(server: Server) {
|
||||
}
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
async function userDataDir() {
|
||||
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-chromium-profile');
|
||||
await fs.promises.mkdir(result, { recursive: true });
|
||||
return result;
|
||||
}
|
||||
|
@ -24,7 +24,7 @@ export const console: Resource = {
|
||||
},
|
||||
|
||||
read: async (context, uri) => {
|
||||
const messages = await context.ensureConsole();
|
||||
const messages = await context.console();
|
||||
const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
|
||||
return [{
|
||||
uri,
|
||||
|
@ -23,8 +23,18 @@ import type { Tool } from './tools/tool';
|
||||
import type { Resource } from './resources/resource';
|
||||
import type { LaunchOptions } from 'playwright';
|
||||
|
||||
export function createServerWithTools(name: string, version: string, tools: Tool[], resources: Resource[], launchOption?: LaunchOptions): Server {
|
||||
const context = new Context(launchOption);
|
||||
type Options = {
|
||||
name: string;
|
||||
version: string;
|
||||
tools: Tool[];
|
||||
resources: Resource[],
|
||||
userDataDir: string;
|
||||
launchOptions?: LaunchOptions;
|
||||
};
|
||||
|
||||
export function createServerWithTools(options: Options): Server {
|
||||
const { name, version, tools, resources, userDataDir, launchOptions } = options;
|
||||
const context = new Context(userDataDir, launchOptions);
|
||||
const server = new Server({ name, version }, {
|
||||
capabilities: {
|
||||
tools: {},
|
||||
|
@ -36,7 +36,7 @@ export const navigate: ToolFactory = snapshot => ({
|
||||
},
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = navigateSchema.parse(params);
|
||||
const page = await context.ensurePage();
|
||||
const page = await context.createPage();
|
||||
await page.goto(validatedParams.url, { waitUntil: 'domcontentloaded' });
|
||||
// Cap load event to 5 seconds, the page is operational at this point.
|
||||
await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
||||
@ -60,10 +60,7 @@ export const goBack: ToolFactory = snapshot => ({
|
||||
inputSchema: zodToJsonSchema(goBackSchema),
|
||||
},
|
||||
handle: async context => {
|
||||
return await runAndWait(context, 'Navigated back', async () => {
|
||||
const page = await context.ensurePage();
|
||||
await page.goBack();
|
||||
}, snapshot);
|
||||
return await runAndWait(context, 'Navigated back', async page => page.goBack(), snapshot);
|
||||
},
|
||||
});
|
||||
|
||||
@ -76,10 +73,7 @@ export const goForward: ToolFactory = snapshot => ({
|
||||
inputSchema: zodToJsonSchema(goForwardSchema),
|
||||
},
|
||||
handle: async context => {
|
||||
return await runAndWait(context, 'Navigated forward', async () => {
|
||||
const page = await context.ensurePage();
|
||||
await page.goForward();
|
||||
}, snapshot);
|
||||
return await runAndWait(context, 'Navigated forward', async page => page.goForward(), snapshot);
|
||||
},
|
||||
});
|
||||
|
||||
@ -95,8 +89,7 @@ export const wait: Tool = {
|
||||
},
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = waitSchema.parse(params);
|
||||
const page = await context.ensurePage();
|
||||
await page.waitForTimeout(Math.min(10000, validatedParams.time * 1000));
|
||||
await new Promise(f => setTimeout(f, Math.min(10000, validatedParams.time * 1000)));
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
@ -133,7 +126,7 @@ export const pdf: Tool = {
|
||||
inputSchema: zodToJsonSchema(pdfSchema),
|
||||
},
|
||||
handle: async context => {
|
||||
const page = await context.ensurePage();
|
||||
const page = await context.existingPage();
|
||||
const fileName = path.join(os.tmpdir(), `/page-${new Date().toISOString()}.pdf`);
|
||||
await page.pdf({ path: fileName });
|
||||
return {
|
||||
|
@ -29,7 +29,7 @@ export const screenshot: Tool = {
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
const page = await context.ensurePage();
|
||||
const page = await context.existingPage();
|
||||
const screenshot = await page.screenshot({ type: 'jpeg', quality: 50, scale: 'css' });
|
||||
return {
|
||||
content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: 'image/jpeg' }],
|
||||
@ -55,7 +55,7 @@ export const moveMouse: Tool = {
|
||||
|
||||
handle: async (context, params) => {
|
||||
const validatedParams = moveMouseSchema.parse(params);
|
||||
const page = await context.ensurePage();
|
||||
const page = await context.existingPage();
|
||||
await page.mouse.move(validatedParams.x, validatedParams.y);
|
||||
return {
|
||||
content: [{ type: 'text', text: `Moved mouse to (${validatedParams.x}, ${validatedParams.y})` }],
|
||||
|
@ -30,7 +30,7 @@ export const snapshot: Tool = {
|
||||
},
|
||||
|
||||
handle: async context => {
|
||||
return await captureAriaSnapshot(await context.ensurePage());
|
||||
return await captureAriaSnapshot(await context.existingPage());
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -72,7 +72,7 @@ async function waitForCompletion<R>(page: playwright.Page, callback: () => Promi
|
||||
}
|
||||
|
||||
export async function runAndWait(context: Context, status: string, callback: (page: playwright.Page) => Promise<any>, snapshot: boolean = false): Promise<ToolResult> {
|
||||
const page = await context.ensurePage();
|
||||
const page = await context.existingPage();
|
||||
await waitForCompletion(page, () => callback(page));
|
||||
return snapshot ? captureAriaSnapshot(page, status) : {
|
||||
content: [{ type: 'text', text: status }],
|
||||
|
@ -121,11 +121,12 @@ export const test = baseTest.extend<Fixtures>({
|
||||
await use(await startServer());
|
||||
},
|
||||
|
||||
startServer: async ({ }, use) => {
|
||||
startServer: async ({ }, use, testInfo) => {
|
||||
let server: MCPServer | undefined;
|
||||
const userDataDir = testInfo.outputPath('user-data-dir');
|
||||
|
||||
use(async options => {
|
||||
server = new MCPServer('node', [path.join(__dirname, '../cli.js'), '--headless'], options);
|
||||
server = new MCPServer('node', [path.join(__dirname, '../cli.js'), '--headless', '--user-data-dir', userDataDir], options);
|
||||
const initialize = await server.send({
|
||||
jsonrpc: '2.0',
|
||||
id: 0,
|
||||
|
Loading…
x
Reference in New Issue
Block a user