playwright-stealth-mcp-server 0.0.9 → 0.2.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 | - |
@@ -120,15 +122,19 @@ return title;
120
122
 
121
123
  ### `browser_screenshot`
122
124
 
123
- Take a screenshot of the current page. Screenshots are saved to filesystem storage and can be accessed later via MCP resources.
125
+ Take a screenshot of the current page, a specific element, or a page region. Screenshots are saved to filesystem storage and can be accessed later via MCP resources.
124
126
 
125
127
  **Parameters:**
126
128
 
127
129
  - `fullPage` (optional): Capture full scrollable page. Default: `false`
130
+ - `selector` (optional): CSS selector of a specific element to screenshot (e.g., `#main-content`, `.hero-banner`, `table.results`)
131
+ - `clip` (optional): Region of the page to screenshot as `{x, y, width, height}` in pixels
128
132
  - `resultHandling` (optional): How to handle the result:
129
133
  - `saveAndReturn` (default): Saves to storage AND returns inline base64 image
130
134
  - `saveOnly`: Saves to storage and returns only the resource URI (more efficient for large screenshots)
131
135
 
136
+ **Note:** `fullPage`, `selector`, and `clip` are mutually exclusive. Only one can be specified per call.
137
+
132
138
  **Returns:**
133
139
 
134
140
  - With `saveAndReturn`: Inline base64 PNG image plus a `resource_link` with the file URI
@@ -142,14 +148,30 @@ Get the current browser state including URL, title, and configuration.
142
148
 
143
149
  Close the browser session. A new browser will be launched on the next `browser_execute` call.
144
150
 
151
+ ### `browser_start_recording`
152
+
153
+ Start recording the browser session as a WebM video.
154
+
155
+ This tool recycles the browser context with video recording enabled. Session state (cookies, localStorage, sessionStorage) is automatically preserved across the context recycling.
156
+
157
+ If called while already recording, the current recording is automatically stopped and saved before starting a new one.
158
+
159
+ ### `browser_stop_recording`
160
+
161
+ Stop recording and save the video.
162
+
163
+ Returns a `resource_link` with the `file://` URI to the saved video (WebM format). Session state is preserved — the browser navigates back to the previous URL automatically.
164
+
165
+ Returns an error if no recording is active.
166
+
145
167
  ## MCP Resources
146
168
 
147
- The server exposes saved screenshots as MCP resources. Clients can use:
169
+ The server exposes saved screenshots and video recordings as MCP resources. Clients can use:
148
170
 
149
- - `resources/list`: List all saved screenshots with their URIs and metadata
150
- - `resources/read`: Read a screenshot by its `file://` URI
171
+ - `resources/list`: List all saved screenshots and videos with their URIs and metadata
172
+ - `resources/read`: Read a screenshot or video by its `file://` URI
151
173
 
152
- This allows clients to access previously captured screenshots without needing to take new ones.
174
+ This allows clients to access previously captured screenshots and recordings without needing to take new ones.
153
175
 
154
176
  ## Usage Examples
155
177
 
@@ -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.9",
3
+ "version": "0.2.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,19 +2,34 @@
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;
13
14
  }): Promise<ExecuteResult>;
14
- screenshot(): Promise<ScreenshotResult>;
15
+ screenshot(_options?: {
16
+ fullPage?: boolean;
17
+ selector?: string;
18
+ clip?: {
19
+ x: number;
20
+ y: number;
21
+ width: number;
22
+ height: number;
23
+ };
24
+ }): Promise<ScreenshotResult>;
15
25
  getState(): Promise<BrowserState>;
16
26
  close(): Promise<void>;
17
27
  getConfig(): PlaywrightConfig;
28
+ isRecording(): boolean;
29
+ startRecording(): Promise<{
30
+ previousUrl?: string;
31
+ }>;
32
+ stopRecording(): Promise<StopRecordingResult>;
18
33
  }
19
34
  export declare function createMockPlaywrightClient(): IPlaywrightClient;
20
35
  //# 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
  }
@@ -48,7 +49,7 @@ export class MockPlaywrightClient {
48
49
  consoleOutput: ['[log] Mock execution completed'],
49
50
  };
