playwright-stealth-mcp-server 0.0.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,6 +8,7 @@ A Model Context Protocol (MCP) server for browser automation using Playwright wi
8
8
  - **Stealth Mode**: Optional anti-detection measures using `playwright-extra` and `puppeteer-extra-plugin-stealth`
9
9
  - **Persistent Sessions**: Browser session persists across tool calls for multi-step automation
10
10
  - **Screenshot Support**: Capture page screenshots for visual verification
11
+ - **Video Recording**: Record browser interactions as WebM videos with explicit start/stop controls
11
12
  - **Code Execution**: Run arbitrary Playwright code with access to the `page` object
12
13
  - **Full Permissions**: All browser permissions (notifications, geolocation, camera, etc.) granted by default for testing web apps
13
14
 
@@ -92,6 +93,7 @@ Add to your Claude Desktop config file:
92
93
  | `TIMEOUT` | Default timeout for Playwright actions (click, fill, etc.) in milliseconds | `30000` |
93
94
  | `NAVIGATION_TIMEOUT` | Default timeout for page navigation (goto, reload, etc.) in milliseconds | `60000` |
94
95
  | `SCREENSHOT_STORAGE_PATH` | Directory for storing screenshots | `/tmp/playwright-screenshots` |
96
+ | `VIDEO_STORAGE_PATH` | Directory for storing video recordings | `/tmp/playwright-videos` |
95
97
  | `PROXY_URL` | Proxy server URL (e.g., `http://proxy.example.com:8080`) | - |
96
98
  | `PROXY_USERNAME` | Proxy authentication username | - |
97
99
  | `PROXY_PASSWORD` | Proxy authentication password | - |
@@ -142,14 +144,30 @@ Get the current browser state including URL, title, and configuration.
142
144
 
143
145
  Close the browser session. A new browser will be launched on the next `browser_execute` call.
144
146
 
147
+ ### `browser_start_recording`
148
+
149
+ Start recording the browser session as a WebM video.
150
+
151
+ This tool recycles the browser context with video recording enabled. **Browser state (cookies, localStorage, sessionStorage) is lost** when recording starts. If you need to be logged in during the recording, authenticate again after starting.
152
+
153
+ If called while already recording, the current recording is automatically stopped and saved before starting a new one.
154
+
155
+ ### `browser_stop_recording`
156
+
157
+ Stop recording and save the video.
158
+
159
+ Returns a `resource_link` with the `file://` URI to the saved video (WebM format). **Browser state is lost** when recording stops — the browser navigates back to the previous URL automatically.
160
+
161
+ Returns an error if no recording is active.
162
+
145
163
  ## MCP Resources
146
164
 
147
- The server exposes saved screenshots as MCP resources. Clients can use:
165
+ The server exposes saved screenshots and video recordings as MCP resources. Clients can use:
148
166
 
149
- - `resources/list`: List all saved screenshots with their URIs and metadata
150
- - `resources/read`: Read a screenshot by its `file://` URI
167
+ - `resources/list`: List all saved screenshots and videos with their URIs and metadata
168
+ - `resources/read`: Read a screenshot or video by its `file://` URI
151
169
 
152
- This allows clients to access previously captured screenshots without needing to take new ones.
170
+ This allows clients to access previously captured screenshots and recordings without needing to take new ones.
153
171
 
154
172
  ## Usage Examples
155
173
 
@@ -3,12 +3,20 @@
3
3
  * Integration test entry point with mock client
4
4
  * Used for testing the MCP server without launching a real browser
5
5
  */
6
+ import { readFileSync } from 'fs';
7
+ import { dirname, join } from 'path';
8
+ import { fileURLToPath } from 'url';
6
9
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
10
  import { createMCPServer } from '../shared/index.js';
8
11
  import { logServerStart, logError } from '../shared/logging.js';
9
12
  import { createMockPlaywrightClient } from '../shared/playwright-client/playwright-client.integration-mock.js';
