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