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 +27 -5
- package/build/index.integration-with-mock.js +9 -1
- package/build/index.js +9 -0
- package/package.json +1 -1
- package/shared/index.d.ts +1 -1
- package/shared/playwright-client/playwright-client.integration-mock.d.ts +17 -2
- package/shared/playwright-client/playwright-client.integration-mock.js +21 -1
- package/shared/resources.d.ts +1 -1
- package/shared/resources.js +46 -21
- package/shared/server.d.ts +69 -2
- package/shared/server.js +206 -15
- package/shared/storage/index.d.ts +3 -0
- package/shared/storage/index.js +3 -0
- package/shared/storage/types.d.ts +7 -0
- package/shared/storage/video-factory.d.ts +7 -0
- package/shared/storage/video-factory.js +17 -0
- package/shared/storage/video-filesystem.d.ts +16 -0
- package/shared/storage/video-filesystem.js +125 -0
- package/shared/storage/video-types.d.ts +29 -0
- package/shared/storage/video-types.js +4 -0
- package/shared/tools.js +192 -7
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
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(
|
|
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({
|
package/shared/resources.d.ts
CHANGED
|
@@ -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
|
package/shared/resources.js
CHANGED
|
@@ -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
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
}
|
package/shared/server.d.ts
CHANGED
|
@@ -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
|
|
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:
|
|
405
|
+
version: options.version,
|
|
215
406
|
}, {
|
|
216
407
|
capabilities: {
|
|
217
408
|
tools: {},
|
package/shared/storage/index.js
CHANGED
|
@@ -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,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
|
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
|
|
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
|
|
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
|
-
|
|
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
|