Pavel Feldman commited on
Commit
0fb9646
·
unverified ·
1 Parent(s): 9728527

chore: experimental agent mode (#516)

Browse files
README.md CHANGED
@@ -124,6 +124,7 @@ Playwright MCP server supports following arguments. They can be provided in the
124
  --block-service-workers block service workers
125
  --browser <browser> browser or chrome channel to use, possible
126
  values: chrome, firefox, webkit, msedge.
 
127
  --caps <caps> comma-separated list of capabilities to enable,
128
  possible values: tabs, pdf, history, wait, files,
129
  install. Default is all.
 
124
  --block-service-workers block service workers
125
  --browser <browser> browser or chrome channel to use, possible
126
  values: chrome, firefox, webkit, msedge.
127
+ --browser-agent <endpoint> Use browser agent (experimental).
128
  --caps <caps> comma-separated list of capabilities to enable,
129
  possible values: tabs, pdf, history, wait, files,
130
  install. Default is all.
config.d.ts CHANGED
@@ -23,6 +23,11 @@ export type Config = {
23
  * The browser to use.
24
  */
25
  browser?: {
 
 
 
 
 
26
  /**
27
  * The type of browser to use.
28
  */
 
23
  * The browser to use.
24
  */
25
  browser?: {
26
+ /**
27
+ * Use browser agent (experimental).
28
+ */
29
+ browserAgent?: string;
30
+
31
  /**
32
  * The type of browser to use.
33
  */
package-lock.json CHANGED
@@ -12,6 +12,7 @@
12
  "@modelcontextprotocol/sdk": "^1.11.0",
13
  "commander": "^13.1.0",
14
  "debug": "^4.4.1",
 
15
  "playwright": "1.53.0-alpha-2025-05-27",
16
  "zod-to-json-schema": "^3.24.4"
17
  },
@@ -853,16 +854,16 @@
853
  "license": "MIT"
854
  },
855
  "node_modules/body-parser": {
856
- "version": "2.1.0",
857
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.1.0.tgz",
858
- "integrity": "sha512-/hPxh61E+ll0Ujp24Ilm64cykicul1ypfwjVttduAiEdtnJFvLePSrIPk+HMImtNv5270wOGCb1Tns2rybMkoQ==",
859
  "license": "MIT",
860
  "dependencies": {
861
  "bytes": "^3.1.2",
862
  "content-type": "^1.0.5",
863
  "debug": "^4.4.0",
864
  "http-errors": "^2.0.0",
865
- "iconv-lite": "^0.5.2",
866
  "on-finished": "^2.4.1",
867
  "qs": "^6.14.0",
868
  "raw-body": "^3.0.0",
@@ -872,21 +873,6 @@
872
  "node": ">=18"
873
  }
874
  },
875
- "node_modules/body-parser/node_modules/qs": {
876
- "version": "6.14.0",
877
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
878
- "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
879
- "license": "BSD-3-Clause",
880
- "dependencies": {
881
- "side-channel": "^1.1.0"
882
- },
883
- "engines": {
884
- "node": ">=0.6"
885
- },
886
- "funding": {
887
- "url": "https://github.com/sponsors/ljharb"
888
- }
889
- },
890
  "node_modules/brace-expansion": {
891
  "version": "1.1.11",
892
  "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -1220,16 +1206,6 @@
1220
  "node": ">= 0.8"
1221
  }
1222
  },
1223
- "node_modules/destroy": {
1224
- "version": "1.2.0",
1225
- "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
1226
- "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
1227
- "license": "MIT",
1228
- "engines": {
1229
- "node": ">= 0.8",
1230
- "npm": "1.2.8000 || >= 1.4.16"
1231
- }
1232
- },
1233
  "node_modules/doctrine": {
1234
  "version": "2.1.0",
1235
  "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -1765,46 +1741,45 @@
1765
  }
1766
  },
1767
  "node_modules/express": {
1768
- "version": "5.0.1",
1769
- "resolved": "https://registry.npmjs.org/express/-/express-5.0.1.tgz",
1770
- "integrity": "sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==",
1771
  "license": "MIT",
1772
  "dependencies": {
1773
  "accepts": "^2.0.0",
1774
- "body-parser": "^2.0.1",
1775
  "content-disposition": "^1.0.0",
1776
- "content-type": "~1.0.4",
1777
- "cookie": "0.7.1",
1778
  "cookie-signature": "^1.2.1",
1779
- "debug": "4.3.6",
1780
- "depd": "2.0.0",
1781
- "encodeurl": "~2.0.0",
1782
- "escape-html": "~1.0.3",
1783
- "etag": "~1.8.1",
1784
- "finalhandler": "^2.0.0",
1785
- "fresh": "2.0.0",
1786
- "http-errors": "2.0.0",
1787
  "merge-descriptors": "^2.0.0",
1788
- "methods": "~1.1.2",
1789
  "mime-types": "^3.0.0",
1790
- "on-finished": "2.4.1",
1791
- "once": "1.4.0",
1792
- "parseurl": "~1.3.3",
1793
- "proxy-addr": "~2.0.7",
1794
- "qs": "6.13.0",
1795
- "range-parser": "~1.2.1",
1796
- "router": "^2.0.0",
1797
- "safe-buffer": "5.2.1",
1798
  "send": "^1.1.0",
1799
- "serve-static": "^2.1.0",
1800
- "setprototypeof": "1.2.0",
1801
- "statuses": "2.0.1",
1802
- "type-is": "^2.0.0",
1803
- "utils-merge": "1.0.1",
1804
- "vary": "~1.1.2"
1805
  },
1806
  "engines": {
1807
  "node": ">= 18"
 
 
 
 
1808
  }
1809
  },
1810
  "node_modules/express-rate-limit": {
@@ -1822,29 +1797,6 @@
1822
  "express": "^4.11 || 5 || ^5.0.0-beta.1"
1823
  }
1824
  },
