feat: implement centralized artifact storage with session isolation

Add comprehensive artifact storage system with session-specific directories:

- Add --artifact-dir CLI option and PLAYWRIGHT_MCP_ARTIFACT_DIR env var
- Create ArtifactManager class for session-specific artifact organization
- Implement ArtifactManagerRegistry for multi-session support
- Add tool call logging with JSON persistence in tool-calls.json
- Update screenshot, video, and PDF tools to use centralized storage
- Add browser_configure_artifacts tool for per-session control
- Support dynamic enable/disable without server restart
- Maintain backward compatibility when artifact storage not configured

Directory structure: {artifactDir}/{sessionId}/[artifacts, videos/, tool-calls.json]

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ryan Malloy 2025-08-15 06:42:16 -06:00
parent ecedcc48d6
commit d8202f6694
12 changed files with 727 additions and 93 deletions

160
README.md
View File

@ -144,6 +144,8 @@ Playwright MCP server supports following arguments. They can be provided in the
> npx @playwright/mcp@latest --help > npx @playwright/mcp@latest --help
--allowed-origins <origins> semicolon-separated list of origins to allow the --allowed-origins <origins> semicolon-separated list of origins to allow the
browser to request. Default is to allow all. browser to request. Default is to allow all.
--artifact-dir <path> path to the directory for centralized artifact
storage with session-specific subdirectories.
--blocked-origins <origins> semicolon-separated list of origins to block the --blocked-origins <origins> semicolon-separated list of origins to block the
browser from requesting. Blocklist is evaluated browser from requesting. Blocklist is evaluated
before allowlist. If used without the allowlist, before allowlist. If used without the allowlist,
@ -296,6 +298,9 @@ npx @playwright/mcp@latest --config path/to/config.json
// Directory for output files // Directory for output files
outputDir?: string; outputDir?: string;
// Directory for centralized artifact storage with session-specific subdirectories
artifactDir?: string;
// Network configuration // Network configuration
network?: { network?: {
// List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked. // List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.
@ -314,6 +319,125 @@ npx @playwright/mcp@latest --config path/to/config.json
``` ```
</details> </details>
### Centralized Artifact Storage
The Playwright MCP server supports centralized artifact storage for organizing all generated files (screenshots, videos, and PDFs) in session-specific directories with comprehensive logging.
#### Configuration
**Command Line Option:**
```bash
npx @playwright/mcp@latest --artifact-dir /path/to/artifacts
```
**Environment Variable:**
```bash
export PLAYWRIGHT_MCP_ARTIFACT_DIR="/path/to/artifacts"
npx @playwright/mcp@latest
```
**MCP Client Configuration:**
```js
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest",
"--artifact-dir",
"./browser-artifacts"
]
}
}
}
```
#### Features
When artifact storage is enabled, the server provides:
- **Session Isolation**: Each MCP session gets its own subdirectory
- **Organized Storage**: All artifacts saved to `{artifact-dir}/{session-id}/`
- **Tool Call Logging**: Complete audit trail in `tool-calls.json`
- **Automatic Organization**: Videos saved to `videos/` subdirectory
#### Directory Structure
```
browser-artifacts/
└── mcp-session-abc123/
├── tool-calls.json # Complete log of all tool calls
├── page-2024-01-15T10-30-00.png # Screenshots
├── document.pdf # Generated PDFs
└── videos/
└── session-recording.webm # Video recordings
```
#### Tool Call Log Format
The `tool-calls.json` file contains detailed information about each operation:
```json
[
{
"timestamp": "2024-01-15T10:30:00.000Z",
"toolName": "browser_take_screenshot",
"parameters": {
"filename": "login-page.png"
},
"result": "success",
"artifactPath": "login-page.png"
},
{
"timestamp": "2024-01-15T10:31:15.000Z",
"toolName": "browser_start_recording",
"parameters": {
"filename": "user-journey"
},
"result": "success"
}
]
```
#### Per-Session Control
You can dynamically enable, disable, or configure artifact storage during a session using the `browser_configure_artifacts` tool:
**Check Current Status:**
```
browser_configure_artifacts
```
**Enable Artifact Storage:**
```json
{
"enabled": true,
"directory": "./my-artifacts"
}
```
**Disable Artifact Storage:**
```json
{
"enabled": false
}
```
**Custom Session ID:**
```json
{
"enabled": true,
"sessionId": "my-custom-session"
}
```
#### Compatibility
- **Backward Compatible**: When `--artifact-dir` is not specified, all tools work exactly as before
- **Dynamic Control**: Artifact storage can be enabled/disabled per session without server restart
- **Fallback Behavior**: If artifact storage fails, tools fall back to default output directory
- **No Breaking Changes**: Existing configurations continue to work unchanged
### Standalone MCP server ### Standalone MCP server
When running headed browser on system w/o display or from worker processes of the IDEs, When running headed browser on system w/o display or from worker processes of the IDEs,
@ -409,6 +533,34 @@ http.createServer(async (req, res) => {
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
- **browser_configure**
- Title: Configure browser settings
- Description: Change browser configuration settings like headless/headed mode, viewport size, user agent, device emulation, geolocation, locale, timezone, color scheme, or permissions for subsequent operations. This will close the current browser and restart it with new settings.
- Parameters:
- `headless` (boolean, optional): Whether to run the browser in headless mode
- `viewport` (object, optional): Browser viewport size
- `userAgent` (string, optional): User agent string for the browser
- `device` (string, optional): Device to emulate (e.g., "iPhone 13", "iPad", "Pixel 5"). Use browser_list_devices to see available devices.
- `geolocation` (object, optional): Set geolocation coordinates
- `locale` (string, optional): Browser locale (e.g., "en-US", "fr-FR", "ja-JP")
- `timezone` (string, optional): Timezone ID (e.g., "America/New_York", "Europe/London", "Asia/Tokyo")
- `colorScheme` (string, optional): Preferred color scheme
- `permissions` (array, optional): Permissions to grant (e.g., ["geolocation", "notifications", "camera", "microphone"])
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_configure_artifacts**
- Title: Configure artifact storage
- Description: Enable, disable, or configure centralized artifact storage for screenshots, videos, and PDFs during this session. Allows dynamic control over where artifacts are saved and how they are organized.
- Parameters:
- `enabled` (boolean, optional): Enable or disable centralized artifact storage for this session
- `directory` (string, optional): Directory path for artifact storage (if different from server default)
- `sessionId` (string, optional): Custom session ID for artifact organization (auto-generated if not provided)
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_console_messages** - **browser_console_messages**
- Title: Get console messages - Title: Get console messages
- Description: Returns all console messages - Description: Returns all console messages
@ -469,6 +621,14 @@ http.createServer(async (req, res) => {
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
- **browser_list_devices**
- Title: List available devices for emulation
- Description: Get a list of all available device emulation profiles including mobile phones, tablets, and desktop browsers. Each device includes viewport, user agent, and capabilities information.
- Parameters: None
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_navigate** - **browser_navigate**
- Title: Navigate to a URL - Title: Navigate to a URL
- Description: Navigate to a URL - Description: Navigate to a URL

6
config.d.ts vendored
View File

@ -100,6 +100,12 @@ export type Config = {
*/ */
outputDir?: string; outputDir?: string;
/**
* The directory to save all screenshots and videos with session-specific subdirectories.
* When set, all artifacts will be saved to {artifactDir}/{sessionId}/ with tool call logs.
*/
artifactDir?: string;
network?: { network?: {
/** /**
* List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked. * List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked.

256
src/artifactManager.ts Normal file
View File

@ -0,0 +1,256 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as fs from 'fs';
import * as path from 'path';
import debug from 'debug';
import { sanitizeForFilePath } from './tools/utils.js';
const artifactDebug = debug('pw:mcp:artifacts');
export interface ArtifactEntry {
timestamp: string;
toolName: string;
parameters: any;
result: 'success' | 'error';
artifactPath?: string;
error?: string;
}
/**
* Manages centralized artifact storage with session-specific directories and tool call logging
*/
export class ArtifactManager {
private _baseDir: string;
private _sessionId: string;
private _sessionDir: string;
private _logFile: string;
private _logEntries: ArtifactEntry[] = [];
constructor(baseDir: string, sessionId: string) {
this._baseDir = baseDir;
this._sessionId = sessionId;
this._sessionDir = path.join(baseDir, sanitizeForFilePath(sessionId));
this._logFile = path.join(this._sessionDir, 'tool-calls.json');
// Ensure session directory exists
this._ensureSessionDirectory();
// Load existing log if it exists
this._loadExistingLog();
artifactDebug(`artifact manager initialized for session ${sessionId} in ${this._sessionDir}`);
}
/**
* Get the session-specific directory for artifacts
*/
getSessionDir(): string {
return this._sessionDir;
}
/**
* Get a full path for an artifact file in the session directory
*/
getArtifactPath(filename: string): string {
return path.join(this._sessionDir, sanitizeForFilePath(filename));
}
/**
* Create a subdirectory within the session directory
*/
getSubdirectory(subdir: string): string {
const subdirPath = path.join(this._sessionDir, sanitizeForFilePath(subdir));
fs.mkdirSync(subdirPath, { recursive: true });
return subdirPath;
}
/**
* Log a tool call with optional artifact path
*/
logToolCall(toolName: string, parameters: any, result: 'success' | 'error', artifactPath?: string, error?: string): void {
const entry: ArtifactEntry = {
timestamp: new Date().toISOString(),
toolName,
parameters,
result,
artifactPath: artifactPath ? path.relative(this._sessionDir, artifactPath) : undefined,
error
};
this._logEntries.push(entry);
this._saveLog();
artifactDebug(`logged tool call: ${toolName} -> ${result} ${artifactPath ? `(${entry.artifactPath})` : ''}`);
}
/**
* Get all logged tool calls for this session
*/
getToolCallLog(): ArtifactEntry[] {
return [...this._logEntries];
}
/**
* Get statistics about this session's artifacts
*/
getSessionStats(): {
sessionId: string;
sessionDir: string;
toolCallCount: number;
successCount: number;
errorCount: number;
artifactCount: number;
directorySize: number;
} {
const successCount = this._logEntries.filter(e => e.result === 'success').length;
const errorCount = this._logEntries.filter(e => e.result === 'error').length;
const artifactCount = this._logEntries.filter(e => e.artifactPath).length;
return {
sessionId: this._sessionId,
sessionDir: this._sessionDir,
toolCallCount: this._logEntries.length,
successCount,
errorCount,
artifactCount,
directorySize: this._getDirectorySize(this._sessionDir)
};
}
private _ensureSessionDirectory(): void {
try {
fs.mkdirSync(this._sessionDir, { recursive: true });
} catch (error) {
throw new Error(`Failed to create session directory ${this._sessionDir}: ${error}`);
}
}
private _loadExistingLog(): void {
try {
if (fs.existsSync(this._logFile)) {
const logData = fs.readFileSync(this._logFile, 'utf8');
this._logEntries = JSON.parse(logData);
artifactDebug(`loaded ${this._logEntries.length} existing log entries`);
}
} catch (error) {
artifactDebug(`failed to load existing log: ${error}`);
this._logEntries = [];
}
}
private _saveLog(): void {
try {
fs.writeFileSync(this._logFile, JSON.stringify(this._logEntries, null, 2));
} catch (error) {
artifactDebug(`failed to save log: ${error}`);
}
}
private _getDirectorySize(dirPath: string): number {
let size = 0;
try {
const files = fs.readdirSync(dirPath);
for (const file of files) {
const filePath = path.join(dirPath, file);
const stats = fs.statSync(filePath);
if (stats.isDirectory())
size += this._getDirectorySize(filePath);
else
size += stats.size;
}
} catch (error) {
// Ignore errors
}
return size;
}
}
/**
* Global artifact manager instances keyed by session ID
*/
export class ArtifactManagerRegistry {
private static _instance: ArtifactManagerRegistry;
private _managers: Map<string, ArtifactManager> = new Map();
private _baseDir: string | undefined;
static getInstance(): ArtifactManagerRegistry {
if (!ArtifactManagerRegistry._instance)
ArtifactManagerRegistry._instance = new ArtifactManagerRegistry();
return ArtifactManagerRegistry._instance;
}
/**
* Set the base directory for all artifact storage
*/
setBaseDir(baseDir: string): void {
this._baseDir = baseDir;
artifactDebug(`artifact registry base directory set to: ${baseDir}`);
}
/**
* Get or create an artifact manager for a session
*/
getManager(sessionId: string): ArtifactManager | undefined {
if (!this._baseDir)
return undefined; // Artifact storage not configured
let manager = this._managers.get(sessionId);
if (!manager) {
manager = new ArtifactManager(this._baseDir, sessionId);
this._managers.set(sessionId, manager);
}
return manager;
}
/**
* Remove a session's artifact manager
*/
removeManager(sessionId: string): void {
this._managers.delete(sessionId);
}
/**
* Get all active session managers
*/
getAllManagers(): Map<string, ArtifactManager> {
return new Map(this._managers);
}
/**
* Get summary statistics across all sessions
*/
getGlobalStats(): {
baseDir: string | undefined;
activeSessions: number;
totalToolCalls: number;
totalArtifacts: number;
} {
const managers = Array.from(this._managers.values());
const totalToolCalls = managers.reduce((sum, m) => sum + m.getSessionStats().toolCallCount, 0);
const totalArtifacts = managers.reduce((sum, m) => sum + m.getSessionStats().artifactCount, 0);
return {
baseDir: this._baseDir,
activeSessions: this._managers.size,
totalToolCalls,
totalArtifacts
};
}
}

View File

@ -23,6 +23,7 @@ import { filteredTools } from './tools.js';
import { packageJSON } from './package.js'; import { packageJSON } from './package.js';
import { SessionManager } from './sessionManager.js'; import { SessionManager } from './sessionManager.js';
import { EnvironmentIntrospector } from './environmentIntrospection.js'; import { EnvironmentIntrospector } from './environmentIntrospection.js';
import { ArtifactManagerRegistry } from './artifactManager.js';
import type { BrowserContextFactory } from './browserContextFactory.js'; import type { BrowserContextFactory } from './browserContextFactory.js';
import type * as mcpServer from './mcp/server.js'; import type * as mcpServer from './mcp/server.js';
@ -46,6 +47,12 @@ export class BrowserServerBackend implements ServerBackend {
this._browserContextFactory = browserContextFactory; this._browserContextFactory = browserContextFactory;
this._environmentIntrospector = new EnvironmentIntrospector(); this._environmentIntrospector = new EnvironmentIntrospector();
// Initialize artifact manager registry if artifact directory is configured
if (config.artifactDir) {
const registry = ArtifactManagerRegistry.getInstance();
registry.setBaseDir(config.artifactDir);
}
// Create a default context - will be replaced when session ID is set // Create a default context - will be replaced when session ID is set
this._context = new Context(this._tools, config, browserContextFactory, this._environmentIntrospector); this._context = new Context(this._tools, config, browserContextFactory, this._environmentIntrospector);
} }
@ -55,19 +62,19 @@ export class BrowserServerBackend implements ServerBackend {
} }
setSessionId(sessionId: string): void { setSessionId(sessionId: string): void {
if (this._sessionId === sessionId) { if (this._sessionId === sessionId)
return; // Already using this session return; // Already using this session
}
this._sessionId = sessionId; this._sessionId = sessionId;
// Get or create persistent context for this session // Get or create persistent context for this session
const sessionManager = SessionManager.getInstance(); const sessionManager = SessionManager.getInstance();
this._context = sessionManager.getOrCreateContext( this._context = sessionManager.getOrCreateContext(
sessionId, sessionId,
this._tools, this._tools,
this._config, this._config,
this._browserContextFactory this._browserContextFactory
); );
// Update environment introspector reference // Update environment introspector reference
@ -81,7 +88,43 @@ export class BrowserServerBackend implements ServerBackend {
async callTool(schema: mcpServer.ToolSchema<any>, parsedArguments: any) { async callTool(schema: mcpServer.ToolSchema<any>, parsedArguments: any) {
const response = new Response(this._context, schema.name, parsedArguments); const response = new Response(this._context, schema.name, parsedArguments);
const tool = this._tools.find(tool => tool.schema.name === schema.name)!; const tool = this._tools.find(tool => tool.schema.name === schema.name)!;
await tool.handle(this._context, parsedArguments, response);
let toolResult: 'success' | 'error' = 'success';
let errorMessage: string | undefined;
let artifactPath: string | undefined;
try {
await tool.handle(this._context, parsedArguments, response);
// Check if this tool created any artifacts
const serialized = await response.serialize();
if (serialized.content) {
// Look for file paths in the response
for (const content of serialized.content) {
if (content.type === 'text' && content.text) {
// Simple heuristic to find file paths
const pathMatches = content.text.match(/(?:saved to|created at|file:|path:)\s*([^\s\n]+\.(png|jpg|jpeg|webm|mp4|pdf))/gi);
if (pathMatches) {
artifactPath = pathMatches[0].split(/\s+/).pop();
break;
}
}
}
}
} catch (error) {
toolResult = 'error';
errorMessage = String(error);
}
// Log the tool call if artifact manager is available
if (this._sessionId) {
const registry = ArtifactManagerRegistry.getInstance();
const artifactManager = registry.getManager(this._sessionId);
if (artifactManager)
artifactManager.logToolCall(schema.name, parsedArguments, toolResult, artifactPath, errorMessage);
}
if (this._sessionLog) if (this._sessionLog)
await this._sessionLog.log(response); await this._sessionLog.log(response);
return await response.serialize(); return await response.serialize();

View File

@ -24,6 +24,7 @@ import type { BrowserContextOptions, LaunchOptions } from 'playwright';
export type CLIOptions = { export type CLIOptions = {
allowedOrigins?: string[]; allowedOrigins?: string[];
artifactDir?: string;
blockedOrigins?: string[]; blockedOrigins?: string[];
blockServiceWorkers?: boolean; blockServiceWorkers?: boolean;
browser?: string; browser?: string;
@ -81,6 +82,7 @@ export type FullConfig = Config & {
}, },
network: NonNullable<Config['network']>, network: NonNullable<Config['network']>,
outputDir: string; outputDir: string;
artifactDir?: string;
server: NonNullable<Config['server']>, server: NonNullable<Config['server']>,
}; };
@ -131,9 +133,9 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
channel, channel,
executablePath: cliOptions.executablePath, executablePath: cliOptions.executablePath,
}; };
if (cliOptions.headless !== undefined) { if (cliOptions.headless !== undefined)
launchOptions.headless = cliOptions.headless; launchOptions.headless = cliOptions.headless;
}
// --no-sandbox was passed, disable the sandbox // --no-sandbox was passed, disable the sandbox
if (cliOptions.sandbox === false) if (cliOptions.sandbox === false)
@ -196,6 +198,7 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
saveSession: cliOptions.saveSession, saveSession: cliOptions.saveSession,
saveTrace: cliOptions.saveTrace, saveTrace: cliOptions.saveTrace,
outputDir: cliOptions.outputDir, outputDir: cliOptions.outputDir,
artifactDir: cliOptions.artifactDir,
imageResponses: cliOptions.imageResponses, imageResponses: cliOptions.imageResponses,
}; };
@ -205,6 +208,7 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
function configFromEnv(): Config { function configFromEnv(): Config {
const options: CLIOptions = {}; const options: CLIOptions = {};
options.allowedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_ALLOWED_ORIGINS); options.allowedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_ALLOWED_ORIGINS);
options.artifactDir = envToString(process.env.PLAYWRIGHT_MCP_ARTIFACT_DIR);
options.blockedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_BLOCKED_ORIGINS); options.blockedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_BLOCKED_ORIGINS);
options.blockServiceWorkers = envToBoolean(process.env.PLAYWRIGHT_MCP_BLOCK_SERVICE_WORKERS); options.blockServiceWorkers = envToBoolean(process.env.PLAYWRIGHT_MCP_BLOCK_SERVICE_WORKERS);
options.browser = envToString(process.env.PLAYWRIGHT_MCP_BROWSER); options.browser = envToString(process.env.PLAYWRIGHT_MCP_BROWSER);

View File

@ -88,6 +88,12 @@ export class Context {
} }
} }
updateSessionId(customSessionId: string) {
testDebug(`updating sessionId from ${this.sessionId} to ${customSessionId}`);
// Note: sessionId is readonly, but we can update it for artifact management
(this as any).sessionId = customSessionId;
}
tabs(): Tab[] { tabs(): Tab[] {
return this._tabs; return this._tabs;
} }
@ -352,15 +358,15 @@ export class Context {
const currentConfig = { ...this.config }; const currentConfig = { ...this.config };
// Update the configuration // Update the configuration
if (changes.headless !== undefined) { if (changes.headless !== undefined)
currentConfig.browser.launchOptions.headless = changes.headless; currentConfig.browser.launchOptions.headless = changes.headless;
}
// Handle device emulation - this overrides individual viewport/userAgent settings // Handle device emulation - this overrides individual viewport/userAgent settings
if (changes.device) { if (changes.device) {
if (!devices[changes.device]) { if (!devices[changes.device])
throw new Error(`Unknown device: ${changes.device}`); throw new Error(`Unknown device: ${changes.device}`);
}
const deviceConfig = devices[changes.device]; const deviceConfig = devices[changes.device];
// Apply all device properties to context options // Apply all device properties to context options
@ -370,12 +376,12 @@ export class Context {
}; };
} else { } else {
// Apply individual settings only if no device is specified // Apply individual settings only if no device is specified
if (changes.viewport) { if (changes.viewport)
currentConfig.browser.contextOptions.viewport = changes.viewport; currentConfig.browser.contextOptions.viewport = changes.viewport;
}
if (changes.userAgent) { if (changes.userAgent)
currentConfig.browser.contextOptions.userAgent = changes.userAgent; currentConfig.browser.contextOptions.userAgent = changes.userAgent;
}
} }
// Apply additional context options // Apply additional context options
@ -387,21 +393,21 @@ export class Context {
}; };
} }
if (changes.locale) { if (changes.locale)
currentConfig.browser.contextOptions.locale = changes.locale; currentConfig.browser.contextOptions.locale = changes.locale;
}
if (changes.timezone) {
if (changes.timezone)
currentConfig.browser.contextOptions.timezoneId = changes.timezone; currentConfig.browser.contextOptions.timezoneId = changes.timezone;
}
if (changes.colorScheme) {
if (changes.colorScheme)
currentConfig.browser.contextOptions.colorScheme = changes.colorScheme; currentConfig.browser.contextOptions.colorScheme = changes.colorScheme;
}
if (changes.permissions) {
if (changes.permissions)
currentConfig.browser.contextOptions.permissions = changes.permissions; currentConfig.browser.contextOptions.permissions = changes.permissions;
}
// Store the modified config // Store the modified config
(this as any).config = currentConfig; (this as any).config = currentConfig;

