diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a0d2881 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,72 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is the Playwright MCP (Model Context Protocol) server - a TypeScript/Node.js project that provides browser automation capabilities through structured accessibility snapshots. It enables LLMs to interact with web pages without requiring screenshots or vision models. + +## Development Commands + +**Build:** +- `npm run build` - Build TypeScript to JavaScript in `lib/` directory +- `npm run build:extension` - Build browser extension in `extension/lib/` +- `npm run watch` - Watch mode for main build +- `npm run watch:extension` - Watch mode for extension build + +**Testing:** +- `npm test` - Run all Playwright tests +- `npm run ctest` - Run Chrome-specific tests only +- `npm run ftest` - Run Firefox-specific tests only +- `npm run wtest` - Run WebKit-specific tests only + +**Linting & Quality:** +- `npm run lint` - Run linter and type checking (includes README update) +- `npm run lint-fix` - Auto-fix linting issues +- `npm run update-readme` - Update README with generated tool documentation + +**Development:** +- `npm run clean` - Remove built files from `lib/` and `extension/lib/` + +## Architecture + +**Core Components:** +- `src/index.ts` - Main entry point providing `createConnection()` API +- `src/server.ts` - MCP server implementation with connection management +- `src/connection.ts` - Creates MCP server with tool handlers and request processing +- `src/tools.ts` - Aggregates all available tools from `src/tools/` directory +- `src/context.ts` - Browser context management and state handling +- `src/browserContextFactory.ts` - Factory for creating browser contexts with different configurations + +**Tool System:** +- All browser automation tools are in `src/tools/` directory +- Each tool file exports an array of tool definitions +- Tools are categorized by capability: `core`, `tabs`, `install`, `pdf`, `vision` +- Tool capabilities are filtered based on config to enable/disable features + +**Browser Management:** +- Supports multiple browsers: Chrome, Firefox, WebKit, Edge +- Two modes: persistent profile (default) or isolated contexts +- Browser contexts are created through factory pattern for flexibility +- CDP (Chrome DevTools Protocol) support for remote browser connections + +**Configuration:** +- `src/config.ts` - Configuration resolution and validation +- Supports both CLI arguments and JSON config files +- Browser launch options, context options, network settings, capabilities + +**Transport:** +- Supports both STDIO and HTTP/SSE transports +- STDIO for direct MCP client connections +- HTTP mode for standalone server operation + +## Key Files + +- `cli.js` - CLI entry point (imports `lib/program.js`) +- `src/program.ts` - Command-line argument parsing and server setup +- `playwright.config.ts` - Test configuration for multiple browser projects +- `tests/fixtures.ts` - Custom Playwright test fixtures for MCP testing + +## Extension + +The `extension/` directory contains a browser extension for CDP relay functionality, built separately with its own TypeScript config. \ No newline at end of file diff --git a/README.md b/README.md index de48c2d..a5e1224 100644 --- a/README.md +++ b/README.md @@ -511,6 +511,14 @@ http.createServer(async (req, res) => { +- **browser_recording_status** + - Title: Get video recording status + - Description: Check if video recording is currently enabled and get recording details. Use this to verify recording is active before performing actions, or to check output directory and settings. + - Parameters: None + - Read-only: **true** + + + - **browser_resize** - Title: Resize browser window - Description: Resize the browser window @@ -540,6 +548,24 @@ http.createServer(async (req, res) => { +- **browser_start_recording** + - Title: Start video recording + - Description: Start recording browser session video. This must be called BEFORE performing browser actions you want to record. New browser contexts will be created with video recording enabled. Videos are automatically saved when pages/contexts close. + - Parameters: + - `size` (object, optional): Video recording size + - `filename` (string, optional): Base filename for video files (default: session-{timestamp}.webm) + - Read-only: **false** + + + +- **browser_stop_recording** + - Title: Stop video recording + - Description: Stop video recording and return the paths to recorded video files. This closes all active pages to ensure videos are properly saved. Call this when you want to finalize and access the recorded videos. + - Parameters: None + - Read-only: **true** + + + - **browser_take_screenshot** - Title: Take a screenshot - Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions. diff --git a/src/context.ts b/src/context.ts index d18faa0..8e6dabc 100644 --- a/src/context.ts +++ b/src/context.ts @@ -33,6 +33,9 @@ export class Context { private _tabs: Tab[] = []; private _currentTab: Tab | undefined; clientVersion: { name: string; version: string; } | undefined; + private _videoRecordingConfig: { dir: string; size?: { width: number; height: number } } | undefined; + private _videoBaseFilename: string | undefined; + private _activePagesWithVideos: Set = new Set(); constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) { this.tools = tools; @@ -116,6 +119,11 @@ export class Context { this._tabs.push(tab); if (!this._currentTab) this._currentTab = tab; + + // Track pages with video recording + if (this._videoRecordingConfig && page.video()) + this._activePagesWithVideos.add(page); + } private _onPageClosed(tab: Tab) { @@ -171,8 +179,16 @@ export class Context { } private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { - // TODO: move to the browser context factory to make it based on isolation mode. - const result = await this._browserContextFactory.createContext(this.clientVersion!); + let result: { browserContext: playwright.BrowserContext, close: () => Promise }; + + if (this._videoRecordingConfig) { + // Create a new browser context with video recording enabled + result = await this._createVideoEnabledContext(); + } else { + // Use the standard browser context factory + result = await this._browserContextFactory.createContext(this.clientVersion!); + } + const { browserContext } = result; await this._setupRequestInterception(browserContext); for (const page of browserContext.pages()) @@ -188,4 +204,81 @@ export class Context { } return result; } + + private async _createVideoEnabledContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { + // For video recording, we need to create an isolated context + const browserType = playwright[this.config.browser.browserName]; + + const browser = await browserType.launch({ + ...this.config.browser.launchOptions, + handleSIGINT: false, + handleSIGTERM: false, + }); + + const contextOptions = { + ...this.config.browser.contextOptions, + recordVideo: this._videoRecordingConfig, + }; + + const browserContext = await browser.newContext(contextOptions); + + return { + browserContext, + close: async () => { + await browserContext.close(); + await browser.close(); + } + }; + } + + setVideoRecording(config: { dir: string; size?: { width: number; height: number } }, baseFilename: string) { + this._videoRecordingConfig = config; + this._videoBaseFilename = baseFilename; + + // Force recreation of browser context to include video recording + if (this._browserContextPromise) { + void this.close().then(() => { + // The next call to _ensureBrowserContext will create a new context with video recording + }); + } + } + + getVideoRecordingInfo() { + return { + enabled: !!this._videoRecordingConfig, + config: this._videoRecordingConfig, + baseFilename: this._videoBaseFilename, + activeRecordings: this._activePagesWithVideos.size, + }; + } + + async stopVideoRecording(): Promise { + if (!this._videoRecordingConfig) + return []; + + + const videoPaths: string[] = []; + + // Close all pages to save videos + for (const page of this._activePagesWithVideos) { + try { + if (!page.isClosed()) { + await page.close(); + const video = page.video(); + if (video) { + const videoPath = await video.path(); + videoPaths.push(videoPath); + } + } + } catch (error) { + testDebug('Error closing page for video recording:', error); + } + } + + this._activePagesWithVideos.clear(); + this._videoRecordingConfig = undefined; + this._videoBaseFilename = undefined; + + return videoPaths; + } } diff --git a/src/tools.ts b/src/tools.ts index 9b7c2a3..5ffb184 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -27,6 +27,7 @@ import pdf from './tools/pdf.js'; import snapshot from './tools/snapshot.js'; import tabs from './tools/tabs.js'; import screenshot from './tools/screenshot.js'; +import video from './tools/video.js'; import wait from './tools/wait.js'; import mouse from './tools/mouse.js'; @@ -47,5 +48,6 @@ export const allTools: Tool[] = [ ...screenshot, ...snapshot, ...tabs, + ...video, ...wait, ]; diff --git a/src/tools/video.ts b/src/tools/video.ts new file mode 100644 index 0000000..e970d85 --- /dev/null +++ b/src/tools/video.ts @@ -0,0 +1,143 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from 'path'; +import { z } from 'zod'; +import { defineTool } from './tool.js'; + +const startRecording = defineTool({ + capability: 'core', + + schema: { + name: 'browser_start_recording', + title: 'Start video recording', + description: 'Start recording browser session video. This must be called BEFORE performing browser actions you want to record. New browser contexts will be created with video recording enabled. Videos are automatically saved when pages/contexts close.', + inputSchema: z.object({ + size: z.object({ + width: z.number().optional().describe('Video width in pixels (default: scales to fit 800x800)'), + height: z.number().optional().describe('Video height in pixels (default: scales to fit 800x800)'), + }).optional().describe('Video recording size'), + filename: z.string().optional().describe('Base filename for video files (default: session-{timestamp}.webm)'), + }), + type: 'destructive', + }, + + handle: async (context, params, response) => { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const baseFilename = params.filename || `session-${timestamp}`; + const videoDir = path.join(context.config.outputDir, 'videos'); + + // Update context options to enable video recording + const recordVideoOptions: any = { + dir: videoDir, + }; + + if (params.size) + recordVideoOptions.size = params.size; + + + // Store video recording config in context for future browser contexts + context.setVideoRecording(recordVideoOptions, baseFilename); + + response.addResult(`āœ“ Video recording enabled. Videos will be saved to: ${videoDir}`); + response.addResult(`āœ“ Video files will be named: ${baseFilename}-*.webm`); + response.addResult(`\nNext steps:`); + response.addResult(`1. Navigate to pages and perform browser actions`); + response.addResult(`2. Use browser_stop_recording when finished to save videos`); + response.addResult(`3. Videos are automatically saved when pages close`); + response.addCode(`// Video recording enabled for new browser contexts`); + response.addCode(`const context = await browser.newContext({`); + response.addCode(` recordVideo: {`); + response.addCode(` dir: '${videoDir}',`); + if (params.size) + response.addCode(` size: { width: ${params.size.width || 'auto'}, height: ${params.size.height || 'auto'} }`); + + response.addCode(` }`); + response.addCode(`});`); + }, +}); + +const stopRecording = defineTool({ + capability: 'core', + + schema: { + name: 'browser_stop_recording', + title: 'Stop video recording', + description: 'Stop video recording and return the paths to recorded video files. This closes all active pages to ensure videos are properly saved. Call this when you want to finalize and access the recorded videos.', + inputSchema: z.object({}), + type: 'readOnly', + }, + + handle: async (context, params, response) => { + const videoPaths = await context.stopVideoRecording(); + + if (videoPaths.length === 0) { + response.addResult('No video recording was active.'); + return; + } + + response.addResult(`āœ“ Video recording stopped. ${videoPaths.length} video file(s) saved:`); + for (const videoPath of videoPaths) + response.addResult(`šŸ“¹ ${videoPath}`); + + response.addResult(`\nVideos are now ready for viewing or sharing.`); + response.addCode(`// Video recording stopped`); + response.addCode(`await context.close(); // Ensures video is saved`); + }, +}); + +const getRecordingStatus = defineTool({ + capability: 'core', + + schema: { + name: 'browser_recording_status', + title: 'Get video recording status', + description: 'Check if video recording is currently enabled and get recording details. Use this to verify recording is active before performing actions, or to check output directory and settings.', + inputSchema: z.object({}), + type: 'readOnly', + }, + + handle: async (context, params, response) => { + const recordingInfo = context.getVideoRecordingInfo(); + + if (!recordingInfo.enabled) { + response.addResult('āŒ Video recording is not enabled.'); + response.addResult('\nšŸ’” To start recording:'); + response.addResult('1. Use browser_start_recording to enable recording'); + response.addResult('2. Navigate to pages and perform actions'); + response.addResult('3. Use browser_stop_recording to save videos'); + return; + } + + response.addResult(`āœ… Video recording is active:`); + response.addResult(`šŸ“ Output directory: ${recordingInfo.config?.dir}`); + response.addResult(`šŸ“ Base filename: ${recordingInfo.baseFilename}`); + if (recordingInfo.config?.size) + response.addResult(`šŸ“ Video size: ${recordingInfo.config.size.width}x${recordingInfo.config.size.height}`); + else + response.addResult(`šŸ“ Video size: auto-scaled to fit 800x800`); + + response.addResult(`šŸŽ¬ Active recordings: ${recordingInfo.activeRecordings}`); + if (recordingInfo.activeRecordings === 0) + response.addResult(`\nšŸ’” Tip: Navigate to pages to start recording browser actions`); + }, +}); + +export default [ + startRecording, + stopRecording, + getRecordingStatus, +];