diff --git a/README.md b/README.md index 5933c88..99e52d9 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ Playwright MCP server supports following arguments. They can be provided in the --block-service-workers block service workers --browser browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge. + --browser-agent Use browser agent (experimental). --caps comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all. diff --git a/config.d.ts b/config.d.ts index 1c010aa..a935918 100644 --- a/config.d.ts +++ b/config.d.ts @@ -23,6 +23,11 @@ export type Config = { * The browser to use. */ browser?: { + /** + * Use browser agent (experimental). + */ + browserAgent?: string; + /** * The type of browser to use. */ diff --git a/package-lock.json b/package-lock.json index eed8bf6..c556e4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@modelcontextprotocol/sdk": "^1.11.0", "commander": "^13.1.0", "debug": "^4.4.1", + "mime": "^4.0.7", "playwright": "1.53.0-alpha-2025-05-27", "zod-to-json-schema": "^3.24.4" }, @@ -853,16 +854,16 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.1.0.tgz", - "integrity": "sha512-/hPxh61E+ll0Ujp24Ilm64cykicul1ypfwjVttduAiEdtnJFvLePSrIPk+HMImtNv5270wOGCb1Tns2rybMkoQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", - "iconv-lite": "^0.5.2", + "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", @@ -872,21 +873,6 @@ "node": ">=18" } }, - "node_modules/body-parser/node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1220,16 +1206,6 @@ "node": ">= 0.8" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -1765,46 +1741,45 @@ } }, "node_modules/express": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.0.1.tgz", - "integrity": "sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.0.1", + "body-parser": "^2.2.0", "content-disposition": "^1.0.0", - "content-type": "~1.0.4", - "cookie": "0.7.1", + "content-type": "^1.0.5", + "cookie": "^0.7.1", "cookie-signature": "^1.2.1", - "debug": "4.3.6", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "^2.0.0", - "fresh": "2.0.0", - "http-errors": "2.0.0", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", - "methods": "~1.1.2", "mime-types": "^3.0.0", - "on-finished": "2.4.1", - "once": "1.4.0", - "parseurl": "~1.3.3", - "proxy-addr": "~2.0.7", - "qs": "6.13.0", - "range-parser": "~1.2.1", - "router": "^2.0.0", - "safe-buffer": "5.2.1", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", "send": "^1.1.0", - "serve-static": "^2.1.0", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "^2.0.0", - "utils-merge": "1.0.1", - "vary": "~1.1.2" + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" }, "engines": { "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express-rate-limit": { @@ -1822,29 +1797,6 @@ "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": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2308,12 +2260,12 @@ } }, "node_modules/iconv-lite": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.2.tgz", - "integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" @@ -2924,15 +2876,6 @@ "node": ">= 8" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/metric-lcs": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/metric-lcs/-/metric-lcs-0.1.2.tgz", @@ -2954,6 +2897,21 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz", + "integrity": "sha512-2OfDPL+e03E0LrXaGYOtTFIYhiuzep94NSsuhrNULq+stylcJedcHdzHtz0atMUuGwJfFYs0YL5xeC/Ca2x0eQ==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -2964,12 +2922,12 @@ } }, "node_modules/mime-types": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz", - "integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", "license": "MIT", "dependencies": { - "mime-db": "^1.53.0" + "mime-db": "^1.54.0" }, "engines": { "node": ">= 0.6" @@ -3367,12 +3325,12 @@ } }, "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -3426,18 +3384,6 @@ "node": ">= 0.8" } }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -3525,11 +3471,13 @@ } }, "node_modules/router": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.1.0.tgz", - "integrity": "sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "license": "MIT", "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" @@ -3657,19 +3605,18 @@ } }, "node_modules/send": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.1.0.tgz", - "integrity": "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", "license": "MIT", "dependencies": { "debug": "^4.3.5", - "destroy": "^1.2.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", - "fresh": "^0.5.2", + "fresh": "^2.0.0", "http-errors": "^2.0.0", - "mime-types": "^2.1.35", + "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", @@ -3679,46 +3626,16 @@ "node": ">= 18" } }, - "node_modules/send/node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/send/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/send/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/serve-static": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz", - "integrity": "sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", - "send": "^1.0.0" + "send": "^1.2.0" }, "engines": { "node": ">= 18" @@ -4051,9 +3968,9 @@ } }, "node_modules/type-is": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.0.tgz", - "integrity": "sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", "dependencies": { "content-type": "^1.0.5", @@ -4201,15 +4118,6 @@ "punycode": "^2.1.0" } }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index e9e4623..4492291 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@modelcontextprotocol/sdk": "^1.11.0", "commander": "^13.1.0", "debug": "^4.4.1", + "mime": "^4.0.7", "playwright": "1.53.0-alpha-2025-05-27", "zod-to-json-schema": "^3.24.4" }, diff --git a/src/browserAgent.ts b/src/browserAgent.ts new file mode 100644 index 0000000..67ec6f7 --- /dev/null +++ b/src/browserAgent.ts @@ -0,0 +1,197 @@ +/** + * 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. + */ + +/* eslint-disable no-console */ + +import net from 'net'; + +import { program } from 'commander'; +import playwright from 'playwright'; + +import { HttpServer } from './httpServer.js'; +import { packageJSON } from './package.js'; + +import type http from 'http'; + +export type LaunchBrowserRequest = { + browserType: string; + userDataDir: string; + launchOptions: playwright.LaunchOptions; + contextOptions: playwright.BrowserContextOptions; +}; + +export type BrowserInfo = { + browserType: string; + userDataDir: string; + cdpPort: number; + launchOptions: playwright.LaunchOptions; + contextOptions: playwright.BrowserContextOptions; + error?: string; +}; + +type BrowserEntry = { + browser?: playwright.Browser; + info: BrowserInfo; +}; + +class Agent { + private _server = new HttpServer(); + private _entries: BrowserEntry[] = []; + + constructor() { + this._setupExitHandler(); + } + + async start(port: number) { + await this._server.start({ port }); + this._server.routePath('/json/list', (req, res) => { + this._handleJsonList(res); + }); + this._server.routePath('/json/launch', async (req, res) => { + void this._handleLaunchBrowser(req, res).catch(e => console.error(e)); + }); + this._setEntries([]); + } + + private _handleJsonList(res: http.ServerResponse) { + const list = this._entries.map(browser => browser.info); + res.end(JSON.stringify(list)); + } + + private async _handleLaunchBrowser(req: http.IncomingMessage, res: http.ServerResponse) { + const request = await readBody(req); + let info = this._entries.map(entry => entry.info).find(info => info.userDataDir === request.userDataDir); + if (!info || info.error) + info = await this._newBrowser(request); + res.end(JSON.stringify(info)); + } + + private async _newBrowser(request: LaunchBrowserRequest): Promise { + const cdpPort = await findFreePort(); + (request.launchOptions as any).cdpPort = cdpPort; + const info: BrowserInfo = { + browserType: request.browserType, + userDataDir: request.userDataDir, + cdpPort, + launchOptions: request.launchOptions, + contextOptions: request.contextOptions, + }; + + const browserType = playwright[request.browserType as 'chromium' | 'firefox' | 'webkit']; + const { browser, error } = await browserType.launchPersistentContext(request.userDataDir, { + ...request.launchOptions, + ...request.contextOptions, + handleSIGINT: false, + handleSIGTERM: false, + }).then(context => { + return { browser: context.browser()!, error: undefined }; + }).catch(error => { + return { browser: undefined, error: error.message }; + }); + this._setEntries([...this._entries, { + browser, + info: { + browserType: request.browserType, + userDataDir: request.userDataDir, + cdpPort, + launchOptions: request.launchOptions, + contextOptions: request.contextOptions, + error, + }, + }]); + browser?.on('disconnected', () => { + this._setEntries(this._entries.filter(entry => entry.browser !== browser)); + }); + return info; + } + + private _updateReport() { + // Clear the current line and move cursor to top of screen + process.stdout.write('\x1b[2J\x1b[H'); + process.stdout.write(`Playwright Browser agent v${packageJSON.version}\n`); + process.stdout.write(`Listening on ${this._server.urlPrefix('human-readable')}\n\n`); + + if (this._entries.length === 0) { + process.stdout.write('No browsers currently running\n'); + return; + } + + process.stdout.write('Running browsers:\n'); + for (const entry of this._entries) { + const status = entry.browser ? 'running' : 'error'; + const statusColor = entry.browser ? '\x1b[32m' : '\x1b[31m'; // green for running, red for error + process.stdout.write(`${statusColor}${entry.info.browserType}\x1b[0m (${entry.info.userDataDir}) - ${statusColor}${status}\x1b[0m\n`); + if (entry.info.error) + process.stdout.write(` Error: ${entry.info.error}\n`); + } + + } + + private _setEntries(entries: BrowserEntry[]) { + this._entries = entries; + this._updateReport(); + } + + private _setupExitHandler() { + let isExiting = false; + const handleExit = async () => { + if (isExiting) + return; + isExiting = true; + setTimeout(() => process.exit(0), 15000); + for (const entry of this._entries) + await entry.browser?.close().catch(() => {}); + process.exit(0); + }; + + process.stdin.on('close', handleExit); + process.on('SIGINT', handleExit); + process.on('SIGTERM', handleExit); + } +} + +program + .name('browser-agent') + .option('-p, --port ', 'Port to listen on', '9224') + .action(async options => { + await main(options); + }); + +void program.parseAsync(process.argv); + +async function main(options: { port: string }) { + const agent = new Agent(); + await agent.start(+options.port); +} + +function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString()))); + }); +} + +async function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, () => { + const { port } = server.address() as net.AddressInfo; + server.close(() => resolve(port)); + }); + server.on('error', reject); + }); +} diff --git a/src/browserContextFactory.ts b/src/browserContextFactory.ts index 723d0a1..92fbf18 100644 --- a/src/browserContextFactory.ts +++ b/src/browserContextFactory.ts @@ -15,13 +15,16 @@ */ import fs from 'node:fs'; -import os from 'node:os'; +import net from 'node:net'; import path from 'node:path'; +import os from 'node:os'; import debug from 'debug'; import * as playwright from 'playwright'; +import { userDataDir } from './fileUtils.js'; import type { FullConfig } from './config.js'; +import type { BrowserInfo, LaunchBrowserRequest } from './browserAgent.js'; const testDebug = debug('pw:mcp:test'); @@ -32,6 +35,8 @@ export function contextFactory(browserConfig: FullConfig['browser']): BrowserCon return new CdpContextFactory(browserConfig); if (browserConfig.isolated) return new IsolatedContextFactory(browserConfig); + if (browserConfig.browserAgent) + return new AgentContextFactory(browserConfig); return new PersistentContextFactory(browserConfig); } @@ -97,6 +102,7 @@ class IsolatedContextFactory extends BaseContextFactory { } protected override async _doObtainBrowser(): Promise { + await injectCdpPort(this.browserConfig); const browserType = playwright[this.browserConfig.browserName]; return browserType.launch({ ...this.browserConfig.launchOptions, @@ -155,6 +161,7 @@ class PersistentContextFactory implements BrowserContextFactory { } async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { + await injectCdpPort(this.browserConfig); testDebug('create browser context (persistent)'); const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir(); @@ -209,3 +216,51 @@ class PersistentContextFactory implements BrowserContextFactory { return result; } } + +export class AgentContextFactory extends BaseContextFactory { + constructor(browserConfig: FullConfig['browser']) { + super('persistent', browserConfig); + } + + protected override async _doObtainBrowser(): Promise { + const response = await fetch(new URL(`/json/launch`, this.browserConfig.browserAgent), { + method: 'POST', + body: JSON.stringify({ + browserType: this.browserConfig.browserName, + userDataDir: this.browserConfig.userDataDir ?? await this._createUserDataDir(), + launchOptions: this.browserConfig.launchOptions, + contextOptions: this.browserConfig.contextOptions, + } as LaunchBrowserRequest), + }); + const info = await response.json() as BrowserInfo; + if (info.error) + throw new Error(info.error); + return await playwright.chromium.connectOverCDP(`http://localhost:${info.cdpPort}/`); + } + + protected override async _doCreateContext(browser: playwright.Browser): Promise { + return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0]; + } + + private async _createUserDataDir() { + const dir = await userDataDir(this.browserConfig); + await fs.promises.mkdir(dir, { recursive: true }); + return dir; + } +} + +async function injectCdpPort(browserConfig: FullConfig['browser']) { + if (browserConfig.browserName === 'chromium') + (browserConfig.launchOptions as any).cdpPort = await findFreePort(); +} + +async function findFreePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, () => { + const { port } = server.address() as net.AddressInfo; + server.close(() => resolve(port)); + }); + server.on('error', reject); + }); +} diff --git a/src/config.ts b/src/config.ts index cb7825e..f25e5a2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,7 +15,6 @@ */ import fs from 'fs'; -import net from 'net'; import os from 'os'; import path from 'path'; import { devices } from 'playwright'; @@ -29,6 +28,7 @@ export type CLIOptions = { blockedOrigins?: string[]; blockServiceWorkers?: boolean; browser?: string; + browserAgent?: string; caps?: string; cdpEndpoint?: string; config?: string; @@ -96,8 +96,6 @@ export async function resolveCLIConfig(cliOptions: CLIOptions): Promise { - const server = net.createServer(); - server.listen(0, () => { - const { port } = server.address() as net.AddressInfo; - server.close(() => resolve(port)); - }); - server.on('error', reject); - }); -} - async function loadConfig(configFile: string | undefined): Promise { if (!configFile) return {}; @@ -232,6 +220,8 @@ function pickDefined(obj: T | undefined): Partial { function mergeConfig(base: FullConfig, overrides: Config): FullConfig { const browser: FullConfig['browser'] = { + ...pickDefined(base.browser), + ...pickDefined(overrides.browser), browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium', isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false, launchOptions: { @@ -243,9 +233,6 @@ function mergeConfig(base: FullConfig, overrides: Config): FullConfig { ...pickDefined(base.browser?.contextOptions), ...pickDefined(overrides.browser?.contextOptions), }, - userDataDir: overrides.browser?.userDataDir ?? base.browser?.userDataDir, - cdpEndpoint: overrides.browser?.cdpEndpoint ?? base.browser?.cdpEndpoint, - remoteEndpoint: overrides.browser?.remoteEndpoint ?? base.browser?.remoteEndpoint, }; if (browser.browserName !== 'chromium' && browser.launchOptions) diff --git a/src/fileUtils.ts b/src/fileUtils.ts new file mode 100644 index 0000000..4155b74 --- /dev/null +++ b/src/fileUtils.ts @@ -0,0 +1,37 @@ +/** + * 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 os from 'node:os'; +import path from 'node:path'; + +import type { FullConfig } from './config.js'; + +export function cacheDir() { + 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); + return path.join(cacheDirectory, 'ms-playwright'); +} + +export async function userDataDir(browserConfig: FullConfig['browser']) { + return path.join(cacheDir(), 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`); +} diff --git a/src/httpServer.ts b/src/httpServer.ts new file mode 100644 index 0000000..9e67bef --- /dev/null +++ b/src/httpServer.ts @@ -0,0 +1,232 @@ +/** + * 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 'fs'; +import path from 'path'; +import http from 'http'; +import net from 'net'; + +import mime from 'mime'; + +import { ManualPromise } from './manualPromise.js'; + + +export type ServerRouteHandler = (request: http.IncomingMessage, response: http.ServerResponse) => void; + +export type Transport = { + sendEvent?: (method: string, params: any) => void; + close?: () => void; + onconnect: () => void; + dispatch: (method: string, params: any) => Promise; + onclose: () => void; +}; + +export class HttpServer { + private _server: http.Server; + private _urlPrefixPrecise: string = ''; + private _urlPrefixHumanReadable: string = ''; + private _port: number = 0; + private _routes: { prefix?: string, exact?: string, handler: ServerRouteHandler }[] = []; + + constructor() { + this._server = http.createServer(this._onRequest.bind(this)); + decorateServer(this._server); + } + + server() { + return this._server; + } + + routePrefix(prefix: string, handler: ServerRouteHandler) { + this._routes.push({ prefix, handler }); + } + + routePath(path: string, handler: ServerRouteHandler) { + this._routes.push({ exact: path, handler }); + } + + port(): number { + return this._port; + } + + private async _tryStart(port: number | undefined, host: string) { + const errorPromise = new ManualPromise(); + const errorListener = (error: Error) => errorPromise.reject(error); + this._server.on('error', errorListener); + + try { + this._server.listen(port, host); + await Promise.race([ + new Promise(cb => this._server!.once('listening', cb)), + errorPromise, + ]); + } finally { + this._server.removeListener('error', errorListener); + } + } + + async start(options: { port?: number, preferredPort?: number, host?: string } = {}): Promise { + const host = options.host || 'localhost'; + if (options.preferredPort) { + try { + await this._tryStart(options.preferredPort, host); + } catch (e: any) { + if (!e || !e.message || !e.message.includes('EADDRINUSE')) + throw e; + await this._tryStart(undefined, host); + } + } else { + await this._tryStart(options.port, host); + } + + const address = this._server.address(); + if (typeof address === 'string') { + this._urlPrefixPrecise = address; + this._urlPrefixHumanReadable = address; + } else { + this._port = address!.port; + const resolvedHost = address!.family === 'IPv4' ? address!.address : `[${address!.address}]`; + this._urlPrefixPrecise = `http://${resolvedHost}:${address!.port}`; + this._urlPrefixHumanReadable = `http://${host}:${address!.port}`; + } + } + + async stop() { + await new Promise(cb => this._server!.close(cb)); + } + + urlPrefix(purpose: 'human-readable' | 'precise'): string { + return purpose === 'human-readable' ? this._urlPrefixHumanReadable : this._urlPrefixPrecise; + } + + serveFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string, headers?: { [name: string]: string }): boolean { + try { + for (const [name, value] of Object.entries(headers || {})) + response.setHeader(name, value); + if (request.headers.range) + this._serveRangeFile(request, response, absoluteFilePath); + else + this._serveFile(response, absoluteFilePath); + return true; + } catch (e) { + return false; + } + } + + _serveFile(response: http.ServerResponse, absoluteFilePath: string) { + const content = fs.readFileSync(absoluteFilePath); + response.statusCode = 200; + const contentType = mime.getType(path.extname(absoluteFilePath)) || 'application/octet-stream'; + response.setHeader('Content-Type', contentType); + response.setHeader('Content-Length', content.byteLength); + response.end(content); + } + + _serveRangeFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string) { + const range = request.headers.range; + if (!range || !range.startsWith('bytes=') || range.includes(', ') || [...range].filter(char => char === '-').length !== 1) { + response.statusCode = 400; + return response.end('Bad request'); + } + + // Parse the range header: https://datatracker.ietf.org/doc/html/rfc7233#section-2.1 + const [startStr, endStr] = range.replace(/bytes=/, '').split('-'); + + // Both start and end (when passing to fs.createReadStream) and the range header are inclusive and start counting at 0. + let start: number; + let end: number; + const size = fs.statSync(absoluteFilePath).size; + if (startStr !== '' && endStr === '') { + // No end specified: use the whole file + start = +startStr; + end = size - 1; + } else if (startStr === '' && endStr !== '') { + // No start specified: calculate start manually + start = size - +endStr; + end = size - 1; + } else { + start = +startStr; + end = +endStr; + } + + // Handle unavailable range request + if (Number.isNaN(start) || Number.isNaN(end) || start >= size || end >= size || start > end) { + // Return the 416 Range Not Satisfiable: https://datatracker.ietf.org/doc/html/rfc7233#section-4.4 + response.writeHead(416, { + 'Content-Range': `bytes */${size}` + }); + return response.end(); + } + + // Sending Partial Content: https://datatracker.ietf.org/doc/html/rfc7233#section-4.1 + response.writeHead(206, { + 'Content-Range': `bytes ${start}-${end}/${size}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': end - start + 1, + 'Content-Type': mime.getType(path.extname(absoluteFilePath))!, + }); + + const readable = fs.createReadStream(absoluteFilePath, { start, end }); + readable.pipe(response); + } + + private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) { + if (request.method === 'OPTIONS') { + response.writeHead(200); + response.end(); + return; + } + + request.on('error', () => response.end()); + try { + if (!request.url) { + response.end(); + return; + } + const url = new URL('http://localhost' + request.url); + for (const route of this._routes) { + if (route.exact && url.pathname === route.exact) { + route.handler(request, response); + return; + } + if (route.prefix && url.pathname.startsWith(route.prefix)) { + route.handler(request, response); + return; + } + } + response.statusCode = 404; + response.end(); + } catch (e) { + response.end(); + } + } +} + +function decorateServer(server: net.Server) { + const sockets = new Set(); + server.on('connection', socket => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + }); + + const close = server.close; + server.close = (callback?: (err?: Error) => void) => { + for (const socket of sockets) + socket.destroy(); + sockets.clear(); + return close.call(server, callback); + }; +} diff --git a/src/program.ts b/src/program.ts index 8f1dd7c..537a244 100644 --- a/src/program.ts +++ b/src/program.ts @@ -30,6 +30,7 @@ program .option('--blocked-origins ', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList) .option('--block-service-workers', 'block service workers') .option('--browser ', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.') + .option('--browser-agent ', 'Use browser agent (experimental).') .option('--caps ', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.') .option('--cdp-endpoint ', 'CDP endpoint to connect to.') .option('--config ', 'path to the configuration file.') diff --git a/tests/agent.spec.ts b/tests/agent.spec.ts new file mode 100644 index 0000000..ce2a8ee --- /dev/null +++ b/tests/agent.spec.ts @@ -0,0 +1,77 @@ +/** + * 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 path from 'path'; +import url from 'node:url'; + +import { spawn } from 'child_process'; +import { test as baseTest, expect } from './fixtures.js'; + +import type { ChildProcess } from 'child_process'; + +const __filename = url.fileURLToPath(import.meta.url); + +const test = baseTest.extend<{ agentEndpoint: (options?: { args?: string[] }) => Promise<{ url: URL, stdout: () => string }> }>({ + agentEndpoint: async ({}, use) => { + let cp: ChildProcess | undefined; + await use(async (options?: { args?: string[] }) => { + if (cp) + throw new Error('Process already running'); + + cp = spawn('node', [ + path.join(path.dirname(__filename), '../lib/browserAgent.js'), + ...(options?.args || []), + ], { + stdio: 'pipe', + env: { + ...process.env, + DEBUG: 'pw:mcp:test', + DEBUG_COLORS: '0', + DEBUG_HIDE_DATE: '1', + }, + }); + let stdout = ''; + const url = await new Promise(resolve => cp!.stdout?.on('data', data => { + stdout += data.toString(); + const match = stdout.match(/Listening on (http:\/\/.*)/); + if (match) + resolve(match[1]); + })); + + return { url: new URL(url), stdout: () => stdout }; + }); + cp?.kill('SIGTERM'); + }, +}); + +test.skip(({ mcpBrowser }) => mcpBrowser !== 'chrome', 'Agent is CDP-only for now'); + +test('browser lifecycle', async ({ agentEndpoint, startClient, server }) => { + const { url: agentUrl } = await agentEndpoint(); + const { client: client1 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] }); + expect(await client1.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toContainTextContent('Hello, world!'); + + const { client: client2 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] }); + expect(await client2.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toContainTextContent('Hello, world!'); + + await client1.close(); + await client2.close(); +}); diff --git a/tests/fixtures.ts b/tests/fixtures.ts index e207d7e..5c6c166 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -65,12 +65,14 @@ export const test = baseTest.extend( }, startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => { - const userDataDir = testInfo.outputPath('user-data-dir'); + const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined; const configDir = path.dirname(test.info().config.configFile!); let client: Client | undefined; await use(async options => { - const args = ['--user-data-dir', path.relative(configDir, userDataDir)]; + const args: string[] = []; + if (userDataDir) + args.push('--user-data-dir', userDataDir); if (process.env.CI && process.platform === 'linux') args.push('--no-sandbox'); if (mcpHeadless) @@ -239,5 +241,5 @@ export const expect = baseExpect.extend({ }); export function formatOutput(output: string): string[] { - return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/test-results.*/, '').trim()).filter(Boolean); + return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').trim()).filter(Boolean); }