rename
@@ -0,0 +1,61 @@
|
||||
# Playwriter MCP
|
||||
|
||||
Control your Chrome browser via Model Context Protocol (MCP) using Chrome DevTools Protocol (CDP) events.
|
||||
|
||||
## What is Playwriter MCP?
|
||||
|
||||
Playwriter MCP is a Chrome extension that enables Playwright to connect to your existing Chrome instance without spawning a new browser or requiring Chrome to be started in CDP mode. This allows AI assistants and automation tools to interact with your browser seamlessly through the Model Context Protocol.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **No new Chrome instances**: Works with your current browser session
|
||||
- **No CDP mode required**: No need to restart Chrome with special flags
|
||||
- **MCP integration**: Exposes browser control through the Model Context Protocol
|
||||
- **CDP events**: Full access to Chrome DevTools Protocol capabilities
|
||||
- **Playwright compatible**: Connect Playwright directly to your running Chrome
|
||||
|
||||
## How it Works
|
||||
|
||||
1. Install the extension in your Chrome browser
|
||||
2. Click the extension icon to attach the debugger to the current tab
|
||||
3. The extension creates a relay connection using CDP
|
||||
4. Connect your MCP client (like Playwright) to control the browser
|
||||
5. The icon changes color to indicate connection status:
|
||||
- Gray: Not connected
|
||||
- Green: Successfully connected
|
||||
|
||||
## Use Cases
|
||||
|
||||
- Browser automation without disrupting your workflow
|
||||
- AI-assisted web browsing and testing
|
||||
- Debugging and development with MCP-enabled tools
|
||||
- Remote browser control for various applications
|
||||
|
||||
## Permissions
|
||||
|
||||
This extension requires the following permissions:
|
||||
|
||||
- **debugger**: To access Chrome DevTools Protocol
|
||||
- **activeTab**: To interact with the current tab
|
||||
- **tabs**: To manage browser tabs
|
||||
- **all_urls**: To work with any website
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Install the extension from the Chrome Web Store
|
||||
2. Navigate to any webpage
|
||||
3. Click the Playwriter MCP extension icon
|
||||
4. The debugger will attach and the icon will turn green when connected
|
||||
5. Connect your MCP client to start controlling the browser
|
||||
|
||||
## Privacy & Security
|
||||
|
||||
Playwriter MCP runs locally in your browser and does not send any data to external servers. All browser control happens through the standard Chrome DevTools Protocol on your machine.
|
||||
|
||||
## Support
|
||||
|
||||
For issues, feature requests, or contributions, visit the [GitHub repository](https://github.com/remorses/playwriter).
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 501 B |
|
After Width: | Height: | Size: 774 B |
|
After Width: | Height: | Size: 999 B |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 469 B |
|
After Width: | Height: | Size: 764 B |
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Playwriter MCP",
|
||||
"version": "0.0.48",
|
||||
"description": "Control Chrome via MCP using CDP events. Connect Playwright without spawning new Chrome instances.",
|
||||
"permissions": [
|
||||
"debugger",
|
||||
"activeTab",
|
||||
"tabs"
|
||||
],
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "lib/background.mjs",
|
||||
"type": "module"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "Click to attach debugger",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-gray-16.png",
|
||||
"32": "icons/icon-gray-32.png",
|
||||
"48": "icons/icon-gray-48.png",
|
||||
"128": "icons/icon-gray-128.png"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"16": "icons/icon-gray-16.png",
|
||||
"32": "icons/icon-gray-32.png",
|
||||
"48": "icons/icon-gray-48.png",
|
||||
"128": "icons/icon-gray-128.png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@playwright/mcp-extension",
|
||||
"version": "0.0.48",
|
||||
"description": "Playwright MCP Browser Extension",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/remorses/playwriter.git"
|
||||
},
|
||||
"homepage": "https://github.com/remorses/playwriter",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"author": {
|
||||
"name": "Microsoft Corporation"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"build": "tsc --project . && vite build --config vite.sw.config.mts",
|
||||
"watch": "vite build --watch --config vite.sw.config.mts",
|
||||
"test": "playwright test",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.0.315",
|
||||
"concurrently": "^8.2.2",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^5.4.21",
|
||||
"vite-plugin-static-copy": "^3.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwriter": "workspace:*",
|
||||
"zustand": "^5.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { RelayConnection, debugLog } from './relayConnection'
|
||||
import { create } from 'zustand'
|
||||
|
||||
// Relay URL - fixed port for MCP bridge
|
||||
const RELAY_URL = 'ws://localhost:19988/extension'
|
||||
|
||||
type ConnectionState = 'disconnected' | 'reconnecting' | 'connected' | 'error'
|
||||
|
||||
interface ExtensionState {
|
||||
connection: RelayConnection | undefined
|
||||
connectedTabs: Map<number, string>
|
||||
connectionState: ConnectionState
|
||||
currentTabId: number | undefined
|
||||
errorText: string | undefined
|
||||
}
|
||||
|
||||
const useExtensionStore = create<ExtensionState>(() => ({
|
||||
connection: undefined,
|
||||
connectedTabs: new Map(),
|
||||
connectionState: 'disconnected',
|
||||
currentTabId: undefined,
|
||||
errorText: undefined,
|
||||
}))
|
||||
|
||||
async function resetDebugger() {
|
||||
let targets = await chrome.debugger.getTargets()
|
||||
targets = targets.filter((x) => x.tabId && x.attached)
|
||||
console.log(`found ${targets.length} existing debugger targets. detaching them before background script starts`)
|
||||
console.log(targets)
|
||||
for (const target of targets) {
|
||||
await chrome.debugger.detach({ tabId: target.tabId })
|
||||
}
|
||||
}
|
||||
resetDebugger()
|
||||
|
||||
chrome.runtime.onInstalled.addListener((details) => {
|
||||
if (details.reason === 'install') {
|
||||
void chrome.tabs.create({ url: 'welcome.html' })
|
||||
}
|
||||
})
|
||||
|
||||
const icons = {
|
||||
connected: {
|
||||
path: {
|
||||
'16': '/icons/icon-16.png',
|
||||
'32': '/icons/icon-32.png',
|
||||
'48': '/icons/icon-48.png',
|
||||
'128': '/icons/icon-128.png',
|
||||
},
|
||||
title: 'Connected - Click to disconnect',
|
||||
badgeText: '',
|
||||
badgeColor: undefined,
|
||||
},
|
||||
connecting: {
|
||||
path: {
|
||||
'16': '/icons/icon-gray-16.png',
|
||||
'32': '/icons/icon-gray-32.png',
|
||||
'48': '/icons/icon-gray-48.png',
|
||||
'128': '/icons/icon-gray-128.png',
|
||||
},
|
||||
title: 'Connecting...',
|
||||
badgeText: '...',
|
||||
badgeColor: '#FF9800',
|
||||
},
|
||||
disconnected: {
|
||||
path: {
|
||||
'16': '/icons/icon-gray-16.png',
|
||||
'32': '/icons/icon-gray-32.png',
|
||||
'48': '/icons/icon-gray-48.png',
|
||||
'128': '/icons/icon-gray-48.png',
|
||||
},
|
||||
title: 'Click to attach debugger',
|
||||
badgeText: '',
|
||||
badgeColor: undefined,
|
||||
},
|
||||
error: {
|
||||
path: {
|
||||
'16': '/icons/icon-gray-16.png',
|
||||
'32': '/icons/icon-gray-32.png',
|
||||
'48': '/icons/icon-gray-48.png',
|
||||
'128': '/icons/icon-gray-48.png',
|
||||
},
|
||||
title: 'Error',
|
||||
badgeText: '!',
|
||||
badgeColor: '#f44336',
|
||||
},
|
||||
} as const
|
||||
|
||||
useExtensionStore.subscribe(async (state, prevState) => {
|
||||
console.log(state)
|
||||
const { connectionState, connectedTabs, errorText } = state
|
||||
|
||||
const tabs = await chrome.tabs.query({})
|
||||
const allTabIds = [undefined, ...tabs.map((tab) => tab.id).filter((id): id is number => id !== undefined)]
|
||||
|
||||
for (const tabId of allTabIds) {
|
||||
const iconConfig = (() => {
|
||||
if (connectionState === 'error') {
|
||||
return icons.error
|
||||
}
|
||||
if (connectionState === 'reconnecting') {
|
||||
return icons.connecting
|
||||
}
|
||||
if (tabId !== undefined && connectedTabs.has(tabId) && connectionState === 'connected') {
|
||||
return icons.connected
|
||||
}
|
||||
return icons.disconnected
|
||||
})()
|
||||
const title = connectionState === 'error' && errorText ? errorText : iconConfig.title
|
||||
void chrome.action.setIcon({ tabId, path: iconConfig.path })
|
||||
void chrome.action.setTitle({ tabId, title })
|
||||
if (iconConfig.badgeColor) void chrome.action.setBadgeBackgroundColor({ tabId, color: iconConfig.badgeColor })
|
||||
void chrome.action.setBadgeText({ tabId, text: iconConfig.badgeText })
|
||||
}
|
||||
})
|
||||
|
||||
async function ensureConnection(): Promise<void> {
|
||||
const { connection } = useExtensionStore.getState()
|
||||
if (connection) {
|
||||
debugLog('Connection already exists, reusing')
|
||||
return
|
||||
}
|
||||
|
||||
debugLog('No existing connection, creating new relay connection')
|
||||
debugLog('Waiting for server at http://localhost:19988...')
|
||||
|
||||
useExtensionStore.setState({ connectionState: 'reconnecting' })
|
||||
while (true) {
|
||||
try {
|
||||
await fetch('http://localhost:19988', { method: 'HEAD' })
|
||||
debugLog('Server is available')
|
||||
break
|
||||
} catch (error: any) {
|
||||
debugLog('Server not available, retrying in 1 second...')
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}
|
||||
}
|
||||
|
||||
debugLog('Server is ready, creating WebSocket connection to:', RELAY_URL)
|
||||
const socket = new WebSocket(RELAY_URL)
|
||||
debugLog('WebSocket created, initial readyState:', socket.readyState, '(0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED)')
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let timeoutFired = false
|
||||
const timeout = setTimeout(() => {
|
||||
timeoutFired = true
|
||||
debugLog('=== WebSocket connection TIMEOUT after 5 seconds ===')
|
||||
debugLog('Final WebSocket readyState:', socket.readyState)
|
||||
debugLog('WebSocket URL:', socket.url)
|
||||
debugLog('Socket protocol:', socket.protocol)
|
||||
reject(new Error('Connection timeout'))
|
||||
}, 5000)
|
||||
|
||||
socket.onopen = () => {
|
||||
if (timeoutFired) {
|
||||
debugLog('WebSocket opened but timeout already fired!')
|
||||
return
|
||||
}
|
||||
debugLog('WebSocket onopen fired! readyState:', socket.readyState)
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
}
|
||||
|
||||
socket.onerror = (error) => {
|
||||
debugLog('WebSocket onerror during connection:', error)
|
||||
debugLog('Error type:', error.type)
|
||||
debugLog('Current readyState:', socket.readyState)
|
||||
if (!timeoutFired) {
|
||||
clearTimeout(timeout)
|
||||
reject(new Error('WebSocket connection failed'))
|
||||
}
|
||||
}
|
||||
|
||||
socket.onclose = (event) => {
|
||||
debugLog('WebSocket onclose during connection setup:', {
|
||||
code: event.code,
|
||||
reason: event.reason,
|
||||
wasClean: event.wasClean,
|
||||
readyState: socket.readyState,
|
||||
})
|
||||
if (!timeoutFired) {
|
||||
clearTimeout(timeout)
|
||||
reject(new Error(`WebSocket closed: ${event.reason || event.code}`))
|
||||
}
|
||||
}
|
||||
|
||||
debugLog('Event handlers set, waiting for connection...')
|
||||
})
|
||||
|
||||
debugLog('WebSocket connected successfully, creating RelayConnection instance')
|
||||
const newConnection = new RelayConnection({
|
||||
ws: socket,
|
||||
onClose: () => {
|
||||
debugLog('=== Relay connection onClose callback triggered ===')
|
||||
const { connectedTabs } = useExtensionStore.getState()
|
||||
debugLog('Connected tabs before potential reconnection:', Array.from(connectedTabs.keys()))
|
||||
useExtensionStore.setState({ connection: undefined, connectionState: 'disconnected' })
|
||||
|
||||
if (connectedTabs.size > 0) {
|
||||
debugLog('Tabs still connected, triggering reconnection')
|
||||
void reconnect()
|
||||
} else {
|
||||
debugLog('No tabs to reconnect')
|
||||
}
|
||||
},
|
||||
onTabDetached: (tabId, reason) => {
|
||||
debugLog('=== Manual tab detachment detected for tab:', tabId, '===')
|
||||
debugLog('User closed debugger via Chrome automation bar')
|
||||
|
||||
useExtensionStore.setState((state) => {
|
||||
const newTabs = new Map(state.connectedTabs)
|
||||
newTabs.delete(tabId)
|
||||
return { connectedTabs: newTabs }
|
||||
})
|
||||
if (reason === chrome.debugger.DetachReason.CANCELED_BY_USER) {
|
||||
// if user cancels debugger. disconnect everything
|
||||
useExtensionStore.setState({ connectionState: 'disconnected' })
|
||||
}
|
||||
debugLog('Removed tab from _connectedTabs map')
|
||||
},
|
||||
})
|
||||
|
||||
useExtensionStore.setState({ connection: newConnection })
|
||||
debugLog('Connection established, WebSocket open (caller should set connectionState)')
|
||||
}
|
||||
|
||||
async function connectTab(tabId: number): Promise<void> {
|
||||
try {
|
||||
debugLog(`=== Starting connection to tab ${tabId} ===`)
|
||||
|
||||
useExtensionStore.setState((state) => {
|
||||
const newTabs = new Map(state.connectedTabs)
|
||||
newTabs.set(tabId, '')
|
||||
return { connectedTabs: newTabs }
|
||||
})
|
||||
|
||||
await ensureConnection()
|
||||
|
||||
debugLog('Calling attachTab for tab:', tabId)
|
||||
const { connection } = useExtensionStore.getState()
|
||||
if (!connection) return
|
||||
const targetInfo = await connection.attachTab(tabId)
|
||||
debugLog('attachTab completed, updating targetId in connectedTabs map')
|
||||
useExtensionStore.setState((state) => {
|
||||
const newTabs = new Map(state.connectedTabs)
|
||||
|
||||
newTabs.set(tabId, targetInfo?.targetId)
|
||||
return { connectedTabs: newTabs, connectionState: 'connected' }
|
||||
})
|
||||
|
||||
debugLog(`=== Successfully connected to tab ${tabId} ===`)
|
||||
} catch (error: any) {
|
||||
debugLog(`=== Failed to connect to tab ${tabId} ===`)
|
||||
debugLog('Error details:', error)
|
||||
debugLog('Error stack:', error.stack)
|
||||
|
||||
useExtensionStore.setState((state) => {
|
||||
const newTabs = new Map(state.connectedTabs)
|
||||
newTabs.delete(tabId)
|
||||
return { connectedTabs: newTabs, connectionState: 'error', errorText: `Error: ${error.message}` }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectTab(tabId: number): Promise<void> {
|
||||
debugLog(`=== Disconnecting tab ${tabId} ===`)
|
||||
|
||||
const { connectedTabs, connection } = useExtensionStore.getState()
|
||||
if (!connectedTabs.has(tabId)) {
|
||||
debugLog('Tab not in connectedTabs map, ignoring disconnect')
|
||||
return
|
||||
}
|
||||
|
||||
debugLog('Calling detachTab on connection')
|
||||
connection?.detachTab(tabId)
|
||||
useExtensionStore.setState((state) => {
|
||||
const newTabs = new Map(state.connectedTabs)
|
||||
newTabs.delete(tabId)
|
||||
return { connectedTabs: newTabs }
|
||||
})
|
||||
debugLog('Tab removed from connectedTabs map')
|
||||
|
||||
const { connectedTabs: updatedTabs, connection: updatedConnection } = useExtensionStore.getState()
|
||||
debugLog('Connected tabs remaining:', updatedTabs.size)
|
||||
if (updatedTabs.size === 0 && updatedConnection) {
|
||||
debugLog('No tabs remaining, closing relay connection')
|
||||
updatedConnection.close('All tabs disconnected')
|
||||
useExtensionStore.setState({ connection: undefined, connectionState: 'disconnected' })
|
||||
}
|
||||
}
|
||||
|
||||
async function reconnect(): Promise<void> {
|
||||
debugLog('=== Starting reconnection ===')
|
||||
const { connectedTabs } = useExtensionStore.getState()
|
||||
debugLog('Tabs to reconnect:', Array.from(connectedTabs.keys()))
|
||||
|
||||
try {
|
||||
await ensureConnection()
|
||||
|
||||
const tabsToReconnect = Array.from(connectedTabs.keys())
|
||||
debugLog('Re-attaching', tabsToReconnect.length, 'tabs')
|
||||
|
||||
for (const tabId of tabsToReconnect) {
|
||||
const { connectedTabs: currentTabs } = useExtensionStore.getState()
|
||||
if (!currentTabs.has(tabId)) {
|
||||
debugLog('Tab', tabId, 'was manually disconnected during reconnection, skipping')
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
debugLog('Checking if tab', tabId, 'still exists')
|
||||
await chrome.tabs.get(tabId)
|
||||
|
||||
debugLog('Re-attaching tab:', tabId)
|
||||
const { connection } = useExtensionStore.getState()
|
||||
if (!connection) return
|
||||
const targetInfo = await connection.attachTab(tabId)
|
||||
useExtensionStore.setState((state) => {
|
||||
const newTabs = new Map(state.connectedTabs)
|
||||
newTabs.set(tabId, targetInfo.targetId)
|
||||
return { connectedTabs: newTabs }
|
||||
})
|
||||
debugLog('Successfully re-attached tab:', tabId)
|
||||
} catch (error: any) {
|
||||
debugLog('Failed to re-attach tab:', tabId, error.message)
|
||||
useExtensionStore.setState((state) => {
|
||||
const newTabs = new Map(state.connectedTabs)
|
||||
newTabs.delete(tabId)
|
||||
return { connectedTabs: newTabs }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const { connectedTabs: finalTabs } = useExtensionStore.getState()
|
||||
debugLog('=== Reconnection complete ===')
|
||||
debugLog('Successfully reconnected tabs:', finalTabs.size)
|
||||
|
||||
if (finalTabs.size > 0) {
|
||||
useExtensionStore.setState({ connectionState: 'connected' })
|
||||
debugLog('Set connectionState to connected')
|
||||
} else {
|
||||
debugLog('No tabs successfully reconnected, staying in reconnecting state')
|
||||
useExtensionStore.setState({ connectionState: 'disconnected' })
|
||||
}
|
||||
} catch (error: any) {
|
||||
debugLog('=== Reconnection failed ===', error)
|
||||
|
||||
useExtensionStore.setState({
|
||||
connectedTabs: new Map(),
|
||||
connectionState: 'error',
|
||||
errorText: 'Reconnection failed - Click to retry',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function onTabRemoved(tabId: number): Promise<void> {
|
||||
const { connectedTabs } = useExtensionStore.getState()
|
||||
debugLog('Tab removed event for tab:', tabId, 'is connected:', connectedTabs.has(tabId))
|
||||
if (!connectedTabs.has(tabId)) return
|
||||
|
||||
debugLog(`Connected tab ${tabId} was closed, disconnecting`)
|
||||
await disconnectTab(tabId)
|
||||
}
|
||||
|
||||
async function onTabActivated(activeInfo: chrome.tabs.TabActiveInfo): Promise<void> {
|
||||
debugLog('Tab activated:', activeInfo.tabId)
|
||||
useExtensionStore.setState({ currentTabId: activeInfo.tabId })
|
||||
}
|
||||
|
||||
async function onActionClicked(tab: chrome.tabs.Tab): Promise<void> {
|
||||
if (!tab.id) {
|
||||
debugLog('No tab ID available')
|
||||
return
|
||||
}
|
||||
|
||||
const { connectedTabs, connectionState, connection } = useExtensionStore.getState()
|
||||
|
||||
if (connectionState === 'reconnecting' || connectionState === 'error') {
|
||||
debugLog('User clicked during reconnection/error, canceling and disconnecting all tabs')
|
||||
|
||||
const tabsToDisconnect = Array.from(connectedTabs.keys())
|
||||
|
||||
for (const tabId of tabsToDisconnect) {
|
||||
connection?.detachTab(tabId)
|
||||
}
|
||||
|
||||
useExtensionStore.setState({ connectionState: 'disconnected', connectedTabs: new Map(), errorText: undefined })
|
||||
|
||||
if (connection) {
|
||||
connection.close('User cancelled reconnection')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (connectedTabs.has(tab.id)) {
|
||||
await disconnectTab(tab.id)
|
||||
} else {
|
||||
await connectTab(tab.id)
|
||||
}
|
||||
}
|
||||
|
||||
debugLog(`Using relay URL: ${RELAY_URL}`)
|
||||
chrome.tabs.onRemoved.addListener(onTabRemoved)
|
||||
chrome.tabs.onActivated.addListener(onTabActivated)
|
||||
chrome.action.onClicked.addListener(onActionClicked)
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* 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 { Protocol } from 'playwriter/src/cdp-types';
|
||||
import type { ExtensionCommandMessage, ExtensionResponseMessage } from 'playwriter/src/extension/protocol';
|
||||
|
||||
export function debugLog(...args: unknown[]): void {
|
||||
const enabled = true;
|
||||
if (enabled) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[Extension]', ...args);
|
||||
}
|
||||
}
|
||||
|
||||
interface AttachedTab {
|
||||
debuggee: chrome.debugger.Debuggee;
|
||||
targetId: string;
|
||||
sessionId: string;
|
||||
targetInfo: Protocol.Target.TargetInfo;
|
||||
// Cache execution contexts for this tab. When Playwright reconnects and calls Runtime.enable,
|
||||
// Chrome's debugger does NOT re-send Runtime.executionContextCreated events for contexts that
|
||||
// already exist. We must manually replay them so Playwright knows what contexts are available.
|
||||
// Without this, page.evaluate() hangs because Playwright has no valid execution context IDs.
|
||||
executionContexts: Map<number, Protocol.Runtime.ExecutionContextCreatedEvent>;
|
||||
}
|
||||
|
||||
export class RelayConnection {
|
||||
private _attachedTabs: Map<number, AttachedTab> = new Map();
|
||||
private _nextSessionId: number = 1;
|
||||
private _ws: WebSocket;
|
||||
private _closed = false;
|
||||
private _onCloseCallback?: () => void;
|
||||
private _onTabDetachedCallback?: (tabId: number, reason: `${chrome.debugger.DetachReason}`) => void
|
||||
|
||||
constructor({ ws, onClose, onTabDetached }: {
|
||||
ws: WebSocket;
|
||||
onClose?: () => void;
|
||||
onTabDetached?: (tabId: number, reason: `${chrome.debugger.DetachReason}`) => void;
|
||||
}) {
|
||||
this._ws = ws;
|
||||
this._onCloseCallback = onClose;
|
||||
this._onTabDetachedCallback = onTabDetached;
|
||||
this._ws.onmessage = async (event: MessageEvent) => {
|
||||
let message: ExtensionCommandMessage;
|
||||
try {
|
||||
message = JSON.parse(event.data);
|
||||
} catch (error: any) {
|
||||
debugLog('Error parsing message:', error);
|
||||
this._sendMessage({
|
||||
error: {
|
||||
code: -32700,
|
||||
message: `Error parsing message: ${error.message}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog('Received message:', message);
|
||||
|
||||
const response: ExtensionResponseMessage = {
|
||||
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);
|
||||
};
|
||||
this._ws.onclose = (event: CloseEvent) => {
|
||||
debugLog('WebSocket onclose event:', {
|
||||
code: event.code,
|
||||
reason: event.reason,
|
||||
wasClean: event.wasClean
|
||||
});
|
||||
this._onClose();
|
||||
};
|
||||
this._ws.onerror = (event: Event) => {
|
||||
debugLog('WebSocket onerror event:', event);
|
||||
};
|
||||
chrome.debugger.onEvent.addListener(this._onDebuggerEvent);
|
||||
chrome.debugger.onDetach.addListener(this._onDebuggerDetach);
|
||||
debugLog('RelayConnection created, WebSocket readyState:', this._ws.readyState);
|
||||
}
|
||||
|
||||
async attachTab(tabId: number): Promise<Protocol.Target.TargetInfo> {
|
||||
const debuggee = { tabId };
|
||||
|
||||
debugLog('Attaching debugger to tab:', tabId, 'WebSocket state:', this._ws.readyState);
|
||||
|
||||
try {
|
||||
await chrome.debugger.attach(debuggee, '1.3');
|
||||
debugLog('Debugger attached successfully to tab:', tabId);
|
||||
} catch (error: any) {
|
||||
debugLog('ERROR attaching debugger to tab:', tabId, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
debugLog('Sending Target.getTargetInfo command for tab:', tabId);
|
||||
const result = await chrome.debugger.sendCommand(
|
||||
debuggee,
|
||||
'Target.getTargetInfo'
|
||||
) as Protocol.Target.GetTargetInfoResponse;
|
||||
|
||||
debugLog('Received targetInfo for tab:', tabId, result.targetInfo);
|
||||
|
||||
const targetInfo = result.targetInfo;
|
||||
const sessionId = `pw-tab-${this._nextSessionId++}`;
|
||||
|
||||
this._attachedTabs.set(tabId, {
|
||||
debuggee,
|
||||
targetId: targetInfo.targetId,
|
||||
sessionId,
|
||||
targetInfo,
|
||||
executionContexts: new Map()
|
||||
});
|
||||
|
||||
debugLog('Sending Target.attachedToTarget event, WebSocket state:', this._ws.readyState);
|
||||
this._sendMessage({
|
||||
method: 'forwardCDPEvent',
|
||||
params: {
|
||||
method: 'Target.attachedToTarget',
|
||||
params: {
|
||||
sessionId,
|
||||
targetInfo: {
|
||||
...targetInfo,
|
||||
attached: true
|
||||
},
|
||||
waitingForDebugger: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
debugLog('Tab attached successfully:', tabId, 'sessionId:', sessionId, 'targetId:', targetInfo.targetId);
|
||||
return targetInfo;
|
||||
}
|
||||
|
||||
detachTab(tabId: number): void {
|
||||
const tab = this._attachedTabs.get(tabId);
|
||||
if (!tab) {
|
||||
debugLog('detachTab: tab not found in map:', tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog('Detaching tab:', tabId, 'sessionId:', tab.sessionId);
|
||||
|
||||
this._sendMessage({
|
||||
method: 'forwardCDPEvent',
|
||||
params: {
|
||||
method: 'Target.detachedFromTarget',
|
||||
params: {
|
||||
sessionId: tab.sessionId,
|
||||
targetId: tab.targetId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this._attachedTabs.delete(tabId);
|
||||
debugLog('Removed tab from _attachedTabs map. Remaining tabs:', this._attachedTabs.size);
|
||||
|
||||
chrome.debugger.detach(tab.debuggee)
|
||||
.then(() => {
|
||||
debugLog('Successfully detached debugger from tab:', tabId);
|
||||
})
|
||||
.catch((err) => {
|
||||
debugLog('Error detaching debugger from tab:', tabId, err.message);
|
||||
});
|
||||
}
|
||||
|
||||
close(message: string): void {
|
||||
debugLog('Closing RelayConnection, reason:', message, 'current state:', this._ws.readyState);
|
||||
this._ws.close(1000, message);
|
||||
this._onClose();
|
||||
}
|
||||
|
||||
private _onClose() {
|
||||
if (this._closed) {
|
||||
debugLog('_onClose called but already closed');
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog('Connection closing, attached tabs count:', this._attachedTabs.size);
|
||||
this._closed = true;
|
||||
|
||||
|
||||
chrome.debugger.onEvent.removeListener(this._onDebuggerEvent);
|
||||
chrome.debugger.onDetach.removeListener(this._onDebuggerDetach);
|
||||
|
||||
const tabIds = Array.from(this._attachedTabs.keys());
|
||||
debugLog('Detaching all tabs:', tabIds);
|
||||
|
||||
for (const [tabId, tab] of this._attachedTabs) {
|
||||
debugLog('Detaching debugger from tab:', tabId);
|
||||
chrome.debugger.detach(tab.debuggee)
|
||||
.then(() => {
|
||||
debugLog('Successfully detached from tab:', tabId);
|
||||
})
|
||||
.catch((err) => {
|
||||
debugLog('Error detaching from tab:', tabId, err.message);
|
||||
});
|
||||
}
|
||||
|
||||
this._attachedTabs.clear();
|
||||
debugLog('All tabs cleared from map. Chrome automation bar should disappear in a few seconds.');
|
||||
|
||||
debugLog('Connection closed, calling onClose callback');
|
||||
this._onCloseCallback?.();
|
||||
}
|
||||
|
||||
private _onDebuggerEvent = (source: chrome.debugger.DebuggerSession, method: string, params: any): void => {
|
||||
const tab = this._attachedTabs.get(source.tabId!);
|
||||
if (!tab) return;
|
||||
|
||||
// Track execution contexts so we can replay them when Playwright reconnects.
|
||||
// Chrome's debugger only sends Runtime.executionContextCreated events once per context,
|
||||
// not on every Runtime.enable call. We cache them here and replay on reconnection.
|
||||
if (method === 'Runtime.executionContextCreated') {
|
||||
const contextEvent = params as Protocol.Runtime.ExecutionContextCreatedEvent;
|
||||
tab.executionContexts.set(contextEvent.context.id, contextEvent);
|
||||
debugLog('Cached execution context:', contextEvent.context.id, 'for tab:', source.tabId, 'total contexts:', tab.executionContexts.size);
|
||||
} else if (method === 'Runtime.executionContextDestroyed') {
|
||||
const destroyedEvent = params as Protocol.Runtime.ExecutionContextDestroyedEvent;
|
||||
tab.executionContexts.delete(destroyedEvent.executionContextId);
|
||||
debugLog('Removed execution context:', destroyedEvent.executionContextId, 'from tab:', source.tabId, 'remaining:', tab.executionContexts.size);
|
||||
} else if (method === 'Runtime.executionContextsCleared') {
|
||||
tab.executionContexts.clear();
|
||||
debugLog('Cleared all execution contexts for tab:', source.tabId);
|
||||
}
|
||||
|
||||
debugLog('Forwarding CDP event:', method, 'from tab:', source.tabId);
|
||||
|
||||
this._sendMessage({
|
||||
method: 'forwardCDPEvent',
|
||||
params: {
|
||||
sessionId: source.sessionId || tab.sessionId,
|
||||
method,
|
||||
params,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private _onDebuggerDetach = (source: chrome.debugger.Debuggee, reason: `${chrome.debugger.DetachReason}`): void => {
|
||||
const tabId = source.tabId;
|
||||
debugLog('_onDebuggerDetach called for tab:', tabId, 'reason:', reason, 'isAttached:', tabId ? this._attachedTabs.has(tabId) : false);
|
||||
|
||||
if (!tabId || !this._attachedTabs.has(tabId)) {
|
||||
debugLog('Ignoring debugger detach event for untracked tab:', tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog(`Manual debugger detachment detected for tab ${tabId}: ${reason}`);
|
||||
debugLog('User closed debugger via Chrome automation bar, calling onTabDetached callback');
|
||||
this._onTabDetachedCallback?.(tabId, reason);
|
||||
|
||||
this.detachTab(tabId);
|
||||
};
|
||||
|
||||
private async _handleCommand(message: ExtensionCommandMessage): Promise<any> {
|
||||
if (message.method === 'attachToTab') {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (message.method === 'forwardCDPCommand') {
|
||||
const { sessionId, method, params } = message.params;
|
||||
|
||||
if (method === 'Target.createTarget') {
|
||||
const url = params?.url || 'about:blank';
|
||||
debugLog('Creating new tab with URL:', url);
|
||||
|
||||
const tab = await chrome.tabs.create({ url, active: false });
|
||||
if (!tab.id) {
|
||||
throw new Error('Failed to create tab');
|
||||
}
|
||||
|
||||
debugLog('Created tab:', tab.id, 'waiting for it to load...');
|
||||
|
||||
// Wait a bit for tab to initialize
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Attach to the new tab
|
||||
const targetInfo = await this.attachTab(tab.id);
|
||||
|
||||
return { targetId: targetInfo.targetId };
|
||||
}
|
||||
|
||||
if (method === 'Target.closeTarget' && params?.targetId) {
|
||||
debugLog('Closing target:', params.targetId);
|
||||
|
||||
for (const [tabId, tab] of this._attachedTabs) {
|
||||
if (tab.targetId === params.targetId) {
|
||||
debugLog('Found tab to close:', tabId);
|
||||
await chrome.tabs.remove(tabId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
debugLog('Target not found:', params.targetId);
|
||||
throw new Error(`Target not found: ${params.targetId}`);
|
||||
}
|
||||
|
||||
let targetTab: AttachedTab | undefined;
|
||||
|
||||
for (const [tabId, tab] of this._attachedTabs) {
|
||||
if (tab.sessionId === sessionId) {
|
||||
targetTab = tab;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetTab) {
|
||||
if (method === 'Browser.getVersion' || method === 'Target.getTargets') {
|
||||
targetTab = this._attachedTabs.values().next().value;
|
||||
}
|
||||
|
||||
if (!targetTab) {
|
||||
throw new Error(`No tab found for sessionId: ${sessionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
debugLog('CDP command:', method, 'for tab:', targetTab.debuggee.tabId);
|
||||
|
||||
const debuggerSession: chrome.debugger.DebuggerSession = {
|
||||
...targetTab.debuggee,
|
||||
sessionId: sessionId !== targetTab.sessionId ? sessionId : undefined,
|
||||
};
|
||||
|
||||
const result = await chrome.debugger.sendCommand(
|
||||
debuggerSession,
|
||||
method,
|
||||
params
|
||||
);
|
||||
|
||||
// When Playwright reconnects and calls Runtime.enable, Chrome does NOT automatically
|
||||
// re-send Runtime.executionContextCreated events for contexts that already exist.
|
||||
// This causes page.evaluate() to hang because Playwright has no execution context IDs.
|
||||
// Solution: manually replay all cached contexts so Playwright gets fresh, valid IDs.
|
||||
if (method === 'Runtime.enable' && targetTab.executionContexts.size > 0) {
|
||||
debugLog('Runtime.enable called, replaying', targetTab.executionContexts.size, 'cached execution contexts for tab:', targetTab.debuggee.tabId);
|
||||
|
||||
for (const contextEvent of targetTab.executionContexts.values()) {
|
||||
debugLog('Replaying execution context:', contextEvent.context.id);
|
||||
this._sendMessage({
|
||||
method: 'forwardCDPEvent',
|
||||
params: {
|
||||
sessionId: sessionId,
|
||||
method: 'Runtime.executionContextCreated',
|
||||
params: contextEvent,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private _sendMessage(message: any): void {
|
||||
if (this._ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this._ws.send(JSON.stringify(message));
|
||||
debugLog('Message sent successfully, type:', message.method || 'response');
|
||||
} catch (error: any) {
|
||||
debugLog('ERROR sending message:', error, 'message type:', message.method || 'response');
|
||||
}
|
||||
} else {
|
||||
debugLog('Cannot send message, WebSocket not open. State:', this._ws.readyState, 'message type:', message.method || 'response');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"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",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
],
|
||||
"exclude": [
|
||||
"src/ui",
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 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 { dirname, resolve } from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
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]'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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 { fileURLToPath } from 'url';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: resolve(__dirname, 'icons/*'),
|
||||
dest: 'icons'
|
||||
},
|
||||
{
|
||||
src: resolve(__dirname, 'manifest.json'),
|
||||
dest: '.'
|
||||
},
|
||||
{
|
||||
src: resolve(__dirname, 'welcome.html'),
|
||||
dest: '.'
|
||||
}
|
||||
]
|
||||
})
|
||||
],
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/background.ts'),
|
||||
fileName: 'lib/background',
|
||||
formats: ['es']
|
||||
},
|
||||
outDir: 'dist',
|
||||
emptyOutDir: false,
|
||||
minify: false
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Welcome to Playwriter MCP</title>
|
||||
<style>
|
||||
:root {
|
||||
--color-bg: #ffffff;
|
||||
--color-text: #24292f;
|
||||
--color-heading: #1f2328;
|
||||
--color-border: #d0d7de;
|
||||
--color-code-bg: #f6f8fa;
|
||||
--color-link: #0969da;
|
||||
--color-link-hover: #0550ae;
|
||||
--color-success: #1a7f37;
|
||||
--color-muted: #57606a;
|
||||
--max-width: 640px;
|
||||
--spacing: 1.5rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-bg: #0d1117;
|
||||
--color-text: #e6edf3;
|
||||
--color-heading: #f0f6fc;
|
||||
--color-border: #30363d;
|
||||
--color-code-bg: #161b22;
|
||||
--color-link: #4493f8;
|
||||
--color-link-hover: #539bf5;
|
||||
--color-success: #3fb950;
|
||||
--color-muted: #8d96a0;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans",
|
||||
Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-bg);
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-heading);
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-heading);
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--color-link-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
||||
"Liberation Mono", monospace;
|
||||
font-size: 0.875em;
|
||||
background-color: var(--color-code-bg);
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: var(--color-code-bg);
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.step-number {
|
||||
color: var(--color-link);
|
||||
font-weight: 700;
|
||||
font-size: 1.125rem;
|
||||
margin-right: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 3rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
text-align: center;
|
||||
color: var(--color-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section>
|
||||
<h2>Getting Started with Playwriter MCP</h2>
|
||||
|
||||
<p>
|
||||
Control your browser via extension instead of spawning a full new
|
||||
Chrome window. Uses less context window by running Playwright CDP
|
||||
protocol directly. Collaborate with your agent and help get it
|
||||
unstuck when needed.
|
||||
</p>
|
||||
|
||||
<h3>Quick Start</h3>
|
||||
|
||||
<div class="step">
|
||||
<span class="step-number">1</span>
|
||||
<div class="step-content">
|
||||
<strong>Pin this extension on Chrome toolbar</strong>
|
||||
<p>
|
||||
Click the puzzle icon in your Chrome toolbar and pin the
|
||||
Playwriter MCP extension for easy access.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<span class="step-number">2</span>
|
||||
<div class="step-content">
|
||||
<strong>Click the extension icon</strong>
|
||||
<p>
|
||||
Click the Playwriter MCP extension icon in your browser toolbar
|
||||
to attach the debugger to the current tab. The icon will turn
|
||||
green when successfully connected.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<span class="step-number">3</span>
|
||||
<div class="step-content">
|
||||
<strong>Add the MCP to your agent</strong>
|
||||
<p>
|
||||
Add the following configuration to your MCP client settings (e.g.,
|
||||
Claude Desktop's <code>claude_desktop_config.json</code>):
|
||||
</p>
|
||||
<pre><code>{
|
||||
"mcpServers": {
|
||||
"playwriter": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"playwriter"
|
||||
]
|
||||
}
|
||||
}
|
||||
}</code></pre>
|
||||
<p>
|
||||
This will enable your AI assistant to control the browser through
|
||||
the extension.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>How It Works</h2>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<strong>No new Chrome instances:</strong> Works with your current
|
||||
browser session
|
||||
</li>
|
||||
<li>
|
||||
<strong>No CDP mode required:</strong> No need to restart Chrome
|
||||
with special flags
|
||||
</li>
|
||||
<li>
|
||||
<strong>Full CDP access:</strong> Complete Chrome DevTools Protocol
|
||||
capabilities
|
||||
</li>
|
||||
<li>
|
||||
<strong>Visual feedback:</strong> Extension icon changes color to
|
||||
indicate connection status
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Icon States</h2>
|
||||
|
||||
<ul>
|
||||
<li><strong>Gray:</strong> Not connected to any tab</li>
|
||||
<li><strong>Green:</strong> Successfully connected and ready</li>
|
||||
<li><strong>Orange badge (...):</strong> Connecting to relay server</li>
|
||||
<li><strong>Red badge (!):</strong> Error occurred</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Privacy & Security</h2>
|
||||
|
||||
<p>
|
||||
Playwriter MCP runs locally in your browser and does not send any
|
||||
data to external servers. All browser control happens through the
|
||||
standard Chrome DevTools Protocol on your machine.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Need Help?</h2>
|
||||
|
||||
<p>
|
||||
For issues, feature requests, or contributions, visit the
|
||||
<a
|
||||
href="https://github.com/remorses/playwriter"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
GitHub repository
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<p>
|
||||
Playwriter MCP © Microsoft Corporation · Licensed under
|
||||
Apache-2.0
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||