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',
badgeText: '!',
badgeColor: [0, 0, 0, 0] as [number, number, number, number],
badgeColor: [220, 38, 38, 255] as [number, number, number, number],
},
} as const
+1
View File
@@ -30,6 +30,7 @@
"@mizchi/selector-generator": "1.50.0-next",
"@types/chrome": "^0.0.315",
"@types/node": "^24.10.1",
"@types/ws": "^8.18.1",
"@vitest/ui": "^4.0.8",
"image-size": "^2.0.2",
"mcp-extension": "workspace:*",
+42 -28
View File
@@ -18,29 +18,33 @@ export class CDPSession {
constructor(ws: WebSocket) {
this.ws = ws
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) {
const response = message as CDPResponseBase
const pending = this.pendingRequests.get(response.id)
if (pending) {
this.pendingRequests.delete(response.id)
if (response.error) {
pending.reject(new Error(response.error.message))
} else {
pending.resolve(response.result)
if ('id' in message) {
const response = message as CDPResponseBase
const pending = this.pendingRequests.get(response.id)
if (pending) {
this.pendingRequests.delete(response.id)
if (response.error) {
pending.reject(new Error(response.error.message))
} else {
pending.resolve(response.result)
}
}
}
} else if ('method' in message) {
const event = message as CDPEventBase
if (event.sessionId === this.sessionId || !event.sessionId) {
const listeners = this.eventListeners.get(event.method)
if (listeners) {
for (const listener of listeners) {
listener(event.params)
} else if ('method' in message) {
const event = message as CDPEventBase
if (event.sessionId === this.sessionId || !event.sessionId) {
const listeners = this.eventListeners.get(event.method)
if (listeners) {
for (const listener of listeners) {
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>(
method: K,
params?: ProtocolMapping.Commands[K]['paramsType'][0]
params?: ProtocolMapping.Commands[K]['paramsType'][0],
): Promise<ProtocolMapping.Commands[K]['returnType']> {
const id = ++this.messageId
const message: Record<string, unknown> = { id, method, params }
@@ -73,10 +77,16 @@ export class CDPSession {
reject: (error) => {
clearTimeout(timeout)
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() {
for (const pending of this.pendingRequests.values()) {
pending.reject(new Error('CDPSession detached'))
try {
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 pageTargets = targetInfos.filter(t => t.type === 'page')
const pageTargets = targetInfos.filter((t) => t.type === 'page')
if (pageIndex >= pageTargets.length) {
cdp.detach()
@@ -134,7 +148,7 @@ export async function getCDPSessionForPage({ page, wsUrl }: { page: Page; wsUrl:
const { sessionId } = await cdp.send('Target.attachToTarget', {
targetId: target.targetId,
flatten: true
flatten: true,
})
cdp.setSessionId(sessionId)
+62 -2
View File
@@ -86,6 +86,7 @@ async function setupTestContext({ tempDirPrefix }: { tempDirPrefix: string }): P
const browserContext = await chromium.launchPersistentContext(userDataDir, {
channel: 'chromium',
headless: !process.env.HEADFUL,
colorScheme: 'dark',
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
@@ -1698,6 +1699,59 @@ describe('MCP Server Tests', () => {
await stagehand.close()
}, 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,
}).toMatchInlineSnapshot(`
{
"durationMicroseconds": 11057,
"durationMicroseconds": 7536,
"hasNodes": true,
"nodeCount": 4,
"nodeCount": 20,
"sampleFunctionNames": [
"(root)",
"(program)",
"(idle)",
"evaluate",
"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 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> {
if (os.platform() !== 'darwin') {
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 {
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() {
@@ -209,6 +224,7 @@ async function ensureConnection(): Promise<{ browser: Browser; page: Page }> {
// Set up console listener for all existing pages
context.pages().forEach((p) => setupPageConsoleListener(p))
await preserveSystemColorScheme(context)
await setDeviceScaleFactorForMacOS(context)
state.browser = browser
@@ -246,31 +262,32 @@ function setupPageConsoleListener(page: Page) {
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()}`
try {
let 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)!
if (!browserLogs.has(targetId)) {
browserLogs.set(targetId, [])
}
const pageLogs = browserLogs.get(targetId)!
pageLogs.push(logEntry)
if (pageLogs.length > MAX_LOGS_PER_PAGE) {
pageLogs.shift()
pageLogs.push(logEntry)
if (pageLogs.length > MAX_LOGS_PER_PAGE) {
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) {
const page = pages[0]
page.waitForEvent('load', { timeout })
await page.emulateMedia({ colorScheme: null })
await page.waitForEvent('load', { timeout }).catch(() => {})
return page
}
}
@@ -334,6 +350,7 @@ async function resetConnection(): Promise<{ browser: Browser; page: Page; contex
// Set up console listener for all existing pages
context.pages().forEach((p) => setupPageConsoleListener(p))
await preserveSystemColorScheme(context)
await setDeviceScaleFactorForMacOS(context)
state.browser = browser
@@ -350,7 +367,9 @@ const server = new McpServer({
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(
'execute',
@@ -694,12 +713,13 @@ server.tool(
},
)
// 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)
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 os from 'node:os'
import path from 'node:path'
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 {
return path.join(xdgData!, 'playwriter')
const dataDir = xdgData || path.join(os.homedir(), '.local', 'share')
return path.join(dataDir, 'playwriter')
}
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))
while (Date.now() - startTime < timeout) {
const { ready, readyState, pendingRequests } = await checkPageReady()
lastReadyState = readyState
lastPendingRequests = pendingRequests
try {
const { ready, readyState, pendingRequests } = await checkPageReady()
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 {
success: true,
readyState,
pendingRequests: [],
success: false,
readyState: 'error',
pendingRequests: ['page.evaluate failed - page may have closed or navigated'],
waitTimeMs: Date.now() - startTime,
timedOut: false,
}
+10
View File
@@ -112,6 +112,9 @@ importers:
'@types/node':
specifier: ^24.10.1
version: 24.10.1
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
'@vitest/ui':
specifier: ^4.0.8
version: 4.0.8(vitest@4.0.8)
@@ -884,6 +887,9 @@ packages:
'@types/uuid@10.0.0':
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':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
@@ -3555,6 +3561,10 @@ snapshots:
'@types/uuid@10.0.0': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 24.10.1
'@types/yauzl@2.10.3':
dependencies:
'@types/node': 24.10.1