1825
- "node_modules/express/node_modules/debug": {
1826
- "version": "4.3.6",
1827
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
1828
- "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==",
1829
- "license": "MIT",
1830
- "dependencies": {
1831
- "ms": "2.1.2"
1832
- },
1833
- "engines": {
1834
- "node": ">=6.0"
1835
- },
1836
- "peerDependenciesMeta": {
1837
- "supports-color": {
1838
- "optional": true
1839
- }
1840
- }
1841
- },
1842
- "node_modules/express/node_modules/ms": {
1843
- "version": "2.1.2",
1844
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
1845
- "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
1846
- "license": "MIT"
1847
- },
1848
  "node_modules/fast-deep-equal": {
1849
  "version": "3.1.3",
1850
  "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2308,12 +2260,12 @@
2308
  }
2309
  },
2310
  "node_modules/iconv-lite": {
2311
- "version": "0.5.2",
2312
- "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.2.tgz",
2313
- "integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==",
2314
  "license": "MIT",
2315
  "dependencies": {
2316
- "safer-buffer": ">= 2.1.2 < 3"
2317
  },
2318
  "engines": {
2319
  "node": ">=0.10.0"
@@ -2924,15 +2876,6 @@
2924
  "node": ">= 8"
2925
  }
2926
  },
2927
- "node_modules/methods": {
2928
- "version": "1.1.2",
2929
- "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
2930
- "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
2931
- "license": "MIT",
2932
- "engines": {
2933
- "node": ">= 0.6"
2934
- }
2935
- },
2936
  "node_modules/metric-lcs": {
2937
  "version": "0.1.2",
2938
  "resolved": "https://registry.npmjs.org/metric-lcs/-/metric-lcs-0.1.2.tgz",
@@ -2954,6 +2897,21 @@
2954
  "node": ">=8.6"
2955
  }
2956
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2957
  "node_modules/mime-db": {
2958
  "version": "1.54.0",
2959
  "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -2964,12 +2922,12 @@
2964
  }
2965
  },
2966
  "node_modules/mime-types": {
2967
- "version": "3.0.0",
2968
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz",
2969
- "integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==",
2970
  "license": "MIT",
2971
  "dependencies": {
2972
- "mime-db": "^1.53.0"
2973
  },
2974
  "engines": {
2975
  "node": ">= 0.6"
@@ -3367,12 +3325,12 @@
3367
  }
3368
  },
3369
  "node_modules/qs": {
3370
- "version": "6.13.0",
3371
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
3372
- "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
3373
  "license": "BSD-3-Clause",
3374
  "dependencies": {
3375
- "side-channel": "^1.0.6"
3376
  },
3377
  "engines": {
3378
  "node": ">=0.6"
@@ -3426,18 +3384,6 @@
3426
  "node": ">= 0.8"
3427
  }
3428
  },
3429
- "node_modules/raw-body/node_modules/iconv-lite": {
3430
- "version": "0.6.3",
3431
- "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
3432
- "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
3433
- "license": "MIT",
3434
- "dependencies": {
3435
- "safer-buffer": ">= 2.1.2 < 3.0.0"
3436
- },
3437
- "engines": {
3438
- "node": ">=0.10.0"
3439
- }
3440
- },
3441
  "node_modules/reflect.getprototypeof": {
3442
  "version": "1.0.10",
3443
  "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -3525,11 +3471,13 @@
3525
  }
3526
  },
3527
  "node_modules/router": {
3528
- "version": "2.1.0",
3529
- "resolved": "https://registry.npmjs.org/router/-/router-2.1.0.tgz",
3530
- "integrity": "sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA==",
3531
  "license": "MIT",
3532
  "dependencies": {
 
 
3533
  "is-promise": "^4.0.0",
3534
  "parseurl": "^1.3.3",
3535
  "path-to-regexp": "^8.0.0"
@@ -3657,19 +3605,18 @@
3657
  }
3658
  },
3659
  "node_modules/send": {
3660
- "version": "1.1.0",
3661
- "resolved": "https://registry.npmjs.org/send/-/send-1.1.0.tgz",
3662
- "integrity": "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==",
3663
  "license": "MIT",
3664
  "dependencies": {
3665
  "debug": "^4.3.5",
3666
- "destroy": "^1.2.0",
3667
  "encodeurl": "^2.0.0",
3668
  "escape-html": "^1.0.3",
3669
  "etag": "^1.8.1",
3670
- "fresh": "^0.5.2",
3671
  "http-errors": "^2.0.0",
3672
- "mime-types": "^2.1.35",
3673
  "ms": "^2.1.3",
3674
  "on-finished": "^2.4.1",
3675
  "range-parser": "^1.2.1",
@@ -3679,46 +3626,16 @@
3679
  "node": ">= 18"
3680
  }
3681
  },
3682
- "node_modules/send/node_modules/fresh": {
3683
- "version": "0.5.2",
3684
- "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
3685
- "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
3686
- "license": "MIT",
3687
- "engines": {
3688
- "node": ">= 0.6"
3689
- }
3690
- },
3691
- "node_modules/send/node_modules/mime-db": {
3692
- "version": "1.52.0",
3693
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
3694
- "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
3695
- "license": "MIT",
3696
- "engines": {
3697
- "node": ">= 0.6"
3698
- }
3699
- },
3700
- "node_modules/send/node_modules/mime-types": {
3701
- "version": "2.1.35",
3702
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
3703
- "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
3704
- "license": "MIT",
3705
- "dependencies": {
3706
- "mime-db": "1.52.0"
3707
- },
3708
- "engines": {
3709
- "node": ">= 0.6"
3710
- }
3711
- },
3712
  "node_modules/serve-static": {
3713
- "version": "2.1.0",
3714
- "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz",
3715
- "integrity": "sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==",
3716
  "license": "MIT",
3717
  "dependencies": {
3718
  "encodeurl": "^2.0.0",
3719
  "escape-html": "^1.0.3",
3720
  "parseurl": "^1.3.3",
3721
- "send": "^1.0.0"
3722
  },
3723
  "engines": {
3724
  "node": ">= 18"
@@ -4051,9 +3968,9 @@
4051
  }
4052
  },
4053
  "node_modules/type-is": {
4054
- "version": "2.0.0",
4055
- "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.0.tgz",
4056
- "integrity": "sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==",
4057
  "license": "MIT",
4058
  "dependencies": {
4059
  "content-type": "^1.0.5",
@@ -4201,15 +4118,6 @@
4201
  "punycode": "^2.1.0"
4202
  }
4203
  },
