Skip to main content
Glama

Playwright MCP

by FlameZerg
Apache 2.0
proxy-server.js11.7 kB
#!/usr/bin/env node const http = require('http'); const { spawn } = require('child_process'); const agent = new http.Agent({ keepAlive: true, keepAliveMsecs: 60000, maxSockets: 32, maxFreeSockets: 8 }); const PORT = process.env.PORT || 8081; const HOST = '0.0.0.0'; const BACKEND_PORT = 8082; const STARTUP_TIMEOUT = 58000; // 58秒启动超时(留 2s 缓冲) const HEALTH_CHECK_INTERVAL = 25000; // 25秒健康检查 const REQUEST_TIMEOUT = 58000; // 58秒请求超时 const RETRY_DELAYS = [1000, 2000, 5000]; // 重试延迟:1s, 2s, 5s(指数退避) let isBackendReady = false; let isBrowserInstalled = false; let startupTimer = null; let lastSuccessfulRequestTime = Date.now(); // 最后一次成功请求时间 console.log('========================================'); console.log(`🚀 启动 Playwright MCP 代理服务器 ${HOST}:${PORT}`); console.log(` 环境: ${process.env.NODE_ENV || 'production'}`); console.log(` 浏览器路径: ${process.env.PLAYWRIGHT_BROWSERS_PATH}`); console.log('========================================'); // 浏览器检查(仅检查,不安装) const fs = require('fs'); const browsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH || '/ms-playwright'; function checkBrowserInstalled() { if (!fs.existsSync(browsersPath)) { return false; } try { const files = fs.readdirSync(browsersPath); const hasChromium = files.some(f => f.startsWith('chromium')); if (hasChromium) { console.log(`✅ 浏览器已就绪: ${browsersPath}`); return true; } return false; } catch (err) { console.error(`❌ 浏览器检查失败: ${err.message}`); return false; } } // 进程锁管理 const LOCK_FILE = '/tmp/playwright-mcp.lock'; function cleanupLocks() { try { if (fs.existsSync(LOCK_FILE)) { fs.unlinkSync(LOCK_FILE); } } catch (err) { // 静默失败 } } cleanupLocks(); let playwrightProcess = null; let isStarting = false; function startPlaywrightBackend() { if (playwrightProcess || isStarting) { return; } isStarting = true; console.log('🚀 启动 Playwright MCP 后端...'); playwrightProcess = spawn('node', [ 'cli.js', '--headless', '--browser', 'chromium', '--no-sandbox', '--port', BACKEND_PORT, '--isolated', '--shared-browser-context', '--save-session', '--timeout-action=58000', '--timeout-navigation=58000', '--output-dir=/tmp/playwright-output' ], { stdio: ['ignore', 'pipe', 'pipe'] }); playwrightProcess.stdout.on('data', (data) => { const message = data.toString().trim(); // 仅记录关键启动信息 if (message.includes('listening') || message.includes('started') || message.includes(BACKEND_PORT)) { isBackendReady = true; if (startupTimer) { clearTimeout(startupTimer); startupTimer = null; } console.log('✅ 后端服务已就绪'); } }); playwrightProcess.stderr.on('data', (data) => { const errorMsg = data.toString().trim(); // 仅记录关键错误 if (errorMsg.includes('ETXTBSY')) { console.error('❌ 浏览器文件锁冲突 (ETXTBSY)'); cleanupLocks(); } else if (errorMsg.includes('not installed') || errorMsg.includes('Executable doesn')) { console.error('❌ 浏览器缺失错误'); } }); playwrightProcess.on('error', (error) => { console.error(`❌ 后端启动失败: ${error.message}`); isStarting = false; playwrightProcess = null; }); playwrightProcess.on('exit', (code, signal) => { isStarting = false; playwrightProcess = null; if (code !== 0 && code !== null) { console.error(`❌ 后端异常退出 (code: ${code}, signal: ${signal})`); } }); isStarting = false; } // 按需健康检查(仅在请求失败时触发) function triggerHealthCheckIfNeeded() { const timeSinceLastSuccess = Date.now() - lastSuccessfulRequestTime; // 若距上次成功请求 > 60s,执行健康检查 if (timeSinceLastSuccess > 60000) { console.log(`⌛ 后端 ${Math.floor(timeSinceLastSuccess / 1000)}s 未响应,执行健康检查...`); checkBackendHealth((healthy) => { if (!healthy && playwrightProcess) { console.error('❌ 后端健康检查失败,重启中...'); playwrightProcess.kill('SIGTERM'); playwrightProcess = null; isBackendReady = false; cleanupLocks(); setTimeout(() => { startPlaywrightBackend(); }, 3000); } }); } } // 健康检查 function checkBackendHealth(callback) { const req = http.request({ hostname: 'localhost', port: BACKEND_PORT, path: '/', method: 'GET', timeout: 2000 }, (res) => { callback(true); req.destroy(); }); req.on('error', () => callback(false)); req.on('timeout', () => { callback(false); req.destroy(); }); req.end(); } // 等待后端就绪 function waitForBackend(callback) { if (isBackendReady) { callback(); return; } const startTime = Date.now(); const checkInterval = setInterval(() => { checkBackendHealth((healthy) => { if (healthy) { clearInterval(checkInterval); if (startupTimer) { clearTimeout(startupTimer); startupTimer = null; } isBackendReady = true; callback(); } }); }, 500); // 500ms 快速轮询 startupTimer = setTimeout(() => { clearInterval(checkInterval); console.error('⚠️ 后端启动超时'); callback(); }, STARTUP_TIMEOUT); } // 转发请求(带指数退避重试) function forwardRequest(req, res, retryCount = 0) { const proxyHeaders = { ...req.headers }; proxyHeaders.host = `localhost:${BACKEND_PORT}`; const proxyReq = http.request({ hostname: 'localhost', port: BACKEND_PORT, path: req.url, method: req.method, headers: proxyHeaders, timeout: REQUEST_TIMEOUT, agent }, (proxyRes) => { // 记录成功请求时间 lastSuccessfulRequestTime = Date.now(); Object.keys(proxyRes.headers).forEach(key => { res.setHeader(key, proxyRes.headers[key]); }); res.writeHead(proxyRes.statusCode); proxyRes.pipe(res); }); proxyReq.on('error', (error) => { const canRetry = retryCount < RETRY_DELAYS.length && (error.code === 'ECONNREFUSED' || error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT'); if (canRetry) { const delay = RETRY_DELAYS[retryCount]; setTimeout(() => { forwardRequest(req, res, retryCount + 1); }, delay); } else { // 重试失败,触发健康检查 triggerHealthCheckIfNeeded(); console.error(`❌ 请求失败: ${error.message}`); if (!res.headersSent) { res.writeHead(502, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Backend unavailable', message: error.message })); } } }); proxyReq.on('timeout', () => { proxyReq.destroy(); if (!res.headersSent) { res.writeHead(504, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Request timeout' })); } }); req.pipe(proxyReq); } // 代理服务器 const proxyServer = http.createServer((req, res) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id, mcp-protocol-version'); res.setHeader('Connection', 'keep-alive'); if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; } // 健康检查 if (req.url === '/health' || req.url === '/healthz') { if (isBackendReady && isBrowserInstalled) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'healthy' })); } else { res.writeHead(503, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'starting' })); } return; } // MCP 端点判断(支持查询参数) const urlPath = req.url.split('?')[0]; const isMcpEndpoint = urlPath === '/mcp' || urlPath.startsWith('/mcp/'); // 非 MCP 端点:后端未就绪时返回 503 if (!isMcpEndpoint && !isBackendReady) { res.writeHead(503, { 'Content-Type': 'application/json', 'Retry-After': '10' }); res.end(JSON.stringify({ error: 'Service starting', message: '服务启动中,请稍后重试' })); return; } // MCP 端点:后端未就绪时返回 SSE 占位流(兼容 Smithery HTTP MCP 协议) if (isMcpEndpoint && req.method === 'POST' && !isBackendReady) { let body = ''; req.on('data', chunk => body += chunk.toString()); req.on('end', () => { try { const mcpRequest = JSON.parse(body); const method = mcpRequest.method || ''; // notifications/* 单向消息:返回 202 Accepted if (method.startsWith('notifications/') || mcpRequest.id === undefined) { res.writeHead(202); res.end(); return; } // 返回 SSE 流式占位响应 res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' }); const placeholderResponse = { jsonrpc: '2.0', id: mcpRequest.id, result: { protocolVersion: '2025-06-18', capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 'playwright-mcp', version: '0.0.45' }, instructions: '浏览器正在初始化,请稍后重试(约 5-10 秒)' } }; // SSE 格式: data: <json>\n\n res.write(`data: ${JSON.stringify(placeholderResponse)}\n\n`); res.end(); } catch (err) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid JSON-RPC request' })); } }); return; // 阻止后续 forwardRequest 执行 } forwardRequest(req, res); }); // Server keep-alive and timeout tuning (58s) proxyServer.keepAliveTimeout = 58000; proxyServer.headersTimeout = 58000; proxyServer.requestTimeout = 58000; // 启动流程(立即启动后端,不等待浏览器检查) (async () => { try { // 立即启动 Playwright 后端 console.log('🚀 启动 Playwright MCP 后端...'); startPlaywrightBackend(); // 等待后端就绪 waitForBackend(() => { console.log('✅ 后端服务已就绪'); isBrowserInstalled = true; // 标记为已就绪 }); // 启动代理服务器(并行) proxyServer.listen(PORT, HOST, () => { console.log(`✅ 代理服务器已启动: http://${HOST}:${PORT}`); console.log(` 转发到: http://localhost:${BACKEND_PORT}`); }); } catch (err) { console.error(`❌ 启动失败: ${err.message}`); process.exit(1); } })(); // 进程清理 process.on('SIGTERM', () => { console.log('🛑 服务关闭中...'); cleanupLocks(); if (playwrightProcess) playwrightProcess.kill(); proxyServer.close(); process.exit(0); }); process.on('SIGINT', () => { console.log('🛑 服务关闭中...'); cleanupLocks(); if (playwrightProcess) playwrightProcess.kill(); proxyServer.close(); process.exit(0); }); process.on('exit', cleanupLocks);

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/FlameZerg/playwright-mcp-streamablehttp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server