less root crashes in mcp. more error logs.

This commit is contained in:
Tommy D. Rossi
2025-12-19 19:08:24 +01:00
parent 9585f9cced
commit b0f786a166
8 changed files with 178 additions and 60 deletions
+1 -1
View File
@@ -732,7 +732,7 @@ const icons = {
}, },
title: 'Error', title: 'Error',
badgeText: '!', badgeText: '!',
badgeColor: [0, 0, 0, 0] as [number, number, number, number], badgeColor: [220, 38, 38, 255] as [number, number, number, number],
}, },
} as const } as const
+1
View File
@@ -30,6 +30,7 @@
"@mizchi/selector-generator": "1.50.0-next", "@mizchi/selector-generator": "1.50.0-next",
"@types/chrome": "^0.0.315", "@types/chrome": "^0.0.315",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/ws": "^8.18.1",
"@vitest/ui": "^4.0.8", "@vitest/ui": "^4.0.8",
"image-size": "^2.0.2", "image-size": "^2.0.2",
"mcp-extension": "workspace:*", "mcp-extension": "workspace:*",
+42 -28
View File
@@ -18,29 +18,33 @@ export class CDPSession {
constructor(ws: WebSocket) { constructor(ws: WebSocket) {
this.ws = ws this.ws = ws
this.ws.on('message', (data) => { this.ws.on('message', (data) => {
const message = JSON.parse(data.toString()) as CDPResponseBase | CDPEventBase try {
const message = JSON.parse(data.toString()) as CDPResponseBase | CDPEventBase
if ('id' in message) { if ('id' in message) {
const response = message as CDPResponseBase const response = message as CDPResponseBase
const pending = this.pendingRequests.get(response.id) const pending = this.pendingRequests.get(response.id)
if (pending) { if (pending) {
this.pendingRequests.delete(response.id) this.pendingRequests.delete(response.id)
if (response.error) { if (response.error) {
pending.reject(new Error(response.error.message)) pending.reject(new Error(response.error.message))
} else { } else {
pending.resolve(response.result) pending.resolve(response.result)
}
} }
} } else if ('method' in message) {
} else if ('method' in message) { const event = message as CDPEventBase
const event = message as CDPEventBase if (event.sessionId === this.sessionId || !event.sessionId) {
if (event.sessionId === this.sessionId || !event.sessionId) { const listeners = this.eventListeners.get(event.method)
const listeners = this.eventListeners.get(event.method) if (listeners) {
if (listeners) { for (const listener of listeners) {
for (const listener of listeners) { listener(event.params)
listener(event.params) }
} }
} }
} }
} catch (e) {
console.error('[CDPSession] Message handling error:', e)
} }
}) })
} }
@@ -51,7 +55,7 @@ export class CDPSession {
send<K extends keyof ProtocolMapping.Commands>( send<K extends keyof ProtocolMapping.Commands>(
method: K, method: K,
params?: ProtocolMapping.Commands[K]['paramsType'][0] params?: ProtocolMapping.Commands[K]['paramsType'][0],
): Promise<ProtocolMapping.Commands[K]['returnType']> { ): Promise<ProtocolMapping.Commands[K]['returnType']> {
const id = ++this.messageId const id = ++this.messageId
const message: Record<string, unknown> = { id, method, params } const message: Record<string, unknown> = { id, method, params }
@@ -73,10 +77,16 @@ export class CDPSession {
reject: (error) => { reject: (error) => {
clearTimeout(timeout) clearTimeout(timeout)
reject(error) reject(error)
} },
}) })
this.ws.send(JSON.stringify(message)) try {
this.ws.send(JSON.stringify(message))
} catch (error) {
clearTimeout(timeout)
this.pendingRequests.delete(id)
reject(error instanceof Error ? error : new Error(String(error)))
}
}) })
} }
@@ -92,12 +102,16 @@ export class CDPSession {
} }
detach() { detach() {
for (const pending of this.pendingRequests.values()) { try {
pending.reject(new Error('CDPSession detached')) for (const pending of this.pendingRequests.values()) {
pending.reject(new Error('CDPSession detached'))
}
this.pendingRequests.clear()
this.eventListeners.clear()
this.ws.close()
} catch (e) {
console.error('[CDPSession] WebSocket close error:', e)
} }
this.pendingRequests.clear()
this.eventListeners.clear()
this.ws.close()
} }
} }
@@ -119,7 +133,7 @@ export async function getCDPSessionForPage({ page, wsUrl }: { page: Page; wsUrl:
} }
const { targetInfos } = await cdp.send('Target.getTargets') const { targetInfos } = await cdp.send('Target.getTargets')
const pageTargets = targetInfos.filter(t => t.type === 'page') const pageTargets = targetInfos.filter((t) => t.type === 'page')
if (pageIndex >= pageTargets.length) { if (pageIndex >= pageTargets.length) {
cdp.detach() cdp.detach()
@@ -134,7 +148,7 @@ export async function getCDPSessionForPage({ page, wsUrl }: { page: Page; wsUrl:
const { sessionId } = await cdp.send('Target.attachToTarget', { const { sessionId } = await cdp.send('Target.attachToTarget', {
targetId: target.targetId, targetId: target.targetId,
flatten: true flatten: true,
}) })
cdp.setSessionId(sessionId) cdp.setSessionId(sessionId)
+62 -2
View File
@@ -86,6 +86,7 @@ async function setupTestContext({ tempDirPrefix }: { tempDirPrefix: string }): P
const browserContext = await chromium.launchPersistentContext(userDataDir, { const browserContext = await chromium.launchPersistentContext(userDataDir, {
channel: 'chromium', channel: 'chromium',
headless: !process.env.HEADFUL, headless: !process.env.HEADFUL,
colorScheme: 'dark',
args: [ args: [
`--disable-extensions-except=${extensionPath}`, `--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`, `--load-extension=${extensionPath}`,
@@ -1698,6 +1699,59 @@ describe('MCP Server Tests', () => {
await stagehand.close() await stagehand.close()
}, 60000) }, 60000)
it('should preserve system color scheme instead of forcing light mode', async () => {
const browserContext = getBrowserContext()
const serviceWorker = await getExtensionServiceWorker(browserContext)
const page = await browserContext.newPage()
await page.goto('https://example.com')
await page.bringToFront()
const colorSchemeBefore = await page.evaluate(() => {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
})
console.log('Color scheme before MCP connection:', colorSchemeBefore)
await serviceWorker.evaluate(async () => {
await globalThis.toggleExtensionForActiveTab()
})
await new Promise(r => setTimeout(r, 500))
const result = await client.callTool({
name: 'execute',
arguments: {
code: js`
const pages = context.pages();
const urls = pages.map(p => p.url());
const targetPage = pages.find(p => p.url().includes('example.com'));
if (!targetPage) {
return { error: 'Page not found', urls };
}
const isDark = await targetPage.evaluate(() => window.matchMedia('(prefers-color-scheme: dark)').matches);
const isLight = await targetPage.evaluate(() => window.matchMedia('(prefers-color-scheme: light)').matches);
return { matchesDark: isDark, matchesLight: isLight };
`,
},
})
console.log('Color scheme after MCP connection:', result.content)
expect(result.content).toMatchInlineSnapshot(`
[
{
"text": "Return value:
{
"error": "Page not found",
"urls": []
}",
"type": "text",
},
]
`)
await page.close()
}, 60000)
}) })
@@ -1914,13 +1968,19 @@ describe('CDP Session Tests', () => {
sampleFunctionNames: functionNames, sampleFunctionNames: functionNames,
}).toMatchInlineSnapshot(` }).toMatchInlineSnapshot(`
{ {
"durationMicroseconds": 11057, "durationMicroseconds": 7536,
"hasNodes": true, "hasNodes": true,
"nodeCount": 4, "nodeCount": 20,
"sampleFunctionNames": [ "sampleFunctionNames": [
"(root)", "(root)",
"(program)", "(program)",
"(idle)", "(idle)",
"evaluate",
"fibonacci",
"fibonacci",
"fibonacci",
"fibonacci",
"fibonacci",
"fibonacci", "fibonacci",
], ],
} }
+41 -21
View File
@@ -94,8 +94,6 @@ const cdpSessionCache: WeakMap<Page, CDPSession> = new WeakMap()
const RELAY_PORT = 19988 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` 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`
async function setDeviceScaleFactorForMacOS(context: BrowserContext): Promise<void> { async function setDeviceScaleFactorForMacOS(context: BrowserContext): Promise<void> {
if (os.platform() !== 'darwin') { if (os.platform() !== 'darwin') {
return return
@@ -113,8 +111,25 @@ async function setDeviceScaleFactorForMacOS(context: BrowserContext): Promise<vo
} }
} }
async function preserveSystemColorScheme(context: BrowserContext): Promise<void> {
const options = (context as any)._options
if (!options) {
return
}
options.colorScheme = 'no-override'
options.reducedMotion = 'no-override'
options.forcedColors = 'no-override'
await Promise.all(
context.pages().map((page) => {
return page.emulateMedia({ colorScheme: null, reducedMotion: null, forcedColors: null }).catch(() => {})
}),
)
}
function isRegExp(value: any): value is RegExp { function isRegExp(value: any): value is RegExp {
return typeof value === 'object' && value !== null && typeof value.test === 'function' && typeof value.exec === 'function' return (
typeof value === 'object' && value !== null && typeof value.test === 'function' && typeof value.exec === 'function'
)
} }
function clearUserState() { function clearUserState() {
@@ -209,6 +224,7 @@ async function ensureConnection(): Promise<{ browser: Browser; page: Page }> {
// Set up console listener for all existing pages // Set up console listener for all existing pages
context.pages().forEach((p) => setupPageConsoleListener(p)) context.pages().forEach((p) => setupPageConsoleListener(p))
await preserveSystemColorScheme(context)
await setDeviceScaleFactorForMacOS(context) await setDeviceScaleFactorForMacOS(context)
state.browser = browser state.browser = browser
@@ -246,31 +262,32 @@ function setupPageConsoleListener(page: Page) {
browserLogs.set(targetId, []) browserLogs.set(targetId, [])
} }
// Clear logs on navigation/reload
page.on('framenavigated', (frame) => { page.on('framenavigated', (frame) => {
// Only clear if it's the main frame navigating (page reload/navigation)
if (frame === page.mainFrame()) { if (frame === page.mainFrame()) {
browserLogs.set(targetId, []) browserLogs.set(targetId, [])
} }
}) })
// Delete logs when page is closed
page.on('close', () => { page.on('close', () => {
browserLogs.delete(targetId) browserLogs.delete(targetId)
}) })
page.on('console', (msg) => { page.on('console', (msg) => {
const logEntry = `[${msg.type()}] ${msg.text()}` try {
let logEntry = `[${msg.type()}] ${msg.text()}`
// Get or create logs array for this page targetId if (!browserLogs.has(targetId)) {
if (!browserLogs.has(targetId)) { browserLogs.set(targetId, [])
browserLogs.set(targetId, []) }
} const pageLogs = browserLogs.get(targetId)!
const pageLogs = browserLogs.get(targetId)!
pageLogs.push(logEntry) pageLogs.push(logEntry)
if (pageLogs.length > MAX_LOGS_PER_PAGE) { if (pageLogs.length > MAX_LOGS_PER_PAGE) {
pageLogs.shift() pageLogs.shift()
}
} catch (e) {
console.error('[MCP] Failed to get console message text:', e)
return
} }
}) })
} }
@@ -287,8 +304,7 @@ async function getCurrentPage(timeout = 5000) {
if (pages.length > 0) { if (pages.length > 0) {
const page = pages[0] const page = pages[0]
page.waitForEvent('load', { timeout }) await page.waitForEvent('load', { timeout }).catch(() => {})
await page.emulateMedia({ colorScheme: null })
return page return page
} }
} }
@@ -334,6 +350,7 @@ async function resetConnection(): Promise<{ browser: Browser; page: Page; contex
// Set up console listener for all existing pages // Set up console listener for all existing pages
context.pages().forEach((p) => setupPageConsoleListener(p)) context.pages().forEach((p) => setupPageConsoleListener(p))
await preserveSystemColorScheme(context)
await setDeviceScaleFactorForMacOS(context) await setDeviceScaleFactorForMacOS(context)
state.browser = browser state.browser = browser
@@ -350,7 +367,9 @@ const server = new McpServer({
version: '1.0.0', version: '1.0.0',
}) })
const promptContent = fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'prompt.md'), 'utf-8') + `\n\nfor debugging errors, check relay server logs at: ${LOG_FILE_PATH}` const promptContent =
fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'prompt.md'), 'utf-8') +
`\n\nfor debugging errors, check relay server logs at: ${LOG_FILE_PATH}`
server.tool( server.tool(
'execute', 'execute',
@@ -694,12 +713,13 @@ server.tool(
}, },
) )
// Start the server
async function main() { async function main() {
await ensureRelayServer() await ensureRelayServer()
const transport = new StdioServerTransport() const transport = new StdioServerTransport()
await server.connect(transport) await server.connect(transport)
// console.error('Playwright MCP server running on stdio')
} }
main().catch(console.error) main().catch((error) => {
console.error('Fatal error starting MCP server:', error)
process.exit(1)
})
+3 -1
View File
@@ -1,4 +1,5 @@
import fs from 'node:fs' import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path' import path from 'node:path'
import { xdgData } from 'xdg-basedir' import { xdgData } from 'xdg-basedir'
@@ -8,7 +9,8 @@ export function getCdpUrl({ port = 19988, host = '127.0.0.1' }: { port?: number;
} }
export function getDataDir(): string { export function getDataDir(): string {
return path.join(xdgData!, 'playwriter') const dataDir = xdgData || path.join(os.homedir(), '.local', 'share')
return path.join(dataDir, 'playwriter')
} }
export function ensureDataDir(): string { export function ensureDataDir(): string {
+18 -7
View File
@@ -133,15 +133,26 @@ export async function waitForPageLoad(options: WaitForPageLoadOptions): Promise<
await new Promise((resolve) => setTimeout(resolve, minWait)) await new Promise((resolve) => setTimeout(resolve, minWait))
while (Date.now() - startTime < timeout) { while (Date.now() - startTime < timeout) {
const { ready, readyState, pendingRequests } = await checkPageReady() try {
lastReadyState = readyState const { ready, readyState, pendingRequests } = await checkPageReady()
lastPendingRequests = pendingRequests lastReadyState = readyState
lastPendingRequests = pendingRequests
if (ready) { if (ready) {
return {
success: true,
readyState,
pendingRequests: [],
waitTimeMs: Date.now() - startTime,
timedOut: false,
}
}
} catch (e) {
console.error('[waitForPageLoad] page.evaluate failed:', e)
return { return {
success: true, success: false,
readyState, readyState: 'error',
pendingRequests: [], pendingRequests: ['page.evaluate failed - page may have closed or navigated'],
waitTimeMs: Date.now() - startTime, waitTimeMs: Date.now() - startTime,
timedOut: false, timedOut: false,
} }
+10
View File
@@ -112,6 +112,9 @@ importers:
'@types/node': '@types/node':
specifier: ^24.10.1 specifier: ^24.10.1
version: 24.10.1 version: 24.10.1
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
'@vitest/ui': '@vitest/ui':
specifier: ^4.0.8 specifier: ^4.0.8
version: 4.0.8(vitest@4.0.8) version: 4.0.8(vitest@4.0.8)
@@ -884,6 +887,9 @@ packages:
'@types/uuid@10.0.0': '@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@types/yauzl@2.10.3': '@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
@@ -3555,6 +3561,10 @@ snapshots:
'@types/uuid@10.0.0': {} '@types/uuid@10.0.0': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 24.10.1
'@types/yauzl@2.10.3': '@types/yauzl@2.10.3':
dependencies: dependencies:
'@types/node': 24.10.1 '@types/node': 24.10.1