4204
- "node_modules/utils-merge": {
4205
- "version": "1.0.1",
4206
- "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
4207
- "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
4208
- "license": "MIT",
4209
- "engines": {
4210
- "node": ">= 0.4.0"
4211
- }
4212
- },
4213
  "node_modules/vary": {
4214
  "version": "1.1.2",
4215
  "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
 
12
  "@modelcontextprotocol/sdk": "^1.11.0",
13
  "commander": "^13.1.0",
14
  "debug": "^4.4.1",
15
+ "mime": "^4.0.7",
16
  "playwright": "1.53.0-alpha-2025-05-27",
17
  "zod-to-json-schema": "^3.24.4"
18
  },
 
854
  "license": "MIT"
855
  },
856
  "node_modules/body-parser": {
857
+ "version": "2.2.0",
858
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
859
+ "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
860
  "license": "MIT",
861
  "dependencies": {
862
  "bytes": "^3.1.2",
863
  "content-type": "^1.0.5",
864
  "debug": "^4.4.0",
865
  "http-errors": "^2.0.0",
866
+ "iconv-lite": "^0.6.3",
867
  "on-finished": "^2.4.1",
868
  "qs": "^6.14.0",
869
  "raw-body": "^3.0.0",
 
873
  "node": ">=18"
874
  }
875
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
876
  "node_modules/brace-expansion": {
877
  "version": "1.1.11",
878
  "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
 
1206
  "node": ">= 0.8"
1207
  }
1208
  },
 
 
 
 
 
 
 
 
 
 
1209
  "node_modules/doctrine": {
1210
  "version": "2.1.0",
1211
  "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
 
1741
  }
1742
  },
1743
  "node_modules/express": {
1744
+ "version": "5.1.0",
1745
+ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
1746
+ "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
1747
  "license": "MIT",
1748
  "dependencies": {
1749
  "accepts": "^2.0.0",
1750
+ "body-parser": "^2.2.0",
1751
  "content-disposition": "^1.0.0",
1752
+ "content-type": "^1.0.5",
1753
+ "cookie": "^0.7.1",
1754
  "cookie-signature": "^1.2.1",
1755
+ "debug": "^4.4.0",
1756
+ "encodeurl": "^2.0.0",
1757
+ "escape-html": "^1.0.3",
1758
+ "etag": "^1.8.1",
1759
+ "finalhandler": "^2.1.0",
1760
+ "fresh": "^2.0.0",
1761
+ "http-errors": "^2.0.0",
 
1762
  "merge-descriptors": "^2.0.0",
 
1763
  "mime-types": "^3.0.0",
1764
+ "on-finished": "^2.4.1",
1765
+ "once": "^1.4.0",
1766
+ "parseurl": "^1.3.3",
1767
+ "proxy-addr": "^2.0.7",
1768
+ "qs": "^6.14.0",
1769
+ "range-parser": "^1.2.1",
1770
+ "router": "^2.2.0",
 
1771
  "send": "^1.1.0",
1772
+ "serve-static": "^2.2.0",
1773
+ "statuses": "^2.0.1",
1774
+ "type-is": "^2.0.1",
1775
+ "vary": "^1.1.2"
 
 
1776
  },
1777
  "engines": {
1778
  "node": ">= 18"
1779
+ },
1780
+ "funding": {
1781
+ "type": "opencollective",
1782
+ "url": "https://opencollective.com/express"
1783
  }
1784
  },
1785
  "node_modules/express-rate-limit": {
 
1797
  "express": "^4.11 || 5 || ^5.0.0-beta.1"
1798
  }
1799
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1800
  "node_modules/fast-deep-equal": {
1801
  "version": "3.1.3",
1802
  "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
 
2260
  }
2261
  },
2262
  "node_modules/iconv-lite": {
2263
+ "version": "0.6.3",
2264
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
2265
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
2266
  "license": "MIT",
2267
  "dependencies": {
2268
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
2269
  },
2270
  "engines": {
2271
  "node": ">=0.10.0"
 
2876
  "node": ">= 8"
2877
  }
2878
  },
 
 
 
 
 
 
 
 
 
2879
  "node_modules/metric-lcs": {
2880
  "version": "0.1.2",
2881
  "resolved": "https://registry.npmjs.org/metric-lcs/-/metric-lcs-0.1.2.tgz",
 
2897
  "node": ">=8.6"
2898
  }
2899
  },
2900
+ "node_modules/mime": {
2901
+ "version": "4.0.7",
2902
+ "resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz",
2903
+ "integrity": "sha512-2OfDPL+e03E0LrXaGYOtTFIYhiuzep94NSsuhrNULq+stylcJedcHdzHtz0atMUuGwJfFYs0YL5xeC/Ca2x0eQ==",
2904
+ "funding": [
2905
+ "https://github.com/sponsors/broofa"
2906
+ ],
2907
+ "license": "MIT",
2908
+ "bin": {
2909
+ "mime": "bin/cli.js"
2910
+ },
2911
+ "engines": {
2912
+ "node": ">=16"
2913
+ }
2914
+ },
2915
  "node_modules/mime-db": {
2916
  "version": "1.54.0",
2917
  "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
 
2922
  }
2923
  },
2924
  "node_modules/mime-types": {
2925
+ "version": "3.0.1",
2926
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
2927
+ "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
2928
  "license": "MIT",
2929
  "dependencies": {
2930
+ "mime-db": "^1.54.0"
2931
  },
2932
  "engines": {
2933
  "node": ">= 0.6"
 
3325
  }
3326
  },
3327
  "node_modules/qs": {
3328
+ "version": "6.14.0",
3329
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
3330
+ "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
3331
  "license": "BSD-3-Clause",
3332
  "dependencies": {
3333
+ "side-channel": "^1.1.0"
3334
  },
3335
  "engines": {
3336
  "node": ">=0.6"
 
3384
  "node": ">= 0.8"
3385
  }
3386
  },
 
 
 
 
 
 
 
 
 
 
 
 
3387
  "node_modules/reflect.getprototypeof": {
3388
  "version": "1.0.10",
3389
  "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
 
3471
  }
3472
  },
3473
  "node_modules/router": {
3474
+ "version": "2.2.0",
3475
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
3476
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
3477
  "license": "MIT",
3478
  "dependencies": {
3479
+ "debug": "^4.4.0",
3480
+ "depd": "^2.0.0",
3481
  "is-promise": "^4.0.0",
3482
  "parseurl": "^1.3.3",
3483
  "path-to-regexp": "^8.0.0"
 
3605
  }
3606
  },
