Skip to main content
Glama

Better Playwright MCP

by livoras
playwright-server.ts29.5 kB
/** * HTTP Server for Playwright with Page Management * Based on Microsoft's playwright-mcp core */ import express from 'express'; import type { Request, Response } from 'express'; import * as playwright from 'playwright'; import { v4 as uuid } from 'uuid'; import type { Server } from 'http'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { SmartOutlineSimple } from '../utils/smart-outline-simple.js'; import { searchSnapshot } from '../utils/search-snapshot.js'; import type { SearchOptions, SearchResponse } from '../types/search.js'; type PageEx = playwright.Page & { _snapshotForAI: () => Promise<string>; }; interface PageInfo { id: string; name: string; description: string; page: playwright.Page; refMappings: Map<string, RefMapping>; } interface RefMapping { role: string; name?: string; selector?: string; } export class PlaywrightServer { private app: express.Application; private httpServer: Server | null = null; private browser: playwright.Browser | null = null; private browserContext: playwright.BrowserContext | null = null; private persistentContext: playwright.BrowserContext | null = null; private pages = new Map<string, PageInfo>(); private userDataDir: string; private useChrome: boolean; private headless: boolean; private smartOutlineGenerator: SmartOutlineSimple; constructor(private port: number = parseInt(process.env.PORT || '3102')) { // Configuration from environment variables this.useChrome = process.env.USE_CHROMIUM !== 'true'; // Default to Chrome this.headless = process.env.HEADLESS === 'true'; // Default to headed // User data directory for persistence this.userDataDir = process.env.USER_DATA_DIR || path.join(os.homedir(), '.better-playwright-mcp', 'user-data'); this.app = express(); // Initialize smart outline generator this.smartOutlineGenerator = new SmartOutlineSimple(); this.app.use(express.json()); this.registerRoutes(); } private registerRoutes() { // Health check this.app.get('/health', (req: Request, res: Response) => { res.json({ status: 'ok', pages: this.pages.size }); }); // Page management this.app.post('/api/pages', async (req: Request, res: Response) => { try { const { name, description, url } = req.body; const result = await this.createPage(name, description, url); res.json(result); } catch (error: any) { res.status(500).json({ error: error.message }); } }); this.app.get('/api/pages', (req: Request, res: Response) => { const pages = Array.from(this.pages.values()).map(p => ({ id: p.id, name: p.name, description: p.description, url: p.page.url(), title: p.page.title() })); res.json(pages); }); this.app.delete('/api/pages/:pageId', async (req: Request, res: Response) => { try { const { pageId } = req.params; await this.closePage(pageId); res.json({ success: true }); } catch (error: any) { res.status(500).json({ error: error.message }); } }); // Navigation this.app.post('/api/pages/:pageId/navigate', async (req: Request, res: Response) => { try { const { pageId } = req.params; const { url } = req.body; const result = await this.navigate(pageId, url); res.json(result); } catch (error: any) { res.status(500).json({ error: error.message }); } }); // Click action using ref this.app.post('/api/pages/:pageId/click', async (req: Request, res: Response) => { try { const { pageId } = req.params; const { ref, element } = req.body; await this.click(pageId, ref, element); res.json({ success: true, action: 'click', pageId, ref: ref || element }); } catch (error: any) { res.status(500).json({ error: error.message }); } }); // Type action using ref this.app.post('/api/pages/:pageId/type', async (req: Request, res: Response) => { try { const { pageId } = req.params; const { ref, element, text } = req.body; await this.type(pageId, ref, element, text); res.json({ success: true, action: 'type', pageId, ref: ref || element, text }); } catch (error: any) { res.status(500).json({ error: error.message }); } }); // Fill action using ref this.app.post('/api/pages/:pageId/fill', async (req: Request, res: Response) => { try { const { pageId } = req.params; const { ref, element, value } = req.body; await this.fill(pageId, ref, element, value); res.json({ success: true, action: 'fill', pageId, ref: ref || element, value }); } catch (error: any) { res.status(500).json({ error: error.message }); } }); // Select option using ref this.app.post('/api/pages/:pageId/select', async (req: Request, res: Response) => { try { const { pageId } = req.params; const { ref, element, value } = req.body; await this.select(pageId, ref, element, value); res.json({ success: true, action: 'select', pageId, ref: ref || element, value }); } catch (error: any) { res.status(500).json({ error: error.message }); } }); // Screenshot this.app.post('/api/pages/:pageId/screenshot', async (req: Request, res: Response) => { try { const { pageId } = req.params; const { fullPage = true } = req.body; const screenshot = await this.screenshot(pageId, fullPage); res.json({ screenshot }); } catch (error: any) { res.status(500).json({ error: error.message }); } }); // Hover action using ref this.app.post('/api/pages/:pageId/hover', async (req: Request, res: Response) => { try { const { pageId } = req.params; const { ref, element } = req.body; await this.hover(pageId, ref, element); res.json({ success: true, action: 'hover', pageId, ref: ref || element }); } catch (error: any) { res.status(500).json({ error: error.message }); } }); // Press key this.app.post('/api/pages/:pageId/press', async (req: Request, res: Response) => { try { const { pageId } = req.params; const { key } = req.body; await this.pressKey(pageId, key); res.json({ success: true, action: 'press', pageId, key }); } catch (error: any) { res.status(500).json({ error: error.message }); } }); // File upload this.app.post('/api/pages/:pageId/upload', async (req: Request, res: Response) => { try { const { pageId } = req.params; const { ref, files } = req.body; await this.uploadFiles(pageId, ref, files); res.json({ success: true, action: 'upload', pageId, ref, filesCount: files.length }); } catch (error: any) { res.status(500).json({ error: error.message }); } }); // Handle dialog this.app.post('/api/pages/:pageId/dialog', async (req: Request, res: Response) => { try { const { pageId } = req.params; const { accept, text } = req.body; await this.handleDialog(pageId, accept, text); res.json({ success: true }); } catch (error: any) { res.status(500).json({ error: error.message }); } }); // Navigate back this.app.post('/api/pages/:pageId/back', async (req: Request, res: Response) => { try { const { pageId } = req.params; await this.navigateBack(pageId); res.json({ success: true, action: 'back', pageId }); } catch (error: any) { res.status(500).json({ error: error.message }); } }); // Navigate forward this.app.post('/api/pages/:pageId/forward', async (req: Request, res: Response) => { try { const { pageId } = req.params; await this.navigateForward(pageId); res.json({ success: true, action: 'forward', pageId }); } catch (error: any) { res.status(500).json({ error: error.message }); } }); // Scroll to bottom this.app.post('/api/pages/:pageId/scroll-bottom', async (req: Request, res: Response) => { try { const { pageId } = req.params; const { ref } = req.body; await this.scrollToBottom(pageId, ref); res.json({ success: true, action: 'scroll-bottom', pageId, ref }); } catch (error: any) { res.status(500).json({ error: error.message }); } }); // Scroll to top this.app.post('/api/pages/:pageId/scroll-top', async (req: Request, res: Response) => { try { const { pageId } = req.params; const { ref } = req.body; await this.scrollToTop(pageId, ref); res.json({ success: true, action: 'scroll-top', pageId, ref }); } catch (error: any) { res.status(500).json({ error: error.message }); } }); // Wait for timeout this.app.post('/api/pages/:pageId/wait-timeout', async (req: Request, res: Response) => { try { const { pageId } = req.params; const { timeout } = req.body; await this.waitForTimeout(pageId, timeout); res.json({ success: true }); } catch (error: any) { res.status(500).json({ error: error.message }); } }); // Wait for selector this.app.post('/api/pages/:pageId/wait-selector', async (req: Request, res: Response) => { try { const { pageId } = req.params; const { selector, options } = req.body; await this.waitForSelector(pageId, selector, options); res.json({ success: true }); } catch (error: any) { res.status(500).json({ error: error.message }); } }); // Get page outline (structured summary - fixed 200 lines) this.app.post('/api/pages/:pageId/outline', async (req: Request, res: Response) => { try { const { pageId } = req.params; // Get current snapshot const snapshotData = await this.getSnapshot(pageId); // Generate outline with intelligent folding (fixed 200 lines) const outline = this.generateOutline(snapshotData.snapshot); res.json({ outline }); } catch (error: any) { res.status(500).json({ error: error.message }); } }); // Get raw snapshot - COMMENTED OUT // this.app.post('/api/pages/:pageId/raw-snapshot', async (req: Request, res: Response) => { // try { // const { pageId } = req.params; // const snapshotData = await this.getSnapshot(pageId); // res.json({ snapshot: snapshotData.snapshot }); // } catch (error: any) { // res.status(500).json({ error: error.message }); // } // }); // Search snapshot this.app.post('/api/pages/:pageId/search', async (req: Request, res: Response) => { try { const { pageId } = req.params; const { pattern, ignoreCase = false, lineLimit = 100 } = req.body; // Get current snapshot const snapshotData = await this.getSnapshot(pageId); // Execute search on snapshot const result = searchSnapshot(snapshotData.snapshot, { pattern, ignoreCase, lineLimit }); res.json(result); } catch (error: any) { res.status(500).json({ error: error.message }); } }); // Debug: Save raw snapshot to file - COMMENTED OUT // this.app.post('/api/pages/:pageId/save-snapshot', async (req: Request, res: Response) => { // try { // const { pageId } = req.params; // const snapshotData = await this.getSnapshot(pageId); // // // Save to file // const fs = await import('fs/promises'); // const filename = `/tmp/snapshot-${pageId}-${Date.now()}.txt`; // await fs.writeFile(filename, snapshotData.snapshot, 'utf-8'); // // res.json({ success: true, file: filename, lines: snapshotData.snapshot.split('\n').length }); // } catch (error: any) { // res.status(500).json({ error: error.message }); // } // }); } private async ensureBrowser() { // Use persistent context with Chrome for user data persistence if (!this.persistentContext) { console.log(`🔧 Launching ${this.useChrome ? 'Chrome' : 'Chromium'} with user data at: ${this.userDataDir}`); this.persistentContext = await playwright.chromium.launchPersistentContext( this.userDataDir, { headless: this.headless, channel: this.useChrome ? 'chrome' : undefined, args: [ '--disable-blink-features=AutomationControlled', '--disable-dev-shm-usage' ], ignoreDefaultArgs: ['--enable-automation'], } ); this.browserContext = this.persistentContext; console.log('✅ Browser launched with persistent context'); } } async createPage(name: string, description: string, url?: string): Promise<{ success: boolean; pageId: string; name: string; description: string; url?: string }> { await this.ensureBrowser(); const pageId = uuid(); const page = await this.browserContext!.newPage(); const pageInfo: PageInfo = { id: pageId, name, description, page, refMappings: new Map() }; this.pages.set(pageId, pageInfo); if (url) { await page.goto(url); } return { success: true, pageId, name, description, ...(url && { url }) }; } async closePage(pageId: string) { const pageInfo = this.pages.get(pageId); if (!pageInfo) { throw new Error(`Page ${pageId} not found`); } await pageInfo.page.close(); this.pages.delete(pageId); } async navigate(pageId: string, url: string) { const pageInfo = this.pages.get(pageId); if (!pageInfo) { throw new Error(`Page ${pageId} not found`); } await pageInfo.page.goto(url); return { success: true, action: 'navigate', pageId, url }; } private async getSnapshot(pageId: string) { const pageInfo = this.pages.get(pageId); if (!pageInfo) { throw new Error(`Page ${pageId} not found`); } // Get snapshot using Playwright's internal API const snapshot = await (pageInfo.page as PageEx)._snapshotForAI(); // Parse refs from snapshot and build mappings this.parseRefMappings(pageId, snapshot); return { pageId, url: pageInfo.page.url(), title: await pageInfo.page.title(), snapshot: snapshot, modalStates: [], consoleMessages: [] }; } private parseRefMappings(pageId: string, snapshot: string) { const pageInfo = this.pages.get(pageId); if (!pageInfo) return; const mappings = new Map<string, RefMapping>(); const lines = snapshot.split('\n'); for (const line of lines) { const refMatch = line.match(/\[ref=([^\]]+)\]/); if (refMatch) { const ref = refMatch[1]; // Parse role and name from line const roleMatch = line.match(/- (\w+)/); const nameMatch = line.match(/"([^"]+)"/); if (roleMatch) { mappings.set(ref, { role: roleMatch[1], name: nameMatch ? nameMatch[1] : undefined }); } } } pageInfo.refMappings = mappings; } async click(pageId: string, ref: string, element: string) { const pageInfo = this.pages.get(pageId); if (!pageInfo) { throw new Error(`Page ${pageId} not found`); } // Use aria-ref selector directly await pageInfo.page.locator(`aria-ref=${ref}`).click(); } async type(pageId: string, ref: string, element: string, text: string) { const pageInfo = this.pages.get(pageId); if (!pageInfo) { throw new Error(`Page ${pageId} not found`); } // Use aria-ref selector directly await pageInfo.page.locator(`aria-ref=${ref}`).type(text); } async fill(pageId: string, ref: string, element: string, value: string) { const pageInfo = this.pages.get(pageId); if (!pageInfo) { throw new Error(`Page ${pageId} not found`); } // Use aria-ref selector directly await pageInfo.page.locator(`aria-ref=${ref}`).fill(value); } async select(pageId: string, ref: string, element: string, value: string | string[]) { const pageInfo = this.pages.get(pageId); if (!pageInfo) { throw new Error(`Page ${pageId} not found`); } // Use aria-ref selector directly await pageInfo.page.locator(`aria-ref=${ref}`).selectOption(value); } async screenshot(pageId: string, fullPage: boolean = true) { const pageInfo = this.pages.get(pageId); if (!pageInfo) { throw new Error(`Page ${pageId} not found`); } const buffer = await pageInfo.page.screenshot({ fullPage }); return buffer.toString('base64'); } async hover(pageId: string, ref: string, element: string) { const pageInfo = this.pages.get(pageId); if (!pageInfo) { throw new Error(`Page ${pageId} not found`); } // Use aria-ref selector directly await pageInfo.page.locator(`aria-ref=${ref}`).hover(); } async pressKey(pageId: string, key: string) { const pageInfo = this.pages.get(pageId); if (!pageInfo) { throw new Error(`Page ${pageId} not found`); } await pageInfo.page.keyboard.press(key); } async uploadFiles(pageId: string, ref: string, files: string[]) { const pageInfo = this.pages.get(pageId); if (!pageInfo) { throw new Error(`Page ${pageId} not found`); } // Use aria-ref selector directly await pageInfo.page.locator(`aria-ref=${ref}`).setInputFiles(files); } async handleDialog(pageId: string, accept: boolean, text?: string) { const pageInfo = this.pages.get(pageId); if (!pageInfo) { throw new Error(`Page ${pageId} not found`); } // Set up dialog handler pageInfo.page.once('dialog', async dialog => { if (accept) { await dialog.accept(text); } else { await dialog.dismiss(); } }); } async navigateBack(pageId: string) { const pageInfo = this.pages.get(pageId); if (!pageInfo) { throw new Error(`Page ${pageId} not found`); } await pageInfo.page.goBack(); } async navigateForward(pageId: string) { const pageInfo = this.pages.get(pageId); if (!pageInfo) { throw new Error(`Page ${pageId} not found`); } await pageInfo.page.goForward(); } async scrollToBottom(pageId: string, ref?: string) { const pageInfo = this.pages.get(pageId); if (!pageInfo) { throw new Error(`Page ${pageId} not found`); } if (ref) { // Scroll element to bottom await pageInfo.page.locator(`aria-ref=${ref}`).evaluate(el => { el.scrollTop = el.scrollHeight; }); } else { // Scroll page to bottom await pageInfo.page.evaluate(() => { window.scrollTo(0, document.body.scrollHeight); }); } } async scrollToTop(pageId: string, ref?: string) { const pageInfo = this.pages.get(pageId); if (!pageInfo) { throw new Error(`Page ${pageId} not found`); } if (ref) { // Scroll element to top await pageInfo.page.locator(`aria-ref=${ref}`).evaluate(el => { el.scrollTop = 0; }); } else { // Scroll page to top await pageInfo.page.evaluate(() => { window.scrollTo(0, 0); }); } } async waitForTimeout(pageId: string, timeout: number) { const pageInfo = this.pages.get(pageId); if (!pageInfo) { throw new Error(`Page ${pageId} not found`); } await pageInfo.page.waitForTimeout(timeout); } async waitForSelector(pageId: string, selector: string, options?: any) { const pageInfo = this.pages.get(pageId); if (!pageInfo) { throw new Error(`Page ${pageId} not found`); } await pageInfo.page.waitForSelector(selector, options); } private generateOutline(snapshot: string): string { // Use smart outline generator return this.smartOutlineGenerator.generate(snapshot); } private generateOutlineOld(snapshot: string): string { const maxLines = 200; const lines = snapshot.split('\n'); const result: string[] = []; // Phase 1: Analyze structure and detect list groups interface ListGroup { indent: number; elementType: string; firstLine: number; lines: string[]; childrenOfFirst: string[]; } const listGroups: ListGroup[] = []; let currentGroup: ListGroup | null = null; // First pass: identify all list groups for (let i = 0; i < lines.length; i++) { const line = lines[i]; const indent = line.length - line.trimStart().length; const elementMatch = line.match(/^\s*-\s*([a-z]+)/); const elementType = elementMatch ? elementMatch[1] : ''; if (!elementType) continue; // Debug: Track all elements between ref=e300 and ref=e800 const refMatch = line.match(/\[ref=(e\d+)\]/); if (refMatch) { const refNum = parseInt(refMatch[1].substring(1)); if (refNum >= 300 && refNum <= 800) { console.log(`[DEBUG] Line ${i}: indent=${indent}, type=${elementType}, ref=${refMatch[1]}`); } } // IMPROVED LOGIC: Handle nested structures if (currentGroup) { if (indent > currentGroup.indent) { // This is a child element - skip it, don't break the group if (elementType === 'listitem') { console.log(`[DEBUG] Skipping child element at line ${i}: indent=${indent} > groupIndent=${currentGroup.indent}`); } continue; } else if (indent === currentGroup.indent && elementType === currentGroup.elementType) { // Same level and type - add to group currentGroup.lines.push(line); if (elementType === 'listitem') { console.log(`[DEBUG] Added to group: ${currentGroup.lines.length} items`); } } else { // Different element at same or shallower level - save and start new group if (currentGroup && elementType === 'listitem') { console.log(`[DEBUG] Breaking group at line ${i}: currentIndent=${currentGroup.indent}, newIndent=${indent}, currentType=${currentGroup.elementType}, newType=${elementType}`); } // Save previous group if it has multiple elements if (currentGroup && currentGroup.lines.length >= 3) { // Capture children of first element for structure sample const firstIndent = currentGroup.indent; currentGroup.childrenOfFirst = []; for (let j = currentGroup.firstLine + 1; j < lines.length; j++) { const childLine = lines[j]; const childIndent = childLine.length - childLine.trimStart().length; if (childIndent <= firstIndent) break; if (childIndent === firstIndent + 2) { currentGroup.childrenOfFirst.push(childLine); } } if (currentGroup.elementType === 'listitem') { console.log(`[DEBUG] Saved group: ${currentGroup.elementType} with ${currentGroup.lines.length} items at indent ${currentGroup.indent}`); } listGroups.push(currentGroup); } else if (currentGroup && currentGroup.elementType === 'listitem') { console.log(`[DEBUG] Group too small: ${currentGroup.lines.length} items`); } // Start new group currentGroup = { indent, elementType, firstLine: i, lines: [line], childrenOfFirst: [] }; } } else { // No current group - start a new one currentGroup = { indent, elementType, firstLine: i, lines: [line], childrenOfFirst: [] }; } } // Don't forget the last group if (currentGroup && currentGroup.lines.length >= 3) { listGroups.push(currentGroup); } // Phase 2: Calculate dynamic priorities const staticHighPriority = ['heading', 'button', 'link', 'searchbox', 'navigation', 'banner', 'main', 'form']; const staticMediumPriority = ['textbox', 'checkbox', 'radio', 'select', 'list', 'article', 'section', 'region']; // Find the dominant list groups (by element count) const sortedGroups = [...listGroups].sort((a, b) => b.lines.length - a.lines.length); const dominantGroups = sortedGroups.slice(0, 3); // Determine which groups are high priority const isHighPriorityGroup = (group: ListGroup) => { return group.lines.length >= 10 || dominantGroups.includes(group) || staticHighPriority.includes(group.elementType); }; // Phase 3: Build output intelligently const processedGroups = new Set<ListGroup>(); let lineCount = 0; // Process lines with awareness of list groups for (let i = 0; i < lines.length && lineCount < maxLines; i++) { const line = lines[i]; const indent = line.length - line.trimStart().length; const elementMatch = line.match(/^\s*-\s*([a-z]+)/); const elementType = elementMatch ? elementMatch[1] : ''; // Check if this line starts a list group const group = listGroups.find(g => g.firstLine === i && !processedGroups.has(g) ); if (group && isHighPriorityGroup(group)) { // Process important list group with sample preservation processedGroups.add(group); // Add first element as complete sample result.push(group.lines[0]); lineCount++; // Add children of first element (up to 10 lines for structure) const childrenToShow = Math.min(group.childrenOfFirst.length, 10); for (let j = 0; j < childrenToShow && lineCount < maxLines; j++) { result.push(group.childrenOfFirst[j]); lineCount++; } // Collapse remaining elements if (group.lines.length > 1) { const remaining = group.lines.length - 1; const indentStr = ' '.repeat(group.indent); // Extract ref range if present const firstRef = group.lines[0].match(/\[ref=([^\]]+)\]/)?.[1]; const lastRef = group.lines[group.lines.length - 1].match(/\[ref=([^\]]+)\]/)?.[1]; const refRange = firstRef && lastRef ? ` [ref=${firstRef}-${lastRef}]` : ''; result.push(`${indentStr}- ${group.elementType} (... and ${remaining} more similar)${refRange}`); lineCount++; } // Skip the lines we've already processed i = group.firstLine + group.lines.length - 1; } else if (!group || !isHighPriorityGroup(group)) { // Process regular elements if (staticHighPriority.includes(elementType) || (lineCount < maxLines * 0.7 && staticMediumPriority.includes(elementType))) { result.push(line); lineCount++; } else if (lineCount < maxLines * 0.9) { // Add low priority elements only if we have space result.push(line); lineCount++; } } } // Add summary header with statistics const header = `Page Outline (${lineCount}/200 lines):\n`; return header + result.join('\n'); } async start() { return new Promise<void>((resolve) => { this.httpServer = this.app.listen(this.port, () => { console.log(`🚀 Playwright Server running on http://localhost:${this.port}`); resolve(); }); }); } async stop() { // Close all pages for (const [pageId] of this.pages) { await this.closePage(pageId); } // Close persistent context if (this.persistentContext) { await this.persistentContext.close(); console.log('✅ Persistent context closed, user data saved'); } // Close browser if (this.browser) { await this.browser.close(); } // Stop HTTP server if (this.httpServer) { this.httpServer.close(); } } } // Start server if run directly if (import.meta.url === `file://${process.argv[1]}`) { const server = new PlaywrightServer(); server.start().catch(console.error); // Handle graceful shutdown process.on('SIGINT', async () => { console.log('\n⏹️ Shutting down...'); await server.stop(); process.exit(0); }); }

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/livoras/better-playwright-mcp'

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