Compare commits
209 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5d810f896 | ||
|
|
1efd3b55e5 | ||
|
|
1d1db1e287 | ||
|
|
25f15e7f5e | ||
|
|
c559243ef6 | ||
|
|
91d5d24cab | ||
|
|
92554abfd1 | ||
|
|
4370f2cdf2 | ||
|
|
ba726fb44a | ||
|
|
2fc4e88048 | ||
|
|
3f148a4005 | ||
|
|
c92aefdc12 | ||
|
|
badfd82202 | ||
|
|
12942b81d6 | ||
|
|
73adb0fdf0 | ||
|
|
8572ab300c | ||
|
|
c091a11d76 | ||
|
|
dbd44110f1 | ||
|
|
2f41a3f6b1 | ||
|
|
7c4d67b3ae | ||
|
|
53c6b6dcb1 | ||
|
|
1fb2878271 | ||
|
|
ab0ecc4075 | ||
|
|
f010164bf1 | ||
|
|
db9cfe1720 | ||
|
|
24f81a7a27 | ||
|
|
21ced701b5 | ||
|
|
d3bf2eefc6 | ||
|
|
2ca899316d | ||
|
|
16f3523317 | ||
|
|
6c2dda31ad | ||
|
|
3b6ecf0a43 | ||
|
|
636f1956cc | ||
|
|
5aef2aafcb | ||
|
|
8ecc46c905 | ||
|
|
5dbb1504ba | ||
|
|
20e1144c3b | ||
|
|
eab20aa69e | ||
|
|
46ce86f97e | ||
|
|
4890b9d509 | ||
|
|
3f6837baa9 | ||
|
|
6d62c173c8 | ||
|
|
3c6eac9b21 | ||
|
|
41a44f7abc | ||
|
|
372395666a | ||
|
|
a60d7b8cd1 | ||
|
|
ffe0117456 | ||
|
|
7c07cc86eb | ||
|
|
3787439fc1 | ||
|
|
2a86ac74e3 | ||
|
|
6dd44923da | ||
|
|
f600234897 | ||
|
|
4df162aff5 | ||
|
|
65d99fe595 | ||
|
|
903c857f19 | ||
|
|
9b5f97b076 | ||
|
|
04988d8fac | ||
|
|
2bf57e22c6 | ||
|
|
dbf113d5e4 | ||
|
|
6710a78641 | ||
|
|
a9b9fb85da | ||
|
|
26a2a6fc83 | ||
|
|
e934d5e23e | ||
|
|
ecfa10448b | ||
|
|
e153ac3b7c | ||
|
|
e0fb748ccc | ||
|
|
c63b7823e1 | ||
|
|
bd34e9d7e9 | ||
|
|
c72d0320f4 | ||
|
|
da8a244f33 | ||
|
|
31a4fb3d07 | ||
|
|
bc120baa78 | ||
|
|
2c5eac89a8 | ||
|
|
288f1b863b | ||
|
|
53e3e37991 | ||
|
|
b1a0f775cf | ||
|
|
6320b08173 | ||
|
|
601a74305c | ||
|
|
c2b98dc70b | ||
|
|
70862ce456 | ||
|
|
468c84eb8f | ||
|
|
cfcca40b90 | ||
|
|
f1826b96b6 | ||
|
|
eeeab4f042 | ||
|
|
efe3ff0c7c | ||
|
|
e3df209b96 | ||
|
|
29711d07d3 | ||
|
|
b0be1ee256 | ||
|
|
d3867affed | ||
|
|
1eee30fd45 | ||
|
|
29ac29e6bb | ||
|
|
9f8441daa5 | ||
|
|
64f950ae42 | ||
|
|
5bfff0a059 | ||
|
|
c97bc6e2ae | ||
|
|
fe0c0ffffe | ||
|
|
9526910864 | ||
|
|
95454735bf | ||
|
|
e9f6433241 | ||
|
|
d61aa16fee | ||
|
|
012c906500 | ||
|
|
825a97d66e | ||
|
|
3061d9aa56 | ||
|
|
da818d113a | ||
|
|
a5a57df105 | ||
|
|
be8adb1866 | ||
|
|
c5a2324aaf | ||
|
|
128474b4aa | ||
|
|
7fca8f50f8 | ||
|
|
841bb417d1 | ||
|
|
59f1d67a4e | ||
|
|
1600ba6645 | ||
|
|
127c996e86 | ||
|
|
4bd39c07e9 | ||
|
|
f5b68dc590 | ||
|
|
875bd3b6ec | ||
|
|
137b74750c | ||
|
|
ded00dc422 | ||
|
|
5df6c2431b | ||
|
|
9066988098 | ||
|
|
1dc4977ff9 | ||
|
|
96e234012d | ||
|
|
6c3f3b6576 | ||
|
|
0df6d7a441 | ||
|
|
4ea7041ba9 | ||
|
|
7dae68de78 | ||
|
|
60495ed9b0 | ||
|
|
0aaef661b1 | ||
|
|
abbe7858a2 | ||
|
|
767af21e02 | ||
|
|
27c498e0e7 | ||
|
|
0fb9646c4d | ||
|
|
9728527900 | ||
|
|
675b083db3 | ||
|
|
0b74cdaaf8 | ||
|
|
f31ef598bc | ||
|
|
656779531c | ||
|
|
eec177d3ac | ||
|
|
54ed7c3200 | ||
|
|
3cd74a824a | ||
|
|
177b008328 | ||
|
|
9429463951 | ||
|
|
45f493da6c | ||
|
|
9e5ffd2ccf | ||
|
|
1051ea810a | ||
|
|
f20ae22ec6 | ||
|
|
13cd1b4bd9 | ||
|
|
c318f13895 | ||
|
|
1318e39fac | ||
|
|
c2b7fb29de | ||
|
|
aa6ac51f92 | ||
|
|
fea50e6840 | ||
|
|
746c9fc124 | ||
|
|
ee33097abe | ||
|
|
ab20175826 | ||
|
|
c506027aec | ||
|
|
7be0c8872e | ||
|
|
ce72367208 | ||
|
|
949f956378 | ||
|
|
a1eee8351e | ||
|
|
fea3f26e85 | ||
|
|
dd5b41f1d8 | ||
|
|
05dc5d915b | ||
|
|
65a229c79f | ||
|
|
84664d4b09 | ||
|
|
445170a76b | ||
|
|
c28b480b51 | ||
|
|
65716b60dd | ||
|
|
75f74a54bc | ||
|
|
ef41c626ef | ||
|
|
95ca08fdb7 | ||
|
|
053c2f3d32 | ||
|
|
57b3c14276 | ||
|
|
85c85bd2fb | ||
|
|
09ba7989c3 | ||
|
|
a115c31953 | ||
|
|
b5be37e5e7 | ||
|
|
c2255246a3 | ||
|
|
950d0d1d34 | ||
|
|
cdeba454b5 | ||
|
|
91ae93c167 | ||
|
|
35e6c49d7c | ||
|
|
e95b5b1dd6 | ||
|
|
23a2e5fee7 | ||
|
|
d01aa19ffa | ||
|
|
8cd7d5a753 | ||
|
|
42faa3ccf8 | ||
|
|
4694d60fc5 | ||
|
|
7dc689eee7 | ||
|
|
5df011ad4b | ||
|
|
200cf737bb | ||
|
|
d8a59e0d0d | ||
|
|
21533d9000 | ||
|
|
49979641fa | ||
|
|
43aa4001b5 | ||
|
|
7e087af6a6 | ||
|
|
927a1280f1 | ||
|
|
292e75d464 | ||
|
|
2c9376e50f | ||
|
|
062cdd0704 | ||
|
|
a713300c5b | ||
|
|
a15f0f301b | ||
|
|
23ce973377 | ||
|
|
685dea9e19 | ||
|
|
878be97668 | ||
|
|
6d6b1a384b | ||
|
|
fd22def4c5 | ||
|
|
1b60870f50 | ||
|
|
1c760b3826 |
97
.github/workflows/ci.yml
vendored
97
.github/workflows/ci.yml
vendored
@@ -11,10 +11,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js 18
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '20'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -28,33 +28,96 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
os: [ubuntu-latest, macos-15, windows-latest]
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
- name: Use Node.js 20
|
||||||
- name: Use Node.js 18
|
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '20'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Playwright install
|
- name: Playwright install
|
||||||
run: npx playwright install --with-deps
|
run: npx playwright install --with-deps
|
||||||
|
|
||||||
- name: Install MS Edge
|
- name: Install MS Edge
|
||||||
if: ${{ matrix.os == 'macos-latest' }} # MS Edge is not preinstalled on macOS runners.
|
# MS Edge is not preinstalled on macOS runners.
|
||||||
|
if: ${{ matrix.os == 'macos-latest' }}
|
||||||
run: npx playwright install msedge
|
run: npx playwright install msedge
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
|
||||||
run: npx playwright install --with-deps
|
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm test -- --forbid-only
|
run: npm test
|
||||||
|
|
||||||
|
test_docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Use Node.js 20
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Playwright install
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
tags: playwright-mcp-dev:latest
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
load: true
|
||||||
|
- name: Run tests
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# Used for the Docker tests to share the test-results folder with the container.
|
||||||
|
umask 0000
|
||||||
|
npm run test -- --project=chromium-docker
|
||||||
|
env:
|
||||||
|
MCP_IN_DOCKER: 1
|
||||||
|
|
||||||
|
test_extension:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
runs-on: macos-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ./extension
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Use Node.js 20
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20' # crypto.randomUUID(); stalls in v18.20.8
|
||||||
|
cache: 'npm'
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Build extension
|
||||||
|
run: npm run build
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: extension
|
||||||
|
path: ./extension/dist
|
||||||
|
retention-days: 7
|
||||||
|
- name: Install and build MCP server
|
||||||
|
run: |
|
||||||
|
cd ..
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
npx playwright install chromium
|
||||||
|
- name: Run tests
|
||||||
|
run: |
|
||||||
|
if [[ "$(uname)" == "Linux" ]]; then
|
||||||
|
xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test
|
||||||
|
else
|
||||||
|
npm run test
|
||||||
|
fi
|
||||||
|
shell: bash
|
||||||
|
|||||||
44
.github/workflows/copilot-setup-steps.yml
vendored
Normal file
44
.github/workflows/copilot-setup-steps.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
name: "Copilot Setup Steps"
|
||||||
|
|
||||||
|
# Automatically run the setup steps when they are changed to allow for easy validation, and
|
||||||
|
# allow manual testing through the repository's "Actions" tab
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- .github/workflows/copilot-setup-steps.yml
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- .github/workflows/copilot-setup-steps.yml
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot.
|
||||||
|
copilot-setup-steps:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
# Set the permissions to the lowest permissions possible needed for your steps.
|
||||||
|
# Copilot will be given its own token for its operations.
|
||||||
|
permissions:
|
||||||
|
# If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission. If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete.
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# You can define any steps you want, and they will run before the agent starts.
|
||||||
|
# If you do not check out your code, Copilot will do this for you.
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "18.19"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install JavaScript dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Playwright install
|
||||||
|
run: npx playwright install --with-deps
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
78
.github/workflows/publish.yml
vendored
78
.github/workflows/publish.yml
vendored
@@ -7,7 +7,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write # Needed for npm provenance
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
@@ -21,4 +21,78 @@ jobs:
|
|||||||
- run: npm run ctest
|
- run: npm run ctest
|
||||||
- run: npm publish --provenance
|
- run: npm publish --provenance
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
|
publish-docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write # Needed for OIDC login to Azure
|
||||||
|
environment: allow-publishing-docker-to-acr
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up QEMU # Needed for multi-platform builds (e.g., arm64 on amd64 runner)
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
- name: Set up Docker Buildx # Needed for multi-platform builds
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Azure Login via OIDC
|
||||||
|
uses: azure/login@v2
|
||||||
|
with:
|
||||||
|
client-id: ${{ secrets.AZURE_DOCKER_CLIENT_ID }}
|
||||||
|
tenant-id: ${{ secrets.AZURE_DOCKER_TENANT_ID }}
|
||||||
|
subscription-id: ${{ secrets.AZURE_DOCKER_SUBSCRIPTION_ID }}
|
||||||
|
- name: Login to ACR
|
||||||
|
run: az acr login --name playwright
|
||||||
|
- name: Build and push Docker image
|
||||||
|
id: build-push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile # Adjust path if your Dockerfile is elsewhere
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
playwright.azurecr.io/public/playwright/mcp:${{ github.event.release.tag_name }}
|
||||||
|
playwright.azurecr.io/public/playwright/mcp:latest
|
||||||
|
- uses: oras-project/setup-oras@v1
|
||||||
|
- name: Set oras tags
|
||||||
|
run: |
|
||||||
|
attach_eol_manifest() {
|
||||||
|
local image="$1"
|
||||||
|
local today=$(date -u +'%Y-%m-%d')
|
||||||
|
# oras is re-using Docker credentials, so we don't need to login.
|
||||||
|
# Following the advice in https://portal.microsofticm.com/imp/v3/incidents/incident/476783820/summary
|
||||||
|
oras attach --artifact-type application/vnd.microsoft.artifact.lifecycle --annotation "vnd.microsoft.artifact.lifecycle.end-of-life.date=$today" $image
|
||||||
|
}
|
||||||
|
# for each tag, attach the eol manifest
|
||||||
|
for tag in $(echo ${{ steps.build-push.outputs.metadata['image.name'] }} | tr ',' '\n'); do
|
||||||
|
attach_eol_manifest $tag
|
||||||
|
done
|
||||||
|
|
||||||
|
package-extension:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write # Needed to upload release assets
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: 'npm'
|
||||||
|
- name: Install extension dependencies
|
||||||
|
working-directory: ./extension
|
||||||
|
run: npm ci
|
||||||
|
- name: Build extension
|
||||||
|
working-directory: ./extension
|
||||||
|
run: npm run build
|
||||||
|
- name: Package extension
|
||||||
|
working-directory: ./extension
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
zip -r ../playwright-mcp-extension-${{ github.event.release.tag_name }}.zip .
|
||||||
|
cd ..
|
||||||
|
- name: Upload extension to release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
gh release upload ${{github.event.release.tag_name}} ./extension/playwright-mcp-extension-${{ github.event.release.tag_name }}.zip
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,7 +1,10 @@
|
|||||||
lib/
|
lib/
|
||||||
|
dist/
|
||||||
node_modules/
|
node_modules/
|
||||||
test-results/
|
test-results/
|
||||||
|
playwright-report/
|
||||||
.vscode/mcp.json
|
.vscode/mcp.json
|
||||||
|
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.env
|
||||||
|
sessions/
|
||||||
|
|||||||
65
Dockerfile
65
Dockerfile
@@ -1,22 +1,69 @@
|
|||||||
FROM node:22-bookworm-slim
|
ARG PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Base
|
||||||
|
# ------------------------------
|
||||||
|
# Base stage: Contains only the minimal dependencies required for runtime
|
||||||
|
# (node_modules and Playwright system dependencies)
|
||||||
|
FROM node:22-bookworm-slim AS base
|
||||||
|
|
||||||
|
ARG PLAYWRIGHT_BROWSERS_PATH
|
||||||
|
ENV PLAYWRIGHT_BROWSERS_PATH=${PLAYWRIGHT_BROWSERS_PATH}
|
||||||
|
|
||||||
# Set the working directory
|
# Set the working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package.json and package-lock.json at this stage to leverage the build cache
|
RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \
|
||||||
COPY package*.json ./
|
--mount=type=bind,source=package.json,target=package.json \
|
||||||
|
--mount=type=bind,source=package-lock.json,target=package-lock.json \
|
||||||
|
npm ci --omit=dev && \
|
||||||
|
# Install system dependencies for playwright
|
||||||
|
npx -y playwright-core install-deps chromium
|
||||||
|
|
||||||
# Install dependencies
|
# ------------------------------
|
||||||
RUN npm ci
|
# Builder
|
||||||
|
# ------------------------------
|
||||||
|
FROM base AS builder
|
||||||
|
|
||||||
# Install chromium and its dependencies, but only for headless mode
|
RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-cache \
|
||||||
RUN npx -y playwright install --with-deps --only-shell chromium
|
--mount=type=bind,source=package.json,target=package.json \
|
||||||
|
--mount=type=bind,source=package-lock.json,target=package-lock.json \
|
||||||
|
npm ci
|
||||||
|
|
||||||
# Copy the rest of the app
|
# Copy the rest of the app
|
||||||
COPY . .
|
COPY *.json *.js *.ts .
|
||||||
|
COPY src src/
|
||||||
|
|
||||||
# Build the app
|
# Build the app
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Browser
|
||||||
|
# ------------------------------
|
||||||
|
# Cache optimization:
|
||||||
|
# - Browser is downloaded only when node_modules or Playwright system dependencies change
|
||||||
|
# - Cache is reused when only source code changes
|
||||||
|
FROM base AS browser
|
||||||
|
|
||||||
|
RUN npx -y playwright-core install --no-shell chromium
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Runtime
|
||||||
|
# ------------------------------
|
||||||
|
FROM base
|
||||||
|
|
||||||
|
ARG PLAYWRIGHT_BROWSERS_PATH
|
||||||
|
ARG USERNAME=node
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Set the correct ownership for the runtime user on production `node_modules`
|
||||||
|
RUN chown -R ${USERNAME}:${USERNAME} node_modules
|
||||||
|
|
||||||
|
USER ${USERNAME}
|
||||||
|
|
||||||
|
COPY --from=browser --chown=${USERNAME}:${USERNAME} ${PLAYWRIGHT_BROWSERS_PATH} ${PLAYWRIGHT_BROWSERS_PATH}
|
||||||
|
COPY --chown=${USERNAME}:${USERNAME} cli.js package.json ./
|
||||||
|
COPY --from=builder --chown=${USERNAME}:${USERNAME} /app/lib /app/lib
|
||||||
|
|
||||||
# Run in headless and only with chromium (other browsers need more dependencies not included in this image)
|
# Run in headless and only with chromium (other browsers need more dependencies not included in this image)
|
||||||
ENTRYPOINT ["node", "cli.js", "--headless", "--browser", "chromium"]
|
ENTRYPOINT ["node", "cli.js", "--headless", "--browser", "chromium", "--no-sandbox"]
|
||||||
|
|||||||
740
README.md
740
README.md
@@ -4,25 +4,24 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
|
|||||||
|
|
||||||
### Key Features
|
### Key Features
|
||||||
|
|
||||||
- **Fast and lightweight**: Uses Playwright's accessibility tree, not pixel-based input.
|
- **Fast and lightweight**. Uses Playwright's accessibility tree, not pixel-based input.
|
||||||
- **LLM-friendly**: No vision models needed, operates purely on structured data.
|
- **LLM-friendly**. No vision models needed, operates purely on structured data.
|
||||||
- **Deterministic tool application**: Avoids ambiguity common with screenshot-based approaches.
|
- **Deterministic tool application**. Avoids ambiguity common with screenshot-based approaches.
|
||||||
|
|
||||||
### Use Cases
|
### Requirements
|
||||||
|
- Node.js 18 or newer
|
||||||
- Web navigation and form-filling
|
- VS Code, Cursor, Windsurf, Claude Desktop, Goose or any other MCP client
|
||||||
- Data extraction from structured content
|
|
||||||
- Automated testing driven by LLMs
|
|
||||||
- General-purpose browser interaction for agents
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
// Generate using:
|
// Generate using:
|
||||||
node utils/generate_links.js
|
node utils/generate-links.js
|
||||||
-->
|
-->
|
||||||
|
|
||||||
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
|
### Getting started
|
||||||
|
|
||||||
### Example config
|
First, install the Playwright MCP server with your client.
|
||||||
|
|
||||||
|
**Standard config** works in most of the tools:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
{
|
{
|
||||||
@@ -37,20 +36,111 @@ node utils/generate_links.js
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Table of Contents
|
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
|
||||||
|
|
||||||
- [Installation in VS Code](#installation-in-vs-code)
|
|
||||||
- [Command line](#command-line)
|
|
||||||
- [User profile](#user-profile)
|
|
||||||
- [Configuration file](#configuration-file)
|
|
||||||
- [Running on Linux](#running-on-linux)
|
|
||||||
- [Docker](#docker)
|
|
||||||
- [Programmatic usage](#programmatic-usage)
|
|
||||||
- [Tool modes](#tool-modes)
|
|
||||||
|
|
||||||
### Installation in VS Code
|
<details>
|
||||||
|
<summary>Claude Code</summary>
|
||||||
|
|
||||||
You can install the Playwright MCP server using the VS Code CLI:
|
Use the Claude Code CLI to add the Playwright MCP server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add playwright npx @playwright/mcp@latest
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Claude Desktop</summary>
|
||||||
|
|
||||||
|
Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user), use the standard config above.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Cursor</summary>
|
||||||
|
|
||||||
|
#### Click the button to install:
|
||||||
|
|
||||||
|
[](cursor://anysphere.cursor-deeplink/mcp/install?name=Playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
|
||||||
|
|
||||||
|
#### Or install manually:
|
||||||
|
|
||||||
|
Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp`. You can also verify config or add command like arguments via clicking `Edit`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Gemini CLI</summary>
|
||||||
|
|
||||||
|
Follow the MCP install [guide](https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md#configure-the-mcp-server-in-settingsjson), use the standard config above.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Goose</summary>
|
||||||
|
|
||||||
|
#### Click the button to install:
|
||||||
|
|
||||||
|
[](https://block.github.io/goose/extension?cmd=npx&arg=%40playwright%2Fmcp%40latest&id=playwright&name=Playwright&description=Interact%20with%20web%20pages%20through%20structured%20accessibility%20snapshots%20using%20Playwright)
|
||||||
|
|
||||||
|
#### Or install manually:
|
||||||
|
|
||||||
|
Go to `Advanced settings` -> `Extensions` -> `Add custom extension`. Name to your liking, use type `STDIO`, and set the `command` to `npx @playwright/mcp`. Click "Add Extension".
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>LM Studio</summary>
|
||||||
|
|
||||||
|
#### Click the button to install:
|
||||||
|
|
||||||
|
[](https://lmstudio.ai/install-mcp?name=playwright&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyJAcGxheXdyaWdodC9tY3BAbGF0ZXN0Il19)
|
||||||
|
|
||||||
|
#### Or install manually:
|
||||||
|
|
||||||
|
Go to `Program` in the right sidebar -> `Install` -> `Edit mcp.json`. Use the standard config above.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>opencode</summary>
|
||||||
|
|
||||||
|
Follow the MCP Servers [documentation](https://opencode.ai/docs/mcp-servers/). For example in `~/.config/opencode/opencode.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"mcp": {
|
||||||
|
"playwright": {
|
||||||
|
"type": "local",
|
||||||
|
"command": [
|
||||||
|
"npx",
|
||||||
|
"@playwright/mcp@latest"
|
||||||
|
],
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Qodo Gen</summary>
|
||||||
|
|
||||||
|
Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in VSCode or IntelliJ → Connect more tools → + Add new MCP → Paste the standard config above.
|
||||||
|
|
||||||
|
Click <code>Save</code>.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>VS Code</summary>
|
||||||
|
|
||||||
|
#### Click the button to install:
|
||||||
|
|
||||||
|
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
|
||||||
|
|
||||||
|
#### Or install manually:
|
||||||
|
|
||||||
|
Follow the MCP install [guide](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server), use the standard config above. You can also install the Playwright MCP server using the VS Code CLI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# For VS Code
|
# For VS Code
|
||||||
@@ -58,42 +148,130 @@ code --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@la
|
|||||||
```
|
```
|
||||||
|
|
||||||
After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code.
|
After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code.
|
||||||
|
</details>
|
||||||
|
|
||||||
### Command line
|
<details>
|
||||||
|
<summary>Windsurf</summary>
|
||||||
|
|
||||||
The Playwright MCP server supports the following command-line options:
|
Follow Windsurf MCP [documentation](https://docs.windsurf.com/windsurf/cascade/mcp). Use the standard config above.
|
||||||
|
|
||||||
- `--browser <browser>`: Browser or chrome channel to use. Possible values:
|
</details>
|
||||||
- `chrome`, `firefox`, `webkit`, `msedge`
|
|
||||||
- Chrome channels: `chrome-beta`, `chrome-canary`, `chrome-dev`
|
### Configuration
|
||||||
- Edge channels: `msedge-beta`, `msedge-canary`, `msedge-dev`
|
|
||||||
- Default: `chrome`
|
Playwright MCP server supports following arguments. They can be provided in the JSON configuration above, as a part of the `"args"` list:
|
||||||
- `--caps <caps>`: Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.
|
|
||||||
- `--cdp-endpoint <endpoint>`: CDP endpoint to connect to
|
<!--- Options generated by update-readme.js -->
|
||||||
- `--executable-path <path>`: Path to the browser executable
|
|
||||||
- `--headless`: Run browser in headless mode (headed by default)
|
```
|
||||||
- `--device`: Emulate mobile device
|
> npx @playwright/mcp@latest --help
|
||||||
- `--user-data-dir <path>`: Path to the user data directory
|
--allowed-origins <origins> semicolon-separated list of origins to allow the
|
||||||
- `--port <port>`: Port to listen on for SSE transport
|
browser to request. Default is to allow all.
|
||||||
- `--host <host>`: Host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.
|
--blocked-origins <origins> semicolon-separated list of origins to block the
|
||||||
- `--vision`: Run server that uses screenshots (Aria snapshots are used by default)
|
browser from requesting. Blocklist is evaluated
|
||||||
- `--config <path>`: Path to the configuration file
|
before allowlist. If used without the allowlist,
|
||||||
|
requests not matching the blocklist are still
|
||||||
|
allowed.
|
||||||
|
--block-service-workers block service workers
|
||||||
|
--browser <browser> browser or chrome channel to use, possible
|
||||||
|
values: chrome, firefox, webkit, msedge.
|
||||||
|
--caps <caps> comma-separated list of additional capabilities
|
||||||
|
to enable, possible values: vision, pdf.
|
||||||
|
--cdp-endpoint <endpoint> CDP endpoint to connect to.
|
||||||
|
--config <path> path to the configuration file.
|
||||||
|
--device <device> device to emulate, for example: "iPhone 15"
|
||||||
|
--executable-path <path> path to the browser executable.
|
||||||
|
--extension Connect to a running browser instance
|
||||||
|
(Edge/Chrome only). Requires the "Playwright MCP
|
||||||
|
Bridge" browser extension to be installed.
|
||||||
|
--headless run browser in headless mode, headed by default
|
||||||
|
--host <host> host to bind server to. Default is localhost. Use
|
||||||
|
0.0.0.0 to bind to all interfaces.
|
||||||
|
--ignore-https-errors ignore https errors
|
||||||
|
--isolated keep the browser profile in memory, do not save
|
||||||
|
it to disk.
|
||||||
|
--image-responses <mode> whether to send image responses to the client.
|
||||||
|
Can be "allow" or "omit", Defaults to "allow".
|
||||||
|
--no-sandbox disable the sandbox for all process types that
|
||||||
|
are normally sandboxed.
|
||||||
|
--output-dir <path> path to the directory for output files.
|
||||||
|
--port <port> port to listen on for SSE transport.
|
||||||
|
--proxy-bypass <bypass> comma-separated domains to bypass proxy, for
|
||||||
|
example ".com,chromium.org,.domain.com"
|
||||||
|
--proxy-server <proxy> specify proxy server, for example
|
||||||
|
"http://myproxy:3128" or "socks5://myproxy:8080"
|
||||||
|
--save-session Whether to save the Playwright MCP session into
|
||||||
|
the output directory.
|
||||||
|
--save-trace Whether to save the Playwright Trace of the
|
||||||
|
session into the output directory.
|
||||||
|
--storage-state <path> path to the storage state file for isolated
|
||||||
|
sessions.
|
||||||
|
--user-agent <ua string> specify user agent string
|
||||||
|
--user-data-dir <path> path to the user data directory. If not
|
||||||
|
specified, a temporary directory will be created.
|
||||||
|
--viewport-size <size> specify browser viewport size in pixels, for
|
||||||
|
example "1280, 720"
|
||||||
|
```
|
||||||
|
|
||||||
|
<!--- End of options generated section -->
|
||||||
|
|
||||||
### User profile
|
### User profile
|
||||||
|
|
||||||
Playwright MCP will launch the browser with the new profile, located at
|
You can run Playwright MCP with persistent profile like a regular browser (default), in isolated contexts for testing sessions, or connect to your existing browser using the browser extension.
|
||||||
|
|
||||||
```
|
**Persistent profile**
|
||||||
- `%USERPROFILE%\AppData\Local\ms-playwright\mcp-{channel}-profile` on Windows
|
|
||||||
- `~/Library/Caches/ms-playwright/mcp-{channel}-profile` on macOS
|
All the logged in information will be stored in the persistent profile, you can delete it between sessions if you'd like to clear the offline state.
|
||||||
- `~/.cache/ms-playwright/mcp-{channel}-profile` on Linux
|
Persistent profile is located at the following locations and you can override it with the `--user-data-dir` argument.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
%USERPROFILE%\AppData\Local\ms-playwright\mcp-{channel}-profile
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
- ~/Library/Caches/ms-playwright/mcp-{channel}-profile
|
||||||
|
|
||||||
|
# Linux
|
||||||
|
- ~/.cache/ms-playwright/mcp-{channel}-profile
|
||||||
```
|
```
|
||||||
|
|
||||||
All the logged in information will be stored in that profile, you can delete it between sessions if you'd like to clear the offline state.
|
**Isolated**
|
||||||
|
|
||||||
|
In the isolated mode, each session is started in the isolated profile. Every time you ask MCP to close the browser,
|
||||||
|
the session is closed and all the storage state for this session is lost. You can provide initial storage state
|
||||||
|
to the browser via the config's `contextOptions` or via the `--storage-state` argument. Learn more about the storage
|
||||||
|
state [here](https://playwright.dev/docs/auth).
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"@playwright/mcp@latest",
|
||||||
|
"--isolated",
|
||||||
|
"--storage-state={path/to/storage.json}"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Browser Extension**
|
||||||
|
|
||||||
|
The Playwright MCP Chrome Extension allows you to connect to existing browser tabs and leverage your logged-in sessions and browser state. See [extension/README.md](extension/README.md) for installation and setup instructions.
|
||||||
|
|
||||||
### Configuration file
|
### Configuration file
|
||||||
|
|
||||||
The Playwright MCP server can be configured using a JSON configuration file. Here's the complete configuration format:
|
The Playwright MCP server can be configured using a JSON configuration file. You can specify the configuration file
|
||||||
|
using the `--config` command line option:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @playwright/mcp@latest --config path/to/config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Configuration file schema</summary>
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
@@ -102,6 +280,9 @@ The Playwright MCP server can be configured using a JSON configuration file. Her
|
|||||||
// Browser type to use (chromium, firefox, or webkit)
|
// Browser type to use (chromium, firefox, or webkit)
|
||||||
browserName?: 'chromium' | 'firefox' | 'webkit';
|
browserName?: 'chromium' | 'firefox' | 'webkit';
|
||||||
|
|
||||||
|
// Keep the browser profile in memory, do not save it to disk.
|
||||||
|
isolated?: boolean;
|
||||||
|
|
||||||
// Path to user data directory for browser profile persistence
|
// Path to user data directory for browser profile persistence
|
||||||
userDataDir?: string;
|
userDataDir?: string;
|
||||||
|
|
||||||
@@ -134,61 +315,58 @@ The Playwright MCP server can be configured using a JSON configuration file. Her
|
|||||||
host?: string; // Host to bind to (default: localhost)
|
host?: string; // Host to bind to (default: localhost)
|
||||||
},
|
},
|
||||||
|
|
||||||
// List of enabled capabilities
|
// List of additional capabilities
|
||||||
capabilities?: Array<
|
capabilities?: Array<
|
||||||
'core' | // Core browser automation
|
|
||||||
'tabs' | // Tab management
|
'tabs' | // Tab management
|
||||||
|
'install' | // Browser installation
|
||||||
'pdf' | // PDF generation
|
'pdf' | // PDF generation
|
||||||
'history' | // Browser history
|
'vision' | // Coordinate-based interactions
|
||||||
'wait' | // Wait utilities
|
|
||||||
'files' | // File handling
|
|
||||||
'install' // Browser installation
|
|
||||||
>;
|
>;
|
||||||
|
|
||||||
// Enable vision mode (screenshots instead of accessibility snapshots)
|
|
||||||
vision?: boolean;
|
|
||||||
|
|
||||||
// Directory for output files
|
// Directory for output files
|
||||||
outputDir?: string;
|
outputDir?: string;
|
||||||
|
|
||||||
// Tool-specific configurations
|
// Network configuration
|
||||||
tools?: {
|
network?: {
|
||||||
browser_take_screenshot?: {
|
// List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||||
// Disable base64-encoded image responses
|
allowedOrigins?: string[];
|
||||||
omitBase64?: boolean;
|
|
||||||
}
|
// List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||||
}
|
blockedOrigins?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to send image responses to the client. Can be "allow" or "omit".
|
||||||
|
* Defaults to "allow".
|
||||||
|
*/
|
||||||
|
imageResponses?: 'allow' | 'omit';
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
You can specify the configuration file using the `--config` command line option:
|
### Standalone MCP server
|
||||||
|
|
||||||
```bash
|
|
||||||
npx @playwright/mcp@latest --config path/to/config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running on Linux
|
|
||||||
|
|
||||||
When running headed browser on system w/o display or from worker processes of the IDEs,
|
When running headed browser on system w/o display or from worker processes of the IDEs,
|
||||||
run the MCP server from environment with the DISPLAY and pass the `--port` flag to enable SSE transport.
|
run the MCP server from environment with the DISPLAY and pass the `--port` flag to enable HTTP transport.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx @playwright/mcp@latest --port 8931
|
npx @playwright/mcp@latest --port 8931
|
||||||
```
|
```
|
||||||
|
|
||||||
And then in MCP client config, set the `url` to the SSE endpoint:
|
And then in MCP client config, set the `url` to the HTTP endpoint:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"playwright": {
|
"playwright": {
|
||||||
"url": "http://localhost:8931/sse"
|
"url": "http://localhost:8931/mcp"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker
|
<details>
|
||||||
|
<summary><b>Docker</b></summary>
|
||||||
|
|
||||||
**NOTE:** The Docker implementation only supports headless chromium at the moment.
|
**NOTE:** The Docker implementation only supports headless chromium at the moment.
|
||||||
|
|
||||||
@@ -197,7 +375,7 @@ And then in MCP client config, set the `url` to the SSE endpoint:
|
|||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"playwright": {
|
"playwright": {
|
||||||
"command": "docker",
|
"command": "docker",
|
||||||
"args": ["run", "-i", "--rm", "--init", "mcp/playwright"]
|
"args": ["run", "-i", "--rm", "--init", "--pull=always", "mcr.microsoft.com/playwright/mcp"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,95 +384,207 @@ And then in MCP client config, set the `url` to the SSE endpoint:
|
|||||||
You can build the Docker image yourself.
|
You can build the Docker image yourself.
|
||||||
|
|
||||||
```
|
```
|
||||||
docker build -t mcp/playwright .
|
docker build -t mcr.microsoft.com/playwright/mcp .
|
||||||
```
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
### Programmatic usage
|
<details>
|
||||||
|
<summary><b>Programmatic usage</b></summary>
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
|
|
||||||
import { createServer } from '@playwright/mcp';
|
import { createConnection } from '@playwright/mcp';
|
||||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
|
|
||||||
http.createServer(async (req, res) => {
|
http.createServer(async (req, res) => {
|
||||||
// ...
|
// ...
|
||||||
|
|
||||||
// Creates a headless Playwright MCP server with SSE transport
|
// Creates a headless Playwright MCP server with SSE transport
|
||||||
const mcpServer = await createServer({ headless: true });
|
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
|
||||||
const transport = new SSEServerTransport('/messages', res);
|
const transport = new SSEServerTransport('/messages', res);
|
||||||
await mcpServer.connect(transport);
|
await connection.sever.connect(transport);
|
||||||
|
|
||||||
// ...
|
// ...
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
### Tool modes
|
### Tools
|
||||||
|
|
||||||
The tools are available in two modes:
|
<!--- Tools generated by update-readme.js -->
|
||||||
|
|
||||||
1. **Snapshot Mode** (default): Uses accessibility snapshots for better performance and reliability
|
<details>
|
||||||
2. **Vision Mode**: Uses screenshots for visual-based interactions
|
<summary><b>Core automation</b></summary>
|
||||||
|
|
||||||
To use Vision Mode, add the `--vision` flag when starting the server:
|
|
||||||
|
|
||||||
```js
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"playwright": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"@playwright/mcp@latest",
|
|
||||||
"--vision"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Vision Mode works best with the computer use models that are able to interact with elements using
|
|
||||||
X Y coordinate space, based on the provided screenshot.
|
|
||||||
|
|
||||||
|
|
||||||
<!--- Generated by update-readme.js -->
|
|
||||||
|
|
||||||
### Snapshot-based Interactions
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_snapshot**
|
|
||||||
- Description: Capture accessibility snapshot of the current page, this is better than screenshot
|
|
||||||
- Parameters: None
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_click**
|
- **browser_click**
|
||||||
|
- Title: Click
|
||||||
- Description: Perform click on a web page
|
- Description: Perform click on a web page
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
- `ref` (string): Exact target element reference from the page snapshot
|
- `ref` (string): Exact target element reference from the page snapshot
|
||||||
|
- `doubleClick` (boolean, optional): Whether to perform a double click instead of a single click
|
||||||
|
- `button` (string, optional): Button to click, defaults to left
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_close**
|
||||||
|
- Title: Close browser
|
||||||
|
- Description: Close the page
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_console_messages**
|
||||||
|
- Title: Get console messages
|
||||||
|
- Description: Returns all console messages
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_drag**
|
- **browser_drag**
|
||||||
|
- Title: Drag mouse
|
||||||
- Description: Perform drag and drop between two elements
|
- Description: Perform drag and drop between two elements
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `startElement` (string): Human-readable source element description used to obtain the permission to interact with the element
|
- `startElement` (string): Human-readable source element description used to obtain the permission to interact with the element
|
||||||
- `startRef` (string): Exact source element reference from the page snapshot
|
- `startRef` (string): Exact source element reference from the page snapshot
|
||||||
- `endElement` (string): Human-readable target element description used to obtain the permission to interact with the element
|
- `endElement` (string): Human-readable target element description used to obtain the permission to interact with the element
|
||||||
- `endRef` (string): Exact target element reference from the page snapshot
|
- `endRef` (string): Exact target element reference from the page snapshot
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_evaluate**
|
||||||
|
- Title: Evaluate JavaScript
|
||||||
|
- Description: Evaluate JavaScript expression on page or element
|
||||||
|
- Parameters:
|
||||||
|
- `function` (string): () => { /* code */ } or (element) => { /* code */ } when element is provided
|
||||||
|
- `element` (string, optional): Human-readable element description used to obtain permission to interact with the element
|
||||||
|
- `ref` (string, optional): Exact target element reference from the page snapshot
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_file_upload**
|
||||||
|
- Title: Upload files
|
||||||
|
- Description: Upload one or multiple files
|
||||||
|
- Parameters:
|
||||||
|
- `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_handle_dialog**
|
||||||
|
- Title: Handle a dialog
|
||||||
|
- Description: Handle a dialog
|
||||||
|
- Parameters:
|
||||||
|
- `accept` (boolean): Whether to accept the dialog.
|
||||||
|
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_hover**
|
- **browser_hover**
|
||||||
|
- Title: Hover mouse
|
||||||
- Description: Hover over element on page
|
- Description: Hover over element on page
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
- `ref` (string): Exact target element reference from the page snapshot
|
- `ref` (string): Exact target element reference from the page snapshot
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_navigate**
|
||||||
|
- Title: Navigate to a URL
|
||||||
|
- Description: Navigate to a URL
|
||||||
|
- Parameters:
|
||||||
|
- `url` (string): The URL to navigate to
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_navigate_back**
|
||||||
|
- Title: Go back
|
||||||
|
- Description: Go back to the previous page
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_navigate_forward**
|
||||||
|
- Title: Go forward
|
||||||
|
- Description: Go forward to the next page
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_network_requests**
|
||||||
|
- Title: List network requests
|
||||||
|
- Description: Returns all network requests since loading the page
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_press_key**
|
||||||
|
- Title: Press a key
|
||||||
|
- Description: Press a key on the keyboard
|
||||||
|
- Parameters:
|
||||||
|
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_resize**
|
||||||
|
- Title: Resize browser window
|
||||||
|
- Description: Resize the browser window
|
||||||
|
- Parameters:
|
||||||
|
- `width` (number): Width of the browser window
|
||||||
|
- `height` (number): Height of the browser window
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_select_option**
|
||||||
|
- Title: Select option
|
||||||
|
- Description: Select an option in a dropdown
|
||||||
|
- Parameters:
|
||||||
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
|
- `ref` (string): Exact target element reference from the page snapshot
|
||||||
|
- `values` (array): Array of values to select in the dropdown. This can be a single value or multiple values.
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_snapshot**
|
||||||
|
- Title: Page snapshot
|
||||||
|
- Description: Capture accessibility snapshot of the current page, this is better than screenshot
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_take_screenshot**
|
||||||
|
- Title: Take a screenshot
|
||||||
|
- Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
|
||||||
|
- Parameters:
|
||||||
|
- `type` (string, optional): Image format for the screenshot. Default is png.
|
||||||
|
- `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.
|
||||||
|
- `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
|
||||||
|
- `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
|
||||||
|
- `fullPage` (boolean, optional): When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots.
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_type**
|
- **browser_type**
|
||||||
|
- Title: Type text
|
||||||
- Description: Type text into editable element
|
- Description: Type text into editable element
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
@@ -302,187 +592,127 @@ X Y coordinate space, based on the provided screenshot.
|
|||||||
- `text` (string): Text to type into the element
|
- `text` (string): Text to type into the element
|
||||||
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
|
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
|
||||||
- `slowly` (boolean, optional): Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.
|
- `slowly` (boolean, optional): Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_select_option**
|
- **browser_wait_for**
|
||||||
- Description: Select an option in a dropdown
|
- Title: Wait for
|
||||||
|
- Description: Wait for text to appear or disappear or a specified time to pass
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
- `time` (number, optional): The time to wait in seconds
|
||||||
- `ref` (string): Exact target element reference from the page snapshot
|
- `text` (string, optional): The text to wait for
|
||||||
- `values` (array): Array of values to select in the dropdown. This can be a single value or multiple values.
|
- `textGone` (string, optional): The text to wait for to disappear
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Tab management</b></summary>
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_take_screenshot**
|
- **browser_tab_close**
|
||||||
- Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
|
- Title: Close a tab
|
||||||
|
- Description: Close a tab
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `raw` (boolean, optional): Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.
|
- `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
|
||||||
- `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
|
- Read-only: **false**
|
||||||
- `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
|
|
||||||
|
|
||||||
### Vision-based Interactions
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_screen_capture**
|
- **browser_tab_list**
|
||||||
- Description: Take a screenshot of the current page
|
- Title: List tabs
|
||||||
|
- Description: List browser tabs
|
||||||
- Parameters: None
|
- Parameters: None
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_screen_move_mouse**
|
- **browser_tab_new**
|
||||||
- Description: Move mouse to a given position
|
- Title: Open a new tab
|
||||||
|
- Description: Open a new tab
|
||||||
|
- Parameters:
|
||||||
|
- `url` (string, optional): The URL to navigate to in the new tab. If not provided, the new tab will be blank.
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_tab_select**
|
||||||
|
- Title: Select a tab
|
||||||
|
- Description: Select a tab by index
|
||||||
|
- Parameters:
|
||||||
|
- `index` (number): The index of the tab to select
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Browser installation</b></summary>
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_install**
|
||||||
|
- Title: Install the browser specified in the config
|
||||||
|
- Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed.
|
||||||
|
- Parameters: None
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Coordinate-based (opt-in via --caps=vision)</b></summary>
|
||||||
|
|
||||||
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
|
- **browser_mouse_click_xy**
|
||||||
|
- Title: Click
|
||||||
|
- Description: Click left mouse button at a given position
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
- `x` (number): X coordinate
|
- `x` (number): X coordinate
|
||||||
- `y` (number): Y coordinate
|
- `y` (number): Y coordinate
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_screen_click**
|
- **browser_mouse_drag_xy**
|
||||||
- Description: Click left mouse button
|
- Title: Drag mouse
|
||||||
- Parameters:
|
- Description: Drag left mouse button to a given position
|
||||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
|
||||||
- `x` (number): X coordinate
|
|
||||||
- `y` (number): Y coordinate
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_screen_drag**
|
|
||||||
- Description: Drag left mouse button
|
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
- `startX` (number): Start X coordinate
|
- `startX` (number): Start X coordinate
|
||||||
- `startY` (number): Start Y coordinate
|
- `startY` (number): Start Y coordinate
|
||||||
- `endX` (number): End X coordinate
|
- `endX` (number): End X coordinate
|
||||||
- `endY` (number): End Y coordinate
|
- `endY` (number): End Y coordinate
|
||||||
|
- Read-only: **false**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_screen_type**
|
- **browser_mouse_move_xy**
|
||||||
- Description: Type text
|
- Title: Move mouse
|
||||||
|
- Description: Move mouse to a given position
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `text` (string): Text to type into the element
|
- `element` (string): Human-readable element description used to obtain permission to interact with the element
|
||||||
- `submit` (boolean, optional): Whether to submit entered text (press Enter after)
|
- `x` (number): X coordinate
|
||||||
|
- `y` (number): Y coordinate
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
### Tab Management
|
</details>
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<details>
|
||||||
|
<summary><b>PDF generation (opt-in via --caps=pdf)</b></summary>
|
||||||
- **browser_tab_list**
|
|
||||||
- Description: List browser tabs
|
|
||||||
- Parameters: None
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_tab_new**
|
|
||||||
- Description: Open a new tab
|
|
||||||
- Parameters:
|
|
||||||
- `url` (string, optional): The URL to navigate to in the new tab. If not provided, the new tab will be blank.
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_tab_select**
|
|
||||||
- Description: Select a tab by index
|
|
||||||
- Parameters:
|
|
||||||
- `index` (number): The index of the tab to select
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_tab_close**
|
|
||||||
- Description: Close a tab
|
|
||||||
- Parameters:
|
|
||||||
- `index` (number, optional): The index of the tab to close. Closes current tab if not provided.
|
|
||||||
|
|
||||||
### Navigation
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_navigate**
|
|
||||||
- Description: Navigate to a URL
|
|
||||||
- Parameters:
|
|
||||||
- `url` (string): The URL to navigate to
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_navigate_back**
|
|
||||||
- Description: Go back to the previous page
|
|
||||||
- Parameters: None
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_navigate_forward**
|
|
||||||
- Description: Go forward to the next page
|
|
||||||
- Parameters: None
|
|
||||||
|
|
||||||
### Keyboard
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_press_key**
|
|
||||||
- Description: Press a key on the keyboard
|
|
||||||
- Parameters:
|
|
||||||
- `key` (string): Name of the key to press or a character to generate, such as `ArrowLeft` or `a`
|
|
||||||
|
|
||||||
### Console
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_console_messages**
|
|
||||||
- Description: Returns all console messages
|
|
||||||
- Parameters: None
|
|
||||||
|
|
||||||
### Files and Media
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_file_upload**
|
|
||||||
- Description: Upload one or multiple files
|
|
||||||
- Parameters:
|
|
||||||
- `paths` (array): The absolute paths to the files to upload. Can be a single file or multiple files.
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!-- NOTE: This has been generated via update-readme.js -->
|
||||||
|
|
||||||
- **browser_pdf_save**
|
- **browser_pdf_save**
|
||||||
|
- Title: Save as PDF
|
||||||
- Description: Save page as PDF
|
- Description: Save page as PDF
|
||||||
- Parameters: None
|
|
||||||
|
|
||||||
### Utilities
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_close**
|
|
||||||
- Description: Close the page
|
|
||||||
- Parameters: None
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_wait**
|
|
||||||
- Description: Wait for a specified time in seconds
|
|
||||||
- Parameters:
|
- Parameters:
|
||||||
- `time` (number): The time to wait in seconds
|
- `filename` (string, optional): File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.
|
||||||
|
- Read-only: **true**
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
</details>
|
||||||
|
|
||||||
- **browser_resize**
|
|
||||||
- Description: Resize the browser window
|
|
||||||
- Parameters:
|
|
||||||
- `width` (number): Width of the browser window
|
|
||||||
- `height` (number): Height of the browser window
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
<!--- End of tools generated section -->
|
||||||
|
|
||||||
- **browser_install**
|
|
||||||
- Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed.
|
|
||||||
- Parameters: None
|
|
||||||
|
|
||||||
<!-- NOTE: This has been generated via update-readme.js -->
|
|
||||||
|
|
||||||
- **browser_handle_dialog**
|
|
||||||
- Description: Handle a dialog
|
|
||||||
- Parameters:
|
|
||||||
- `accept` (boolean): Whether to accept the dialog.
|
|
||||||
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
|
|
||||||
|
|
||||||
<!--- End of generated section -->
|
|
||||||
|
|||||||
2
cli.js
2
cli.js
@@ -15,4 +15,4 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require('./lib/program');
|
import './lib/program.js';
|
||||||
|
|||||||
50
config.d.ts
vendored
50
config.d.ts
vendored
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
|
|
||||||
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install';
|
export type ToolCapability = 'core' | 'core-tabs' | 'core-install' | 'vision' | 'pdf';
|
||||||
|
|
||||||
export type Config = {
|
export type Config = {
|
||||||
/**
|
/**
|
||||||
@@ -28,6 +28,11 @@ export type Config = {
|
|||||||
*/
|
*/
|
||||||
browserName?: 'chromium' | 'firefox' | 'webkit';
|
browserName?: 'chromium' | 'firefox' | 'webkit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the browser profile in memory, do not save it to disk.
|
||||||
|
*/
|
||||||
|
isolated?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Path to a user data directory for browser profile persistence.
|
* Path to a user data directory for browser profile persistence.
|
||||||
* Temporary directory is created by default.
|
* Temporary directory is created by default.
|
||||||
@@ -40,7 +45,7 @@ export type Config = {
|
|||||||
*
|
*
|
||||||
* This is useful for settings options like `channel`, `headless`, `executablePath`, etc.
|
* This is useful for settings options like `channel`, `headless`, `executablePath`, etc.
|
||||||
*/
|
*/
|
||||||
launchOptions?: playwright.BrowserLaunchOptions;
|
launchOptions?: playwright.LaunchOptions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Context options for the browser context.
|
* Context options for the browser context.
|
||||||
@@ -75,39 +80,40 @@ export type Config = {
|
|||||||
/**
|
/**
|
||||||
* List of enabled tool capabilities. Possible values:
|
* List of enabled tool capabilities. Possible values:
|
||||||
* - 'core': Core browser automation features.
|
* - 'core': Core browser automation features.
|
||||||
* - 'tabs': Tab management features.
|
|
||||||
* - 'pdf': PDF generation and manipulation.
|
* - 'pdf': PDF generation and manipulation.
|
||||||
* - 'history': Browser history access.
|
* - 'vision': Coordinate-based interactions.
|
||||||
* - 'wait': Wait and timing utilities.
|
|
||||||
* - 'files': File upload/download support.
|
|
||||||
* - 'install': Browser installation utilities.
|
|
||||||
*/
|
*/
|
||||||
capabilities?: ToolCapability[];
|
capabilities?: ToolCapability[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run server that uses screenshots (Aria snapshots are used by default).
|
* Whether to save the Playwright session into the output directory.
|
||||||
*/
|
*/
|
||||||
vision?: boolean;
|
saveSession?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to save the Playwright trace of the session into the output directory.
|
||||||
|
*/
|
||||||
|
saveTrace?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The directory to save output files.
|
* The directory to save output files.
|
||||||
*/
|
*/
|
||||||
outputDir?: string;
|
outputDir?: string;
|
||||||
|
|
||||||
/**
|
network?: {
|
||||||
* Configuration for specific tools.
|
|
||||||
*/
|
|
||||||
tools?: {
|
|
||||||
/**
|
/**
|
||||||
* Configuration for the browser_take_screenshot tool.
|
* List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||||
*/
|
*/
|
||||||
browser_take_screenshot?: {
|
allowedOrigins?: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether to disable base64-encoded image responses to the clients that
|
* List of origins to block the browser to request. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
|
||||||
* don't support binary data or prefer to save on tokens.
|
*/
|
||||||
*/
|
blockedOrigins?: string[];
|
||||||
omitBase64?: boolean;
|
};
|
||||||
}
|
|
||||||
}
|
/**
|
||||||
|
* Whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them.
|
||||||
|
*/
|
||||||
|
imageResponses?: 'allow' | 'omit';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const plugins = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const baseRules = {
|
export const baseRules = {
|
||||||
|
"import/extensions": ["error", "ignorePackages", {ts: "always"}],
|
||||||
"@typescript-eslint/no-floating-promises": "error",
|
"@typescript-eslint/no-floating-promises": "error",
|
||||||
"@typescript-eslint/no-unused-vars": [
|
"@typescript-eslint/no-unused-vars": [
|
||||||
2,
|
2,
|
||||||
@@ -179,6 +180,7 @@ export const baseRules = {
|
|||||||
|
|
||||||
// react
|
// react
|
||||||
"react/react-in-jsx-scope": 0,
|
"react/react-in-jsx-scope": 0,
|
||||||
|
"no-console": 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
const languageOptions = {
|
const languageOptions = {
|
||||||
@@ -190,6 +192,31 @@ const languageOptions = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const importOrderRules = {
|
||||||
|
"import/order": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
groups: [
|
||||||
|
"builtin",
|
||||||
|
"external",
|
||||||
|
"internal",
|
||||||
|
["parent", "sibling"],
|
||||||
|
"index",
|
||||||
|
"type",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"import/consistent-type-specifier-style": [2, "prefer-top-level"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const noFloatingPromisesRules = {
|
||||||
|
"@typescript-eslint/no-floating-promises": "error",
|
||||||
|
};
|
||||||
|
|
||||||
|
const noBooleanCompareRules = {
|
||||||
|
"@typescript-eslint/no-unnecessary-boolean-literal-compare": 2,
|
||||||
|
};
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
ignores: ["**/*.js"],
|
ignores: ["**/*.js"],
|
||||||
@@ -198,6 +225,11 @@ export default [
|
|||||||
files: ["**/*.ts", "**/*.tsx"],
|
files: ["**/*.ts", "**/*.tsx"],
|
||||||
plugins,
|
plugins,
|
||||||
languageOptions,
|
languageOptions,
|
||||||
rules: baseRules,
|
rules: {
|
||||||
|
...baseRules,
|
||||||
|
...importOrderRules,
|
||||||
|
...noFloatingPromisesRules,
|
||||||
|
...noBooleanCompareRules,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
47
examples.md
47
examples.md
@@ -1,47 +0,0 @@
|
|||||||
# Examples
|
|
||||||
|
|
||||||
Below are some examples of prompts to use with Playwright MCP. Draw inspiration from them to create your own. If you come up with a new prompt that works well, feel free to let us know in an issue or pull request.
|
|
||||||
|
|
||||||
## Write a regression test for a bug report
|
|
||||||
|
|
||||||
We have received a user report about a bug on our website https://debs-obrien.github.io/playwright-movies-app. I'd like you to verify if this bug exists by trying to follow the steps. If you see different behaviour, explain how it's different. If you see the same behaviour, please write me a Playwright regression test that encapsulates the buggy user flow. While writing the test, draw inspiration from the Playwright code you ran. Here's the user report:
|
|
||||||
|
|
||||||
Howdy. I love your movies site, but there's something fishy going on. I'm looking at The Garfield Movie - a spectacular film - but somehow it's showing the wrong length. The movie is 90mins long, I measured myself yesterday! But somehow your site is claiming 101mins. Am I looking at it wrong, or is there a bug?
|
|
||||||
|
|
||||||
**Output:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression test for bug: The Garfield Movie showing incorrect runtime
|
|
||||||
* User reported actual runtime is 90 minutes, but site shows 101 minutes
|
|
||||||
*/
|
|
||||||
test('verify The Garfield Movie runtime bug', async ({ page }) => {
|
|
||||||
// Navigate to the movies app home page
|
|
||||||
await page.goto('https://debs-obrien.github.io/playwright-movies-app');
|
|
||||||
|
|
||||||
// Wait for page to load completely
|
|
||||||
await expect(page.getByRole('heading', { name: 'Popular', level: 1 })).toBeVisible();
|
|
||||||
|
|
||||||
// Look for The Garfield Movie and click on it
|
|
||||||
const garfieldMovieLink = page.getByRole('link', { name: /poster of The Garfield Movie/i });
|
|
||||||
await expect(garfieldMovieLink).toBeVisible();
|
|
||||||
await garfieldMovieLink.click();
|
|
||||||
|
|
||||||
// Verify we're on the correct movie page
|
|
||||||
await expect(page.getByRole('heading', { name: 'The Garfield Movie', level: 1 })).toBeVisible();
|
|
||||||
|
|
||||||
// Check the runtime information
|
|
||||||
// The bug: Expected runtime should be 90 min, but site shows 101 min
|
|
||||||
const movieInfoText = await page.locator('text=English / 101 min. / 2024').textContent();
|
|
||||||
|
|
||||||
// This test will fail because of the bug (which is what we want to demonstrate)
|
|
||||||
// Once fixed, this assertion should be updated to the correct runtime (90 min)
|
|
||||||
expect(movieInfoText).toContain('90 min');
|
|
||||||
|
|
||||||
// Alternative assertion that verifies the incorrect runtime is still present
|
|
||||||
// Uncomment this and comment the above assertion to verify the bug exists
|
|
||||||
// expect(movieInfoText).toContain('101 min');
|
|
||||||
});
|
|
||||||
```
|
|
||||||
10
examples/generate-test.md
Normal file
10
examples/generate-test.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
Use Playwright tools to generate test for scenario:
|
||||||
|
|
||||||
|
## GitHub PR Checks Navigation Checklist
|
||||||
|
|
||||||
|
1. Open the [Microsoft Playwright GitHub repository](https://github.com/microsoft/playwright).
|
||||||
|
2. Click on the **Pull requests** tab.
|
||||||
|
3. Find and open the pull request titled **"chore: make noWaitAfter a default"**.
|
||||||
|
4. Switch to the **Checks** tab for that pull request.
|
||||||
|
5. Expand the **infra** check suite to view its jobs.
|
||||||
|
6. Click on the **docs & lint** job to view its details.
|
||||||
48
extension/README.md
Normal file
48
extension/README.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Playwright MCP Chrome Extension
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
The Playwright MCP Chrome Extension allows you to connect to pages in your existing browser and leverage the state of your default user profile. This means the AI assistant can interact with websites where you're already logged in, using your existing cookies, sessions, and browser state, providing a seamless experience without requiring separate authentication or setup.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Chrome/Edge/Chromium browser
|
||||||
|
|
||||||
|
## Installation Steps
|
||||||
|
|
||||||
|
### Download the Extension
|
||||||
|
|
||||||
|
Download the latest Chrome extension from GitHub:
|
||||||
|
- **Download link**: https://github.com/microsoft/playwright-mcp/releases
|
||||||
|
|
||||||
|
### Load Chrome Extension
|
||||||
|
|
||||||
|
1. Open Chrome and navigate to `chrome://extensions/`
|
||||||
|
2. Enable "Developer mode" (toggle in the top right corner)
|
||||||
|
3. Click "Load unpacked" and select the extension directory
|
||||||
|
|
||||||
|
### Configure Playwright MCP server
|
||||||
|
|
||||||
|
Configure Playwright MCP server to connect to the browser using the extension by passing the `--extension` option when running the MCP server:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright-extension": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"@playwright/mcp@latest",
|
||||||
|
"--extension"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Browser Tab Selection
|
||||||
|
|
||||||
|
When the LLM interacts with the browser for the first time, it will load a page where you can select which browser tab the LLM will connect to. This allows you to control which specific page the AI assistant will interact with during the session.
|
||||||
|
|
||||||
|
|
||||||
BIN
extension/icons/icon-128.png
Normal file
BIN
extension/icons/icon-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
BIN
extension/icons/icon-16.png
Normal file
BIN
extension/icons/icon-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 571 B |
BIN
extension/icons/icon-32.png
Normal file
BIN
extension/icons/icon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
extension/icons/icon-48.png
Normal file
BIN
extension/icons/icon-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
40
extension/manifest.json
Normal file
40
extension/manifest.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Playwright MCP Bridge",
|
||||||
|
"version": "0.0.34",
|
||||||
|
"description": "Share browser tabs with Playwright MCP server",
|
||||||
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9nMS2b0WCohjVHPGb8D9qAdkbIngDqoAjTeSccHJijgcONejge+OJxOQOMLu7b0ovt1c9BiEJa5JcpM+EHFVGL1vluBxK71zmBy1m2f9vZF3HG0LSCp7YRkum9rAIEthDwbkxx6XTvpmAY5rjFa/NON6b9Hlbo+8peUSkoOK7HTwYnnI36asZ9eUTiveIf+DMPLojW2UX33vDWG2UKvMVDewzclb4+uLxAYshY7Mx8we/b44xu+Anb/EBLKjOPk9Yh541xJ5Ozc8EiP/5yxOp9c/lRiYUHaRW+4r0HKZyFt0eZ52ti2iM4Nfk7jRXR7an3JPsUIf5deC/1cVM/+1ZQIDAQAB",
|
||||||
|
|
||||||
|
"permissions": [
|
||||||
|
"debugger",
|
||||||
|
"activeTab",
|
||||||
|
"tabs",
|
||||||
|
"storage"
|
||||||
|
],
|
||||||
|
|
||||||
|
"host_permissions": [
|
||||||
|
"<all_urls>"
|
||||||
|
],
|
||||||
|
|
||||||
|
"background": {
|
||||||
|
"service_worker": "lib/background.js",
|
||||||
|
"type": "module"
|
||||||
|
},
|
||||||
|
|
||||||
|
"action": {
|
||||||
|
"default_title": "Playwright MCP Bridge",
|
||||||
|
"default_icon": {
|
||||||
|
"16": "icons/icon-16.png",
|
||||||
|
"32": "icons/icon-32.png",
|
||||||
|
"48": "icons/icon-48.png",
|
||||||
|
"128": "icons/icon-128.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"icons": {
|
||||||
|
"16": "icons/icon-16.png",
|
||||||
|
"32": "icons/icon-32.png",
|
||||||
|
"48": "icons/icon-48.png",
|
||||||
|
"128": "icons/icon-128.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
1884
extension/package-lock.json
generated
Normal file
1884
extension/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
extension/package.json
Normal file
36
extension/package.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "@playwright/mcp-extension",
|
||||||
|
"version": "0.0.34",
|
||||||
|
"description": "Playwright MCP Browser Extension",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/microsoft/playwright-mcp.git"
|
||||||
|
},
|
||||||
|
"homepage": "https://playwright.dev",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"author": {
|
||||||
|
"name": "Microsoft Corporation"
|
||||||
|
},
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --project . && tsc --project tsconfig.ui.json && vite build",
|
||||||
|
"watch": "tsc --watch --project . & tsc --watch --project tsconfig.ui.json & vite build --watch",
|
||||||
|
"test": "playwright test",
|
||||||
|
"clean": "rm -rf dist"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/chrome": "^0.0.315",
|
||||||
|
"@types/react": "^18.2.66",
|
||||||
|
"@types/react-dom": "^18.2.22",
|
||||||
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"typescript": "^5.8.2",
|
||||||
|
"vite": "^5.0.0",
|
||||||
|
"vite-plugin-static-copy": "^3.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
31
extension/playwright.config.ts
Normal file
31
extension/playwright.config.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* 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 { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
|
import type { TestOptions } from '../tests/fixtures.js';
|
||||||
|
|
||||||
|
export default defineConfig<TestOptions>({
|
||||||
|
testDir: './tests',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: 'list',
|
||||||
|
projects: [
|
||||||
|
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
219
extension/src/background.ts
Normal file
219
extension/src/background.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* 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 { RelayConnection, debugLog } from './relayConnection.js';
|
||||||
|
|
||||||
|
type PageMessage = {
|
||||||
|
type: 'connectToMCPRelay';
|
||||||
|
mcpRelayUrl: string;
|
||||||
|
} | {
|
||||||
|
type: 'getTabs';
|
||||||
|
} | {
|
||||||
|
type: 'connectToTab';
|
||||||
|
tabId: number;
|
||||||
|
windowId: number;
|
||||||
|
mcpRelayUrl: string;
|
||||||
|
} | {
|
||||||
|
type: 'getConnectionStatus';
|
||||||
|
} | {
|
||||||
|
type: 'disconnect';
|
||||||
|
};
|
||||||
|
|
||||||
|
class TabShareExtension {
|
||||||
|
private _activeConnection: RelayConnection | undefined;
|
||||||
|
private _connectedTabId: number | null = null;
|
||||||
|
private _pendingTabSelection = new Map<number, { connection: RelayConnection, timerId?: number }>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
chrome.tabs.onRemoved.addListener(this._onTabRemoved.bind(this));
|
||||||
|
chrome.tabs.onUpdated.addListener(this._onTabUpdated.bind(this));
|
||||||
|
chrome.tabs.onActivated.addListener(this._onTabActivated.bind(this));
|
||||||
|
chrome.runtime.onMessage.addListener(this._onMessage.bind(this));
|
||||||
|
chrome.action.onClicked.addListener(this._onActionClicked.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promise-based message handling is not supported in Chrome: https://issues.chromium.org/issues/40753031
|
||||||
|
private _onMessage(message: PageMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void) {
|
||||||
|
switch (message.type) {
|
||||||
|
case 'connectToMCPRelay':
|
||||||
|
this._connectToRelay(sender.tab!.id!, message.mcpRelayUrl!).then(
|
||||||
|
() => sendResponse({ success: true }),
|
||||||
|
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||||
|
return true;
|
||||||
|
case 'getTabs':
|
||||||
|
this._getTabs().then(
|
||||||
|
tabs => sendResponse({ success: true, tabs, currentTabId: sender.tab?.id }),
|
||||||
|
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||||
|
return true;
|
||||||
|
case 'connectToTab':
|
||||||
|
this._connectTab(sender.tab!.id!, message.tabId, message.windowId, message.mcpRelayUrl!).then(
|
||||||
|
() => sendResponse({ success: true }),
|
||||||
|
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||||
|
return true; // Return true to indicate that the response will be sent asynchronously
|
||||||
|
case 'getConnectionStatus':
|
||||||
|
sendResponse({
|
||||||
|
connectedTabId: this._connectedTabId
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
case 'disconnect':
|
||||||
|
this._disconnect().then(
|
||||||
|
() => sendResponse({ success: true }),
|
||||||
|
(error: any) => sendResponse({ success: false, error: error.message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _connectToRelay(selectorTabId: number, mcpRelayUrl: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
debugLog(`Connecting to relay at ${mcpRelayUrl}`);
|
||||||
|
const socket = new WebSocket(mcpRelayUrl);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
socket.onopen = () => resolve();
|
||||||
|
socket.onerror = () => reject(new Error('WebSocket error'));
|
||||||
|
setTimeout(() => reject(new Error('Connection timeout')), 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const connection = new RelayConnection(socket);
|
||||||
|
connection.onclose = () => {
|
||||||
|
debugLog('Connection closed');
|
||||||
|
this._pendingTabSelection.delete(selectorTabId);
|
||||||
|
// TODO: show error in the selector tab?
|
||||||
|
};
|
||||||
|
this._pendingTabSelection.set(selectorTabId, { connection });
|
||||||
|
debugLog(`Connected to MCP relay`);
|
||||||
|
} catch (error: any) {
|
||||||
|
debugLog(`Failed to connect to MCP relay:`, error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _connectTab(selectorTabId: number, tabId: number, windowId: number, mcpRelayUrl: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
debugLog(`Connecting tab ${tabId} to relay at ${mcpRelayUrl}`);
|
||||||
|
try {
|
||||||
|
this._activeConnection?.close('Another connection is requested');
|
||||||
|
} catch (error: any) {
|
||||||
|
debugLog(`Error closing active connection:`, error);
|
||||||
|
}
|
||||||
|
await this._setConnectedTabId(null);
|
||||||
|
|
||||||
|
this._activeConnection = this._pendingTabSelection.get(selectorTabId)?.connection;
|
||||||
|
if (!this._activeConnection)
|
||||||
|
throw new Error('No active MCP relay connection');
|
||||||
|
this._pendingTabSelection.delete(selectorTabId);
|
||||||
|
|
||||||
|
this._activeConnection.setTabId(tabId);
|
||||||
|
this._activeConnection.onclose = () => {
|
||||||
|
debugLog('MCP connection closed');
|
||||||
|
this._activeConnection = undefined;
|
||||||
|
void this._setConnectedTabId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
this._setConnectedTabId(tabId),
|
||||||
|
chrome.tabs.update(tabId, { active: true }),
|
||||||
|
chrome.windows.update(windowId, { focused: true }),
|
||||||
|
]);
|
||||||
|
debugLog(`Connected to MCP bridge`);
|
||||||
|
} catch (error: any) {
|
||||||
|
await this._setConnectedTabId(null);
|
||||||
|
debugLog(`Failed to connect tab ${tabId}:`, error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _setConnectedTabId(tabId: number | null): Promise<void> {
|
||||||
|
const oldTabId = this._connectedTabId;
|
||||||
|
this._connectedTabId = tabId;
|
||||||
|
if (oldTabId && oldTabId !== tabId)
|
||||||
|
await this._updateBadge(oldTabId, { text: '' });
|
||||||
|
if (tabId)
|
||||||
|
await this._updateBadge(tabId, { text: '✓', color: '#4CAF50', title: 'Connected to MCP client' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _updateBadge(tabId: number, { text, color, title }: { text: string; color?: string, title?: string }): Promise<void> {
|
||||||
|
try {
|
||||||
|
await chrome.action.setBadgeText({ tabId, text });
|
||||||
|
await chrome.action.setTitle({ tabId, title: title || '' });
|
||||||
|
if (color)
|
||||||
|
await chrome.action.setBadgeBackgroundColor({ tabId, color });
|
||||||
|
} catch (error: any) {
|
||||||
|
// Ignore errors as the tab may be closed already.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _onTabRemoved(tabId: number): Promise<void> {
|
||||||
|
const pendingConnection = this._pendingTabSelection.get(tabId)?.connection;
|
||||||
|
if (pendingConnection) {
|
||||||
|
this._pendingTabSelection.delete(tabId);
|
||||||
|
pendingConnection.close('Browser tab closed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this._connectedTabId !== tabId)
|
||||||
|
return;
|
||||||
|
this._activeConnection?.close('Browser tab closed');
|
||||||
|
this._activeConnection = undefined;
|
||||||
|
this._connectedTabId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onTabActivated(activeInfo: chrome.tabs.TabActiveInfo) {
|
||||||
|
for (const [tabId, pending] of this._pendingTabSelection) {
|
||||||
|
if (tabId === activeInfo.tabId) {
|
||||||
|
if (pending.timerId) {
|
||||||
|
clearTimeout(pending.timerId);
|
||||||
|
pending.timerId = undefined;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!pending.timerId) {
|
||||||
|
pending.timerId = setTimeout(() => {
|
||||||
|
const existed = this._pendingTabSelection.delete(tabId);
|
||||||
|
if (existed) {
|
||||||
|
pending.connection.close('Tab has been inactive for 5 seconds');
|
||||||
|
chrome.tabs.sendMessage(tabId, { type: 'connectionTimeout' });
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) {
|
||||||
|
if (this._connectedTabId === tabId)
|
||||||
|
void this._setConnectedTabId(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _getTabs(): Promise<chrome.tabs.Tab[]> {
|
||||||
|
const tabs = await chrome.tabs.query({});
|
||||||
|
return tabs.filter(tab => tab.url && !['chrome:', 'edge:', 'devtools:'].some(scheme => tab.url!.startsWith(scheme)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _onActionClicked(): Promise<void> {
|
||||||
|
await chrome.tabs.create({
|
||||||
|
url: chrome.runtime.getURL('status.html'),
|
||||||
|
active: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _disconnect(): Promise<void> {
|
||||||
|
this._activeConnection?.close('User disconnected');
|
||||||
|
this._activeConnection = undefined;
|
||||||
|
await this._setConnectedTabId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
new TabShareExtension();
|
||||||
178
extension/src/relayConnection.ts
Normal file
178
extension/src/relayConnection.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function debugLog(...args: unknown[]): void {
|
||||||
|
const enabled = true;
|
||||||
|
if (enabled) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('[Extension]', ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProtocolCommand = {
|
||||||
|
id: number;
|
||||||
|
method: string;
|
||||||
|
params?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProtocolResponse = {
|
||||||
|
id?: number;
|
||||||
|
method?: string;
|
||||||
|
params?: any;
|
||||||
|
result?: any;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class RelayConnection {
|
||||||
|
private _debuggee: chrome.debugger.Debuggee;
|
||||||
|
private _ws: WebSocket;
|
||||||
|
private _eventListener: (source: chrome.debugger.DebuggerSession, method: string, params: any) => void;
|
||||||
|
private _detachListener: (source: chrome.debugger.Debuggee, reason: string) => void;
|
||||||
|
private _tabPromise: Promise<void>;
|
||||||
|
private _tabPromiseResolve!: () => void;
|
||||||
|
private _closed = false;
|
||||||
|
|
||||||
|
onclose?: () => void;
|
||||||
|
|
||||||
|
constructor(ws: WebSocket) {
|
||||||
|
this._debuggee = { };
|
||||||
|
this._tabPromise = new Promise(resolve => this._tabPromiseResolve = resolve);
|
||||||
|
this._ws = ws;
|
||||||
|
this._ws.onmessage = this._onMessage.bind(this);
|
||||||
|
this._ws.onclose = () => this._onClose();
|
||||||
|
// Store listeners for cleanup
|
||||||
|
this._eventListener = this._onDebuggerEvent.bind(this);
|
||||||
|
this._detachListener = this._onDebuggerDetach.bind(this);
|
||||||
|
chrome.debugger.onEvent.addListener(this._eventListener);
|
||||||
|
chrome.debugger.onDetach.addListener(this._detachListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Either setTabId or close is called after creating the connection.
|
||||||
|
setTabId(tabId: number): void {
|
||||||
|
this._debuggee = { tabId };
|
||||||
|
this._tabPromiseResolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
close(message: string): void {
|
||||||
|
this._ws.close(1000, message);
|
||||||
|
// ws.onclose is called asynchronously, so we call it here to avoid forwarding
|
||||||
|
// CDP events to the closed connection.
|
||||||
|
this._onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onClose() {
|
||||||
|
if (this._closed)
|
||||||
|
return;
|
||||||
|
this._closed = true;
|
||||||
|
chrome.debugger.onEvent.removeListener(this._eventListener);
|
||||||
|
chrome.debugger.onDetach.removeListener(this._detachListener);
|
||||||
|
chrome.debugger.detach(this._debuggee).catch(() => {});
|
||||||
|
this.onclose?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onDebuggerEvent(source: chrome.debugger.DebuggerSession, method: string, params: any): void {
|
||||||
|
if (source.tabId !== this._debuggee.tabId)
|
||||||
|
return;
|
||||||
|
debugLog('Forwarding CDP event:', method, params);
|
||||||
|
const sessionId = source.sessionId;
|
||||||
|
this._sendMessage({
|
||||||
|
method: 'forwardCDPEvent',
|
||||||
|
params: {
|
||||||
|
sessionId,
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onDebuggerDetach(source: chrome.debugger.Debuggee, reason: string): void {
|
||||||
|
if (source.tabId !== this._debuggee.tabId)
|
||||||
|
return;
|
||||||
|
this.close(`Debugger detached: ${reason}`);
|
||||||
|
this._debuggee = { };
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onMessage(event: MessageEvent): void {
|
||||||
|
this._onMessageAsync(event).catch(e => debugLog('Error handling message:', e));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _onMessageAsync(event: MessageEvent): Promise<void> {
|
||||||
|
let message: ProtocolCommand;
|
||||||
|
try {
|
||||||
|
message = JSON.parse(event.data);
|
||||||
|
} catch (error: any) {
|
||||||
|
debugLog('Error parsing message:', error);
|
||||||
|
this._sendError(-32700, `Error parsing message: ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLog('Received message:', message);
|
||||||
|
|
||||||
|
const response: ProtocolResponse = {
|
||||||
|
id: message.id,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
response.result = await this._handleCommand(message);
|
||||||
|
} catch (error: any) {
|
||||||
|
debugLog('Error handling command:', error);
|
||||||
|
response.error = error.message;
|
||||||
|
}
|
||||||
|
debugLog('Sending response:', response);
|
||||||
|
this._sendMessage(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handleCommand(message: ProtocolCommand): Promise<any> {
|
||||||
|
if (message.method === 'attachToTab') {
|
||||||
|
await this._tabPromise;
|
||||||
|
debugLog('Attaching debugger to tab:', this._debuggee);
|
||||||
|
await chrome.debugger.attach(this._debuggee, '1.3');
|
||||||
|
const result: any = await chrome.debugger.sendCommand(this._debuggee, 'Target.getTargetInfo');
|
||||||
|
return {
|
||||||
|
targetInfo: result?.targetInfo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!this._debuggee.tabId)
|
||||||
|
throw new Error('No tab is connected. Please go to the Playwright MCP extension and select the tab you want to connect to.');
|
||||||
|
if (message.method === 'forwardCDPCommand') {
|
||||||
|
const { sessionId, method, params } = message.params;
|
||||||
|
debugLog('CDP command:', method, params);
|
||||||
|
const debuggerSession: chrome.debugger.DebuggerSession = {
|
||||||
|
...this._debuggee,
|
||||||
|
sessionId,
|
||||||
|
};
|
||||||
|
// Forward CDP command to chrome.debugger
|
||||||
|
return await chrome.debugger.sendCommand(
|
||||||
|
debuggerSession,
|
||||||
|
method,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _sendError(code: number, message: string): void {
|
||||||
|
this._sendMessage({
|
||||||
|
error: {
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _sendMessage(message: any): void {
|
||||||
|
if (this._ws.readyState === WebSocket.OPEN)
|
||||||
|
this._ws.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
195
extension/src/ui/connect.css
Normal file
195
extension/src/ui/connect.css
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base styles */
|
||||||
|
.app-container {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #1f2328;
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Banner */
|
||||||
|
.status-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner {
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner.connected {
|
||||||
|
color: #1f2328;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner.connected::before {
|
||||||
|
content: "\2705";
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner.error {
|
||||||
|
color: #1f2328;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-banner.error::before {
|
||||||
|
content: "\274C";
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.button-container {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-right: 8px;
|
||||||
|
min-width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.primary {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
color: #3c4043;
|
||||||
|
border: 1px solid #dadce0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.primary:hover {
|
||||||
|
background-color: #f1f3f4;
|
||||||
|
border-color: #dadce0;
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(60,64,67,.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.default {
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
color: #24292f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.default:hover {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.reject {
|
||||||
|
background-color: #da3633;
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid #da3633;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.reject:hover {
|
||||||
|
background-color: #c73836;
|
||||||
|
border-color: #c73836;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab selection */
|
||||||
|
.tab-section-title {
|
||||||
|
padding-left: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #656d76;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.selected {
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-radio {
|
||||||
|
margin-right: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-favicon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f2328;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-url {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #656d76;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
29
extension/src/ui/connect.html
Normal file
29
extension/src/ui/connect.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<!--
|
||||||
|
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.
|
||||||
|
-->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Playwright MCP extension</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="../../icons/icon-32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="../../icons/icon-16.png">
|
||||||
|
<link rel="stylesheet" href="connect.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="connect.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
168
extension/src/ui/connect.tsx
Normal file
168
extension/src/ui/connect.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* 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 React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { Button, TabItem } from './tabItem.js';
|
||||||
|
import type { TabInfo } from './tabItem.js';
|
||||||
|
|
||||||
|
type StatusType = 'connected' | 'error' | 'connecting';
|
||||||
|
|
||||||
|
const ConnectApp: React.FC = () => {
|
||||||
|
const [tabs, setTabs] = useState<TabInfo[]>([]);
|
||||||
|
const [status, setStatus] = useState<{ type: StatusType; message: string } | null>(null);
|
||||||
|
const [showButtons, setShowButtons] = useState(true);
|
||||||
|
const [showTabList, setShowTabList] = useState(true);
|
||||||
|
const [clientInfo, setClientInfo] = useState('unknown');
|
||||||
|
const [mcpRelayUrl, setMcpRelayUrl] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const relayUrl = params.get('mcpRelayUrl');
|
||||||
|
|
||||||
|
if (!relayUrl) {
|
||||||
|
setShowButtons(false);
|
||||||
|
setStatus({ type: 'error', message: 'Missing mcpRelayUrl parameter in URL.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMcpRelayUrl(relayUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = JSON.parse(params.get('client') || '{}');
|
||||||
|
const info = `${client.name}/${client.version}`;
|
||||||
|
setClientInfo(info);
|
||||||
|
setStatus({
|
||||||
|
type: 'connecting',
|
||||||
|
message: `🎭 Playwright MCP started from "${info}" is trying to connect. Do you want to continue?`
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setStatus({ type: 'error', message: 'Failed to parse client version.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void connectToMCPRelay(relayUrl);
|
||||||
|
void loadTabs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const connectToMCPRelay = useCallback(async (mcpRelayUrl: string) => {
|
||||||
|
const response = await chrome.runtime.sendMessage({ type: 'connectToMCPRelay', mcpRelayUrl });
|
||||||
|
if (!response.success)
|
||||||
|
setStatus({ type: 'error', message: 'Failed to connect to MCP relay: ' + response.error });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadTabs = useCallback(async () => {
|
||||||
|
const response = await chrome.runtime.sendMessage({ type: 'getTabs' });
|
||||||
|
if (response.success)
|
||||||
|
setTabs(response.tabs);
|
||||||
|
else
|
||||||
|
setStatus({ type: 'error', message: 'Failed to load tabs: ' + response.error });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleConnectToTab = useCallback(async (tab: TabInfo) => {
|
||||||
|
setShowButtons(false);
|
||||||
|
setShowTabList(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await chrome.runtime.sendMessage({
|
||||||
|
type: 'connectToTab',
|
||||||
|
mcpRelayUrl,
|
||||||
|
tabId: tab.id,
|
||||||
|
windowId: tab.windowId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.success) {
|
||||||
|
setStatus({ type: 'connected', message: `MCP client "${clientInfo}" connected.` });
|
||||||
|
} else {
|
||||||
|
setStatus({
|
||||||
|
type: 'error',
|
||||||
|
message: response?.error || `MCP client "${clientInfo}" failed to connect.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setStatus({
|
||||||
|
type: 'error',
|
||||||
|
message: `MCP client "${clientInfo}" failed to connect: ${e}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [clientInfo, mcpRelayUrl]);
|
||||||
|
|
||||||
|
const handleReject = useCallback(() => {
|
||||||
|
setShowButtons(false);
|
||||||
|
setShowTabList(false);
|
||||||
|
setStatus({ type: 'error', message: 'Connection rejected. This tab can be closed.' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = (message: any) => {
|
||||||
|
if (message.type === 'connectionTimeout')
|
||||||
|
handleReject();
|
||||||
|
};
|
||||||
|
chrome.runtime.onMessage.addListener(listener);
|
||||||
|
return () => {
|
||||||
|
chrome.runtime.onMessage.removeListener(listener);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='app-container'>
|
||||||
|
<div className='content-wrapper'>
|
||||||
|
{status && (
|
||||||
|
<div className='status-container'>
|
||||||
|
<StatusBanner type={status.type} message={status.message} />
|
||||||
|
{showButtons && (
|
||||||
|
<Button variant='reject' onClick={handleReject}>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showTabList && (
|
||||||
|
<div>
|
||||||
|
<div className='tab-section-title'>
|
||||||
|
Select page to expose to MCP server:
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<TabItem
|
||||||
|
key={tab.id}
|
||||||
|
tab={tab}
|
||||||
|
button={
|
||||||
|
<Button variant='primary' onClick={() => handleConnectToTab(tab)}>
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusBanner: React.FC<{ type: StatusType; message: string }> = ({ type, message }) => {
|
||||||
|
return <div className={`status-banner ${type}`}>{message}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize the React app
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
if (container) {
|
||||||
|
const root = createRoot(container);
|
||||||
|
root.render(<ConnectApp />);
|
||||||
|
}
|
||||||
13
extension/src/ui/status.html
Normal file
13
extension/src/ui/status.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Playwright MCP Bridge Status</title>
|
||||||
|
<link rel="stylesheet" href="connect.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="status.tsx" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
110
extension/src/ui/status.tsx
Normal file
110
extension/src/ui/status.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* 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 React, { useState, useEffect } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { Button, TabItem } from './tabItem.js';
|
||||||
|
|
||||||
|
import type { TabInfo } from './tabItem.js';
|
||||||
|
|
||||||
|
interface ConnectionStatus {
|
||||||
|
isConnected: boolean;
|
||||||
|
connectedTabId: number | null;
|
||||||
|
connectedTab?: TabInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatusApp: React.FC = () => {
|
||||||
|
const [status, setStatus] = useState<ConnectionStatus>({
|
||||||
|
isConnected: false,
|
||||||
|
connectedTabId: null
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadStatus = async () => {
|
||||||
|
// Get current connection status from background script
|
||||||
|
const { connectedTabId } = await chrome.runtime.sendMessage({ type: 'getConnectionStatus' });
|
||||||
|
if (connectedTabId) {
|
||||||
|
const tab = await chrome.tabs.get(connectedTabId);
|
||||||
|
setStatus({
|
||||||
|
isConnected: true,
|
||||||
|
connectedTabId,
|
||||||
|
connectedTab: {
|
||||||
|
id: tab.id!,
|
||||||
|
windowId: tab.windowId!,
|
||||||
|
title: tab.title!,
|
||||||
|
url: tab.url!,
|
||||||
|
favIconUrl: tab.favIconUrl
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setStatus({
|
||||||
|
isConnected: false,
|
||||||
|
connectedTabId: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openConnectedTab = async () => {
|
||||||
|
if (!status.connectedTabId)
|
||||||
|
return;
|
||||||
|
await chrome.tabs.update(status.connectedTabId, { active: true });
|
||||||
|
window.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnect = async () => {
|
||||||
|
await chrome.runtime.sendMessage({ type: 'disconnect' });
|
||||||
|
window.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='app-container'>
|
||||||
|
<div className='content-wrapper'>
|
||||||
|
{status.isConnected && status.connectedTab ? (
|
||||||
|
<div>
|
||||||
|
<div className='tab-section-title'>
|
||||||
|
Page with connected MCP client:
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<TabItem
|
||||||
|
tab={status.connectedTab}
|
||||||
|
button={
|
||||||
|
<Button variant='primary' onClick={disconnect}>
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
onClick={openConnectedTab}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='status-banner'>
|
||||||
|
No MCP clients are currently connected.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize the React app
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
if (container) {
|
||||||
|
const root = createRoot(container);
|
||||||
|
root.render(<StatusApp />);
|
||||||
|
}
|
||||||
67
extension/src/ui/tabItem.tsx
Normal file
67
extension/src/ui/tabItem.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* 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 React from 'react';
|
||||||
|
|
||||||
|
export interface TabInfo {
|
||||||
|
id: number;
|
||||||
|
windowId: number;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
favIconUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button: React.FC<{ variant: 'primary' | 'default' | 'reject'; onClick: () => void; children: React.ReactNode }> = ({
|
||||||
|
variant,
|
||||||
|
onClick,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<button className={`button ${variant}`} onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export interface TabItemProps {
|
||||||
|
tab: TabInfo;
|
||||||
|
onClick?: () => void;
|
||||||
|
button?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TabItem: React.FC<TabItemProps> = ({
|
||||||
|
tab,
|
||||||
|
onClick,
|
||||||
|
button
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className='tab-item' onClick={onClick} style={onClick ? { cursor: 'pointer' } : undefined}>
|
||||||
|
<img
|
||||||
|
src={tab.favIconUrl || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><rect width="16" height="16" fill="%23f6f8fa"/></svg>'}
|
||||||
|
alt=''
|
||||||
|
className='tab-favicon'
|
||||||
|
/>
|
||||||
|
<div className='tab-content'>
|
||||||
|
<div className='tab-title'>
|
||||||
|
{tab.title || 'Untitled'}
|
||||||
|
</div>
|
||||||
|
<div className='tab-url'>{tab.url}</div>
|
||||||
|
</div>
|
||||||
|
{button}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
4
extension/src/ui/tsconfig.json
Normal file
4
extension/src/ui/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Help VSCode to find right tsconfig file.
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.ui.json"
|
||||||
|
}
|
||||||
187
extension/tests/extension.spec.ts
Normal file
187
extension/tests/extension.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* 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 { fileURLToPath } from 'url';
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
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 { StartClient } from '../../tests/fixtures.js';
|
||||||
|
|
||||||
|
type BrowserWithExtension = {
|
||||||
|
userDataDir: string;
|
||||||
|
launch: (mode?: 'disable-extension') => Promise<BrowserContext>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const test = base.extend<{ browserWithExtension: BrowserWithExtension }>({
|
||||||
|
browserWithExtension: async ({ mcpBrowser }, use, testInfo) => {
|
||||||
|
// 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#
|
||||||
|
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;
|
||||||
|
const userDataDir = testInfo.outputPath('extension-user-data-dir');
|
||||||
|
await use({
|
||||||
|
userDataDir,
|
||||||
|
launch: async (mode?: 'disable-extension') => {
|
||||||
|
browserContext = await chromium.launchPersistentContext(userDataDir, {
|
||||||
|
channel: mcpBrowser,
|
||||||
|
// Opening the browser singleton only works in headed.
|
||||||
|
headless: false,
|
||||||
|
// Automation disables singleton browser process behavior, which is necessary for the extension.
|
||||||
|
ignoreDefaultArgs: ['--enable-automation'],
|
||||||
|
args: mode === 'disable-extension' ? [] : [
|
||||||
|
`--disable-extensions-except=${pathToExtension}`,
|
||||||
|
`--load-extension=${pathToExtension}`,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// for manifest v3:
|
||||||
|
let [serviceWorker] = browserContext.serviceWorkers();
|
||||||
|
if (!serviceWorker)
|
||||||
|
serviceWorker = await browserContext.waitForEvent('serviceworker');
|
||||||
|
|
||||||
|
return browserContext;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await browserContext?.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function startAndCallConnectTool(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
|
||||||
|
const { client } = await startClient({
|
||||||
|
args: [`--connect-tool`],
|
||||||
|
config: {
|
||||||
|
browser: {
|
||||||
|
userDataDir: browserWithExtension.userDataDir,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_connect',
|
||||||
|
arguments: {
|
||||||
|
name: 'extension'
|
||||||
|
}
|
||||||
|
})).toHaveResponse({
|
||||||
|
result: 'Successfully changed connection method.',
|
||||||
|
});
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startWithExtensionFlag(browserWithExtension: BrowserWithExtension, startClient: StartClient): Promise<Client> {
|
||||||
|
const { client } = await startClient({
|
||||||
|
args: [`--extension`],
|
||||||
|
config: {
|
||||||
|
browser: {
|
||||||
|
userDataDir: browserWithExtension.userDataDir,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [mode, startClientMethod] of [
|
||||||
|
['connect-tool', startAndCallConnectTool],
|
||||||
|
['extension-flag', startWithExtensionFlag],
|
||||||
|
] as const) {
|
||||||
|
|
||||||
|
test(`navigate with extension (${mode})`, async ({ browserWithExtension, startClient, server }) => {
|
||||||
|
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 selectorPage = await confirmationPagePromise;
|
||||||
|
await selectorPage.locator('.tab-item', { hasText: 'Playwright MCP Extension' }).getByRole('button', { name: 'Connect' }).click();
|
||||||
|
|
||||||
|
expect(await navigateResponse).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`snapshot of an existing page (${mode})`, async ({ browserWithExtension, startClient, server }) => {
|
||||||
|
const browserContext = await browserWithExtension.launch();
|
||||||
|
|
||||||
|
const page = await browserContext.newPage();
|
||||||
|
await page.goto(server.HELLO_WORLD);
|
||||||
|
|
||||||
|
// Another empty page.
|
||||||
|
await browserContext.newPage();
|
||||||
|
expect(browserContext.pages()).toHaveLength(3);
|
||||||
|
|
||||||
|
const client = await startClientMethod(browserWithExtension, startClient);
|
||||||
|
expect(browserContext.pages()).toHaveLength(3);
|
||||||
|
|
||||||
|
const confirmationPagePromise = browserContext.waitForEvent('page', page => {
|
||||||
|
return page.url().startsWith('chrome-extension://jakfalbnbhgkpmoaakfflhflbfpkailf/connect.html');
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigateResponse = client.callTool({
|
||||||
|
name: 'browser_snapshot',
|
||||||
|
arguments: { },
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectorPage = await confirmationPagePromise;
|
||||||
|
expect(browserContext.pages()).toHaveLength(4);
|
||||||
|
|
||||||
|
await selectorPage.locator('.tab-item', { hasText: 'Title' }).getByRole('button', { name: 'Connect' }).click();
|
||||||
|
|
||||||
|
expect(await navigateResponse).toHaveResponse({
|
||||||
|
pageState: expect.stringContaining(`- generic [active] [ref=e1]: Hello, world!`),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(browserContext.pages()).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`extension not installed timeout (${mode})`, async ({ browserWithExtension, startClient, server }) => {
|
||||||
|
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = '100';
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toHaveResponse({
|
||||||
|
result: expect.stringContaining('Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed.'),
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await confirmationPagePromise;
|
||||||
|
|
||||||
|
process.env.PWMCP_TEST_CONNECTION_TIMEOUT = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
21
extension/tsconfig.json
Normal file
21
extension/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"strict": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "./dist/lib",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["chrome"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "react"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src",
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/ui",
|
||||||
|
]
|
||||||
|
}
|
||||||
19
extension/tsconfig.ui.json
Normal file
19
extension/tsconfig.ui.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"strict": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "./lib",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["chrome"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "react",
|
||||||
|
"noEmit": true,
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/ui",
|
||||||
|
],
|
||||||
|
}
|
||||||
54
extension/vite.config.ts
Normal file
54
extension/vite.config.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 { resolve } from 'path';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
viteStaticCopy({
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
src: '../../icons/*',
|
||||||
|
dest: 'icons'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '../../manifest.json',
|
||||||
|
dest: '.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
],
|
||||||
|
root: resolve(__dirname, 'src/ui'),
|
||||||
|
build: {
|
||||||
|
outDir: resolve(__dirname, 'dist/'),
|
||||||
|
emptyOutDir: false,
|
||||||
|
minify: false,
|
||||||
|
rollupOptions: {
|
||||||
|
input: ['src/ui/connect.html', 'src/ui/status.html'],
|
||||||
|
output: {
|
||||||
|
manualChunks: undefined,
|
||||||
|
entryFileNames: 'lib/ui/[name].js',
|
||||||
|
chunkFileNames: 'lib/ui/[name].js',
|
||||||
|
assetFileNames: 'lib/ui/[name].[ext]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
6
index.d.ts
vendored
6
index.d.ts
vendored
@@ -16,8 +16,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
import type { Config } from './config.js';
|
||||||
|
import type { BrowserContext } from 'playwright';
|
||||||
|
|
||||||
import type { Config } from './config';
|
export declare function createConnection(config?: Config, contextGetter?: () => Promise<BrowserContext>): Promise<Server>;
|
||||||
|
|
||||||
export declare function createServer(config?: Config): Promise<Server>;
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
4
index.js
4
index.js
@@ -15,5 +15,5 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { createServer } = require('./lib/index');
|
import { createConnection } from './lib/index.js';
|
||||||
module.exports = { createServer };
|
export { createConnection };
|
||||||
|
|||||||
993
package-lock.json
generated
993
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.16",
|
"version": "0.0.34",
|
||||||
"description": "Playwright Tools for MCP",
|
"description": "Playwright Tools for MCP",
|
||||||
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/microsoft/playwright-mcp.git"
|
"url": "git+https://github.com/microsoft/playwright-mcp.git"
|
||||||
@@ -16,13 +17,16 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"lint": "npm run update-readme && eslint .",
|
"lint": "npm run update-readme && npm run check-deps && eslint . && tsc --noEmit",
|
||||||
|
"lint-fix": "eslint . --fix",
|
||||||
|
"check-deps": "node utils/check-deps.js",
|
||||||
"update-readme": "node utils/update-readme.js",
|
"update-readme": "node utils/update-readme.js",
|
||||||
"watch": "tsc --watch",
|
"watch": "tsc --watch",
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
"ctest": "playwright test --project=chrome",
|
"ctest": "playwright test --project=chrome",
|
||||||
"ftest": "playwright test --project=firefox",
|
"ftest": "playwright test --project=firefox",
|
||||||
"wtest": "playwright test --project=webkit",
|
"wtest": "playwright test --project=webkit",
|
||||||
|
"run-server": "node lib/browserServer.js",
|
||||||
"clean": "rm -rf lib",
|
"clean": "rm -rf lib",
|
||||||
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
|
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
|
||||||
},
|
},
|
||||||
@@ -34,24 +38,34 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.10.1",
|
"@modelcontextprotocol/sdk": "^1.16.0",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"playwright": "1.53.0-alpha-2025-04-25",
|
"debug": "^4.4.1",
|
||||||
"yaml": "^2.7.1",
|
"dotenv": "^17.2.0",
|
||||||
|
"mime": "^4.0.7",
|
||||||
|
"playwright": "1.55.0-alpha-2025-08-12",
|
||||||
|
"playwright-core": "1.55.0-alpha-2025-08-12",
|
||||||
|
"ws": "^8.18.1",
|
||||||
|
"zod": "^3.24.1",
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.57.0",
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@playwright/test": "1.53.0-alpha-2025-04-25",
|
"@playwright/test": "1.55.0-alpha-2025-08-12",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
|
"@types/debug": "^4.1.12",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||||
"@typescript-eslint/parser": "^8.26.1",
|
"@typescript-eslint/parser": "^8.26.1",
|
||||||
"@typescript-eslint/utils": "^8.26.1",
|
"@typescript-eslint/utils": "^8.26.1",
|
||||||
|
"esbuild": "^0.20.1",
|
||||||
"eslint": "^9.19.0",
|
"eslint": "^9.19.0",
|
||||||
"eslint-plugin-import": "^2.31.0",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"eslint-plugin-notice": "^1.0.0",
|
"eslint-plugin-notice": "^1.0.0",
|
||||||
|
"openai": "^5.10.2",
|
||||||
"typescript": "^5.8.2"
|
"typescript": "^5.8.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -16,20 +16,27 @@
|
|||||||
|
|
||||||
import { defineConfig } from '@playwright/test';
|
import { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
import type { Project } from '@playwright/test';
|
import type { TestOptions } from './tests/fixtures.js';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig<TestOptions>({
|
||||||
testDir: './tests',
|
testDir: './tests',
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
workers: process.env.CI ? 2 : undefined,
|
||||||
workers: process.env.CI ? 1 : undefined,
|
|
||||||
reporter: 'list',
|
reporter: 'list',
|
||||||
projects: [
|
projects: [
|
||||||
{ name: 'chrome' },
|
{ name: 'chrome' },
|
||||||
{ name: 'msedge', use: { mcpBrowser: 'msedge' } },
|
|
||||||
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
||||||
|
...process.env.MCP_IN_DOCKER ? [{
|
||||||
|
name: 'chromium-docker',
|
||||||
|
grep: /browser_navigate|browser_click/,
|
||||||
|
use: {
|
||||||
|
mcpBrowser: 'chromium',
|
||||||
|
mcpMode: 'docker' as const
|
||||||
|
}
|
||||||
|
}] : [],
|
||||||
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
||||||
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
||||||
].filter(Boolean) as Project[],
|
... process.platform === 'win32' ? [{ name: 'msedge', use: { mcpBrowser: 'msedge' } }] : [],
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
7
src/DEPS.list
Normal file
7
src/DEPS.list
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[*]
|
||||||
|
./tools/
|
||||||
|
./mcp/
|
||||||
|
./utils/
|
||||||
|
|
||||||
|
[program.ts]
|
||||||
|
***
|
||||||
172
src/actions.d.ts
vendored
Normal file
172
src/actions.d.ts
vendored
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Point = { x: number, y: number };
|
||||||
|
|
||||||
|
export type ActionName =
|
||||||
|
'check' |
|
||||||
|
'click' |
|
||||||
|
'closePage' |
|
||||||
|
'fill' |
|
||||||
|
'navigate' |
|
||||||
|
'openPage' |
|
||||||
|
'press' |
|
||||||
|
'select' |
|
||||||
|
'uncheck' |
|
||||||
|
'setInputFiles' |
|
||||||
|
'assertText' |
|
||||||
|
'assertValue' |
|
||||||
|
'assertChecked' |
|
||||||
|
'assertVisible' |
|
||||||
|
'assertSnapshot';
|
||||||
|
|
||||||
|
export type ActionBase = {
|
||||||
|
name: ActionName,
|
||||||
|
signals: Signal[],
|
||||||
|
ariaSnapshot?: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionWithSelector = ActionBase & {
|
||||||
|
selector: string,
|
||||||
|
ref?: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClickAction = ActionWithSelector & {
|
||||||
|
name: 'click',
|
||||||
|
button: 'left' | 'middle' | 'right',
|
||||||
|
modifiers: number,
|
||||||
|
clickCount: number,
|
||||||
|
position?: Point,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CheckAction = ActionWithSelector & {
|
||||||
|
name: 'check',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UncheckAction = ActionWithSelector & {
|
||||||
|
name: 'uncheck',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FillAction = ActionWithSelector & {
|
||||||
|
name: 'fill',
|
||||||
|
text: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NavigateAction = ActionBase & {
|
||||||
|
name: 'navigate',
|
||||||
|
url: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpenPageAction = ActionBase & {
|
||||||
|
name: 'openPage',
|
||||||
|
url: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClosesPageAction = ActionBase & {
|
||||||
|
name: 'closePage',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PressAction = ActionWithSelector & {
|
||||||
|
name: 'press',
|
||||||
|
key: string,
|
||||||
|
modifiers: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SelectAction = ActionWithSelector & {
|
||||||
|
name: 'select',
|
||||||
|
options: string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SetInputFilesAction = ActionWithSelector & {
|
||||||
|
name: 'setInputFiles',
|
||||||
|
files: string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssertTextAction = ActionWithSelector & {
|
||||||
|
name: 'assertText',
|
||||||
|
text: string,
|
||||||
|
substring: boolean,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssertValueAction = ActionWithSelector & {
|
||||||
|
name: 'assertValue',
|
||||||
|
value: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssertCheckedAction = ActionWithSelector & {
|
||||||
|
name: 'assertChecked',
|
||||||
|
checked: boolean,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssertVisibleAction = ActionWithSelector & {
|
||||||
|
name: 'assertVisible',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssertSnapshotAction = ActionWithSelector & {
|
||||||
|
name: 'assertSnapshot',
|
||||||
|
ariaSnapshot: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Action = ClickAction | CheckAction | ClosesPageAction | OpenPageAction | UncheckAction | FillAction | NavigateAction | PressAction | SelectAction | SetInputFilesAction | AssertTextAction | AssertValueAction | AssertCheckedAction | AssertVisibleAction | AssertSnapshotAction;
|
||||||
|
export type AssertAction = AssertCheckedAction | AssertValueAction | AssertTextAction | AssertVisibleAction | AssertSnapshotAction;
|
||||||
|
export type PerformOnRecordAction = ClickAction | CheckAction | UncheckAction | PressAction | SelectAction;
|
||||||
|
|
||||||
|
// Signals.
|
||||||
|
|
||||||
|
export type BaseSignal = {
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NavigationSignal = BaseSignal & {
|
||||||
|
name: 'navigation',
|
||||||
|
url: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PopupSignal = BaseSignal & {
|
||||||
|
name: 'popup',
|
||||||
|
popupAlias: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DownloadSignal = BaseSignal & {
|
||||||
|
name: 'download',
|
||||||
|
downloadAlias: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DialogSignal = BaseSignal & {
|
||||||
|
name: 'dialog',
|
||||||
|
dialogAlias: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Signal = NavigationSignal | PopupSignal | DownloadSignal | DialogSignal;
|
||||||
|
|
||||||
|
export type FrameDescription = {
|
||||||
|
pageGuid: string;
|
||||||
|
pageAlias: string;
|
||||||
|
framePath: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionInContext = {
|
||||||
|
frame: FrameDescription;
|
||||||
|
description?: string;
|
||||||
|
action: Action;
|
||||||
|
startTime: number;
|
||||||
|
endTime?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SignalInContext = {
|
||||||
|
frame: FrameDescription;
|
||||||
|
signal: Signal;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
253
src/browserContextFactory.ts
Normal file
253
src/browserContextFactory.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* 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 net from 'net';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import * as playwright from 'playwright';
|
||||||
|
// @ts-ignore
|
||||||
|
import { registryDirectory } from 'playwright-core/lib/server/registry/index';
|
||||||
|
// @ts-ignore
|
||||||
|
import { startTraceViewerServer } from 'playwright-core/lib/server';
|
||||||
|
import { logUnhandledError, testDebug } from './utils/log.js';
|
||||||
|
import { createHash } from './utils/guid.js';
|
||||||
|
import { outputFile } from './config.js';
|
||||||
|
|
||||||
|
import type { FullConfig } from './config.js';
|
||||||
|
|
||||||
|
export function contextFactory(config: FullConfig): BrowserContextFactory {
|
||||||
|
if (config.browser.remoteEndpoint)
|
||||||
|
return new RemoteContextFactory(config);
|
||||||
|
if (config.browser.cdpEndpoint)
|
||||||
|
return new CdpContextFactory(config);
|
||||||
|
if (config.browser.isolated)
|
||||||
|
return new IsolatedContextFactory(config);
|
||||||
|
return new PersistentContextFactory(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientInfo = { name?: string, version?: string, rootPath?: string };
|
||||||
|
|
||||||
|
export interface BrowserContextFactory {
|
||||||
|
readonly name: string;
|
||||||
|
readonly description: string;
|
||||||
|
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class BaseContextFactory implements BrowserContextFactory {
|
||||||
|
readonly name: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly config: FullConfig;
|
||||||
|
protected _browserPromise: Promise<playwright.Browser> | undefined;
|
||||||
|
|
||||||
|
constructor(name: string, description: string, config: FullConfig) {
|
||||||
|
this.name = name;
|
||||||
|
this.description = description;
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _obtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
|
||||||
|
if (this._browserPromise)
|
||||||
|
return this._browserPromise;
|
||||||
|
testDebug(`obtain browser (${this.name})`);
|
||||||
|
this._browserPromise = this._doObtainBrowser(clientInfo);
|
||||||
|
void this._browserPromise.then(browser => {
|
||||||
|
browser.on('disconnected', () => {
|
||||||
|
this._browserPromise = undefined;
|
||||||
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
this._browserPromise = undefined;
|
||||||
|
});
|
||||||
|
return this._browserPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
|
||||||
|
throw new Error('Not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
|
testDebug(`create browser context (${this.name})`);
|
||||||
|
const browser = await this._obtainBrowser(clientInfo);
|
||||||
|
const browserContext = await this._doCreateContext(browser);
|
||||||
|
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
|
throw new Error('Not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) {
|
||||||
|
testDebug(`close browser context (${this.name})`);
|
||||||
|
if (browser.contexts().length === 1)
|
||||||
|
this._browserPromise = undefined;
|
||||||
|
await browserContext.close().catch(logUnhandledError);
|
||||||
|
if (browser.contexts().length === 0) {
|
||||||
|
testDebug(`close browser (${this.name})`);
|
||||||
|
await browser.close().catch(logUnhandledError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class IsolatedContextFactory extends BaseContextFactory {
|
||||||
|
constructor(config: FullConfig) {
|
||||||
|
super('isolated', 'Create a new isolated browser context', config);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doObtainBrowser(clientInfo: ClientInfo): Promise<playwright.Browser> {
|
||||||
|
await injectCdpPort(this.config.browser);
|
||||||
|
const browserType = playwright[this.config.browser.browserName];
|
||||||
|
return browserType.launch({
|
||||||
|
tracesDir: await startTraceServer(this.config, clientInfo.rootPath),
|
||||||
|
...this.config.browser.launchOptions,
|
||||||
|
handleSIGINT: false,
|
||||||
|
handleSIGTERM: false,
|
||||||
|
}).catch(error => {
|
||||||
|
if (error.message.includes('Executable doesn\'t exist'))
|
||||||
|
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
|
return browser.newContext(this.config.browser.contextOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CdpContextFactory extends BaseContextFactory {
|
||||||
|
constructor(config: FullConfig) {
|
||||||
|
super('cdp', 'Connect to a browser over CDP', config);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
|
return playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint!);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
|
return this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RemoteContextFactory extends BaseContextFactory {
|
||||||
|
constructor(config: FullConfig) {
|
||||||
|
super('remote', 'Connect to a browser using a remote endpoint', config);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
|
const url = new URL(this.config.browser.remoteEndpoint!);
|
||||||
|
url.searchParams.set('browser', this.config.browser.browserName);
|
||||||
|
if (this.config.browser.launchOptions)
|
||||||
|
url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions));
|
||||||
|
return playwright[this.config.browser.browserName].connect(String(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
|
return browser.newContext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PersistentContextFactory implements BrowserContextFactory {
|
||||||
|
readonly config: FullConfig;
|
||||||
|
readonly name = 'persistent';
|
||||||
|
readonly description = 'Create a new persistent browser context';
|
||||||
|
|
||||||
|
private _userDataDirs = new Set<string>();
|
||||||
|
|
||||||
|
constructor(config: FullConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
|
await injectCdpPort(this.config.browser);
|
||||||
|
testDebug('create browser context (persistent)');
|
||||||
|
const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath);
|
||||||
|
const tracesDir = await startTraceServer(this.config, clientInfo.rootPath);
|
||||||
|
|
||||||
|
this._userDataDirs.add(userDataDir);
|
||||||
|
testDebug('lock user data dir', userDataDir);
|
||||||
|
|
||||||
|
const browserType = playwright[this.config.browser.browserName];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
try {
|
||||||
|
const browserContext = await browserType.launchPersistentContext(userDataDir, {
|
||||||
|
tracesDir,
|
||||||
|
...this.config.browser.launchOptions,
|
||||||
|
...this.config.browser.contextOptions,
|
||||||
|
handleSIGINT: false,
|
||||||
|
handleSIGTERM: false,
|
||||||
|
});
|
||||||
|
const close = () => this._closeBrowserContext(browserContext, userDataDir);
|
||||||
|
return { browserContext, close };
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message.includes('Executable doesn\'t exist'))
|
||||||
|
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
|
||||||
|
if (error.message.includes('ProcessSingleton') || error.message.includes('Invalid URL')) {
|
||||||
|
// User data directory is already in use, try again.
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _closeBrowserContext(browserContext: playwright.BrowserContext, userDataDir: string) {
|
||||||
|
testDebug('close browser context (persistent)');
|
||||||
|
testDebug('release user data dir', userDataDir);
|
||||||
|
await browserContext.close().catch(() => {});
|
||||||
|
this._userDataDirs.delete(userDataDir);
|
||||||
|
testDebug('close browser context complete (persistent)');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _createUserDataDir(rootPath: string | undefined) {
|
||||||
|
const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? registryDirectory;
|
||||||
|
const browserToken = this.config.browser.launchOptions?.channel ?? this.config.browser?.browserName;
|
||||||
|
// Hesitant putting hundreds of files into the user's workspace, so using it for hashing instead.
|
||||||
|
const rootPathToken = rootPath ? `-${createHash(rootPath)}` : '';
|
||||||
|
const result = path.join(dir, `mcp-${browserToken}${rootPathToken}`);
|
||||||
|
await fs.promises.mkdir(result, { recursive: true });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function injectCdpPort(browserConfig: FullConfig['browser']) {
|
||||||
|
if (browserConfig.browserName === 'chromium')
|
||||||
|
(browserConfig.launchOptions as any).cdpPort = await findFreePort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findFreePort(): Promise<number> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startTraceServer(config: FullConfig, rootPath: string | undefined): Promise<string | undefined> {
|
||||||
|
if (!config.saveTrace)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
const tracesDir = await outputFile(config, rootPath, `traces-${Date.now()}`);
|
||||||
|
const server = await startTraceViewerServer();
|
||||||
|
const urlPrefix = server.urlPrefix('human-readable');
|
||||||
|
const url = urlPrefix + '/trace/index.html?trace=' + tracesDir + '/trace.json';
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('\nTrace viewer listening on ' + url);
|
||||||
|
return tracesDir;
|
||||||
|
}
|
||||||
92
src/browserServerBackend.ts
Normal file
92
src/browserServerBackend.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* 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 { fileURLToPath } from 'url';
|
||||||
|
import { FullConfig } from './config.js';
|
||||||
|
import { Context } from './context.js';
|
||||||
|
import { logUnhandledError } from './utils/log.js';
|
||||||
|
import { Response } from './response.js';
|
||||||
|
import { SessionLog } from './sessionLog.js';
|
||||||
|
import { filteredTools } from './tools.js';
|
||||||
|
import { packageJSON } from './utils/package.js';
|
||||||
|
import { toMcpTool } from './mcp/tool.js';
|
||||||
|
|
||||||
|
import type { Tool } from './tools/tool.js';
|
||||||
|
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||||
|
import type * as mcpServer from './mcp/server.js';
|
||||||
|
import type { ServerBackend } from './mcp/server.js';
|
||||||
|
|
||||||
|
export class BrowserServerBackend implements ServerBackend {
|
||||||
|
name = 'Playwright';
|
||||||
|
version = packageJSON.version;
|
||||||
|
|
||||||
|
private _tools: Tool[];
|
||||||
|
private _context: Context | undefined;
|
||||||
|
private _sessionLog: SessionLog | undefined;
|
||||||
|
private _config: FullConfig;
|
||||||
|
private _browserContextFactory: BrowserContextFactory;
|
||||||
|
|
||||||
|
constructor(config: FullConfig, factory: BrowserContextFactory) {
|
||||||
|
this._config = config;
|
||||||
|
this._browserContextFactory = factory;
|
||||||
|
this._tools = filteredTools(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(clientVersion: mcpServer.ClientVersion, roots: mcpServer.Root[]): Promise<void> {
|
||||||
|
let rootPath: string | undefined;
|
||||||
|
if (roots.length > 0) {
|
||||||
|
const firstRootUri = roots[0]?.uri;
|
||||||
|
const url = firstRootUri ? new URL(firstRootUri) : undefined;
|
||||||
|
rootPath = url ? fileURLToPath(url) : undefined;
|
||||||
|
}
|
||||||
|
this._sessionLog = this._config.saveSession ? await SessionLog.create(this._config, rootPath) : undefined;
|
||||||
|
this._context = new Context({
|
||||||
|
tools: this._tools,
|
||||||
|
config: this._config,
|
||||||
|
browserContextFactory: this._browserContextFactory,
|
||||||
|
sessionLog: this._sessionLog,
|
||||||
|
clientInfo: { ...clientVersion, rootPath },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTools(): Promise<mcpServer.Tool[]> {
|
||||||
|
return this._tools.map(tool => toMcpTool(tool.schema));
|
||||||
|
}
|
||||||
|
|
||||||
|
async callTool(name: string, rawArguments: mcpServer.CallToolRequest['params']['arguments']) {
|
||||||
|
const tool = this._tools.find(tool => tool.schema.name === name)!;
|
||||||
|
if (!tool)
|
||||||
|
throw new Error(`Tool "${name}" not found`);
|
||||||
|
const parsedArguments = tool.schema.inputSchema.parse(rawArguments || {});
|
||||||
|
const context = this._context!;
|
||||||
|
const response = new Response(context, name, parsedArguments);
|
||||||
|
context.setRunningTool(true);
|
||||||
|
try {
|
||||||
|
await tool.handle(context, parsedArguments, response);
|
||||||
|
await response.finish();
|
||||||
|
this._sessionLog?.logResponse(response);
|
||||||
|
} catch (error: any) {
|
||||||
|
response.addError(String(error));
|
||||||
|
} finally {
|
||||||
|
context.setRunningTool(false);
|
||||||
|
}
|
||||||
|
return response.serialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
serverClosed() {
|
||||||
|
void this._context?.dispose().catch(logUnhandledError);
|
||||||
|
}
|
||||||
|
}
|
||||||
271
src/config.ts
271
src/config.ts
@@ -15,52 +15,92 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import net from 'net';
|
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { devices } from 'playwright';
|
import { devices } from 'playwright';
|
||||||
|
import { sanitizeForFilePath } from './utils/fileUtils.js';
|
||||||
|
|
||||||
import { sanitizeForFilePath } from './tools/utils';
|
import type { Config, ToolCapability } from '../config.js';
|
||||||
|
|
||||||
import type { Config, ToolCapability } from '../config';
|
|
||||||
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
|
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
|
||||||
|
|
||||||
export type CLIOptions = {
|
export type CLIOptions = {
|
||||||
|
allowedOrigins?: string[];
|
||||||
|
blockedOrigins?: string[];
|
||||||
|
blockServiceWorkers?: boolean;
|
||||||
browser?: string;
|
browser?: string;
|
||||||
caps?: string;
|
caps?: string[];
|
||||||
cdpEndpoint?: string;
|
cdpEndpoint?: string;
|
||||||
|
config?: string;
|
||||||
|
device?: string;
|
||||||
executablePath?: string;
|
executablePath?: string;
|
||||||
headless?: boolean;
|
headless?: boolean;
|
||||||
device?: string;
|
|
||||||
userDataDir?: string;
|
|
||||||
port?: number;
|
|
||||||
host?: string;
|
host?: string;
|
||||||
vision?: boolean;
|
ignoreHttpsErrors?: boolean;
|
||||||
config?: string;
|
isolated?: boolean;
|
||||||
|
imageResponses?: 'allow' | 'omit';
|
||||||
|
sandbox?: boolean;
|
||||||
|
outputDir?: string;
|
||||||
|
port?: number;
|
||||||
|
proxyBypass?: string;
|
||||||
|
proxyServer?: string;
|
||||||
|
saveSession?: boolean;
|
||||||
|
saveTrace?: boolean;
|
||||||
|
storageState?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
userDataDir?: string;
|
||||||
|
viewportSize?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultConfig: Config = {
|
const defaultConfig: FullConfig = {
|
||||||
browser: {
|
browser: {
|
||||||
browserName: 'chromium',
|
browserName: 'chromium',
|
||||||
userDataDir: os.tmpdir(),
|
|
||||||
launchOptions: {
|
launchOptions: {
|
||||||
channel: 'chrome',
|
channel: 'chrome',
|
||||||
headless: os.platform() === 'linux' && !process.env.DISPLAY,
|
headless: os.platform() === 'linux' && !process.env.DISPLAY,
|
||||||
|
chromiumSandbox: true,
|
||||||
},
|
},
|
||||||
contextOptions: {
|
contextOptions: {
|
||||||
viewport: null,
|
viewport: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
network: {
|
||||||
|
allowedOrigins: undefined,
|
||||||
|
blockedOrigins: undefined,
|
||||||
|
},
|
||||||
|
server: {},
|
||||||
|
saveTrace: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function resolveConfig(cliOptions: CLIOptions): Promise<Config> {
|
type BrowserUserConfig = NonNullable<Config['browser']>;
|
||||||
const config = await loadConfig(cliOptions.config);
|
|
||||||
const cliOverrides = await configFromCLIOptions(cliOptions);
|
export type FullConfig = Config & {
|
||||||
return mergeConfig(defaultConfig, mergeConfig(config, cliOverrides));
|
browser: Omit<BrowserUserConfig, 'browserName'> & {
|
||||||
|
browserName: 'chromium' | 'firefox' | 'webkit';
|
||||||
|
launchOptions: NonNullable<BrowserUserConfig['launchOptions']>;
|
||||||
|
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
|
||||||
|
},
|
||||||
|
network: NonNullable<Config['network']>,
|
||||||
|
saveTrace: boolean;
|
||||||
|
server: NonNullable<Config['server']>,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function resolveConfig(config: Config): Promise<FullConfig> {
|
||||||
|
return mergeConfig(defaultConfig, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
|
export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConfig> {
|
||||||
let browserName: 'chromium' | 'firefox' | 'webkit';
|
const configInFile = await loadConfig(cliOptions.config);
|
||||||
|
const envOverrides = configFromEnv();
|
||||||
|
const cliOverrides = configFromCLIOptions(cliOptions);
|
||||||
|
let result = defaultConfig;
|
||||||
|
result = mergeConfig(result, configInFile);
|
||||||
|
result = mergeConfig(result, envOverrides);
|
||||||
|
result = mergeConfig(result, cliOverrides);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configFromCLIOptions(cliOptions: CLIOptions): Config {
|
||||||
|
let browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
|
||||||
let channel: string | undefined;
|
let channel: string | undefined;
|
||||||
switch (cliOptions.browser) {
|
switch (cliOptions.browser) {
|
||||||
case 'chrome':
|
case 'chrome':
|
||||||
@@ -81,26 +121,60 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|||||||
case 'webkit':
|
case 'webkit':
|
||||||
browserName = 'webkit';
|
browserName = 'webkit';
|
||||||
break;
|
break;
|
||||||
default:
|
|
||||||
browserName = 'chromium';
|
|
||||||
channel = 'chrome';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Launch options
|
||||||
const launchOptions: LaunchOptions = {
|
const launchOptions: LaunchOptions = {
|
||||||
channel,
|
channel,
|
||||||
executablePath: cliOptions.executablePath,
|
executablePath: cliOptions.executablePath,
|
||||||
headless: cliOptions.headless,
|
headless: cliOptions.headless,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (browserName === 'chromium')
|
// --no-sandbox was passed, disable the sandbox
|
||||||
(launchOptions as any).webSocketPort = await findFreePort();
|
if (cliOptions.sandbox === false)
|
||||||
|
launchOptions.chromiumSandbox = false;
|
||||||
|
|
||||||
const contextOptions: BrowserContextOptions | undefined = cliOptions.device ? devices[cliOptions.device] : undefined;
|
if (cliOptions.proxyServer) {
|
||||||
|
launchOptions.proxy = {
|
||||||
|
server: cliOptions.proxyServer
|
||||||
|
};
|
||||||
|
if (cliOptions.proxyBypass)
|
||||||
|
launchOptions.proxy.bypass = cliOptions.proxyBypass;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
if (cliOptions.device && cliOptions.cdpEndpoint)
|
||||||
|
throw new Error('Device emulation is not supported with cdpEndpoint.');
|
||||||
|
|
||||||
|
// Context options
|
||||||
|
const contextOptions: BrowserContextOptions = cliOptions.device ? devices[cliOptions.device] : {};
|
||||||
|
if (cliOptions.storageState)
|
||||||
|
contextOptions.storageState = cliOptions.storageState;
|
||||||
|
|
||||||
|
if (cliOptions.userAgent)
|
||||||
|
contextOptions.userAgent = cliOptions.userAgent;
|
||||||
|
|
||||||
|
if (cliOptions.viewportSize) {
|
||||||
|
try {
|
||||||
|
const [width, height] = cliOptions.viewportSize.split(',').map(n => +n);
|
||||||
|
if (isNaN(width) || isNaN(height))
|
||||||
|
throw new Error('bad values');
|
||||||
|
contextOptions.viewport = { width, height };
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('Invalid viewport size format: use "width,height", for example --viewport-size="800,600"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cliOptions.ignoreHttpsErrors)
|
||||||
|
contextOptions.ignoreHTTPSErrors = true;
|
||||||
|
|
||||||
|
if (cliOptions.blockServiceWorkers)
|
||||||
|
contextOptions.serviceWorkers = 'block';
|
||||||
|
|
||||||
|
const result: Config = {
|
||||||
browser: {
|
browser: {
|
||||||
browserName,
|
browserName,
|
||||||
userDataDir: cliOptions.userDataDir ?? await createUserDataDir({ browserName, channel }),
|
isolated: cliOptions.isolated,
|
||||||
|
userDataDir: cliOptions.userDataDir,
|
||||||
launchOptions,
|
launchOptions,
|
||||||
contextOptions,
|
contextOptions,
|
||||||
cdpEndpoint: cliOptions.cdpEndpoint,
|
cdpEndpoint: cliOptions.cdpEndpoint,
|
||||||
@@ -109,20 +183,48 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|||||||
port: cliOptions.port,
|
port: cliOptions.port,
|
||||||
host: cliOptions.host,
|
host: cliOptions.host,
|
||||||
},
|
},
|
||||||
capabilities: cliOptions.caps?.split(',').map((c: string) => c.trim() as ToolCapability),
|
capabilities: cliOptions.caps as ToolCapability[],
|
||||||
vision: !!cliOptions.vision,
|
network: {
|
||||||
|
allowedOrigins: cliOptions.allowedOrigins,
|
||||||
|
blockedOrigins: cliOptions.blockedOrigins,
|
||||||
|
},
|
||||||
|
saveSession: cliOptions.saveSession,
|
||||||
|
saveTrace: cliOptions.saveTrace,
|
||||||
|
outputDir: cliOptions.outputDir,
|
||||||
|
imageResponses: cliOptions.imageResponses,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findFreePort() {
|
function configFromEnv(): Config {
|
||||||
return new Promise((resolve, reject) => {
|
const options: CLIOptions = {};
|
||||||
const server = net.createServer();
|
options.allowedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_ALLOWED_ORIGINS);
|
||||||
server.listen(0, () => {
|
options.blockedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_BLOCKED_ORIGINS);
|
||||||
const { port } = server.address() as net.AddressInfo;
|
options.blockServiceWorkers = envToBoolean(process.env.PLAYWRIGHT_MCP_BLOCK_SERVICE_WORKERS);
|
||||||
server.close(() => resolve(port));
|
options.browser = envToString(process.env.PLAYWRIGHT_MCP_BROWSER);
|
||||||
});
|
options.caps = commaSeparatedList(process.env.PLAYWRIGHT_MCP_CAPS);
|
||||||
server.on('error', reject);
|
options.cdpEndpoint = envToString(process.env.PLAYWRIGHT_MCP_CDP_ENDPOINT);
|
||||||
});
|
options.config = envToString(process.env.PLAYWRIGHT_MCP_CONFIG);
|
||||||
|
options.device = envToString(process.env.PLAYWRIGHT_MCP_DEVICE);
|
||||||
|
options.executablePath = envToString(process.env.PLAYWRIGHT_MCP_EXECUTABLE_PATH);
|
||||||
|
options.headless = envToBoolean(process.env.PLAYWRIGHT_MCP_HEADLESS);
|
||||||
|
options.host = envToString(process.env.PLAYWRIGHT_MCP_HOST);
|
||||||
|
options.ignoreHttpsErrors = envToBoolean(process.env.PLAYWRIGHT_MCP_IGNORE_HTTPS_ERRORS);
|
||||||
|
options.isolated = envToBoolean(process.env.PLAYWRIGHT_MCP_ISOLATED);
|
||||||
|
if (process.env.PLAYWRIGHT_MCP_IMAGE_RESPONSES === 'omit')
|
||||||
|
options.imageResponses = 'omit';
|
||||||
|
options.sandbox = envToBoolean(process.env.PLAYWRIGHT_MCP_SANDBOX);
|
||||||
|
options.outputDir = envToString(process.env.PLAYWRIGHT_MCP_OUTPUT_DIR);
|
||||||
|
options.port = envToNumber(process.env.PLAYWRIGHT_MCP_PORT);
|
||||||
|
options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS);
|
||||||
|
options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER);
|
||||||
|
options.saveTrace = envToBoolean(process.env.PLAYWRIGHT_MCP_SAVE_TRACE);
|
||||||
|
options.storageState = envToString(process.env.PLAYWRIGHT_MCP_STORAGE_STATE);
|
||||||
|
options.userAgent = envToString(process.env.PLAYWRIGHT_MCP_USER_AGENT);
|
||||||
|
options.userDataDir = envToString(process.env.PLAYWRIGHT_MCP_USER_DATA_DIR);
|
||||||
|
options.viewportSize = envToString(process.env.PLAYWRIGHT_MCP_VIEWPORT_SIZE);
|
||||||
|
return configFromCLIOptions(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadConfig(configFile: string | undefined): Promise<Config> {
|
async function loadConfig(configFile: string | undefined): Promise<Config> {
|
||||||
@@ -136,46 +238,83 @@ async function loadConfig(configFile: string | undefined): Promise<Config> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createUserDataDir(options: { browserName: 'chromium' | 'firefox' | 'webkit', channel: string | undefined }) {
|
export async function outputFile(config: FullConfig, rootPath: string | undefined, name: string): Promise<string> {
|
||||||
let cacheDirectory: string;
|
const outputDir = config.outputDir
|
||||||
if (process.platform === 'linux')
|
?? (rootPath ? path.join(rootPath, '.playwright-mcp') : undefined)
|
||||||
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
?? path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString()));
|
||||||
else if (process.platform === 'darwin')
|
|
||||||
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
|
|
||||||
else if (process.platform === 'win32')
|
|
||||||
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
||||||
else
|
|
||||||
throw new Error('Unsupported platform: ' + process.platform);
|
|
||||||
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${options.channel ?? options.browserName}-profile`);
|
|
||||||
await fs.promises.mkdir(result, { recursive: true });
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function outputFile(config: Config, name: string): Promise<string> {
|
await fs.promises.mkdir(outputDir, { recursive: true });
|
||||||
const result = config.outputDir ?? os.tmpdir();
|
|
||||||
await fs.promises.mkdir(result, { recursive: true });
|
|
||||||
const fileName = sanitizeForFilePath(name);
|
const fileName = sanitizeForFilePath(name);
|
||||||
return path.join(result, fileName);
|
return path.join(outputDir, fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeConfig(base: Config, overrides: Config): Config {
|
function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
|
||||||
const browser: Config['browser'] = {
|
return Object.fromEntries(
|
||||||
...base.browser,
|
Object.entries(obj ?? {}).filter(([_, v]) => v !== undefined)
|
||||||
...overrides.browser,
|
) as Partial<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: {
|
launchOptions: {
|
||||||
...base.browser?.launchOptions,
|
...pickDefined(base.browser?.launchOptions),
|
||||||
...overrides.browser?.launchOptions,
|
...pickDefined(overrides.browser?.launchOptions),
|
||||||
...{ assistantMode: true },
|
...{ assistantMode: true },
|
||||||
},
|
},
|
||||||
contextOptions: {
|
contextOptions: {
|
||||||
...base.browser?.contextOptions,
|
...pickDefined(base.browser?.contextOptions),
|
||||||
...overrides.browser?.contextOptions,
|
...pickDefined(overrides.browser?.contextOptions),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (browser.browserName !== 'chromium' && browser.launchOptions)
|
||||||
|
delete browser.launchOptions.channel;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...pickDefined(base),
|
||||||
...overrides,
|
...pickDefined(overrides),
|
||||||
browser,
|
browser,
|
||||||
};
|
network: {
|
||||||
|
...pickDefined(base.network),
|
||||||
|
...pickDefined(overrides.network),
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
...pickDefined(base.server),
|
||||||
|
...pickDefined(overrides.server),
|
||||||
|
},
|
||||||
|
} as FullConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function semicolonSeparatedList(value: string | undefined): string[] | undefined {
|
||||||
|
if (!value)
|
||||||
|
return undefined;
|
||||||
|
return value.split(';').map(v => v.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commaSeparatedList(value: string | undefined): string[] | undefined {
|
||||||
|
if (!value)
|
||||||
|
return undefined;
|
||||||
|
return value.split(',').map(v => v.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function envToNumber(value: string | undefined): number | undefined {
|
||||||
|
if (!value)
|
||||||
|
return undefined;
|
||||||
|
return +value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function envToBoolean(value: string | undefined): boolean | undefined {
|
||||||
|
if (value === 'true' || value === '1')
|
||||||
|
return true;
|
||||||
|
if (value === 'false' || value === '0')
|
||||||
|
return false;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function envToString(value: string | undefined): string | undefined {
|
||||||
|
return value ? value.trim() : undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
413
src/context.ts
413
src/context.ts
@@ -14,218 +14,108 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import debug from 'debug';
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
|
|
||||||
import { waitForCompletion } from './tools/utils';
|
import { logUnhandledError } from './utils/log.js';
|
||||||
import { ManualPromise } from './manualPromise';
|
import { Tab } from './tab.js';
|
||||||
import { Tab } from './tab';
|
import { outputFile } from './config.js';
|
||||||
|
|
||||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
|
import type { FullConfig } from './config.js';
|
||||||
import type { ModalState, Tool, ToolActionResult } from './tools/tool';
|
import type { Tool } from './tools/tool.js';
|
||||||
import type { Config } from '../config';
|
import type { BrowserContextFactory, ClientInfo } from './browserContextFactory.js';
|
||||||
|
import type * as actions from './actions.js';
|
||||||
|
import type { SessionLog } from './sessionLog.js';
|
||||||
|
|
||||||
type PendingAction = {
|
const testDebug = debug('pw:mcp:test');
|
||||||
dialogShown: ManualPromise<void>;
|
|
||||||
|
type ContextOptions = {
|
||||||
|
tools: Tool[];
|
||||||
|
config: FullConfig;
|
||||||
|
browserContextFactory: BrowserContextFactory;
|
||||||
|
sessionLog: SessionLog | undefined;
|
||||||
|
clientInfo: ClientInfo;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class Context {
|
export class Context {
|
||||||
readonly tools: Tool[];
|
readonly tools: Tool[];
|
||||||
readonly config: Config;
|
readonly config: FullConfig;
|
||||||
private _browser: playwright.Browser | undefined;
|
readonly sessionLog: SessionLog | undefined;
|
||||||
private _browserContext: playwright.BrowserContext | undefined;
|
readonly options: ContextOptions;
|
||||||
private _createBrowserContextPromise: Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> | undefined;
|
private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
|
||||||
|
private _browserContextFactory: BrowserContextFactory;
|
||||||
private _tabs: Tab[] = [];
|
private _tabs: Tab[] = [];
|
||||||
private _currentTab: Tab | undefined;
|
private _currentTab: Tab | undefined;
|
||||||
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
private _clientInfo: ClientInfo;
|
||||||
private _pendingAction: PendingAction | undefined;
|
|
||||||
|
|
||||||
constructor(tools: Tool[], config: Config) {
|
private static _allContexts: Set<Context> = new Set();
|
||||||
this.tools = tools;
|
private _closeBrowserContextPromise: Promise<void> | undefined;
|
||||||
this.config = config;
|
private _isRunningTool: boolean = false;
|
||||||
|
private _abortController = new AbortController();
|
||||||
|
|
||||||
|
constructor(options: ContextOptions) {
|
||||||
|
this.tools = options.tools;
|
||||||
|
this.config = options.config;
|
||||||
|
this.sessionLog = options.sessionLog;
|
||||||
|
this.options = options;
|
||||||
|
this._browserContextFactory = options.browserContextFactory;
|
||||||
|
this._clientInfo = options.clientInfo;
|
||||||
|
testDebug('create context');
|
||||||
|
Context._allContexts.add(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
modalStates(): ModalState[] {
|
static async disposeAll() {
|
||||||
return this._modalStates;
|
await Promise.all([...Context._allContexts].map(context => context.dispose()));
|
||||||
}
|
|
||||||
|
|
||||||
setModalState(modalState: ModalState, inTab: Tab) {
|
|
||||||
this._modalStates.push({ ...modalState, tab: inTab });
|
|
||||||
}
|
|
||||||
|
|
||||||
clearModalState(modalState: ModalState) {
|
|
||||||
this._modalStates = this._modalStates.filter(state => state !== modalState);
|
|
||||||
}
|
|
||||||
|
|
||||||
modalStatesMarkdown(): string[] {
|
|
||||||
const result: string[] = ['### Modal state'];
|
|
||||||
if (this._modalStates.length === 0)
|
|
||||||
result.push('- There is no modal state present');
|
|
||||||
for (const state of this._modalStates) {
|
|
||||||
const tool = this.tools.find(tool => tool.clearsModalState === state.type);
|
|
||||||
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tabs(): Tab[] {
|
tabs(): Tab[] {
|
||||||
return this._tabs;
|
return this._tabs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentTab(): Tab | undefined {
|
||||||
|
return this._currentTab;
|
||||||
|
}
|
||||||
|
|
||||||
currentTabOrDie(): Tab {
|
currentTabOrDie(): Tab {
|
||||||
if (!this._currentTab)
|
if (!this._currentTab)
|
||||||
throw new Error('No current snapshot available. Capture a snapshot of navigate to a new location first.');
|
throw new Error('No open pages available. Use the "browser_navigate" tool to navigate to a page first.');
|
||||||
return this._currentTab;
|
return this._currentTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
async newTab(): Promise<Tab> {
|
async newTab(): Promise<Tab> {
|
||||||
const browserContext = await this._ensureBrowserContext();
|
const { browserContext } = await this._ensureBrowserContext();
|
||||||
const page = await browserContext.newPage();
|
const page = await browserContext.newPage();
|
||||||
this._currentTab = this._tabs.find(t => t.page === page)!;
|
this._currentTab = this._tabs.find(t => t.page === page)!;
|
||||||
return this._currentTab;
|
return this._currentTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
async selectTab(index: number) {
|
async selectTab(index: number) {
|
||||||
this._currentTab = this._tabs[index - 1];
|
const tab = this._tabs[index];
|
||||||
await this._currentTab.page.bringToFront();
|
if (!tab)
|
||||||
|
throw new Error(`Tab ${index} not found`);
|
||||||
|
await tab.page.bringToFront();
|
||||||
|
this._currentTab = tab;
|
||||||
|
return tab;
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureTab(): Promise<Tab> {
|
async ensureTab(): Promise<Tab> {
|
||||||
const context = await this._ensureBrowserContext();
|
const { browserContext } = await this._ensureBrowserContext();
|
||||||
if (!this._currentTab)
|
if (!this._currentTab)
|
||||||
await context.newPage();
|
await browserContext.newPage();
|
||||||
return this._currentTab!;
|
return this._currentTab!;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listTabsMarkdown(): Promise<string> {
|
async closeTab(index: number | undefined): Promise<string> {
|
||||||
if (!this._tabs.length)
|
const tab = index === undefined ? this._currentTab : this._tabs[index];
|
||||||
return '### No tabs open';
|
if (!tab)
|
||||||
const lines: string[] = ['### Open tabs'];
|
throw new Error(`Tab ${index} not found`);
|
||||||
for (let i = 0; i < this._tabs.length; i++) {
|
const url = tab.page.url();
|
||||||
const tab = this._tabs[i];
|
await tab.page.close();
|
||||||
const title = await tab.page.title();
|
return url;
|
||||||
const url = tab.page.url();
|
|
||||||
const current = tab === this._currentTab ? ' (current)' : '';
|
|
||||||
lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
|
|
||||||
}
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async closeTab(index: number | undefined) {
|
async outputFile(name: string): Promise<string> {
|
||||||
const tab = index === undefined ? this._currentTab : this._tabs[index - 1];
|
return outputFile(this.config, this._clientInfo.rootPath, name);
|
||||||
await tab?.page.close();
|
|
||||||
return await this.listTabsMarkdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
async run(tool: Tool, params: Record<string, unknown> | undefined) {
|
|
||||||
// Tab management is done outside of the action() call.
|
|
||||||
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params));
|
|
||||||
const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult;
|
|
||||||
const racingAction = action ? () => this._raceAgainstModalDialogs(action) : undefined;
|
|
||||||
|
|
||||||
if (resultOverride)
|
|
||||||
return resultOverride;
|
|
||||||
|
|
||||||
if (!this._currentTab) {
|
|
||||||
return {
|
|
||||||
content: [{
|
|
||||||
type: 'text',
|
|
||||||
text: 'No open pages available. Use the "browser_navigate" tool to navigate to a page first.',
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const tab = this.currentTabOrDie();
|
|
||||||
// TODO: race against modal dialogs to resolve clicks.
|
|
||||||
let actionResult: { content?: (ImageContent | TextContent)[] } | undefined;
|
|
||||||
try {
|
|
||||||
if (waitForNetwork)
|
|
||||||
actionResult = await waitForCompletion(this, tab.page, async () => racingAction?.()) ?? undefined;
|
|
||||||
else
|
|
||||||
actionResult = await racingAction?.() ?? undefined;
|
|
||||||
} finally {
|
|
||||||
if (captureSnapshot && !this._javaScriptBlocked())
|
|
||||||
await tab.captureSnapshot();
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: string[] = [];
|
|
||||||
result.push(`- Ran Playwright code:
|
|
||||||
\`\`\`js
|
|
||||||
${code.join('\n')}
|
|
||||||
\`\`\`
|
|
||||||
`);
|
|
||||||
|
|
||||||
if (this.modalStates().length) {
|
|
||||||
result.push(...this.modalStatesMarkdown());
|
|
||||||
return {
|
|
||||||
content: [{
|
|
||||||
type: 'text',
|
|
||||||
text: result.join('\n'),
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.tabs().length > 1)
|
|
||||||
result.push(await this.listTabsMarkdown(), '');
|
|
||||||
|
|
||||||
if (this.tabs().length > 1)
|
|
||||||
result.push('### Current tab');
|
|
||||||
|
|
||||||
result.push(
|
|
||||||
`- Page URL: ${tab.page.url()}`,
|
|
||||||
`- Page Title: ${await tab.page.title()}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (captureSnapshot && tab.hasSnapshot())
|
|
||||||
result.push(tab.snapshotOrDie().text());
|
|
||||||
|
|
||||||
const content = actionResult?.content ?? [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
...content,
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: result.join('\n'),
|
|
||||||
}
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async waitForTimeout(time: number) {
|
|
||||||
if (this._currentTab && !this._javaScriptBlocked())
|
|
||||||
await this._currentTab.page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
|
||||||
else
|
|
||||||
await new Promise(f => setTimeout(f, time));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _raceAgainstModalDialogs(action: () => Promise<ToolActionResult>): Promise<ToolActionResult> {
|
|
||||||
this._pendingAction = {
|
|
||||||
dialogShown: new ManualPromise(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let result: ToolActionResult | undefined;
|
|
||||||
try {
|
|
||||||
await Promise.race([
|
|
||||||
action().then(r => result = r),
|
|
||||||
this._pendingAction.dialogShown,
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
this._pendingAction = undefined;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _javaScriptBlocked(): boolean {
|
|
||||||
return this._modalStates.some(state => state.type === 'dialog');
|
|
||||||
}
|
|
||||||
|
|
||||||
dialogShown(tab: Tab, dialog: playwright.Dialog) {
|
|
||||||
this.setModalState({
|
|
||||||
type: 'dialog',
|
|
||||||
description: `"${dialog.type()}" dialog with message "${dialog.message()}"`,
|
|
||||||
dialog,
|
|
||||||
}, tab);
|
|
||||||
this._pendingAction?.dialogShown.resolve();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _onPageCreated(page: playwright.Page) {
|
private _onPageCreated(page: playwright.Page) {
|
||||||
@@ -236,7 +126,6 @@ ${code.join('\n')}
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _onPageClosed(tab: Tab) {
|
private _onPageClosed(tab: Tab) {
|
||||||
this._modalStates = this._modalStates.filter(state => state.tab !== tab);
|
|
||||||
const index = this._tabs.indexOf(tab);
|
const index = this._tabs.indexOf(tab);
|
||||||
if (index === -1)
|
if (index === -1)
|
||||||
return;
|
return;
|
||||||
@@ -244,76 +133,144 @@ ${code.join('\n')}
|
|||||||
|
|
||||||
if (this._currentTab === tab)
|
if (this._currentTab === tab)
|
||||||
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
|
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
|
||||||
if (this._browserContext && !this._tabs.length)
|
if (!this._tabs.length)
|
||||||
void this.close();
|
void this.closeBrowserContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
async close() {
|
async closeBrowserContext() {
|
||||||
if (!this._browserContext)
|
if (!this._closeBrowserContextPromise)
|
||||||
|
this._closeBrowserContextPromise = this._closeBrowserContextImpl().catch(logUnhandledError);
|
||||||
|
await this._closeBrowserContextPromise;
|
||||||
|
this._closeBrowserContextPromise = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunningTool() {
|
||||||
|
return this._isRunningTool;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRunningTool(isRunningTool: boolean) {
|
||||||
|
this._isRunningTool = isRunningTool;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _closeBrowserContextImpl() {
|
||||||
|
if (!this._browserContextPromise)
|
||||||
return;
|
return;
|
||||||
const browserContext = this._browserContext;
|
|
||||||
const browser = this._browser;
|
|
||||||
this._createBrowserContextPromise = undefined;
|
|
||||||
this._browserContext = undefined;
|
|
||||||
this._browser = undefined;
|
|
||||||
|
|
||||||
await browserContext?.close().then(async () => {
|
testDebug('close context');
|
||||||
await browser?.close();
|
|
||||||
}).catch(() => {});
|
const promise = this._browserContextPromise;
|
||||||
|
this._browserContextPromise = undefined;
|
||||||
|
|
||||||
|
await promise.then(async ({ browserContext, close }) => {
|
||||||
|
if (this.config.saveTrace)
|
||||||
|
await browserContext.tracing.stop();
|
||||||
|
await close();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _ensureBrowserContext() {
|
async dispose() {
|
||||||
if (!this._browserContext) {
|
this._abortController.abort('MCP context disposed');
|
||||||
const context = await this._createBrowserContext();
|
await this.closeBrowserContext();
|
||||||
this._browser = context.browser;
|
Context._allContexts.delete(this);
|
||||||
this._browserContext = context.browserContext;
|
|
||||||
for (const page of this._browserContext.pages())
|
|
||||||
this._onPageCreated(page);
|
|
||||||
this._browserContext.on('page', page => this._onPageCreated(page));
|
|
||||||
}
|
|
||||||
return this._browserContext;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _createBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
|
private async _setupRequestInterception(context: playwright.BrowserContext) {
|
||||||
if (!this._createBrowserContextPromise)
|
if (this.config.network?.allowedOrigins?.length) {
|
||||||
this._createBrowserContextPromise = this._innerCreateBrowserContext();
|
await context.route('**', route => route.abort('blockedbyclient'));
|
||||||
return this._createBrowserContextPromise;
|
|
||||||
|
for (const origin of this.config.network.allowedOrigins)
|
||||||
|
await context.route(`*://${origin}/**`, route => route.continue());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.config.network?.blockedOrigins?.length) {
|
||||||
|
for (const origin of this.config.network.blockedOrigins)
|
||||||
|
await context.route(`*://${origin}/**`, route => route.abort('blockedbyclient'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _innerCreateBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
|
private _ensureBrowserContext() {
|
||||||
if (this.config.browser?.remoteEndpoint) {
|
if (!this._browserContextPromise) {
|
||||||
const url = new URL(this.config.browser?.remoteEndpoint);
|
this._browserContextPromise = this._setupBrowserContext();
|
||||||
if (this.config.browser.browserName)
|
this._browserContextPromise.catch(() => {
|
||||||
url.searchParams.set('browser', this.config.browser.browserName);
|
this._browserContextPromise = undefined;
|
||||||
if (this.config.browser.launchOptions)
|
});
|
||||||
url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions));
|
|
||||||
const browser = await playwright[this.config.browser?.browserName ?? 'chromium'].connect(String(url));
|
|
||||||
const browserContext = await browser.newContext();
|
|
||||||
return { browser, browserContext };
|
|
||||||
}
|
}
|
||||||
|
return this._browserContextPromise;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.config.browser?.cdpEndpoint) {
|
private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
const browser = await playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint);
|
if (this._closeBrowserContextPromise)
|
||||||
const browserContext = browser.contexts()[0];
|
throw new Error('Another browser context is being closed.');
|
||||||
return { browser, browserContext };
|
// TODO: move to the browser context factory to make it based on isolation mode.
|
||||||
|
const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal);
|
||||||
|
const { browserContext } = result;
|
||||||
|
await this._setupRequestInterception(browserContext);
|
||||||
|
if (this.sessionLog)
|
||||||
|
await InputRecorder.create(this, browserContext);
|
||||||
|
for (const page of browserContext.pages())
|
||||||
|
this._onPageCreated(page);
|
||||||
|
browserContext.on('page', page => this._onPageCreated(page));
|
||||||
|
if (this.config.saveTrace) {
|
||||||
|
await browserContext.tracing.start({
|
||||||
|
name: 'trace',
|
||||||
|
screenshots: false,
|
||||||
|
snapshots: true,
|
||||||
|
sources: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
const browserContext = await launchPersistentContext(this.config.browser);
|
|
||||||
return { browserContext };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function launchPersistentContext(browserConfig: Config['browser']): Promise<playwright.BrowserContext> {
|
export class InputRecorder {
|
||||||
try {
|
private _context: Context;
|
||||||
const browserType = browserConfig?.browserName ? playwright[browserConfig.browserName] : playwright.chromium;
|
private _browserContext: playwright.BrowserContext;
|
||||||
return await browserType.launchPersistentContext(browserConfig?.userDataDir || '', { ...browserConfig?.launchOptions, ...browserConfig?.contextOptions });
|
|
||||||
} catch (error: any) {
|
private constructor(context: Context, browserContext: playwright.BrowserContext) {
|
||||||
if (error.message.includes('Executable doesn\'t exist'))
|
this._context = context;
|
||||||
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
|
this._browserContext = browserContext;
|
||||||
throw error;
|
}
|
||||||
|
|
||||||
|
static async create(context: Context, browserContext: playwright.BrowserContext) {
|
||||||
|
const recorder = new InputRecorder(context, browserContext);
|
||||||
|
await recorder._initialize();
|
||||||
|
return recorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _initialize() {
|
||||||
|
const sessionLog = this._context.sessionLog!;
|
||||||
|
await (this._browserContext as any)._enableRecorder({
|
||||||
|
mode: 'recording',
|
||||||
|
recorderMode: 'api',
|
||||||
|
}, {
|
||||||
|
actionAdded: (page: playwright.Page, data: actions.ActionInContext, code: string) => {
|
||||||
|
if (this._context.isRunningTool())
|
||||||
|
return;
|
||||||
|
const tab = Tab.forPage(page);
|
||||||
|
if (tab)
|
||||||
|
sessionLog.logUserAction(data.action, tab, code, false);
|
||||||
|
},
|
||||||
|
actionUpdated: (page: playwright.Page, data: actions.ActionInContext, code: string) => {
|
||||||
|
if (this._context.isRunningTool())
|
||||||
|
return;
|
||||||
|
const tab = Tab.forPage(page);
|
||||||
|
if (tab)
|
||||||
|
sessionLog.logUserAction(data.action, tab, code, true);
|
||||||
|
},
|
||||||
|
signalAdded: (page: playwright.Page, data: actions.SignalInContext) => {
|
||||||
|
if (this._context.isRunningTool())
|
||||||
|
return;
|
||||||
|
if (data.signal.name !== 'navigation')
|
||||||
|
return;
|
||||||
|
const tab = Tab.forPage(page);
|
||||||
|
const navigateAction: actions.Action = {
|
||||||
|
name: 'navigate',
|
||||||
|
url: data.signal.url,
|
||||||
|
signals: [],
|
||||||
|
};
|
||||||
|
if (tab)
|
||||||
|
sessionLog.logUserAction(navigateAction, tab, `await page.goto('${data.signal.url}');`, false);
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
|
||||||
return (locator as any)._generateLocatorString();
|
|
||||||
}
|
|
||||||
|
|||||||
3
src/extension/DEPS.list
Normal file
3
src/extension/DEPS.list
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[*]
|
||||||
|
../mcp/
|
||||||
|
../utils/
|
||||||
408
src/extension/cdpRelay.ts
Normal file
408
src/extension/cdpRelay.ts
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket server that bridges Playwright MCP and Chrome Extension
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - /cdp/guid - Full CDP interface for Playwright MCP
|
||||||
|
* - /extension/guid - Extension connection for chrome.debugger forwarding
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import http from 'http';
|
||||||
|
import debug from 'debug';
|
||||||
|
import { WebSocket, WebSocketServer } from 'ws';
|
||||||
|
import { httpAddressToString } from '../utils/httpServer.js';
|
||||||
|
import { logUnhandledError } from '../utils/log.js';
|
||||||
|
import { ManualPromise } from '../utils/manualPromise.js';
|
||||||
|
import type websocket from 'ws';
|
||||||
|
import type { ClientInfo } from '../browserContextFactory.js';
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const { registry } = await import('playwright-core/lib/server/registry/index');
|
||||||
|
|
||||||
|
const debugLogger = debug('pw:mcp:relay');
|
||||||
|
|
||||||
|
type CDPCommand = {
|
||||||
|
id: number;
|
||||||
|
sessionId?: string;
|
||||||
|
method: string;
|
||||||
|
params?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CDPResponse = {
|
||||||
|
id?: number;
|
||||||
|
sessionId?: string;
|
||||||
|
method?: string;
|
||||||
|
params?: any;
|
||||||
|
result?: any;
|
||||||
|
error?: { code?: number; message: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CDPRelayServer {
|
||||||
|
private _wsHost: string;
|
||||||
|
private _browserChannel: string;
|
||||||
|
private _userDataDir?: string;
|
||||||
|
private _cdpPath: string;
|
||||||
|
private _extensionPath: string;
|
||||||
|
private _wss: WebSocketServer;
|
||||||
|
private _playwrightConnection: WebSocket | null = null;
|
||||||
|
private _extensionConnection: ExtensionConnection | null = null;
|
||||||
|
private _connectedTabInfo: {
|
||||||
|
targetInfo: any;
|
||||||
|
// Page sessionId that should be used by this connection.
|
||||||
|
sessionId: string;
|
||||||
|
} | undefined;
|
||||||
|
private _nextSessionId: number = 1;
|
||||||
|
private _extensionConnectionPromise!: ManualPromise<void>;
|
||||||
|
|
||||||
|
constructor(server: http.Server, browserChannel: string, userDataDir?: string) {
|
||||||
|
this._wsHost = httpAddressToString(server.address()).replace(/^http/, 'ws');
|
||||||
|
this._browserChannel = browserChannel;
|
||||||
|
this._userDataDir = userDataDir;
|
||||||
|
|
||||||
|
const uuid = crypto.randomUUID();
|
||||||
|
this._cdpPath = `/cdp/${uuid}`;
|
||||||
|
this._extensionPath = `/extension/${uuid}`;
|
||||||
|
|
||||||
|
this._resetExtensionConnection();
|
||||||
|
this._wss = new WebSocketServer({ server });
|
||||||
|
this._wss.on('connection', this._onConnection.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
cdpEndpoint() {
|
||||||
|
return `${this._wsHost}${this._cdpPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
extensionEndpoint() {
|
||||||
|
return `${this._wsHost}${this._extensionPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureExtensionConnectionForMCPContext(clientInfo: ClientInfo, abortSignal: AbortSignal) {
|
||||||
|
debugLogger('Ensuring extension connection for MCP context');
|
||||||
|
if (this._extensionConnection)
|
||||||
|
return;
|
||||||
|
this._connectBrowser(clientInfo);
|
||||||
|
debugLogger('Waiting for incoming extension connection');
|
||||||
|
await Promise.race([
|
||||||
|
this._extensionConnectionPromise,
|
||||||
|
new Promise((_, reject) => setTimeout(() => {
|
||||||
|
reject(new Error(`Extension connection timeout. Make sure the "Playwright MCP Bridge" extension is installed. See https://github.com/microsoft/playwright-mcp/blob/main/extension/README.md for installation instructions.`));
|
||||||
|
}, process.env.PWMCP_TEST_CONNECTION_TIMEOUT ? parseInt(process.env.PWMCP_TEST_CONNECTION_TIMEOUT, 10) : 5_000)),
|
||||||
|
new Promise((_, reject) => abortSignal.addEventListener('abort', reject))
|
||||||
|
]);
|
||||||
|
debugLogger('Extension connection established');
|
||||||
|
}
|
||||||
|
|
||||||
|
private _connectBrowser(clientInfo: ClientInfo) {
|
||||||
|
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
|
||||||
|
// 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');
|
||||||
|
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
|
||||||
|
url.searchParams.set('client', JSON.stringify(clientInfo));
|
||||||
|
const href = url.toString();
|
||||||
|
const executableInfo = registry.findExecutable(this._browserChannel);
|
||||||
|
if (!executableInfo)
|
||||||
|
throw new Error(`Unsupported channel: "${this._browserChannel}"`);
|
||||||
|
const executablePath = executableInfo.executablePath();
|
||||||
|
if (!executablePath)
|
||||||
|
throw new Error(`"${this._browserChannel}" executable not found. Make sure it is installed at a standard location.`);
|
||||||
|
|
||||||
|
const args: string[] = [];
|
||||||
|
if (this._userDataDir)
|
||||||
|
args.push(`--user-data-dir=${this._userDataDir}`);
|
||||||
|
args.push(href);
|
||||||
|
|
||||||
|
spawn(executablePath, args, {
|
||||||
|
windowsHide: true,
|
||||||
|
detached: true,
|
||||||
|
shell: false,
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.closeConnections('Server stopped');
|
||||||
|
this._wss.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeConnections(reason: string) {
|
||||||
|
this._closePlaywrightConnection(reason);
|
||||||
|
this._closeExtensionConnection(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onConnection(ws: WebSocket, request: http.IncomingMessage): void {
|
||||||
|
const url = new URL(`http://localhost${request.url}`);
|
||||||
|
debugLogger(`New connection to ${url.pathname}`);
|
||||||
|
if (url.pathname === this._cdpPath) {
|
||||||
|
this._handlePlaywrightConnection(ws);
|
||||||
|
} else if (url.pathname === this._extensionPath) {
|
||||||
|
this._handleExtensionConnection(ws);
|
||||||
|
} else {
|
||||||
|
debugLogger(`Invalid path: ${url.pathname}`);
|
||||||
|
ws.close(4004, 'Invalid path');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handlePlaywrightConnection(ws: WebSocket): void {
|
||||||
|
if (this._playwrightConnection) {
|
||||||
|
debugLogger('Rejecting second Playwright connection');
|
||||||
|
ws.close(1000, 'Another CDP client already connected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._playwrightConnection = ws;
|
||||||
|
ws.on('message', async data => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(data.toString());
|
||||||
|
await this._handlePlaywrightMessage(message);
|
||||||
|
} catch (error: any) {
|
||||||
|
debugLogger(`Error while handling Playwright message\n${data.toString()}\n`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ws.on('close', () => {
|
||||||
|
if (this._playwrightConnection !== ws)
|
||||||
|
return;
|
||||||
|
this._playwrightConnection = null;
|
||||||
|
this._closeExtensionConnection('Playwright client disconnected');
|
||||||
|
debugLogger('Playwright WebSocket closed');
|
||||||
|
});
|
||||||
|
ws.on('error', error => {
|
||||||
|
debugLogger('Playwright WebSocket error:', error);
|
||||||
|
});
|
||||||
|
debugLogger('Playwright MCP connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
private _closeExtensionConnection(reason: string) {
|
||||||
|
this._extensionConnection?.close(reason);
|
||||||
|
this._extensionConnectionPromise.reject(new Error(reason));
|
||||||
|
this._resetExtensionConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _resetExtensionConnection() {
|
||||||
|
this._connectedTabInfo = undefined;
|
||||||
|
this._extensionConnection = null;
|
||||||
|
this._extensionConnectionPromise = new ManualPromise();
|
||||||
|
void this._extensionConnectionPromise.catch(logUnhandledError);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _closePlaywrightConnection(reason: string) {
|
||||||
|
if (this._playwrightConnection?.readyState === WebSocket.OPEN)
|
||||||
|
this._playwrightConnection.close(1000, reason);
|
||||||
|
this._playwrightConnection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleExtensionConnection(ws: WebSocket): void {
|
||||||
|
if (this._extensionConnection) {
|
||||||
|
ws.close(1000, 'Another extension connection already established');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._extensionConnection = new ExtensionConnection(ws);
|
||||||
|
this._extensionConnection.onclose = (c, reason) => {
|
||||||
|
debugLogger('Extension WebSocket closed:', reason, c === this._extensionConnection);
|
||||||
|
if (this._extensionConnection !== c)
|
||||||
|
return;
|
||||||
|
this._resetExtensionConnection();
|
||||||
|
this._closePlaywrightConnection(`Extension disconnected: ${reason}`);
|
||||||
|
};
|
||||||
|
this._extensionConnection.onmessage = this._handleExtensionMessage.bind(this);
|
||||||
|
this._extensionConnectionPromise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleExtensionMessage(method: string, params: any) {
|
||||||
|
switch (method) {
|
||||||
|
case 'forwardCDPEvent':
|
||||||
|
const sessionId = params.sessionId || this._connectedTabInfo?.sessionId;
|
||||||
|
this._sendToPlaywright({
|
||||||
|
sessionId,
|
||||||
|
method: params.method,
|
||||||
|
params: params.params
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'detachedFromTab':
|
||||||
|
debugLogger('← Debugger detached from tab:', params);
|
||||||
|
this._connectedTabInfo = undefined;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handlePlaywrightMessage(message: CDPCommand): Promise<void> {
|
||||||
|
debugLogger('← Playwright:', `${message.method} (id=${message.id})`);
|
||||||
|
const { id, sessionId, method, params } = message;
|
||||||
|
try {
|
||||||
|
const result = await this._handleCDPCommand(method, params, sessionId);
|
||||||
|
this._sendToPlaywright({ id, sessionId, result });
|
||||||
|
} catch (e) {
|
||||||
|
debugLogger('Error in the extension:', e);
|
||||||
|
this._sendToPlaywright({
|
||||||
|
id,
|
||||||
|
sessionId,
|
||||||
|
error: { message: (e as Error).message }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handleCDPCommand(method: string, params: any, sessionId: string | undefined): Promise<any> {
|
||||||
|
switch (method) {
|
||||||
|
case 'Browser.getVersion': {
|
||||||
|
return {
|
||||||
|
protocolVersion: '1.3',
|
||||||
|
product: 'Chrome/Extension-Bridge',
|
||||||
|
userAgent: 'CDP-Bridge-Server/1.0.0',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'Browser.setDownloadBehavior': {
|
||||||
|
return { };
|
||||||
|
}
|
||||||
|
case 'Target.setAutoAttach': {
|
||||||
|
// Forward child session handling.
|
||||||
|
if (sessionId)
|
||||||
|
break;
|
||||||
|
// Simulate auto-attach behavior with real target info
|
||||||
|
const { targetInfo } = await this._extensionConnection!.send('attachToTab');
|
||||||
|
this._connectedTabInfo = {
|
||||||
|
targetInfo,
|
||||||
|
sessionId: `pw-tab-${this._nextSessionId++}`,
|
||||||
|
};
|
||||||
|
debugLogger('Simulating auto-attach');
|
||||||
|
this._sendToPlaywright({
|
||||||
|
method: 'Target.attachedToTarget',
|
||||||
|
params: {
|
||||||
|
sessionId: this._connectedTabInfo.sessionId,
|
||||||
|
targetInfo: {
|
||||||
|
...this._connectedTabInfo.targetInfo,
|
||||||
|
attached: true,
|
||||||
|
},
|
||||||
|
waitingForDebugger: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { };
|
||||||
|
}
|
||||||
|
case 'Target.getTargetInfo': {
|
||||||
|
return this._connectedTabInfo?.targetInfo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await this._forwardToExtension(method, params, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _forwardToExtension(method: string, params: any, sessionId: string | undefined): Promise<any> {
|
||||||
|
if (!this._extensionConnection)
|
||||||
|
throw new Error('Extension not connected');
|
||||||
|
// Top level sessionId is only passed between the relay and the client.
|
||||||
|
if (this._connectedTabInfo?.sessionId === sessionId)
|
||||||
|
sessionId = undefined;
|
||||||
|
return await this._extensionConnection.send('forwardCDPCommand', { sessionId, method, params });
|
||||||
|
}
|
||||||
|
|
||||||
|
private _sendToPlaywright(message: CDPResponse): void {
|
||||||
|
debugLogger('→ Playwright:', `${message.method ?? `response(id=${message.id})`}`);
|
||||||
|
this._playwrightConnection?.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtensionResponse = {
|
||||||
|
id?: number;
|
||||||
|
method?: string;
|
||||||
|
params?: any;
|
||||||
|
result?: any;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
class ExtensionConnection {
|
||||||
|
private readonly _ws: WebSocket;
|
||||||
|
private readonly _callbacks = new Map<number, { resolve: (o: any) => void, reject: (e: Error) => void, error: Error }>();
|
||||||
|
private _lastId = 0;
|
||||||
|
|
||||||
|
onmessage?: (method: string, params: any) => void;
|
||||||
|
onclose?: (self: ExtensionConnection, reason: string) => void;
|
||||||
|
|
||||||
|
constructor(ws: WebSocket) {
|
||||||
|
this._ws = ws;
|
||||||
|
this._ws.on('message', this._onMessage.bind(this));
|
||||||
|
this._ws.on('close', this._onClose.bind(this));
|
||||||
|
this._ws.on('error', this._onError.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(method: string, params?: any, sessionId?: string): Promise<any> {
|
||||||
|
if (this._ws.readyState !== WebSocket.OPEN)
|
||||||
|
throw new Error(`Unexpected WebSocket state: ${this._ws.readyState}`);
|
||||||
|
const id = ++this._lastId;
|
||||||
|
this._ws.send(JSON.stringify({ id, method, params, sessionId }));
|
||||||
|
const error = new Error(`Protocol error: ${method}`);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this._callbacks.set(id, { resolve, reject, error });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close(message: string) {
|
||||||
|
debugLogger('closing extension connection:', message);
|
||||||
|
if (this._ws.readyState === WebSocket.OPEN)
|
||||||
|
this._ws.close(1000, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onMessage(event: websocket.RawData) {
|
||||||
|
const eventData = event.toString();
|
||||||
|
let parsedJson;
|
||||||
|
try {
|
||||||
|
parsedJson = JSON.parse(eventData);
|
||||||
|
} catch (e: any) {
|
||||||
|
debugLogger(`<closing ws> Closing websocket due to malformed JSON. eventData=${eventData} e=${e?.message}`);
|
||||||
|
this._ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this._handleParsedMessage(parsedJson);
|
||||||
|
} catch (e: any) {
|
||||||
|
debugLogger(`<closing ws> Closing websocket due to failed onmessage callback. eventData=${eventData} e=${e?.message}`);
|
||||||
|
this._ws.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleParsedMessage(object: ExtensionResponse) {
|
||||||
|
if (object.id && this._callbacks.has(object.id)) {
|
||||||
|
const callback = this._callbacks.get(object.id)!;
|
||||||
|
this._callbacks.delete(object.id);
|
||||||
|
if (object.error) {
|
||||||
|
const error = callback.error;
|
||||||
|
error.message = object.error;
|
||||||
|
callback.reject(error);
|
||||||
|
} else {
|
||||||
|
callback.resolve(object.result);
|
||||||
|
}
|
||||||
|
} else if (object.id) {
|
||||||
|
debugLogger('← Extension: unexpected response', object);
|
||||||
|
} else {
|
||||||
|
this.onmessage?.(object.method!, object.params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onClose(event: websocket.CloseEvent) {
|
||||||
|
debugLogger(`<ws closed> code=${event.code} reason=${event.reason}`);
|
||||||
|
this._dispose();
|
||||||
|
this.onclose?.(this, event.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onError(event: websocket.ErrorEvent) {
|
||||||
|
debugLogger(`<ws error> message=${event.message} type=${event.type} target=${event.target}`);
|
||||||
|
this._dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _dispose() {
|
||||||
|
for (const callback of this._callbacks.values())
|
||||||
|
callback.reject(new Error('WebSocket closed'));
|
||||||
|
this._callbacks.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/extension/extensionContextFactory.ts
Normal file
66
src/extension/extensionContextFactory.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* 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 debug from 'debug';
|
||||||
|
import * as playwright from 'playwright';
|
||||||
|
import { startHttpServer } from '../utils/httpServer.js';
|
||||||
|
import { CDPRelayServer } from './cdpRelay.js';
|
||||||
|
|
||||||
|
import type { BrowserContextFactory, ClientInfo } from '../browserContextFactory.js';
|
||||||
|
|
||||||
|
const debugLogger = debug('pw:mcp:relay');
|
||||||
|
|
||||||
|
export class ExtensionContextFactory implements BrowserContextFactory {
|
||||||
|
name = 'extension';
|
||||||
|
description = 'Connect to a browser using the Playwright MCP extension';
|
||||||
|
|
||||||
|
private _browserChannel: string;
|
||||||
|
private _userDataDir?: string;
|
||||||
|
|
||||||
|
constructor(browserChannel: string, userDataDir: string | undefined) {
|
||||||
|
this._browserChannel = browserChannel;
|
||||||
|
this._userDataDir = userDataDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContext(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
|
const browser = await this._obtainBrowser(clientInfo, abortSignal);
|
||||||
|
return {
|
||||||
|
browserContext: browser.contexts()[0],
|
||||||
|
close: async () => {
|
||||||
|
debugLogger('close() called for browser context');
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _obtainBrowser(clientInfo: ClientInfo, abortSignal: AbortSignal): Promise<playwright.Browser> {
|
||||||
|
const relay = await this._startRelay(abortSignal);
|
||||||
|
await relay.ensureExtensionConnectionForMCPContext(clientInfo, abortSignal);
|
||||||
|
return await playwright.chromium.connectOverCDP(relay.cdpEndpoint());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _startRelay(abortSignal: AbortSignal) {
|
||||||
|
const httpServer = await startHttpServer({});
|
||||||
|
if (abortSignal.aborted) {
|
||||||
|
httpServer.close();
|
||||||
|
throw new Error(abortSignal.reason);
|
||||||
|
}
|
||||||
|
const cdpRelayServer = new CDPRelayServer(httpServer, this._browserChannel, this._userDataDir);
|
||||||
|
abortSignal.addEventListener('abort', () => cdpRelayServer.stop());
|
||||||
|
debugLogger(`CDP relay server started, extension endpoint: ${cdpRelayServer.extensionEndpoint()}.`);
|
||||||
|
return cdpRelayServer;
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/index.ts
83
src/index.ts
@@ -14,60 +14,37 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createServerWithTools } from './server';
|
import { BrowserServerBackend } from './browserServerBackend.js';
|
||||||
import common from './tools/common';
|
import { resolveConfig } from './config.js';
|
||||||
import console from './tools/console';
|
import { contextFactory } from './browserContextFactory.js';
|
||||||
import dialogs from './tools/dialogs';
|
import * as mcpServer from './mcp/server.js';
|
||||||
import files from './tools/files';
|
|
||||||
import install from './tools/install';
|
|
||||||
import keyboard from './tools/keyboard';
|
|
||||||
import navigate from './tools/navigate';
|
|
||||||
import network from './tools/network';
|
|
||||||
import pdf from './tools/pdf';
|
|
||||||
import snapshot from './tools/snapshot';
|
|
||||||
import tabs from './tools/tabs';
|
|
||||||
import screen from './tools/screen';
|
|
||||||
|
|
||||||
import type { Tool } from './tools/tool';
|
import type { Config } from '../config.js';
|
||||||
import type { Config } from '../config';
|
import type { BrowserContext } from 'playwright';
|
||||||
|
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
|
||||||
const snapshotTools: Tool<any>[] = [
|
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Server> {
|
||||||
...common(true),
|
const config = await resolveConfig(userConfig);
|
||||||
...console,
|
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config);
|
||||||
...dialogs(true),
|
return mcpServer.createServer(new BrowserServerBackend(config, factory), false);
|
||||||
...files(true),
|
}
|
||||||
...install,
|
|
||||||
...keyboard(true),
|
class SimpleBrowserContextFactory implements BrowserContextFactory {
|
||||||
...navigate(true),
|
name = 'custom';
|
||||||
...network,
|
description = 'Connect to a browser using a custom context getter';
|
||||||
...pdf,
|
|
||||||
...snapshot,
|
private readonly _contextGetter: () => Promise<BrowserContext>;
|
||||||
...tabs(true),
|
|
||||||
];
|
constructor(contextGetter: () => Promise<BrowserContext>) {
|
||||||
|
this._contextGetter = contextGetter;
|
||||||
const screenshotTools: Tool<any>[] = [
|
}
|
||||||
...common(false),
|
|
||||||
...console,
|
async createContext(): Promise<{ browserContext: BrowserContext, close: () => Promise<void> }> {
|
||||||
...dialogs(false),
|
const browserContext = await this._contextGetter();
|
||||||
...files(false),
|
return {
|
||||||
...install,
|
browserContext,
|
||||||
...keyboard(false),
|
close: () => browserContext.close()
|
||||||
...navigate(false),
|
};
|
||||||
...network,
|
}
|
||||||
...pdf,
|
|
||||||
...screen,
|
|
||||||
...tabs(false),
|
|
||||||
];
|
|
||||||
|
|
||||||
const packageJSON = require('../package.json');
|
|
||||||
|
|
||||||
export async function createServer(config: Config = {}): Promise<Server> {
|
|
||||||
const allTools = config.vision ? screenshotTools : snapshotTools;
|
|
||||||
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
|
|
||||||
return createServerWithTools({
|
|
||||||
name: 'Playwright',
|
|
||||||
version: packageJSON.version,
|
|
||||||
tools,
|
|
||||||
}, config);
|
|
||||||
}
|
}
|
||||||
|
|||||||
108
src/loop/loop.ts
Normal file
108
src/loop/loop.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* 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 debug from 'debug';
|
||||||
|
import type { Tool, ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
|
||||||
|
export type LLMToolCall = {
|
||||||
|
name: string;
|
||||||
|
arguments: any;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LLMTool = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LLMMessage =
|
||||||
|
| { role: 'user'; content: string }
|
||||||
|
| { role: 'assistant'; content: string; toolCalls?: LLMToolCall[] }
|
||||||
|
| { role: 'tool'; toolCallId: string; content: string; isError?: boolean };
|
||||||
|
|
||||||
|
export type LLMConversation = {
|
||||||
|
messages: LLMMessage[];
|
||||||
|
tools: LLMTool[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface LLMDelegate {
|
||||||
|
createConversation(task: string, tools: Tool[], oneShot: boolean): LLMConversation;
|
||||||
|
makeApiCall(conversation: LLMConversation): Promise<LLMToolCall[]>;
|
||||||
|
addToolResults(conversation: LLMConversation, results: Array<{ toolCallId: string; content: string; isError?: boolean }>): void;
|
||||||
|
checkDoneToolCall(toolCall: LLMToolCall): string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTask(delegate: LLMDelegate, client: Client, task: string, oneShot: boolean = false): Promise<LLMMessage[]> {
|
||||||
|
const { tools } = await client.listTools();
|
||||||
|
const taskContent = oneShot ? `Perform following task: ${task}.` : `Perform following task: ${task}. Once the task is complete, call the "done" tool.`;
|
||||||
|
const conversation = delegate.createConversation(taskContent, tools, oneShot);
|
||||||
|
|
||||||
|
for (let iteration = 0; iteration < 5; ++iteration) {
|
||||||
|
debug('history')('Making API call for iteration', iteration);
|
||||||
|
const toolCalls = await delegate.makeApiCall(conversation);
|
||||||
|
if (toolCalls.length === 0)
|
||||||
|
throw new Error('Call the "done" tool when the task is complete.');
|
||||||
|
|
||||||
|
const toolResults: Array<{ toolCallId: string; content: string; isError?: boolean }> = [];
|
||||||
|
for (const toolCall of toolCalls) {
|
||||||
|
const doneResult = delegate.checkDoneToolCall(toolCall);
|
||||||
|
if (doneResult !== null)
|
||||||
|
return conversation.messages;
|
||||||
|
|
||||||
|
const { name, arguments: args, id } = toolCall;
|
||||||
|
try {
|
||||||
|
debug('tool')(name, args);
|
||||||
|
const response = await client.callTool({
|
||||||
|
name,
|
||||||
|
arguments: args,
|
||||||
|
});
|
||||||
|
const responseContent = (response.content || []) as (TextContent | ImageContent)[];
|
||||||
|
debug('tool')(responseContent);
|
||||||
|
const text = responseContent.filter(part => part.type === 'text').map(part => part.text).join('\n');
|
||||||
|
|
||||||
|
toolResults.push({
|
||||||
|
toolCallId: id,
|
||||||
|
content: text,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
debug('tool')(error);
|
||||||
|
toolResults.push({
|
||||||
|
toolCallId: id,
|
||||||
|
content: `Error while executing tool "${name}": ${error instanceof Error ? error.message : String(error)}\n\nPlease try to recover and complete the task.`,
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Skip remaining tool calls for this iteration
|
||||||
|
for (const remainingToolCall of toolCalls.slice(toolCalls.indexOf(toolCall) + 1)) {
|
||||||
|
toolResults.push({
|
||||||
|
toolCallId: remainingToolCall.id,
|
||||||
|
content: `This tool call is skipped due to previous error.`,
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate.addToolResults(conversation, toolResults);
|
||||||
|
if (oneShot)
|
||||||
|
return conversation.messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Failed to perform step, max attempts reached');
|
||||||
|
}
|
||||||
177
src/loop/loopClaude.ts
Normal file
177
src/loop/loopClaude.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* 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 type Anthropic from '@anthropic-ai/sdk';
|
||||||
|
import type { LLMDelegate, LLMConversation, LLMToolCall, LLMTool } from './loop.js';
|
||||||
|
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
|
const model = 'claude-sonnet-4-20250514';
|
||||||
|
|
||||||
|
export class ClaudeDelegate implements LLMDelegate {
|
||||||
|
private _anthropic: Anthropic | undefined;
|
||||||
|
|
||||||
|
async anthropic(): Promise<Anthropic> {
|
||||||
|
if (!this._anthropic) {
|
||||||
|
const anthropic = await import('@anthropic-ai/sdk');
|
||||||
|
this._anthropic = new anthropic.Anthropic();
|
||||||
|
}
|
||||||
|
return this._anthropic;
|
||||||
|
}
|
||||||
|
|
||||||
|
createConversation(task: string, tools: Tool[], oneShot: boolean): LLMConversation {
|
||||||
|
const llmTools: LLMTool[] = tools.map(tool => ({
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description || '',
|
||||||
|
inputSchema: tool.inputSchema,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!oneShot) {
|
||||||
|
llmTools.push({
|
||||||
|
name: 'done',
|
||||||
|
description: 'Call this tool when the task is complete.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: task
|
||||||
|
}],
|
||||||
|
tools: llmTools,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async makeApiCall(conversation: LLMConversation): Promise<LLMToolCall[]> {
|
||||||
|
// Convert generic messages to Claude format
|
||||||
|
const claudeMessages: Anthropic.Messages.MessageParam[] = [];
|
||||||
|
|
||||||
|
for (const message of conversation.messages) {
|
||||||
|
if (message.role === 'user') {
|
||||||
|
claudeMessages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: message.content
|
||||||
|
});
|
||||||
|
} else if (message.role === 'assistant') {
|
||||||
|
const content: Anthropic.Messages.ContentBlock[] = [];
|
||||||
|
|
||||||
|
// Add text content
|
||||||
|
if (message.content) {
|
||||||
|
content.push({
|
||||||
|
type: 'text',
|
||||||
|
text: message.content,
|
||||||
|
citations: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tool calls
|
||||||
|
if (message.toolCalls) {
|
||||||
|
for (const toolCall of message.toolCalls) {
|
||||||
|
content.push({
|
||||||
|
type: 'tool_use',
|
||||||
|
id: toolCall.id,
|
||||||
|
name: toolCall.name,
|
||||||
|
input: toolCall.arguments
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeMessages.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content
|
||||||
|
});
|
||||||
|
} else if (message.role === 'tool') {
|
||||||
|
// Tool results are added differently - we need to find if there's already a user message with tool results
|
||||||
|
const lastMessage = claudeMessages[claudeMessages.length - 1];
|
||||||
|
const toolResult: Anthropic.Messages.ToolResultBlockParam = {
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: message.toolCallId,
|
||||||
|
content: message.content,
|
||||||
|
is_error: message.isError,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (lastMessage && lastMessage.role === 'user' && Array.isArray(lastMessage.content)) {
|
||||||
|
// Add to existing tool results message
|
||||||
|
(lastMessage.content as Anthropic.Messages.ToolResultBlockParam[]).push(toolResult);
|
||||||
|
} else {
|
||||||
|
// Create new tool results message
|
||||||
|
claudeMessages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: [toolResult]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert generic tools to Claude format
|
||||||
|
const claudeTools: Anthropic.Messages.Tool[] = conversation.tools.map(tool => ({
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
input_schema: tool.inputSchema,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const anthropic = await this.anthropic();
|
||||||
|
const response = await anthropic.messages.create({
|
||||||
|
model,
|
||||||
|
max_tokens: 10000,
|
||||||
|
messages: claudeMessages,
|
||||||
|
tools: claudeTools,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract tool calls and add assistant message to generic conversation
|
||||||
|
const toolCalls = response.content.filter(block => block.type === 'tool_use') as Anthropic.Messages.ToolUseBlock[];
|
||||||
|
const textContent = response.content.filter(block => block.type === 'text').map(block => (block as Anthropic.Messages.TextBlock).text).join('');
|
||||||
|
|
||||||
|
const llmToolCalls: LLMToolCall[] = toolCalls.map(toolCall => ({
|
||||||
|
name: toolCall.name,
|
||||||
|
arguments: toolCall.input as any,
|
||||||
|
id: toolCall.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add assistant message to generic conversation
|
||||||
|
conversation.messages.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: textContent,
|
||||||
|
toolCalls: llmToolCalls.length > 0 ? llmToolCalls : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
return llmToolCalls;
|
||||||
|
}
|
||||||
|
|
||||||
|
addToolResults(
|
||||||
|
conversation: LLMConversation,
|
||||||
|
results: Array<{ toolCallId: string; content: string; isError?: boolean }>
|
||||||
|
): void {
|
||||||
|
for (const result of results) {
|
||||||
|
conversation.messages.push({
|
||||||
|
role: 'tool',
|
||||||
|
toolCallId: result.toolCallId,
|
||||||
|
content: result.content,
|
||||||
|
isError: result.isError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkDoneToolCall(toolCall: LLMToolCall): string | null {
|
||||||
|
if (toolCall.name === 'done')
|
||||||
|
return (toolCall.arguments as { result: string }).result;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
168
src/loop/loopOpenAI.ts
Normal file
168
src/loop/loopOpenAI.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* 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 type OpenAI from 'openai';
|
||||||
|
import type { LLMDelegate, LLMConversation, LLMToolCall, LLMTool } from './loop.js';
|
||||||
|
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
|
const model = 'gpt-4.1';
|
||||||
|
|
||||||
|
export class OpenAIDelegate implements LLMDelegate {
|
||||||
|
private _openai: OpenAI | undefined;
|
||||||
|
|
||||||
|
async openai(): Promise<OpenAI> {
|
||||||
|
if (!this._openai) {
|
||||||
|
const oai = await import('openai');
|
||||||
|
this._openai = new oai.OpenAI();
|
||||||
|
}
|
||||||
|
return this._openai;
|
||||||
|
}
|
||||||
|
|
||||||
|
createConversation(task: string, tools: Tool[], oneShot: boolean): LLMConversation {
|
||||||
|
const genericTools: LLMTool[] = tools.map(tool => ({
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description || '',
|
||||||
|
inputSchema: tool.inputSchema,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!oneShot) {
|
||||||
|
genericTools.push({
|
||||||
|
name: 'done',
|
||||||
|
description: 'Call this tool when the task is complete.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: task
|
||||||
|
}],
|
||||||
|
tools: genericTools,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async makeApiCall(conversation: LLMConversation): Promise<LLMToolCall[]> {
|
||||||
|
// Convert generic messages to OpenAI format
|
||||||
|
const openaiMessages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [];
|
||||||
|
|
||||||
|
for (const message of conversation.messages) {
|
||||||
|
if (message.role === 'user') {
|
||||||
|
openaiMessages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: message.content
|
||||||
|
});
|
||||||
|
} else if (message.role === 'assistant') {
|
||||||
|
const toolCalls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[] = [];
|
||||||
|
|
||||||
|
if (message.toolCalls) {
|
||||||
|
for (const toolCall of message.toolCalls) {
|
||||||
|
toolCalls.push({
|
||||||
|
id: toolCall.id,
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: toolCall.name,
|
||||||
|
arguments: JSON.stringify(toolCall.arguments)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantMessage: OpenAI.Chat.Completions.ChatCompletionAssistantMessageParam = {
|
||||||
|
role: 'assistant'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (message.content)
|
||||||
|
assistantMessage.content = message.content;
|
||||||
|
|
||||||
|
if (toolCalls.length > 0)
|
||||||
|
assistantMessage.tool_calls = toolCalls;
|
||||||
|
|
||||||
|
openaiMessages.push(assistantMessage);
|
||||||
|
} else if (message.role === 'tool') {
|
||||||
|
openaiMessages.push({
|
||||||
|
role: 'tool',
|
||||||
|
tool_call_id: message.toolCallId,
|
||||||
|
content: message.content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert generic tools to OpenAI format
|
||||||
|
const openaiTools: OpenAI.Chat.Completions.ChatCompletionTool[] = conversation.tools.map(tool => ({
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
parameters: tool.inputSchema,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const openai = await this.openai();
|
||||||
|
const response = await openai.chat.completions.create({
|
||||||
|
model,
|
||||||
|
messages: openaiMessages,
|
||||||
|
tools: openaiTools,
|
||||||
|
tool_choice: 'auto'
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = response.choices[0].message;
|
||||||
|
|
||||||
|
// Extract tool calls and add assistant message to generic conversation
|
||||||
|
const toolCalls = message.tool_calls || [];
|
||||||
|
const genericToolCalls: LLMToolCall[] = toolCalls.map(toolCall => {
|
||||||
|
const functionCall = toolCall.function;
|
||||||
|
return {
|
||||||
|
name: functionCall.name,
|
||||||
|
arguments: JSON.parse(functionCall.arguments),
|
||||||
|
id: toolCall.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add assistant message to generic conversation
|
||||||
|
conversation.messages.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: message.content || '',
|
||||||
|
toolCalls: genericToolCalls.length > 0 ? genericToolCalls : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
return genericToolCalls;
|
||||||
|
}
|
||||||
|
|
||||||
|
addToolResults(
|
||||||
|
conversation: LLMConversation,
|
||||||
|
results: Array<{ toolCallId: string; content: string; isError?: boolean }>
|
||||||
|
): void {
|
||||||
|
for (const result of results) {
|
||||||
|
conversation.messages.push({
|
||||||
|
role: 'tool',
|
||||||
|
toolCallId: result.toolCallId,
|
||||||
|
content: result.content,
|
||||||
|
isError: result.isError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkDoneToolCall(toolCall: LLMToolCall): string | null {
|
||||||
|
if (toolCall.name === 'done')
|
||||||
|
return toolCall.arguments.result;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/loop/main.ts
Normal file
72
src/loop/main.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* 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 path from 'path';
|
||||||
|
import url from 'url';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { program } from 'commander';
|
||||||
|
import { OpenAIDelegate } from './loopOpenAI.js';
|
||||||
|
import { ClaudeDelegate } from './loopClaude.js';
|
||||||
|
import { runTask } from './loop.js';
|
||||||
|
|
||||||
|
import type { LLMDelegate } from './loop.js';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
|
async function run(delegate: LLMDelegate) {
|
||||||
|
const transport = new StdioClientTransport({
|
||||||
|
command: 'node',
|
||||||
|
args: [
|
||||||
|
path.resolve(__filename, '../../../cli.js'),
|
||||||
|
'--save-session',
|
||||||
|
'--output-dir', path.resolve(__filename, '../../../sessions')
|
||||||
|
],
|
||||||
|
stderr: 'inherit',
|
||||||
|
env: process.env as Record<string, string>,
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client.connect(transport);
|
||||||
|
await client.ping();
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
const messages = await runTask(delegate, client, task);
|
||||||
|
for (const message of messages)
|
||||||
|
console.log(`${message.role}: ${message.content}`);
|
||||||
|
}
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = [
|
||||||
|
'Open https://playwright.dev/',
|
||||||
|
];
|
||||||
|
|
||||||
|
program
|
||||||
|
.option('--model <model>', 'model to use')
|
||||||
|
.action(async options => {
|
||||||
|
if (options.model === 'claude')
|
||||||
|
await run(new ClaudeDelegate());
|
||||||
|
else
|
||||||
|
await run(new OpenAIDelegate());
|
||||||
|
});
|
||||||
|
void program.parseAsync(process.argv);
|
||||||
5
src/loopTools/DEPS.list
Normal file
5
src/loopTools/DEPS.list
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[*]
|
||||||
|
../
|
||||||
|
../loop/
|
||||||
|
../mcp/
|
||||||
|
../utils/
|
||||||
77
src/loopTools/context.ts
Normal file
77
src/loopTools/context.ts
Normal file
@@ -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 { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { contextFactory } from '../browserContextFactory.js';
|
||||||
|
import { BrowserServerBackend } from '../browserServerBackend.js';
|
||||||
|
import { Context as BrowserContext } from '../context.js';
|
||||||
|
import { runTask } from '../loop/loop.js';
|
||||||
|
import { OpenAIDelegate } from '../loop/loopOpenAI.js';
|
||||||
|
import { ClaudeDelegate } from '../loop/loopClaude.js';
|
||||||
|
import { InProcessTransport } from '../mcp/inProcessTransport.js';
|
||||||
|
import * as mcpServer from '../mcp/server.js';
|
||||||
|
|
||||||
|
import type { LLMDelegate } from '../loop/loop.js';
|
||||||
|
import type { FullConfig } from '../config.js';
|
||||||
|
|
||||||
|
export class Context {
|
||||||
|
readonly config: FullConfig;
|
||||||
|
private _client: Client;
|
||||||
|
private _delegate: LLMDelegate;
|
||||||
|
|
||||||
|
constructor(config: FullConfig, client: Client) {
|
||||||
|
this.config = config;
|
||||||
|
this._client = client;
|
||||||
|
if (process.env.OPENAI_API_KEY)
|
||||||
|
this._delegate = new OpenAIDelegate();
|
||||||
|
else if (process.env.ANTHROPIC_API_KEY)
|
||||||
|
this._delegate = new ClaudeDelegate();
|
||||||
|
else
|
||||||
|
throw new Error('No LLM API key found. Please set OPENAI_API_KEY or ANTHROPIC_API_KEY environment variable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(config: FullConfig) {
|
||||||
|
const client = new Client({ name: 'Playwright Proxy', version: '1.0.0' });
|
||||||
|
const browserContextFactory = contextFactory(config);
|
||||||
|
const server = mcpServer.createServer(new BrowserServerBackend(config, browserContextFactory), false);
|
||||||
|
await client.connect(new InProcessTransport(server));
|
||||||
|
await client.ping();
|
||||||
|
return new Context(config, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
async runTask(task: string, oneShot: boolean = false): Promise<mcpServer.CallToolResult> {
|
||||||
|
const messages = await runTask(this._delegate, this._client!, task, oneShot);
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
// Skip the first message, which is the user's task.
|
||||||
|
for (const message of messages.slice(1)) {
|
||||||
|
// Trim out all page snapshots.
|
||||||
|
if (!message.content.trim())
|
||||||
|
continue;
|
||||||
|
const index = oneShot ? -1 : message.content.indexOf('### Page state');
|
||||||
|
const trimmedContent = index === -1 ? message.content : message.content.substring(0, index);
|
||||||
|
lines.push(`[${message.role}]:`, trimmedContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: lines.join('\n') }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
await BrowserContext.disposeAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/loopTools/main.ts
Normal file
65
src/loopTools/main.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* 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 dotenv from 'dotenv';
|
||||||
|
|
||||||
|
import * as mcpServer from '../mcp/server.js';
|
||||||
|
import * as mcpTransport from '../mcp/transport.js';
|
||||||
|
import { packageJSON } from '../utils/package.js';
|
||||||
|
import { Context } from './context.js';
|
||||||
|
import { perform } from './perform.js';
|
||||||
|
import { snapshot } from './snapshot.js';
|
||||||
|
import { toMcpTool } from '../mcp/tool.js';
|
||||||
|
|
||||||
|
import type { FullConfig } from '../config.js';
|
||||||
|
import type { ServerBackend } from '../mcp/server.js';
|
||||||
|
import type { Tool } from './tool.js';
|
||||||
|
|
||||||
|
export async function runLoopTools(config: FullConfig) {
|
||||||
|
dotenv.config();
|
||||||
|
const serverBackendFactory = () => new LoopToolsServerBackend(config);
|
||||||
|
await mcpTransport.start(serverBackendFactory, config.server);
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoopToolsServerBackend implements ServerBackend {
|
||||||
|
readonly name = 'Playwright';
|
||||||
|
readonly version = packageJSON.version;
|
||||||
|
private _config: FullConfig;
|
||||||
|
private _context: Context | undefined;
|
||||||
|
private _tools: Tool<any>[] = [perform, snapshot];
|
||||||
|
|
||||||
|
constructor(config: FullConfig) {
|
||||||
|
this._config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
this._context = await Context.create(this._config);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTools(): Promise<mcpServer.Tool[]> {
|
||||||
|
return this._tools.map(tool => toMcpTool(tool.schema));
|
||||||
|
}
|
||||||
|
|
||||||
|
async callTool(name: string, args: mcpServer.CallToolRequest['params']['arguments']): Promise<mcpServer.CallToolResult> {
|
||||||
|
const tool = this._tools.find(tool => tool.schema.name === name)!;
|
||||||
|
const parsedArguments = tool.schema.inputSchema.parse(args || {});
|
||||||
|
return await tool.handle(this._context!, parsedArguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
serverClosed() {
|
||||||
|
void this._context!.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/loopTools/perform.ts
Normal file
36
src/loopTools/perform.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* 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 { defineTool } from './tool.js';
|
||||||
|
|
||||||
|
const performSchema = z.object({
|
||||||
|
task: z.string().describe('The task to perform with the browser'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const perform = defineTool({
|
||||||
|
schema: {
|
||||||
|
name: 'browser_perform',
|
||||||
|
title: 'Perform a task with the browser',
|
||||||
|
description: 'Perform a task with the browser. It can click, type, export, capture screenshot, drag, hover, select options, etc.',
|
||||||
|
inputSchema: performSchema,
|
||||||
|
type: 'destructive',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (context, params) => {
|
||||||
|
return await context.runTask(params.task);
|
||||||
|
},
|
||||||
|
});
|
||||||
32
src/loopTools/snapshot.ts
Normal file
32
src/loopTools/snapshot.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* 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 { defineTool } from './tool.js';
|
||||||
|
|
||||||
|
export const snapshot = defineTool({
|
||||||
|
schema: {
|
||||||
|
name: 'browser_snapshot',
|
||||||
|
title: 'Take a snapshot of the browser',
|
||||||
|
description: 'Take a snapshot of the browser to read what is on the page.',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (context, params) => {
|
||||||
|
return await context.runTask('Capture browser snapshot', true);
|
||||||
|
},
|
||||||
|
});
|
||||||
30
src/loopTools/tool.ts
Normal file
30
src/loopTools/tool.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* 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 type { z } from 'zod';
|
||||||
|
import type * as mcpServer from '../mcp/server.js';
|
||||||
|
import type { Context } from './context.js';
|
||||||
|
import type { ToolSchema } from '../mcp/tool.js';
|
||||||
|
|
||||||
|
|
||||||
|
export type Tool<Input extends z.Schema = z.Schema> = {
|
||||||
|
schema: ToolSchema<Input>;
|
||||||
|
handle: (context: Context, params: z.output<Input>) => Promise<mcpServer.CallToolResult>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function defineTool<Input extends z.Schema>(tool: Tool<Input>): Tool<Input> {
|
||||||
|
return tool;
|
||||||
|
}
|
||||||
2
src/mcp/DEPS.list
Normal file
2
src/mcp/DEPS.list
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[*]
|
||||||
|
../utils/
|
||||||
1
src/mcp/README.md
Normal file
1
src/mcp/README.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- Generic MCP utils, no dependencies on Playwright here.
|
||||||
92
src/mcp/inProcessTransport.ts
Normal file
92
src/mcp/inProcessTransport.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* 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 type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
import type { Transport, TransportSendOptions } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
|
import type { JSONRPCMessage, MessageExtraInfo } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
|
export class InProcessTransport implements Transport {
|
||||||
|
private _server: Server;
|
||||||
|
private _serverTransport: InProcessServerTransport;
|
||||||
|
private _connected: boolean = false;
|
||||||
|
|
||||||
|
constructor(server: Server) {
|
||||||
|
this._server = server;
|
||||||
|
this._serverTransport = new InProcessServerTransport(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this._connected)
|
||||||
|
throw new Error('InprocessTransport already started!');
|
||||||
|
|
||||||
|
await this._server.connect(this._serverTransport);
|
||||||
|
this._connected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {
|
||||||
|
if (!this._connected)
|
||||||
|
throw new Error('Transport not connected');
|
||||||
|
|
||||||
|
|
||||||
|
this._serverTransport._receiveFromClient(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this._connected) {
|
||||||
|
this._connected = false;
|
||||||
|
this.onclose?.();
|
||||||
|
this._serverTransport.onclose?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onclose?: (() => void) | undefined;
|
||||||
|
onerror?: ((error: Error) => void) | undefined;
|
||||||
|
onmessage?: ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void) | undefined;
|
||||||
|
sessionId?: string | undefined;
|
||||||
|
setProtocolVersion?: ((version: string) => void) | undefined;
|
||||||
|
|
||||||
|
_receiveFromServer(message: JSONRPCMessage, extra?: MessageExtraInfo): void {
|
||||||
|
this.onmessage?.(message, extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InProcessServerTransport implements Transport {
|
||||||
|
private _clientTransport: InProcessTransport;
|
||||||
|
|
||||||
|
constructor(clientTransport: InProcessTransport) {
|
||||||
|
this._clientTransport = clientTransport;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {
|
||||||
|
this._clientTransport._receiveFromServer(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.onclose?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
onclose?: (() => void) | undefined;
|
||||||
|
onerror?: ((error: Error) => void) | undefined;
|
||||||
|
onmessage?: ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void) | undefined;
|
||||||
|
sessionId?: string | undefined;
|
||||||
|
setProtocolVersion?: ((version: string) => void) | undefined;
|
||||||
|
_receiveFromClient(message: JSONRPCMessage): void {
|
||||||
|
this.onmessage?.(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/mcp/proxyBackend.ts
Normal file
131
src/mcp/proxyBackend.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* 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 { logUnhandledError } from '../utils/log.js';
|
||||||
|
import { packageJSON } from '../utils/package.js';
|
||||||
|
|
||||||
|
|
||||||
|
import type { ServerBackend, ClientVersion, Root } from './server.js';
|
||||||
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
|
import type { Tool, CallToolResult, CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
|
export type MCPProvider = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
connect(): Promise<Transport>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ProxyBackend implements ServerBackend {
|
||||||
|
name = 'Playwright MCP Client Switcher';
|
||||||
|
version = packageJSON.version;
|
||||||
|
|
||||||
|
private _mcpProviders: MCPProvider[];
|
||||||
|
private _currentClient: Client | undefined;
|
||||||
|
private _contextSwitchTool: Tool;
|
||||||
|
private _roots: Root[] = [];
|
||||||
|
|
||||||
|
constructor(mcpProviders: MCPProvider[]) {
|
||||||
|
this._mcpProviders = mcpProviders;
|
||||||
|
this._contextSwitchTool = this._defineContextSwitchTool();
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(clientVersion: ClientVersion, roots: Root[]): Promise<void> {
|
||||||
|
this._roots = roots;
|
||||||
|
await this._setCurrentClient(this._mcpProviders[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTools(): Promise<Tool[]> {
|
||||||
|
const response = await this._currentClient!.listTools();
|
||||||
|
if (this._mcpProviders.length === 1)
|
||||||
|
return response.tools;
|
||||||
|
return [
|
||||||
|
...response.tools,
|
||||||
|
this._contextSwitchTool,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult> {
|
||||||
|
if (name === this._contextSwitchTool.name)
|
||||||
|
return this._callContextSwitchTool(args);
|
||||||
|
return await this._currentClient!.callTool({
|
||||||
|
name,
|
||||||
|
arguments: args,
|
||||||
|
}) as CallToolResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
serverClosed?(): void {
|
||||||
|
void this._currentClient?.close().catch(logUnhandledError);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _callContextSwitchTool(params: any): Promise<CallToolResult> {
|
||||||
|
try {
|
||||||
|
const factory = this._mcpProviders.find(factory => factory.name === params.name);
|
||||||
|
if (!factory)
|
||||||
|
throw new Error('Unknown connection method: ' + params.name);
|
||||||
|
|
||||||
|
await this._setCurrentClient(factory);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: '### Result\nSuccessfully changed connection method.\n' }],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `### Result\nError: ${error}\n` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _defineContextSwitchTool(): Tool {
|
||||||
|
return {
|
||||||
|
name: 'browser_connect',
|
||||||
|
description: [
|
||||||
|
'Connect to a browser using one of the available methods:',
|
||||||
|
...this._mcpProviders.map(factory => `- "${factory.name}": ${factory.description}`),
|
||||||
|
].join('\n'),
|
||||||
|
inputSchema: zodToJsonSchema(z.object({
|
||||||
|
name: z.enum(this._mcpProviders.map(factory => factory.name) as [string, ...string[]]).default(this._mcpProviders[0].name).describe('The method to use to connect to the browser'),
|
||||||
|
}), { strictUnions: true }) as Tool['inputSchema'],
|
||||||
|
annotations: {
|
||||||
|
title: 'Connect to a browser context',
|
||||||
|
readOnlyHint: true,
|
||||||
|
openWorldHint: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _setCurrentClient(factory: MCPProvider) {
|
||||||
|
await this._currentClient?.close();
|
||||||
|
this._currentClient = undefined;
|
||||||
|
|
||||||
|
const client = new Client({ name: 'Playwright MCP Proxy', version: packageJSON.version });
|
||||||
|
client.registerCapabilities({
|
||||||
|
roots: {
|
||||||
|
listRoots: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
client.setRequestHandler(ListRootsRequestSchema, () => ({ roots: this._roots }));
|
||||||
|
client.setRequestHandler(PingRequestSchema, () => ({}));
|
||||||
|
|
||||||
|
const transport = await factory.connect();
|
||||||
|
await client.connect(transport);
|
||||||
|
this._currentClient = client;
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/mcp/server.ts
Normal file
122
src/mcp/server.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* 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 debug from 'debug';
|
||||||
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import { ManualPromise } from '../utils/manualPromise.js';
|
||||||
|
import { logUnhandledError } from '../utils/log.js';
|
||||||
|
|
||||||
|
import type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
|
export type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
export type { Tool, CallToolResult, CallToolRequest, Root } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
|
const serverDebug = debug('pw:mcp:server');
|
||||||
|
|
||||||
|
export type ClientVersion = { name: string, version: string };
|
||||||
|
export interface ServerBackend {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
initialize?(clientVersion: ClientVersion, roots: Root[]): Promise<void>;
|
||||||
|
listTools(): Promise<Tool[]>;
|
||||||
|
callTool(name: string, args: CallToolRequest['params']['arguments']): Promise<CallToolResult>;
|
||||||
|
serverClosed?(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerBackendFactory = () => ServerBackend;
|
||||||
|
|
||||||
|
export async function connect(serverBackendFactory: ServerBackendFactory, transport: Transport, runHeartbeat: boolean) {
|
||||||
|
const backend = serverBackendFactory();
|
||||||
|
const server = createServer(backend, runHeartbeat);
|
||||||
|
await server.connect(transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServer(backend: ServerBackend, runHeartbeat: boolean): Server {
|
||||||
|
const initializedPromise = new ManualPromise<void>();
|
||||||
|
const server = new Server({ name: backend.name, version: backend.version }, {
|
||||||
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||||
|
serverDebug('listTools');
|
||||||
|
await initializedPromise;
|
||||||
|
const tools = await backend.listTools();
|
||||||
|
return { tools };
|
||||||
|
});
|
||||||
|
|
||||||
|
let heartbeatRunning = false;
|
||||||
|
server.setRequestHandler(CallToolRequestSchema, async request => {
|
||||||
|
serverDebug('callTool', request);
|
||||||
|
await initializedPromise;
|
||||||
|
|
||||||
|
if (runHeartbeat && !heartbeatRunning) {
|
||||||
|
heartbeatRunning = true;
|
||||||
|
startHeartbeat(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await backend.callTool(request.params.name, request.params.arguments || {});
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: '### Result\n' + String(error) }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
addServerListener(server, 'initialized', async () => {
|
||||||
|
try {
|
||||||
|
const capabilities = server.getClientCapabilities();
|
||||||
|
let clientRoots: Root[] = [];
|
||||||
|
if (capabilities?.roots) {
|
||||||
|
const { roots } = await server.listRoots(undefined, { timeout: 2_000 }).catch(() => ({ roots: [] }));
|
||||||
|
clientRoots = roots;
|
||||||
|
}
|
||||||
|
const clientVersion = server.getClientVersion() ?? { name: 'unknown', version: 'unknown' };
|
||||||
|
await backend.initialize?.(clientVersion, clientRoots);
|
||||||
|
initializedPromise.resolve();
|
||||||
|
} catch (e) {
|
||||||
|
logUnhandledError(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
addServerListener(server, 'close', () => backend.serverClosed?.());
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startHeartbeat = (server: Server) => {
|
||||||
|
const beat = () => {
|
||||||
|
Promise.race([
|
||||||
|
server.ping(),
|
||||||
|
new Promise((_, reject) => setTimeout(() => reject(new Error('ping timeout')), 5000)),
|
||||||
|
]).then(() => {
|
||||||
|
setTimeout(beat, 3000);
|
||||||
|
}).catch(() => {
|
||||||
|
void server.close();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
beat();
|
||||||
|
};
|
||||||
|
|
||||||
|
function addServerListener(server: Server, event: 'close' | 'initialized', listener: () => void) {
|
||||||
|
const oldListener = server[`on${event}`];
|
||||||
|
server[`on${event}`] = () => {
|
||||||
|
oldListener?.();
|
||||||
|
listener();
|
||||||
|
};
|
||||||
|
}
|
||||||
42
src/mcp/tool.ts
Normal file
42
src/mcp/tool.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* 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 { zodToJsonSchema } from 'zod-to-json-schema';
|
||||||
|
|
||||||
|
import type { z } from 'zod';
|
||||||
|
import type * as mcpServer from './server.js';
|
||||||
|
|
||||||
|
export type ToolSchema<Input extends z.Schema> = {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: Input;
|
||||||
|
type: 'readOnly' | 'destructive';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function toMcpTool(tool: ToolSchema<any>): mcpServer.Tool {
|
||||||
|
return {
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: zodToJsonSchema(tool.inputSchema, { strictUnions: true }) as mcpServer.Tool['inputSchema'],
|
||||||
|
annotations: {
|
||||||
|
title: tool.title,
|
||||||
|
readOnlyHint: tool.type === 'readOnly',
|
||||||
|
destructiveHint: tool.type === 'destructive',
|
||||||
|
openWorldHint: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
137
src/mcp/transport.ts
Normal file
137
src/mcp/transport.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* 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 http from 'http';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import debug from 'debug';
|
||||||
|
|
||||||
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.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 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');
|
||||||
|
|
||||||
|
async function handleSSE(serverBackendFactory: ServerBackendFactory, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
const sessionId = url.searchParams.get('sessionId');
|
||||||
|
if (!sessionId) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
return res.end('Missing sessionId');
|
||||||
|
}
|
||||||
|
|
||||||
|
const transport = sessions.get(sessionId);
|
||||||
|
if (!transport) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
return res.end('Session not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await transport.handlePostMessage(req, res);
|
||||||
|
} else if (req.method === 'GET') {
|
||||||
|
const transport = new SSEServerTransport('/sse', res);
|
||||||
|
sessions.set(transport.sessionId, transport);
|
||||||
|
testDebug(`create SSE session: ${transport.sessionId}`);
|
||||||
|
await mcpServer.connect(serverBackendFactory, transport, false);
|
||||||
|
res.on('close', () => {
|
||||||
|
testDebug(`delete SSE session: ${transport.sessionId}`);
|
||||||
|
sessions.delete(transport.sessionId);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.statusCode = 405;
|
||||||
|
res.end('Method not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStreamable(serverBackendFactory: ServerBackendFactory, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>) {
|
||||||
|
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||||
|
if (sessionId) {
|
||||||
|
const transport = sessions.get(sessionId);
|
||||||
|
if (!transport) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end('Session not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return await transport.handleRequest(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
const transport = new StreamableHTTPServerTransport({
|
||||||
|
sessionIdGenerator: () => crypto.randomUUID(),
|
||||||
|
onsessioninitialized: async sessionId => {
|
||||||
|
testDebug(`create http session: ${transport.sessionId}`);
|
||||||
|
await mcpServer.connect(serverBackendFactory, transport, true);
|
||||||
|
sessions.set(sessionId, transport);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
transport.onclose = () => {
|
||||||
|
if (!transport.sessionId)
|
||||||
|
return;
|
||||||
|
sessions.delete(transport.sessionId);
|
||||||
|
testDebug(`delete http session: ${transport.sessionId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
await transport.handleRequest(req, res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.statusCode = 400;
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,101 +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 * as playwright from 'playwright';
|
|
||||||
import yaml from 'yaml';
|
|
||||||
|
|
||||||
type PageOrFrameLocator = playwright.Page | playwright.FrameLocator;
|
|
||||||
|
|
||||||
export class PageSnapshot {
|
|
||||||
private _frameLocators: PageOrFrameLocator[] = [];
|
|
||||||
private _text!: string;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
}
|
|
||||||
|
|
||||||
static async create(page: playwright.Page): Promise<PageSnapshot> {
|
|
||||||
const snapshot = new PageSnapshot();
|
|
||||||
await snapshot._build(page);
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
text(): string {
|
|
||||||
return this._text;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _build(page: playwright.Page) {
|
|
||||||
const yamlDocument = await this._snapshotFrame(page);
|
|
||||||
this._text = [
|
|
||||||
`- Page Snapshot`,
|
|
||||||
'```yaml',
|
|
||||||
yamlDocument.toString({ indentSeq: false }).trim(),
|
|
||||||
'```',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _snapshotFrame(frame: playwright.Page | playwright.FrameLocator) {
|
|
||||||
const frameIndex = this._frameLocators.push(frame) - 1;
|
|
||||||
const snapshotString = await frame.locator('body').ariaSnapshot({ ref: true, emitGeneric: true });
|
|
||||||
const snapshot = yaml.parseDocument(snapshotString);
|
|
||||||
|
|
||||||
const visit = async (node: any): Promise<unknown> => {
|
|
||||||
if (yaml.isPair(node)) {
|
|
||||||
await Promise.all([
|
|
||||||
visit(node.key).then(k => node.key = k),
|
|
||||||
visit(node.value).then(v => node.value = v)
|
|
||||||
]);
|
|
||||||
} else if (yaml.isSeq(node) || yaml.isMap(node)) {
|
|
||||||
node.items = await Promise.all(node.items.map(visit));
|
|
||||||
} else if (yaml.isScalar(node)) {
|
|
||||||
if (typeof node.value === 'string') {
|
|
||||||
const value = node.value;
|
|
||||||
if (frameIndex > 0)
|
|
||||||
node.value = value.replace('[ref=', `[ref=f${frameIndex}`);
|
|
||||||
if (value.startsWith('iframe ')) {
|
|
||||||
const ref = value.match(/\[ref=(.*)\]/)?.[1];
|
|
||||||
if (ref) {
|
|
||||||
try {
|
|
||||||
const childSnapshot = await this._snapshotFrame(frame.frameLocator(`aria-ref=${ref}`));
|
|
||||||
return snapshot.createPair(node.value, childSnapshot);
|
|
||||||
} catch (error) {
|
|
||||||
return snapshot.createPair(node.value, '<could not take iframe snapshot>');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return node;
|
|
||||||
};
|
|
||||||
await visit(snapshot.contents);
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
refLocator(ref: string): playwright.Locator {
|
|
||||||
let frame = this._frameLocators[0];
|
|
||||||
const match = ref.match(/^f(\d+)(.*)/);
|
|
||||||
if (match) {
|
|
||||||
const frameIndex = parseInt(match[1], 10);
|
|
||||||
frame = this._frameLocators[frameIndex];
|
|
||||||
ref = match[2];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!frame)
|
|
||||||
throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
|
|
||||||
|
|
||||||
return frame.locator(`aria-ref=${ref}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
119
src/program.ts
119
src/program.ts
@@ -14,46 +14,92 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { program } from 'commander';
|
import { program, Option } from 'commander';
|
||||||
|
import * as mcpServer from './mcp/server.js';
|
||||||
|
import * as mcpTransport from './mcp/transport.js';
|
||||||
|
import { commaSeparatedList, resolveCLIConfig, semicolonSeparatedList } from './config.js';
|
||||||
|
import { packageJSON } from './utils/package.js';
|
||||||
|
import { Context } from './context.js';
|
||||||
|
import { contextFactory } from './browserContextFactory.js';
|
||||||
|
import { runLoopTools } from './loopTools/main.js';
|
||||||
|
import { ProxyBackend } from './mcp/proxyBackend.js';
|
||||||
|
import { BrowserServerBackend } from './browserServerBackend.js';
|
||||||
|
import { ExtensionContextFactory } from './extension/extensionContextFactory.js';
|
||||||
|
import { InProcessTransport } from './mcp/inProcessTransport.js';
|
||||||
|
|
||||||
import { createServer } from './index';
|
import type { MCPProvider } from './mcp/proxyBackend.js';
|
||||||
import { ServerList } from './server';
|
import type { FullConfig } from './config.js';
|
||||||
|
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||||
import { startHttpTransport, startStdioTransport } from './transport';
|
|
||||||
|
|
||||||
import { resolveConfig } from './config';
|
|
||||||
|
|
||||||
const packageJSON = require('../package.json');
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.version('Version ' + packageJSON.version)
|
.version('Version ' + packageJSON.version)
|
||||||
.name(packageJSON.name)
|
.name(packageJSON.name)
|
||||||
.option('--browser <browser>', 'Browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
|
.option('--allowed-origins <origins>', 'semicolon-separated list of origins to allow the browser to request. Default is to allow all.', semicolonSeparatedList)
|
||||||
.option('--caps <caps>', 'Comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
|
.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)
|
||||||
|
.option('--block-service-workers', 'block service workers')
|
||||||
|
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
|
||||||
|
.option('--caps <caps>', 'comma-separated list of additional capabilities to enable, possible values: vision, pdf.', commaSeparatedList)
|
||||||
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
||||||
.option('--executable-path <path>', 'Path to the browser executable.')
|
.option('--config <path>', 'path to the configuration file.')
|
||||||
.option('--headless', 'Run browser in headless mode, headed by default')
|
.option('--device <device>', 'device to emulate, for example: "iPhone 15"')
|
||||||
.option('--device <device>', 'Device to emulate, for example: "iPhone 15"')
|
.option('--executable-path <path>', 'path to the browser executable.')
|
||||||
.option('--user-data-dir <path>', 'Path to the user data directory')
|
.option('--extension', 'Connect to a running browser instance (Edge/Chrome only). Requires the "Playwright MCP Bridge" browser extension to be installed.')
|
||||||
.option('--port <port>', 'Port to listen on for SSE transport.')
|
.option('--headless', 'run browser in headless mode, headed by default')
|
||||||
.option('--host <host>', 'Host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
|
.option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
|
||||||
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
|
.option('--ignore-https-errors', 'ignore https errors')
|
||||||
.option('--config <path>', 'Path to the configuration file.')
|
.option('--isolated', 'keep the browser profile in memory, do not save it to disk.')
|
||||||
|
.option('--image-responses <mode>', 'whether to send image responses to the client. Can be "allow" or "omit", Defaults to "allow".')
|
||||||
|
.option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.')
|
||||||
|
.option('--output-dir <path>', 'path to the directory for output files.')
|
||||||
|
.option('--port <port>', 'port to listen on for SSE transport.')
|
||||||
|
.option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')
|
||||||
|
.option('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"')
|
||||||
|
.option('--save-session', 'Whether to save the Playwright MCP session into the output directory.')
|
||||||
|
.option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.')
|
||||||
|
.option('--storage-state <path>', 'path to the storage state file for isolated sessions.')
|
||||||
|
.option('--user-agent <ua string>', 'specify user agent string')
|
||||||
|
.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"')
|
||||||
|
.addOption(new Option('--connect-tool', 'Allow to switch between different browser connection methods.').hideHelp())
|
||||||
|
.addOption(new Option('--loop-tools', 'Run loop tools').hideHelp())
|
||||||
|
.addOption(new Option('--vision', 'Legacy option, use --caps=vision instead').hideHelp())
|
||||||
.action(async options => {
|
.action(async options => {
|
||||||
const config = await resolveConfig(options);
|
setupExitWatchdog();
|
||||||
const serverList = new ServerList(() => createServer(config));
|
|
||||||
setupExitWatchdog(serverList);
|
|
||||||
|
|
||||||
if (options.port)
|
if (options.vision) {
|
||||||
startHttpTransport(+options.port, options.host, serverList);
|
// eslint-disable-next-line no-console
|
||||||
else
|
console.error('The --vision option is deprecated, use --caps=vision instead');
|
||||||
await startStdioTransport(serverList);
|
options.caps = 'vision';
|
||||||
|
}
|
||||||
|
const config = await resolveCLIConfig(options);
|
||||||
|
|
||||||
|
if (options.extension) {
|
||||||
|
const contextFactory = createExtensionContextFactory(config);
|
||||||
|
const serverBackendFactory = () => new BrowserServerBackend(config, contextFactory);
|
||||||
|
await mcpTransport.start(serverBackendFactory, config.server);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.loopTools) {
|
||||||
|
await runLoopTools(config);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const browserContextFactory = contextFactory(config);
|
||||||
|
const providers: MCPProvider[] = [mcpProviderForBrowserContextFactory(config, browserContextFactory)];
|
||||||
|
if (options.connectTool)
|
||||||
|
providers.push(mcpProviderForBrowserContextFactory(config, createExtensionContextFactory(config)));
|
||||||
|
await mcpTransport.start(() => new ProxyBackend(providers), config.server);
|
||||||
});
|
});
|
||||||
|
|
||||||
function setupExitWatchdog(serverList: ServerList) {
|
function setupExitWatchdog() {
|
||||||
|
let isExiting = false;
|
||||||
const handleExit = async () => {
|
const handleExit = async () => {
|
||||||
|
if (isExiting)
|
||||||
|
return;
|
||||||
|
isExiting = true;
|
||||||
setTimeout(() => process.exit(0), 15000);
|
setTimeout(() => process.exit(0), 15000);
|
||||||
await serverList.closeAll();
|
await Context.disposeAll();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,4 +108,19 @@ function setupExitWatchdog(serverList: ServerList) {
|
|||||||
process.on('SIGTERM', handleExit);
|
process.on('SIGTERM', handleExit);
|
||||||
}
|
}
|
||||||
|
|
||||||
program.parse(process.argv);
|
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);
|
||||||
|
|||||||
201
src/response.ts
Normal file
201
src/response.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* 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 { renderModalStates } from './tab.js';
|
||||||
|
|
||||||
|
import type { Tab, TabSnapshot } from './tab.js';
|
||||||
|
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import type { Context } from './context.js';
|
||||||
|
|
||||||
|
export class Response {
|
||||||
|
private _result: string[] = [];
|
||||||
|
private _code: string[] = [];
|
||||||
|
private _images: { contentType: string, data: Buffer }[] = [];
|
||||||
|
private _context: Context;
|
||||||
|
private _includeSnapshot = false;
|
||||||
|
private _includeTabs = false;
|
||||||
|
private _tabSnapshot: TabSnapshot | undefined;
|
||||||
|
|
||||||
|
readonly toolName: string;
|
||||||
|
readonly toolArgs: Record<string, any>;
|
||||||
|
private _isError: boolean | undefined;
|
||||||
|
|
||||||
|
constructor(context: Context, toolName: string, toolArgs: Record<string, any>) {
|
||||||
|
this._context = context;
|
||||||
|
this.toolName = toolName;
|
||||||
|
this.toolArgs = toolArgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
addResult(result: string) {
|
||||||
|
this._result.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
addError(error: string) {
|
||||||
|
this._result.push(error);
|
||||||
|
this._isError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
isError() {
|
||||||
|
return this._isError;
|
||||||
|
}
|
||||||
|
|
||||||
|
result() {
|
||||||
|
return this._result.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
addCode(code: string) {
|
||||||
|
this._code.push(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
code() {
|
||||||
|
return this._code.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
addImage(image: { contentType: string, data: Buffer }) {
|
||||||
|
this._images.push(image);
|
||||||
|
}
|
||||||
|
|
||||||
|
images() {
|
||||||
|
return this._images;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIncludeSnapshot() {
|
||||||
|
this._includeSnapshot = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIncludeTabs() {
|
||||||
|
this._includeTabs = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async finish() {
|
||||||
|
// All the async snapshotting post-action is happening here.
|
||||||
|
// Everything below should race against modal states.
|
||||||
|
if (this._includeSnapshot && this._context.currentTab())
|
||||||
|
this._tabSnapshot = await this._context.currentTabOrDie().captureSnapshot();
|
||||||
|
for (const tab of this._context.tabs())
|
||||||
|
await tab.updateTitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
tabSnapshot(): TabSnapshot | undefined {
|
||||||
|
return this._tabSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
serialize(): { content: (TextContent | ImageContent)[], isError?: boolean } {
|
||||||
|
const response: string[] = [];
|
||||||
|
|
||||||
|
// Start with command result.
|
||||||
|
if (this._result.length) {
|
||||||
|
response.push('### Result');
|
||||||
|
response.push(this._result.join('\n'));
|
||||||
|
response.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add code if it exists.
|
||||||
|
if (this._code.length) {
|
||||||
|
response.push(`### Ran Playwright code
|
||||||
|
\`\`\`js
|
||||||
|
${this._code.join('\n')}
|
||||||
|
\`\`\``);
|
||||||
|
response.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// List browser tabs.
|
||||||
|
if (this._includeSnapshot || this._includeTabs)
|
||||||
|
response.push(...renderTabsMarkdown(this._context.tabs(), this._includeTabs));
|
||||||
|
|
||||||
|
// Add snapshot if provided.
|
||||||
|
if (this._tabSnapshot?.modalStates.length) {
|
||||||
|
response.push(...renderModalStates(this._context, this._tabSnapshot.modalStates));
|
||||||
|
response.push('');
|
||||||
|
} else if (this._tabSnapshot) {
|
||||||
|
response.push(renderTabSnapshot(this._tabSnapshot));
|
||||||
|
response.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main response part
|
||||||
|
const content: (TextContent | ImageContent)[] = [
|
||||||
|
{ type: 'text', text: response.join('\n') },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Image attachments.
|
||||||
|
if (this._context.config.imageResponses !== 'omit') {
|
||||||
|
for (const image of this._images)
|
||||||
|
content.push({ type: 'image', data: image.data.toString('base64'), mimeType: image.contentType });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { content, isError: this._isError };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTabSnapshot(tabSnapshot: TabSnapshot): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
if (tabSnapshot.consoleMessages.length) {
|
||||||
|
lines.push(`### New console messages`);
|
||||||
|
for (const message of tabSnapshot.consoleMessages)
|
||||||
|
lines.push(`- ${trim(message.toString(), 100)}`);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabSnapshot.downloads.length) {
|
||||||
|
lines.push(`### Downloads`);
|
||||||
|
for (const entry of tabSnapshot.downloads) {
|
||||||
|
if (entry.finished)
|
||||||
|
lines.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
|
||||||
|
else
|
||||||
|
lines.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`### Page state`);
|
||||||
|
lines.push(`- Page URL: ${tabSnapshot.url}`);
|
||||||
|
lines.push(`- Page Title: ${tabSnapshot.title}`);
|
||||||
|
lines.push(`- Page Snapshot:`);
|
||||||
|
lines.push('```yaml');
|
||||||
|
lines.push(tabSnapshot.ariaSnapshot);
|
||||||
|
lines.push('```');
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTabsMarkdown(tabs: Tab[], force: boolean = false): string[] {
|
||||||
|
if (tabs.length === 1 && !force)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
if (!tabs.length) {
|
||||||
|
return [
|
||||||
|
'### Open tabs',
|
||||||
|
'No open tabs. Use the "browser_navigate" tool to navigate to a page first.',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = ['### Open tabs'];
|
||||||
|
for (let i = 0; i < tabs.length; i++) {
|
||||||
|
const tab = tabs[i];
|
||||||
|
const current = tab.isCurrentTab() ? ' (current)' : '';
|
||||||
|
lines.push(`- ${i}:${current} [${tab.lastTitle()}] (${tab.page.url()})`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trim(text: string, maxLength: number) {
|
||||||
|
if (text.length <= maxLength)
|
||||||
|
return text;
|
||||||
|
return text.slice(0, maxLength) + '...';
|
||||||
|
}
|
||||||
108
src/server.ts
108
src/server.ts
@@ -1,108 +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 { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
||||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
||||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
||||||
|
|
||||||
import { Context } from './context';
|
|
||||||
|
|
||||||
import type { Tool } from './tools/tool';
|
|
||||||
import type { Config } from '../config';
|
|
||||||
|
|
||||||
type MCPServerOptions = {
|
|
||||||
name: string;
|
|
||||||
version: string;
|
|
||||||
tools: Tool[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createServerWithTools(serverOptions: MCPServerOptions, config: Config): Server {
|
|
||||||
const { name, version, tools } = serverOptions;
|
|
||||||
const context = new Context(tools, config);
|
|
||||||
const server = new Server({ name, version }, {
|
|
||||||
capabilities: {
|
|
||||||
tools: {},
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
||||||
return {
|
|
||||||
tools: tools.map(tool => ({
|
|
||||||
name: tool.schema.name,
|
|
||||||
description: tool.schema.description,
|
|
||||||
inputSchema: zodToJsonSchema(tool.schema.inputSchema)
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
server.setRequestHandler(CallToolRequestSchema, async request => {
|
|
||||||
const errorResult = (...messages: string[]) => ({
|
|
||||||
content: [{ type: 'text', text: messages.join('\n') }],
|
|
||||||
isError: true,
|
|
||||||
});
|
|
||||||
const tool = tools.find(tool => tool.schema.name === request.params.name);
|
|
||||||
if (!tool)
|
|
||||||
return errorResult(`Tool "${request.params.name}" not found`);
|
|
||||||
|
|
||||||
|
|
||||||
const modalStates = context.modalStates().map(state => state.type);
|
|
||||||
if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState))
|
|
||||||
return errorResult(`The tool "${request.params.name}" can only be used when there is related modal state present.`, ...context.modalStatesMarkdown());
|
|
||||||
if (!tool.clearsModalState && modalStates.length)
|
|
||||||
return errorResult(`Tool "${request.params.name}" does not handle the modal state.`, ...context.modalStatesMarkdown());
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await context.run(tool, request.params.arguments);
|
|
||||||
} catch (error) {
|
|
||||||
return errorResult(String(error));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const oldClose = server.close.bind(server);
|
|
||||||
|
|
||||||
server.close = async () => {
|
|
||||||
await oldClose();
|
|
||||||
await context.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ServerList {
|
|
||||||
private _servers: Server[] = [];
|
|
||||||
private _serverFactory: () => Promise<Server>;
|
|
||||||
|
|
||||||
constructor(serverFactory: () => Promise<Server>) {
|
|
||||||
this._serverFactory = serverFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
async create() {
|
|
||||||
const server = await this._serverFactory();
|
|
||||||
this._servers.push(server);
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
|
|
||||||
async close(server: Server) {
|
|
||||||
const index = this._servers.indexOf(server);
|
|
||||||
if (index !== -1)
|
|
||||||
this._servers.splice(index, 1);
|
|
||||||
await server.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
async closeAll() {
|
|
||||||
await Promise.all(this._servers.map(server => server.close()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
176
src/sessionLog.ts
Normal file
176
src/sessionLog.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* 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 { Response } from './response.js';
|
||||||
|
import { logUnhandledError } from './utils/log.js';
|
||||||
|
import { outputFile } from './config.js';
|
||||||
|
|
||||||
|
import type { FullConfig } from './config.js';
|
||||||
|
import type * as actions from './actions.js';
|
||||||
|
import type { Tab, TabSnapshot } from './tab.js';
|
||||||
|
|
||||||
|
type LogEntry = {
|
||||||
|
timestamp: number;
|
||||||
|
toolCall?: {
|
||||||
|
toolName: string;
|
||||||
|
toolArgs: Record<string, any>;
|
||||||
|
result: string;
|
||||||
|
isError?: boolean;
|
||||||
|
};
|
||||||
|
userAction?: actions.Action;
|
||||||
|
code: string;
|
||||||
|
tabSnapshot?: TabSnapshot;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SessionLog {
|
||||||
|
private _folder: string;
|
||||||
|
private _file: string;
|
||||||
|
private _ordinal = 0;
|
||||||
|
private _pendingEntries: LogEntry[] = [];
|
||||||
|
private _sessionFileQueue = Promise.resolve();
|
||||||
|
private _flushEntriesTimeout: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
constructor(sessionFolder: string) {
|
||||||
|
this._folder = sessionFolder;
|
||||||
|
this._file = path.join(this._folder, 'session.md');
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(config: FullConfig, rootPath: string | undefined): Promise<SessionLog> {
|
||||||
|
const sessionFolder = await outputFile(config, rootPath, `session-${Date.now()}`);
|
||||||
|
await fs.promises.mkdir(sessionFolder, { recursive: true });
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`Session: ${sessionFolder}`);
|
||||||
|
return new SessionLog(sessionFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
logResponse(response: Response) {
|
||||||
|
const entry: LogEntry = {
|
||||||
|
timestamp: performance.now(),
|
||||||
|
toolCall: {
|
||||||
|
toolName: response.toolName,
|
||||||
|
toolArgs: response.toolArgs,
|
||||||
|
result: response.result(),
|
||||||
|
isError: response.isError(),
|
||||||
|
},
|
||||||
|
code: response.code(),
|
||||||
|
tabSnapshot: response.tabSnapshot(),
|
||||||
|
};
|
||||||
|
this._appendEntry(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
logUserAction(action: actions.Action, tab: Tab, code: string, isUpdate: boolean) {
|
||||||
|
code = code.trim();
|
||||||
|
if (isUpdate) {
|
||||||
|
const lastEntry = this._pendingEntries[this._pendingEntries.length - 1];
|
||||||
|
if (lastEntry.userAction?.name === action.name) {
|
||||||
|
lastEntry.userAction = action;
|
||||||
|
lastEntry.code = code;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (action.name === 'navigate') {
|
||||||
|
// Already logged at this location.
|
||||||
|
const lastEntry = this._pendingEntries[this._pendingEntries.length - 1];
|
||||||
|
if (lastEntry?.tabSnapshot?.url === action.url)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entry: LogEntry = {
|
||||||
|
timestamp: performance.now(),
|
||||||
|
userAction: action,
|
||||||
|
code,
|
||||||
|
tabSnapshot: {
|
||||||
|
url: tab.page.url(),
|
||||||
|
title: '',
|
||||||
|
ariaSnapshot: action.ariaSnapshot || '',
|
||||||
|
modalStates: [],
|
||||||
|
consoleMessages: [],
|
||||||
|
downloads: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this._appendEntry(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _appendEntry(entry: LogEntry) {
|
||||||
|
this._pendingEntries.push(entry);
|
||||||
|
if (this._flushEntriesTimeout)
|
||||||
|
clearTimeout(this._flushEntriesTimeout);
|
||||||
|
this._flushEntriesTimeout = setTimeout(() => this._flushEntries(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _flushEntries() {
|
||||||
|
clearTimeout(this._flushEntriesTimeout);
|
||||||
|
const entries = this._pendingEntries;
|
||||||
|
this._pendingEntries = [];
|
||||||
|
const lines: string[] = [''];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const ordinal = (++this._ordinal).toString().padStart(3, '0');
|
||||||
|
if (entry.toolCall) {
|
||||||
|
lines.push(
|
||||||
|
`### Tool call: ${entry.toolCall.toolName}`,
|
||||||
|
`- Args`,
|
||||||
|
'```json',
|
||||||
|
JSON.stringify(entry.toolCall.toolArgs, null, 2),
|
||||||
|
'```',
|
||||||
|
);
|
||||||
|
if (entry.toolCall.result) {
|
||||||
|
lines.push(
|
||||||
|
entry.toolCall.isError ? `- Error` : `- Result`,
|
||||||
|
'```',
|
||||||
|
entry.toolCall.result,
|
||||||
|
'```',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.userAction) {
|
||||||
|
const actionData = { ...entry.userAction } as any;
|
||||||
|
delete actionData.ariaSnapshot;
|
||||||
|
delete actionData.selector;
|
||||||
|
delete actionData.signals;
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
`### User action: ${entry.userAction.name}`,
|
||||||
|
`- Args`,
|
||||||
|
'```json',
|
||||||
|
JSON.stringify(actionData, null, 2),
|
||||||
|
'```',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.code) {
|
||||||
|
lines.push(
|
||||||
|
`- Code`,
|
||||||
|
'```js',
|
||||||
|
entry.code,
|
||||||
|
'```');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.tabSnapshot) {
|
||||||
|
const fileName = `${ordinal}.snapshot.yml`;
|
||||||
|
fs.promises.writeFile(path.join(this._folder, fileName), entry.tabSnapshot.ariaSnapshot).catch(logUnhandledError);
|
||||||
|
lines.push(`- Snapshot: ${fileName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
this._sessionFileQueue = this._sessionFileQueue.then(() => fs.promises.appendFile(this._file, lines.join('\n')));
|
||||||
|
}
|
||||||
|
}
|
||||||
281
src/tab.ts
281
src/tab.ts
@@ -14,79 +14,300 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
|
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
||||||
|
import { logUnhandledError } from './utils/log.js';
|
||||||
|
import { ManualPromise } from './utils/manualPromise.js';
|
||||||
|
import { ModalState } from './tools/tool.js';
|
||||||
|
|
||||||
import { PageSnapshot } from './pageSnapshot';
|
import type { Context } from './context.js';
|
||||||
|
|
||||||
import type { Context } from './context';
|
type PageEx = playwright.Page & {
|
||||||
|
_snapshotForAI: () => Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
export class Tab {
|
export const TabEvents = {
|
||||||
|
modalState: 'modalState'
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TabEventsInterface = {
|
||||||
|
[TabEvents.modalState]: [modalState: ModalState];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TabSnapshot = {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
ariaSnapshot: string;
|
||||||
|
modalStates: ModalState[];
|
||||||
|
consoleMessages: ConsoleMessage[];
|
||||||
|
downloads: { download: playwright.Download, finished: boolean, outputFile: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Tab extends EventEmitter<TabEventsInterface> {
|
||||||
readonly context: Context;
|
readonly context: Context;
|
||||||
readonly page: playwright.Page;
|
readonly page: playwright.Page;
|
||||||
private _console: playwright.ConsoleMessage[] = [];
|
private _lastTitle = 'about:blank';
|
||||||
|
private _consoleMessages: ConsoleMessage[] = [];
|
||||||
|
private _recentConsoleMessages: ConsoleMessage[] = [];
|
||||||
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
|
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
|
||||||
private _snapshot: PageSnapshot | undefined;
|
|
||||||
private _onPageClose: (tab: Tab) => void;
|
private _onPageClose: (tab: Tab) => void;
|
||||||
|
private _modalStates: ModalState[] = [];
|
||||||
|
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
|
||||||
|
|
||||||
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
|
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
|
||||||
|
super();
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.page = page;
|
this.page = page;
|
||||||
this._onPageClose = onPageClose;
|
this._onPageClose = onPageClose;
|
||||||
page.on('console', event => this._console.push(event));
|
page.on('console', event => this._handleConsoleMessage(messageToConsoleMessage(event)));
|
||||||
|
page.on('pageerror', error => this._handleConsoleMessage(pageErrorToConsoleMessage(error)));
|
||||||
page.on('request', request => this._requests.set(request, null));
|
page.on('request', request => this._requests.set(request, null));
|
||||||
page.on('response', response => this._requests.set(response.request(), response));
|
page.on('response', response => this._requests.set(response.request(), response));
|
||||||
page.on('framenavigated', frame => {
|
|
||||||
if (!frame.parentFrame())
|
|
||||||
this._clearCollectedArtifacts();
|
|
||||||
});
|
|
||||||
page.on('close', () => this._onClose());
|
page.on('close', () => this._onClose());
|
||||||
page.on('filechooser', chooser => {
|
page.on('filechooser', chooser => {
|
||||||
this.context.setModalState({
|
this.setModalState({
|
||||||
type: 'fileChooser',
|
type: 'fileChooser',
|
||||||
description: 'File chooser',
|
description: 'File chooser',
|
||||||
fileChooser: chooser,
|
fileChooser: chooser,
|
||||||
}, this);
|
});
|
||||||
|
});
|
||||||
|
page.on('dialog', dialog => this._dialogShown(dialog));
|
||||||
|
page.on('download', download => {
|
||||||
|
void this._downloadStarted(download);
|
||||||
});
|
});
|
||||||
page.on('dialog', dialog => this.context.dialogShown(this, dialog));
|
|
||||||
page.setDefaultNavigationTimeout(60000);
|
page.setDefaultNavigationTimeout(60000);
|
||||||
page.setDefaultTimeout(5000);
|
page.setDefaultTimeout(5000);
|
||||||
|
(page as any)[tabSymbol] = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static forPage(page: playwright.Page): Tab | undefined {
|
||||||
|
return (page as any)[tabSymbol];
|
||||||
|
}
|
||||||
|
|
||||||
|
modalStates(): ModalState[] {
|
||||||
|
return this._modalStates;
|
||||||
|
}
|
||||||
|
|
||||||
|
setModalState(modalState: ModalState) {
|
||||||
|
this._modalStates.push(modalState);
|
||||||
|
this.emit(TabEvents.modalState, modalState);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearModalState(modalState: ModalState) {
|
||||||
|
this._modalStates = this._modalStates.filter(state => state !== modalState);
|
||||||
|
}
|
||||||
|
|
||||||
|
modalStatesMarkdown(): string[] {
|
||||||
|
return renderModalStates(this.context, this.modalStates());
|
||||||
|
}
|
||||||
|
|
||||||
|
private _dialogShown(dialog: playwright.Dialog) {
|
||||||
|
this.setModalState({
|
||||||
|
type: 'dialog',
|
||||||
|
description: `"${dialog.type()}" dialog with message "${dialog.message()}"`,
|
||||||
|
dialog,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _downloadStarted(download: playwright.Download) {
|
||||||
|
const entry = {
|
||||||
|
download,
|
||||||
|
finished: false,
|
||||||
|
outputFile: await this.context.outputFile(download.suggestedFilename())
|
||||||
|
};
|
||||||
|
this._downloads.push(entry);
|
||||||
|
await download.saveAs(entry.outputFile);
|
||||||
|
entry.finished = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _clearCollectedArtifacts() {
|
private _clearCollectedArtifacts() {
|
||||||
this._console.length = 0;
|
this._consoleMessages.length = 0;
|
||||||
|
this._recentConsoleMessages.length = 0;
|
||||||
this._requests.clear();
|
this._requests.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _handleConsoleMessage(message: ConsoleMessage) {
|
||||||
|
this._consoleMessages.push(message);
|
||||||
|
this._recentConsoleMessages.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
private _onClose() {
|
private _onClose() {
|
||||||
this._clearCollectedArtifacts();
|
this._clearCollectedArtifacts();
|
||||||
this._onPageClose(this);
|
this._onPageClose(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateTitle() {
|
||||||
|
await this._raceAgainstModalStates(async () => {
|
||||||
|
this._lastTitle = await callOnPageNoTrace(this.page, page => page.title());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
lastTitle(): string {
|
||||||
|
return this._lastTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
isCurrentTab(): boolean {
|
||||||
|
return this === this.context.currentTab();
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> {
|
||||||
|
await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(logUnhandledError));
|
||||||
|
}
|
||||||
|
|
||||||
async navigate(url: string) {
|
async navigate(url: string) {
|
||||||
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
this._clearCollectedArtifacts();
|
||||||
|
|
||||||
|
const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(logUnhandledError));
|
||||||
|
try {
|
||||||
|
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||||
|
} catch (_e: unknown) {
|
||||||
|
const e = _e as Error;
|
||||||
|
const mightBeDownload =
|
||||||
|
e.message.includes('net::ERR_ABORTED') // chromium
|
||||||
|
|| e.message.includes('Download is starting'); // firefox + webkit
|
||||||
|
if (!mightBeDownload)
|
||||||
|
throw e;
|
||||||
|
// on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit
|
||||||
|
const download = await Promise.race([
|
||||||
|
downloadEvent,
|
||||||
|
new Promise(resolve => setTimeout(resolve, 3000)),
|
||||||
|
]);
|
||||||
|
if (!download)
|
||||||
|
throw e;
|
||||||
|
// Make sure other "download" listeners are notified first.
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Cap load event to 5 seconds, the page is operational at this point.
|
// Cap load event to 5 seconds, the page is operational at this point.
|
||||||
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
await this.waitForLoadState('load', { timeout: 5000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
hasSnapshot(): boolean {
|
consoleMessages(): ConsoleMessage[] {
|
||||||
return !!this._snapshot;
|
return this._consoleMessages;
|
||||||
}
|
|
||||||
|
|
||||||
snapshotOrDie(): PageSnapshot {
|
|
||||||
if (!this._snapshot)
|
|
||||||
throw new Error('No snapshot available');
|
|
||||||
return this._snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
console(): playwright.ConsoleMessage[] {
|
|
||||||
return this._console;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
requests(): Map<playwright.Request, playwright.Response | null> {
|
requests(): Map<playwright.Request, playwright.Response | null> {
|
||||||
return this._requests;
|
return this._requests;
|
||||||
}
|
}
|
||||||
|
|
||||||
async captureSnapshot() {
|
async captureSnapshot(): Promise<TabSnapshot> {
|
||||||
this._snapshot = await PageSnapshot.create(this.page);
|
let tabSnapshot: TabSnapshot | undefined;
|
||||||
|
const modalStates = await this._raceAgainstModalStates(async () => {
|
||||||
|
const snapshot = await (this.page as PageEx)._snapshotForAI();
|
||||||
|
tabSnapshot = {
|
||||||
|
url: this.page.url(),
|
||||||
|
title: await this.page.title(),
|
||||||
|
ariaSnapshot: snapshot,
|
||||||
|
modalStates: [],
|
||||||
|
consoleMessages: [],
|
||||||
|
downloads: this._downloads,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (tabSnapshot) {
|
||||||
|
// Assign console message late so that we did not lose any to modal state.
|
||||||
|
tabSnapshot.consoleMessages = this._recentConsoleMessages;
|
||||||
|
this._recentConsoleMessages = [];
|
||||||
|
}
|
||||||
|
return tabSnapshot ?? {
|
||||||
|
url: this.page.url(),
|
||||||
|
title: '',
|
||||||
|
ariaSnapshot: '',
|
||||||
|
modalStates,
|
||||||
|
consoleMessages: [],
|
||||||
|
downloads: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private _javaScriptBlocked(): boolean {
|
||||||
|
return this._modalStates.some(state => state.type === 'dialog');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _raceAgainstModalStates(action: () => Promise<void>): Promise<ModalState[]> {
|
||||||
|
if (this.modalStates().length)
|
||||||
|
return this.modalStates();
|
||||||
|
|
||||||
|
const promise = new ManualPromise<ModalState[]>();
|
||||||
|
const listener = (modalState: ModalState) => promise.resolve([modalState]);
|
||||||
|
this.once(TabEvents.modalState, listener);
|
||||||
|
|
||||||
|
return await Promise.race([
|
||||||
|
action().then(() => {
|
||||||
|
this.off(TabEvents.modalState, listener);
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
promise,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForCompletion(callback: () => Promise<void>) {
|
||||||
|
await this._raceAgainstModalStates(() => waitForCompletion(this, callback));
|
||||||
|
}
|
||||||
|
|
||||||
|
async refLocator(params: { element: string, ref: string }): Promise<playwright.Locator> {
|
||||||
|
return (await this.refLocators([params]))[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async refLocators(params: { element: string, ref: string }[]): Promise<playwright.Locator[]> {
|
||||||
|
const snapshot = await (this.page as PageEx)._snapshotForAI();
|
||||||
|
return params.map(param => {
|
||||||
|
if (!snapshot.includes(`[ref=${param.ref}]`))
|
||||||
|
throw new Error(`Ref ${param.ref} not found in the current page snapshot. Try capturing new snapshot.`);
|
||||||
|
return this.page.locator(`aria-ref=${param.ref}`).describe(param.element);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForTimeout(time: number) {
|
||||||
|
if (this._javaScriptBlocked()) {
|
||||||
|
await new Promise(f => setTimeout(f, time));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await callOnPageNoTrace(this.page, page => {
|
||||||
|
return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ConsoleMessage = {
|
||||||
|
type: ReturnType<playwright.ConsoleMessage['type']> | undefined;
|
||||||
|
text: string;
|
||||||
|
toString(): string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function messageToConsoleMessage(message: playwright.ConsoleMessage): ConsoleMessage {
|
||||||
|
return {
|
||||||
|
type: message.type(),
|
||||||
|
text: message.text(),
|
||||||
|
toString: () => `[${message.type().toUpperCase()}] ${message.text()} @ ${message.location().url}:${message.location().lineNumber}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageErrorToConsoleMessage(errorOrValue: Error | any): ConsoleMessage {
|
||||||
|
if (errorOrValue instanceof Error) {
|
||||||
|
return {
|
||||||
|
type: undefined,
|
||||||
|
text: errorOrValue.message,
|
||||||
|
toString: () => errorOrValue.stack || errorOrValue.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: undefined,
|
||||||
|
text: String(errorOrValue),
|
||||||
|
toString: () => String(errorOrValue),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderModalStates(context: Context, modalStates: ModalState[]): string[] {
|
||||||
|
const result: string[] = ['### Modal state'];
|
||||||
|
if (modalStates.length === 0)
|
||||||
|
result.push('- There is no modal state present');
|
||||||
|
for (const state of modalStates) {
|
||||||
|
const tool = context.tools.filter(tool => 'clearsModalState' in tool).find(tool => tool.clearsModalState === state.type);
|
||||||
|
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabSymbol = Symbol('tabSymbol');
|
||||||
|
|||||||
56
src/tools.ts
Normal file
56
src/tools.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* 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 common from './tools/common.js';
|
||||||
|
import console from './tools/console.js';
|
||||||
|
import dialogs from './tools/dialogs.js';
|
||||||
|
import evaluate from './tools/evaluate.js';
|
||||||
|
import files from './tools/files.js';
|
||||||
|
import install from './tools/install.js';
|
||||||
|
import keyboard from './tools/keyboard.js';
|
||||||
|
import navigate from './tools/navigate.js';
|
||||||
|
import network from './tools/network.js';
|
||||||
|
import pdf from './tools/pdf.js';
|
||||||
|
import snapshot from './tools/snapshot.js';
|
||||||
|
import tabs from './tools/tabs.js';
|
||||||
|
import screenshot from './tools/screenshot.js';
|
||||||
|
import wait from './tools/wait.js';
|
||||||
|
import mouse from './tools/mouse.js';
|
||||||
|
|
||||||
|
import type { Tool } from './tools/tool.js';
|
||||||
|
import type { FullConfig } from './config.js';
|
||||||
|
|
||||||
|
export const allTools: Tool<any>[] = [
|
||||||
|
...common,
|
||||||
|
...console,
|
||||||
|
...dialogs,
|
||||||
|
...evaluate,
|
||||||
|
...files,
|
||||||
|
...install,
|
||||||
|
...keyboard,
|
||||||
|
...navigate,
|
||||||
|
...network,
|
||||||
|
...mouse,
|
||||||
|
...pdf,
|
||||||
|
...screenshot,
|
||||||
|
...snapshot,
|
||||||
|
...tabs,
|
||||||
|
...wait,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function filteredTools(config: FullConfig) {
|
||||||
|
return allTools.filter(tool => tool.capability.startsWith('core') || config.capabilities?.includes(tool.capability));
|
||||||
|
}
|
||||||
2
src/tools/DEPS.list
Normal file
2
src/tools/DEPS.list
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[*]
|
||||||
|
../utils/
|
||||||
@@ -15,82 +15,49 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool, type ToolFactory } from './tool';
|
import { defineTabTool, defineTool } from './tool.js';
|
||||||
|
|
||||||
const wait: ToolFactory = captureSnapshot => defineTool({
|
|
||||||
capability: 'wait',
|
|
||||||
|
|
||||||
schema: {
|
|
||||||
name: 'browser_wait',
|
|
||||||
description: 'Wait for a specified time in seconds',
|
|
||||||
inputSchema: z.object({
|
|
||||||
time: z.number().describe('The time to wait in seconds'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
await new Promise(f => setTimeout(f, Math.min(10000, params.time * 1000)));
|
|
||||||
return {
|
|
||||||
code: [`// Waited for ${params.time} seconds`],
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const close = defineTool({
|
const close = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_close',
|
name: 'browser_close',
|
||||||
|
title: 'Close browser',
|
||||||
description: 'Close the page',
|
description: 'Close the page',
|
||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async (context, params, response) => {
|
||||||
await context.close();
|
await context.closeBrowserContext();
|
||||||
return {
|
response.setIncludeTabs();
|
||||||
code: [`// Internal to close the page`],
|
response.addCode(`await page.close()`);
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const resize: ToolFactory = captureSnapshot => defineTool({
|
const resize = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_resize',
|
name: 'browser_resize',
|
||||||
|
title: 'Resize browser window',
|
||||||
description: 'Resize the browser window',
|
description: 'Resize the browser window',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
width: z.number().describe('Width of the browser window'),
|
width: z.number().describe('Width of the browser window'),
|
||||||
height: z.number().describe('Height of the browser window'),
|
height: z.number().describe('Height of the browser window'),
|
||||||
}),
|
}),
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params, response) => {
|
||||||
const tab = context.currentTabOrDie();
|
response.addCode(`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`);
|
||||||
|
|
||||||
const code = [
|
await tab.waitForCompletion(async () => {
|
||||||
`// Resize browser window to ${params.width}x${params.height}`,
|
|
||||||
`await page.setViewportSize({ width: ${params.width}, height: ${params.height} });`
|
|
||||||
];
|
|
||||||
|
|
||||||
const action = async () => {
|
|
||||||
await tab.page.setViewportSize({ width: params.width, height: params.height });
|
await tab.page.setViewportSize({ width: params.width, height: params.height });
|
||||||
};
|
});
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: true
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default (captureSnapshot: boolean) => [
|
export default [
|
||||||
close,
|
close,
|
||||||
wait(captureSnapshot),
|
resize
|
||||||
resize(captureSnapshot)
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -15,28 +15,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool } from './tool';
|
import { defineTabTool } from './tool.js';
|
||||||
|
|
||||||
const console = defineTool({
|
const console = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_console_messages',
|
name: 'browser_console_messages',
|
||||||
|
title: 'Get console messages',
|
||||||
description: 'Returns all console messages',
|
description: 'Returns all console messages',
|
||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
handle: async context => {
|
handle: async (tab, params, response) => {
|
||||||
const messages = context.currentTabOrDie().console();
|
tab.consoleMessages().map(message => response.addResult(message.toString()));
|
||||||
const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
|
|
||||||
return {
|
|
||||||
code: [`// <internal code to get console messages>`],
|
|
||||||
action: async () => {
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text', text: log }]
|
|
||||||
};
|
|
||||||
},
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,46 +15,41 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool, type ToolFactory } from './tool';
|
import { defineTabTool } from './tool.js';
|
||||||
|
|
||||||
const handleDialog: ToolFactory = captureSnapshot => defineTool({
|
const handleDialog = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_handle_dialog',
|
name: 'browser_handle_dialog',
|
||||||
|
title: 'Handle a dialog',
|
||||||
description: 'Handle a dialog',
|
description: 'Handle a dialog',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
accept: z.boolean().describe('Whether to accept the dialog.'),
|
accept: z.boolean().describe('Whether to accept the dialog.'),
|
||||||
promptText: z.string().optional().describe('The text of the prompt in case of a prompt dialog.'),
|
promptText: z.string().optional().describe('The text of the prompt in case of a prompt dialog.'),
|
||||||
}),
|
}),
|
||||||
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params, response) => {
|
||||||
const dialogState = context.modalStates().find(state => state.type === 'dialog');
|
response.setIncludeSnapshot();
|
||||||
|
|
||||||
|
const dialogState = tab.modalStates().find(state => state.type === 'dialog');
|
||||||
if (!dialogState)
|
if (!dialogState)
|
||||||
throw new Error('No dialog visible');
|
throw new Error('No dialog visible');
|
||||||
|
|
||||||
if (params.accept)
|
tab.clearModalState(dialogState);
|
||||||
await dialogState.dialog.accept(params.promptText);
|
await tab.waitForCompletion(async () => {
|
||||||
else
|
if (params.accept)
|
||||||
await dialogState.dialog.dismiss();
|
await dialogState.dialog.accept(params.promptText);
|
||||||
|
else
|
||||||
context.clearModalState(dialogState);
|
await dialogState.dialog.dismiss();
|
||||||
|
});
|
||||||
const code = [
|
|
||||||
`// <internal code to handle "${dialogState.dialog.type()}" dialog>`,
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
|
|
||||||
clearsModalState: 'dialog',
|
clearsModalState: 'dialog',
|
||||||
});
|
});
|
||||||
|
|
||||||
export default (captureSnapshot: boolean) => [
|
export default [
|
||||||
handleDialog(captureSnapshot),
|
handleDialog,
|
||||||
];
|
];
|
||||||
|
|||||||
62
src/tools/evaluate.ts
Normal file
62
src/tools/evaluate.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* 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 { defineTabTool } from './tool.js';
|
||||||
|
import * as javascript from '../utils/codegen.js';
|
||||||
|
import { generateLocator } from './utils.js';
|
||||||
|
|
||||||
|
import type * as playwright from 'playwright';
|
||||||
|
|
||||||
|
const evaluateSchema = z.object({
|
||||||
|
function: z.string().describe('() => { /* code */ } or (element) => { /* code */ } when element is provided'),
|
||||||
|
element: z.string().optional().describe('Human-readable element description used to obtain permission to interact with the element'),
|
||||||
|
ref: z.string().optional().describe('Exact target element reference from the page snapshot'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const evaluate = defineTabTool({
|
||||||
|
capability: 'core',
|
||||||
|
schema: {
|
||||||
|
name: 'browser_evaluate',
|
||||||
|
title: 'Evaluate JavaScript',
|
||||||
|
description: 'Evaluate JavaScript expression on page or element',
|
||||||
|
inputSchema: evaluateSchema,
|
||||||
|
type: 'destructive',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (tab, params, response) => {
|
||||||
|
response.setIncludeSnapshot();
|
||||||
|
|
||||||
|
let locator: playwright.Locator | undefined;
|
||||||
|
if (params.ref && params.element) {
|
||||||
|
locator = await tab.refLocator({ ref: params.ref, element: params.element });
|
||||||
|
response.addCode(`await page.${await generateLocator(locator)}.evaluate(${javascript.quote(params.function)});`);
|
||||||
|
} else {
|
||||||
|
response.addCode(`await page.evaluate(${javascript.quote(params.function)});`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tab.waitForCompletion(async () => {
|
||||||
|
const receiver = locator ?? tab.page as any;
|
||||||
|
const result = await receiver._evaluateFunction(params.function);
|
||||||
|
response.addResult(JSON.stringify(result, null, 2) || 'undefined');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default [
|
||||||
|
evaluate,
|
||||||
|
];
|
||||||
@@ -15,43 +15,38 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool, type ToolFactory } from './tool';
|
import { defineTabTool } from './tool.js';
|
||||||
|
|
||||||
const uploadFile: ToolFactory = captureSnapshot => defineTool({
|
const uploadFile = defineTabTool({
|
||||||
capability: 'files',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_file_upload',
|
name: 'browser_file_upload',
|
||||||
|
title: 'Upload files',
|
||||||
description: 'Upload one or multiple files',
|
description: 'Upload one or multiple files',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'),
|
paths: z.array(z.string()).describe('The absolute paths to the files to upload. Can be a single file or multiple files.'),
|
||||||
}),
|
}),
|
||||||
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params, response) => {
|
||||||
const modalState = context.modalStates().find(state => state.type === 'fileChooser');
|
response.setIncludeSnapshot();
|
||||||
|
|
||||||
|
const modalState = tab.modalStates().find(state => state.type === 'fileChooser');
|
||||||
if (!modalState)
|
if (!modalState)
|
||||||
throw new Error('No file chooser visible');
|
throw new Error('No file chooser visible');
|
||||||
|
|
||||||
const code = [
|
response.addCode(`await fileChooser.setFiles(${JSON.stringify(params.paths)})`);
|
||||||
`// <internal code to chose files ${params.paths.join(', ')}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
const action = async () => {
|
tab.clearModalState(modalState);
|
||||||
|
await tab.waitForCompletion(async () => {
|
||||||
await modalState.fileChooser.setFiles(params.paths);
|
await modalState.fileChooser.setFiles(params.paths);
|
||||||
context.clearModalState(modalState);
|
});
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: true,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
clearsModalState: 'fileChooser',
|
clearsModalState: 'fileChooser',
|
||||||
});
|
});
|
||||||
|
|
||||||
export default (captureSnapshot: boolean) => [
|
export default [
|
||||||
uploadFile(captureSnapshot),
|
uploadFile,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -16,22 +16,26 @@
|
|||||||
|
|
||||||
import { fork } from 'child_process';
|
import { fork } from 'child_process';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool } from './tool';
|
import { defineTool } from './tool.js';
|
||||||
|
|
||||||
|
|
||||||
const install = defineTool({
|
const install = defineTool({
|
||||||
capability: 'install',
|
capability: 'core-install',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_install',
|
name: 'browser_install',
|
||||||
|
title: 'Install the browser specified in the config',
|
||||||
description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.',
|
description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.',
|
||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async (context, params, response) => {
|
||||||
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.launchOptions.browserName ?? 'chrome';
|
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.browserName ?? 'chrome';
|
||||||
const cli = path.join(require.resolve('playwright/package.json'), '..', 'cli.js');
|
const cliUrl = import.meta.resolve('playwright/package.json');
|
||||||
const child = fork(cli, ['install', channel], {
|
const cliPath = path.join(fileURLToPath(cliUrl), '..', 'cli.js');
|
||||||
|
const child = fork(cliPath, ['install', channel], {
|
||||||
stdio: 'pipe',
|
stdio: 'pipe',
|
||||||
});
|
});
|
||||||
const output: string[] = [];
|
const output: string[] = [];
|
||||||
@@ -45,11 +49,7 @@ const install = defineTool({
|
|||||||
reject(new Error(`Failed to install browser: ${output.join('')}`));
|
reject(new Error(`Failed to install browser: ${output.join('')}`));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return {
|
response.setIncludeTabs();
|
||||||
code: [`// Browser ${channel} installed`],
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,38 +15,75 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool, type ToolFactory } from './tool';
|
|
||||||
|
|
||||||
const pressKey: ToolFactory = captureSnapshot => defineTool({
|
import { defineTabTool } from './tool.js';
|
||||||
|
import { elementSchema } from './snapshot.js';
|
||||||
|
import { generateLocator } from './utils.js';
|
||||||
|
import * as javascript from '../utils/codegen.js';
|
||||||
|
|
||||||
|
const pressKey = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_press_key',
|
name: 'browser_press_key',
|
||||||
|
title: 'Press a key',
|
||||||
description: 'Press a key on the keyboard',
|
description: 'Press a key on the keyboard',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'),
|
key: z.string().describe('Name of the key to press or a character to generate, such as `ArrowLeft` or `a`'),
|
||||||
}),
|
}),
|
||||||
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params, response) => {
|
||||||
const tab = context.currentTabOrDie();
|
response.setIncludeSnapshot();
|
||||||
|
response.addCode(`// Press ${params.key}`);
|
||||||
|
response.addCode(`await page.keyboard.press('${params.key}');`);
|
||||||
|
|
||||||
const code = [
|
await tab.waitForCompletion(async () => {
|
||||||
`// Press ${params.key}`,
|
await tab.page.keyboard.press(params.key);
|
||||||
`await page.keyboard.press('${params.key}');`,
|
});
|
||||||
];
|
|
||||||
|
|
||||||
const action = () => tab.page.keyboard.press(params.key);
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: true
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default (captureSnapshot: boolean) => [
|
const typeSchema = elementSchema.extend({
|
||||||
pressKey(captureSnapshot),
|
text: z.string().describe('Text to type into the element'),
|
||||||
|
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
|
||||||
|
slowly: z.boolean().optional().describe('Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const type = defineTabTool({
|
||||||
|
capability: 'core',
|
||||||
|
schema: {
|
||||||
|
name: 'browser_type',
|
||||||
|
title: 'Type text',
|
||||||
|
description: 'Type text into editable element',
|
||||||
|
inputSchema: typeSchema,
|
||||||
|
type: 'destructive',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (tab, params, response) => {
|
||||||
|
const locator = await tab.refLocator(params);
|
||||||
|
|
||||||
|
await tab.waitForCompletion(async () => {
|
||||||
|
if (params.slowly) {
|
||||||
|
response.setIncludeSnapshot();
|
||||||
|
response.addCode(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
|
||||||
|
await locator.pressSequentially(params.text);
|
||||||
|
} else {
|
||||||
|
response.addCode(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
|
||||||
|
await locator.fill(params.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.submit) {
|
||||||
|
response.setIncludeSnapshot();
|
||||||
|
response.addCode(`await page.${await generateLocator(locator)}.press('Enter');`);
|
||||||
|
await locator.press('Enter');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default [
|
||||||
|
pressKey,
|
||||||
|
type,
|
||||||
];
|
];
|
||||||
|
|||||||
113
src/tools/mouse.ts
Normal file
113
src/tools/mouse.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* 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 { defineTabTool } from './tool.js';
|
||||||
|
|
||||||
|
const elementSchema = z.object({
|
||||||
|
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mouseMove = defineTabTool({
|
||||||
|
capability: 'vision',
|
||||||
|
schema: {
|
||||||
|
name: 'browser_mouse_move_xy',
|
||||||
|
title: 'Move mouse',
|
||||||
|
description: 'Move mouse to a given position',
|
||||||
|
inputSchema: elementSchema.extend({
|
||||||
|
x: z.number().describe('X coordinate'),
|
||||||
|
y: z.number().describe('Y coordinate'),
|
||||||
|
}),
|
||||||
|
type: 'readOnly',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (tab, params, response) => {
|
||||||
|
response.addCode(`// Move mouse to (${params.x}, ${params.y})`);
|
||||||
|
response.addCode(`await page.mouse.move(${params.x}, ${params.y});`);
|
||||||
|
|
||||||
|
await tab.waitForCompletion(async () => {
|
||||||
|
await tab.page.mouse.move(params.x, params.y);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mouseClick = defineTabTool({
|
||||||
|
capability: 'vision',
|
||||||
|
schema: {
|
||||||
|
name: 'browser_mouse_click_xy',
|
||||||
|
title: 'Click',
|
||||||
|
description: 'Click left mouse button at a given position',
|
||||||
|
inputSchema: elementSchema.extend({
|
||||||
|
x: z.number().describe('X coordinate'),
|
||||||
|
y: z.number().describe('Y coordinate'),
|
||||||
|
}),
|
||||||
|
type: 'destructive',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (tab, params, response) => {
|
||||||
|
response.setIncludeSnapshot();
|
||||||
|
|
||||||
|
response.addCode(`// Click mouse at coordinates (${params.x}, ${params.y})`);
|
||||||
|
response.addCode(`await page.mouse.move(${params.x}, ${params.y});`);
|
||||||
|
response.addCode(`await page.mouse.down();`);
|
||||||
|
response.addCode(`await page.mouse.up();`);
|
||||||
|
|
||||||
|
await tab.waitForCompletion(async () => {
|
||||||
|
await tab.page.mouse.move(params.x, params.y);
|
||||||
|
await tab.page.mouse.down();
|
||||||
|
await tab.page.mouse.up();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mouseDrag = defineTabTool({
|
||||||
|
capability: 'vision',
|
||||||
|
schema: {
|
||||||
|
name: 'browser_mouse_drag_xy',
|
||||||
|
title: 'Drag mouse',
|
||||||
|
description: 'Drag left mouse button to a given position',
|
||||||
|
inputSchema: elementSchema.extend({
|
||||||
|
startX: z.number().describe('Start X coordinate'),
|
||||||
|
startY: z.number().describe('Start Y coordinate'),
|
||||||
|
endX: z.number().describe('End X coordinate'),
|
||||||
|
endY: z.number().describe('End Y coordinate'),
|
||||||
|
}),
|
||||||
|
type: 'destructive',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (tab, params, response) => {
|
||||||
|
response.setIncludeSnapshot();
|
||||||
|
|
||||||
|
response.addCode(`// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`);
|
||||||
|
response.addCode(`await page.mouse.move(${params.startX}, ${params.startY});`);
|
||||||
|
response.addCode(`await page.mouse.down();`);
|
||||||
|
response.addCode(`await page.mouse.move(${params.endX}, ${params.endY});`);
|
||||||
|
response.addCode(`await page.mouse.up();`);
|
||||||
|
|
||||||
|
await tab.waitForCompletion(async () => {
|
||||||
|
await tab.page.mouse.move(params.startX, params.startY);
|
||||||
|
await tab.page.mouse.down();
|
||||||
|
await tab.page.mouse.move(params.endX, params.endY);
|
||||||
|
await tab.page.mouse.up();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default [
|
||||||
|
mouseMove,
|
||||||
|
mouseClick,
|
||||||
|
mouseDrag,
|
||||||
|
];
|
||||||
@@ -15,84 +15,65 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool, type ToolFactory } from './tool';
|
import { defineTool, defineTabTool } from './tool.js';
|
||||||
|
|
||||||
const navigate: ToolFactory = captureSnapshot => defineTool({
|
const navigate = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
|
title: 'Navigate to a URL',
|
||||||
description: 'Navigate to a URL',
|
description: 'Navigate to a URL',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
url: z.string().describe('The URL to navigate to'),
|
url: z.string().describe('The URL to navigate to'),
|
||||||
}),
|
}),
|
||||||
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params, response) => {
|
||||||
const tab = await context.ensureTab();
|
const tab = await context.ensureTab();
|
||||||
await tab.navigate(params.url);
|
await tab.navigate(params.url);
|
||||||
|
|
||||||
const code = [
|
response.setIncludeSnapshot();
|
||||||
`// Navigate to ${params.url}`,
|
response.addCode(`await page.goto('${params.url}');`);
|
||||||
`await page.goto('${params.url}');`,
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const goBack: ToolFactory = captureSnapshot => defineTool({
|
const goBack = defineTabTool({
|
||||||
capability: 'history',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_navigate_back',
|
name: 'browser_navigate_back',
|
||||||
|
title: 'Go back',
|
||||||
description: 'Go back to the previous page',
|
description: 'Go back to the previous page',
|
||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async (tab, params, response) => {
|
||||||
const tab = await context.ensureTab();
|
|
||||||
await tab.page.goBack();
|
await tab.page.goBack();
|
||||||
const code = [
|
response.setIncludeSnapshot();
|
||||||
`// Navigate back`,
|
response.addCode(`await page.goBack();`);
|
||||||
`await page.goBack();`,
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const goForward: ToolFactory = captureSnapshot => defineTool({
|
const goForward = defineTabTool({
|
||||||
capability: 'history',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_navigate_forward',
|
name: 'browser_navigate_forward',
|
||||||
|
title: 'Go forward',
|
||||||
description: 'Go forward to the next page',
|
description: 'Go forward to the next page',
|
||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
handle: async context => {
|
handle: async (tab, params, response) => {
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
await tab.page.goForward();
|
await tab.page.goForward();
|
||||||
const code = [
|
response.setIncludeSnapshot();
|
||||||
`// Navigate forward`,
|
response.addCode(`await page.goForward();`);
|
||||||
`await page.goForward();`,
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default (captureSnapshot: boolean) => [
|
export default [
|
||||||
navigate(captureSnapshot),
|
navigate,
|
||||||
goBack(captureSnapshot),
|
goBack,
|
||||||
goForward(captureSnapshot),
|
goForward,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -15,32 +15,24 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool } from './tool';
|
import { defineTabTool } from './tool.js';
|
||||||
|
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
|
|
||||||
const requests = defineTool({
|
const requests = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_network_requests',
|
name: 'browser_network_requests',
|
||||||
|
title: 'List network requests',
|
||||||
description: 'Returns all network requests since loading the page',
|
description: 'Returns all network requests since loading the page',
|
||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async (tab, params, response) => {
|
||||||
const requests = context.currentTabOrDie().requests();
|
const requests = tab.requests();
|
||||||
const log = [...requests.entries()].map(([request, response]) => renderRequest(request, response)).join('\n');
|
[...requests.entries()].forEach(([req, res]) => response.addResult(renderRequest(req, res)));
|
||||||
return {
|
|
||||||
code: [`// <internal code to list network requests>`],
|
|
||||||
action: async () => {
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text', text: log }]
|
|
||||||
};
|
|
||||||
},
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,35 +15,30 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool } from './tool';
|
import { defineTabTool } from './tool.js';
|
||||||
|
|
||||||
import * as javascript from '../javascript';
|
import * as javascript from '../utils/codegen.js';
|
||||||
import { outputFile } from '../config';
|
|
||||||
|
|
||||||
const pdf = defineTool({
|
const pdfSchema = z.object({
|
||||||
|
filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdf = defineTabTool({
|
||||||
capability: 'pdf',
|
capability: 'pdf',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_pdf_save',
|
name: 'browser_pdf_save',
|
||||||
|
title: 'Save as PDF',
|
||||||
description: 'Save page as PDF',
|
description: 'Save page as PDF',
|
||||||
inputSchema: z.object({}),
|
inputSchema: pdfSchema,
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async (tab, params, response) => {
|
||||||
const tab = context.currentTabOrDie();
|
const fileName = await tab.context.outputFile(params.filename ?? `page-${new Date().toISOString()}.pdf`);
|
||||||
const fileName = await outputFile(context.config, `page-${new Date().toISOString()}'.pdf'`);
|
response.addCode(`await page.pdf(${javascript.formatObject({ path: fileName })});`);
|
||||||
|
response.addResult(`Saved page as ${fileName}`);
|
||||||
const code = [
|
await tab.page.pdf({ path: fileName });
|
||||||
`// Save page as ${fileName}`,
|
|
||||||
`await page.pdf(${javascript.formatObject({ path: fileName })});`,
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action: async () => tab.page.pdf({ path: fileName }).then(() => {}),
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,205 +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 { z } from 'zod';
|
|
||||||
import { defineTool } from './tool';
|
|
||||||
|
|
||||||
import * as javascript from '../javascript';
|
|
||||||
|
|
||||||
const elementSchema = z.object({
|
|
||||||
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const screenshot = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_screen_capture',
|
|
||||||
description: 'Take a screenshot of the current page',
|
|
||||||
inputSchema: z.object({}),
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async context => {
|
|
||||||
const tab = await context.ensureTab();
|
|
||||||
const options = { type: 'jpeg' as 'jpeg', quality: 50, scale: 'css' as 'css' };
|
|
||||||
|
|
||||||
const code = [
|
|
||||||
`// Take a screenshot of the current page`,
|
|
||||||
`await page.screenshot(${javascript.formatObject(options)});`,
|
|
||||||
];
|
|
||||||
|
|
||||||
const action = () => tab.page.screenshot(options).then(buffer => {
|
|
||||||
return {
|
|
||||||
content: [{ type: 'image' as 'image', data: buffer.toString('base64'), mimeType: 'image/jpeg' }],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: false
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const moveMouse = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_screen_move_mouse',
|
|
||||||
description: 'Move mouse to a given position',
|
|
||||||
inputSchema: elementSchema.extend({
|
|
||||||
x: z.number().describe('X coordinate'),
|
|
||||||
y: z.number().describe('Y coordinate'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
const code = [
|
|
||||||
`// Move mouse to (${params.x}, ${params.y})`,
|
|
||||||
`await page.mouse.move(${params.x}, ${params.y});`,
|
|
||||||
];
|
|
||||||
const action = () => tab.page.mouse.move(params.x, params.y);
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: false
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const click = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_screen_click',
|
|
||||||
description: 'Click left mouse button',
|
|
||||||
inputSchema: elementSchema.extend({
|
|
||||||
x: z.number().describe('X coordinate'),
|
|
||||||
y: z.number().describe('Y coordinate'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
const code = [
|
|
||||||
`// Click mouse at coordinates (${params.x}, ${params.y})`,
|
|
||||||
`await page.mouse.move(${params.x}, ${params.y});`,
|
|
||||||
`await page.mouse.down();`,
|
|
||||||
`await page.mouse.up();`,
|
|
||||||
];
|
|
||||||
const action = async () => {
|
|
||||||
await tab.page.mouse.move(params.x, params.y);
|
|
||||||
await tab.page.mouse.down();
|
|
||||||
await tab.page.mouse.up();
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const drag = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
|
|
||||||
schema: {
|
|
||||||
name: 'browser_screen_drag',
|
|
||||||
description: 'Drag left mouse button',
|
|
||||||
inputSchema: elementSchema.extend({
|
|
||||||
startX: z.number().describe('Start X coordinate'),
|
|
||||||
startY: z.number().describe('Start Y coordinate'),
|
|
||||||
endX: z.number().describe('End X coordinate'),
|
|
||||||
endY: z.number().describe('End Y coordinate'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
|
|
||||||
const code = [
|
|
||||||
`// Drag mouse from (${params.startX}, ${params.startY}) to (${params.endX}, ${params.endY})`,
|
|
||||||
`await page.mouse.move(${params.startX}, ${params.startY});`,
|
|
||||||
`await page.mouse.down();`,
|
|
||||||
`await page.mouse.move(${params.endX}, ${params.endY});`,
|
|
||||||
`await page.mouse.up();`,
|
|
||||||
];
|
|
||||||
|
|
||||||
const action = async () => {
|
|
||||||
await tab.page.mouse.move(params.startX, params.startY);
|
|
||||||
await tab.page.mouse.down();
|
|
||||||
await tab.page.mouse.move(params.endX, params.endY);
|
|
||||||
await tab.page.mouse.up();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const type = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
|
|
||||||
schema: {
|
|
||||||
name: 'browser_screen_type',
|
|
||||||
description: 'Type text',
|
|
||||||
inputSchema: z.object({
|
|
||||||
text: z.string().describe('Text to type into the element'),
|
|
||||||
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
|
|
||||||
const code = [
|
|
||||||
`// Type ${params.text}`,
|
|
||||||
`await page.keyboard.type('${params.text}');`,
|
|
||||||
];
|
|
||||||
|
|
||||||
const action = async () => {
|
|
||||||
await tab.page.keyboard.type(params.text);
|
|
||||||
if (params.submit)
|
|
||||||
await tab.page.keyboard.press('Enter');
|
|
||||||
};
|
|
||||||
|
|
||||||
if (params.submit) {
|
|
||||||
code.push(`// Submit text`);
|
|
||||||
code.push(`await page.keyboard.press('Enter');`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default [
|
|
||||||
screenshot,
|
|
||||||
moveMouse,
|
|
||||||
click,
|
|
||||||
drag,
|
|
||||||
type,
|
|
||||||
];
|
|
||||||
92
src/tools/screenshot.ts
Normal file
92
src/tools/screenshot.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* 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 { defineTabTool } from './tool.js';
|
||||||
|
import * as javascript from '../utils/codegen.js';
|
||||||
|
import { generateLocator } from './utils.js';
|
||||||
|
|
||||||
|
import type * as playwright from 'playwright';
|
||||||
|
|
||||||
|
const screenshotSchema = z.object({
|
||||||
|
type: z.enum(['png', 'jpeg']).default('png').describe('Image format for the screenshot. Default is png.'),
|
||||||
|
filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.'),
|
||||||
|
element: z.string().optional().describe('Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.'),
|
||||||
|
ref: z.string().optional().describe('Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.'),
|
||||||
|
fullPage: z.boolean().optional().describe('When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots.'),
|
||||||
|
}).refine(data => {
|
||||||
|
return !!data.element === !!data.ref;
|
||||||
|
}, {
|
||||||
|
message: 'Both element and ref must be provided or neither.',
|
||||||
|
path: ['ref', 'element']
|
||||||
|
}).refine(data => {
|
||||||
|
return !(data.fullPage && (data.element || data.ref));
|
||||||
|
}, {
|
||||||
|
message: 'fullPage cannot be used with element screenshots.',
|
||||||
|
path: ['fullPage']
|
||||||
|
});
|
||||||
|
|
||||||
|
const screenshot = defineTabTool({
|
||||||
|
capability: 'core',
|
||||||
|
schema: {
|
||||||
|
name: 'browser_take_screenshot',
|
||||||
|
title: 'Take a screenshot',
|
||||||
|
description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`,
|
||||||
|
inputSchema: screenshotSchema,
|
||||||
|
type: 'readOnly',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (tab, params, response) => {
|
||||||
|
const fileType = params.type || 'png';
|
||||||
|
const fileName = await tab.context.outputFile(params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
|
||||||
|
const options: playwright.PageScreenshotOptions = {
|
||||||
|
type: fileType,
|
||||||
|
quality: fileType === 'png' ? undefined : 90,
|
||||||
|
scale: 'css',
|
||||||
|
path: fileName,
|
||||||
|
...(params.fullPage !== undefined && { fullPage: params.fullPage })
|
||||||
|
};
|
||||||
|
const isElementScreenshot = params.element && params.ref;
|
||||||
|
|
||||||
|
const screenshotTarget = isElementScreenshot ? params.element : (params.fullPage ? 'full page' : 'viewport');
|
||||||
|
response.addCode(`// Screenshot ${screenshotTarget} and save it as ${fileName}`);
|
||||||
|
|
||||||
|
// Only get snapshot when element screenshot is needed
|
||||||
|
const locator = params.ref ? await tab.refLocator({ element: params.element || '', ref: params.ref }) : null;
|
||||||
|
|
||||||
|
if (locator)
|
||||||
|
response.addCode(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
|
||||||
|
else
|
||||||
|
response.addCode(`await page.screenshot(${javascript.formatObject(options)});`);
|
||||||
|
|
||||||
|
const buffer = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
||||||
|
response.addResult(`Took the ${screenshotTarget} screenshot and saved it as ${fileName}`);
|
||||||
|
|
||||||
|
// https://github.com/microsoft/playwright-mcp/issues/817
|
||||||
|
// Never return large images to LLM, saving them to the file system is enough.
|
||||||
|
if (!params.fullPage) {
|
||||||
|
response.addImage({
|
||||||
|
contentType: fileType === 'png' ? 'image/png' : 'image/jpeg',
|
||||||
|
data: buffer
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default [
|
||||||
|
screenshot,
|
||||||
|
];
|
||||||
@@ -16,66 +16,73 @@
|
|||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { defineTool } from './tool';
|
import { defineTabTool, defineTool } from './tool.js';
|
||||||
import * as javascript from '../javascript';
|
import * as javascript from '../utils/codegen.js';
|
||||||
import { outputFile } from '../config';
|
import { generateLocator } from './utils.js';
|
||||||
|
|
||||||
import type * as playwright from 'playwright';
|
|
||||||
|
|
||||||
const snapshot = defineTool({
|
const snapshot = defineTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_snapshot',
|
name: 'browser_snapshot',
|
||||||
|
title: 'Page snapshot',
|
||||||
description: 'Capture accessibility snapshot of the current page, this is better than screenshot',
|
description: 'Capture accessibility snapshot of the current page, this is better than screenshot',
|
||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async (context, params, response) => {
|
||||||
await context.ensureTab();
|
await context.ensureTab();
|
||||||
|
response.setIncludeSnapshot();
|
||||||
return {
|
|
||||||
code: [`// <internal code to capture accessibility snapshot>`],
|
|
||||||
captureSnapshot: true,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const elementSchema = z.object({
|
export const elementSchema = z.object({
|
||||||
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
||||||
ref: z.string().describe('Exact target element reference from the page snapshot'),
|
ref: z.string().describe('Exact target element reference from the page snapshot'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const click = defineTool({
|
const clickSchema = elementSchema.extend({
|
||||||
|
doubleClick: z.boolean().optional().describe('Whether to perform a double click instead of a single click'),
|
||||||
|
button: z.enum(['left', 'right', 'middle']).optional().describe('Button to click, defaults to left'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const click = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
|
title: 'Click',
|
||||||
description: 'Perform click on a web page',
|
description: 'Perform click on a web page',
|
||||||
inputSchema: elementSchema,
|
inputSchema: clickSchema,
|
||||||
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params, response) => {
|
||||||
const tab = context.currentTabOrDie();
|
response.setIncludeSnapshot();
|
||||||
const locator = tab.snapshotOrDie().refLocator(params.ref);
|
|
||||||
|
|
||||||
const code = [
|
const locator = await tab.refLocator(params);
|
||||||
`// Click ${params.element}`,
|
const button = params.button;
|
||||||
`await page.${await generateLocator(locator)}.click();`
|
const buttonAttr = button ? `{ button: '${button}' }` : '';
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
if (params.doubleClick)
|
||||||
code,
|
response.addCode(`await page.${await generateLocator(locator)}.dblclick(${buttonAttr});`);
|
||||||
action: () => locator.click(),
|
else
|
||||||
captureSnapshot: true,
|
response.addCode(`await page.${await generateLocator(locator)}.click(${buttonAttr});`);
|
||||||
waitForNetwork: true,
|
|
||||||
};
|
|
||||||
|
await tab.waitForCompletion(async () => {
|
||||||
|
if (params.doubleClick)
|
||||||
|
await locator.dblclick({ button });
|
||||||
|
else
|
||||||
|
await locator.click({ button });
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const drag = defineTool({
|
const drag = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_drag',
|
name: 'browser_drag',
|
||||||
|
title: 'Drag mouse',
|
||||||
description: 'Perform drag and drop between two elements',
|
description: 'Perform drag and drop between two elements',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'),
|
startElement: z.string().describe('Human-readable source element description used to obtain the permission to interact with the element'),
|
||||||
@@ -83,96 +90,44 @@ const drag = defineTool({
|
|||||||
endElement: z.string().describe('Human-readable target element description used to obtain the permission to interact with the element'),
|
endElement: z.string().describe('Human-readable target element description used to obtain the permission to interact with the element'),
|
||||||
endRef: z.string().describe('Exact target element reference from the page snapshot'),
|
endRef: z.string().describe('Exact target element reference from the page snapshot'),
|
||||||
}),
|
}),
|
||||||
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params, response) => {
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
response.setIncludeSnapshot();
|
||||||
const startLocator = snapshot.refLocator(params.startRef);
|
|
||||||
const endLocator = snapshot.refLocator(params.endRef);
|
|
||||||
|
|
||||||
const code = [
|
const [startLocator, endLocator] = await tab.refLocators([
|
||||||
`// Drag ${params.startElement} to ${params.endElement}`,
|
{ ref: params.startRef, element: params.startElement },
|
||||||
`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`
|
{ ref: params.endRef, element: params.endElement },
|
||||||
];
|
]);
|
||||||
|
|
||||||
return {
|
await tab.waitForCompletion(async () => {
|
||||||
code,
|
await startLocator.dragTo(endLocator);
|
||||||
action: () => startLocator.dragTo(endLocator),
|
});
|
||||||
captureSnapshot: true,
|
|
||||||
waitForNetwork: true,
|
response.addCode(`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`);
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const hover = defineTool({
|
const hover = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_hover',
|
name: 'browser_hover',
|
||||||
|
title: 'Hover mouse',
|
||||||
description: 'Hover over element on page',
|
description: 'Hover over element on page',
|
||||||
inputSchema: elementSchema,
|
inputSchema: elementSchema,
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params, response) => {
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
response.setIncludeSnapshot();
|
||||||
const locator = snapshot.refLocator(params.ref);
|
|
||||||
|
|
||||||
const code = [
|
const locator = await tab.refLocator(params);
|
||||||
`// Hover over ${params.element}`,
|
response.addCode(`await page.${await generateLocator(locator)}.hover();`);
|
||||||
`await page.${await generateLocator(locator)}.hover();`
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
await tab.waitForCompletion(async () => {
|
||||||
code,
|
await locator.hover();
|
||||||
action: () => locator.hover(),
|
});
|
||||||
captureSnapshot: true,
|
|
||||||
waitForNetwork: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const typeSchema = elementSchema.extend({
|
|
||||||
text: z.string().describe('Text to type into the element'),
|
|
||||||
submit: z.boolean().optional().describe('Whether to submit entered text (press Enter after)'),
|
|
||||||
slowly: z.boolean().optional().describe('Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const type = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_type',
|
|
||||||
description: 'Type text into editable element',
|
|
||||||
inputSchema: typeSchema,
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
|
||||||
const locator = snapshot.refLocator(params.ref);
|
|
||||||
|
|
||||||
const code: string[] = [];
|
|
||||||
const steps: (() => Promise<void>)[] = [];
|
|
||||||
|
|
||||||
if (params.slowly) {
|
|
||||||
code.push(`// Press "${params.text}" sequentially into "${params.element}"`);
|
|
||||||
code.push(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(params.text)});`);
|
|
||||||
steps.push(() => locator.pressSequentially(params.text));
|
|
||||||
} else {
|
|
||||||
code.push(`// Fill "${params.text}" into "${params.element}"`);
|
|
||||||
code.push(`await page.${await generateLocator(locator)}.fill(${javascript.quote(params.text)});`);
|
|
||||||
steps.push(() => locator.fill(params.text));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.submit) {
|
|
||||||
code.push(`// Submit text`);
|
|
||||||
code.push(`await page.${await generateLocator(locator)}.press('Enter');`);
|
|
||||||
steps.push(() => locator.press('Enter'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action: () => steps.reduce((acc, step) => acc.then(step), Promise.resolve()),
|
|
||||||
captureSnapshot: true,
|
|
||||||
waitForNetwork: true,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -180,101 +135,32 @@ const selectOptionSchema = elementSchema.extend({
|
|||||||
values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'),
|
values: z.array(z.string()).describe('Array of values to select in the dropdown. This can be a single value or multiple values.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectOption = defineTool({
|
const selectOption = defineTabTool({
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_select_option',
|
name: 'browser_select_option',
|
||||||
|
title: 'Select option',
|
||||||
description: 'Select an option in a dropdown',
|
description: 'Select an option in a dropdown',
|
||||||
inputSchema: selectOptionSchema,
|
inputSchema: selectOptionSchema,
|
||||||
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (tab, params, response) => {
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
response.setIncludeSnapshot();
|
||||||
const locator = snapshot.refLocator(params.ref);
|
|
||||||
|
|
||||||
const code = [
|
const locator = await tab.refLocator(params);
|
||||||
`// Select options [${params.values.join(', ')}] in ${params.element}`,
|
response.addCode(`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`);
|
||||||
`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(params.values)});`
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
await tab.waitForCompletion(async () => {
|
||||||
code,
|
await locator.selectOption(params.values);
|
||||||
action: () => locator.selectOption(params.values).then(() => {}),
|
});
|
||||||
captureSnapshot: true,
|
|
||||||
waitForNetwork: true,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const screenshotSchema = z.object({
|
|
||||||
raw: z.boolean().optional().describe('Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.'),
|
|
||||||
element: z.string().optional().describe('Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.'),
|
|
||||||
ref: z.string().optional().describe('Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.'),
|
|
||||||
}).refine(data => {
|
|
||||||
return !!data.element === !!data.ref;
|
|
||||||
}, {
|
|
||||||
message: 'Both element and ref must be provided or neither.',
|
|
||||||
path: ['ref', 'element']
|
|
||||||
});
|
|
||||||
|
|
||||||
const screenshot = defineTool({
|
|
||||||
capability: 'core',
|
|
||||||
schema: {
|
|
||||||
name: 'browser_take_screenshot',
|
|
||||||
description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`,
|
|
||||||
inputSchema: screenshotSchema,
|
|
||||||
},
|
|
||||||
|
|
||||||
handle: async (context, params) => {
|
|
||||||
const tab = context.currentTabOrDie();
|
|
||||||
const snapshot = tab.snapshotOrDie();
|
|
||||||
const fileType = params.raw ? 'png' : 'jpeg';
|
|
||||||
const fileName = await outputFile(context.config, `page-${new Date().toISOString()}.${fileType}`);
|
|
||||||
const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName };
|
|
||||||
const isElementScreenshot = params.element && params.ref;
|
|
||||||
|
|
||||||
const code = [
|
|
||||||
`// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
const locator = params.ref ? snapshot.refLocator(params.ref) : null;
|
|
||||||
|
|
||||||
if (locator)
|
|
||||||
code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
|
|
||||||
else
|
|
||||||
code.push(`await page.screenshot(${javascript.formatObject(options)});`);
|
|
||||||
|
|
||||||
const includeBase64 = !context.config.tools?.browser_take_screenshot?.omitBase64;
|
|
||||||
const action = async () => {
|
|
||||||
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
|
||||||
return {
|
|
||||||
content: includeBase64 ? [{
|
|
||||||
type: 'image' as 'image',
|
|
||||||
data: screenshot.toString('base64'),
|
|
||||||
mimeType: fileType === 'png' ? 'image/png' : 'image/jpeg',
|
|
||||||
}] : []
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
action,
|
|
||||||
captureSnapshot: true,
|
|
||||||
waitForNetwork: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
|
||||||
return (locator as any)._generateLocatorString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
snapshot,
|
snapshot,
|
||||||
click,
|
click,
|
||||||
drag,
|
drag,
|
||||||
hover,
|
hover,
|
||||||
type,
|
|
||||||
selectOption,
|
selectOption,
|
||||||
screenshot,
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -15,112 +15,87 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { defineTool, type ToolFactory } from './tool';
|
import { defineTool } from './tool.js';
|
||||||
|
|
||||||
const listTabs = defineTool({
|
const listTabs = defineTool({
|
||||||
capability: 'tabs',
|
capability: 'core-tabs',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_tab_list',
|
name: 'browser_tab_list',
|
||||||
|
title: 'List tabs',
|
||||||
description: 'List browser tabs',
|
description: 'List browser tabs',
|
||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async (context, params, response) => {
|
||||||
await context.ensureTab();
|
await context.ensureTab();
|
||||||
return {
|
response.setIncludeTabs();
|
||||||
code: [`// <internal code to list tabs>`],
|
|
||||||
captureSnapshot: false,
|
|
||||||
waitForNetwork: false,
|
|
||||||
resultOverride: {
|
|
||||||
content: [{
|
|
||||||
type: 'text',
|
|
||||||
text: await context.listTabsMarkdown(),
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectTab: ToolFactory = captureSnapshot => defineTool({
|
const selectTab = defineTool({
|
||||||
capability: 'tabs',
|
capability: 'core-tabs',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_tab_select',
|
name: 'browser_tab_select',
|
||||||
|
title: 'Select a tab',
|
||||||
description: 'Select a tab by index',
|
description: 'Select a tab by index',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
index: z.number().describe('The index of the tab to select'),
|
index: z.number().describe('The index of the tab to select'),
|
||||||
}),
|
}),
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params, response) => {
|
||||||
await context.selectTab(params.index);
|
await context.selectTab(params.index);
|
||||||
const code = [
|
response.setIncludeSnapshot();
|
||||||
`// <internal code to select tab ${params.index}>`,
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: false
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const newTab: ToolFactory = captureSnapshot => defineTool({
|
const newTab = defineTool({
|
||||||
capability: 'tabs',
|
capability: 'core-tabs',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_tab_new',
|
name: 'browser_tab_new',
|
||||||
|
title: 'Open a new tab',
|
||||||
description: 'Open a new tab',
|
description: 'Open a new tab',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'),
|
url: z.string().optional().describe('The URL to navigate to in the new tab. If not provided, the new tab will be blank.'),
|
||||||
}),
|
}),
|
||||||
|
type: 'readOnly',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params, response) => {
|
||||||
await context.newTab();
|
const tab = await context.newTab();
|
||||||
if (params.url)
|
if (params.url)
|
||||||
await context.currentTabOrDie().navigate(params.url);
|
await tab.navigate(params.url);
|
||||||
|
response.setIncludeSnapshot();
|
||||||
const code = [
|
|
||||||
`// <internal code to open a new tab>`,
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: false
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const closeTab: ToolFactory = captureSnapshot => defineTool({
|
const closeTab = defineTool({
|
||||||
capability: 'tabs',
|
capability: 'core-tabs',
|
||||||
|
|
||||||
schema: {
|
schema: {
|
||||||
name: 'browser_tab_close',
|
name: 'browser_tab_close',
|
||||||
|
title: 'Close a tab',
|
||||||
description: 'Close a tab',
|
description: 'Close a tab',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
|
index: z.number().optional().describe('The index of the tab to close. Closes current tab if not provided.'),
|
||||||
}),
|
}),
|
||||||
|
type: 'destructive',
|
||||||
},
|
},
|
||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params, response) => {
|
||||||
await context.closeTab(params.index);
|
await context.closeTab(params.index);
|
||||||
const code = [
|
response.setIncludeSnapshot();
|
||||||
`// <internal code to close tab ${params.index}>`,
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
captureSnapshot,
|
|
||||||
waitForNetwork: false
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default (captureSnapshot: boolean) => [
|
export default [
|
||||||
listTabs,
|
listTabs,
|
||||||
newTab(captureSnapshot),
|
newTab,
|
||||||
selectTab(captureSnapshot),
|
selectTab,
|
||||||
closeTab(captureSnapshot),
|
closeTab,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -14,19 +14,13 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
|
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
import type { Context } from '../context';
|
import type { Context } from '../context.js';
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
import type { ToolCapability } from '../../config';
|
import type { ToolCapability } from '../../config.js';
|
||||||
|
import type { Tab } from '../tab.js';
|
||||||
export type ToolSchema<Input extends InputType> = {
|
import type { Response } from '../response.js';
|
||||||
name: string;
|
import type { ToolSchema } from '../mcp/tool.js';
|
||||||
description: string;
|
|
||||||
inputSchema: Input;
|
|
||||||
};
|
|
||||||
|
|
||||||
type InputType = z.Schema;
|
|
||||||
|
|
||||||
export type FileUploadModalState = {
|
export type FileUploadModalState = {
|
||||||
type: 'fileChooser';
|
type: 'fileChooser';
|
||||||
@@ -42,25 +36,35 @@ export type DialogModalState = {
|
|||||||
|
|
||||||
export type ModalState = FileUploadModalState | DialogModalState;
|
export type ModalState = FileUploadModalState | DialogModalState;
|
||||||
|
|
||||||
export type ToolActionResult = { content?: (ImageContent | TextContent)[] } | undefined | void;
|
export type Tool<Input extends z.Schema = z.Schema> = {
|
||||||
|
capability: ToolCapability;
|
||||||
export type ToolResult = {
|
schema: ToolSchema<Input>;
|
||||||
code: string[];
|
handle: (context: Context, params: z.output<Input>, response: Response) => Promise<void>;
|
||||||
action?: () => Promise<ToolActionResult>;
|
|
||||||
captureSnapshot: boolean;
|
|
||||||
waitForNetwork: boolean;
|
|
||||||
resultOverride?: ToolActionResult;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Tool<Input extends InputType = InputType> = {
|
export function defineTool<Input extends z.Schema>(tool: Tool<Input>): Tool<Input> {
|
||||||
|
return tool;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TabTool<Input extends z.Schema = z.Schema> = {
|
||||||
capability: ToolCapability;
|
capability: ToolCapability;
|
||||||
schema: ToolSchema<Input>;
|
schema: ToolSchema<Input>;
|
||||||
clearsModalState?: ModalState['type'];
|
clearsModalState?: ModalState['type'];
|
||||||
handle: (context: Context, params: z.output<Input>) => Promise<ToolResult>;
|
handle: (tab: Tab, params: z.output<Input>, response: Response) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ToolFactory = (snapshot: boolean) => Tool<any>;
|
export function defineTabTool<Input extends z.Schema>(tool: TabTool<Input>): Tool<Input> {
|
||||||
|
return {
|
||||||
export function defineTool<Input extends InputType>(tool: Tool<Input>): Tool<Input> {
|
...tool,
|
||||||
return tool;
|
handle: async (context, params, response) => {
|
||||||
|
const tab = context.currentTabOrDie();
|
||||||
|
const modalStates = tab.modalStates().map(state => state.type);
|
||||||
|
if (tool.clearsModalState && !modalStates.includes(tool.clearsModalState))
|
||||||
|
response.addError(`Error: The tool "${tool.schema.name}" can only be used when there is related modal state present.\n` + tab.modalStatesMarkdown().join('\n'));
|
||||||
|
else if (!tool.clearsModalState && modalStates.length)
|
||||||
|
response.addError(`Error: Tool "${tool.schema.name}" does not handle the modal state.\n` + tab.modalStatesMarkdown().join('\n'));
|
||||||
|
else
|
||||||
|
return tool.handle(tab, params, response);
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,13 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type * as playwright from 'playwright';
|
// @ts-ignore
|
||||||
import type { Context } from '../context';
|
import { asLocator } from 'playwright-core/lib/utils';
|
||||||
|
|
||||||
export async function waitForCompletion<R>(context: Context, page: playwright.Page, callback: () => Promise<R>): Promise<R> {
|
import type * as playwright from 'playwright';
|
||||||
|
import type { Tab } from '../tab.js';
|
||||||
|
|
||||||
|
export async function waitForCompletion<R>(tab: Tab, callback: () => Promise<R>): Promise<R> {
|
||||||
const requests = new Set<playwright.Request>();
|
const requests = new Set<playwright.Request>();
|
||||||
let frameNavigated = false;
|
let frameNavigated = false;
|
||||||
let waitCallback: () => void = () => {};
|
let waitCallback: () => void = () => {};
|
||||||
@@ -36,9 +39,7 @@ export async function waitForCompletion<R>(context: Context, page: playwright.Pa
|
|||||||
frameNavigated = true;
|
frameNavigated = true;
|
||||||
dispose();
|
dispose();
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
void frame.waitForLoadState('load').then(() => {
|
void tab.waitForLoadState('load').then(waitCallback);
|
||||||
waitCallback();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTimeout = () => {
|
const onTimeout = () => {
|
||||||
@@ -46,15 +47,15 @@ export async function waitForCompletion<R>(context: Context, page: playwright.Pa
|
|||||||
waitCallback();
|
waitCallback();
|
||||||
};
|
};
|
||||||
|
|
||||||
page.on('request', requestListener);
|
tab.page.on('request', requestListener);
|
||||||
page.on('requestfinished', requestFinishedListener);
|
tab.page.on('requestfinished', requestFinishedListener);
|
||||||
page.on('framenavigated', frameNavigateListener);
|
tab.page.on('framenavigated', frameNavigateListener);
|
||||||
const timeout = setTimeout(onTimeout, 10000);
|
const timeout = setTimeout(onTimeout, 10000);
|
||||||
|
|
||||||
const dispose = () => {
|
const dispose = () => {
|
||||||
page.off('request', requestListener);
|
tab.page.off('request', requestListener);
|
||||||
page.off('requestfinished', requestFinishedListener);
|
tab.page.off('requestfinished', requestFinishedListener);
|
||||||
page.off('framenavigated', frameNavigateListener);
|
tab.page.off('framenavigated', frameNavigateListener);
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,13 +64,22 @@ export async function waitForCompletion<R>(context: Context, page: playwright.Pa
|
|||||||
if (!requests.size && !frameNavigated)
|
if (!requests.size && !frameNavigated)
|
||||||
waitCallback();
|
waitCallback();
|
||||||
await waitBarrier;
|
await waitBarrier;
|
||||||
await context.waitForTimeout(1000);
|
await tab.waitForTimeout(1000);
|
||||||
return result;
|
return result;
|
||||||
} finally {
|
} finally {
|
||||||
dispose();
|
dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeForFilePath(s: string) {
|
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
||||||
return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
|
try {
|
||||||
|
const { resolvedSelector } = await (locator as any)._resolveSelector();
|
||||||
|
return asLocator('javascript', resolvedSelector);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('Ref not found, likely because element was removed. Use browser_snapshot to see what elements are currently on the page.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function callOnPageNoTrace<T>(page: playwright.Page, callback: (page: playwright.Page) => Promise<T>): Promise<T> {
|
||||||
|
return await (page as any)._wrapApiCall(() => callback(page), { internal: true });
|
||||||
}
|
}
|
||||||
|
|||||||
65
src/tools/wait.ts
Normal file
65
src/tools/wait.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* 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 { defineTool } from './tool.js';
|
||||||
|
|
||||||
|
const wait = defineTool({
|
||||||
|
capability: 'core',
|
||||||
|
|
||||||
|
schema: {
|
||||||
|
name: 'browser_wait_for',
|
||||||
|
title: 'Wait for',
|
||||||
|
description: 'Wait for text to appear or disappear or a specified time to pass',
|
||||||
|
inputSchema: z.object({
|
||||||
|
time: z.number().optional().describe('The time to wait in seconds'),
|
||||||
|
text: z.string().optional().describe('The text to wait for'),
|
||||||
|
textGone: z.string().optional().describe('The text to wait for to disappear'),
|
||||||
|
}),
|
||||||
|
type: 'readOnly',
|
||||||
|
},
|
||||||
|
|
||||||
|
handle: async (context, params, response) => {
|
||||||
|
if (!params.text && !params.textGone && !params.time)
|
||||||
|
throw new Error('Either time, text or textGone must be provided');
|
||||||
|
|
||||||
|
if (params.time) {
|
||||||
|
response.addCode(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`);
|
||||||
|
await new Promise(f => setTimeout(f, Math.min(30000, params.time! * 1000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tab = context.currentTabOrDie();
|
||||||
|
const locator = params.text ? tab.page.getByText(params.text).first() : undefined;
|
||||||
|
const goneLocator = params.textGone ? tab.page.getByText(params.textGone).first() : undefined;
|
||||||
|
|
||||||
|
if (goneLocator) {
|
||||||
|
response.addCode(`await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`);
|
||||||
|
await goneLocator.waitFor({ state: 'hidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locator) {
|
||||||
|
response.addCode(`await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`);
|
||||||
|
await locator.waitFor({ state: 'visible' });
|
||||||
|
}
|
||||||
|
|
||||||
|
response.addResult(`Waited for ${params.text || params.textGone || params.time}`);
|
||||||
|
response.setIncludeSnapshot();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default [
|
||||||
|
wait,
|
||||||
|
];
|
||||||
127
src/transport.ts
127
src/transport.ts
@@ -1,127 +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 http from 'node:http';
|
|
||||||
import assert from 'node:assert';
|
|
||||||
import crypto from 'node:crypto';
|
|
||||||
|
|
||||||
import { ServerList } from './server';
|
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
||||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
||||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
||||||
|
|
||||||
export async function startStdioTransport(serverList: ServerList) {
|
|
||||||
const server = await serverList.create();
|
|
||||||
await server.connect(new StdioServerTransport());
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSSE(req: http.IncomingMessage, res: http.ServerResponse, url: URL, serverList: ServerList, sessions: Map<string, SSEServerTransport>) {
|
|
||||||
if (req.method === 'POST') {
|
|
||||||
const sessionId = url.searchParams.get('sessionId');
|
|
||||||
if (!sessionId) {
|
|
||||||
res.statusCode = 400;
|
|
||||||
return res.end('Missing sessionId');
|
|
||||||
}
|
|
||||||
|
|
||||||
const transport = sessions.get(sessionId);
|
|
||||||
if (!transport) {
|
|
||||||
res.statusCode = 404;
|
|
||||||
return res.end('Session not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return await transport.handlePostMessage(req, res);
|
|
||||||
} else if (req.method === 'GET') {
|
|
||||||
const transport = new SSEServerTransport('/sse', res);
|
|
||||||
sessions.set(transport.sessionId, transport);
|
|
||||||
const server = await serverList.create();
|
|
||||||
res.on('close', () => {
|
|
||||||
sessions.delete(transport.sessionId);
|
|
||||||
serverList.close(server).catch(e => console.error(e));
|
|
||||||
});
|
|
||||||
return await server.connect(transport);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.statusCode = 405;
|
|
||||||
res.end('Method not allowed');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStreamable(req: http.IncomingMessage, res: http.ServerResponse, serverList: ServerList, sessions: Map<string, StreamableHTTPServerTransport>) {
|
|
||||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
|
||||||
if (sessionId) {
|
|
||||||
const transport = sessions.get(sessionId);
|
|
||||||
if (!transport) {
|
|
||||||
res.statusCode = 404;
|
|
||||||
res.end('Session not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return await transport.handleRequest(req, res);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'POST') {
|
|
||||||
const transport = new StreamableHTTPServerTransport({
|
|
||||||
sessionIdGenerator: () => crypto.randomUUID(),
|
|
||||||
onsessioninitialized: sessionId => {
|
|
||||||
sessions.set(sessionId, transport);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
transport.onclose = () => {
|
|
||||||
if (transport.sessionId)
|
|
||||||
sessions.delete(transport.sessionId);
|
|
||||||
};
|
|
||||||
const server = await serverList.create();
|
|
||||||
await server.connect(transport);
|
|
||||||
return await transport.handleRequest(req, res);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.statusCode = 400;
|
|
||||||
res.end('Invalid request');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startHttpTransport(port: number, hostname: string | undefined, serverList: ServerList) {
|
|
||||||
const sseSessions = new Map<string, SSEServerTransport>();
|
|
||||||
const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
|
|
||||||
const httpServer = http.createServer(async (req, res) => {
|
|
||||||
const url = new URL(`http://localhost${req.url}`);
|
|
||||||
if (url.pathname.startsWith('/mcp'))
|
|
||||||
await handleStreamable(req, res, serverList, streamableSessions);
|
|
||||||
else
|
|
||||||
await handleSSE(req, res, url, serverList, sseSessions);
|
|
||||||
});
|
|
||||||
httpServer.listen(port, hostname, () => {
|
|
||||||
const address = httpServer.address();
|
|
||||||
assert(address, 'Could not bind server socket');
|
|
||||||
let url: string;
|
|
||||||
if (typeof address === 'string') {
|
|
||||||
url = address;
|
|
||||||
} else {
|
|
||||||
const resolvedPort = address.port;
|
|
||||||
let resolvedHost = address.family === 'IPv4' ? address.address : `[${address.address}]`;
|
|
||||||
if (resolvedHost === '0.0.0.0' || resolvedHost === '[::]')
|
|
||||||
resolvedHost = 'localhost';
|
|
||||||
url = `http://${resolvedHost}:${resolvedPort}`;
|
|
||||||
}
|
|
||||||
console.log(`Listening on ${url}`);
|
|
||||||
console.log('Put this in your client config:');
|
|
||||||
console.log(JSON.stringify({
|
|
||||||
'mcpServers': {
|
|
||||||
'playwright': {
|
|
||||||
'url': `${url}/sse`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, undefined, 2));
|
|
||||||
console.log('If your client supports streamable HTTP, you can use the /mcp endpoint instead.');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,7 @@ export function escapeWithQuotes(text: string, char: string = '\'') {
|
|||||||
if (char === '"')
|
if (char === '"')
|
||||||
return char + escapedText.replace(/["]/g, '\\"') + char;
|
return char + escapedText.replace(/["]/g, '\\"') + char;
|
||||||
if (char === '`')
|
if (char === '`')
|
||||||
return char + escapedText.replace(/[`]/g, '`') + char;
|
return char + escapedText.replace(/[`]/g, '\\`') + char;
|
||||||
throw new Error('Invalid escape char');
|
throw new Error('Invalid escape char');
|
||||||
}
|
}
|
||||||
|
|
||||||
39
src/utils/fileUtils.ts
Normal file
39
src/utils/fileUtils.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
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 function sanitizeForFilePath(s: string) {
|
||||||
|
const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
|
||||||
|
const separator = s.lastIndexOf('.');
|
||||||
|
if (separator === -1)
|
||||||
|
return sanitize(s);
|
||||||
|
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
|
||||||
|
}
|
||||||
25
src/utils/guid.ts
Normal file
25
src/utils/guid.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* 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 crypto from 'crypto';
|
||||||
|
|
||||||
|
export function createGuid(): string {
|
||||||
|
return crypto.randomBytes(16).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHash(data: string): string {
|
||||||
|
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 7);
|
||||||
|
}
|
||||||
44
src/utils/httpServer.ts
Normal file
44
src/utils/httpServer.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 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}`;
|
||||||
|
}
|
||||||
@@ -14,23 +14,12 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Context } from '../context';
|
import debug from 'debug';
|
||||||
|
|
||||||
export type ResourceSchema = {
|
const errorsDebug = debug('pw:mcp:errors');
|
||||||
uri: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
mimeType?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ResourceResult = {
|
export function logUnhandledError(error: unknown) {
|
||||||
uri: string;
|
errorsDebug(error);
|
||||||
mimeType?: string;
|
}
|
||||||
text?: string;
|
|
||||||
blob?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Resource = {
|
export const testDebug = debug('pw:mcp:test');
|
||||||
schema: ResourceSchema;
|
|
||||||
read: (context: Context, uri: string) => Promise<ResourceResult[]>;
|
|
||||||
};
|
|
||||||
22
src/utils/package.ts
Normal file
22
src/utils/package.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* 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 url from 'url';
|
||||||
|
|
||||||
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', '..', 'package.json'), 'utf8'));
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('test snapshot tool list', async ({ client }) => {
|
test('test snapshot tool list', async ({ client }) => {
|
||||||
const { tools } = await client.listTools();
|
const { tools } = await client.listTools();
|
||||||
@@ -22,6 +22,7 @@ test('test snapshot tool list', async ({ client }) => {
|
|||||||
'browser_click',
|
'browser_click',
|
||||||
'browser_console_messages',
|
'browser_console_messages',
|
||||||
'browser_drag',
|
'browser_drag',
|
||||||
|
'browser_evaluate',
|
||||||
'browser_file_upload',
|
'browser_file_upload',
|
||||||
'browser_handle_dialog',
|
'browser_handle_dialog',
|
||||||
'browser_hover',
|
'browser_hover',
|
||||||
@@ -33,7 +34,6 @@ test('test snapshot tool list', async ({ client }) => {
|
|||||||
'browser_navigate_forward',
|
'browser_navigate_forward',
|
||||||
'browser_navigate',
|
'browser_navigate',
|
||||||
'browser_network_requests',
|
'browser_network_requests',
|
||||||
'browser_pdf_save',
|
|
||||||
'browser_press_key',
|
'browser_press_key',
|
||||||
'browser_resize',
|
'browser_resize',
|
||||||
'browser_snapshot',
|
'browser_snapshot',
|
||||||
@@ -42,49 +42,71 @@ test('test snapshot tool list', async ({ client }) => {
|
|||||||
'browser_tab_new',
|
'browser_tab_new',
|
||||||
'browser_tab_select',
|
'browser_tab_select',
|
||||||
'browser_take_screenshot',
|
'browser_take_screenshot',
|
||||||
'browser_wait',
|
'browser_wait_for',
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test vision tool list', async ({ visionClient }) => {
|
test('test tool list proxy mode', async ({ startClient }) => {
|
||||||
const { tools: visionTools } = await visionClient.listTools();
|
const { client } = await startClient({
|
||||||
expect(new Set(visionTools.map(t => t.name))).toEqual(new Set([
|
args: ['--connect-tool'],
|
||||||
'browser_close',
|
});
|
||||||
|
const { tools } = await client.listTools();
|
||||||
|
expect(new Set(tools.map(t => t.name))).toEqual(new Set([
|
||||||
|
'browser_click',
|
||||||
|
'browser_connect', // the extra tool
|
||||||
'browser_console_messages',
|
'browser_console_messages',
|
||||||
|
'browser_drag',
|
||||||
|
'browser_evaluate',
|
||||||
'browser_file_upload',
|
'browser_file_upload',
|
||||||
'browser_handle_dialog',
|
'browser_handle_dialog',
|
||||||
|
'browser_hover',
|
||||||
|
'browser_select_option',
|
||||||
|
'browser_type',
|
||||||
|
'browser_close',
|
||||||
'browser_install',
|
'browser_install',
|
||||||
'browser_navigate_back',
|
'browser_navigate_back',
|
||||||
'browser_navigate_forward',
|
'browser_navigate_forward',
|
||||||
'browser_navigate',
|
'browser_navigate',
|
||||||
'browser_network_requests',
|
'browser_network_requests',
|
||||||
'browser_pdf_save',
|
|
||||||
'browser_press_key',
|
'browser_press_key',
|
||||||
'browser_resize',
|
'browser_resize',
|
||||||
'browser_screen_capture',
|
'browser_snapshot',
|
||||||
'browser_screen_click',
|
|
||||||
'browser_screen_drag',
|
|
||||||
'browser_screen_move_mouse',
|
|
||||||
'browser_screen_type',
|
|
||||||
'browser_tab_close',
|
'browser_tab_close',
|
||||||
'browser_tab_list',
|
'browser_tab_list',
|
||||||
'browser_tab_new',
|
'browser_tab_new',
|
||||||
'browser_tab_select',
|
'browser_tab_select',
|
||||||
'browser_wait',
|
'browser_take_screenshot',
|
||||||
|
'browser_wait_for',
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test capabilities', async ({ startClient }) => {
|
test('test capabilities (pdf)', async ({ startClient }) => {
|
||||||
const client = await startClient({
|
const { client } = await startClient({
|
||||||
args: ['--caps="core"'],
|
args: ['--caps=pdf'],
|
||||||
});
|
});
|
||||||
const { tools } = await client.listTools();
|
const { tools } = await client.listTools();
|
||||||
const toolNames = tools.map(t => t.name);
|
const toolNames = tools.map(t => t.name);
|
||||||
expect(toolNames).not.toContain('browser_file_upload');
|
expect(toolNames).toContain('browser_pdf_save');
|
||||||
expect(toolNames).not.toContain('browser_pdf_save');
|
});
|
||||||
expect(toolNames).not.toContain('browser_screen_capture');
|
|
||||||
expect(toolNames).not.toContain('browser_screen_click');
|
test('test capabilities (vision)', async ({ startClient }) => {
|
||||||
expect(toolNames).not.toContain('browser_screen_drag');
|
const { client } = await startClient({
|
||||||
expect(toolNames).not.toContain('browser_screen_move_mouse');
|
args: ['--caps=vision'],
|
||||||
expect(toolNames).not.toContain('browser_screen_type');
|
});
|
||||||
|
const { tools } = await client.listTools();
|
||||||
|
const toolNames = tools.map(t => t.name);
|
||||||
|
expect(toolNames).toContain('browser_mouse_move_xy');
|
||||||
|
expect(toolNames).toContain('browser_mouse_click_xy');
|
||||||
|
expect(toolNames).toContain('browser_mouse_drag_xy');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('support for legacy --vision option', async ({ startClient }) => {
|
||||||
|
const { client } = await startClient({
|
||||||
|
args: ['--vision'],
|
||||||
|
});
|
||||||
|
const { tools } = await client.listTools();
|
||||||
|
const toolNames = tools.map(t => t.name);
|
||||||
|
expect(toolNames).toContain('browser_mouse_move_xy');
|
||||||
|
expect(toolNames).toContain('browser_mouse_click_xy');
|
||||||
|
expect(toolNames).toContain('browser_mouse_drag_xy');
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user