3607
  "node_modules/send": {
3608
+ "version": "1.2.0",
3609
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
3610
+ "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
3611
  "license": "MIT",
3612
  "dependencies": {
3613
  "debug": "^4.3.5",
 
3614
  "encodeurl": "^2.0.0",
3615
  "escape-html": "^1.0.3",
3616
  "etag": "^1.8.1",
3617
+ "fresh": "^2.0.0",
3618
  "http-errors": "^2.0.0",
3619
+ "mime-types": "^3.0.1",
3620
  "ms": "^2.1.3",
3621
  "on-finished": "^2.4.1",
3622
  "range-parser": "^1.2.1",
 
3626
  "node": ">= 18"
3627
  }
3628
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3629
  "node_modules/serve-static": {
3630
+ "version": "2.2.0",
3631
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
3632
+ "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
3633
  "license": "MIT",
3634
  "dependencies": {
3635
  "encodeurl": "^2.0.0",
3636
  "escape-html": "^1.0.3",
3637
  "parseurl": "^1.3.3",
3638
+ "send": "^1.2.0"
3639
  },
3640
  "engines": {
3641
  "node": ">= 18"
 
3968
  }
3969
  },
3970
  "node_modules/type-is": {
3971
+ "version": "2.0.1",
3972
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
3973
+ "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
3974
  "license": "MIT",
3975
  "dependencies": {
3976
  "content-type": "^1.0.5",
 
4118
  "punycode": "^2.1.0"
4119
  }
4120
  },
 
 
 
 
 
 
 
 
 
4121
  "node_modules/vary": {
4122
  "version": "1.1.2",
4123
  "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
package.json CHANGED
@@ -38,6 +38,7 @@
38
  "@modelcontextprotocol/sdk": "^1.11.0",
39
  "commander": "^13.1.0",
40
  "debug": "^4.4.1",
 
41
  "playwright": "1.53.0-alpha-2025-05-27",
42
  "zod-to-json-schema": "^3.24.4"
43
  },
 
38
  "@modelcontextprotocol/sdk": "^1.11.0",
39
  "commander": "^13.1.0",
40
  "debug": "^4.4.1",
41
+ "mime": "^4.0.7",
42
  "playwright": "1.53.0-alpha-2025-05-27",
43
  "zod-to-json-schema": "^3.24.4"
44
  },
src/browserAgent.ts ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Copyright (c) Microsoft Corporation.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /* eslint-disable no-console */
18
+
19
+ import net from 'net';
20
+
21
+ import { program } from 'commander';
22
+ import playwright from 'playwright';
23
+
24
+ import { HttpServer } from './httpServer.js';
25
+ import { packageJSON } from './package.js';
26
+
27
+ import type http from 'http';
28
+
29
+ export type LaunchBrowserRequest = {
30
+ browserType: string;
31
+ userDataDir: string;
32
+ launchOptions: playwright.LaunchOptions;
33
+ contextOptions: playwright.BrowserContextOptions;
34
+ };
35
+
36
+ export type BrowserInfo = {
37
+ browserType: string;
38
+ userDataDir: string;
39
+ cdpPort: number;
40
+ launchOptions: playwright.LaunchOptions;
41
+ contextOptions: playwright.BrowserContextOptions;
42
+ error?: string;
43
+ };
44
+
45
+ type BrowserEntry = {
46
+ browser?: playwright.Browser;
47
+ info: BrowserInfo;
48
+ };
49
+
50
+ class Agent {
51
+ private _server = new HttpServer();
52
+ private _entries: BrowserEntry[] = [];
53
+
54
+ constructor() {
55
+ this._setupExitHandler();
56
+ }
57
+
58
+ async start(port: number) {
59
+ await this._server.start({ port });
60
+ this._server.routePath('/json/list', (req, res) => {
61
+ this._handleJsonList(res);
62
+ });
63
+ this._server.routePath('/json/launch', async (req, res) => {
64
+ void this._handleLaunchBrowser(req, res).catch(e => console.error(e));
65
+ });
66
+ this._setEntries([]);
67
+ }
68
+
69
+ private _handleJsonList(res: http.ServerResponse) {
70
+ const list = this._entries.map(browser => browser.info);
71
+ res.end(JSON.stringify(list));
72
+ }
73
+
74
+ private async _handleLaunchBrowser(req: http.IncomingMessage, res: http.ServerResponse) {
75
+ const request = await readBody<LaunchBrowserRequest>(req);
76
+ let info = this._entries.map(entry => entry.info).find(info => info.userDataDir === request.userDataDir);
77
+ if (!info || info.error)
78
+ info = await this._newBrowser(request);
79
+ res.end(JSON.stringify(info));
80
+ }
81
+
82
+ private async _newBrowser(request: LaunchBrowserRequest): Promise<BrowserInfo> {
83
+ const cdpPort = await findFreePort();
84
+ (request.launchOptions as any).cdpPort = cdpPort;
85
+ const info: BrowserInfo = {
86
+ browserType: request.browserType,
87
+ userDataDir: request.userDataDir,
88
+ cdpPort,
89
+ launchOptions: request.launchOptions,
90
+ contextOptions: request.contextOptions,
91
+ };
92
+
93
+ const browserType = playwright[request.browserType as 'chromium' | 'firefox' | 'webkit'];
94
+ const { browser, error } = await browserType.launchPersistentContext(request.userDataDir, {
95
+ ...request.launchOptions,
96
+ ...request.contextOptions,
97
+ handleSIGINT: false,
98
+ handleSIGTERM: false,
99
+ }).then(context => {
100
+ return { browser: context.browser()!, error: undefined };
101
+ }).catch(error => {
102
+ return { browser: undefined, error: error.message };
103
+ });
104
+ this._setEntries([...this._entries, {
105
+ browser,
106
+ info: {
107
+ browserType: request.browserType,
108
+ userDataDir: request.userDataDir,
109
+ cdpPort,
110
+ launchOptions: request.launchOptions,
111
+ contextOptions: request.contextOptions,
112
+ error,
113
+ },
114
+ }]);
115
+ browser?.on('disconnected', () => {
116
+ this._setEntries(this._entries.filter(entry => entry.browser !== browser));
117
+ });
118
+ return info;
119
+ }
120
+
121
+ private _updateReport() {
122
+ // Clear the current line and move cursor to top of screen
123
+ process.stdout.write('\x1b[2J\x1b[H');
124
+ process.stdout.write(`Playwright Browser agent v${packageJSON.version}\n`);
125
+ process.stdout.write(`Listening on ${this._server.urlPrefix('human-readable')}\n\n`);
126
+
127
+ if (this._entries.length === 0) {
128
+ process.stdout.write('No browsers currently running\n');
129
+ return;
130
+ }
131
+
132
+ process.stdout.write('Running browsers:\n');
133
+ for (const entry of this._entries) {
134
+ const status = entry.browser ? 'running' : 'error';
135
+ const statusColor = entry.browser ? '\x1b[32m' : '\x1b[31m'; // green for running, red for error
136
+ process.stdout.write(`${statusColor}${entry.info.browserType}\x1b[0m (${entry.info.userDataDir}) - ${statusColor}${status}\x1b[0m\n`);
137
+ if (entry.info.error)
138
+ process.stdout.write(` Error: ${entry.info.error}\n`);
139
+ }
140
+
141
+ }
142
+
143
+ private _setEntries(entries: BrowserEntry[]) {
144
+ this._entries = entries;
145
+ this._updateReport();
146
+ }
147
+
148
+ private _setupExitHandler() {
149
+ let isExiting = false;
150
+ const handleExit = async () => {
151
+ if (isExiting)
152
+ return;
153
+ isExiting = true;
154
+ setTimeout(() => process.exit(0), 15000);
155
+ for (const entry of this._entries)
156
+ await entry.browser?.close().catch(() => {});
157
+ process.exit(0);
158
+ };
159
+
160
+ process.stdin.on('close', handleExit);
161
+ process.on('SIGINT', handleExit);
162
+ process.on('SIGTERM', handleExit);
163
+ }
164
+ }
165
+
166
+ program
167
+ .name('browser-agent')
168
+ .option('-p, --port <port>', 'Port to listen on', '9224')
169
+ .action(async options => {
170
+ await main(options);
171
+ });
172
+
173
+ void program.parseAsync(process.argv);
174
+
175
+ async function main(options: { port: string }) {
176
+ const agent = new Agent();
177
+ await agent.start(+options.port);
178
+ }
179
+
180
+ function readBody<T>(req: http.IncomingMessage): Promise<T> {
181
+ return new Promise((resolve, reject) => {
182
+ const chunks: Buffer[] = [];
183
+ req.on('data', (chunk: Buffer) => chunks.push(chunk));
184
+ req.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString())));
185
+ });
186
+ }
187
+
188
+ async function findFreePort(): Promise<number> {
189
+ return new Promise((resolve, reject) => {
190
+ const server = net.createServer();
191
+ server.listen(0, () => {
192
+ const { port } = server.address() as net.AddressInfo;
193
+ server.close(() => resolve(port));
194
+ });
195
+ server.on('error', reject);
196
+ });
197
+ }
src/browserContextFactory.ts CHANGED
@@ -15,13 +15,16 @@
15
  */
