Compare commits
28 Commits
v0.0.34
...
vscode-cli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76ba7f7bb6 | ||
|
|
dc149c19c0 | ||
|
|
74e3ab5267 | ||
|
|
d12b5aab18 | ||
|
|
922002e435 | ||
|
|
21e03968c5 | ||
|
|
ee59735f42 | ||
|
|
da5b0c6fdd | ||
|
|
35c464ef5b | ||
|
|
f6862a39c3 | ||
|
|
e664e0460c | ||
|
|
865eac2fee | ||
|
|
fcd953c097 | ||
|
|
14b931d25d | ||
|
|
bcbc2fecb8 | ||
|
|
5a0cfb9e65 | ||
|
|
1ff80f8761 | ||
|
|
98fef06b3b | ||
|
|
affe1d7ed9 | ||
|
|
cc61b67c14 | ||
|
|
7a814d5cd4 | ||
|
|
39c384850f | ||
|
|
f8a61de332 | ||
|
|
9d17572403 | ||
|
|
0741b8bee8 | ||
|
|
0d0783be07 | ||
|
|
001fa6f2fb | ||
|
|
e884b3aacb |
@@ -49,7 +49,7 @@ class TabShareExtension {
|
|||||||
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
|
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case 'connectToMCPRelay':
|
case 'connectToMCPRelay':
|
||||||
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl!).then(
|
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl).then(
|
||||||
() => sendResponse({ success: true }),
|
() => sendResponse({ success: true }),
|
||||||
(error: any) => sendResponse({ success: false, error: error.message }));
|
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||||
return true;
|
return true;
|
||||||
@@ -96,8 +96,9 @@ class TabShareExtension {
|
|||||||
this._pendingTabSelection.set(selectorTabId, { connection });
|
this._pendingTabSelection.set(selectorTabId, { connection });
|
||||||
debugLog(`Connected to MCP relay`);
|
debugLog(`Connected to MCP relay`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
debugLog(`Failed to connect to MCP relay:`, error.message);
|
const message = `Failed to connect to MCP relay: ${error.message}`;
|
||||||
throw error;
|
debugLog(message);
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,15 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import { Button, TabItem } from './tabItem.js';
|
import { Button, TabItem } from './tabItem.js';
|
||||||
import type { TabInfo } from './tabItem.js';
|
import type { TabInfo } from './tabItem.js';
|
||||||
|
|
||||||
type StatusType = 'connected' | 'error' | 'connecting';
|
type Status =
|
||||||
|
| { type: 'connecting'; message: string }
|
||||||
|
| { type: 'connected'; message: string }
|
||||||
|
| { type: 'error'; message: string }
|
||||||
|
| { type: 'error'; versionMismatch: { pwMcpVersion: string; extensionVersion: string } };
|
||||||
|
|
||||||
const ConnectApp: React.FC = () => {
|
const ConnectApp: React.FC = () => {
|
||||||
const [tabs, setTabs] = useState<TabInfo[]>([]);
|
const [tabs, setTabs] = useState<TabInfo[]>([]);
|
||||||
const [status, setStatus] = useState<{ type: StatusType; message: string } | null>(null);
|
const [status, setStatus] = useState<Status | null>(null);
|
||||||
const [showButtons, setShowButtons] = useState(true);
|
const [showButtons, setShowButtons] = useState(true);
|
||||||
const [showTabList, setShowTabList] = useState(true);
|
const [showTabList, setShowTabList] = useState(true);
|
||||||
const [clientInfo, setClientInfo] = useState('unknown');
|
const [clientInfo, setClientInfo] = useState('unknown');
|
||||||
@@ -54,15 +58,37 @@ const ConnectApp: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pwMcpVersion = params.get('pwMcpVersion');
|
||||||
|
const extensionVersion = chrome.runtime.getManifest().version;
|
||||||
|
if (pwMcpVersion !== extensionVersion) {
|
||||||
|
setShowButtons(false);
|
||||||
|
setShowTabList(false);
|
||||||
|
setStatus({
|
||||||
|
type: 'error',
|
||||||
|
versionMismatch: {
|
||||||
|
pwMcpVersion: pwMcpVersion || 'unknown',
|
||||||
|
extensionVersion
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void connectToMCPRelay(relayUrl);
|
void connectToMCPRelay(relayUrl);
|
||||||
void loadTabs();
|
void loadTabs();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleReject = useCallback((message: string) => {
|
||||||
|
setShowButtons(false);
|
||||||
|
setShowTabList(false);
|
||||||
|
setStatus({ type: 'error', message });
|
||||||
|
}, []);
|
||||||
|
|
||||||
const connectToMCPRelay = useCallback(async (mcpRelayUrl: string) => {
|
const connectToMCPRelay = useCallback(async (mcpRelayUrl: string) => {
|
||||||
|
|
||||||
const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl });
|
const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl });
|
||||||
if (!response.success)
|
if (!response.success)
|
||||||
setStatus({ type: 'error', message: 'Failed to connect to MCP relay: ' + response.error });
|
handleReject(response.error);
|
||||||
}, []);
|
}, [handleReject]);
|
||||||
|
|
||||||
const loadTabs = useCallback(async () => {
|
const loadTabs = useCallback(async () => {
|
||||||
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
|
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
|
||||||
@@ -100,31 +126,25 @@ const ConnectApp: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [clientInfo, mcpRelayUrl]);
|
}, [clientInfo, mcpRelayUrl]);
|
||||||
|
|
||||||
const handleReject = useCallback(() => {
|
|
||||||
setShowButtons(false);
|
|
||||||
setShowTabList(false);
|
|
||||||
setStatus({ type: 'error', message: 'Connection rejected. This tab can be closed.' });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = (message: any) => {
|
const listener = (message: any) => {
|
||||||
if (message.type === 'connectionTimeout')
|
if (message.type === 'connectionTimeout')
|
||||||
handleReject();
|
handleReject('Connection timed out.');
|
||||||
};
|
};
|
||||||
chrome.runtime.onMessage.addListener(listener);
|
chrome.runtime.onMessage.addListener(listener);
|
||||||
return () => {
|
return () => {
|
||||||
chrome.runtime.onMessage.removeListener(listener);
|
chrome.runtime.onMessage.removeListener(listener);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [handleReject]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='app-container'>
|
<div className='app-container'>
|
||||||
<div className='content-wrapper'>
|
<div className='content-wrapper'>
|
||||||
{status && (
|
{status && (
|
||||||
<div className='status-container'>
|
<div className='status-container'>
|
||||||
<StatusBanner type={status.type} message={status.message} />
|
<StatusBanner status={status} />
|
||||||
{showButtons && (
|
{showButtons && (
|
||||||
<Button variant='reject' onClick={handleReject}>
|
<Button variant='reject' onClick={() => handleReject('Connection rejected. This tab can be closed.')}>
|
||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -156,8 +176,27 @@ const ConnectApp: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StatusBanner: React.FC<{ type: StatusType; message: string }> = ({ type, message }) => {
|
const VersionMismatchError: React.FC<{ pwMcpVersion: string; extensionVersion: string }> = ({ pwMcpVersion, extensionVersion }) => {
|
||||||
return <div className={`status-banner ${type}`}>{message}</div>;
|
const readmeUrl = 'https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md';
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
Incompatible Playwright MCP version: {pwMcpVersion} (extension version: {extensionVersion}).
|
||||||
|
Please install the latest version of the extension.{' '}
|
||||||
|
See <a href={readmeUrl} target='_blank' rel='noopener noreferrer'>installation instructions</a>.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusBanner: React.FC<{ status: Status }> = ({ status }) => {
|
||||||
|
return (
|
||||||
|
<div className={`status-banner ${status.type}`}>
|
||||||
|
{'versionMismatch' in status ? (
|
||||||
|
<VersionMismatchError pwMcpVersion={status.versionMismatch.pwMcpVersion} extensionVersion={status.versionMismatch.extensionVersion} />
|
||||||
|
) : (
|
||||||
|
status.message
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize the React app
|
// Initialize the React app
|
||||||
|
|||||||
@@ -14,12 +14,15 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
|
import packageJSON from '../../package.json' assert { type: 'json' };
|
||||||
import { test as base, expect } from '../../tests/fixtures.js';
|
import { test as base, expect } from '../../tests/fixtures.js';
|
||||||
|
|
||||||
import type { BrowserContext } from 'playwright';
|
|
||||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import type { BrowserContext } from 'playwright';
|
||||||
import type { StartClient } from '../../tests/fixtures.js';
|
import type { StartClient } from '../../tests/fixtures.js';
|
||||||
|
|
||||||
type BrowserWithExtension = {
|
type BrowserWithExtension = {
|
||||||
@@ -27,14 +30,22 @@ type BrowserWithExtension = {
|
|||||||
launch: (mode?: 'disable-extension') => Promise<BrowserContext>;
|
launch: (mode?: 'disable-extension') => Promise<BrowserContext>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({
|
type TestFixtures = {
|
||||||
browserWithExtension: async ({ mcpBrowser }, use, testInfo) => {
|
browserWithExtension: BrowserWithExtension,
|
||||||
|
pathToExtension: string,
|
||||||
|
useShortConnectionTimeout: (timeoutMs: number) => void
|
||||||
|
};
|
||||||
|
|
||||||
|
const test = base.extend<TestFixtures>({
|
||||||
|
pathToExtension: async ({}, use) => {
|
||||||
|
await use(fileURLToPath(new URL('../dist', import.meta.url)));
|
||||||
|
},
|
||||||
|
|
||||||
|
browserWithExtension: async ({ mcpBrowser, pathToExtension }, use, testInfo) => {
|
||||||
// The flags no longer work in Chrome since
|
// The flags no longer work in Chrome since
|
||||||
// https://chromium.googlesource.com/chromium/src/+/290ed8046692651ce76088914750cb659b65fb17%5E%21/chrome/browser/extensions/extension_service.cc?pli=1#
|
// https://chromium.googlesource.com/chromium/src/+/290ed8046692651ce76088914750cb659b65fb17%5E%21/chrome/browser/extensions/extension_service.cc?pli=1#
|
||||||
test.skip('chromium' !== mcpBrowser, '--load-extension is not supported for official builds of Chromium');
|
test.skip('chromium' !== mcpBrowser, '--load-extension is not supported for official builds of Chromium');
|
||||||
|
|
||||||
const pathToExtension = fileURLToPath(new URL('../dist', import.meta.url));
|
|
||||||
|
|
||||||
let browserContext: BrowserContext | undefined;
|
let browserContext: BrowserContext | undefined;
|
||||||
const userDataDir = testInfo.outputPath('extension-user-data-dir');
|
const userDataDir = testInfo.outputPath('extension-user-data-dir');
|
||||||
await use({
|
await use({
|
||||||
@@ -60,9 +71,16 @@ const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({
|
|||||||
return browserContext;
|
return browserContext;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await browserContext?.close();
|
await browserContext?.close();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
useShortConnectionTimeout: async ({}, use) => {
|
||||||
|
await use((timeoutMs: number) => {
|
||||||
|
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = timeoutMs.toString();
|
||||||
|
});
|
||||||
|
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined;
|
||||||
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function startAndCallConnectTool(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
|
async function startAndCallConnectTool(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
|
||||||
@@ -99,6 +117,21 @@ async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const testWithOldVersion = test.extend({
|
||||||
|
pathToExtension: async ({}, use, testInfo) => {
|
||||||
|
const extensionDir = testInfo.outputPath('extension');
|
||||||
|
const oldPath = fileURLToPath(new URL('../dist', import.meta.url));
|
||||||
|
|
||||||
|
await fs.promises.cp(oldPath, extensionDir, { recursive: true });
|
||||||
|
const manifestPath = path.join(extensionDir, 'manifest.json');
|
||||||
|
const manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8'));
|
||||||
|
manifest.version = '0.0.1';
|
||||||
|
await fs.promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
||||||
|
|
||||||
|
await use(extensionDir);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
for (const [mode, startClientMethod] of [
|
for (const [mode, startClientMethod] of [
|
||||||
['connect-tool', startAndCallConnectTool],
|
['connect-tool', startAndCallConnectTool],
|
||||||
['extension-flag', startWithExtensionFlag],
|
['extension-flag', startWithExtensionFlag],
|
||||||
@@ -160,8 +193,8 @@ for (const [mode, startClientMethod] of [
|
|||||||
expect(browserContext.pages()).toHaveLength(4);
|
expect(browserContext.pages()).toHaveLength(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server }) => {
|
test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
|
||||||
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = '100';
|
useShortConnectionTimeout(100);
|
||||||
|
|
||||||
const browserContext = await browserWithExtension.launch();
|
const browserContext = await browserWithExtension.launch();
|
||||||
|
|
||||||
@@ -180,8 +213,32 @@ for (const [mode, startClientMethod] of [
|
|||||||
});
|
});
|
||||||
|
|
||||||
await confirmationPagePromise;
|
await confirmationPagePromise;
|
||||||
|
});
|
||||||
|
|
||||||
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined;
|
testWithOldVersion(`extension version mismatch (${mode})`, async ({ browserWithExtension, startClient, server, useShortConnectionTimeout }) => {
|
||||||
|
useShortConnectionTimeout(500);
|
||||||
|
|
||||||
|
// Prelaunch the browser, so that it is properly closed after the test.
|
||||||
|
const browserContext = await browserWithExtension.launch();
|
||||||
|
|
||||||
|
const client = await startClientMethod(browserWithExtension, startClient);
|
||||||
|
|
||||||
|
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
|
||||||
|
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigateResponse = client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmationPage = await confirmationPagePromise;
|
||||||
|
await expect(confirmationPage.locator('.status-banner')).toHaveText(`Incompatible Playwright MCP version: ${packageJSON.version} (extension version: 0.0.1). Please install the latest version of the extension. See installation instructions.`);
|
||||||
|
|
||||||
|
expect(await navigateResponse).toHaveResponse({
|
||||||
|
result: expect.stringContaining('Extension connection timeout.'),
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,27 +42,23 @@ export function contextFactory(config: FullConfig): BrowserContextFactory {
|
|||||||
export type ClientInfo = { name?: string, version?: string, rootPath?: string };
|
export type ClientInfo = { name?: string, version?: string, rootPath?: string };
|
||||||
|
|
||||||
export interface BrowserContextFactory {
|
export interface BrowserContextFactory {
|
||||||
readonly name: string;
|
|
||||||
readonly description: string;
|
|
||||||
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
|
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
class BaseContextFactory implements BrowserContextFactory {
|
class BaseContextFactory implements BrowserContextFactory {
|
||||||
readonly name: string;
|
|
||||||
readonly description: string;
|
|
||||||
readonly config: FullConfig;
|
readonly config: FullConfig;
|
||||||
|
private _logName: string;
|
||||||
protected _browserPromise: Promise<playwright.Browser> | undefined;
|
protected _browserPromise: Promise<playwright.Browser> | undefined;
|
||||||
|
|
||||||
constructor(name: string, description: string, config: FullConfig) {
|
constructor(name: string, config: FullConfig) {
|
||||||
this.name = name;
|
this._logName = name;
|
||||||
this.description = description;
|
|
||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async _obtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
|
protected async _obtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
|
||||||
if (this._browserPromise)
|
if (this._browserPromise)
|
||||||
return this._browserPromise;
|
return this._browserPromise;
|
||||||
testDebug(`obtain browser (${this.name})`);
|
testDebug(`obtain browser (${this._logName})`);
|
||||||
this._browserPromise = this._doObtainBrowser(clientInfo);
|
this._browserPromise = this._doObtainBrowser(clientInfo);
|
||||||
void this._browserPromise.then(browser => {
|
void this._browserPromise.then(browser => {
|
||||||
browser.on('disconnected', () => {
|
browser.on('disconnected', () => {
|
||||||
@@ -79,7 +75,7 @@ class BaseContextFactory implements BrowserContextFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
testDebug(`create browser context (${this.name})`);
|
testDebug(`create browser context (${this._logName})`);
|
||||||
const browser = await this._obtainBrowser(clientInfo);
|
const browser = await this._obtainBrowser(clientInfo);
|
||||||
const browserContext = await this._doCreateContext(browser);
|
const browserContext = await this._doCreateContext(browser);
|
||||||
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
|
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
|
||||||
@@ -90,12 +86,12 @@ class BaseContextFactory implements BrowserContextFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) {
|
private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) {
|
||||||
testDebug(`close browser context (${this.name})`);
|
testDebug(`close browser context (${this._logName})`);
|
||||||
if (browser.contexts().length === 1)
|
if (browser.contexts().length === 1)
|
||||||
this._browserPromise = undefined;
|
this._browserPromise = undefined;
|
||||||
await browserContext.close().catch(logUnhandledError);
|
await browserContext.close().catch(logUnhandledError);
|
||||||
if (browser.contexts().length === 0) {
|
if (browser.contexts().length === 0) {
|
||||||
testDebug(`close browser (${this.name})`);
|
testDebug(`close browser (${this._logName})`);
|
||||||
await browser.close().catch(logUnhandledError);
|
await browser.close().catch(logUnhandledError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,7 +99,7 @@ class BaseContextFactory implements BrowserContextFactory {
|
|||||||
|
|
||||||
class IsolatedContextFactory extends BaseContextFactory {
|
class IsolatedContextFactory extends BaseContextFactory {
|
||||||
constructor(config: FullConfig) {
|
constructor(config: FullConfig) {
|
||||||
super('isolated', 'Create a new isolated browser context', config);
|
super('isolated', config);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
|
protected override async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
|
||||||
@@ -128,7 +124,7 @@ class IsolatedContextFactory extends BaseContextFactory {
|
|||||||
|
|
||||||
class CdpContextFactory extends BaseContextFactory {
|
class CdpContextFactory extends BaseContextFactory {
|
||||||
constructor(config: FullConfig) {
|
constructor(config: FullConfig) {
|
||||||
super('cdp', 'Connect to a browser over CDP', config);
|
super('cdp', config);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
@@ -142,7 +138,7 @@ class CdpContextFactory extends BaseContextFactory {
|
|||||||
|
|
||||||
class RemoteContextFactory extends BaseContextFactory {
|
class RemoteContextFactory extends BaseContextFactory {
|
||||||
constructor(config: FullConfig) {
|
constructor(config: FullConfig) {
|
||||||
super('remote', 'Connect to a browser using a remote endpoint', config);
|
super('remote', config);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { logUnhandledError } from './utils/log.js';
|
|||||||
import { Response } from './response.js';
|
import { Response } from './response.js';
|
||||||
import { SessionLog } from './sessionLog.js';
|
import { SessionLog } from './sessionLog.js';
|
||||||
import { filteredTools } from './tools.js';
|
import { filteredTools } from './tools.js';
|
||||||
import { packageJSON } from './utils/package.js';
|
|
||||||
import { toMcpTool } from './mcp/tool.js';
|
import { toMcpTool } from './mcp/tool.js';
|
||||||
|
|
||||||
import type { Tool } from './tools/tool.js';
|
import type { Tool } from './tools/tool.js';
|
||||||
@@ -30,9 +29,6 @@ import type * as mcpServer from './mcp/server.js';
|
|||||||
import type { ServerBackend } from './mcp/server.js';
|
import type { ServerBackend } from './mcp/server.js';
|
||||||
|
|
||||||
export class BrowserServerBackend implements ServerBackend {
|
export class BrowserServerBackend implements ServerBackend {
|
||||||
name = 'Playwright';
|
|
||||||
version = packageJSON.version;
|
|
||||||
|
|
||||||
private _tools: Tool[];
|
private _tools: Tool[];
|
||||||
private _context: Context | undefined;
|
private _context: Context | undefined;
|
||||||
private _sessionLog: SessionLog | undefined;
|
private _sessionLog: SessionLog | undefined;
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ import { spawn } from 'child_process';
|
|||||||
import http from 'http';
|
import http from 'http';
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import { WebSocket, WebSocketServer } from 'ws';
|
import { WebSocket, WebSocketServer } from 'ws';
|
||||||
import { httpAddressToString } from '../utils/httpServer.js';
|
import { httpAddressToString } from '../mcp/http.js';
|
||||||
import { logUnhandledError } from '../utils/log.js';
|
import { logUnhandledError } from '../utils/log.js';
|
||||||
import { ManualPromise } from '../utils/manualPromise.js';
|
import { ManualPromise } from '../utils/manualPromise.js';
|
||||||
|
import { packageJSON } from '../utils/package.js';
|
||||||
|
|
||||||
import type websocket from 'ws';
|
import type websocket from 'ws';
|
||||||
import type { ClientInfo } from '../browserContextFactory.js';
|
import type { ClientInfo } from '../browserContextFactory.js';
|
||||||
|
|
||||||
@@ -113,7 +115,12 @@ export class CDPRelayServer {
|
|||||||
// Need to specify "key" in the manifest.json to make the id stable when loading from file.
|
// Need to specify "key" in the manifest.json to make the id stable when loading from file.
|
||||||
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
const url = new URL('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||||
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
|
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
|
||||||
url.searchParams.set('client', JSON.stringify(clientInfo));
|
const client = {
|
||||||
|
name: clientInfo.name,
|
||||||
|
version: clientInfo.version,
|
||||||
|
};
|
||||||
|
url.searchParams.set('client', JSON.stringify(client));
|
||||||
|
url.searchParams.set('pwMcpVersion', packageJSON.version);
|
||||||
const href = url.toString();
|
const href = url.toString();
|
||||||
const executableInfo = registry.findExecutable(this._browserChannel);
|
const executableInfo = registry.findExecutable(this._browserChannel);
|
||||||
if (!executableInfo)
|
if (!executableInfo)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
import { startHttpServer } from '../utils/httpServer.js';
|
import { startHttpServer } from '../mcp/http.js';
|
||||||
import { CDPRelayServer } from './cdpRelay.js';
|
import { CDPRelayServer } from './cdpRelay.js';
|
||||||
|
|
||||||
import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
|
import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
|
||||||
@@ -24,9 +24,6 @@ import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory
|
|||||||
const debugLogger = debug('pw:mcp:relay');
|
const debugLogger = debug('pw:mcp:relay');
|
||||||
|
|
||||||
export class ExtensionContextFactory implements BrowserContextFactory {
|
export class ExtensionContextFactory implements BrowserContextFactory {
|
||||||
name = 'extension';
|
|
||||||
description = 'Connect to a browser using the Playwright MCP extension';
|
|
||||||
|
|
||||||
private _browserChannel: string;
|
private _browserChannel: string;
|
||||||
private _userDataDir?: string;
|
private _userDataDir?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { BrowserServerBackend } from './browserServerBackend.js';
|
|||||||
import { resolveConfig } from './config.js';
|
import { resolveConfig } from './config.js';
|
||||||
import { contextFactory } from './browserContextFactory.js';
|
import { contextFactory } from './browserContextFactory.js';
|
||||||
import * as mcpServer from './mcp/server.js';
|
import * as mcpServer from './mcp/server.js';
|
||||||
|
import { packageJSON } from './utils/package.js';
|
||||||
|
|
||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
import type { BrowserContext } from 'playwright';
|
import type { BrowserContext } from 'playwright';
|
||||||
@@ -27,7 +28,7 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|||||||
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Server> {
|
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Server> {
|
||||||
const config = await resolveConfig(userConfig);
|
const config = await resolveConfig(userConfig);
|
||||||
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config);
|
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config);
|
||||||
return mcpServer.createServer(new BrowserServerBackend(config, factory), false);
|
return mcpServer.createServer('Playwright', packageJSON.version, new BrowserServerBackend(config, factory), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
class SimpleBrowserContextFactory implements BrowserContextFactory {
|
class SimpleBrowserContextFactory implements BrowserContextFactory {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { OpenAIDelegate } from '../loop/loopOpenAI.js';
|
|||||||
import { ClaudeDelegate } from '../loop/loopClaude.js';
|
import { ClaudeDelegate } from '../loop/loopClaude.js';
|
||||||
import { InProcessTransport } from '../mcp/inProcessTransport.js';
|
import { InProcessTransport } from '../mcp/inProcessTransport.js';
|
||||||
import * as mcpServer from '../mcp/server.js';
|
import * as mcpServer from '../mcp/server.js';
|
||||||
|
import { packageJSON } from '../utils/package.js';
|
||||||
|
|
||||||
import type { LLMDelegate } from '../loop/loop.js';
|
import type { LLMDelegate } from '../loop/loop.js';
|
||||||
import type { FullConfig } from '../config.js';
|
import type { FullConfig } from '../config.js';
|
||||||
@@ -44,9 +45,9 @@ export class Context {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async create(config: FullConfig) {
|
static async create(config: FullConfig) {
|
||||||
const client = new Client({ name: 'Playwright Proxy', version: '1.0.0' });
|
const client = new Client({ name: 'Playwright Proxy', version: packageJSON.version });
|
||||||
const browserContextFactory = contextFactory(config);
|
const browserContextFactory = contextFactory(config);
|
||||||
const server = mcpServer.createServer(new BrowserServerBackend(config, browserContextFactory), false);
|
const server = mcpServer.createServer('Playwright Subagent', packageJSON.version, new BrowserServerBackend(config, browserContextFactory), false);
|
||||||
await client.connect(new InProcessTransport(server));
|
await client.connect(new InProcessTransport(server));
|
||||||
await client.ping();
|
await client.ping();
|
||||||
return new Context(config, client);
|
return new Context(config, client);
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
import * as mcpServer from '../mcp/server.js';
|
import * as mcpServer from '../mcp/server.js';
|
||||||
import * as mcpTransport from '../mcp/transport.js';
|
|
||||||
import { packageJSON } from '../utils/package.js';
|
import { packageJSON } from '../utils/package.js';
|
||||||
import { Context } from './context.js';
|
import { Context } from './context.js';
|
||||||
import { perform } from './perform.js';
|
import { perform } from './perform.js';
|
||||||
@@ -30,13 +29,16 @@ import type { Tool } from './tool.js';
|
|||||||
|
|
||||||
export async function runLoopTools(config: FullConfig) {
|
export async function runLoopTools(config: FullConfig) {
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
const serverBackendFactory = () => new LoopToolsServerBackend(config);
|
const serverBackendFactory = {
|
||||||
await mcpTransport.start(serverBackendFactory, config.server);
|
name: 'Playwright',
|
||||||
|
nameInConfig: 'playwright-loop',
|
||||||
|
version: packageJSON.version,
|
||||||
|
create: () => new LoopToolsServerBackend(config)
|
||||||
|
};
|
||||||
|
await mcpServer.start(serverBackendFactory, config.server);
|
||||||
}
|
}
|
||||||
|
|
||||||
class LoopToolsServerBackend implements ServerBackend {
|
class LoopToolsServerBackend implements ServerBackend {
|
||||||
readonly name = 'Playwright';
|
|
||||||
readonly version = packageJSON.version;
|
|
||||||
private _config: FullConfig;
|
private _config: FullConfig;
|
||||||
private _context: Context | undefined;
|
private _context: Context | undefined;
|
||||||
private _tools: Tool<any>[] = [perform, snapshot];
|
private _tools: Tool<any>[] = [perform, snapshot];
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
[*]
|
[*]
|
||||||
../utils/
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
- Generic MCP utils, no dependencies on Playwright here.
|
- Generic MCP utils, no dependencies on anything.
|
||||||
|
|||||||
@@ -14,33 +14,61 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import assert from 'assert';
|
||||||
|
import net from 'net';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
|
||||||
import debug from 'debug';
|
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 { httpAddressToString, startHttpServer } from '../utils/httpServer.js';
|
|
||||||
import * as mcpServer from './server.js';
|
import * as mcpServer from './server.js';
|
||||||
|
|
||||||
import type { ServerBackendFactory } from './server.js';
|
import type { ServerBackendFactory } from './server.js';
|
||||||
|
|
||||||
export async function start(serverBackendFactory: ServerBackendFactory, options: { host?: string; port?: number }) {
|
|
||||||
if (options.port !== undefined) {
|
|
||||||
const httpServer = await startHttpServer(options);
|
|
||||||
startHttpTransport(httpServer, serverBackendFactory);
|
|
||||||
} else {
|
|
||||||
await startStdioTransport(serverBackendFactory);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startStdioTransport(serverBackendFactory: ServerBackendFactory) {
|
|
||||||
await mcpServer.connect(serverBackendFactory, new StdioServerTransport(), false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const testDebug = debug('pw:mcp:test');
|
const testDebug = debug('pw:mcp:test');
|
||||||
|
|
||||||
|
export async function startHttpServer(config: { host?: string, port?: number }, abortSignal?: AbortSignal): Promise<http.Server> {
|
||||||
|
const { host, port } = config;
|
||||||
|
const httpServer = http.createServer();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
httpServer.on('error', reject);
|
||||||
|
abortSignal?.addEventListener('abort', () => {
|
||||||
|
httpServer.close();
|
||||||
|
reject(new Error('Aborted'));
|
||||||
|
});
|
||||||
|
httpServer.listen(port, host, () => {
|
||||||
|
resolve();
|
||||||
|
httpServer.removeListener('error', reject);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return httpServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function httpAddressToString(address: string | net.AddressInfo | null): string {
|
||||||
|
assert(address, 'Could not bind server socket');
|
||||||
|
if (typeof address === 'string')
|
||||||
|
return address;
|
||||||
|
const resolvedPort = address.port;
|
||||||
|
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
|
||||||
|
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
|
||||||
|
resolvedHost = 'localhost';
|
||||||
|
return `http://${resolvedHost}:${resolvedPort}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installHttpTransport(httpServer: http.Server, serverBackendFactory: ServerBackendFactory) {
|
||||||
|
const sseSessions = new Map();
|
||||||
|
const streamableSessions = new Map();
|
||||||
|
httpServer.on('request', async (req, res) => {
|
||||||
|
const url = new URL(`http://localhost${req.url}`);
|
||||||
|
if (url.pathname.startsWith('/sse'))
|
||||||
|
await handleSSE(serverBackendFactory, req, res, url, sseSessions);
|
||||||
|
else
|
||||||
|
await handleStreamable(serverBackendFactory, req, res, streamableSessions);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSSE(serverBackendFactory: ServerBackendFactory, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
|
async function handleSSE(serverBackendFactory: ServerBackendFactory, 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');
|
||||||
@@ -108,30 +136,3 @@ async function handleStreamable(serverBackendFactory: ServerBackendFactory, req:
|
|||||||
res.statusCode = 400;
|
res.statusCode = 400;
|
||||||
res.end('Invalid request');
|
res.end('Invalid request');
|
||||||
}
|
}
|
||||||
|
|
||||||
function startHttpTransport(httpServer: http.Server, serverBackendFactory: ServerBackendFactory) {
|
|
||||||
const sseSessions = new Map();
|
|
||||||
const streamableSessions = new Map();
|
|
||||||
httpServer.on('request', async (req, res) => {
|
|
||||||
const url = new URL(`http://localhost${req.url}`);
|
|
||||||
if (url.pathname.startsWith('/sse'))
|
|
||||||
await handleSSE(serverBackendFactory, req, res, url, sseSessions);
|
|
||||||
else
|
|
||||||
await handleStreamable(serverBackendFactory, req, res, streamableSessions);
|
|
||||||
});
|
|
||||||
const url = httpAddressToString(httpServer.address());
|
|
||||||
const message = [
|
|
||||||
`Listening on ${url}`,
|
|
||||||
'Put this in your client config:',
|
|
||||||
JSON.stringify({
|
|
||||||
'mcpServers': {
|
|
||||||
'playwright': {
|
|
||||||
'url': `${url}/mcp`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, undefined, 2),
|
|
||||||
'For legacy SSE transport support, you can use the /sse endpoint instead.',
|
|
||||||
].join('\n');
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error(message);
|
|
||||||
}
|
|
||||||
@@ -14,14 +14,12 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import debug from 'debug';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||||
|
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
import { ListRootsRequestSchema, PingRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
import { ListRootsRequestSchema, PingRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { logUnhandledError } from '../utils/log.js';
|
|
||||||
import { packageJSON } from '../utils/package.js';
|
|
||||||
|
|
||||||
|
|
||||||
import type { ServerBackend, ClientVersion, Root } from './server.js';
|
import type { ServerBackend, ClientVersion, Root } from './server.js';
|
||||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
@@ -33,10 +31,9 @@ export type MCPProvider = {
|
|||||||
connect(): Promise<Transport>;
|
connect(): Promise<Transport>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class ProxyBackend implements ServerBackend {
|
const errorsDebug = debug('pw:mcp:errors');
|
||||||
name = 'Playwright MCP Client Switcher';
|
|
||||||
version = packageJSON.version;
|
|
||||||
|
|
||||||
|
export class ProxyBackend implements ServerBackend {
|
||||||
private _mcpProviders: MCPProvider[];
|
private _mcpProviders: MCPProvider[];
|
||||||
private _currentClient: Client | undefined;
|
private _currentClient: Client | undefined;
|
||||||
private _contextSwitchTool: Tool;
|
private _contextSwitchTool: Tool;
|
||||||
@@ -72,7 +69,7 @@ export class ProxyBackend implements ServerBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
serverClosed?(): void {
|
serverClosed?(): void {
|
||||||
void this._currentClient?.close().catch(logUnhandledError);
|
void this._currentClient?.close().catch(errorsDebug);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _callContextSwitchTool(params: any): Promise<CallToolResult> {
|
private async _callContextSwitchTool(params: any): Promise<CallToolResult> {
|
||||||
@@ -115,7 +112,7 @@ export class ProxyBackend implements ServerBackend {
|
|||||||
await this._currentClient?.close();
|
await this._currentClient?.close();
|
||||||
this._currentClient = undefined;
|
this._currentClient = undefined;
|
||||||
|
|
||||||
const client = new Client({ name: 'Playwright MCP Proxy', version: packageJSON.version });
|
const client = new Client({ name: 'Playwright MCP Proxy', version: '0.0.0' });
|
||||||
client.registerCapabilities({
|
client.registerCapabilities({
|
||||||
roots: {
|
roots: {
|
||||||
listRoots: true,
|
listRoots: true,
|
||||||
|
|||||||
@@ -15,10 +15,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
|
|
||||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { ManualPromise } from '../utils/manualPromise.js';
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
import { logUnhandledError } from '../utils/log.js';
|
import { httpAddressToString, installHttpTransport, startHttpServer } from './http.js';
|
||||||
|
import { InProcessTransport } from './inProcessTransport.js';
|
||||||
|
|
||||||
import type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js';
|
import type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
@@ -26,28 +28,37 @@ export type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|||||||
export type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js';
|
export type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
const serverDebug = debug('pw:mcp:server');
|
const serverDebug = debug('pw:mcp:server');
|
||||||
|
const errorsDebug = debug('pw:mcp:errors');
|
||||||
|
|
||||||
export type ClientVersion = { name: string, version: string };
|
export type ClientVersion = { name: string, version: string };
|
||||||
export interface ServerBackend {
|
export interface ServerBackend {
|
||||||
name: string;
|
|
||||||
version: string;
|
|
||||||
initialize?(clientVersion: ClientVersion, roots: Root[]): Promise<void>;
|
initialize?(clientVersion: ClientVersion, roots: Root[]): Promise<void>;
|
||||||
listTools(): Promise<Tool[]>;
|
listTools(): Promise<Tool[]>;
|
||||||
callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult>;
|
callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult>;
|
||||||
serverClosed?(): void;
|
serverClosed?(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerBackendFactory = () => ServerBackend;
|
export type ServerBackendFactory = {
|
||||||
|
name: string;
|
||||||
|
nameInConfig: string;
|
||||||
|
version: string;
|
||||||
|
create: () => ServerBackend;
|
||||||
|
};
|
||||||
|
|
||||||
export async function connect(serverBackendFactory: ServerBackendFactory, transport: Transport, runHeartbeat: boolean) {
|
export async function connect(factory: ServerBackendFactory, transport: Transport, runHeartbeat: boolean) {
|
||||||
const backend = serverBackendFactory();
|
const server = createServer(factory.name, factory.version, factory.create(), runHeartbeat);
|
||||||
const server = createServer(backend, runHeartbeat);
|
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServer(backend: ServerBackend, runHeartbeat: boolean): Server {
|
export async function wrapInProcess(backend: ServerBackend): Promise<Transport> {
|
||||||
const initializedPromise = new ManualPromise<void>();
|
const server = createServer('Internal', '0.0.0', backend, false);
|
||||||
const server = new Server({ name: backend.name, version: backend.version }, {
|
return new InProcessTransport(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServer(name: string, version: string, backend: ServerBackend, runHeartbeat: boolean): Server {
|
||||||
|
let initializedPromiseResolve = () => {};
|
||||||
|
const initializedPromise = new Promise<void>(resolve => initializedPromiseResolve = resolve);
|
||||||
|
const server = new Server({ name, version }, {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {},
|
tools: {},
|
||||||
}
|
}
|
||||||
@@ -89,9 +100,9 @@ export function createServer(backend: ServerBackend, runHeartbeat: boolean): Ser
|
|||||||
}
|
}
|
||||||
const clientVersion = server.getClientVersion() ?? { name: 'unknown', version: 'unknown' };
|
const clientVersion = server.getClientVersion() ?? { name: 'unknown', version: 'unknown' };
|
||||||
await backend.initialize?.(clientVersion, clientRoots);
|
await backend.initialize?.(clientVersion, clientRoots);
|
||||||
initializedPromise.resolve();
|
initializedPromiseResolve();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logUnhandledError(e);
|
errorsDebug(e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
addServerListener(server, 'close', () => backend.serverClosed?.());
|
addServerListener(server, 'close', () => backend.serverClosed?.());
|
||||||
@@ -120,3 +131,27 @@ function addServerListener(server: Server, event: 'close' | 'initialized', liste
|
|||||||
listener();
|
listener();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function start(serverBackendFactory: ServerBackendFactory, options: { host?: string; port?: number }) {
|
||||||
|
if (options.port === undefined) {
|
||||||
|
await connect(serverBackendFactory, new StdioServerTransport(), false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const httpServer = await startHttpServer(options);
|
||||||
|
await installHttpTransport(httpServer, serverBackendFactory);
|
||||||
|
const url = httpAddressToString(httpServer.address());
|
||||||
|
|
||||||
|
const mcpConfig: any = { mcpServers: { } };
|
||||||
|
mcpConfig.mcpServers[serverBackendFactory.nameInConfig] = {
|
||||||
|
url: `${url}/mcp`
|
||||||
|
};
|
||||||
|
const message = [
|
||||||
|
`Listening on ${url}`,
|
||||||
|
'Put this in your client config:',
|
||||||
|
JSON.stringify(mcpConfig, undefined, 2),
|
||||||
|
'For legacy SSE transport support, you can use the /sse endpoint instead.',
|
||||||
|
].join('\n');
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(message);
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
|
|
||||||
import { program, Option } from 'commander';
|
import { program, Option } from 'commander';
|
||||||
import * as mcpServer from './mcp/server.js';
|
import * as mcpServer from './mcp/server.js';
|
||||||
import * as mcpTransport from './mcp/transport.js';
|
|
||||||
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js';
|
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js';
|
||||||
import { packageJSON } from './utils/package.js';
|
import { packageJSON } from './utils/package.js';
|
||||||
import { Context } from './context.js';
|
import { Context } from './context.js';
|
||||||
@@ -25,11 +24,9 @@ import { runLoopTools } from './loopTools/main.js';
|
|||||||
import { ProxyBackend } from './mcp/proxyBackend.js';
|
import { ProxyBackend } from './mcp/proxyBackend.js';
|
||||||
import { BrowserServerBackend } from './browserServerBackend.js';
|
import { BrowserServerBackend } from './browserServerBackend.js';
|
||||||
import { ExtensionContextFactory } from './extension/extensionContextFactory.js';
|
import { ExtensionContextFactory } from './extension/extensionContextFactory.js';
|
||||||
import { InProcessTransport } from './mcp/inProcessTransport.js';
|
|
||||||
|
|
||||||
|
import { runVSCodeTools } from './vscode/host.js';
|
||||||
import type { MCPProvider } from './mcp/proxyBackend.js';
|
import type { MCPProvider } from './mcp/proxyBackend.js';
|
||||||
import type { FullConfig } from './config.js';
|
|
||||||
import type { BrowserContextFactory } from './browserContextFactory.js';
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.version('Version ' + packageJSON.version)
|
.version('Version ' + packageJSON.version)
|
||||||
@@ -61,6 +58,7 @@ program
|
|||||||
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
|
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
|
||||||
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
|
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
|
||||||
.addOption(new Option('--connect-tool', 'Allow to switch between different browser connection methods.').hideHelp())
|
.addOption(new Option('--connect-tool', 'Allow to switch between different browser connection methods.').hideHelp())
|
||||||
|
.addOption(new Option('--vscode', 'VS Code tools.').hideHelp())
|
||||||
.addOption(new Option('--loop-tools', 'Run loop tools').hideHelp())
|
.addOption(new Option('--loop-tools', 'Run loop tools').hideHelp())
|
||||||
.addOption(new Option('--vision', 'Legacy option, use --caps=vision instead').hideHelp())
|
.addOption(new Option('--vision', 'Legacy option, use --caps=vision instead').hideHelp())
|
||||||
.action(async options => {
|
.action(async options => {
|
||||||
@@ -71,12 +69,24 @@ program
|
|||||||
console.error('The --vision option is deprecated, use --caps=vision instead');
|
console.error('The --vision option is deprecated, use --caps=vision instead');
|
||||||
options.caps = 'vision';
|
options.caps = 'vision';
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await resolveCLIConfig(options);
|
const config = await resolveCLIConfig(options);
|
||||||
|
const browserContextFactory = contextFactory(config);
|
||||||
|
const extensionContextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
|
||||||
|
|
||||||
if (options.extension) {
|
if (options.extension) {
|
||||||
const contextFactory = createExtensionContextFactory(config);
|
const serverBackendFactory: mcpServer.ServerBackendFactory = {
|
||||||
const serverBackendFactory = () => new BrowserServerBackend(config, contextFactory);
|
name: 'Playwright w/ extension',
|
||||||
await mcpTransport.start(serverBackendFactory, config.server);
|
nameInConfig: 'playwright-extension',
|
||||||
|
version: packageJSON.version,
|
||||||
|
create: () => new BrowserServerBackend(config, extensionContextFactory)
|
||||||
|
};
|
||||||
|
await mcpServer.start(serverBackendFactory, config.server);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.vscode) {
|
||||||
|
await runVSCodeTools(config);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,11 +95,36 @@ program
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const browserContextFactory = contextFactory(config);
|
if (options.connectTool) {
|
||||||
const providers: MCPProvider[] = [mcpProviderForBrowserContextFactory(config, browserContextFactory)];
|
const providers: MCPProvider[] = [
|
||||||
if (options.connectTool)
|
{
|
||||||
providers.push(mcpProviderForBrowserContextFactory(config, createExtensionContextFactory(config)));
|
name: 'default',
|
||||||
await mcpTransport.start(() => new ProxyBackend(providers), config.server);
|
description: 'Starts standalone browser',
|
||||||
|
connect: () => mcpServer.wrapInProcess(new BrowserServerBackend(config, browserContextFactory)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'extension',
|
||||||
|
description: 'Connect to a browser using the Playwright MCP extension',
|
||||||
|
connect: () => mcpServer.wrapInProcess(new BrowserServerBackend(config, extensionContextFactory)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const factory: mcpServer.ServerBackendFactory = {
|
||||||
|
name: 'Playwright w/ switch',
|
||||||
|
nameInConfig: 'playwright-switch',
|
||||||
|
version: packageJSON.version,
|
||||||
|
create: () => new ProxyBackend(providers),
|
||||||
|
};
|
||||||
|
await mcpServer.start(factory, config.server);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const factory: mcpServer.ServerBackendFactory = {
|
||||||
|
name: 'Playwright',
|
||||||
|
nameInConfig: 'playwright',
|
||||||
|
version: packageJSON.version,
|
||||||
|
create: () => new BrowserServerBackend(config, browserContextFactory)
|
||||||
|
};
|
||||||
|
await mcpServer.start(factory, config.server);
|
||||||
});
|
});
|
||||||
|
|
||||||
function setupExitWatchdog() {
|
function setupExitWatchdog() {
|
||||||
@@ -108,19 +143,4 @@ function setupExitWatchdog() {
|
|||||||
process.on('SIGTERM', handleExit);
|
process.on('SIGTERM', handleExit);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createExtensionContextFactory(config: FullConfig) {
|
|
||||||
return new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mcpProviderForBrowserContextFactory(config: FullConfig, browserContextFactory: BrowserContextFactory) {
|
|
||||||
return {
|
|
||||||
name: browserContextFactory.name,
|
|
||||||
description: browserContextFactory.description,
|
|
||||||
connect: async () => {
|
|
||||||
const server = mcpServer.createServer(new BrowserServerBackend(config, browserContextFactory), false);
|
|
||||||
return new InProcessTransport(server);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
void program.parseAsync(process.argv);
|
void program.parseAsync(process.argv);
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 assert from 'assert';
|
|
||||||
import http from 'http';
|
|
||||||
|
|
||||||
import type * as net from 'net';
|
|
||||||
|
|
||||||
export async function startHttpServer(config: { host?: string, port?: number }): Promise<http.Server> {
|
|
||||||
const { host, port } = config;
|
|
||||||
const httpServer = http.createServer();
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
httpServer.on('error', reject);
|
|
||||||
httpServer.listen(port, host, () => {
|
|
||||||
resolve();
|
|
||||||
httpServer.removeListener('error', reject);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return httpServer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function httpAddressToString(address: string | net.AddressInfo | null): string {
|
|
||||||
assert(address, 'Could not bind server socket');
|
|
||||||
if (typeof address === 'string')
|
|
||||||
return address;
|
|
||||||
const resolvedPort = address.port;
|
|
||||||
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
|
|
||||||
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
|
|
||||||
resolvedHost = 'localhost';
|
|
||||||
return `http://${resolvedHost}:${resolvedPort}`;
|
|
||||||
}
|
|
||||||
6
src/vscode/DEPS.list
Normal file
6
src/vscode/DEPS.list
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[*]
|
||||||
|
../mcp/
|
||||||
|
../utils/
|
||||||
|
../config.js
|
||||||
|
../browserServerBackend.js
|
||||||
|
../browserContextFactory.js
|
||||||
149
src/vscode/host.ts
Normal file
149
src/vscode/host.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* 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 { z } from 'zod';
|
||||||
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||||
|
|
||||||
|
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { ListRootsRequestSchema, PingRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
|
import * as mcpServer from '../mcp/server.js';
|
||||||
|
import { logUnhandledError } from '../utils/log.js';
|
||||||
|
import { packageJSON } from '../utils/package.js';
|
||||||
|
|
||||||
|
import { FullConfig } from '../config.js';
|
||||||
|
import { BrowserServerBackend } from '../browserServerBackend.js';
|
||||||
|
import { contextFactory } from '../browserContextFactory.js';
|
||||||
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
|
import type { ClientVersion, ServerBackend } from '../mcp/server.js';
|
||||||
|
import type { Root, Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const contextSwitchOptions = z.object({
|
||||||
|
connectionString: z.string().optional().describe('The connection string to use to connect to the browser'),
|
||||||
|
lib: z.string().optional().describe('The library to use for the connection'),
|
||||||
|
});
|
||||||
|
|
||||||
|
class VSCodeProxyBackend implements ServerBackend {
|
||||||
|
name = 'Playwright MCP Client Switcher';
|
||||||
|
version = packageJSON.version;
|
||||||
|
|
||||||
|
private _currentClient: Client | undefined;
|
||||||
|
private _contextSwitchTool: Tool;
|
||||||
|
private _roots: Root[] = [];
|
||||||
|
private _clientVersion?: ClientVersion;
|
||||||
|
|
||||||
|
constructor(private readonly _config: FullConfig, private readonly _defaultTransportFactory: () => Promise<Transport>) {
|
||||||
|
this._contextSwitchTool = this._defineContextSwitchTool();
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(clientVersion: ClientVersion, roots: Root[]): Promise<void> {
|
||||||
|
this._clientVersion = clientVersion;
|
||||||
|
this._roots = roots;
|
||||||
|
const transport = await this._defaultTransportFactory();
|
||||||
|
await this._setCurrentClient(transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTools(): Promise<Tool[]> {
|
||||||
|
const response = await this._currentClient!.listTools();
|
||||||
|
return [
|
||||||
|
...response.tools,
|
||||||
|
this._contextSwitchTool,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult> {
|
||||||
|
if (name === this._contextSwitchTool.name)
|
||||||
|
return this._callContextSwitchTool(args as any);
|
||||||
|
return await this._currentClient!.callTool({
|
||||||
|
name,
|
||||||
|
arguments: args,
|
||||||
|
}) as CallToolResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
serverClosed?(): void {
|
||||||
|
void this._currentClient?.close().catch(logUnhandledError);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _callContextSwitchTool(params: z.infer<typeof contextSwitchOptions>): Promise<CallToolResult> {
|
||||||
|
if (!params.connectionString || !params.lib) {
|
||||||
|
const transport = await this._defaultTransportFactory();
|
||||||
|
await this._setCurrentClient(transport);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: '### Result\nSuccessfully disconnected.\n' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._setCurrentClient(
|
||||||
|
new StdioClientTransport({
|
||||||
|
command: process.execPath,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
args: [
|
||||||
|
path.join(fileURLToPath(import.meta.url), '..', 'main.js'),
|
||||||
|
JSON.stringify(this._config),
|
||||||
|
params.connectionString,
|
||||||
|
params.lib,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: '### Result\nSuccessfully connected.\n' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private _defineContextSwitchTool(): Tool {
|
||||||
|
return {
|
||||||
|
name: 'browser_connect',
|
||||||
|
description: 'Do not call, this tool is used in the integration with the Playwright VS Code Extension and meant for programmatic usage only.',
|
||||||
|
inputSchema: zodToJsonSchema(contextSwitchOptions, { strictUnions: true }) as Tool['inputSchema'],
|
||||||
|
annotations: {
|
||||||
|
title: 'Connect to a browser running in VS Code.',
|
||||||
|
readOnlyHint: true,
|
||||||
|
openWorldHint: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _setCurrentClient(transport: Transport) {
|
||||||
|
await this._currentClient?.close();
|
||||||
|
this._currentClient = undefined;
|
||||||
|
|
||||||
|
const client = new Client(this._clientVersion!);
|
||||||
|
client.registerCapabilities({
|
||||||
|
roots: {
|
||||||
|
listRoots: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
client.setRequestHandler(ListRootsRequestSchema, () => ({ roots: this._roots }));
|
||||||
|
client.setRequestHandler(PingRequestSchema, () => ({}));
|
||||||
|
|
||||||
|
await client.connect(transport);
|
||||||
|
this._currentClient = client;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runVSCodeTools(config: FullConfig) {
|
||||||
|
const serverBackendFactory: mcpServer.ServerBackendFactory = {
|
||||||
|
name: 'Playwright w/ vscode',
|
||||||
|
nameInConfig: 'playwright-vscode',
|
||||||
|
version: packageJSON.version,
|
||||||
|
create: () => new VSCodeProxyBackend(config, () => mcpServer.wrapInProcess(new BrowserServerBackend(config, contextFactory(config))))
|
||||||
|
};
|
||||||
|
await mcpServer.start(serverBackendFactory, config.server);
|
||||||
|
return;
|
||||||
|
}
|
||||||
75
src/vscode/main.ts
Normal file
75
src/vscode/main.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* 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 { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
import * as mcpServer from '../mcp/server.js';
|
||||||
|
import { BrowserServerBackend } from '../browserServerBackend.js';
|
||||||
|
import { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
|
||||||
|
import type { FullConfig } from '../config.js';
|
||||||
|
import type { BrowserContext } from 'playwright-core';
|
||||||
|
|
||||||
|
class VSCodeBrowserContextFactory implements BrowserContextFactory {
|
||||||
|
name = 'vscode';
|
||||||
|
description = 'Connect to a browser running in the Playwright VS Code extension';
|
||||||
|
|
||||||
|
constructor(private _config: FullConfig, private _playwright: typeof import('playwright'), private _connectionString: string) {}
|
||||||
|
|
||||||
|
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: BrowserContext; close: () => Promise<void>; }> {
|
||||||
|
let launchOptions: any = this._config.browser.launchOptions;
|
||||||
|
if (this._config.browser.userDataDir) {
|
||||||
|
launchOptions = {
|
||||||
|
...launchOptions,
|
||||||
|
...this._config.browser.contextOptions,
|
||||||
|
userDataDir: this._config.browser.userDataDir,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const connectionString = new URL(this._connectionString);
|
||||||
|
connectionString.searchParams.set('launch-options', JSON.stringify(launchOptions));
|
||||||
|
|
||||||
|
const browserType = this._playwright.chromium; // it could also be firefox or webkit, we just need some browser type to call `connect` on
|
||||||
|
const browser = await browserType.connect(connectionString.toString());
|
||||||
|
|
||||||
|
const context = browser.contexts()[0] ?? await browser.newContext(this._config.browser.contextOptions);
|
||||||
|
|
||||||
|
return {
|
||||||
|
browserContext: context,
|
||||||
|
close: async () => {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(config: FullConfig, connectionString: string, lib: string) {
|
||||||
|
const playwright = await import(lib).then(mod => mod.default ?? mod);
|
||||||
|
const factory = new VSCodeBrowserContextFactory(config, playwright, connectionString);
|
||||||
|
await mcpServer.connect(
|
||||||
|
{
|
||||||
|
name: 'Playwright MCP',
|
||||||
|
nameInConfig: 'playwright-vscode',
|
||||||
|
create: () => new BrowserServerBackend(config, factory),
|
||||||
|
version: 'unused'
|
||||||
|
},
|
||||||
|
new StdioServerTransport(),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await main(
|
||||||
|
JSON.parse(process.argv[2]),
|
||||||
|
process.argv[3],
|
||||||
|
process.argv[4]
|
||||||
|
);
|
||||||
54
tests/vscode.spec.ts
Normal file
54
tests/vscode.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* 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 { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
test('browser_connect(vscode) works', async ({ startClient, playwright, browserName }) => {
|
||||||
|
const { client } = await startClient({
|
||||||
|
args: ['--vscode'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = await playwright[browserName].launchServer();
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_connect',
|
||||||
|
arguments: {
|
||||||
|
connectionString: server.wsEndpoint(),
|
||||||
|
lib: import.meta.resolve('playwright'),
|
||||||
|
}
|
||||||
|
})).toHaveResponse({
|
||||||
|
result: 'Successfully connected.'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: 'data:text/html,foo'
|
||||||
|
}
|
||||||
|
})).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining('foo'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.close();
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_snapshot',
|
||||||
|
arguments: {}
|
||||||
|
}), 'it actually used the server').toHaveResponse({
|
||||||
|
isError: true,
|
||||||
|
result: expect.stringContaining('ECONNREFUSED')
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user