50
51
  }
51
- async screenshot() {
52
+ async screenshot(_options) {
52
53
  // Return a minimal valid PNG as base64 (1x1 transparent pixel)
53
54
  return {
54
55
  data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
@@ -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
@@ -28,12 +39,19 @@ export interface IPlaywrightClient {
28
39
  timeout?: number;
29
40
  }): Promise<ExecuteResult>;
30
41
  /**
31
- * Take a screenshot of the current page.
42
+ * Take a screenshot of the current page, a specific element, or a page region.
32
43
  * Screenshots are automatically limited to MAX_SCREENSHOT_DIMENSION pixels.
33
44
  * If fullPage is requested but would exceed the limit, the screenshot is clipped.
34
45
  */
35
46
  screenshot(options?: {
36
47
  fullPage?: boolean;
48
+ selector?: string;
49
+ clip?: {
50
+ x: number;
51
+ y: number;
52
+ width: number;
53
+ height: number;
54
+ };
37
55
  }): Promise<ScreenshotResult>;
38
56
  /**
39
57
  * Get the current browser state
@@ -47,6 +65,26 @@ export interface IPlaywrightClient {
47
65
  * Get the configuration
48
66
  */
49
67
  getConfig(): PlaywrightConfig;
68
+ /**
69
+ * Whether the browser is currently recording video
70
+ */
71
+ isRecording(): boolean;
72
+ /**
73
+ * Start video recording by recycling the browser context.
74
+ * Closes the current context and creates a new one with recordVideo enabled.
75
+ * Navigates back to the previous URL to maintain continuity.
76
+ * Session state (cookies, localStorage, sessionStorage) is preserved on a best-effort basis.
77
+ */
78
+ startRecording(videoDir: string): Promise<{
79
+ previousUrl?: string;
80
+ }>;
81
+ /**
82
+ * Stop video recording by recycling the browser context.
83
+ * Gets the video path before closing the recording context, then creates
84
+ * a new context without recording and navigates back to the previous URL.
85
+ * Session state (cookies, localStorage, sessionStorage) is preserved on a best-effort basis.
86
+ */
87
+ stopRecording(): Promise<StopRecordingResult>;
50
88
  }
51
89
  /**
52
90
  * Playwright client implementation with optional stealth mode
@@ -57,23 +95,52 @@ export declare class PlaywrightClient implements IPlaywrightClient {
57
95
  private page;
58
96
  private consoleMessages;
59
97
  private config;
98
+ private _recording;
60
99
  constructor(config: PlaywrightConfig);
61
100
  private ensureBrowser;
101
+ private launchBrowserIfNeeded;
102
+ private createContext;
62
103
  execute(code: string, options?: {
63
104
  timeout?: number;
64
105
  }): Promise<ExecuteResult>;
65
106
  screenshot(options?: {
66
107
  fullPage?: boolean;
108
+ selector?: string;
109
+ clip?: {
110
+ x: number;
111
+ y: number;
112
+ width: number;
113
+ height: number;
114
+ };
67
115
  }): Promise<ScreenshotResult>;
68
116
  getState(): Promise<BrowserState>;
69
117
  close(): Promise<void>;
70
118
  getConfig(): PlaywrightConfig;
119
+ isRecording(): boolean;
120
+ /**
121
+ * Capture session state (cookies, localStorage, sessionStorage) from the current context.
122
+ * Uses Playwright's context.storageState() for cookies + localStorage,
123
+ * and page.evaluate() for sessionStorage (not covered by storageState).
124
+ */
125
+ private captureSessionState;
126
+ /**
127
+ * Restore sessionStorage after navigation.
128
+ * Cookies and localStorage are handled by passing storageState to createContext().
129
+ */
130
+ private restoreSessionStorage;
131
+ private setupPageHandlers;
132
+ startRecording(videoDir: string): Promise<{
133
+ previousUrl?: string;
134
+ }>;
135
+ stopRecording(): Promise<StopRecordingResult>;
71
136
  }
72
137
  export type ClientFactory = () => IPlaywrightClient;