16
 
17
  import fs from 'node:fs';
18
- import os from 'node:os';
19
  import path from 'node:path';
 
20
 
21
  import debug from 'debug';
22
  import * as playwright from 'playwright';
 
23
 
24
  import type { FullConfig } from './config.js';
 
25
 
26
  const testDebug = debug('pw:mcp:test');
27
 
@@ -32,6 +35,8 @@ export function contextFactory(browserConfig: FullConfig['browser']): BrowserCon
32
  return new CdpContextFactory(browserConfig);
33
  if (browserConfig.isolated)
34
  return new IsolatedContextFactory(browserConfig);
 
 
35
  return new PersistentContextFactory(browserConfig);
36
  }
37
 
@@ -97,6 +102,7 @@ class IsolatedContextFactory extends BaseContextFactory {
97
  }
98
 
99
  protected override async _doObtainBrowser(): Promise<playwright.Browser> {
 
100
  const browserType = playwright[this.browserConfig.browserName];
101
  return browserType.launch({
102
  ...this.browserConfig.launchOptions,
@@ -155,6 +161,7 @@ class PersistentContextFactory implements BrowserContextFactory {
155
  }
156
 
157
  async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
 
158
  testDebug('create browser context (persistent)');
159
  const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir();
160
 
@@ -209,3 +216,51 @@ class PersistentContextFactory implements BrowserContextFactory {
209
  return result;
210
  }
211
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  */
16
 
17
  import fs from 'node:fs';
18
+ import net from 'node:net';
19
  import path from 'node:path';
20
+ import os from 'node:os';
21
 
22
  import debug from 'debug';
23
  import * as playwright from 'playwright';
24
+ import { userDataDir } from './fileUtils.js';
25
 
26
  import type { FullConfig } from './config.js';
27
+ import type { BrowserInfo, LaunchBrowserRequest } from './browserAgent.js';
28
 
29
  const testDebug = debug('pw:mcp:test');
30
 
 
35
  return new CdpContextFactory(browserConfig);
36
  if (browserConfig.isolated)
37
  return new IsolatedContextFactory(browserConfig);
38
+ if (browserConfig.browserAgent)
39
+ return new AgentContextFactory(browserConfig);
40
  return new PersistentContextFactory(browserConfig);
41
  }
42
 
 
102
  }
103
 
104
  protected override async _doObtainBrowser(): Promise<playwright.Browser> {
105
+ await injectCdpPort(this.browserConfig);
106
  const browserType = playwright[this.browserConfig.browserName];
107
  return browserType.launch({
108
  ...this.browserConfig.launchOptions,
 
161
  }
162
 
163
  async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
164
+ await injectCdpPort(this.browserConfig);
165
  testDebug('create browser context (persistent)');
166
  const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir();
167
 
 
216
  return result;
217
  }
218
  }
219
+
220
+ export class AgentContextFactory extends BaseContextFactory {
221
+ constructor(browserConfig: FullConfig['browser']) {
222
+ super('persistent', browserConfig);
223
+ }
224
+
225
+ protected override async _doObtainBrowser(): Promise<playwright.Browser> {
226
+ const response = await fetch(new URL(`/json/launch`, this.browserConfig.browserAgent), {
227
+ method: 'POST',
228
+ body: JSON.stringify({
229
+ browserType: this.browserConfig.browserName,
230
+ userDataDir: this.browserConfig.userDataDir ?? await this._createUserDataDir(),
231
+ launchOptions: this.browserConfig.launchOptions,
232
+ contextOptions: this.browserConfig.contextOptions,
233
+ } as LaunchBrowserRequest),
234
+ });
235
+ const info = await response.json() as BrowserInfo;
236
+ if (info.error)
237
+ throw new Error(info.error);
238
+ return await playwright.chromium.connectOverCDP(`http://localhost:${info.cdpPort}/`);
239
+ }
240
+
241
+ protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
242
+ return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
243
+ }
244
+
245
+ private async _createUserDataDir() {
246
+ const dir = await userDataDir(this.browserConfig);
247
+ await fs.promises.mkdir(dir, { recursive: true });
248
+ return dir;
249
+ }
250
+ }
251
+
252
+ async function injectCdpPort(browserConfig: FullConfig['browser']) {
253
+ if (browserConfig.browserName === 'chromium')
254
+ (browserConfig.launchOptions as any).cdpPort = await findFreePort();
255
+ }
256
+
257
+ async function findFreePort() {
258
+ return new Promise((resolve, reject) => {
259
+ const server = net.createServer();
260
+ server.listen(0, () => {
261
+ const { port } = server.address() as net.AddressInfo;
262
+ server.close(() => resolve(port));
263
+ });
264
+ server.on('error', reject);
265
+ });
266
+ }
src/config.ts CHANGED
@@ -15,7 +15,6 @@
15
  */
