mirror of
https://github.com/microsoft/playwright-mcp.git
synced 2025-07-26 08:32:26 +08:00
chore: reuse browser in server mode (#495)
This commit is contained in:
parent
54ed7c3200
commit
eec177d3ac
3
index.d.ts
vendored
3
index.d.ts
vendored
@ -18,6 +18,7 @@
|
|||||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
import type { Config } from './config';
|
import type { Config } from './config';
|
||||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
|
import type { BrowserContext } from 'playwright';
|
||||||
|
|
||||||
export type Connection = {
|
export type Connection = {
|
||||||
server: Server;
|
server: Server;
|
||||||
@ -25,5 +26,5 @@ export type Connection = {
|
|||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export declare function createConnection(config?: Config): Promise<Connection>;
|
export declare function createConnection(config?: Config, contextGetter?: () => Promise<BrowserContext>): Promise<Connection>;
|
||||||
export {};
|
export {};
|
||||||
|
108
package-lock.json
generated
108
package-lock.json
generated
@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
|
"debug": "^4.4.1",
|
||||||
"playwright": "1.53.0-alpha-2025-05-27",
|
"playwright": "1.53.0-alpha-2025-05-27",
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
},
|
},
|
||||||
@ -22,6 +23,7 @@
|
|||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@playwright/test": "1.53.0-alpha-2025-05-27",
|
"@playwright/test": "1.53.0-alpha-2025-05-27",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
|
"@types/debug": "^4.1.12",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||||
"@typescript-eslint/parser": "^8.26.1",
|
"@typescript-eslint/parser": "^8.26.1",
|
||||||
@ -354,6 +356,16 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/debug": {
|
||||||
|
"version": "4.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||||
|
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ms": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||||
@ -375,6 +387,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ms": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.13.10",
|
"version": "22.13.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
|
||||||
@ -853,29 +872,6 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/body-parser/node_modules/debug": {
|
|
||||||
"version": "4.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
|
||||||
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/body-parser/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/body-parser/node_modules/qs": {
|
"node_modules/body-parser/node_modules/qs": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||||
@ -1156,12 +1152,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.3.6",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||||
"integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==",
|
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "2.1.2"
|
"ms": "^2.1.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0"
|
"node": ">=6.0"
|
||||||
@ -1826,6 +1822,29 @@
|
|||||||
"express": "^4.11 || 5 || ^5.0.0-beta.1"
|
"express": "^4.11 || 5 || ^5.0.0-beta.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express/node_modules/debug": {
|
||||||
|
"version": "4.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
|
||||||
|
"integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "2.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express/node_modules/ms": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@ -1930,29 +1949,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/finalhandler/node_modules/debug": {
|
|
||||||
"version": "4.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
|
||||||
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/finalhandler/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/find-root": {
|
"node_modules/find-root": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
|
||||||
@ -3003,9 +2999,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/natural-compare": {
|
"node_modules/natural-compare": {
|
||||||
@ -3713,12 +3709,6 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/send/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/serve-static": {
|
"node_modules/serve-static": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz",
|
||||||
|
@ -37,6 +37,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
|
"debug": "^4.4.1",
|
||||||
"playwright": "1.53.0-alpha-2025-05-27",
|
"playwright": "1.53.0-alpha-2025-05-27",
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
},
|
},
|
||||||
@ -45,6 +46,7 @@
|
|||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@playwright/test": "1.53.0-alpha-2025-05-27",
|
"@playwright/test": "1.53.0-alpha-2025-05-27",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
|
"@types/debug": "^4.1.12",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||||
"@typescript-eslint/parser": "^8.26.1",
|
"@typescript-eslint/parser": "^8.26.1",
|
||||||
|
202
src/browserContextFactory.ts
Normal file
202
src/browserContextFactory.ts
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* 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 fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import debug from 'debug';
|
||||||
|
import * as playwright from 'playwright';
|
||||||
|
|
||||||
|
import type { FullConfig } from './config.js';
|
||||||
|
|
||||||
|
const testDebug = debug('pw:mcp:test');
|
||||||
|
|
||||||
|
export function contextFactory(browserConfig: FullConfig['browser']): BrowserContextFactory {
|
||||||
|
if (browserConfig.remoteEndpoint)
|
||||||
|
return new RemoteContextFactory(browserConfig);
|
||||||
|
if (browserConfig.cdpEndpoint)
|
||||||
|
return new CdpContextFactory(browserConfig);
|
||||||
|
if (browserConfig.isolated)
|
||||||
|
return new IsolatedContextFactory(browserConfig);
|
||||||
|
return new PersistentContextFactory(browserConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserContextFactory {
|
||||||
|
createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class BaseContextFactory implements BrowserContextFactory {
|
||||||
|
readonly browserConfig: FullConfig['browser'];
|
||||||
|
protected _browserPromise: Promise<playwright.Browser> | undefined;
|
||||||
|
readonly name: string;
|
||||||
|
|
||||||
|
constructor(name: string, browserConfig: FullConfig['browser']) {
|
||||||
|
this.name = name;
|
||||||
|
this.browserConfig = browserConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _obtainBrowser(): Promise<playwright.Browser> {
|
||||||
|
if (this._browserPromise)
|
||||||
|
return this._browserPromise;
|
||||||
|
testDebug(`obtain browser (${this.name})`);
|
||||||
|
this._browserPromise = this._doObtainBrowser();
|
||||||
|
void this._browserPromise.then(browser => {
|
||||||
|
browser.on('disconnected', () => {
|
||||||
|
this._browserPromise = undefined;
|
||||||
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
this._browserPromise = undefined;
|
||||||
|
});
|
||||||
|
return this._browserPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
|
throw new Error('Not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
|
testDebug(`create browser context (${this.name})`);
|
||||||
|
const browser = await this._obtainBrowser();
|
||||||
|
const browserContext = await this._doCreateContext(browser);
|
||||||
|
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
|
throw new Error('Not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) {
|
||||||
|
testDebug(`close browser context (${this.name})`);
|
||||||
|
if (browser.contexts().length === 1)
|
||||||
|
this._browserPromise = undefined;
|
||||||
|
await browserContext.close().catch(() => {});
|
||||||
|
if (browser.contexts().length === 0) {
|
||||||
|
testDebug(`close browser (${this.name})`);
|
||||||
|
await browser.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class IsolatedContextFactory extends BaseContextFactory {
|
||||||
|
constructor(browserConfig: FullConfig['browser']) {
|
||||||
|
super('isolated', browserConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
|
const browserType = playwright[this.browserConfig.browserName];
|
||||||
|
return browserType.launch(this.browserConfig.launchOptions).catch(error => {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
|
return browser.newContext(this.browserConfig.contextOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CdpContextFactory extends BaseContextFactory {
|
||||||
|
constructor(browserConfig: FullConfig['browser']) {
|
||||||
|
super('cdp', browserConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
|
return playwright.chromium.connectOverCDP(this.browserConfig.cdpEndpoint!);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
|
return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RemoteContextFactory extends BaseContextFactory {
|
||||||
|
constructor(browserConfig: FullConfig['browser']) {
|
||||||
|
super('remote', browserConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
|
const url = new URL(this.browserConfig.remoteEndpoint!);
|
||||||
|
url.searchParams.set('browser', this.browserConfig.browserName);
|
||||||
|
if (this.browserConfig.launchOptions)
|
||||||
|
url.searchParams.set('launch-options', JSON.stringify(this.browserConfig.launchOptions));
|
||||||
|
return playwright[this.browserConfig.browserName].connect(String(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
|
return browser.newContext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PersistentContextFactory implements BrowserContextFactory {
|
||||||
|
readonly browserConfig: FullConfig['browser'];
|
||||||
|
private _userDataDirs = new Set<string>();
|
||||||
|
|
||||||
|
constructor(browserConfig: FullConfig['browser']) {
|
||||||
|
this.browserConfig = browserConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
|
testDebug('create browser context (persistent)');
|
||||||
|
const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir();
|
||||||
|
|
||||||
|
this._userDataDirs.add(userDataDir);
|
||||||
|
testDebug('lock user data dir', userDataDir);
|
||||||
|
|
||||||
|
const browserType = playwright[this.browserConfig.browserName];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
try {
|
||||||
|
const browserContext = await browserType.launchPersistentContext(userDataDir, { ...this.browserConfig.launchOptions, ...this.browserConfig.contextOptions });
|
||||||
|
const close = () => this._closeBrowserContext(browserContext, userDataDir);
|
||||||
|
return { browserContext, close };
|
||||||
|
} 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.`);
|
||||||
|
if (error.message.includes('ProcessSingleton') || error.message.includes('Invalid URL')) {
|
||||||
|
// User data directory is already in use, try again.
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _closeBrowserContext(browserContext: playwright.BrowserContext, userDataDir: string) {
|
||||||
|
testDebug('close browser context (persistent)');
|
||||||
|
testDebug('release user data dir', userDataDir);
|
||||||
|
await browserContext.close().catch(() => {});
|
||||||
|
this._userDataDirs.delete(userDataDir);
|
||||||
|
testDebug('close browser context complete (persistent)');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _createUserDataDir() {
|
||||||
|
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-${this.browserConfig.launchOptions?.channel ?? this.browserConfig?.browserName}-profile`);
|
||||||
|
await fs.promises.mkdir(result, { recursive: true });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
@ -74,8 +74,8 @@ const defaultConfig: FullConfig = {
|
|||||||
type BrowserUserConfig = NonNullable<Config['browser']>;
|
type BrowserUserConfig = NonNullable<Config['browser']>;
|
||||||
|
|
||||||
export type FullConfig = Config & {
|
export type FullConfig = Config & {
|
||||||
browser: BrowserUserConfig & {
|
browser: Omit<BrowserUserConfig, 'browserName'> & {
|
||||||
browserName: NonNullable<BrowserUserConfig['browserName']>;
|
browserName: 'chromium' | 'firefox' | 'webkit';
|
||||||
launchOptions: NonNullable<BrowserUserConfig['launchOptions']>;
|
launchOptions: NonNullable<BrowserUserConfig['launchOptions']>;
|
||||||
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
|
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
|
||||||
},
|
},
|
||||||
|
@ -24,11 +24,13 @@ import { packageJSON } from './package.js';
|
|||||||
|
|
||||||
import { FullConfig } from './config.js';
|
import { FullConfig } from './config.js';
|
||||||
|
|
||||||
export function createConnection(config: FullConfig): Connection {
|
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||||
|
|
||||||
|
export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection {
|
||||||
const allTools = config.vision ? visionTools : snapshotTools;
|
const allTools = config.vision ? visionTools : snapshotTools;
|
||||||
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
|
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
|
||||||
|
|
||||||
const context = new Context(tools, config);
|
const context = new Context(tools, config, browserContextFactory);
|
||||||
const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, {
|
const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {},
|
tools: {},
|
||||||
|
100
src/context.ts
100
src/context.ts
@ -14,10 +14,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'node:fs';
|
import debug from 'debug';
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
|
|
||||||
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
||||||
@ -28,20 +25,19 @@ import { outputFile } from './config.js';
|
|||||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
|
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
|
||||||
import type { FullConfig } from './config.js';
|
import type { FullConfig } from './config.js';
|
||||||
|
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||||
|
|
||||||
type PendingAction = {
|
type PendingAction = {
|
||||||
dialogShown: ManualPromise<void>;
|
dialogShown: ManualPromise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BrowserContextAndBrowser = {
|
const testDebug = debug('pw:mcp:test');
|
||||||
browser?: playwright.Browser;
|
|
||||||
browserContext: playwright.BrowserContext;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class Context {
|
export class Context {
|
||||||
readonly tools: Tool[];
|
readonly tools: Tool[];
|
||||||
readonly config: FullConfig;
|
readonly config: FullConfig;
|
||||||
private _browserContextPromise: Promise<BrowserContextAndBrowser> | undefined;
|
private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
|
||||||
|
private _browserContextFactory: BrowserContextFactory;
|
||||||
private _tabs: Tab[] = [];
|
private _tabs: Tab[] = [];
|
||||||
private _currentTab: Tab | undefined;
|
private _currentTab: Tab | undefined;
|
||||||
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
||||||
@ -49,9 +45,11 @@ export class Context {
|
|||||||
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
|
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
|
||||||
clientVersion: { name: string; version: string; } | undefined;
|
clientVersion: { name: string; version: string; } | undefined;
|
||||||
|
|
||||||
constructor(tools: Tool[], config: FullConfig) {
|
constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
|
||||||
this.tools = tools;
|
this.tools = tools;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
this._browserContextFactory = browserContextFactory;
|
||||||
|
testDebug('create context');
|
||||||
}
|
}
|
||||||
|
|
||||||
clientSupportsImages(): boolean {
|
clientSupportsImages(): boolean {
|
||||||
@ -296,15 +294,15 @@ ${code.join('\n')}
|
|||||||
if (!this._browserContextPromise)
|
if (!this._browserContextPromise)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
testDebug('close context');
|
||||||
|
|
||||||
const promise = this._browserContextPromise;
|
const promise = this._browserContextPromise;
|
||||||
this._browserContextPromise = undefined;
|
this._browserContextPromise = undefined;
|
||||||
|
|
||||||
await promise.then(async ({ browserContext, browser }) => {
|
await promise.then(async ({ browserContext, close }) => {
|
||||||
if (this.config.saveTrace)
|
if (this.config.saveTrace)
|
||||||
await browserContext.tracing.stop();
|
await browserContext.tracing.stop();
|
||||||
await browserContext.close().then(async () => {
|
await close();
|
||||||
await browser?.close();
|
|
||||||
}).catch(() => {});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -332,8 +330,10 @@ ${code.join('\n')}
|
|||||||
return this._browserContextPromise;
|
return this._browserContextPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _setupBrowserContext(): Promise<BrowserContextAndBrowser> {
|
private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
const { browser, browserContext } = await this._createBrowserContext();
|
// TODO: move to the browser context factory to make it based on isolation mode.
|
||||||
|
const result = await this._browserContextFactory.createContext();
|
||||||
|
const { browserContext } = result;
|
||||||
await this._setupRequestInterception(browserContext);
|
await this._setupRequestInterception(browserContext);
|
||||||
for (const page of browserContext.pages())
|
for (const page of browserContext.pages())
|
||||||
this._onPageCreated(page);
|
this._onPageCreated(page);
|
||||||
@ -346,72 +346,6 @@ ${code.join('\n')}
|
|||||||
sources: false,
|
sources: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { browser, browserContext };
|
return result;
|
||||||
}
|
|
||||||
|
|
||||||
private async _createBrowserContext(): Promise<BrowserContextAndBrowser> {
|
|
||||||
if (this.config.browser?.remoteEndpoint) {
|
|
||||||
const url = new URL(this.config.browser?.remoteEndpoint);
|
|
||||||
if (this.config.browser.browserName)
|
|
||||||
url.searchParams.set('browser', this.config.browser.browserName);
|
|
||||||
if (this.config.browser.launchOptions)
|
|
||||||
url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions));
|
|
||||||
const browser = await playwright[this.config.browser?.browserName ?? 'chromium'].connect(String(url));
|
|
||||||
const browserContext = await browser.newContext();
|
|
||||||
return { browser, browserContext };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.config.browser?.cdpEndpoint) {
|
|
||||||
const browser = await playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint);
|
|
||||||
const browserContext = this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0];
|
|
||||||
return { browser, browserContext };
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.config.browser?.isolated ?
|
|
||||||
await createIsolatedContext(this.config.browser) :
|
|
||||||
await launchPersistentContext(this.config.browser);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createIsolatedContext(browserConfig: FullConfig['browser']): Promise<BrowserContextAndBrowser> {
|
|
||||||
try {
|
|
||||||
const browserName = browserConfig?.browserName ?? 'chromium';
|
|
||||||
const browserType = playwright[browserName];
|
|
||||||
const browser = await browserType.launch(browserConfig.launchOptions);
|
|
||||||
const browserContext = await browser.newContext(browserConfig.contextOptions);
|
|
||||||
return { browser, browserContext };
|
|
||||||
} 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function launchPersistentContext(browserConfig: FullConfig['browser']): Promise<BrowserContextAndBrowser> {
|
|
||||||
try {
|
|
||||||
const browserName = browserConfig.browserName ?? 'chromium';
|
|
||||||
const userDataDir = browserConfig.userDataDir ?? await createUserDataDir({ ...browserConfig, browserName });
|
|
||||||
const browserType = playwright[browserName];
|
|
||||||
const browserContext = await browserType.launchPersistentContext(userDataDir, { ...browserConfig.launchOptions, ...browserConfig.contextOptions });
|
|
||||||
return { browserContext };
|
|
||||||
} 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createUserDataDir(browserConfig: FullConfig['browser']) {
|
|
||||||
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-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
|
|
||||||
await fs.promises.mkdir(result, { recursive: true });
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
24
src/index.ts
24
src/index.ts
@ -16,10 +16,30 @@
|
|||||||
|
|
||||||
import { Connection, createConnection as createConnectionImpl } from './connection.js';
|
import { Connection, createConnection as createConnectionImpl } from './connection.js';
|
||||||
import { resolveConfig } from './config.js';
|
import { resolveConfig } from './config.js';
|
||||||
|
import { contextFactory } from './browserContextFactory.js';
|
||||||
|
|
||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
|
import type { BrowserContext } from 'playwright';
|
||||||
|
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||||
|
|
||||||
export async function createConnection(userConfig: Config = {}): Promise<Connection> {
|
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Connection> {
|
||||||
const config = await resolveConfig(userConfig);
|
const config = await resolveConfig(userConfig);
|
||||||
return createConnectionImpl(config);
|
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config.browser);
|
||||||
|
return createConnectionImpl(config, factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
class SimpleBrowserContextFactory implements BrowserContextFactory {
|
||||||
|
private readonly _contextGetter: () => Promise<BrowserContext>;
|
||||||
|
|
||||||
|
constructor(contextGetter: () => Promise<BrowserContext>) {
|
||||||
|
this._contextGetter = contextGetter;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContext(): Promise<{ browserContext: BrowserContext, close: () => Promise<void> }> {
|
||||||
|
const browserContext = await this._contextGetter();
|
||||||
|
return {
|
||||||
|
browserContext,
|
||||||
|
close: () => browserContext.close()
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -15,21 +15,27 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createConnection } from './connection.js';
|
import { createConnection } from './connection.js';
|
||||||
|
import { contextFactory } from './browserContextFactory.js';
|
||||||
|
|
||||||
import type { FullConfig } from './config.js';
|
import type { FullConfig } from './config.js';
|
||||||
import type { Connection } from './connection.js';
|
import type { Connection } from './connection.js';
|
||||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
|
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||||
|
|
||||||
export class Server {
|
export class Server {
|
||||||
readonly config: FullConfig;
|
readonly config: FullConfig;
|
||||||
private _connectionList: Connection[] = [];
|
private _connectionList: Connection[] = [];
|
||||||
|
private _browserConfig: FullConfig['browser'];
|
||||||
|
private _contextFactory: BrowserContextFactory;
|
||||||
|
|
||||||
constructor(config: FullConfig) {
|
constructor(config: FullConfig) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
this._browserConfig = config.browser;
|
||||||
|
this._contextFactory = contextFactory(this._browserConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createConnection(transport: Transport): Promise<Connection> {
|
async createConnection(transport: Transport): Promise<Connection> {
|
||||||
const connection = createConnection(this.config);
|
const connection = createConnection(this.config, this._contextFactory);
|
||||||
this._connectionList.push(connection);
|
this._connectionList.push(connection);
|
||||||
await connection.server.connect(transport);
|
await connection.server.connect(transport);
|
||||||
return connection;
|
return connection;
|
||||||
|
@ -18,6 +18,7 @@ import http from 'node:http';
|
|||||||
import assert from 'node:assert';
|
import assert from 'node:assert';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
import debug from 'debug';
|
||||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
@ -28,6 +29,8 @@ export async function startStdioTransport(server: Server) {
|
|||||||
await server.createConnection(new StdioServerTransport());
|
await server.createConnection(new StdioServerTransport());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const testDebug = debug('pw:mcp:test');
|
||||||
|
|
||||||
async function handleSSE(server: Server, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
|
async function handleSSE(server: Server, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
|
||||||
if (req.method === 'POST') {
|
if (req.method === 'POST') {
|
||||||
const sessionId = url.searchParams.get('sessionId');
|
const sessionId = url.searchParams.get('sessionId');
|
||||||
@ -46,8 +49,10 @@ async function handleSSE(server: Server, req: http.IncomingMessage, res: http.Se
|
|||||||
} else if (req.method === 'GET') {
|
} else if (req.method === 'GET') {
|
||||||
const transport = new SSEServerTransport('/sse', res);
|
const transport = new SSEServerTransport('/sse', res);
|
||||||
sessions.set(transport.sessionId, transport);
|
sessions.set(transport.sessionId, transport);
|
||||||
|
testDebug(`create SSE session: ${transport.sessionId}`);
|
||||||
const connection = await server.createConnection(transport);
|
const connection = await server.createConnection(transport);
|
||||||
res.on('close', () => {
|
res.on('close', () => {
|
||||||
|
testDebug(`delete SSE session: ${transport.sessionId}`);
|
||||||
sessions.delete(transport.sessionId);
|
sessions.delete(transport.sessionId);
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
void connection.close().catch(e => console.error(e));
|
void connection.close().catch(e => console.error(e));
|
||||||
|
@ -15,7 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import url from 'node:url';
|
import url from 'node:url';
|
||||||
import { spawn } from 'node:child_process';
|
import { ChildProcess, spawn } from 'node:child_process';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
@ -26,35 +26,200 @@ import { test as baseTest, expect } from './fixtures.js';
|
|||||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||||
const __filename = url.fileURLToPath(import.meta.url);
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
const test = baseTest.extend<{ serverEndpoint: string }>({
|
const test = baseTest.extend<{ serverEndpoint: (args?: string[]) => Promise<{ url: URL, stderr: () => string }> }>({
|
||||||
serverEndpoint: async ({}, use) => {
|
serverEndpoint: async ({ mcpHeadless }, use, testInfo) => {
|
||||||
const cp = spawn('node', [path.join(path.dirname(__filename), '../cli.js'), '--port', '0'], { stdio: 'pipe' });
|
let cp: ChildProcess | undefined;
|
||||||
try {
|
const userDataDir = testInfo.outputPath('user-data-dir');
|
||||||
|
await use(async (args?: string[]) => {
|
||||||
|
if (cp)
|
||||||
|
throw new Error('Process already running');
|
||||||
|
|
||||||
|
cp = spawn('node', [
|
||||||
|
path.join(path.dirname(__filename), '../cli.js'),
|
||||||
|
'--port=0',
|
||||||
|
'--user-data-dir=' + userDataDir,
|
||||||
|
...(mcpHeadless ? ['--headless'] : []),
|
||||||
|
...(args || []),
|
||||||
|
], {
|
||||||
|
stdio: 'pipe',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DEBUG: 'pw:mcp:test',
|
||||||
|
DEBUG_COLORS: '0',
|
||||||
|
DEBUG_HIDE_DATE: '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
const url = await new Promise<string>(resolve => cp.stderr?.on('data', data => {
|
const url = await new Promise<string>(resolve => cp!.stderr?.on('data', data => {
|
||||||
stderr += data.toString();
|
stderr += data.toString();
|
||||||
const match = stderr.match(/Listening on (http:\/\/.*)/);
|
const match = stderr.match(/Listening on (http:\/\/.*)/);
|
||||||
if (match)
|
if (match)
|
||||||
resolve(match[1]);
|
resolve(match[1]);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await use(url);
|
return { url: new URL(url), stderr: () => stderr };
|
||||||
} finally {
|
});
|
||||||
cp.kill();
|
cp?.kill('SIGTERM');
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sse transport', async ({ serverEndpoint }) => {
|
test('sse transport', async ({ serverEndpoint }) => {
|
||||||
const transport = new SSEClientTransport(new URL(serverEndpoint));
|
const { url } = await serverEndpoint();
|
||||||
|
const transport = new SSEClientTransport(url);
|
||||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||||
await client.connect(transport);
|
await client.connect(transport);
|
||||||
await client.ping();
|
await client.ping();
|
||||||
await client.close();
|
});
|
||||||
|
|
||||||
|
test('sse transport browser lifecycle (isolated)', async ({ serverEndpoint, server }) => {
|
||||||
|
const { url, stderr } = await serverEndpoint(['--isolated']);
|
||||||
|
|
||||||
|
const transport1 = new SSEClientTransport(url);
|
||||||
|
const client1 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client1.connect(transport1);
|
||||||
|
await client1.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
await client1.close();
|
||||||
|
|
||||||
|
const transport2 = new SSEClientTransport(url);
|
||||||
|
const client2 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client2.connect(transport2);
|
||||||
|
await client2.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
await client2.close();
|
||||||
|
|
||||||
|
await expect(async () => {
|
||||||
|
const lines = stderr().split('\n');
|
||||||
|
expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(2);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(2);
|
||||||
|
}).toPass();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sse transport browser lifecycle (isolated, multiclient)', async ({ serverEndpoint, server }) => {
|
||||||
|
const { url, stderr } = await serverEndpoint(['--isolated']);
|
||||||
|
|
||||||
|
const transport1 = new SSEClientTransport(url);
|
||||||
|
const client1 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client1.connect(transport1);
|
||||||
|
await client1.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
|
||||||
|
const transport2 = new SSEClientTransport(url);
|
||||||
|
const client2 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client2.connect(transport2);
|
||||||
|
await client2.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
await client1.close();
|
||||||
|
|
||||||
|
const transport3 = new SSEClientTransport(url);
|
||||||
|
const client3 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client3.connect(transport3);
|
||||||
|
await client3.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
|
||||||
|
await client2.close();
|
||||||
|
await client3.close();
|
||||||
|
|
||||||
|
await expect(async () => {
|
||||||
|
const lines = stderr().split('\n');
|
||||||
|
expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(3);
|
||||||
|
expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(3);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/create context/)).length).toBe(3);
|
||||||
|
expect(lines.filter(line => line.match(/close context/)).length).toBe(3);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(3);
|
||||||
|
expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(3);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(1);
|
||||||
|
expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(1);
|
||||||
|
}).toPass();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sse transport browser lifecycle (persistent)', async ({ serverEndpoint, server }) => {
|
||||||
|
const { url, stderr } = await serverEndpoint();
|
||||||
|
|
||||||
|
const transport1 = new SSEClientTransport(url);
|
||||||
|
const client1 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client1.connect(transport1);
|
||||||
|
await client1.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
await client1.close();
|
||||||
|
|
||||||
|
const transport2 = new SSEClientTransport(url);
|
||||||
|
const client2 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client2.connect(transport2);
|
||||||
|
await client2.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
await client2.close();
|
||||||
|
|
||||||
|
await expect(async () => {
|
||||||
|
const lines = stderr().split('\n');
|
||||||
|
expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/create browser context \(persistent\)/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/close browser context \(persistent\)/)).length).toBe(2);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/lock user data dir/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/release user data dir/)).length).toBe(2);
|
||||||
|
}).toPass();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sse transport browser lifecycle (persistent, multiclient)', async ({ serverEndpoint, server }) => {
|
||||||
|
const { url } = await serverEndpoint();
|
||||||
|
|
||||||
|
const transport1 = new SSEClientTransport(url);
|
||||||
|
const client1 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client1.connect(transport1);
|
||||||
|
await client1.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
|
||||||
|
const transport2 = new SSEClientTransport(url);
|
||||||
|
const client2 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client2.connect(transport2);
|
||||||
|
const response = await client2.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
expect(response.isError).toBe(true);
|
||||||
|
expect(response.content?.[0].text).toContain('use --isolated to run multiple instances of the same browser');
|
||||||
|
|
||||||
|
await client1.close();
|
||||||
|
await client2.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('streamable http transport', async ({ serverEndpoint }) => {
|
test('streamable http transport', async ({ serverEndpoint }) => {
|
||||||
const transport = new StreamableHTTPClientTransport(new URL('/mcp', serverEndpoint));
|
const { url } = await serverEndpoint();
|
||||||
|
const transport = new StreamableHTTPClientTransport(new URL('/mcp', url));
|
||||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||||
await client.connect(transport);
|
await client.connect(transport);
|
||||||
await client.ping();
|
await client.ping();
|
||||||
|
Loading…
x
Reference in New Issue
Block a user