This commit is contained in:
Tommy D. Rossi
2025-11-16 11:30:28 +01:00
parent a8dc08df1b
commit a2fd82d6d8
19 changed files with 0 additions and 0 deletions
+61
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 999 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 764 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

+33
View File
@@ -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"
}
}
+35
View File
@@ -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"
}
}
+406
View File
@@ -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)
+384
View File
@@ -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');
}
}
}
+22
View File
@@ -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",
]
}
+19
View 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",
],
}
+43
View File
@@ -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]'
}
}
}
});
+54
View 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 { 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
}
});
+290
View File
@@ -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 &amp; 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 &copy; Microsoft Corporation &middot; Licensed under
Apache-2.0
</p>
</footer>
</main>
</body>
</html>