16
 
17
  import fs from 'fs';
18
- import net from 'net';
19
  import os from 'os';
20
  import path from 'path';
21
  import { devices } from 'playwright';
@@ -29,6 +28,7 @@ export type CLIOptions = {
29
  blockedOrigins?: string[];
30
  blockServiceWorkers?: boolean;
31
  browser?: string;
 
32
  caps?: string;
33
  cdpEndpoint?: string;
34
  config?: string;
@@ -96,8 +96,6 @@ export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConf
96
  // Derive artifact output directory from config.outputDir
97
  if (result.saveTrace)
98
  result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
99
- if (result.browser.browserName === 'chromium')
100
- (result.browser.launchOptions as any).cdpPort = await findFreePort();
101
  return result;
102
  }
103
 
@@ -171,6 +169,7 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
171
 
172
  const result: Config = {
173
  browser: {
 
174
  browserName,
175
  isolated: cliOptions.isolated,
176
  userDataDir: cliOptions.userDataDir,
@@ -196,17 +195,6 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
196
  return result;
197
  }
198
 
199
- async function findFreePort() {
200
- return new Promise((resolve, reject) => {
201
- const server = net.createServer();
202
- server.listen(0, () => {
203
- const { port } = server.address() as net.AddressInfo;
204
- server.close(() => resolve(port));
205
- });
206
- server.on('error', reject);
207
- });
208
- }
209
-
210
  async function loadConfig(configFile: string | undefined): Promise<Config> {
211
  if (!configFile)
212
  return {};
@@ -232,6 +220,8 @@ function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
232
 
233
  function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
234
  const browser: FullConfig['browser'] = {
 
 
235
  browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
236
  isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
237
  launchOptions: {
@@ -243,9 +233,6 @@ function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
243
  ...pickDefined(base.browser?.contextOptions),
244
  ...pickDefined(overrides.browser?.contextOptions),
245
  },
246
- userDataDir: overrides.browser?.userDataDir ?? base.browser?.userDataDir,
247
- cdpEndpoint: overrides.browser?.cdpEndpoint ?? base.browser?.cdpEndpoint,
248
- remoteEndpoint: overrides.browser?.remoteEndpoint ?? base.browser?.remoteEndpoint,
249
  };
250
 
251
  if (browser.browserName !== 'chromium' && browser.launchOptions)
 
15
  */
16
 
17
  import fs from 'fs';
 
18
  import os from 'os';
19
  import path from 'path';
20
  import { devices } from 'playwright';
 
28
  blockedOrigins?: string[];
29
  blockServiceWorkers?: boolean;
30
  browser?: string;
31
+ browserAgent?: string;
32
  caps?: string;
33
  cdpEndpoint?: string;
34
  config?: string;
 
96
  // Derive artifact output directory from config.outputDir
97
  if (result.saveTrace)
98
  result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
 
 
99
  return result;
100
  }
101
 
 
169
 
170
  const result: Config = {
171
  browser: {
172
+ browserAgent: cliOptions.browserAgent ?? process.env.PW_BROWSER_AGENT,
173
  browserName,
174
  isolated: cliOptions.isolated,
175
  userDataDir: cliOptions.userDataDir,
 
195
  return result;
196
  }
197
 
 
 
 
 
 
 
 
 
 
 
 
198
  async function loadConfig(configFile: string | undefined): Promise<Config> {
199
  if (!configFile)
200
  return {};
 
220
 
221
  function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
222
  const browser: FullConfig['browser'] = {
223
+ ...pickDefined(base.browser),
224
+ ...pickDefined(overrides.browser),
225
  browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
226
  isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
227
  launchOptions: {
 
233
  ...pickDefined(base.browser?.contextOptions),
234
  ...pickDefined(overrides.browser?.contextOptions),
235
  },
 
 
 
236
  };
237
 
238
  if (browser.browserName !== 'chromium' && browser.launchOptions)
src/fileUtils.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Copyright (c) Microsoft Corporation.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+
20
+ import type { FullConfig } from './config.js';
21
+
22
+ export function cacheDir() {
23
+ let cacheDirectory: string;
24
+ if (process.platform === 'linux')
25
+ cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
26
+ else if (process.platform === 'darwin')
27
+ cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
28
+ else if (process.platform === 'win32')
29
+ cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
30
+ else
31
+ throw new Error('Unsupported platform: ' + process.platform);
32
+ return path.join(cacheDirectory, 'ms-playwright');
33
+ }
34
+
35
+ export async function userDataDir(browserConfig: FullConfig['browser']) {
36
+ return path.join(cacheDir(), 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
37
+ }
src/httpServer.ts ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Copyright (c) Microsoft Corporation.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import fs from 'fs';
18
+ import path from 'path';
19
+ import http from 'http';
20
+ import net from 'net';
21
+
22
+ import mime from 'mime';
23
+
24
+ import { ManualPromise } from './manualPromise.js';
25
+
26
+
27
+ export type ServerRouteHandler = (request: http.IncomingMessage, response: http.ServerResponse) => void;
28
+
29
+ export type Transport = {
30
+ sendEvent?: (method: string, params: any) => void;
31
+ close?: () => void;
32
+ onconnect: () => void;
33
+ dispatch: (method: string, params: any) => Promise<any>;
34
+ onclose: () => void;
35
+ };
36
+
37
+ export class HttpServer {
38
+ private _server: http.Server;
39
+ private _urlPrefixPrecise: string = '';
40
+ private _urlPrefixHumanReadable: string = '';
41
+ private _port: number = 0;
42
+ private _routes: { prefix?: string, exact?: string, handler: ServerRouteHandler }[] = [];
43
+
44
+ constructor() {
45
+ this._server = http.createServer(this._onRequest.bind(this));
46
+ decorateServer(this._server);
47
+ }
48
+
49
+ server() {
50
+ return this._server;
51
+ }
52
+
53
+ routePrefix(prefix: string, handler: ServerRouteHandler) {
54
+ this._routes.push({ prefix, handler });
55
+ }
56
+
57
+ routePath(path: string, handler: ServerRouteHandler) {
58
+ this._routes.push({ exact: path, handler });
59
+ }
60
+
61
+ port(): number {
62
+ return this._port;
63
+ }
64
+
65
+ private async _tryStart(port: number | undefined, host: string) {
66
+ const errorPromise = new ManualPromise();
67
+ const errorListener = (error: Error) => errorPromise.reject(error);
68
+ this._server.on('error', errorListener);
69
+
70
+ try {
71
+ this._server.listen(port, host);
72
+ await Promise.race([
73
+ new Promise(cb => this._server!.once('listening', cb)),
74
+ errorPromise,
75
+ ]);
76
+ } finally {
77
+ this._server.removeListener('error', errorListener);
78
+ }
79
+ }
80
+
81
+ async start(options: { port?: number, preferredPort?: number, host?: string } = {}): Promise<void> {
82
+ const host = options.host || 'localhost';
83
+ if (options.preferredPort) {
84
+ try {
85
+ await this._tryStart(options.preferredPort, host);
86
+ } catch (e: any) {
87
+ if (!e || !e.message || !e.message.includes('EADDRINUSE'))
88
+ throw e;
89
+ await this._tryStart(undefined, host);
90
+ }
91
+ } else {
92
+ await this._tryStart(options.port, host);
93
+ }
94
+
95
+ const address = this._server.address();
96
+ if (typeof address === 'string') {
97
+ this._urlPrefixPrecise = address;
98
+ this._urlPrefixHumanReadable = address;
99
+ } else {
100
+ this._port = address!.port;
101
+ const resolvedHost = address!.family === 'IPv4' ? address!.address : `[${address!.address}]`;
102
+ this._urlPrefixPrecise = `http://${resolvedHost}:${address!.port}`;
103
+ this._urlPrefixHumanReadable = `http://${host}:${address!.port}`;
104
+ }
105
+ }
106
+
107
+ async stop() {
108
+ await new Promise(cb => this._server!.close(cb));
109
+ }
110
+
111
+ urlPrefix(purpose: 'human-readable' | 'precise'): string {
112
+ return purpose === 'human-readable' ? this._urlPrefixHumanReadable : this._urlPrefixPrecise;
113
+ }
114
+
115
+ serveFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string, headers?: { [name: string]: string }): boolean {
116
+ try {
117
+ for (const [name, value] of Object.entries(headers || {}))
118
+ response.setHeader(name, value);
119
+ if (request.headers.range)
120
+ this._serveRangeFile(request, response, absoluteFilePath);
121
+ else
122
+ this._serveFile(response, absoluteFilePath);
123
+ return true;
124
+ } catch (e) {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ _serveFile(response: http.ServerResponse, absoluteFilePath: string) {
130
+ const content = fs.readFileSync(absoluteFilePath);
131
+ response.statusCode = 200;
132
+ const contentType = mime.getType(path.extname(absoluteFilePath)) || 'application/octet-stream';
133
+ response.setHeader('Content-Type', contentType);
134
+ response.setHeader('Content-Length', content.byteLength);
135
+ response.end(content);
136
+ }
137
+
138
+ _serveRangeFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string) {
139
+ const range = request.headers.range;
140
+ if (!range || !range.startsWith('bytes=') || range.includes(', ') || [...range].filter(char => char === '-').length !== 1) {
141
+ response.statusCode = 400;
142
+ return response.end('Bad request');
143
+ }
144
+
145
+ // Parse the range header: https://datatracker.ietf.org/doc/html/rfc7233#section-2.1
146
+ const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
147
+
148
+ // Both start and end (when passing to fs.createReadStream) and the range header are inclusive and start counting at 0.
149
+ let start: number;
150
+ let end: number;
151
+ const size = fs.statSync(absoluteFilePath).size;
152
+ if (startStr !== '' && endStr === '') {
153
+ // No end specified: use the whole file
154
+ start = +startStr;
155
+ end = size - 1;
156
+ } else if (startStr === '' && endStr !== '') {
157
+ // No start specified: calculate start manually
158
+ start = size - +endStr;
159
+ end = size - 1;
160
+ } else {
161
+ start = +startStr;
162
+ end = +endStr;
163
+ }
164
+
165
+ // Handle unavailable range request
166
+ if (Number.isNaN(start) || Number.isNaN(end) || start >= size || end >= size || start > end) {
167
+ // Return the 416 Range Not Satisfiable: https://datatracker.ietf.org/doc/html/rfc7233#section-4.4
168
+ response.writeHead(416, {
169
+ 'Content-Range': `bytes */${size}`
170
+ });
171
+ return response.end();
172
+ }
173
+
174
+ // Sending Partial Content: https://datatracker.ietf.org/doc/html/rfc7233#section-4.1
175
+ response.writeHead(206, {
176
+ 'Content-Range': `bytes ${start}-${end}/${size}`,
177
+ 'Accept-Ranges': 'bytes',
178
+ 'Content-Length': end - start + 1,
179
+ 'Content-Type': mime.getType(path.extname(absoluteFilePath))!,
180
+ });
181
+
182
+ const readable = fs.createReadStream(absoluteFilePath, { start, end });
183
+ readable.pipe(response);
184
+ }
185
+
186
+ private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
187
+ if (request.method === 'OPTIONS') {
188
+ response.writeHead(200);
189
+ response.end();
190
+ return;
191
+ }
192
+
193
+ request.on('error', () => response.end());
194
+ try {
195
+ if (!request.url) {
196
+ response.end();
197
+ return;
198
+ }
199
+ const url = new URL('http://localhost' + request.url);
200
+ for (const route of this._routes) {
201
+ if (route.exact && url.pathname === route.exact) {
202
+ route.handler(request, response);
203
+ return;
204
+ }
205
+ if (route.prefix && url.pathname.startsWith(route.prefix)) {
206
+ route.handler(request, response);
207
+ return;
208
+ }
209
+ }
210
+ response.statusCode = 404;
211
+ response.end();
212
+ } catch (e) {
213
+ response.end();
214
+ }
215
+ }
216
+ }
217
+
218
+ function decorateServer(server: net.Server) {
219
+ const sockets = new Set<net.Socket>();
220
+ server.on('connection', socket => {
221
+ sockets.add(socket);
222
+ socket.once('close', () => sockets.delete(socket));
223
+ });
224
+
225
+ const close = server.close;
226
+ server.close = (callback?: (err?: Error) => void) => {
227
+ for (const socket of sockets)
228
+ socket.destroy();
229
+ sockets.clear();
230
+ return close.call(server, callback);
231
+ };
232
+ }
src/program.ts CHANGED
@@ -30,6 +30,7 @@ program
30
  .option('--blocked-origins <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)
31
  .option('--block-service-workers', 'block service workers')
32
  .option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
 
33
  .option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
34
  .option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
35
  .option('--config <path>', 'path to the configuration file.')
 
30
  .option('--blocked-origins <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)
31
  .option('--block-service-workers', 'block service workers')
32
  .option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
33
+ .option('--browser-agent <endpoint>', 'Use browser agent (experimental).')
34
  .option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
35
  .option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
36
  .option('--config <path>', 'path to the configuration file.')
tests/agent.spec.ts ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Copyright (c) Microsoft Corporation.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+ import path from 'path';
17
+ import url from 'node:url';
18
+
19
+ import { spawn } from 'child_process';
20
+ import { test as baseTest, expect } from './fixtures.js';
21
+
22
+ import type { ChildProcess } from 'child_process';
23
+
24
+ const __filename = url.fileURLToPath(import.meta.url);
25
+
26
+ const test = baseTest.extend<{ agentEndpoint: (options?: { args?: string[] }) => Promise<{ url: URL, stdout: () => string }> }>({
27
+ agentEndpoint: async ({}, use) => {
28
+ let cp: ChildProcess | undefined;
29
+ await use(async (options?: { args?: string[] }) => {
30
+ if (cp)
31
+ throw new Error('Process already running');
32
+
33
+ cp = spawn('node', [
34
+ path.join(path.dirname(__filename), '../lib/browserAgent.js'),
35
+ ...(options?.args || []),
36
+ ], {
37
+ stdio: 'pipe',
38
+ env: {
39
+ ...process.env,
40
+ DEBUG: 'pw:mcp:test',
41
+ DEBUG_COLORS: '0',
42
+ DEBUG_HIDE_DATE: '1',
43
+ },
44
+ });
45
+ let stdout = '';
46
+ const url = await new Promise<string>(resolve => cp!.stdout?.on('data', data => {
47
+ stdout += data.toString();
48
+ const match = stdout.match(/Listening on (http:\/\/.*)/);
49
+ if (match)
50
+ resolve(match[1]);
51
+ }));
52
+
53
+ return { url: new URL(url), stdout: () => stdout };
54
+ });
55
+ cp?.kill('SIGTERM');
56
+ },
57
+ });
58
+
59
+ test.skip(({ mcpBrowser }) => mcpBrowser !== 'chrome', 'Agent is CDP-only for now');
60
+
61
+ test('browser lifecycle', async ({ agentEndpoint, startClient, server }) => {
62
+ const { url: agentUrl } = await agentEndpoint();
63
+ const { client: client1 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] });
64
+ expect(await client1.callTool({
65
+ name: 'browser_navigate',
66
+ arguments: { url: server.HELLO_WORLD },
67
+ })).toContainTextContent('Hello, world!');
68
+
69
+ const { client: client2 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] });
70
+ expect(await client2.callTool({
71
+ name: 'browser_navigate',
72
+ arguments: { url: server.HELLO_WORLD },
73
+ })).toContainTextContent('Hello, world!');
74
+
75
+ await client1.close();
76
+ await client2.close();
77
+ });
tests/fixtures.ts CHANGED
@@ -65,12 +65,14 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
65
  },