View File

@ -31,6 +31,7 @@ program
.version('Version ' + packageJSON.version) .version('Version ' + packageJSON.version)
.name(packageJSON.name) .name(packageJSON.name)
.option('--allowed-origins <origins>', 'semicolon-separated list of origins to allow the browser to request. Default is to allow all.', semicolonSeparatedList) .option('--allowed-origins <origins>', 'semicolon-separated list of origins to allow the browser to request. Default is to allow all.', semicolonSeparatedList)
.option('--artifact-dir <path>', 'path to the directory for centralized artifact storage with session-specific subdirectories.')
.option('--blocked-origins <origins>', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList) .option('--blocked-origins <origins>', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList)
.option('--block-service-workers', 'block service workers') .option('--block-service-workers', 'block service workers')
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.') .option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
@ -104,9 +105,9 @@ function setupExitWatchdog(serverConfig: { host?: string; port?: number }) {
process.exit(0); process.exit(0);
}; };
if (serverConfig.port !== undefined) { if (serverConfig.port !== undefined)
process.stdin.on('close', handleExit); process.stdin.on('close', handleExit);
}
process.on('SIGINT', handleExit); process.on('SIGINT', handleExit);
process.on('SIGTERM', handleExit); process.on('SIGTERM', handleExit);

View File

