playwright-stealth-mcp-server 0.0.2 → 0.0.4
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 +66 -7
- package/build/index.js +120 -10
- package/package.json +1 -1
- package/shared/index.d.ts +2 -0
- package/shared/index.js +2 -0
- package/shared/logging.d.ts +4 -0
- package/shared/logging.js +6 -0
- package/shared/resources.d.ts +6 -0
- package/shared/resources.js +40 -0
- package/shared/server.d.ts +2 -2
- package/shared/server.js +21 -2
- package/shared/storage/factory.d.ts +7 -0
- package/shared/storage/factory.js +17 -0
- package/shared/storage/filesystem.d.ts +16 -0
- package/shared/storage/filesystem.js +126 -0
- package/shared/storage/index.d.ts +4 -0
- package/shared/storage/index.js +3 -0
- package/shared/storage/types.d.ts +29 -0
- package/shared/storage/types.js +4 -0
- package/shared/tools.js +58 -4
- package/shared/types.d.ts +22 -0
package/README.md
CHANGED
|
@@ -60,14 +60,38 @@ Add to your Claude Desktop config file:
|
|
|
60
60
|
}
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
+
**With Proxy** (e.g., BrightData Residential Proxy):
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"mcpServers": {
|
|
68
|
+
"playwright-proxy": {
|
|
69
|
+
"command": "npx",
|
|
70
|
+
"args": ["-y", "playwright-stealth-mcp-server"],
|
|
71
|
+
"env": {
|
|
72
|
+
"STEALTH_MODE": "true",
|
|
73
|
+
"PROXY_URL": "http://brd.superproxy.io:22225",
|
|
74
|
+
"PROXY_USERNAME": "brd-customer-XXXXX-zone-residential",
|
|
75
|
+
"PROXY_PASSWORD": "your-password"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
63
82
|
### Environment Variables
|
|
64
83
|
|
|
65
|
-
| Variable
|
|
66
|
-
|
|
|
67
|
-
| `STEALTH_MODE`
|
|
68
|
-
| `HEADLESS`
|
|
69
|
-
| `TIMEOUT`
|
|
70
|
-
| `NAVIGATION_TIMEOUT`
|
|
84
|
+
| Variable | Description | Default |
|
|
85
|
+
| ------------------------- | -------------------------------------------------------------------------- | ----------------------------- |
|
|
86
|
+
| `STEALTH_MODE` | Enable stealth mode with anti-detection measures | `false` |
|
|
87
|
+
| `HEADLESS` | Run browser in headless mode | `true` |
|
|
88
|
+
| `TIMEOUT` | Default timeout for Playwright actions (click, fill, etc.) in milliseconds | `30000` |
|
|
89
|
+
| `NAVIGATION_TIMEOUT` | Default timeout for page navigation (goto, reload, etc.) in milliseconds | `60000` |
|
|
90
|
+
| `SCREENSHOT_STORAGE_PATH` | Directory for storing screenshots | `/tmp/playwright-screenshots` |
|
|
91
|
+
| `PROXY_URL` | Proxy server URL (e.g., `http://proxy.example.com:8080`) | - |
|
|
92
|
+
| `PROXY_USERNAME` | Proxy authentication username | - |
|
|
93
|
+
| `PROXY_PASSWORD` | Proxy authentication password | - |
|
|
94
|
+
| `PROXY_BYPASS` | Comma-separated list of hosts to bypass proxy | - |
|
|
71
95
|
|
|
72
96
|
## Available Tools
|
|
73
97
|
|
|
@@ -90,11 +114,19 @@ return title;
|
|
|
90
114
|
|
|
91
115
|
### `browser_screenshot`
|
|
92
116
|
|
|
93
|
-
Take a screenshot of the current page.
|
|
117
|
+
Take a screenshot of the current page. Screenshots are saved to filesystem storage and can be accessed later via MCP resources.
|
|
94
118
|
|
|
95
119
|
**Parameters:**
|
|
96
120
|
|
|
97
121
|
- `fullPage` (optional): Capture full scrollable page. Default: `false`
|
|
122
|
+
- `resultHandling` (optional): How to handle the result:
|
|
123
|
+
- `saveAndReturn` (default): Saves to storage AND returns inline base64 image
|
|
124
|
+
- `saveOnly`: Saves to storage and returns only the resource URI (more efficient for large screenshots)
|
|
125
|
+
|
|
126
|
+
**Returns:**
|
|
127
|
+
|
|
128
|
+
- With `saveAndReturn`: Inline base64 PNG image plus a `resource_link` with the file URI
|
|
129
|
+
- With `saveOnly`: Only a `resource_link` with the `file://` URI to the saved screenshot
|
|
98
130
|
|
|
99
131
|
### `browser_get_state`
|
|
100
132
|
|
|
@@ -104,6 +136,15 @@ Get the current browser state including URL, title, and configuration.
|
|
|
104
136
|
|
|
105
137
|
Close the browser session. A new browser will be launched on the next `browser_execute` call.
|
|
106
138
|
|
|
139
|
+
## MCP Resources
|
|
140
|
+
|
|
141
|
+
The server exposes saved screenshots as MCP resources. Clients can use:
|
|
142
|
+
|
|
143
|
+
- `resources/list`: List all saved screenshots with their URIs and metadata
|
|
144
|
+
- `resources/read`: Read a screenshot by its `file://` URI
|
|
145
|
+
|
|
146
|
+
This allows clients to access previously captured screenshots without needing to take new ones.
|
|
147
|
+
|
|
107
148
|
## Usage Examples
|
|
108
149
|
|
|
109
150
|
### Navigate and Extract Data
|
|
@@ -183,6 +224,24 @@ Stealth mode includes:
|
|
|
183
224
|
- Plugin/mime type spoofing
|
|
184
225
|
- Navigator property patching
|
|
185
226
|
|
|
227
|
+
## When to Use Proxy
|
|
228
|
+
|
|
229
|
+
Configure proxy settings when:
|
|
230
|
+
|
|
231
|
+
- Scraping sites that rate-limit by IP address
|
|
232
|
+
- Accessing geo-restricted content
|
|
233
|
+
- Avoiding IP-based blocks or bans
|
|
234
|
+
- Rotating IPs for large-scale data collection
|
|
235
|
+
|
|
236
|
+
The server supports HTTP/HTTPS proxies with optional authentication, making it compatible with:
|
|
237
|
+
|
|
238
|
+
- **BrightData** (Residential, Datacenter, ISP proxies)
|
|
239
|
+
- **Oxylabs**, **Smartproxy**, and other residential proxy providers
|
|
240
|
+
- Self-hosted proxy servers
|
|
241
|
+
- Corporate HTTP proxies
|
|
242
|
+
|
|
243
|
+
**Note:** When proxy is configured, the server performs a health check on startup to verify the proxy connection works. If the health check fails, the server will exit with an error.
|
|
244
|
+
|
|
186
245
|
## Development
|
|
187
246
|
|
|
188
247
|
```bash
|
package/build/index.js
CHANGED
|
@@ -1,7 +1,66 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
3
|
import { createMCPServer } from '../shared/index.js';
|
|
4
|
-
import { logServerStart, logError, logWarning } from '../shared/logging.js';
|
|
4
|
+
import { logServerStart, logError, logWarning, logInfo } from '../shared/logging.js';
|
|
5
|
+
// =============================================================================
|
|
6
|
+
// PROXY CONFIGURATION
|
|
7
|
+
// =============================================================================
|
|
8
|
+
/**
|
|
9
|
+
* Build proxy configuration from environment variables
|
|
10
|
+
* @returns ProxyConfig if proxy is configured, undefined otherwise
|
|
11
|
+
*/
|
|
12
|
+
function buildProxyConfig() {
|
|
13
|
+
const proxyUrl = process.env.PROXY_URL;
|
|
14
|
+
if (!proxyUrl) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
server: proxyUrl,
|
|
19
|
+
username: process.env.PROXY_USERNAME,
|
|
20
|
+
password: process.env.PROXY_PASSWORD,
|
|
21
|
+
bypass: process.env.PROXY_BYPASS,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Health check the proxy connection by making a test request
|
|
26
|
+
* @param proxy The proxy configuration to test
|
|
27
|
+
*/
|
|
28
|
+
async function healthCheckProxy(proxy) {
|
|
29
|
+
logInfo('proxy', 'Performing proxy health check...');
|
|
30
|
+
// Use Node.js built-in fetch with proxy agent
|
|
31
|
+
// We'll use a simple approach: launch a quick browser with the proxy to test
|
|
32
|
+
const { chromium } = await import('playwright');
|
|
33
|
+
let browser;
|
|
34
|
+
try {
|
|
35
|
+
browser = await chromium.launch({
|
|
36
|
+
headless: true,
|
|
37
|
+
proxy: {
|
|
38
|
+
server: proxy.server,
|
|
39
|
+
username: proxy.username,
|
|
40
|
+
password: proxy.password,
|
|
41
|
+
bypass: proxy.bypass,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
// Ignore HTTPS errors for residential proxies that perform HTTPS inspection
|
|
45
|
+
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
|
46
|
+
const page = await context.newPage();
|
|
47
|
+
// Try to fetch a reliable endpoint to verify proxy works
|
|
48
|
+
const response = await page.goto('https://httpbin.org/ip', {
|
|
49
|
+
timeout: 30000,
|
|
50
|
+
waitUntil: 'domcontentloaded',
|
|
51
|
+
});
|
|
52
|
+
if (!response || !response.ok()) {
|
|
53
|
+
throw new Error(`Proxy health check failed: HTTP ${response?.status() ?? 'unknown'}`);
|
|
54
|
+
}
|
|
55
|
+
const body = await page.textContent('body');
|
|
56
|
+
logInfo('proxy', `Proxy health check passed. Response: ${body?.trim()}`);
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
if (browser) {
|
|
60
|
+
await browser.close();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
5
64
|
// =============================================================================
|
|
6
65
|
// ENVIRONMENT VALIDATION
|
|
7
66
|
// =============================================================================
|
|
@@ -22,11 +81,32 @@ function validateEnvironment() {
|
|
|
22
81
|
description: 'Default execution timeout in milliseconds',
|
|
23
82
|
defaultValue: '30000',
|
|
24
83
|
},
|
|
84
|
+
{
|
|
85
|
+
name: 'PROXY_URL',
|
|
86
|
+
description: 'Proxy server URL (e.g., http://proxy.example.com:8080)',
|
|
87
|
+
defaultValue: undefined,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'PROXY_USERNAME',
|
|
91
|
+
description: 'Proxy authentication username',
|
|
92
|
+
defaultValue: undefined,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: 'PROXY_PASSWORD',
|
|
96
|
+
description: 'Proxy authentication password',
|
|
97
|
+
defaultValue: undefined,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'PROXY_BYPASS',
|
|
101
|
+
description: 'Comma-separated list of hosts to bypass proxy',
|
|
102
|
+
defaultValue: undefined,
|
|
103
|
+
},
|
|
25
104
|
];
|
|
26
105
|
// Log configuration
|
|
27
106
|
const stealthMode = process.env.STEALTH_MODE === 'true';
|
|
28
107
|
const headless = process.env.HEADLESS !== 'false';
|
|
29
108
|
const timeout = process.env.TIMEOUT || '30000';
|
|
109
|
+
const proxyUrl = process.env.PROXY_URL;
|
|
30
110
|
if (stealthMode) {
|
|
31
111
|
logWarning('config', 'Stealth mode enabled - using anti-detection measures');
|
|
32
112
|
}
|
|
@@ -36,13 +116,29 @@ function validateEnvironment() {
|
|
|
36
116
|
if (process.env.TIMEOUT) {
|
|
37
117
|
logWarning('config', `Custom timeout configured: ${timeout}ms`);
|
|
38
118
|
}
|
|
119
|
+
if (proxyUrl) {
|
|
120
|
+
// Sanitize proxy URL to prevent credential leaks (in case URL contains embedded credentials)
|
|
121
|
+
const sanitizedUrl = proxyUrl.replace(/\/\/[^@]+@/, '//*****@');
|
|
122
|
+
logInfo('config', `Proxy configured: ${sanitizedUrl}`);
|
|
123
|
+
if (process.env.PROXY_USERNAME) {
|
|
124
|
+
logInfo('config', 'Proxy authentication enabled');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
39
127
|
// Show optional configuration if DEBUG is set
|
|
40
128
|
if (process.env.DEBUG) {
|
|
41
129
|
console.error('\nOptional environment variables:');
|
|
42
130
|
optional.forEach(({ name, description, defaultValue }) => {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
131
|
+
// Don't log proxy password
|
|
132
|
+
if (name === 'PROXY_PASSWORD') {
|
|
133
|
+
const hasPassword = !!process.env[name];
|
|
134
|
+
console.error(` - ${name}: ${description}`);
|
|
135
|
+
console.error(` Current: ${hasPassword ? '***' : '(not set)'}`);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const current = process.env[name] || defaultValue;
|
|
139
|
+
console.error(` - ${name}: ${description}`);
|
|
140
|
+
console.error(` Current: ${current || '(not set)'}`);
|
|
141
|
+
}
|
|
46
142
|
});
|
|
47
143
|
console.error('');
|
|
48
144
|
}
|
|
@@ -53,11 +149,24 @@ function validateEnvironment() {
|
|
|
53
149
|
async function main() {
|
|
54
150
|
// Step 1: Validate environment variables
|
|
55
151
|
validateEnvironment();
|
|
56
|
-
// Step 2:
|
|
57
|
-
const
|
|
58
|
-
// Step 3:
|
|
152
|
+
// Step 2: Build proxy configuration if provided
|
|
153
|
+
const proxyConfig = buildProxyConfig();
|
|
154
|
+
// Step 3: If proxy is configured, perform health check
|
|
155
|
+
if (proxyConfig) {
|
|
156
|
+
try {
|
|
157
|
+
await healthCheckProxy(proxyConfig);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
logError('proxy', `Proxy health check failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
161
|
+
logError('proxy', 'Please verify your proxy configuration and try again.');
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Step 4: Create server using factory, passing proxy config
|
|
166
|
+
const { server, registerHandlers, cleanup } = createMCPServer(proxyConfig);
|
|
167
|
+
// Step 5: Register all handlers (tools)
|
|
59
168
|
await registerHandlers(server);
|
|
60
|
-
// Step
|
|
169
|
+
// Step 6: Set up graceful shutdown
|
|
61
170
|
const handleShutdown = async () => {
|
|
62
171
|
logWarning('shutdown', 'Received shutdown signal, closing browser...');
|
|
63
172
|
await cleanup();
|
|
@@ -65,11 +174,12 @@ async function main() {
|
|
|
65
174
|
};
|
|
66
175
|
process.on('SIGINT', handleShutdown);
|
|
67
176
|
process.on('SIGTERM', handleShutdown);
|
|
68
|
-
// Step
|
|
177
|
+
// Step 7: Start server with stdio transport
|
|
69
178
|
const transport = new StdioServerTransport();
|
|
70
179
|
await server.connect(transport);
|
|
71
180
|
const stealthMode = process.env.STEALTH_MODE === 'true';
|
|
72
|
-
|
|
181
|
+
const proxyEnabled = !!proxyConfig;
|
|
182
|
+
logServerStart(`Playwright${stealthMode ? ' (Stealth)' : ''}${proxyEnabled ? ' (Proxy)' : ''}`);
|
|
73
183
|
}
|
|
74
184
|
// Run the server
|
|
75
185
|
main().catch((error) => {
|
package/package.json
CHANGED
package/shared/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { createMCPServer, type IPlaywrightClient, type ClientFactory } from './server.js';
|
|
2
2
|
export { createRegisterTools } from './tools.js';
|
|
3
|
+
export { registerResources } from './resources.js';
|
|
3
4
|
export { logServerStart, logError, logWarning, logDebug } from './logging.js';
|
|
5
|
+
export * from './storage/index.js';
|
|
4
6
|
//# sourceMappingURL=index.d.ts.map
|
package/shared/index.js
CHANGED
package/shared/logging.d.ts
CHANGED
|
@@ -13,6 +13,10 @@ export declare function logError(context: string, error: unknown): void;
|
|
|
13
13
|
* Log a warning
|
|
14
14
|
*/
|
|
15
15
|
export declare function logWarning(context: string, message: string): void;
|
|
16
|
+
/**
|
|
17
|
+
* Log an informational message
|
|
18
|
+
*/
|
|
19
|
+
export declare function logInfo(context: string, message: string): void;
|
|
16
20
|
/**
|
|
17
21
|
* Log debug information (only in development)
|
|
18
22
|
*/
|
package/shared/logging.js
CHANGED
|
@@ -24,6 +24,12 @@ export function logError(context, error) {
|
|
|
24
24
|
export function logWarning(context, message) {
|
|
25
25
|
console.error(`[WARN] ${context}: ${message}`);
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Log an informational message
|
|
29
|
+
*/
|
|
30
|
+
export function logInfo(context, message) {
|
|
31
|
+
console.error(`[INFO] ${context}: ${message}`);
|
|
32
|
+
}
|
|
27
33
|
/**
|
|
28
34
|
* Log debug information (only in development)
|
|
29
35
|
*/
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ListResourcesRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { ScreenshotStorageFactory } from './storage/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Register screenshot resources handlers to an MCP server
|
|
5
|
+
*/
|
|
6
|
+
export function registerResources(server) {
|
|
7
|
+
// Register resource list handler
|
|
8
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
9
|
+
const storage = await ScreenshotStorageFactory.create();
|
|
10
|
+
const resources = await storage.list();
|
|
11
|
+
return {
|
|
12
|
+
resources: resources.map((resource) => ({
|
|
13
|
+
uri: resource.uri,
|
|
14
|
+
name: resource.name,
|
|
15
|
+
description: resource.description,
|
|
16
|
+
mimeType: resource.mimeType,
|
|
17
|
+
})),
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
// Register resource read handler
|
|
21
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
22
|
+
const { uri } = request.params;
|
|
23
|
+
const storage = await ScreenshotStorageFactory.create();
|
|
24
|
+
try {
|
|
25
|
+
const content = await storage.read(uri);
|
|
26
|
+
return {
|
|
27
|
+
contents: [
|
|
28
|
+
{
|
|
29
|
+
uri: content.uri,
|
|
30
|
+
mimeType: content.mimeType,
|
|
31
|
+
blob: content.blob,
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new Error(`Resource not found: ${uri}`);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
package/shared/server.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
-
import type { ExecuteResult, BrowserState, PlaywrightConfig } from './types.js';
|
|
2
|
+
import type { ExecuteResult, BrowserState, PlaywrightConfig, ProxyConfig } from './types.js';
|
|
3
3
|
/**
|
|
4
4
|
* Playwright client interface
|
|
5
5
|
* Defines all methods for browser automation
|
|
@@ -52,7 +52,7 @@ export declare class PlaywrightClient implements IPlaywrightClient {
|
|
|
52
52
|
getConfig(): PlaywrightConfig;
|
|
53
53
|
}
|
|
54
54
|
export type ClientFactory = () => IPlaywrightClient;
|
|
55
|
-
export declare function createMCPServer(): {
|
|
55
|
+
export declare function createMCPServer(proxyConfig?: ProxyConfig): {
|
|
56
56
|
server: Server<{
|
|
57
57
|
method: string;
|
|
58
58
|
params?: {
|
package/shared/server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
2
|
import { createRegisterTools } from './tools.js';
|
|
3
|
+
import { registerResources } from './resources.js';
|
|
3
4
|
/**
|
|
4
5
|
* Playwright client implementation with optional stealth mode
|
|
5
6
|
*/
|
|
@@ -16,6 +17,15 @@ export class PlaywrightClient {
|
|
|
16
17
|
if (this.page) {
|
|
17
18
|
return this.page;
|
|
18
19
|
}
|
|
20
|
+
// Build proxy options for Playwright if configured
|
|
21
|
+
const proxyOptions = this.config.proxy
|
|
22
|
+
? {
|
|
23
|
+
server: this.config.proxy.server,
|
|
24
|
+
username: this.config.proxy.username,
|
|
25
|
+
password: this.config.proxy.password,
|
|
26
|
+
bypass: this.config.proxy.bypass,
|
|
27
|
+
}
|
|
28
|
+
: undefined;
|
|
19
29
|
if (this.config.stealthMode) {
|
|
20
30
|
// Use playwright-extra with stealth plugin
|
|
21
31
|
const { chromium } = await import('playwright-extra');
|
|
@@ -28,6 +38,7 @@ export class PlaywrightClient {
|
|
|
28
38
|
'--disable-dev-shm-usage',
|
|
29
39
|
'--no-sandbox',
|
|
30
40
|
],
|
|
41
|
+
proxy: proxyOptions,
|
|
31
42
|
});
|
|
32
43
|
}
|
|
33
44
|
else {
|
|
@@ -35,6 +46,7 @@ export class PlaywrightClient {
|
|
|
35
46
|
const { chromium } = await import('playwright');
|
|
36
47
|
this.browser = await chromium.launch({
|
|
37
48
|
headless: this.config.headless,
|
|
49
|
+
proxy: proxyOptions,
|
|
38
50
|
});
|
|
39
51
|
}
|
|
40
52
|
this.context = await this.browser.newContext({
|
|
@@ -42,6 +54,9 @@ export class PlaywrightClient {
|
|
|
42
54
|
userAgent: this.config.stealthMode
|
|
43
55
|
? 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
44
56
|
: undefined,
|
|
57
|
+
// Ignore HTTPS errors when using proxy (required for residential proxies like BrightData
|
|
58
|
+
// which perform HTTPS inspection and may re-sign certificates)
|
|
59
|
+
ignoreHTTPSErrors: !!this.config.proxy,
|
|
45
60
|
});
|
|
46
61
|
this.page = await this.context.newPage();
|
|
47
62
|
// Apply timeout configuration to Playwright
|
|
@@ -124,14 +139,15 @@ export class PlaywrightClient {
|
|
|
124
139
|
return this.config;
|
|
125
140
|
}
|
|
126
141
|
}
|
|
127
|
-
export function createMCPServer() {
|
|
142
|
+
export function createMCPServer(proxyConfig) {
|
|
128
143
|
const stealthMode = process.env.STEALTH_MODE === 'true';
|
|
129
144
|
const server = new Server({
|
|
130
145
|
name: 'playwright-stealth-mcp-server',
|
|
131
|
-
version: '0.0.
|
|
146
|
+
version: '0.0.4',
|
|
132
147
|
}, {
|
|
133
148
|
capabilities: {
|
|
134
149
|
tools: {},
|
|
150
|
+
resources: {},
|
|
135
151
|
},
|
|
136
152
|
});
|
|
137
153
|
// Track active client for cleanup
|
|
@@ -148,11 +164,14 @@ export function createMCPServer() {
|
|
|
148
164
|
headless,
|
|
149
165
|
timeout,
|
|
150
166
|
navigationTimeout,
|
|
167
|
+
proxy: proxyConfig,
|
|
151
168
|
});
|
|
152
169
|
return activeClient;
|
|
153
170
|
});
|
|
154
171
|
const registerTools = createRegisterTools(factory);
|
|
155
172
|
registerTools(server);
|
|
173
|
+
// Register resources handlers for screenshot storage
|
|
174
|
+
registerResources(server);
|
|
156
175
|
};
|
|
157
176
|
const cleanup = async () => {
|
|
158
177
|
if (activeClient) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { FileSystemScreenshotStorage } from './filesystem.js';
|
|
2
|
+
export class ScreenshotStorageFactory {
|
|
3
|
+
static instance = null;
|
|
4
|
+
static async create() {
|
|
5
|
+
if (this.instance) {
|
|
6
|
+
return this.instance;
|
|
7
|
+
}
|
|
8
|
+
const rootDir = process.env.SCREENSHOT_STORAGE_PATH;
|
|
9
|
+
const fsStorage = new FileSystemScreenshotStorage(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 { ScreenshotStorage, ScreenshotResourceData, ScreenshotResourceContent, ScreenshotMetadata } from './types.js';
|
|
2
|
+
export declare class FileSystemScreenshotStorage implements ScreenshotStorage {
|
|
3
|
+
private rootDir;
|
|
4
|
+
private initialized;
|
|
5
|
+
constructor(rootDir?: string);
|
|
6
|
+
init(): Promise<void>;
|
|
7
|
+
list(): Promise<ScreenshotResourceData[]>;
|
|
8
|
+
read(uri: string): Promise<ScreenshotResourceContent>;
|
|
9
|
+
write(base64Data: string, metadata: Omit<ScreenshotMetadata, '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=filesystem.d.ts.map
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
export class FileSystemScreenshotStorage {
|
|
5
|
+
rootDir;
|
|
6
|
+
initialized = false;
|
|
7
|
+
constructor(rootDir) {
|
|
8
|
+
this.rootDir = rootDir || path.join(os.tmpdir(), 'playwright-screenshots');
|
|
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('.png')) {
|
|
24
|
+
const metadataFile = file.replace('.png', '.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
|
+
? `Screenshot of ${metadata.pageUrl}`
|
|
36
|
+
: `Screenshot taken at ${metadata.timestamp}`,
|
|
37
|
+
mimeType: 'image/png',
|
|
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: 'image/png',
|
|
68
|
+
blob,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
async write(base64Data, metadata) {
|
|
72
|
+
await this.init();
|
|
73
|
+
const timestamp = new Date().toISOString();
|
|
74
|
+
const fileName = this.generateFileName(timestamp);
|
|
75
|
+
const filePath = path.join(this.rootDir, fileName);
|
|
76
|
+
const metadataPath = path.join(this.rootDir, fileName.replace('.png', '.json'));
|
|
77
|
+
const fullMetadata = {
|
|
78
|
+
...metadata,
|
|
79
|
+
timestamp,
|
|
80
|
+
};
|
|
81
|
+
// Write the screenshot image
|
|
82
|
+
const buffer = Buffer.from(base64Data, 'base64');
|
|
83
|
+
await fs.writeFile(filePath, buffer);
|
|
84
|
+
// Write the metadata
|
|
85
|
+
await fs.writeFile(metadataPath, JSON.stringify(fullMetadata, null, 2), 'utf-8');
|
|
86
|
+
return `file://${filePath}`;
|
|
87
|
+
}
|
|
88
|
+
async exists(uri) {
|
|
89
|
+
const filePath = this.uriToFilePath(uri);
|
|
90
|
+
return this.fileExists(filePath);
|
|
91
|
+
}
|
|
92
|
+
async delete(uri) {
|
|
93
|
+
const filePath = this.uriToFilePath(uri);
|
|
94
|
+
if (!(await this.fileExists(filePath))) {
|
|
95
|
+
throw new Error(`Resource not found: ${uri}`);
|
|
96
|
+
}
|
|
97
|
+
await fs.unlink(filePath);
|
|
98
|
+
// Also delete metadata file if it exists
|
|
99
|
+
const metadataPath = filePath.replace('.png', '.json');
|
|
100
|
+
try {
|
|
101
|
+
await fs.unlink(metadataPath);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Ignore if metadata file doesn't exist
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async fileExists(filePath) {
|
|
108
|
+
try {
|
|
109
|
+
await fs.access(filePath);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
uriToFilePath(uri) {
|
|
117
|
+
if (uri.startsWith('file://')) {
|
|
118
|
+
return uri.substring(7);
|
|
119
|
+
}
|
|
120
|
+
throw new Error(`Invalid file URI: ${uri}`);
|
|
121
|
+
}
|
|
122
|
+
generateFileName(timestamp) {
|
|
123
|
+
const timestampPart = timestamp.replace(/[^0-9T-]/g, '').replace('T', '_');
|
|
124
|
+
return `screenshot_${timestampPart}.png`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for screenshot resource storage
|
|
3
|
+
*/
|
|
4
|
+
export interface ScreenshotMetadata {
|
|
5
|
+
timestamp: string;
|
|
6
|
+
pageUrl?: string;
|
|
7
|
+
pageTitle?: string;
|
|
8
|
+
fullPage: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface ScreenshotResourceData {
|
|
11
|
+
uri: string;
|
|
12
|
+
name: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
mimeType: string;
|
|
15
|
+
metadata: ScreenshotMetadata;
|
|
16
|
+
}
|
|
17
|
+
export interface ScreenshotResourceContent {
|
|
18
|
+
uri: string;
|
|
19
|
+
mimeType: string;
|
|
20
|
+
blob: string;
|
|
21
|
+
}
|
|
22
|
+
export interface ScreenshotStorage {
|
|
23
|
+
list(): Promise<ScreenshotResourceData[]>;
|
|
24
|
+
read(uri: string): Promise<ScreenshotResourceContent>;
|
|
25
|
+
write(base64Data: string, metadata: Omit<ScreenshotMetadata, 'timestamp'>): Promise<string>;
|
|
26
|
+
exists(uri: string): Promise<boolean>;
|
|
27
|
+
delete(uri: string): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=types.d.ts.map
|
package/shared/tools.js
CHANGED
|
@@ -1,5 +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
4
|
// =============================================================================
|
|
4
5
|
// TOOL SCHEMAS
|
|
5
6
|
// =============================================================================
|
|
@@ -9,6 +10,10 @@ const ExecuteSchema = z.object({
|
|
|
9
10
|
});
|
|
10
11
|
const ScreenshotSchema = z.object({
|
|
11
12
|
fullPage: z.boolean().optional().describe('Capture the full scrollable page. Default: false'),
|
|
13
|
+
resultHandling: z
|
|
14
|
+
.enum(['saveAndReturn', 'saveOnly'])
|
|
15
|
+
.optional()
|
|
16
|
+
.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"),
|
|
12
17
|
});
|
|
13
18
|
// =============================================================================
|
|
14
19
|
// TOOL DESCRIPTIONS
|
|
@@ -56,15 +61,23 @@ await page.click('button[type="submit"]');
|
|
|
56
61
|
**Note:** When STEALTH_MODE=true, the browser includes anti-detection measures to help bypass bot protection.`;
|
|
57
62
|
const SCREENSHOT_DESCRIPTION = `Take a screenshot of the current page.
|
|
58
63
|
|
|
59
|
-
Captures the visible viewport or full page as a PNG image
|
|
64
|
+
Captures the visible viewport or full page as a PNG image. Screenshots are saved to filesystem storage and can be accessed later via MCP resources.
|
|
65
|
+
|
|
66
|
+
**Parameters:**
|
|
67
|
+
- \`fullPage\`: Whether to capture the full scrollable page (default: false)
|
|
68
|
+
- \`resultHandling\`: How to handle the result:
|
|
69
|
+
- \`saveAndReturn\` (default): Saves to storage AND returns inline base64 image
|
|
70
|
+
- \`saveOnly\`: Saves to storage and returns only the resource URI (more efficient for large screenshots)
|
|
60
71
|
|
|
61
72
|
**Returns:**
|
|
62
|
-
-
|
|
73
|
+
- With \`saveAndReturn\`: Inline base64 PNG image data plus a resource_link to the saved file
|
|
74
|
+
- With \`saveOnly\`: A resource_link with the \`file://\` URI to the saved screenshot
|
|
63
75
|
|
|
64
76
|
**Use cases:**
|
|
65
77
|
- Verify page state after navigation
|
|
66
78
|
- Debug automation issues
|
|
67
|
-
- Capture visual content for analysis
|
|
79
|
+
- Capture visual content for analysis
|
|
80
|
+
- Store screenshots for later reference via MCP resources`;
|
|
68
81
|
const GET_STATE_DESCRIPTION = `Get the current browser state.
|
|
69
82
|
|
|
70
83
|
Returns information about the current browser session including the URL, page title, and whether a browser is open.
|
|
@@ -161,14 +174,47 @@ export function createRegisterTools(clientFactory) {
|
|
|
161
174
|
type: 'boolean',
|
|
162
175
|
description: 'Capture the full scrollable page. Default: false',
|
|
163
176
|
},
|
|
177
|
+
resultHandling: {
|
|
178
|
+
type: 'string',
|
|
179
|
+
enum: ['saveAndReturn', 'saveOnly'],
|
|
180
|
+
description: "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",
|
|
181
|
+
},
|
|
164
182
|
},
|
|
165
183
|
},
|
|
166
184
|
handler: async (args) => {
|
|
167
185
|
try {
|
|
168
186
|
const validated = ScreenshotSchema.parse(args);
|
|
169
|
-
const
|
|
187
|
+
const client = getClient();
|
|
188
|
+
const base64 = await client.screenshot({
|
|
170
189
|
fullPage: validated.fullPage,
|
|
171
190
|
});
|
|
191
|
+
// Get page metadata for the screenshot
|
|
192
|
+
const state = await client.getState();
|
|
193
|
+
// Save to storage
|
|
194
|
+
const storage = await ScreenshotStorageFactory.create();
|
|
195
|
+
const uri = await storage.write(base64, {
|
|
196
|
+
pageUrl: state.currentUrl,
|
|
197
|
+
pageTitle: state.title,
|
|
198
|
+
fullPage: validated.fullPage ?? false,
|
|
199
|
+
});
|
|
200
|
+
const resultHandling = validated.resultHandling ?? 'saveAndReturn';
|
|
201
|
+
// Generate a name from the URI for the resource link
|
|
202
|
+
const fileName = uri.split('/').pop() || 'screenshot.png';
|
|
203
|
+
if (resultHandling === 'saveOnly') {
|
|
204
|
+
// Return only the resource link
|
|
205
|
+
return {
|
|
206
|
+
content: [
|
|
207
|
+
{
|
|
208
|
+
type: 'resource_link',
|
|
209
|
+
uri,
|
|
210
|
+
name: fileName,
|
|
211
|
+
description: `Screenshot saved to ${uri}`,
|
|
212
|
+
mimeType: 'image/png',
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
// Default: saveAndReturn - return both inline image and resource link
|
|
172
218
|
return {
|
|
173
219
|
content: [
|
|
174
220
|
{
|
|
@@ -176,6 +222,13 @@ export function createRegisterTools(clientFactory) {
|
|
|
176
222
|
data: base64,
|
|
177
223
|
mimeType: 'image/png',
|
|
178
224
|
},
|
|
225
|
+
{
|
|
226
|
+
type: 'resource_link',
|
|
227
|
+
uri,
|
|
228
|
+
name: fileName,
|
|
229
|
+
description: `Screenshot also saved to ${uri}`,
|
|
230
|
+
mimeType: 'image/png',
|
|
231
|
+
},
|
|
179
232
|
],
|
|
180
233
|
};
|
|
181
234
|
}
|
|
@@ -211,6 +264,7 @@ export function createRegisterTools(clientFactory) {
|
|
|
211
264
|
...state,
|
|
212
265
|
stealthMode: config.stealthMode,
|
|
213
266
|
headless: config.headless,
|
|
267
|
+
proxyEnabled: !!config.proxy,
|
|
214
268
|
}, null, 2),
|
|
215
269
|
},
|
|
216
270
|
],
|
package/shared/types.d.ts
CHANGED
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Types for Playwright Stealth MCP server
|
|
3
3
|
*/
|
|
4
|
+
/**
|
|
5
|
+
* Proxy configuration for browser connections
|
|
6
|
+
* Compatible with BrightData Residential Proxies and other HTTP/HTTPS proxies
|
|
7
|
+
*/
|
|
8
|
+
export interface ProxyConfig {
|
|
9
|
+
/** Proxy server URL (e.g., "http://proxy.example.com:8080") */
|
|
10
|
+
server: string;
|
|
11
|
+
/** Optional username for proxy authentication */
|
|
12
|
+
username?: string;
|
|
13
|
+
/** Optional password for proxy authentication */
|
|
14
|
+
password?: string;
|
|
15
|
+
/** Optional comma-separated list of hosts to bypass proxy */
|
|
16
|
+
bypass?: string;
|
|
17
|
+
}
|
|
4
18
|
export interface PlaywrightConfig {
|
|
5
19
|
stealthMode: boolean;
|
|
6
20
|
headless: boolean;
|
|
7
21
|
timeout: number;
|
|
8
22
|
navigationTimeout: number;
|
|
23
|
+
/** Optional proxy configuration */
|
|
24
|
+
proxy?: ProxyConfig;
|
|
9
25
|
}
|
|
10
26
|
export interface ExecuteResult {
|
|
11
27
|
success: boolean;
|
|
@@ -17,5 +33,11 @@ export interface BrowserState {
|
|
|
17
33
|
currentUrl?: string;
|
|
18
34
|
title?: string;
|
|
19
35
|
isOpen: boolean;
|
|
36
|
+
/** Whether stealth mode is enabled */
|
|
37
|
+
stealthMode?: boolean;
|
|
38
|
+
/** Whether browser is running in headless mode */
|
|
39
|
+
headless?: boolean;
|
|
40
|
+
/** Whether proxy is enabled for browser connections */
|
|
41
|
+
proxyEnabled?: boolean;
|
|
20
42
|
}
|
|
21
43
|
//# sourceMappingURL=types.d.ts.map
|