73
138
  /**
74
139
  * Options for creating the MCP server
75
140
  */
76
141
  export interface CreateMCPServerOptions {
142
+ /** Server version (read from package.json) */
143
+ version: string;
77
144
  /** Proxy configuration for browser connections */
78
145
  proxy?: ProxyConfig;
79
146
  /** Browser permissions to grant. If undefined, all permissions are granted. */
@@ -85,7 +152,7 @@ export interface CreateMCPServerOptions {
85
152
  */
86
153
  ignoreHttpsErrors?: boolean;
87
154
  }
88
- export declare function createMCPServer(options?: CreateMCPServerOptions): {
155
+ export declare function createMCPServer(options: CreateMCPServerOptions): {
89
156
  server: Server<{
90
157
  method: string;
91
158
  params?: {
package/shared/server.js CHANGED
@@ -17,13 +17,24 @@ 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
+ this.setupPageHandlers(this.page);
32
+ return this.page;
33
+ }
34
+ async launchBrowserIfNeeded() {
35
+ if (this.browser) {
36
+ return;
37
+ }
27
38
  // Build proxy options for Playwright if configured
28
39
  const proxyOptions = this.config.proxy
29
40
  ? {
@@ -67,6 +78,11 @@ export class PlaywrightClient {
67
78
  proxy: proxyOptions,
68
79
  });
69
80
  }
81
+ }
82
+ async createContext(options) {
83
+ if (!this.browser) {
84
+ throw new Error('Browser not launched');
85
+ }
70
86
  this.context = await this.browser.newContext({
71
87
  viewport: { width: 1920, height: 1080 },
72
88
  // In stealth mode, let the plugin's user-agent-override handle the user agent
@@ -75,6 +91,10 @@ export class PlaywrightClient {
75
91
  // Ignore HTTPS errors by default (convenient for Docker, staging environments, self-signed certs)
76
92
  // Set IGNORE_HTTPS_ERRORS=false for strict certificate validation in production
77
93
  ignoreHTTPSErrors: this.config.ignoreHttpsErrors ?? true,
94
+ // Video recording options (only set when recording)
95
+ ...(options?.recordVideo ? { recordVideo: options.recordVideo } : {}),
96
+ // Restore session state (cookies + localStorage) if provided
97
+ ...(options?.storageState ? { storageState: options.storageState } : {}),
78
98
  });
79
99
  // Grant browser permissions (defaults to all permissions if not specified)
80
100
  const permissionsToGrant = this.config.permissions ?? [...ALL_BROWSER_PERMISSIONS];
@@ -89,19 +109,6 @@ export class PlaywrightClient {
89
109
  logWarning('permissions', `Failed to grant some permissions: ${message}`);
90
110
  }
91
111
  }
92
- this.page = await this.context.newPage();
93
- // Apply timeout configuration to Playwright
94
- this.page.setDefaultTimeout(this.config.timeout);
95
- this.page.setDefaultNavigationTimeout(this.config.navigationTimeout);
96
- // Capture console messages
97
- this.page.on('console', (msg) => {
98
- this.consoleMessages.push(`[${msg.type()}] ${msg.text()}`);
99
- // Keep only last 100 messages
100
- if (this.consoleMessages.length > 100) {
101
- this.consoleMessages.shift();
102
- }
103
- });
104
- return this.page;
105
112
  }
106
113
  async execute(code, options) {
107
114
  const timeout = options?.timeout ?? this.config.timeout;
@@ -137,6 +144,22 @@ export class PlaywrightClient {
137
144
  async screenshot(options) {
138
145
  const page = await this.ensureBrowser();
139
146
  const fullPage = options?.fullPage ?? false;
147
+ const selector = options?.selector;
148
+ const clip = options?.clip;
149
+ // Element screenshot mode
150
+ if (selector) {
151
+ const locator = page.locator(selector);
152
+ const buffer = await locator.screenshot({ type: 'png' });
153
+ return { data: buffer.toString('base64'), wasClipped: false };
154
+ }
155
+ // Clip region screenshot mode
156
+ if (clip) {
157
+ if (clip.width <= 0 || clip.height <= 0) {
158
+ throw new Error('Clip width and height must be positive numbers');
159
+ }
160
+ const buffer = await page.screenshot({ type: 'png', clip });
161
+ return { data: buffer.toString('base64'), wasClipped: false };
162
+ }
140
163
  // Check page dimensions before taking a full-page screenshot
141
164
  if (fullPage) {
142
165
  // Get page dimensions from the browser context
@@ -201,17 +224,185 @@ export class PlaywrightClient {
201
224
  this.context = null;
202
225
  this.page = null;
203
226
  this.consoleMessages = [];
227
+ this._recording = false;
204
228
  }
205
229
  }
206
230
  getConfig() {
207
231
  return this.config;
208
232
  }
233
+ isRecording() {
234
+ return this._recording;
235
+ }
236
+ /**
237
+ * Capture session state (cookies, localStorage, sessionStorage) from the current context.
238
+ * Uses Playwright's context.storageState() for cookies + localStorage,
239
+ * and page.evaluate() for sessionStorage (not covered by storageState).
240
+ */
241
+ async captureSessionState() {
242
+ if (!this.context || !this.page)
243
+ return null;
244
+ try {
245
+ const storageState = await this.context.storageState();
246
+ // Capture sessionStorage via page.evaluate (only for current origin)
247
+ let sessionStorage;
248
+ let currentOrigin;
249
+ try {
250
+ const currentUrl = this.page.url();
251
+ if (currentUrl && currentUrl !== 'about:blank') {
252
+ currentOrigin = new URL(currentUrl).origin;
253
+ const entries = await this.page.evaluate(() => {
254
+ const items = [];
255
+ for (let i = 0; i < window.sessionStorage.length; i++) {
256
+ const key = window.sessionStorage.key(i);
257
+ if (key !== null) {
258
+ items.push({ name: key, value: window.sessionStorage.getItem(key) || '' });
259
+ }
260
+ }
261
+ return items;
262
+ });
263
+ if (entries.length > 0) {
264
+ sessionStorage = entries;
265
+ }
266
+ }
267
+ }
268
+ catch {
269
+ // sessionStorage capture is best-effort
270
+ }
271
+ return { storageState, sessionStorage, currentOrigin };
272
+ }
273
+ catch {
274
+ logWarning('session', 'Failed to capture session state before context recycling');
275
+ return null;
276
+ }
277
+ }
278
+ /**
279
+ * Restore sessionStorage after navigation.
280
+ * Cookies and localStorage are handled by passing storageState to createContext().
281
+ */
282
+ async restoreSessionStorage(state) {
283
+ if (!this.page || !state.sessionStorage || !state.currentOrigin)
284
+ return;
285
+ try {
286
+ const currentUrl = this.page.url();
287
+ if (currentUrl && currentUrl !== 'about:blank') {
288
+ const pageOrigin = new URL(currentUrl).origin;
289
+ if (pageOrigin === state.currentOrigin) {
290
+ await this.page.evaluate((items) => {
291
+ for (const item of items) {
292
+ window.sessionStorage.setItem(item.name, item.value);
293
+ }
294
+ }, state.sessionStorage);
295
+ }
296
+ }
297
+ }
298
+ catch {
299
+ logWarning('session', 'Failed to restore sessionStorage after context recycling');
300
+ }
301
+ }
302
+ setupPageHandlers(page) {
303
+ page.setDefaultTimeout(this.config.timeout);
304
+ page.setDefaultNavigationTimeout(this.config.navigationTimeout);
305
+ 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
+ }
312
+ async startRecording(videoDir) {
313
+ await this.launchBrowserIfNeeded();
314
+ // Capture session state BEFORE recycling the context
315
+ const savedState = await this.captureSessionState();
316
+ // Capture the current URL before recycling the context
317
+ let previousUrl;
318
+ if (this.page) {
319
+ try {
320
+ previousUrl = this.page.url();
321
+ if (previousUrl === 'about:blank') {
322
+ previousUrl = undefined;
323
+ }
324
+ }
325
+ catch {
326
+ // Page may be closed already
327
+ }
328
+ }
329
+ // If currently recording, stop the existing recording first (close context to finalize video)
330
+ if (this.context) {
331
+ // Close the page and context to finalize any existing recording
332
+ this.page = null;
333
+ await this.context.close();
334
+ this.context = null;
335
+ }
336
+ // Create a new context with video recording enabled, restoring session state
337
+ await this.createContext({
338
+ recordVideo: { dir: videoDir, size: { width: 1920, height: 1080 } },
339
+ storageState: savedState?.storageState,
340
+ });
341
+ this.page = await this.context.newPage();
342
+ this.setupPageHandlers(this.page);
343
+ // Navigate back to the previous URL if there was one
344
+ if (previousUrl) {
345
+ await this.page.goto(previousUrl);
346
+ }
347
+ // Restore sessionStorage after navigation (cookies + localStorage handled by storageState)
348
+ if (savedState) {
349
+ await this.restoreSessionStorage(savedState);
350
+ }
351
+ this._recording = true;
352
+ return { previousUrl };
353
+ }
354
+ async stopRecording() {
355
+ if (!this._recording || !this.page) {
356
+ throw new Error('Not currently recording');
357
+ }
358
+ // Capture session state BEFORE closing the recording context
359
+ const savedState = await this.captureSessionState();
360
+ // Capture current page state before closing the context
361
+ let pageUrl;
362
+ let pageTitle;
363
+ try {
364
+ pageUrl = this.page.url();
365
+ if (pageUrl === 'about:blank') {
366
+ pageUrl = undefined;
367
+ }
368
+ pageTitle = await this.page.title();
369
+ }
370
+ catch {
371
+ // Page may have issues
372
+ }
373
+ // Get the video path BEFORE closing the context
374
+ const video = this.page.video();
375
+ if (!video) {
376
+ throw new Error('No video found on the current page');
377
+ }
378
+ const videoPath = await video.path();
379
+ // Close page and context to finalize the video file
380
+ this.page = null;
381
+ await this.context.close();
382
+ this.context = null;
383
+ this._recording = false;
384
+ // Create a new context WITHOUT recording, restoring session state
385
+ await this.createContext({
386
+ storageState: savedState?.storageState,
387
+ });
388
+ this.page = await this.context.newPage();
389
+ this.setupPageHandlers(this.page);
390
+ // Navigate back to the previous URL
391
+ if (pageUrl) {
392
+ await this.page.goto(pageUrl);
393
+ }
394
+ // Restore sessionStorage after navigation
395
+ if (savedState) {
396
+ await this.restoreSessionStorage(savedState);
397
+ }
398
+ return { videoPath, pageUrl, pageTitle };
399
+ }
209
400
  }
210
401
  export function createMCPServer(options) {
211
402
  const stealthMode = process.env.STEALTH_MODE === 'true';
212
403
  const server = new Server({
213
404
  name: 'playwright-stealth-mcp-server',
214
- version: '0.0.9',
405
+ version: options.version,
215
406
  }, {
216
407
  capabilities: {
217
408
  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';
@@ -6,6 +6,13 @@ export interface ScreenshotMetadata {
6
6
  pageUrl?: string;
7
7
  pageTitle?: string;
8
8
  fullPage: boolean;
9
+ selector?: string;
10
+ clip?: {
11
+ x: number;
12
+ y: number;
13
+ width: number;
14
+ height: number;
15
+ };
9
16
  }
10
17
  export interface ScreenshotResourceData {
11
18
  uri: string;
@@ -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
  // =============================================================================
@@ -8,13 +8,31 @@ const ExecuteSchema = z.object({
8
8
  code: z.string().describe('Playwright code to execute. The `page` object is available in scope.'),
9
9
  timeout: z.number().optional().describe('Execution timeout in milliseconds. Default: 30000'),
10
10
  });
11
- const ScreenshotSchema = z.object({
11
+ const ScreenshotSchema = z
12
+ .object({
12
13
  fullPage: z.boolean().optional().describe('Capture the full scrollable page. Default: false'),
14
+ selector: z
15
+ .string()
16
+ .optional()
17
+ .describe('CSS selector of a specific element to screenshot. Mutually exclusive with fullPage and clip.'),
18
+ clip: z
19
+ .object({
20
+ x: z.number().nonnegative().describe('X coordinate of the top-left corner'),
21
+ y: z.number().nonnegative().describe('Y coordinate of the top-left corner'),
22
+ width: z.number().positive().describe('Width of the clip region in pixels'),
23
+ height: z.number().positive().describe('Height of the clip region in pixels'),
24
+ })
25
+ .optional()
26
+ .describe('Region of the page to screenshot as {x, y, width, height}. Mutually exclusive with fullPage and selector.'),
13
27
  resultHandling: z
14
28
  .enum(['saveAndReturn', 'saveOnly'])
15
29
  .optional()
16
30
  .describe("How to handle the screenshot result. 'saveAndReturn' (default) saves to storage and returns inline base64, 'saveOnly' saves to storage and returns only the resource URI"),
17
- });
31
+ })
32
+ .refine((data) => {
33
+ const modes = [data.fullPage, !!data.selector, !!data.clip].filter(Boolean);
34
+ return modes.length <= 1;
35
+ }, { message: 'Only one of fullPage, selector, or clip can be specified' });
18
36
  // =============================================================================
19
37
  // TOOL DESCRIPTIONS
20
38
  // =============================================================================
@@ -59,27 +77,32 @@ await page.click('button[type="submit"]');
59
77
  - \`consoleOutput\`: array of console messages from the page
60
78
 
61
79
  **Note:** When STEALTH_MODE=true, the browser includes anti-detection measures to help bypass bot protection.`;
62
- const SCREENSHOT_DESCRIPTION = `Take a screenshot of the current page.
80
+ const SCREENSHOT_DESCRIPTION = `Take a screenshot of the current page, a specific element, or a page region.
63
81
 
64
- Captures the visible viewport or full page as a PNG image. Screenshots are saved to filesystem storage and can be accessed later via MCP resources.
82
+ Captures the visible viewport, full page, a specific element, or a rectangular region as a PNG image. Screenshots are saved to filesystem storage and can be accessed later via MCP resources.
65
83
 
66
84
  **Parameters:**
67
85
  - \`fullPage\`: Whether to capture the full scrollable page (default: false)
86
+ - \`selector\`: CSS selector of a specific element to screenshot (e.g., '#main-content', '.hero-banner', 'table.results')
87
+ - \`clip\`: Region of the page to screenshot as {x, y, width, height} in pixels
68
88
  - \`resultHandling\`: How to handle the result:
69
89
  - \`saveAndReturn\` (default): Saves to storage AND returns inline base64 image
70
90
  - \`saveOnly\`: Saves to storage and returns only the resource URI (more efficient for large screenshots)
71
91
 
92
+ **Note:** \`fullPage\`, \`selector\`, and \`clip\` are mutually exclusive. Only one can be specified per call.
93
+
72
94
  **Returns:**
73
95
  - With \`saveAndReturn\`: Inline base64 PNG image data plus a resource_link to the saved file
74
96
  - With \`saveOnly\`: A resource_link with the \`file://\` URI to the saved screenshot
75
97
 
76
98
  **Dimension Limits:**
77
- Screenshots are limited to 8000 pixels in any dimension. If a full-page screenshot would exceed this limit, it is automatically clipped from the top-left corner and a warning is included in the response.
99
+ Full-page screenshots are limited to 8000 pixels in any dimension. If a full-page screenshot would exceed this limit, it is automatically clipped from the top-left corner and a warning is included in the response. Element and clip screenshots are not subject to this limit.
78
100
 
79
101
  **Use cases:**
80
102
  - Verify page state after navigation
103
+ - Screenshot a specific element like a chart, table, or form
104
+ - Capture a region of the page by coordinates
81
105
  - Debug automation issues
82
- - Capture visual content for analysis
83
106
  - Store screenshots for later reference via MCP resources`;
84
107
  const GET_STATE_DESCRIPTION = `Get the current browser state.
85
108
 
@@ -96,6 +119,32 @@ Returns information about the current browser session including the URL, page ti
96
119
  const CLOSE_DESCRIPTION = `Close the browser session.
97
120
 
98
121
  Shuts down the browser and clears all state. A new browser will be launched on the next execute call.`;
122
+ const START_RECORDING_DESCRIPTION = `Start recording the browser session as a video.
123
+
124
+ This tool begins capturing all browser interactions as a WebM video file. It works by recycling the browser context with video recording enabled.
125
+
126
+ **Session state preservation:** Cookies and localStorage are automatically saved and restored when the context is recycled. sessionStorage for the current origin is also preserved on a best-effort basis. In rare cases involving multiple origins, some state may not be restored — if you experience authentication issues, log in again.
127
+
128
+ **Behavior when already recording:** If recording is already active, the current recording will be stopped (saving the video) and a new recording will begin.
129
+
130
+ **The video is NOT available until you call \`browser_stop_recording\`.** Playwright only finalizes the video file when the recording context is closed.
131
+
132
+ **Returns:**
133
+ - Confirmation that recording has started
134
+ - The URL the browser was previously on (if any) — the browser navigates back to it automatically`;
135
+ const STOP_RECORDING_DESCRIPTION = `Stop recording the browser session and save the video.
136
+
137
+ This tool stops the active video recording, saves the video file, and returns a resource URI for the recorded video.
138
+
139
+ **Session state preservation:** Cookies and localStorage are automatically saved and restored when the context is recycled. sessionStorage for the current origin is also preserved on a best-effort basis.
140
+
141
+ The browser automatically navigates back to the URL it was on before the recording stopped.
142
+
143
+ **Error:** Returns an error if no recording is currently active. Use \`browser_start_recording\` first.
144
+
145
+ **Returns:**
146
+ - A resource_link with the \`file://\` URI to the saved video file (WebM format)
147
+ - The video can be accessed later via MCP resources`;
99
148
  export function createRegisterTools(clientFactory) {
100
149
  // Create a single client instance that persists across calls
101
150
  let client = null;
@@ -181,6 +230,21 @@ export function createRegisterTools(clientFactory) {
181
230
  type: 'boolean',
182
231
  description: 'Capture the full scrollable page. Default: false',
183
232
  },
233
+ selector: {
234
+ type: 'string',
235
+ description: 'CSS selector of a specific element to screenshot. Mutually exclusive with fullPage and clip.',
236
+ },
237
+ clip: {
238
+ type: 'object',
239
+ description: 'Region of the page to screenshot. Mutually exclusive with fullPage and selector.',
240
+ properties: {
241
+ x: { type: 'number', description: 'X coordinate of the top-left corner' },
242
+ y: { type: 'number', description: 'Y coordinate of the top-left corner' },
243
+ width: { type: 'number', description: 'Width in pixels' },
244
+ height: { type: 'number', description: 'Height in pixels' },
245
+ },
246
+ required: ['x', 'y', 'width', 'height'],
247
+ },
184
248
  resultHandling: {
185
249
  type: 'string',
186
250
  enum: ['saveAndReturn', 'saveOnly'],
@@ -194,6 +258,8 @@ export function createRegisterTools(clientFactory) {
194
258
  const client = getClient();
195
259
  const screenshotResult = await client.screenshot({
196
260
  fullPage: validated.fullPage,
261
+ selector: validated.selector,
262
+ clip: validated.clip,
197
263
  });
198
264
  // Get page metadata for the screenshot
199
265
  const state = await client.getState();
@@ -203,6 +269,8 @@ export function createRegisterTools(clientFactory) {
203
269
  pageUrl: state.currentUrl,
204
270
  pageTitle: state.title,
205
271
  fullPage: validated.fullPage ?? false,
272
+ selector: validated.selector,
273
+ clip: validated.clip,
206
274
  });
207
275
  const resultHandling = validated.resultHandling ?? 'saveAndReturn';
208
276
  // Generate a name from the URI for the resource link
@@ -326,6 +394,123 @@ export function createRegisterTools(clientFactory) {
326
394
  }
327
395
  },
328
396
  },
397
+ {
398
+ name: 'browser_start_recording',
399
+ description: START_RECORDING_DESCRIPTION,
400
+ inputSchema: {
401
+ type: 'object',
402
+ properties: {},
403
+ },
404
+ handler: async () => {
405
+ try {
406
+ const currentClient = getClient();
407
+ // If already recording, stop the current recording first (save the video)
408
+ let previousVideoUri;
409
+ if (currentClient.isRecording()) {
410
+ try {
411
+ const stopResult = await currentClient.stopRecording();
412
+ // Save the previous recording
413
+ const videoStorage = await VideoStorageFactory.create();
414
+ previousVideoUri = await videoStorage.write(stopResult.videoPath, {
415
+ pageUrl: stopResult.pageUrl,
416
+ pageTitle: stopResult.pageTitle,
417
+ });
418
+ }
419
+ catch {
420
+ // Best effort to save previous recording
421
+ }
422
+ }
423
+ // Get the video storage directory for Playwright to write to
424
+ const videoStoragePath = process.env.VIDEO_STORAGE_PATH || '/tmp/playwright-videos';
425
+ const result = await currentClient.startRecording(videoStoragePath);
426
+ const parts = ['Recording started.'];
427
+ if (result.previousUrl) {
428
+ parts.push(`Browser navigated back to: ${result.previousUrl}`);
429
+ }
430
+ parts.push('Note: Session state (cookies, localStorage) has been preserved where possible.');
431
+ if (previousVideoUri) {
432
+ parts.push(`Previous recording saved to: ${previousVideoUri}`);
433
+ }
434
+ return {
435
+ content: [
436
+ {
437
+ type: 'text',
438
+ text: parts.join('\n'),
439
+ },
440
+ ],
441
+ };
442
+ }
443
+ catch (error) {
444
+ return {
445
+ content: [
446
+ {
447
+ type: 'text',
448
+ text: `Error starting recording: ${error instanceof Error ? error.message : String(error)}`,
449
+ },
450
+ ],
451
+ isError: true,
452
+ };
453
+ }
454
+ },
455
+ },
456
+ {
457
+ name: 'browser_stop_recording',
458
+ description: STOP_RECORDING_DESCRIPTION,
459
+ inputSchema: {
460
+ type: 'object',
461
+ properties: {},
462
+ },
463
+ handler: async () => {
464
+ try {
465
+ const currentClient = getClient();
466
+ if (!currentClient.isRecording()) {
467
+ return {
468
+ content: [
469
+ {
470
+ type: 'text',
471
+ text: 'Error: Not currently recording. Use browser_start_recording to begin a recording first.',
472
+ },
473
+ ],
474
+ isError: true,
475
+ };
476
+ }
477
+ const result = await currentClient.stopRecording();
478
+ // Save the video to storage
479
+ const videoStorage = await VideoStorageFactory.create();
480
+ const uri = await videoStorage.write(result.videoPath, {
481
+ pageUrl: result.pageUrl,
482
+ pageTitle: result.pageTitle,
483
+ });
484
+ const fileName = uri.split('/').pop() || 'recording.webm';
485
+ const content = [];
486
+ content.push({
487
+ type: 'text',
488
+ text: `Recording stopped and saved.\nNote: Session state (cookies, localStorage) has been preserved where possible.`,
489
+ });
490
+ content.push({
491
+ type: 'resource_link',
492
+ uri,
493
+ name: fileName,
494
+ description: result.pageUrl
495
+ ? `Video recording of ${result.pageUrl}`
496
+ : 'Video recording',
497
+ mimeType: 'video/webm',
498
+ });
499
+ return { content };
500
+ }
501
+ catch (error) {
502
+ return {
503
+ content: [
504
+ {
505
+ type: 'text',
506
+ text: `Error stopping recording: ${error instanceof Error ? error.message : String(error)}`,
507
+ },
508
+ ],
509
+ isError: true,
510
+ };
511
+ }
512
+ },
513
+ },
329
514
  ];
330
515
  return (server) => {
331
516
  // List available tools