670 lines
20 KiB
TypeScript
670 lines
20 KiB
TypeScript
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
import { z } from 'zod'
|
|
import { Page, Browser, BrowserContext, chromium } from 'playwright-core'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import { spawn } from 'node:child_process'
|
|
import { createRequire } from 'node:module'
|
|
import { fileURLToPath } from 'node:url'
|
|
import vm from 'node:vm'
|
|
import dedent from 'string-dedent'
|
|
import { createPatch } from 'diff'
|
|
import { getCdpUrl } from './utils.js'
|
|
|
|
const require = createRequire(import.meta.url)
|
|
|
|
const usefulGlobals = {
|
|
setTimeout,
|
|
setInterval,
|
|
clearTimeout,
|
|
clearInterval,
|
|
URL,
|
|
URLSearchParams,
|
|
fetch,
|
|
Buffer,
|
|
TextEncoder,
|
|
TextDecoder,
|
|
crypto,
|
|
AbortController,
|
|
AbortSignal,
|
|
structuredClone,
|
|
} as const
|
|
|
|
interface State {
|
|
isConnected: boolean
|
|
page: Page | null
|
|
browser: Browser | null
|
|
context: BrowserContext | null
|
|
}
|
|
|
|
interface VMContext {
|
|
page: Page
|
|
context: BrowserContext
|
|
state: Record<string, any>
|
|
console: {
|
|
log: (...args: any[]) => void
|
|
info: (...args: any[]) => void
|
|
warn: (...args: any[]) => void
|
|
error: (...args: any[]) => void
|
|
debug: (...args: any[]) => void
|
|
}
|
|
accessibilitySnapshot: (options: {
|
|
page: Page
|
|
search?: string | RegExp
|
|
contextLines?: number
|
|
showDiffSinceLastCall?: boolean
|
|
}) => Promise<string>
|
|
getLocatorStringForElement: (element: any) => Promise<string>
|
|
resetPlaywright: () => Promise<{ page: Page; context: BrowserContext }>
|
|
getLatestLogs: (options?: { page?: Page; count?: number; search?: string | RegExp }) => Promise<string[]>
|
|
clearAllLogs: () => void
|
|
require: NodeRequire
|
|
import: (specifier: string) => Promise<any>
|
|
}
|
|
|
|
type VMContextWithGlobals = VMContext & typeof usefulGlobals
|
|
|
|
type SelectorGenerator = typeof import('@mizchi/selector-generator')
|
|
|
|
const state: State = {
|
|
isConnected: false,
|
|
page: null,
|
|
browser: null,
|
|
context: null,
|
|
}
|
|
|
|
const userState: Record<string, any> = {}
|
|
|
|
// Store logs per page targetId
|
|
const browserLogs: Map<string, string[]> = new Map()
|
|
const MAX_LOGS_PER_PAGE = 5000
|
|
|
|
// Store last accessibility snapshot per page for diff feature
|
|
const lastSnapshots: WeakMap<Page, string> = new WeakMap()
|
|
|
|
const RELAY_PORT = 19988
|
|
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 isRegExp(value: any): value is RegExp {
|
|
return typeof value === 'object' && value !== null && typeof value.test === 'function' && typeof value.exec === 'function'
|
|
}
|
|
|
|
function clearUserState() {
|
|
Object.keys(userState).forEach((key) => delete userState[key])
|
|
}
|
|
|
|
function clearConnectionState() {
|
|
state.isConnected = false
|
|
state.browser = null
|
|
state.page = null
|
|
state.context = null
|
|
}
|
|
|
|
async function sendLogToRelayServer(level: string, ...args: any[]) {
|
|
try {
|
|
await fetch(`http://localhost:${RELAY_PORT}/mcp-log`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ level, args }),
|
|
signal: AbortSignal.timeout(1000),
|
|
})
|
|
} catch {
|
|
// Silently fail if relay server is not available
|
|
}
|
|
}
|
|
|
|
async function isPortTaken(port: number): Promise<boolean> {
|
|
try {
|
|
const response = await fetch(`http://localhost:${port}/`, {
|
|
signal: AbortSignal.timeout(100),
|
|
})
|
|
return response.ok
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function ensureRelayServer(): Promise<void> {
|
|
const portTaken = await isPortTaken(RELAY_PORT)
|
|
|
|
if (portTaken) {
|
|
return
|
|
}
|
|
|
|
console.error('CDP relay server not running, starting it...')
|
|
|
|
const scriptPath = require.resolve('../dist/start-relay-server.js')
|
|
|
|
const serverProcess = spawn(process.execPath, [scriptPath], {
|
|
detached: true,
|
|
stdio: 'ignore',
|
|
})
|
|
|
|
serverProcess.unref()
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
const isRunning = await isPortTaken(RELAY_PORT)
|
|
if (isRunning) {
|
|
console.error('CDP relay server started successfully')
|
|
return
|
|
}
|
|
}
|
|
|
|
throw new Error('Failed to start CDP relay server after 5 seconds')
|
|
}
|
|
|
|
async function ensureConnection(): Promise<{ browser: Browser; page: Page }> {
|
|
if (state.isConnected && state.browser && state.page) {
|
|
return { browser: state.browser, page: state.page }
|
|
}
|
|
|
|
await ensureRelayServer()
|
|
|
|
const cdpEndpoint = getCdpUrl({ port: RELAY_PORT })
|
|
const browser = await chromium.connectOverCDP(cdpEndpoint)
|
|
|
|
const contexts = browser.contexts()
|
|
const context = contexts.length > 0 ? contexts[0] : await browser.newContext()
|
|
|
|
// Set up console listener for all future pages
|
|
context.on('page', (page) => {
|
|
setupPageConsoleListener(page)
|
|
})
|
|
|
|
const pages = context.pages()
|
|
if (pages.length === 0) {
|
|
throw new Error(NO_TABS_ERROR)
|
|
}
|
|
const page = pages[0]
|
|
|
|
// Set up console listener for all existing pages
|
|
context.pages().forEach((p) => setupPageConsoleListener(p))
|
|
|
|
state.browser = browser
|
|
state.page = page
|
|
state.context = context
|
|
state.isConnected = true
|
|
|
|
return { browser, page }
|
|
}
|
|
|
|
async function getPageTargetId(page: Page): Promise<string> {
|
|
if (!page) {
|
|
throw new Error('Page is null or undefined')
|
|
}
|
|
|
|
// Always use internal _guid for consistency and speed
|
|
const guid = (page as any)._guid
|
|
if (guid) {
|
|
return guid
|
|
}
|
|
|
|
try {
|
|
// Fallback to CDP if _guid is not available
|
|
const client = await page.context().newCDPSession(page)
|
|
const { targetInfo } = await client.send('Target.getTargetInfo')
|
|
await client.detach()
|
|
|
|
return targetInfo.targetId
|
|
} catch (e) {
|
|
throw new Error(`Could not get page identifier: ${e}`)
|
|
}
|
|
}
|
|
|
|
function setupPageConsoleListener(page: Page) {
|
|
// Get targetId synchronously using _guid
|
|
const targetId = (page as any)._guid as string | undefined
|
|
|
|
if (!targetId) {
|
|
// If no _guid, silently fail - this shouldn't happen in normal operation
|
|
return
|
|
}
|
|
|
|
// Initialize logs array for this page
|
|
if (!browserLogs.has(targetId)) {
|
|
browserLogs.set(targetId, [])
|
|
}
|
|
|
|
// Clear logs on navigation/reload
|
|
page.on('framenavigated', (frame) => {
|
|
// Only clear if it's the main frame navigating (page reload/navigation)
|
|
if (frame === page.mainFrame()) {
|
|
browserLogs.set(targetId, [])
|
|
}
|
|
})
|
|
|
|
// Delete logs when page is closed
|
|
page.on('close', () => {
|
|
browserLogs.delete(targetId)
|
|
})
|
|
|
|
page.on('console', (msg) => {
|
|
const logEntry = `[${msg.type()}] ${msg.text()}`
|
|
|
|
// Get or create logs array for this page targetId
|
|
if (!browserLogs.has(targetId)) {
|
|
browserLogs.set(targetId, [])
|
|
}
|
|
const pageLogs = browserLogs.get(targetId)!
|
|
|
|
pageLogs.push(logEntry)
|
|
if (pageLogs.length > MAX_LOGS_PER_PAGE) {
|
|
pageLogs.shift()
|
|
}
|
|
})
|
|
}
|
|
|
|
async function getCurrentPage(timeout = 5000) {
|
|
if (state.page) {
|
|
return state.page
|
|
}
|
|
|
|
if (state.browser) {
|
|
const contexts = state.browser.contexts()
|
|
if (contexts.length > 0) {
|
|
const pages = contexts[0].pages()
|
|
|
|
if (pages.length > 0) {
|
|
const page = pages[0]
|
|
page.waitForEvent('load', { timeout })
|
|
await page.emulateMedia({ colorScheme: null })
|
|
return page
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new Error(NO_TABS_ERROR)
|
|
}
|
|
|
|
async function resetConnection(): Promise<{ browser: Browser; page: Page; context: BrowserContext }> {
|
|
if (state.browser) {
|
|
try {
|
|
await state.browser.close()
|
|
} catch (e) {
|
|
console.error('Error closing browser:', e)
|
|
}
|
|
}
|
|
|
|
clearConnectionState()
|
|
clearUserState()
|
|
|
|
// DO NOT clear browser logs on reset - logs should persist across reconnections
|
|
// browserLogs.clear()
|
|
|
|
await ensureRelayServer()
|
|
|
|
const cdpEndpoint = getCdpUrl({ port: RELAY_PORT })
|
|
const browser = await chromium.connectOverCDP(cdpEndpoint)
|
|
|
|
const contexts = browser.contexts()
|
|
const context = contexts.length > 0 ? contexts[0] : await browser.newContext()
|
|
|
|
// Set up console listener for all future pages
|
|
context.on('page', (page) => {
|
|
setupPageConsoleListener(page)
|
|
})
|
|
|
|
const pages = context.pages()
|
|
if (pages.length === 0) {
|
|
throw new Error(NO_TABS_ERROR)
|
|
}
|
|
const page = pages[0]
|
|
|
|
// Set up console listener for all existing pages
|
|
context.pages().forEach((p) => setupPageConsoleListener(p))
|
|
|
|
state.browser = browser
|
|
state.page = page
|
|
state.context = context
|
|
state.isConnected = true
|
|
|
|
return { browser, page, context }
|
|
}
|
|
|
|
const server = new McpServer({
|
|
name: 'playwriter',
|
|
title: 'The better playwright MCP: works as a browser extension. No context bloat. More capable.',
|
|
version: '1.0.0',
|
|
})
|
|
|
|
const promptContent = fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'prompt.md'), 'utf-8')
|
|
|
|
server.tool(
|
|
'execute',
|
|
promptContent,
|
|
{
|
|
code: z
|
|
.string()
|
|
.describe(
|
|
'js playwright code, has {page, state, context} in scope. Should be one line, using ; to execute multiple statements. you MUST call execute multiple times instead of writing complex scripts in a single tool call.',
|
|
),
|
|
timeout: z.number().default(5000).describe('Timeout in milliseconds for code execution (default: 5000ms)'),
|
|
},
|
|
async ({ code, timeout }) => {
|
|
const consoleLogs: Array<{ method: string; args: any[] }> = []
|
|
|
|
const formatConsoleLogs = (logs: Array<{ method: string; args: any[] }>, prefix = 'Console output') => {
|
|
if (logs.length === 0) {
|
|
return ''
|
|
}
|
|
|
|
let text = `${prefix}:\n`
|
|
logs.forEach(({ method, args }) => {
|
|
const formattedArgs = args
|
|
.map((arg) => {
|
|
if (typeof arg === 'object') {
|
|
return JSON.stringify(arg, null, 2)
|
|
}
|
|
return String(arg)
|
|
})
|
|
.join(' ')
|
|
text += `[${method}] ${formattedArgs}\n`
|
|
})
|
|
return text + '\n'
|
|
}
|
|
|
|
try {
|
|
await ensureRelayServer()
|
|
await ensureConnection()
|
|
|
|
const page = await getCurrentPage(timeout)
|
|
const context = state.context || page.context()
|
|
|
|
console.error('Executing code:', code)
|
|
|
|
const customConsole = {
|
|
log: (...args: any[]) => {
|
|
consoleLogs.push({ method: 'log', args })
|
|
},
|
|
info: (...args: any[]) => {
|
|
consoleLogs.push({ method: 'info', args })
|
|
},
|
|
warn: (...args: any[]) => {
|
|
consoleLogs.push({ method: 'warn', args })
|
|
},
|
|
error: (...args: any[]) => {
|
|
consoleLogs.push({ method: 'error', args })
|
|
},
|
|
debug: (...args: any[]) => {
|
|
consoleLogs.push({ method: 'debug', args })
|
|
},
|
|
}
|
|
|
|
const accessibilitySnapshot = async (options: {
|
|
page: Page
|
|
search?: string | RegExp
|
|
contextLines?: number
|
|
showDiffSinceLastCall?: boolean
|
|
}) => {
|
|
const { page: targetPage, search, contextLines = 10, showDiffSinceLastCall = false } = options
|
|
if ((targetPage as any)._snapshotForAI) {
|
|
const snapshot = await (targetPage as any)._snapshotForAI()
|
|
const snapshotStr = typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2)
|
|
|
|
if (showDiffSinceLastCall) {
|
|
const previousSnapshot = lastSnapshots.get(targetPage)
|
|
lastSnapshots.set(targetPage, snapshotStr)
|
|
|
|
if (!previousSnapshot) {
|
|
return 'No previous snapshot available. This is the first call for this page. Full snapshot stored for next diff.'
|
|
}
|
|
|
|
const patch = createPatch('snapshot', previousSnapshot, snapshotStr, 'previous', 'current')
|
|
if (patch.split('\n').length <= 4) {
|
|
return 'No changes detected since last snapshot'
|
|
}
|
|
return patch
|
|
}
|
|
|
|
lastSnapshots.set(targetPage, snapshotStr)
|
|
|
|
if (!search) {
|
|
return snapshotStr
|
|
}
|
|
|
|
const lines = snapshotStr.split('\n')
|
|
const matches: { line: string; index: number }[] = []
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i]
|
|
let isMatch = false
|
|
if (isRegExp(search)) {
|
|
isMatch = search.test(line)
|
|
} else {
|
|
isMatch = line.includes(search)
|
|
}
|
|
|
|
if (isMatch) {
|
|
matches.push({ line, index: i })
|
|
if (matches.length >= 10) break
|
|
}
|
|
}
|
|
|
|
if (matches.length === 0) {
|
|
return 'No matches found'
|
|
}
|
|
|
|
return matches
|
|
.map((m) => {
|
|
const start = Math.max(0, m.index - contextLines)
|
|
const end = Math.min(lines.length, m.index + contextLines + 1)
|
|
return lines.slice(start, end).join('\n')
|
|
})
|
|
.join('\n\n---\n\n')
|
|
}
|
|
throw new Error('accessibilitySnapshot is not available on this page')
|
|
}
|
|
|
|
const getLocatorStringForElement = async (element: any) => {
|
|
if (!element || typeof element.evaluate !== 'function') {
|
|
throw new Error('getLocatorStringForElement: argument must be a Playwright Locator or ElementHandle')
|
|
}
|
|
|
|
return await element.evaluate(async (el: any) => {
|
|
const WIN = globalThis as any
|
|
if (!WIN.__selectorGenerator) {
|
|
const module: SelectorGenerator = await import(
|
|
// @ts-ignore
|
|
'https://unpkg.com/@mizchi/selector-generator@1.50.0-next/dist/index.js'
|
|
)
|
|
WIN.__selectorGenerator = {
|
|
createSelectorGenerator: module.createSelectorGenerator,
|
|
toLocator: module.toLocator,
|
|
}
|
|
}
|
|
const { createSelectorGenerator, toLocator } = WIN.__selectorGenerator as SelectorGenerator
|
|
const generator = createSelectorGenerator(WIN)
|
|
const result = generator(el)
|
|
return toLocator(result.selector, 'javascript')
|
|
})
|
|
}
|
|
|
|
const getLatestLogs = async (options?: { page?: Page; count?: number; search?: string | RegExp }) => {
|
|
const { page: filterPage, count, search } = options || {}
|
|
|
|
let allLogs: string[] = []
|
|
|
|
if (filterPage) {
|
|
const targetId = await getPageTargetId(filterPage)
|
|
const pageLogs = browserLogs.get(targetId) || []
|
|
allLogs = [...pageLogs]
|
|
} else {
|
|
for (const pageLogs of browserLogs.values()) {
|
|
allLogs.push(...pageLogs)
|
|
}
|
|
}
|
|
|
|
if (search) {
|
|
allLogs = allLogs.filter((log) => {
|
|
if (typeof search === 'string') {
|
|
return log.includes(search)
|
|
} else if (isRegExp(search)) {
|
|
return search.test(log)
|
|
}
|
|
return false
|
|
})
|
|
}
|
|
|
|
return count !== undefined ? allLogs.slice(-count) : allLogs
|
|
}
|
|
|
|
const clearAllLogs = () => {
|
|
browserLogs.clear()
|
|
}
|
|
|
|
let vmContextObj: VMContextWithGlobals = {
|
|
page,
|
|
context,
|
|
state: userState,
|
|
console: customConsole,
|
|
accessibilitySnapshot,
|
|
getLocatorStringForElement,
|
|
getLatestLogs,
|
|
clearAllLogs,
|
|
resetPlaywright: async () => {
|
|
const { page: newPage, context: newContext } = await resetConnection()
|
|
|
|
const resetObj: VMContextWithGlobals = {
|
|
page: newPage,
|
|
context: newContext,
|
|
state: userState,
|
|
console: customConsole,
|
|
accessibilitySnapshot,
|
|
getLocatorStringForElement,
|
|
getLatestLogs,
|
|
clearAllLogs,
|
|
resetPlaywright: vmContextObj.resetPlaywright,
|
|
require,
|
|
// TODO --experimental-vm-modules is needed to make import work in vm
|
|
import: vmContextObj.import,
|
|
...usefulGlobals,
|
|
}
|
|
Object.keys(vmContextObj).forEach((key) => delete (vmContextObj as any)[key])
|
|
Object.assign(vmContextObj, resetObj)
|
|
return { page: newPage, context: newContext }
|
|
},
|
|
require,
|
|
import: (specifier: string) => import(specifier),
|
|
...usefulGlobals,
|
|
}
|
|
|
|
const vmContext = vm.createContext(vmContextObj)
|
|
|
|
const wrappedCode = `(async () => { ${code} })()`
|
|
|
|
const result = await Promise.race([
|
|
vm.runInContext(wrappedCode, vmContext, {
|
|
timeout,
|
|
displayErrors: true,
|
|
}),
|
|
new Promise((_, reject) =>
|
|
setTimeout(() => reject(new Error(`Code execution timed out after ${timeout}ms`)), timeout),
|
|
),
|
|
])
|
|
|
|
let responseText = formatConsoleLogs(consoleLogs)
|
|
|
|
if (result !== undefined) {
|
|
responseText += 'Return value:\n'
|
|
if (typeof result === 'string') {
|
|
responseText += result
|
|
} else {
|
|
responseText += JSON.stringify(result, null, 2)
|
|
}
|
|
} else if (consoleLogs.length === 0) {
|
|
responseText += 'Code executed successfully (no output)'
|
|
}
|
|
|
|
const MAX_LENGTH = 6000
|
|
let finalText = responseText.trim()
|
|
if (finalText.length > MAX_LENGTH) {
|
|
finalText =
|
|
finalText.slice(0, MAX_LENGTH) +
|
|
`\n\n[Truncated to ${MAX_LENGTH} characters. Better manage your logs or paginate them to read the full logs]`
|
|
}
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: finalText,
|
|
},
|
|
],
|
|
}
|
|
} catch (error: any) {
|
|
const errorStack = error.stack || error.message
|
|
console.error('Error in execute tool:', errorStack)
|
|
|
|
const logsText = formatConsoleLogs(consoleLogs, 'Console output (before error)')
|
|
|
|
const isTimeoutError = error.name === 'TimeoutError' || error.message.includes('Timeout')
|
|
if (!isTimeoutError) {
|
|
sendLogToRelayServer('error', '[MCP] Error:', errorStack)
|
|
}
|
|
|
|
const resetHint = isTimeoutError
|
|
? ''
|
|
: '\n\n[HINT: If this is an internal Playwright error, page/browser closed, or connection issue, call the `reset` tool to reconnect. Do NOT reset for other non-connection non-internal errors.]'
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `${logsText}\nError executing code: ${error.message}\n${errorStack}${resetHint}`,
|
|
},
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
server.tool(
|
|
'reset',
|
|
dedent`
|
|
Recreates the CDP connection and resets the browser/page/context. Use this when the MCP stops responding, you get connection errors, assertion failures, page closed, or timeout issues.
|
|
|
|
After calling this tool, the page and context variables are automatically updated in the execution environment.
|
|
|
|
IMPORTANT: this completely resets the execution context, removing any custom properties you may have added to the global scope AND clearing all keys from the \`state\` object. Only \`page\`, \`context\`, \`state\` (empty), \`console\`, and utility functions will remain.
|
|
`,
|
|
{},
|
|
async () => {
|
|
try {
|
|
const { page, context } = await resetConnection()
|
|
const pagesCount = context.pages().length
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `Connection reset successfully. ${pagesCount} page(s) available. Current page URL: ${page.url()}`,
|
|
},
|
|
],
|
|
}
|
|
} catch (error: any) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `Failed to reset connection: ${error.message}`,
|
|
},
|
|
],
|
|
isError: true,
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
// Start the server
|
|
async function main() {
|
|
await ensureRelayServer()
|
|
const transport = new StdioServerTransport()
|
|
await server.connect(transport)
|
|
// console.error('Playwright MCP server running on stdio')
|
|
}
|
|
|
|
main().catch(console.error)
|
|
|