13
+ // Read version from package.json
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ const packageJsonPath = join(__dirname, '..', 'package.json');
16
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
17
+ const VERSION = packageJson.version;
10
18
  async function main() {
11
- const { server, registerHandlers } = createMCPServer();
19
+ const { server, registerHandlers } = createMCPServer({ version: VERSION });
12
20
  // Use mock client factory for integration tests
13
21
  await registerHandlers(server, createMockPlaywrightClient);
14
22
  const transport = new StdioServerTransport();
package/build/index.js CHANGED
@@ -1,8 +1,16 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from 'fs';
3
+ import { dirname, join } from 'path';
4
+ import { fileURLToPath } from 'url';
2
5
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
6
  import { createMCPServer } from '../shared/index.js';
4
7
  import { logServerStart, logError, logWarning, logInfo } from '../shared/logging.js';
5
8
  import { ALL_BROWSER_PERMISSIONS } from '../shared/types.js';
9
+ // Read version from package.json
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const packageJsonPath = join(__dirname, '..', 'package.json');
12
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
13
+ const VERSION = packageJson.version;
6
14
  // =============================================================================
7
15
  // PERMISSIONS CONFIGURATION
8
16
  // =============================================================================
@@ -248,6 +256,7 @@ async function main() {
248
256
  const ignoreHttpsErrors = process.env.IGNORE_HTTPS_ERRORS !== 'false';
249
257
  // Step 6: Create server using factory, passing proxy config, permissions, and HTTPS error handling
250
258
  const { server, registerHandlers, cleanup } = createMCPServer({
259
+ version: VERSION,
251
260
  proxy: proxyConfig,
252
261
  permissions: browserPermissions,
253
262
  ignoreHttpsErrors,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playwright-stealth-mcp-server",
3
- "version": "0.0.8",
3
+ "version": "0.1.0",
4
4
  "description": "Local implementation of Playwright Stealth MCP server",
5
5
  "mcpName": "com.pulsemcp.servers/playwright-stealth",
6
6
  "main": "build/index.js",
package/shared/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { createMCPServer, type IPlaywrightClient, type ClientFactory, type CreateMCPServerOptions, } from './server.js';
1
+ export { createMCPServer, type IPlaywrightClient, type ClientFactory, type CreateMCPServerOptions, type StopRecordingResult, } from './server.js';
2
2
  export { createRegisterTools } from './tools.js';
3
3
  export { registerResources } from './resources.js';
4
4
  export { logServerStart, logError, logWarning, logDebug, logInfo } from './logging.js';
@@ -2,11 +2,12 @@
2
2
  * Mock Playwright client for integration tests
3
3
  * Simulates browser behavior without launching a real browser
4
4
  */
5
- import type { IPlaywrightClient, ScreenshotResult } from '../server.js';
5
+ import type { IPlaywrightClient, ScreenshotResult, StopRecordingResult } from '../server.js';
6
6
  import type { ExecuteResult, BrowserState, PlaywrightConfig } from '../types.js';
7
7
  export declare class MockPlaywrightClient implements IPlaywrightClient {
8
8
  private state;
9
9
  private config;
10
+ private _recording;
10
11
  constructor(config: PlaywrightConfig);
11
12
  execute(code: string, options?: {
12
13
  timeout?: number;
@@ -15,6 +16,11 @@ export declare class MockPlaywrightClient implements IPlaywrightClient {
15
16
  getState(): Promise<BrowserState>;
16
17
  close(): Promise<void>;
17
18
  getConfig(): PlaywrightConfig;
19
+ isRecording(): boolean;
20
+ startRecording(): Promise<{
21
+ previousUrl?: string;
22
+ }>;
23
+ stopRecording(): Promise<StopRecordingResult>;
18
24
  }
19
25
  export declare function createMockPlaywrightClient(): IPlaywrightClient;
20
26
  //# sourceMappingURL=playwright-client.integration-mock.d.ts.map
@@ -1,6 +1,7 @@
1
1
  export class MockPlaywrightClient {
2
2
  state = { isOpen: false };
3
3
  config;
4
+ _recording = false;
4
5
  constructor(config) {
5
6
  this.config = config;
6
7
  }
@@ -64,6 +65,25 @@ export class MockPlaywrightClient {
64
65
  getConfig() {
65
66
  return this.config;
66
67
  }
68
+ isRecording() {
69
+ return this._recording;
70
+ }
71
+ async startRecording() {
72
+ const previousUrl = this.state.currentUrl;
73
+ this._recording = true;
74
+ return { previousUrl };
75
+ }
76
+ async stopRecording() {
77
+ if (!this._recording) {
78
+ throw new Error('Not currently recording');
79
+ }
80
+ this._recording = false;
81
+ return {
82
+ videoPath: '/tmp/mock-video.webm',
83
+ pageUrl: this.state.currentUrl,
84
+ pageTitle: this.state.title,
85
+ };
86
+ }
67
87
  }
68
88
  export function createMockPlaywrightClient() {
69
89
  return new MockPlaywrightClient({
@@ -1,6 +1,6 @@
1
1
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  /**
3
- * Register screenshot resources handlers to an MCP server
3
+ * Register screenshot and video resources handlers to an MCP server
4
4
  */
5
5
  export declare function registerResources(server: Server): void;
6
6
  //# sourceMappingURL=resources.d.ts.map
@@ -1,40 +1,65 @@
1
1
  import { ListResourcesRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
2
- import { ScreenshotStorageFactory } from './storage/index.js';
2
+ import { ScreenshotStorageFactory, VideoStorageFactory } from './storage/index.js';
3
3
  /**
4
- * Register screenshot resources handlers to an MCP server
4
+ * Register screenshot and video resources handlers to an MCP server
5
5
  */
6
6
  export function registerResources(server) {
7
7
  // Register resource list handler
8
8
  server.setRequestHandler(ListResourcesRequestSchema, async () => {
9
- const storage = await ScreenshotStorageFactory.create();
10
- const resources = await storage.list();
11
- return {
12
- resources: resources.map((resource) => ({
9
+ const screenshotStorage = await ScreenshotStorageFactory.create();
10
+ const screenshotResources = await screenshotStorage.list();
11
+ const videoStorage = await VideoStorageFactory.create();
12
+ const videoResources = await videoStorage.list();
13
+ const allResources = [
14
+ ...screenshotResources.map((resource) => ({
13
15
  uri: resource.uri,
14
16
  name: resource.name,
15
17
  description: resource.description,
16
18
  mimeType: resource.mimeType,
17
19
  })),
18
- };
20
+ ...videoResources.map((resource) => ({
21
+ uri: resource.uri,
22
+ name: resource.name,
23
+ description: resource.description,
24
+ mimeType: resource.mimeType,
25
+ })),
26
+ ];
27
+ return { resources: allResources };
19
28
  });
20
29
  // Register resource read handler
21
30
  server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
22
31
  const { uri } = request.params;
23
- const storage = await ScreenshotStorageFactory.create();
24
- try {
25
- const content = await storage.read(uri);
26
- return {
27
- contents: [
28
- {
29
- uri: content.uri,
30
- mimeType: content.mimeType,
31
- blob: content.blob,
32
- },
33
- ],
34
- };
32
+ // Route to the correct storage based on file extension
33
+ if (uri.endsWith('.webm')) {
34
+ const videoStorage = await VideoStorageFactory.create();
35
+ if (await videoStorage.exists(uri)) {
36
+ const content = await videoStorage.read(uri);
37
+ return {
38
+ contents: [
39
+ {
40
+ uri: content.uri,
41
+ mimeType: content.mimeType,
42
+ blob: content.blob,
43
+ },
44
+ ],
45
+ };
46
+ }
35
47
  }
36
- catch {
37
- throw new Error(`Resource not found: ${uri}`);
48
+ else {
49
+ const screenshotStorage = await ScreenshotStorageFactory.create();
50
+ if (await screenshotStorage.exists(uri)) {
51
+ const content = await screenshotStorage.read(uri);
52
+ return {
53
+ contents: [
54
+ {
55
+ uri: content.uri,
56
+ mimeType: content.mimeType,
57
+ blob: content.blob,
58
+ },
59
+ ],
60
+ };
61
+ }
38
62
  }
63
+ throw new Error(`Resource not found: ${uri}`);
39
64
  });
40
65
  }
@@ -16,6 +16,17 @@ export interface ScreenshotResult {
16
16
  /** Warning message if the screenshot was limited */
17
17
  warning?: string;
18
18
  }
19
+ /**
20
+ * Result of stopping a video recording
21
+ */
22
+ export interface StopRecordingResult {
23
+ /** Path to the saved video file on disk (Playwright temp location) */
24
+ videoPath: string;
25
+ /** URL the browser was on when recording stopped */
26
+ pageUrl?: string;
27
+ /** Page title when recording stopped */
28
+ pageTitle?: string;
29
+ }
19
30
  /**
20
31
  * Playwright client interface
21
32
  * Defines all methods for browser automation
@@ -47,6 +58,26 @@ export interface IPlaywrightClient {
47
58
  * Get the configuration
48
59
  */
49
60
  getConfig(): PlaywrightConfig;
61
+ /**
62
+ * Whether the browser is currently recording video
63
+ */
64
+ isRecording(): boolean;
65
+ /**
66
+ * Start video recording by recycling the browser context.
67
+ * Closes the current context and creates a new one with recordVideo enabled.
68
+ * Navigates back to the previous URL to maintain continuity.
69
+ * Note: Cookies, localStorage, and sessionStorage are lost when the context is recycled.
70
+ */
71
+ startRecording(videoDir: string): Promise<{
72
+ previousUrl?: string;
73
+ }>;
74
+ /**
75
+ * Stop video recording by recycling the browser context.
76
+ * Gets the video path before closing the recording context, then creates
77
+ * a new context without recording and navigates back to the previous URL.
78
+ * Note: Cookies, localStorage, and sessionStorage are lost when the context is recycled.
79
+ */
80
+ stopRecording(): Promise<StopRecordingResult>;
50
81
  }
51
82
  /**
52
83
  * Playwright client implementation with optional stealth mode
@@ -57,8 +88,11 @@ export declare class PlaywrightClient implements IPlaywrightClient {
57
88
  private page;
58
89
  private consoleMessages;
59
90
  private config;
91
+ private _recording;
60
92
  constructor(config: PlaywrightConfig);
61
93
  private ensureBrowser;
94
+ private launchBrowserIfNeeded;
95
+ private createContext;
62
96
  execute(code: string, options?: {
63
97
  timeout?: number;
64
98
  }): Promise<ExecuteResult>;
@@ -68,24 +102,31 @@ export declare class PlaywrightClient implements IPlaywrightClient {
68
102
  getState(): Promise<BrowserState>;
69
103
  close(): Promise<void>;
70
104
  getConfig(): PlaywrightConfig;
105
+ isRecording(): boolean;
106
+ startRecording(videoDir: string): Promise<{
107
+ previousUrl?: string;
108
+ }>;
109
+ stopRecording(): Promise<StopRecordingResult>;
71
110
  }
72
111
  export type ClientFactory = () => IPlaywrightClient;
73
112
  /**
74
113
  * Options for creating the MCP server
75
114
  */
76
115
  export interface CreateMCPServerOptions {
116
+ /** Server version (read from package.json) */
117
+ version: string;
77
118
  /** Proxy configuration for browser connections */
78
119
  proxy?: ProxyConfig;
79
120
  /** Browser permissions to grant. If undefined, all permissions are granted. */
80
121
  permissions?: BrowserPermission[];
81
122
  /**
82
123
  * Whether to ignore HTTPS errors (certificate validation failures).
83
- * Useful in Docker environments where SSL certificates may not match hostnames.
84
- * When undefined, HTTPS errors are only ignored if proxy is configured.
124
+ * Defaults to true for convenience (Docker, staging environments, self-signed certs).
125
+ * Set to false for strict certificate validation in production environments.
85
126
  */
86
127
  ignoreHttpsErrors?: boolean;
87
128
  }
88
- export declare function createMCPServer(options?: CreateMCPServerOptions): {
129
+ export declare function createMCPServer(options: CreateMCPServerOptions): {
89
130
  server: Server<{
90
131
  method: string;
91
132
  params?: {
package/shared/server.js CHANGED
@@ -17,13 +17,34 @@ export class PlaywrightClient {
17
17
  page = null;
18
18
  consoleMessages = [];
19
19
  config;
20
+ _recording = false;
20
21
  constructor(config) {
21
22
  this.config = config;
22
23
  }
23
- async ensureBrowser() {
24
+ async ensureBrowser(options) {
24
25
  if (this.page) {
25
26
  return this.page;
26
27
  }
28
+ await this.launchBrowserIfNeeded();
29
+ await this.createContext(options);
30
+ this.page = await this.context.newPage();
31
+ // Apply timeout configuration to Playwright
32
+ this.page.setDefaultTimeout(this.config.timeout);
33
+ this.page.setDefaultNavigationTimeout(this.config.navigationTimeout);
34
+ // Capture console messages
35
+ this.page.on('console', (msg) => {
36
+ this.consoleMessages.push(`[${msg.type()}] ${msg.text()}`);
37
+ // Keep only last 100 messages
38
+ if (this.consoleMessages.length > 100) {
39
+ this.consoleMessages.shift();
40
+ }
41
+ });
42
+ return this.page;
43
+ }
44
+ async launchBrowserIfNeeded() {
45
+ if (this.browser) {
46
+ return;
47
+ }
27
48
  // Build proxy options for Playwright if configured
28
49
  const proxyOptions = this.config.proxy
29
50
  ? {
@@ -67,15 +88,21 @@ export class PlaywrightClient {
67
88
  proxy: proxyOptions,
68
89
  });
69
90
  }
91
+ }
92
+ async createContext(options) {
93
+ if (!this.browser) {
94
+ throw new Error('Browser not launched');
95
+ }
70
96
  this.context = await this.browser.newContext({
71
97
  viewport: { width: 1920, height: 1080 },
72
98
  // In stealth mode, let the plugin's user-agent-override handle the user agent
73
99
  // In non-stealth mode, use the provided user agent if any
74
100
  userAgent: this.config.stealthMode ? undefined : this.config.stealthUserAgent,
75
- // Ignore HTTPS errors when:
76
- // 1. Explicitly enabled via IGNORE_HTTPS_ERRORS env var, OR
77
- // 2. Using proxy (required for residential proxies that perform HTTPS inspection)
78
- ignoreHTTPSErrors: this.config.ignoreHttpsErrors ?? !!this.config.proxy,
101
+ // Ignore HTTPS errors by default (convenient for Docker, staging environments, self-signed certs)
102
+ // Set IGNORE_HTTPS_ERRORS=false for strict certificate validation in production
103
+ ignoreHTTPSErrors: this.config.ignoreHttpsErrors ?? true,
104
+ // Video recording options (only set when recording)
105
+ ...(options?.recordVideo ? { recordVideo: options.recordVideo } : {}),
79
106
  });
80
107
  // Grant browser permissions (defaults to all permissions if not specified)
81
108
  const permissionsToGrant = this.config.permissions ?? [...ALL_BROWSER_PERMISSIONS];
@@ -90,19 +117,6 @@ export class PlaywrightClient {
90
117
  logWarning('permissions', `Failed to grant some permissions: ${message}`);
91
118
  }
92
119
  }
93
- this.page = await this.context.newPage();
94
- // Apply timeout configuration to Playwright
95
- this.page.setDefaultTimeout(this.config.timeout);
96
- this.page.setDefaultNavigationTimeout(this.config.navigationTimeout);
97
- // Capture console messages
98
- this.page.on('console', (msg) => {
99
- this.consoleMessages.push(`[${msg.type()}] ${msg.text()}`);
100
- // Keep only last 100 messages
101
- if (this.consoleMessages.length > 100) {
102
- this.consoleMessages.shift();
103
- }
104
- });
105
- return this.page;
106
120
  }
107
121
  async execute(code, options) {
108
122
  const timeout = options?.timeout ?? this.config.timeout;
@@ -202,17 +216,110 @@ export class PlaywrightClient {
202
216
  this.context = null;
203
217
  this.page = null;
204
218
  this.consoleMessages = [];
219
+ this._recording = false;
205
220
  }
206
221
  }
207
222
  getConfig() {
208
223
  return this.config;
209
224
  }
225
+ isRecording() {
226
+ return this._recording;
227
+ }
228
+ async startRecording(videoDir) {
229
+ await this.launchBrowserIfNeeded();
230
+ // Capture the current URL before recycling the context
231
+ let previousUrl;
232
+ if (this.page) {
233
+ try {
234
+ previousUrl = this.page.url();
235
+ if (previousUrl === 'about:blank') {
236
+ previousUrl = undefined;
237
+ }
238
+ }
239
+ catch {
240
+ // Page may be closed already
241
+ }
242
+ }
243
+ // If currently recording, stop the existing recording first (close context to finalize video)
244
+ if (this.context) {
245
+ // Close the page and context to finalize any existing recording
246
+ this.page = null;
247
+ await this.context.close();
248
+ this.context = null;
249
+ }
250
+ // Create a new context with video recording enabled
251
+ await this.createContext({
252
+ recordVideo: { dir: videoDir, size: { width: 1920, height: 1080 } },
253
+ });
254
+ this.page = await this.context.newPage();
255
+ this.page.setDefaultTimeout(this.config.timeout);
256
+ this.page.setDefaultNavigationTimeout(this.config.navigationTimeout);
257
+ // Capture console messages on the new page
258
+ this.page.on('console', (msg) => {
259
+ this.consoleMessages.push(`[${msg.type()}] ${msg.text()}`);
260
+ if (this.consoleMessages.length > 100) {
261
+ this.consoleMessages.shift();
262
+ }
263
+ });
264
+ // Navigate back to the previous URL if there was one
265
+ if (previousUrl) {
266
+ await this.page.goto(previousUrl);
267
+ }
268
+ this._recording = true;
269
+ return { previousUrl };
270
+ }
271
+ async stopRecording() {
272
+ if (!this._recording || !this.page) {
273
+ throw new Error('Not currently recording');
274
+ }
275
+ // Capture current page state before closing the context
276
+ let pageUrl;
277
+ let pageTitle;
278
+ try {
279
+ pageUrl = this.page.url();
280
+ if (pageUrl === 'about:blank') {
281
+ pageUrl = undefined;
282
+ }
283
+ pageTitle = await this.page.title();
284
+ }
285
+ catch {
286
+ // Page may have issues
287
+ }
288
+ // Get the video path BEFORE closing the context
289
+ const video = this.page.video();
290
+ if (!video) {
291
+ throw new Error('No video found on the current page');
292
+ }
293
+ const videoPath = await video.path();
294
+ // Close page and context to finalize the video file
295
+ this.page = null;
296
+ await this.context.close();
297
+ this.context = null;
298
+ this._recording = false;
299
+ // Create a new context WITHOUT recording
300
+ await this.createContext();
301
+ this.page = await this.context.newPage();
302
+ this.page.setDefaultTimeout(this.config.timeout);
303
+ this.page.setDefaultNavigationTimeout(this.config.navigationTimeout);
304
+ // Capture console messages on the new page
305
+ this.page.on('console', (msg) => {
306
+ this.consoleMessages.push(`[${msg.type()}] ${msg.text()}`);
307
+ if (this.consoleMessages.length > 100) {
308
+ this.consoleMessages.shift();
309
+ }
310
+ });
311
+ // Navigate back to the previous URL
312
+ if (pageUrl) {
313
+ await this.page.goto(pageUrl);
314
+ }
315
+ return { videoPath, pageUrl, pageTitle };
316
+ }
210
317
  }
211
318
  export function createMCPServer(options) {
212
319
  const stealthMode = process.env.STEALTH_MODE === 'true';
213
320
  const server = new Server({
214
321
  name: 'playwright-stealth-mcp-server',
215
- version: '0.0.8',
322
+ version: options.version,
216
323
  }, {
217
324
  capabilities: {
218
325
  tools: {},
@@ -1,4 +1,7 @@
1
1
  export * from './types.js';
2
2
  export * from './factory.js';
3
3
  export * from './filesystem.js';
4
+ export * from './video-types.js';
5
+ export * from './video-factory.js';
6
+ export * from './video-filesystem.js';
4
7
  //# sourceMappingURL=index.d.ts.map
@@ -1,3 +1,6 @@
1
1
  export * from './types.js';
2
2
  export * from './factory.js';
3
3
  export * from './filesystem.js';
4
+ export * from './video-types.js';
5
+ export * from './video-factory.js';
6
+ export * from './video-filesystem.js';
@@ -0,0 +1,7 @@
1
+ import { VideoStorage } from './video-types.js';
2
+ export declare class VideoStorageFactory {
3
+ private static instance;
4
+ static create(): Promise<VideoStorage>;
5
+ static reset(): void;
6
+ }
7
+ //# sourceMappingURL=video-factory.d.ts.map
@@ -0,0 +1,17 @@
1
+ import { FileSystemVideoStorage } from './video-filesystem.js';
2
+ export class VideoStorageFactory {
3
+ static instance = null;
4
+ static async create() {
5
+ if (this.instance) {
6
+ return this.instance;
7
+ }
8
+ const rootDir = process.env.VIDEO_STORAGE_PATH;
9
+ const fsStorage = new FileSystemVideoStorage(rootDir);
10
+ await fsStorage.init();
11
+ this.instance = fsStorage;
12
+ return this.instance;
13
+ }
14
+ static reset() {
15
+ this.instance = null;
16
+ }
17
+ }
@@ -0,0 +1,16 @@
1
+ import { VideoStorage, VideoResourceData, VideoResourceContent, VideoMetadata } from './video-types.js';
2
+ export declare class FileSystemVideoStorage implements VideoStorage {
3
+ private rootDir;
4
+ private initialized;
5
+ constructor(rootDir?: string);
6
+ init(): Promise<void>;
7
+ list(): Promise<VideoResourceData[]>;
8
+ read(uri: string): Promise<VideoResourceContent>;
9
+ write(sourceFilePath: string, metadata: Omit<VideoMetadata, 'timestamp'>): Promise<string>;
10
+ exists(uri: string): Promise<boolean>;
11
+ delete(uri: string): Promise<void>;
12
+ private fileExists;
13
+ private uriToFilePath;
14
+ private generateFileName;
15
+ }
16
+ //# sourceMappingURL=video-filesystem.d.ts.map
@@ -0,0 +1,125 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ export class FileSystemVideoStorage {
5
+ rootDir;
6
+ initialized = false;
7
+ constructor(rootDir) {
8
+ this.rootDir = rootDir || path.join(os.tmpdir(), 'playwright-videos');
9
+ }
10
+ async init() {
11
+ if (this.initialized) {
12
+ return;
13
+ }
14
+ await fs.mkdir(this.rootDir, { recursive: true });
15
+ this.initialized = true;
16
+ }
17
+ async list() {
18
+ await this.init();
19
+ const resources = [];
20
+ try {
21
+ const files = await fs.readdir(this.rootDir);
22
+ for (const file of files) {
23
+ if (file.endsWith('.webm')) {
24
+ const metadataFile = file.replace('.webm', '.json');
25
+ const filePath = path.join(this.rootDir, file);
26
+ const metadataPath = path.join(this.rootDir, metadataFile);
27
+ try {
28
+ const metadataContent = await fs.readFile(metadataPath, 'utf-8');
29
+ const metadata = JSON.parse(metadataContent);
30
+ const uri = `file://${filePath}`;
31
+ resources.push({
32
+ uri,
33
+ name: file,
34
+ description: metadata.pageUrl
35
+ ? `Video recording of ${metadata.pageUrl}`
36
+ : `Video recording from ${metadata.timestamp}`,
37
+ mimeType: 'video/webm',
38
+ metadata,
39
+ });
40
+ }
41
+ catch {
42
+ // Ignore files without metadata
43
+ }
44
+ }
45
+ }
46
+ }
47
+ catch {
48
+ // Directory might not exist yet
49
+ }
50
+ // Sort by timestamp descending (most recent first)
51
+ resources.sort((a, b) => {
52
+ const timeA = new Date(a.metadata.timestamp).getTime();
53
+ const timeB = new Date(b.metadata.timestamp).getTime();
54
+ return timeB - timeA;
55
+ });
56
+ return resources;
57
+ }
58
+ async read(uri) {
59
+ const filePath = this.uriToFilePath(uri);
60
+ if (!(await this.fileExists(filePath))) {
61
+ throw new Error(`Resource not found: ${uri}`);
62
+ }
63
+ const buffer = await fs.readFile(filePath);
64
+ const blob = buffer.toString('base64');
65
+ return {
66
+ uri,
67
+ mimeType: 'video/webm',
68
+ blob,
69
+ };
70
+ }
71
+ async write(sourceFilePath, metadata) {
72
+ await this.init();
73
+ const timestamp = new Date().toISOString();
74
+ const fileName = this.generateFileName(timestamp);
75
+ const destPath = path.join(this.rootDir, fileName);
76
+ const metadataPath = path.join(this.rootDir, fileName.replace('.webm', '.json'));
77
+ const fullMetadata = {
78
+ ...metadata,
79
+ timestamp,
80
+ };
81
+ // Copy the video file from Playwright's temp location to our storage
82
+ await fs.copyFile(sourceFilePath, destPath);
83
+ // Write the metadata
84
+ await fs.writeFile(metadataPath, JSON.stringify(fullMetadata, null, 2), 'utf-8');
85
+ return `file://${destPath}`;
86
+ }
87
+ async exists(uri) {
88
+ const filePath = this.uriToFilePath(uri);
89
+ return this.fileExists(filePath);
90
+ }
91
+ async delete(uri) {
92
+ const filePath = this.uriToFilePath(uri);
93
+ if (!(await this.fileExists(filePath))) {
94
+ throw new Error(`Resource not found: ${uri}`);
95
+ }
96
+ await fs.unlink(filePath);
97
+ // Also delete metadata file if it exists
98
+ const metadataPath = filePath.replace('.webm', '.json');
99
+ try {
100
+ await fs.unlink(metadataPath);
101
+ }
102
+ catch {
103
+ // Ignore if metadata file doesn't exist
104
+ }
105
+ }
106
+ async fileExists(filePath) {
107
+ try {
108
+ await fs.access(filePath);
109
+ return true;
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ }
115
+ uriToFilePath(uri) {
116
+ if (uri.startsWith('file://')) {
117
+ return uri.substring(7);
118
+ }
119
+ throw new Error(`Invalid file URI: ${uri}`);
120
+ }
121
+ generateFileName(timestamp) {
122
+ const timestampPart = timestamp.replace(/[^0-9T-]/g, '').replace('T', '_');
123
+ return `video_${timestampPart}.webm`;
124
+ }
125
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Types for video resource storage
3
+ */
4
+ export interface VideoMetadata {
5
+ timestamp: string;
6
+ pageUrl?: string;
7
+ pageTitle?: string;
8
+ durationMs?: number;
9
+ }
10
+ export interface VideoResourceData {
11
+ uri: string;
12
+ name: string;
13
+ description?: string;
14
+ mimeType: string;
15
+ metadata: VideoMetadata;
16
+ }
17
+ export interface VideoResourceContent {
18
+ uri: string;
19
+ mimeType: string;
20
+ blob: string;
21
+ }
22
+ export interface VideoStorage {
23
+ list(): Promise<VideoResourceData[]>;
24
+ read(uri: string): Promise<VideoResourceContent>;
25
+ write(filePath: string, metadata: Omit<VideoMetadata, 'timestamp'>): Promise<string>;
26
+ exists(uri: string): Promise<boolean>;
27
+ delete(uri: string): Promise<void>;
28
+ }
29
+ //# sourceMappingURL=video-types.d.ts.map
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Types for video resource storage
3
+ */
4
+ export {};
package/shared/tools.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
2
2
  import { z } from 'zod';
3
- import { ScreenshotStorageFactory } from './storage/index.js';
3
+ import { ScreenshotStorageFactory, VideoStorageFactory } from './storage/index.js';
4
4
  // =============================================================================
5
5
  // TOOL SCHEMAS
6
6
  // =============================================================================
@@ -96,6 +96,40 @@ Returns information about the current browser session including the URL, page ti
96
96
  const CLOSE_DESCRIPTION = `Close the browser session.
97
97
 
98
98
  Shuts down the browser and clears all state. A new browser will be launched on the next execute call.`;
99
+ const START_RECORDING_DESCRIPTION = `Start recording the browser session as a video.
100
+
101
+ This tool begins capturing all browser interactions as a WebM video file. It works by recycling the browser context with video recording enabled.
102
+
103
+ **Important: Browser state is lost when recording starts.** Starting a recording closes the current browser context and creates a new one. This means:
104
+ - Cookies are lost
105
+ - localStorage and sessionStorage are cleared
106
+ - Any authenticated sessions will be invalidated
107
+
108
+ If you need to be logged in during the recording, navigate to the login page and authenticate again after starting the recording.
109
+
110
+ **Behavior when already recording:** If recording is already active, the current recording will be stopped (saving the video) and a new recording will begin.
111
+
112
+ **The video is NOT available until you call \`browser_stop_recording\`.** Playwright only finalizes the video file when the recording context is closed.
113
+
114
+ **Returns:**
115
+ - Confirmation that recording has started
116
+ - The URL the browser was previously on (if any) — the browser navigates back to it automatically`;
117
+ const STOP_RECORDING_DESCRIPTION = `Stop recording the browser session and save the video.
118
+
119
+ This tool stops the active video recording, saves the video file, and returns a resource URI for the recorded video.
120
+
121
+ **Important: Browser state is lost when recording stops.** Stopping a recording closes the recording context and creates a new one. This means:
122
+ - Cookies are lost
123
+ - localStorage and sessionStorage are cleared
124
+ - Any authenticated sessions will be invalidated
125
+
126
+ The browser automatically navigates back to the URL it was on before the recording stopped.
127
+
128
+ **Error:** Returns an error if no recording is currently active. Use \`browser_start_recording\` first.
129
+
130
+ **Returns:**
131
+ - A resource_link with the \`file://\` URI to the saved video file (WebM format)
132
+ - The video can be accessed later via MCP resources`;
99
133
  export function createRegisterTools(clientFactory) {
100
134
  // Create a single client instance that persists across calls
101
135
  let client = null;
@@ -274,7 +308,7 @@ export function createRegisterTools(clientFactory) {
274
308
  stealthMode: config.stealthMode,
275
309
  headless: config.headless,
276
310
  proxyEnabled: !!config.proxy,
277
- ignoreHttpsErrors: config.ignoreHttpsErrors ?? !!config.proxy,
311
+ ignoreHttpsErrors: config.ignoreHttpsErrors ?? true,
278
312
  }, null, 2),
279
313
  },
280
314
  ],
@@ -326,6 +360,123 @@ export function createRegisterTools(clientFactory) {
326
360
  }
327
361
  },
328
362
  },
363
+ {
364
+ name: 'browser_start_recording',
365
+ description: START_RECORDING_DESCRIPTION,
366
+ inputSchema: {
367
+ type: 'object',
368
+ properties: {},
369
+ },
370
+ handler: async () => {
371
+ try {
372
+ const currentClient = getClient();
373
+ // If already recording, stop the current recording first (save the video)
374
+ let previousVideoUri;
375
+ if (currentClient.isRecording()) {
376
+ try {
377
+ const stopResult = await currentClient.stopRecording();
378
+ // Save the previous recording
379
+ const videoStorage = await VideoStorageFactory.create();
380
+ previousVideoUri = await videoStorage.write(stopResult.videoPath, {
381
+ pageUrl: stopResult.pageUrl,
382
+ pageTitle: stopResult.pageTitle,
383
+ });
384
+ }
385
+ catch {
386
+ // Best effort to save previous recording
387
+ }
388
+ }
389
+ // Get the video storage directory for Playwright to write to
390
+ const videoStoragePath = process.env.VIDEO_STORAGE_PATH || '/tmp/playwright-videos';
391
+ const result = await currentClient.startRecording(videoStoragePath);
392
+ const parts = ['Recording started.'];
393
+ if (result.previousUrl) {
394
+ parts.push(`Browser navigated back to: ${result.previousUrl}`);
395
+ }
396
+ parts.push('Note: Cookies and session storage have been cleared. Log in again if needed.');
397
+ if (previousVideoUri) {
398
+ parts.push(`Previous recording saved to: ${previousVideoUri}`);
399
+ }
400
+ return {
401
+ content: [
402
+ {
403
+ type: 'text',
404
+ text: parts.join('\n'),
405
+ },
406
+ ],
407
+ };
408
+ }
409
+ catch (error) {
410
+ return {
411
+ content: [
412
+ {
413
+ type: 'text',
414
+ text: `Error starting recording: ${error instanceof Error ? error.message : String(error)}`,
415
+ },
416
+ ],
417
+ isError: true,
418
+ };
419
+ }
420
+ },
421
+ },
422
+ {
423
+ name: 'browser_stop_recording',
424
+ description: STOP_RECORDING_DESCRIPTION,
425
+ inputSchema: {
426
+ type: 'object',
427
+ properties: {},
428
+ },
429
+ handler: async () => {
430
+ try {
431
+ const currentClient = getClient();
432
+ if (!currentClient.isRecording()) {
433
+ return {
434
+ content: [
435
+ {
436
+ type: 'text',
437
+ text: 'Error: Not currently recording. Use browser_start_recording to begin a recording first.',
438
+ },
439
+ ],
440
+ isError: true,
441
+ };
442
+ }
443
+ const result = await currentClient.stopRecording();
444
+ // Save the video to storage
445
+ const videoStorage = await VideoStorageFactory.create();
446
+ const uri = await videoStorage.write(result.videoPath, {
447
+ pageUrl: result.pageUrl,
448
+ pageTitle: result.pageTitle,
449
+ });
450
+ const fileName = uri.split('/').pop() || 'recording.webm';
451
+ const content = [];
452
+ content.push({
453
+ type: 'text',
454
+ text: `Recording stopped and saved.\nNote: Cookies and session storage have been cleared. Log in again if needed.`,
455
+ });
456
+ content.push({
457
+ type: 'resource_link',
458
+ uri,
459
+ name: fileName,
460
+ description: result.pageUrl
461
+ ? `Video recording of ${result.pageUrl}`
462
+ : 'Video recording',
463
+ mimeType: 'video/webm',
464
+ });
465
+ return { content };
466
+ }
467
+ catch (error) {
468
+ return {
469
+ content: [
470
+ {
471
+ type: 'text',
472
+ text: `Error stopping recording: ${error instanceof Error ? error.message : String(error)}`,
473
+ },
474
+ ],
475
+ isError: true,
476
+ };
477
+ }
478
+ },
479
+ },
329
480
  ];
330
481
  return (server) => {
331
482
  // List available tools
package/shared/types.d.ts CHANGED
@@ -41,9 +41,8 @@ export interface PlaywrightConfig {
41
41
  permissions?: BrowserPermission[];
42
42
  /**
43
43
  * Whether to ignore HTTPS errors (certificate validation failures).
44
- * Useful in Docker environments where SSL certificates may not match hostnames.
45
- * Automatically enabled when proxy is configured.
46
- * Use IGNORE_HTTPS_ERRORS env var to enable explicitly.
44
+ * Defaults to true for convenience (Docker, staging environments, self-signed certs).
45
+ * Set to false for strict certificate validation in production environments.
47
46
  */
48
47
  ignoreHttpsErrors?: boolean;
49
48
  }