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