66
 
67
  startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
68
- const userDataDir = testInfo.outputPath('user-data-dir');
69
  const configDir = path.dirname(test.info().config.configFile!);
70
  let client: Client | undefined;
71
 
72
  await use(async options => {
73
- const args = ['--user-data-dir', path.relative(configDir, userDataDir)];
 
 
74
  if (process.env.CI && process.platform === 'linux')
75
  args.push('--no-sandbox');
76
  if (mcpHeadless)
@@ -239,5 +241,5 @@ export const expect = baseExpect.extend({
239
  });
240
 
241
  export function formatOutput(output: string): string[] {
242
- return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/test-results.*/, '').trim()).filter(Boolean);
243
  }
 
65
  },
66
 
67
  startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
68
+ const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined;
69
  const configDir = path.dirname(test.info().config.configFile!);
70
  let client: Client | undefined;
71
 
72
  await use(async options => {
73
+ const args: string[] = [];
74
+ if (userDataDir)
75
+ args.push('--user-data-dir', userDataDir);
76
  if (process.env.CI && process.platform === 'linux')
77
  args.push('--no-sandbox');
78
  if (mcpHeadless)
 
241
  });
242
 
243
  export function formatOutput(output: string): string[] {
244
+ return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').trim()).filter(Boolean);
245
  }