add support for remote agent in different host than extension. add

support for editing css.
This commit is contained in:
Tommy D. Rossi
2025-12-27 18:48:52 +01:00
parent e63dc52c68
commit e4695e231d
15 changed files with 495 additions and 195 deletions
+18
View File
@@ -177,6 +177,24 @@ The irony is that by trying to make browser control "simpler" with dedicated too
+---------------------+
```
## Remote Agents (Devcontainers, VMs, SSH)
Run agents in isolated environments (devcontainers, VMs, SSH) while controlling Chrome on your host.
**On host (where Chrome runs):**
```bash
npx playwriter serve --token <secret>
```
**In container/VM (where agent runs):**
```bash
export PLAYWRITER_URL="ws://host.docker.internal:19988?token=<secret>"
```
Use `host.docker.internal` for devcontainers, or your host's IP for VMs/SSH.
## Security
Playwriter is designed with security in mind, ensuring that only you can control your browser.
+1 -1
View File
@@ -1,3 +1,3 @@
#!/usr/bin/env node
import './dist/mcp.js'
import './dist/cli.js'
+1
View File
@@ -41,6 +41,7 @@
"@hono/node-server": "^1.19.6",
"@hono/node-ws": "^1.2.0",
"@modelcontextprotocol/sdk": "^1.21.1",
"cac": "^6.7.14",
"chalk": "^5.6.2",
"devtools-protocol": "^0.0.1543509",
"diff": "^8.0.2",
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env node
import { cac } from 'cac'
import { startPlayWriterCDPRelayServer } from './extension/cdp-relay.js'
import { createFileLogger } from './create-logger.js'
import { VERSION } from './utils.js'
const RELAY_PORT = 19988
const cli = cac('playwriter')
cli
.command('', 'Start the MCP server (default)')
.action(async () => {
const { startMcp } = await import('./mcp.js')
await startMcp()
})
cli
.command('serve', 'Start the CDP relay server for remote MCP connections')
.option('--host <host>', 'Host to bind to', { default: '0.0.0.0' })
.option('--token <token>', 'Authentication token for /cdp/* endpoints')
.action(async (options: { host: string; token?: string }) => {
const logger = createFileLogger()
process.title = 'playwriter-serve'
process.on('uncaughtException', async (err) => {
await logger.error('Uncaught Exception:', err)
process.exit(1)
})
process.on('unhandledRejection', async (reason) => {
await logger.error('Unhandled Rejection:', reason)
process.exit(1)
})
const server = await startPlayWriterCDPRelayServer({
port: RELAY_PORT,
host: options.host,
token: options.token,
logger,
})
console.log('Playwriter CDP relay server started')
console.log(` Host: ${options.host}`)
console.log(` Port: ${RELAY_PORT}`)
console.log(` Token: ${options.token ? '(configured)' : '(none)'}`)
console.log(` Logs: ${logger.logFilePath}`)
console.log('')
console.log('Endpoints:')
console.log(` Extension: ws://${options.host}:${RELAY_PORT}/extension`)
console.log(` CDP: ws://${options.host}:${RELAY_PORT}/cdp/<client-id>${options.token ? '?token=<token>' : ''}`)
console.log('')
console.log('Press Ctrl+C to stop.')
process.on('SIGINT', () => {
console.log('\nShutting down...')
server.close()
process.exit(0)
})
process.on('SIGTERM', () => {
console.log('\nShutting down...')
server.close()
process.exit(0)
})
})
cli.help()
cli.version(VERSION)
cli.parse()
+47 -3
View File
@@ -6,7 +6,7 @@ async function listScripts() {
const editor = createEditor({ cdp })
await editor.enable()
const scripts = editor.list({ search: 'app' })
const scripts = editor.list({ pattern: /app/ })
console.log(scripts)
}
@@ -62,7 +62,7 @@ async function searchScripts() {
const todoMatches = await editor.grep({
regex: /TODO|FIXME/i,
include: 'app',
pattern: /app/,
})
console.log(todoMatches)
}
@@ -101,4 +101,48 @@ async function editInlineScript() {
}
}
export { listScripts, readScript, editScript, searchScripts, writeScript, editInlineScript }
// Example: List and read CSS stylesheets
async function readStylesheet() {
const cdp = await getCDPSession({ page })
const editor = createEditor({ cdp })
await editor.enable()
const stylesheets = editor.list({ pattern: /\.css/ })
console.log('Stylesheets:', stylesheets)
if (stylesheets.length > 0) {
const { content, totalLines } = await editor.read({
url: stylesheets[0],
})
console.log('Total lines:', totalLines)
console.log(content)
}
}
// Example: Edit a CSS stylesheet
async function editStylesheet() {
const cdp = await getCDPSession({ page })
const editor = createEditor({ cdp })
await editor.enable()
await editor.edit({
url: 'https://example.com/styles.css',
oldString: 'color: red',
newString: 'color: blue',
})
}
// Example: Search CSS for specific properties
async function searchStyles() {
const cdp = await getCDPSession({ page })
const editor = createEditor({ cdp })
await editor.enable()
const matches = await editor.grep({
regex: /background-color/,
pattern: /\.css/,
})
console.log(matches)
}
export { listScripts, readScript, editScript, searchScripts, writeScript, editInlineScript, readStylesheet, editStylesheet, searchStyles }
+128 -109
View File
@@ -1,10 +1,5 @@
import type { CDPSession } from './cdp-session.js'
export interface ScriptInfo {
url: string
scriptId: string
}
export interface ReadResult {
content: string
totalLines: number
@@ -53,8 +48,8 @@ export interface EditResult {
export class Editor {
private cdp: CDPSession
private enabled = false
private scripts = new Map<string, ScriptInfo>()
private scriptsByUrl = new Map<string, ScriptInfo>()
private scripts = new Map<string, string>()
private stylesheets = new Map<string, string>()
private sourceCache = new Map<string, string>()
constructor({ cdp }: { cdp: CDPSession }) {
@@ -66,15 +61,20 @@ export class Editor {
this.cdp.on('Debugger.scriptParsed', (params) => {
if (!params.url.startsWith('chrome') && !params.url.startsWith('devtools')) {
const url = params.url || `inline://${params.scriptId}`
const info: ScriptInfo = {
url,
scriptId: params.scriptId,
}
this.scripts.set(params.scriptId, info)
this.scriptsByUrl.set(url, info)
this.scripts.set(url, params.scriptId)
this.sourceCache.delete(params.scriptId)
}
})
this.cdp.on('CSS.styleSheetAdded', (params) => {
const header = params.header
if (header.sourceURL?.startsWith('chrome') || header.sourceURL?.startsWith('devtools')) {
return
}
const url = header.sourceURL || `inline-css://${header.styleSheetId}`
this.stylesheets.set(url, header.styleSheetId)
this.sourceCache.delete(header.styleSheetId)
})
}
/**
@@ -87,48 +87,67 @@ export class Editor {
return
}
await this.cdp.send('Debugger.enable')
await this.cdp.send('DOM.enable')
await this.cdp.send('CSS.enable')
this.enabled = true
}
private getScriptByUrl(url: string): ScriptInfo {
const script = this.scriptsByUrl.get(url)
if (!script) {
const available = Array.from(this.scriptsByUrl.keys()).slice(0, 5)
throw new Error(`Script not found: ${url}\nAvailable: ${available.join(', ')}${this.scriptsByUrl.size > 5 ? '...' : ''}`)
private getIdByUrl(url: string): { scriptId: string } | { styleSheetId: string } {
const scriptId = this.scripts.get(url)
if (scriptId) {
return { scriptId }
}
return script
const styleSheetId = this.stylesheets.get(url)
if (styleSheetId) {
return { styleSheetId }
}
const allUrls = [...Array.from(this.scripts.keys()), ...Array.from(this.stylesheets.keys())]
const available = allUrls.slice(0, 5)
throw new Error(`Resource not found: ${url}\nAvailable: ${available.join(', ')}${allUrls.length > 5 ? '...' : ''}`)
}
/**
* Lists available scripts. Use search to filter by URL substring.
* Lists available script and stylesheet URLs. Use pattern to filter by regex.
*
* @param options - Options
* @param options.search - Optional substring to filter URLs (case-insensitive)
* @returns Array of scripts with url and scriptId
* @param options.pattern - Optional regex to filter URLs
* @returns Array of URLs
*
* @example
* ```ts
* // List all scripts
* const scripts = editor.list()
* // List all scripts and stylesheets
* const urls = editor.list()
*
* // List only JS files
* const jsFiles = editor.list({ pattern: /\.js/ })
*
* // List only CSS files
* const cssFiles = editor.list({ pattern: /\.css/ })
*
* // Search for specific scripts
* const appScripts = editor.list({ search: 'app' })
* const reactScripts = editor.list({ search: 'react' })
* const appScripts = editor.list({ pattern: /app/ })
* ```
*/
list({ search }: { search?: string } = {}): ScriptInfo[] {
const scripts = Array.from(this.scripts.values())
const filtered = search ? scripts.filter((s) => s.url.toLowerCase().includes(search.toLowerCase())) : scripts
return filtered
list({ pattern }: { pattern?: RegExp } = {}): string[] {
const urls = [...Array.from(this.scripts.keys()), ...Array.from(this.stylesheets.keys())]
if (!pattern) {
return urls
}
return urls.filter((url) => {
const matches = pattern.test(url)
pattern.lastIndex = 0
return matches
})
}
/**
* Reads a script's source code by URL.
* Reads a script or stylesheet's source code by URL.
* Returns line-numbered content like Claude Code's Read tool.
* For inline scripts, use the `inline://` URL from list() or grep().
*
* @param options - Options
* @param options.url - Script URL (inline scripts have `inline://{id}` URLs)
* @param options.url - Script or stylesheet URL (inline scripts have `inline://{id}` URLs)
* @param options.offset - Line number to start from (0-based, default 0)
* @param options.limit - Number of lines to return (default 2000)
* @returns Content with line numbers, total lines, and range info
@@ -140,8 +159,8 @@ export class Editor {
* url: 'https://example.com/app.js'
* })
*
* // Read inline script (URL from grep result)
* const { content } = await editor.read({ url: 'inline://42' })
* // Read a CSS file
* const { content } = await editor.read({ url: 'https://example.com/styles.css' })
*
* // Read lines 100-200
* const { content } = await editor.read({
@@ -153,14 +172,8 @@ export class Editor {
*/
async read({ url, offset = 0, limit = 2000 }: { url: string; offset?: number; limit?: number }): Promise<ReadResult> {
await this.enable()
const script = this.getScriptByUrl(url)
let source = this.sourceCache.get(script.scriptId)
if (!source) {
const response = await this.cdp.send('Debugger.getScriptSource', { scriptId: script.scriptId })
source = response.scriptSource
this.sourceCache.set(script.scriptId, source)
}
const id = this.getIdByUrl(url)
const source = await this.getSource(id)
const lines = source.split('\n')
const totalLines = lines.length
@@ -178,12 +191,31 @@ export class Editor {
}
}
private async getSource(id: { scriptId: string } | { styleSheetId: string }): Promise<string> {
if ('styleSheetId' in id) {
const cached = this.sourceCache.get(id.styleSheetId)
if (cached) {
return cached
}
const response = await this.cdp.send('CSS.getStyleSheetText', { styleSheetId: id.styleSheetId })
this.sourceCache.set(id.styleSheetId, response.text)
return response.text
}
const cached = this.sourceCache.get(id.scriptId)
if (cached) {
return cached
}
const response = await this.cdp.send('Debugger.getScriptSource', { scriptId: id.scriptId })
this.sourceCache.set(id.scriptId, response.scriptSource)
return response.scriptSource
}
/**
* Edits a script by replacing oldString with newString.
* Edits a script or stylesheet by replacing oldString with newString.
* Like Claude Code's Edit tool - performs exact string replacement.
*
* @param options - Options
* @param options.url - Script URL (inline scripts have `inline://{id}` URLs)
* @param options.url - Script or stylesheet URL (inline scripts have `inline://{id}` URLs)
* @param options.oldString - Exact string to find and replace
* @param options.newString - Replacement string
* @param options.dryRun - If true, validate without applying (default false)
@@ -191,18 +223,18 @@ export class Editor {
*
* @example
* ```ts
* // Replace a string
* // Replace a string in JS
* await editor.edit({
* url: 'https://example.com/app.js',
* oldString: 'const DEBUG = false',
* newString: 'const DEBUG = true'
* })
*
* // Edit inline script
* // Edit CSS
* await editor.edit({
* url: 'inline://42',
* oldString: 'old code',
* newString: 'new code'
* url: 'https://example.com/styles.css',
* oldString: 'color: red',
* newString: 'color: blue'
* })
* ```
*/
@@ -218,14 +250,8 @@ export class Editor {
dryRun?: boolean
}): Promise<EditResult> {
await this.enable()
const script = this.getScriptByUrl(url)
let source = this.sourceCache.get(script.scriptId)
if (!source) {
const response = await this.cdp.send('Debugger.getScriptSource', { scriptId: script.scriptId })
source = response.scriptSource
this.sourceCache.set(script.scriptId, source)
}
const id = this.getIdByUrl(url)
const source = await this.getSource(id)
const matchCount = source.split(oldString).length - 1
if (matchCount === 0) {
@@ -236,72 +262,79 @@ export class Editor {
}
const newSource = source.replace(oldString, newString)
return this.setSource(id, newSource, dryRun)
}
private async setSource(
id: { scriptId: string } | { styleSheetId: string },
content: string,
dryRun = false
): Promise<EditResult> {
if ('styleSheetId' in id) {
await this.cdp.send('CSS.setStyleSheetText', { styleSheetId: id.styleSheetId, text: content })
if (!dryRun) {
this.sourceCache.set(id.styleSheetId, content)
}
return { success: true }
}
const response = await this.cdp.send('Debugger.setScriptSource', {
scriptId: script.scriptId,
scriptSource: newSource,
scriptId: id.scriptId,
scriptSource: content,
dryRun,
})
if (!dryRun) {
this.sourceCache.set(script.scriptId, newSource)
}
return {
success: true,
stackChanged: response.stackChanged,
this.sourceCache.set(id.scriptId, content)
}
return { success: true, stackChanged: response.stackChanged }
}
/**
* Searches for a regex across all scripts.
* Searches for a regex across all scripts and stylesheets.
* Like Claude Code's Grep tool - returns matching lines with context.
*
* @param options - Options
* @param options.regex - Regular expression to search for
* @param options.include - Optional URL substring to filter which scripts to search
* @param options.regex - Regular expression to search for in file contents
* @param options.pattern - Optional regex to filter which URLs to search
* @returns Array of matches with url, line number, and line content
*
* @example
* ```ts
* // Search all scripts for "fetchUser"
* const matches = await editor.grep({ regex: /fetchUser/ })
* // Search all scripts and stylesheets for "color"
* const matches = await editor.grep({ regex: /color/ })
*
* // Search only in app scripts
* // Search only CSS files
* const matches = await editor.grep({
* regex: /TODO/i,
* include: 'app'
* regex: /background-color/,
* pattern: /\.css/
* })
*
* // Regex search for console methods
* // Regex search for console methods in JS
* const matches = await editor.grep({
* regex: /console\.(log|error|warn)/
* regex: /console\.(log|error|warn)/,
* pattern: /\.js/
* })
* ```
*/
async grep({ regex, include }: { regex: RegExp; include?: string }): Promise<SearchMatch[]> {
async grep({ regex, pattern }: { regex: RegExp; pattern?: RegExp }): Promise<SearchMatch[]> {
await this.enable()
const matches: SearchMatch[] = []
const scripts = include ? this.list({ search: include }) : this.list()
const urls = this.list({ pattern })
for (const script of scripts) {
let source = this.sourceCache.get(script.scriptId)
if (!source) {
try {
const response = await this.cdp.send('Debugger.getScriptSource', { scriptId: script.scriptId })
source = response.scriptSource
this.sourceCache.set(script.scriptId, source)
} catch {
continue
}
for (const url of urls) {
let source: string
try {
const id = this.getIdByUrl(url)
source = await this.getSource(id)
} catch {
continue
}
const lines = source.split('\n')
for (let i = 0; i < lines.length; i++) {
if (regex.test(lines[i])) {
matches.push({
url: script.url,
url,
lineNumber: i + 1,
lineContent: lines[i].trim().slice(0, 200),
})
@@ -314,31 +347,17 @@ export class Editor {
}
/**
* Writes entire content to a script, replacing all existing code.
* Writes entire content to a script or stylesheet, replacing all existing code.
* Use with caution - prefer edit() for targeted changes.
*
* @param options - Options
* @param options.url - Script URL (inline scripts have `inline://{id}` URLs)
* @param options.content - New script content
* @param options.dryRun - If true, validate without applying (default false)
* @param options.url - Script or stylesheet URL (inline scripts have `inline://{id}` URLs)
* @param options.content - New content
* @param options.dryRun - If true, validate without applying (default false, only works for JS)
*/
async write({ url, content, dryRun = false }: { url: string; content: string; dryRun?: boolean }): Promise<EditResult> {
await this.enable()
const script = this.getScriptByUrl(url)
const response = await this.cdp.send('Debugger.setScriptSource', {
scriptId: script.scriptId,
scriptSource: content,
dryRun,
})
if (!dryRun) {
this.sourceCache.set(script.scriptId, content)
}
return {
success: true,
stackChanged: response.stackChanged,
}
const id = this.getIdByUrl(url)
return this.setSource(id, content, dryRun)
}
}
+11 -2
View File
@@ -29,7 +29,7 @@ export type RelayServer = {
off<K extends keyof RelayServerEvents>(event: K, listener: RelayServerEvents[K]): void
}
export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.0.0.1', logger }: { port?: number; host?: string; logger?: { log(...args: any[]): void; error(...args: any[]): void } } = {}): Promise<RelayServer> {
export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.0.0.1', token, logger }: { port?: number; host?: string; token?: string; logger?: { log(...args: any[]): void; error(...args: any[]): void } } = {}): Promise<RelayServer> {
const emitter = new EventEmitter()
const connectedTargets = new Map<string, ConnectedTarget>()
@@ -297,7 +297,16 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
}
})
app.get('/cdp/:clientId?', upgradeWebSocket((c) => {
app.get('/cdp/:clientId?', (c, next) => {
if (token) {
const url = new URL(c.req.url, 'http://localhost')
const providedToken = url.searchParams.get('token')
if (providedToken !== token) {
return c.text('Unauthorized', 401)
}
}
return next()
}, upgradeWebSocket((c) => {
const clientId = c.req.param('clientId') || 'default'
return {
+1 -1
View File
@@ -27,7 +27,7 @@ export async function createTransport({ args = [], port }: { args?: string[]; po
}
const transport = new StdioClientTransport({
command: 'pnpm',
args: ['vite-node', path.join(path.dirname(__filename), 'mcp.ts'), ...args],
args: ['vite-node', path.join(path.dirname(__filename), 'cli.ts'), ...args],
cwd: path.join(path.dirname(__filename), '..'),
stderr: 'pipe',
env,
+76
View File
@@ -2546,4 +2546,80 @@ describe('CDP Session Tests', () => {
await browser.close()
await page.close()
}, 60000)
it('editor can list, read, and edit CSS stylesheets', async () => {
const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext)
const page = await browserContext.newPage()
await page.goto('https://example.com/')
await page.bringToFront()
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 100))
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT }))
const cdpPage = browser.contexts()[0].pages().find(p => p.url().includes('example.com'))
expect(cdpPage).toBeDefined()
const wsUrl = getCdpUrl({ port: TEST_PORT })
const cdpSession = await getCDPSessionForPage({ page: cdpPage!, wsUrl })
const editor = new Editor({ cdp: cdpSession })
await editor.enable()
await cdpPage!.addStyleTag({
content: `
.editor-test-element {
color: rgb(255, 0, 0);
background-color: rgb(0, 0, 255);
}
`,
})
await new Promise(r => setTimeout(r, 100))
const stylesheets = editor.list({ pattern: /inline-css:/ })
expect(stylesheets.length).toBeGreaterThan(0)
const cssMatches = await editor.grep({ regex: /editor-test-element/, pattern: /inline-css:/ })
expect(cssMatches.length).toBeGreaterThan(0)
const cssMatch = cssMatches[0]
const { content, totalLines } = await editor.read({ url: cssMatch.url })
expect(content).toContain('editor-test-element')
expect(content).toContain('rgb(255, 0, 0)')
expect(totalLines).toBeGreaterThan(0)
await cdpPage!.evaluate(() => {
const el = document.createElement('div')
el.className = 'editor-test-element'
el.id = 'test-div'
el.textContent = 'Test'
document.body.appendChild(el)
})
const colorBefore = await cdpPage!.evaluate(() => {
const el = document.getElementById('test-div')!
return window.getComputedStyle(el).color
})
expect(colorBefore).toBe('rgb(255, 0, 0)')
await editor.edit({
url: cssMatch.url,
oldString: 'color: rgb(255, 0, 0);',
newString: 'color: rgb(0, 255, 0);',
})
const colorAfter = await cdpPage!.evaluate(() => {
const el = document.getElementById('test-div')!
return window.getComputedStyle(el).color
})
expect(colorAfter).toBe('rgb(0, 255, 0)')
cdpSession.close()
await browser.close()
await page.close()
}, 60000)
})
+60 -13
View File
@@ -104,8 +104,21 @@ const lastSnapshots: WeakMap<Page, string> = new WeakMap()
const cdpSessionCache: WeakMap<Page, CDPSession> = new WeakMap()
const RELAY_PORT = Number(process.env.PLAYWRITER_PORT) || 19988
const REMOTE_URL = process.env.PLAYWRITER_URL
const NO_TABS_ERROR = `No browser tabs are connected. Please install and enable the Playwriter extension on at least one tab: https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe`
function parseRemoteUrl() {
if (!REMOTE_URL) {
return null
}
const url = new URL(REMOTE_URL)
return {
host: url.hostname,
port: Number(url.port) || 19988,
query: url.search.slice(1),
}
}
async function setDeviceScaleFactorForMacOS(context: BrowserContext): Promise<void> {
if (os.platform() !== 'darwin') {
return
@@ -144,9 +157,17 @@ function clearConnectionState() {
state.context = null
}
function getLogServerUrl(): string {
if (REMOTE_URL) {
const url = new URL(REMOTE_URL)
return `http://${url.host}/mcp-log`
}
return `http://127.0.0.1:${RELAY_PORT}/mcp-log`
}
async function sendLogToRelayServer(level: string, ...args: any[]) {
try {
await fetch(`http://127.0.0.1:${RELAY_PORT}/mcp-log`, {
await fetch(getLogServerUrl(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ level, args }),
@@ -220,9 +241,12 @@ async function ensureConnection(): Promise<{ browser: Browser; page: Page }> {
return { browser: state.browser, page: state.page }
}
await ensureRelayServer()
const remote = parseRemoteUrl()
if (!remote) {
await ensureRelayServer()
}
const cdpEndpoint = getCdpUrl({ port: RELAY_PORT })
const cdpEndpoint = getCdpUrl(remote || { port: RELAY_PORT })
const browser = await chromium.connectOverCDP(cdpEndpoint)
const contexts = browser.contexts()
@@ -350,9 +374,12 @@ async function resetConnection(): Promise<{ browser: Browser; page: Page; contex
// DO NOT clear browser logs on reset - logs should persist across reconnections
// browserLogs.clear()
await ensureRelayServer()
const remote = parseRemoteUrl()
if (!remote) {
await ensureRelayServer()
}
const cdpEndpoint = getCdpUrl({ port: RELAY_PORT })
const cdpEndpoint = getCdpUrl(remote || { port: RELAY_PORT })
const browser = await chromium.connectOverCDP(cdpEndpoint)
const contexts = browser.contexts()
@@ -654,7 +681,7 @@ server.tool(
if (cached) {
return cached
}
const wsUrl = getCdpUrl({ port: RELAY_PORT })
const wsUrl = getCdpUrl(parseRemoteUrl() || { port: RELAY_PORT })
const session = await getCDPSessionForPage({ page: options.page, wsUrl })
cdpSessionCache.set(options.page, session)
return session
@@ -817,13 +844,33 @@ server.tool(
},
)
async function main() {
await ensureRelayServer()
async function checkRemoteServer(url: string): Promise<void> {
const parsed = new URL(url)
const versionUrl = `http://${parsed.host}/version`
try {
const response = await fetch(versionUrl, { signal: AbortSignal.timeout(3000) })
if (!response.ok) {
throw new Error(`Server responded with status ${response.status}`)
}
} catch (error: any) {
const isConnectionError = error.cause?.code === 'ECONNREFUSED' || error.name === 'TimeoutError'
if (isConnectionError) {
throw new Error(
`Cannot connect to remote relay server at ${parsed.host}. ` +
`Make sure 'npx playwriter serve' is running on the host machine.`,
)
}
throw new Error(`Failed to connect to remote relay server: ${error.message}`)
}
}
export async function startMcp() {
if (!REMOTE_URL) {
await ensureRelayServer()
} else {
console.error(`Using remote CDP relay server: ${REMOTE_URL}`)
await checkRemoteServer(REMOTE_URL)
}
const transport = new StdioServerTransport()
await server.connect(transport)
}
main().catch((error) => {
console.error('Fatal error starting MCP server:', error)
process.exit(1)
})
+14 -5
View File
@@ -112,23 +112,32 @@ you have access to some functions in addition to playwright methods:
// user triggers the code, then:
if (dbg.isPaused()) { console.log(await dbg.getLocation()); console.log(await dbg.inspectLocalVariables()); await dbg.resume(); }
```
- `createEditor({ cdp })`: creates an Editor instance for viewing and live-editing page scripts. Provides a Claude Code-like interface.
- `createEditor({ cdp })`: creates an Editor instance for viewing and live-editing page scripts and CSS stylesheets. Provides a Claude Code-like interface.
- `cdp`: a CDPSession from `getCDPSession`
- Methods: `enable()`, `list({ search? })`, `read({ url, offset?, limit? })`, `edit({ url, oldString, newString, dryRun? })`, `grep({ regex, include? })`, `write({ url, content, dryRun? })`
- Inline scripts (no URL) get synthetic URLs like `inline://42` - use grep() to find them by content
- Methods: `enable()`, `list({ pattern? })`, `read({ url, offset?, limit? })`, `edit({ url, oldString, newString, dryRun? })`, `grep({ regex, pattern? })`, `write({ url, content, dryRun? })`
- `pattern` parameter: regex to filter URLs (e.g. `/\.js/` for JS files, `/\.css/` for CSS files)
- Inline scripts get `inline://` URLs, inline styles get `inline-css://` URLs - use grep() to find them by content
- Example:
```js
const cdp = await getCDPSession({ page }); const editor = createEditor({ cdp }); await editor.enable();
// list available scripts (inline scripts have inline:// URLs)
console.log(editor.list({ search: 'app' }));
// list all scripts and stylesheets
console.log(editor.list());
// list only JS files
console.log(editor.list({ pattern: /\.js/ }));
// list only CSS files
console.log(editor.list({ pattern: /\.css/ }));
// read a script with line numbers (like Claude Code Read tool)
const { content, totalLines } = await editor.read({ url: 'https://example.com/app.js', offset: 0, limit: 50 });
console.log(content);
// edit a script (like Claude Code Edit tool) - exact string replacement
await editor.edit({ url: 'https://example.com/app.js', oldString: 'DEBUG = false', newString: 'DEBUG = true' });
// edit CSS
await editor.edit({ url: 'https://example.com/styles.css', oldString: 'color: red', newString: 'color: blue' });
// search across all scripts (like Grep) - useful for finding inline scripts
const matches = await editor.grep({ regex: /myFunction/ });
if (matches.length > 0) { await editor.edit({ url: matches[0].url, oldString: 'return false', newString: 'return true' }); }
// search only in CSS files
const cssMatches = await editor.grep({ regex: /background-color/, pattern: /\.css/ });
```
example:
@@ -25,10 +25,10 @@ Return value:
- generic [ref=e18]: Search documentation...
- generic [ref=e19]:
- generic: ⌘K
- link "103k" [ref=e20] [cursor=pointer]:
- link "104k" [ref=e20] [cursor=pointer]:
- /url: https://github.com/shadcn-ui/ui
- img
- generic [ref=e21]: 103k
- generic [ref=e21]: 104k
- button "Toggle theme" [ref=e22]:
- img
- generic [ref=e23]: Toggle theme
@@ -69,76 +69,76 @@ Return value:
- generic [ref=e50]: Theme
- combobox "Theme" [ref=e51]:
- generic [ref=e52]: "Theme:"
- generic: Neutral
- img
- button "Copy Code" [ref=e53]:
- combobox [ref=e53]
- button "Copy Code" [ref=e54]:
- img
- generic [ref=e54]: Copy Code
- generic [ref=e58]:
- generic [ref=e62]:
- group "Payment Method" [ref=e63]:
- generic [ref=e64]: Payment Method
- paragraph [ref=e65]: All transactions are secure and encrypted
- generic [ref=e66]:
- group [ref=e67]:
- generic [ref=e68]: Name on Card
- textbox "Name on Card" [ref=e69]:
- generic [ref=e55]: Copy Code
- generic [ref=e59]:
- generic [ref=e63]:
- group "Payment Method" [ref=e64]:
- generic [ref=e65]: Payment Method
- paragraph [ref=e66]: All transactions are secure and encrypted
- generic [ref=e67]:
- group [ref=e68]:
- generic [ref=e69]: Name on Card
- textbox "Name on Card" [ref=e70]:
- /placeholder: John Doe
- generic [ref=e70]:
- group [ref=e71]:
- generic [ref=e72]: Card Number
- textbox "Card Number" [ref=e73]:
- generic [ref=e71]:
- group [ref=e72]:
- generic [ref=e73]: Card Number
- textbox "Card Number" [ref=e74]:
- /placeholder: 1234 5678 9012 3456
- paragraph [ref=e74]: Enter your 16-digit number.
- group [ref=e75]:
- generic [ref=e76]: CVV
- textbox "CVV" [ref=e77]:
- paragraph [ref=e75]: Enter your 16-digit number.
- group [ref=e76]:
- generic [ref=e77]: CVV
- textbox "CVV" [ref=e78]:
- /placeholder: "123"
- generic [ref=e78]:
- group [ref=e79]:
- generic [ref=e80]: Month
- combobox "Month" [ref=e81]:
- generic [ref=e79]:
- group [ref=e80]:
- generic [ref=e81]: Month
- combobox "Month" [ref=e82]:
- generic: MM
- img
- combobox [ref=e82]
- group [ref=e83]:
- generic [ref=e84]: Year
- combobox "Year" [ref=e85]:
- combobox [ref=e83]
- group [ref=e84]:
- generic [ref=e85]: Year
- combobox "Year" [ref=e86]:
- generic: YYYY
- img
- combobox [ref=e86]
- group "Billing Address" [ref=e88]:
- generic [ref=e89]: Billing Address
- paragraph [ref=e90]: The billing address associated with your payment method
- group [ref=e92]:
- checkbox "Same as shipping address" [checked] [ref=e93]:
- combobox [ref=e87]
- group "Billing Address" [ref=e89]:
- generic [ref=e90]: Billing Address
- paragraph [ref=e91]: The billing address associated with your payment method
- group [ref=e93]:
- checkbox "Same as shipping address" [checked] [ref=e94]:
- generic:
- img
- checkbox [checked]
- generic [ref=e94]: Same as shipping address
- group [ref=e96]:
- group [ref=e98]:
- generic [ref=e99]: Comments
- textbox "Comments" [ref=e100]:
- generic [ref=e95]: Same as shipping address
- group [ref=e97]:
- group [ref=e99]:
- generic [ref=e100]: Comments
- textbox "Comments" [ref=e101]:
- /placeholder: Add any additional comments
- group [ref=e101]:
- button "Submit" [ref=e102]
- button "Cancel" [ref=e103]
- generic [ref=e104]:
- generic [ref=e105]:
- generic [ref=e106]:
- generic [ref=e108]:
- img "@shadcn" [ref=e110]
- img "@maxleiter" [ref=e112]
- img "@evilrabbit" [ref=e114]
- generic [ref=e115]: No Team Members
- generic [ref=e116]: Invite your team to collaborate on this project.
- button "Invite Members" [ref=e118]:
- group [ref=e102]:
- button "Submit" [ref=e103]
- button "Cancel" [ref=e104]
- generic [ref=e105]:
- generic [ref=e106]:
- generic [ref=e107]:
- generic [ref=e109]:
- generic [ref=e111]: CN
- generic [ref=e113]: LR
- generic [ref=e115]: ER
- generic [ref=e116]: No Team Members
- generic [ref=e117]: Invite your team to collaborate on this project.
- button "Invite Members" [ref=e119]:
- img
- text: Invite Members
- generic [ref=e119]:
- generic [ref=e120]:
- generic [ref=e120]:
- generic [ref=e121]:
- status "Loading"
- tex
[Truncated to 6000 characters. Better manage your logs or paginate them to read the full logs]
+2 -2
View File
@@ -20,8 +20,8 @@ process.on('exit', async (code) => {
});
export async function startServer({ port = 19988 }: { port?: number } = {}) {
const server = await startPlayWriterCDPRelayServer({ port, logger })
export async function startServer({ port = 19988, host = '127.0.0.1', token }: { port?: number; host?: string; token?: string } = {}) {
const server = await startPlayWriterCDPRelayServer({ port, host, token, logger })
console.log('CDP Relay Server running. Press Ctrl+C to stop.')
console.log('Logs are being written to:', logger.logFilePath)
+3 -2
View File
@@ -3,9 +3,10 @@ import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
export function getCdpUrl({ port = 19988, host = '127.0.0.1' }: { port?: number; host?: string } = {}) {
export function getCdpUrl({ port = 19988, host = '127.0.0.1', query }: { port?: number; host?: string; query?: string } = {}) {
const id = `${Math.random().toString(36).substring(2, 15)}_${Date.now()}`
return `ws://${host}:${port}/cdp/${id}`
const queryString = query ? `?${query}` : ''
return `ws://${host}:${port}/cdp/${id}${queryString}`
}
export const LOG_FILE_PATH = path.join(os.tmpdir(), 'playwriter', 'relay-server.log')
+3
View File
@@ -69,6 +69,9 @@ importers:
'@modelcontextprotocol/sdk':
specifier: ^1.21.1
version: 1.21.1(@cfworker/json-schema@4.1.1)
cac:
specifier: ^6.7.14
version: 6.7.14
chalk:
specifier: ^5.6.2
version: 5.6.2