@ -31,9 +31,9 @@ export class SessionManager {
private _sessions: Map<string, Context> = new Map(); private _sessions: Map<string, Context> = new Map();
static getInstance(): SessionManager { static getInstance(): SessionManager {
if (!SessionManager._instance) { if (!SessionManager._instance)
SessionManager._instance = new SessionManager(); SessionManager._instance = new SessionManager();
}
return SessionManager._instance; return SessionManager._instance;
} }

View File

@ -17,6 +17,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { devices } from 'playwright'; import { devices } from 'playwright';
import { defineTool } from './tool.js'; import { defineTool } from './tool.js';
import { ArtifactManagerRegistry } from '../artifactManager.js';
import type { Context } from '../context.js'; import type { Context } from '../context.js';
import type { Response } from '../response.js'; import type { Response } from '../response.js';
@ -41,6 +42,12 @@ const configureSchema = z.object({
const listDevicesSchema = z.object({}); const listDevicesSchema = z.object({});
const configureArtifactsSchema = z.object({
enabled: z.boolean().optional().describe('Enable or disable centralized artifact storage for this session'),
directory: z.string().optional().describe('Directory path for artifact storage (if different from server default)'),
sessionId: z.string().optional().describe('Custom session ID for artifact organization (auto-generated if not provided)')
});
export default [ export default [
defineTool({ defineTool({
capability: 'core', capability: 'core',
@ -111,51 +118,51 @@ export default [
// Track what's changing // Track what's changing
if (params.headless !== undefined) { if (params.headless !== undefined) {
const currentHeadless = currentConfig.browser.launchOptions.headless; const currentHeadless = currentConfig.browser.launchOptions.headless;
if (params.headless !== currentHeadless) { if (params.headless !== currentHeadless)
changes.push(`headless: ${currentHeadless}${params.headless}`); changes.push(`headless: ${currentHeadless}${params.headless}`);
}
} }
if (params.viewport) { if (params.viewport) {
const currentViewport = currentConfig.browser.contextOptions.viewport; const currentViewport = currentConfig.browser.contextOptions.viewport;
if (!currentViewport || currentViewport.width !== params.viewport.width || currentViewport.height !== params.viewport.height) { if (!currentViewport || currentViewport.width !== params.viewport.width || currentViewport.height !== params.viewport.height)
changes.push(`viewport: ${currentViewport?.width || 'default'}x${currentViewport?.height || 'default'}${params.viewport.width}x${params.viewport.height}`); changes.push(`viewport: ${currentViewport?.width || 'default'}x${currentViewport?.height || 'default'}${params.viewport.width}x${params.viewport.height}`);
}
} }
if (params.userAgent) { if (params.userAgent) {
const currentUA = currentConfig.browser.contextOptions.userAgent; const currentUA = currentConfig.browser.contextOptions.userAgent;
if (params.userAgent !== currentUA) { if (params.userAgent !== currentUA)
changes.push(`userAgent: ${currentUA || 'default'}${params.userAgent}`); changes.push(`userAgent: ${currentUA || 'default'}${params.userAgent}`);
}
} }
if (params.device) { if (params.device) {
if (!devices[params.device]) { if (!devices[params.device])
throw new Error(`Unknown device: ${params.device}. Use browser_list_devices to see available devices.`); throw new Error(`Unknown device: ${params.device}. Use browser_list_devices to see available devices.`);
}
changes.push(`device: emulating ${params.device}`); changes.push(`device: emulating ${params.device}`);
} }
if (params.geolocation) { if (params.geolocation)
changes.push(`geolocation: ${params.geolocation.latitude}, ${params.geolocation.longitude}${params.geolocation.accuracy || 100}m)`); changes.push(`geolocation: ${params.geolocation.latitude}, ${params.geolocation.longitude}${params.geolocation.accuracy || 100}m)`);
}
if (params.locale) {
if (params.locale)
changes.push(`locale: ${params.locale}`); changes.push(`locale: ${params.locale}`);
}
if (params.timezone) {
if (params.timezone)
changes.push(`timezone: ${params.timezone}`); changes.push(`timezone: ${params.timezone}`);
}
if (params.colorScheme) {
if (params.colorScheme)
changes.push(`colorScheme: ${params.colorScheme}`); changes.push(`colorScheme: ${params.colorScheme}`);
}
if (params.permissions && params.permissions.length > 0) {
if (params.permissions && params.permissions.length > 0)
changes.push(`permissions: ${params.permissions.join(', ')}`); changes.push(`permissions: ${params.permissions.join(', ')}`);
}
if (changes.length === 0) { if (changes.length === 0) {
response.addResult('No configuration changes detected. Current settings remain the same.'); response.addResult('No configuration changes detected. Current settings remain the same.');
@ -182,4 +189,119 @@ export default [
} }
}, },
}), }),
defineTool({
capability: 'core',
schema: {
name: 'browser_configure_artifacts',
title: 'Configure artifact storage',
description: 'Enable, disable, or configure centralized artifact storage for screenshots, videos, and PDFs during this session. Allows dynamic control over where artifacts are saved and how they are organized.',
inputSchema: configureArtifactsSchema,
type: 'destructive',
},
handle: async (context: Context, params: z.output<typeof configureArtifactsSchema>, response: Response) => {
try {
const registry = ArtifactManagerRegistry.getInstance();
const currentSessionId = context.sessionId;
const changes: string[] = [];
// Check current artifact storage status
const hasArtifactManager = currentSessionId && registry.getManager(currentSessionId);
const currentBaseDir = registry.getGlobalStats().baseDir;
if (params.enabled === false) {
// Disable artifact storage for this session
if (hasArtifactManager) {
if (currentSessionId)
registry.removeManager(currentSessionId);
// Clear the session ID from context when disabling
context.updateSessionId('');
changes.push('Disabled centralized artifact storage');
changes.push('Artifacts will now be saved to the default output directory');
} else {
response.addResult('Centralized artifact storage is already disabled for this session.');
return;
}
} else if (params.enabled === true || params.directory) {
// Enable or reconfigure artifact storage
const baseDir = params.directory || currentBaseDir;
if (!baseDir)
throw new Error('No artifact directory specified. Use the "directory" parameter or start the server with --artifact-dir.');
// Set or update the base directory if provided
if (params.directory && params.directory !== currentBaseDir) {
registry.setBaseDir(params.directory);
changes.push(`Updated artifact base directory: ${params.directory}`);
}
// Handle session ID
let sessionId = currentSessionId;
if (params.sessionId && params.sessionId !== currentSessionId) {
// Update session ID in context if provided and different
context.updateSessionId(params.sessionId);
sessionId = params.sessionId;
changes.push(`Updated session ID: ${sessionId}`);
} else if (!sessionId) {
// Generate a new session ID if none exists
sessionId = `mcp-session-${Date.now()}`;
context.updateSessionId(sessionId);
changes.push(`Generated session ID: ${sessionId}`);
}
// Get or create artifact manager for the session
const artifactManager = registry.getManager(sessionId);
if (artifactManager) {
changes.push(`Enabled centralized artifact storage`);
changes.push(`Session directory: ${artifactManager.getSessionDir()}`);
// Show current session stats
const stats = artifactManager.getSessionStats();
if (stats.toolCallCount > 0)
changes.push(`Current session stats: ${stats.toolCallCount} tool calls, ${stats.artifactCount} artifacts`);
} else {
throw new Error(`Failed to initialize artifact manager for session: ${sessionId}`);
}
} else {
// Show current status - re-check after potential changes
const currentManager = currentSessionId ? registry.getManager(currentSessionId) : undefined;
if (currentManager && currentSessionId) {
const stats = currentManager.getSessionStats();
response.addResult(
`✅ Centralized artifact storage is ENABLED\n\n` +
`Session ID: ${currentSessionId}\n` +
`Base directory: ${currentBaseDir}\n` +
`Session directory: ${currentManager.getSessionDir()}\n` +
`Tool calls logged: ${stats.toolCallCount}\n` +
`Artifacts saved: ${stats.artifactCount}\n` +
`Directory size: ${(stats.directorySize / 1024).toFixed(1)} KB\n\n` +
`Use browser_configure_artifacts with:\n` +
`• enabled: false - to disable artifact storage\n` +
`• directory: "/new/path" - to change base directory\n` +
`• sessionId: "custom-id" - to change session ID`
);
} else {
response.addResult(
`❌ Centralized artifact storage is DISABLED\n\n` +
`Artifacts are saved to the default output directory: ${context.config.outputDir}\n\n` +
`Use browser_configure_artifacts with:\n` +
`• enabled: true - to enable artifact storage\n` +
`• directory: "/path/to/artifacts" - to specify artifact directory`
);
}
return;
}
if (changes.length > 0)
response.addResult(`Artifact storage configuration updated:\n${changes.map(c => `${c}`).join('\n')}`);
} catch (error) {
throw new Error(`Failed to configure artifact storage: ${error}`);
}
},
}),
]; ];

View File

@ -19,6 +19,7 @@ import { defineTabTool } from './tool.js';
import * as javascript from '../javascript.js'; import * as javascript from '../javascript.js';
import { outputFile } from '../config.js'; import { outputFile } from '../config.js';
import { ArtifactManagerRegistry } from '../artifactManager.js';
const pdfSchema = z.object({ const pdfSchema = z.object({
filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'), filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'),
@ -36,7 +37,18 @@ const pdf = defineTabTool({
}, },
handle: async (tab, params, response) => { handle: async (tab, params, response) => {
const fileName = await outputFile(tab.context.config, params.filename ?? `page-${new Date().toISOString()}.pdf`); // Use centralized artifact storage if configured
let fileName: string;
const registry = ArtifactManagerRegistry.getInstance();
const artifactManager = tab.context.sessionId ? registry.getManager(tab.context.sessionId) : undefined;
if (artifactManager) {
const defaultName = params.filename ?? `page-${new Date().toISOString()}.pdf`;
fileName = artifactManager.getArtifactPath(defaultName);
} else {
fileName = await outputFile(tab.context.config, params.filename ?? `page-${new Date().toISOString()}.pdf`);
}
response.addCode(`// Save page as ${fileName}`); response.addCode(`// Save page as ${fileName}`);
response.addCode(`await page.pdf(${javascript.formatObject({ path: fileName })});`); response.addCode(`await page.pdf(${javascript.formatObject({ path: fileName })});`);
response.addResult(`Saved page as ${fileName}`); response.addResult(`Saved page as ${fileName}`);

View File

@ -20,6 +20,7 @@ import { defineTabTool } from './tool.js';
import * as javascript from '../javascript.js'; import * as javascript from '../javascript.js';
import { outputFile } from '../config.js'; import { outputFile } from '../config.js';
import { generateLocator } from './utils.js'; import { generateLocator } from './utils.js';
import { ArtifactManagerRegistry } from '../artifactManager.js';
import type * as playwright from 'playwright'; import type * as playwright from 'playwright';
@ -53,7 +54,19 @@ const screenshot = defineTabTool({
handle: async (tab, params, response) => { handle: async (tab, params, response) => {
const fileType = params.raw ? 'png' : 'jpeg'; const fileType = params.raw ? 'png' : 'jpeg';
const fileName = await outputFile(tab.context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
// Use centralized artifact storage if configured
let fileName: string;
const registry = ArtifactManagerRegistry.getInstance();
const artifactManager = tab.context.sessionId ? registry.getManager(tab.context.sessionId) : undefined;
if (artifactManager) {
const defaultName = params.filename ?? `page-${new Date().toISOString()}.${fileType}`;
fileName = artifactManager.getArtifactPath(defaultName);
} else {
fileName = await outputFile(tab.context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
}
const options: playwright.PageScreenshotOptions = { const options: playwright.PageScreenshotOptions = {
type: fileType, type: fileType,
quality: fileType === 'png' ? undefined : 50, quality: fileType === 'png' ? undefined : 50,

View File

@ -17,6 +17,7 @@
import path from 'path'; import path from 'path';
import { z } from 'zod'; import { z } from 'zod';
import { defineTool } from './tool.js'; import { defineTool } from './tool.js';
import { ArtifactManagerRegistry } from '../artifactManager.js';
const startRecording = defineTool({ const startRecording = defineTool({
capability: 'core', capability: 'core',
@ -38,7 +39,17 @@ const startRecording = defineTool({
handle: async (context, params, response) => { handle: async (context, params, response) => {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const baseFilename = params.filename || `session-${timestamp}`; const baseFilename = params.filename || `session-${timestamp}`;
const videoDir = path.join(context.config.outputDir, 'videos');
// Use centralized artifact storage if configured
let videoDir: string;
const registry = ArtifactManagerRegistry.getInstance();
const artifactManager = context.sessionId ? registry.getManager(context.sessionId) : undefined;
if (artifactManager)
videoDir = artifactManager.getSubdirectory('videos');
else
videoDir = path.join(context.config.outputDir, 'videos');
// Update context options to enable video recording // Update context options to enable video recording
const recordVideoOptions: any = { const recordVideoOptions: any = {