From d8202f66942daf00e87ed3cb61a658397df6b502 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Fri, 15 Aug 2025 06:42:16 -0600 Subject: [PATCH] feat: implement centralized artifact storage with session isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 160 ++++++++++++++++++++++ config.d.ts | 6 + src/artifactManager.ts | 256 ++++++++++++++++++++++++++++++++++++ src/browserServerBackend.ts | 73 +++++++--- src/config.ts | 8 +- src/context.ts | 56 ++++---- src/program.ts | 5 +- src/sessionManager.ts | 6 +- src/tools/configure.ts | 208 +++++++++++++++++++++++------ src/tools/pdf.ts | 14 +- src/tools/screenshot.ts | 15 ++- src/tools/video.ts | 13 +- 12 files changed, 727 insertions(+), 93 deletions(-) create mode 100644 src/artifactManager.ts diff --git a/README.md b/README.md index a5e1224..3ef2711 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,8 @@ Playwright MCP server supports following arguments. They can be provided in the > npx @playwright/mcp@latest --help --allowed-origins semicolon-separated list of origins to allow the browser to request. Default is to allow all. + --artifact-dir path to the directory for centralized artifact + storage with session-specific subdirectories. --blocked-origins semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, @@ -296,6 +298,9 @@ npx @playwright/mcp@latest --config path/to/config.json // Directory for output files outputDir?: string; + // Directory for centralized artifact storage with session-specific subdirectories + artifactDir?: string; + // Network configuration network?: { // 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 ``` +### 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 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) => { +- **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** + + + +- **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** + + + - **browser_console_messages** - Title: Get console messages - Description: Returns all console messages @@ -469,6 +621,14 @@ http.createServer(async (req, res) => { +- **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** + + + - **browser_navigate** - Title: Navigate to a URL - Description: Navigate to a URL diff --git a/config.d.ts b/config.d.ts index d63b061..7403cbd 100644 --- a/config.d.ts +++ b/config.d.ts @@ -100,6 +100,12 @@ export type Config = { */ 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?: { /** * List of origins to allow the browser to request. Default is to allow all. Origins matching both `allowedOrigins` and `blockedOrigins` will be blocked. diff --git a/src/artifactManager.ts b/src/artifactManager.ts new file mode 100644 index 0000000..fcfbdea --- /dev/null +++ b/src/artifactManager.ts @@ -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 = 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 { + 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 + }; + } +} diff --git a/src/browserServerBackend.ts b/src/browserServerBackend.ts index 5140d1d..b336281 100644 --- a/src/browserServerBackend.ts +++ b/src/browserServerBackend.ts @@ -23,6 +23,7 @@ import { filteredTools } from './tools.js'; import { packageJSON } from './package.js'; import { SessionManager } from './sessionManager.js'; import { EnvironmentIntrospector } from './environmentIntrospection.js'; +import { ArtifactManagerRegistry } from './artifactManager.js'; import type { BrowserContextFactory } from './browserContextFactory.js'; import type * as mcpServer from './mcp/server.js'; @@ -45,7 +46,13 @@ export class BrowserServerBackend implements ServerBackend { this._config = config; this._browserContextFactory = browserContextFactory; 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 this._context = new Context(this._tools, config, browserContextFactory, this._environmentIntrospector); } @@ -55,21 +62,21 @@ export class BrowserServerBackend implements ServerBackend { } setSessionId(sessionId: string): void { - if (this._sessionId === sessionId) { + if (this._sessionId === sessionId) return; // Already using this session - } + this._sessionId = sessionId; - + // Get or create persistent context for this session const sessionManager = SessionManager.getInstance(); this._context = sessionManager.getOrCreateContext( - sessionId, - this._tools, - this._config, - this._browserContextFactory + sessionId, + this._tools, + this._config, + this._browserContextFactory ); - + // Update environment introspector reference this._environmentIntrospector = this._context.getEnvironmentIntrospector(); } @@ -81,7 +88,43 @@ export class BrowserServerBackend implements ServerBackend { async callTool(schema: mcpServer.ToolSchema, parsedArguments: any) { const response = new Response(this._context, schema.name, parsedArguments); 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) await this._sessionLog.log(response); return await response.serialize(); @@ -114,21 +157,21 @@ export class BrowserServerBackend implements ServerBackend { // For now, we can't directly access the client's exposed roots // This would need MCP SDK enhancement to get the current roots list // Client roots changed - environment capabilities may have updated - + // In a full implementation, we would: // 1. Get the updated roots list from the MCP client - // 2. Update our environment introspector + // 2. Update our environment introspector // 3. Reconfigure browser contexts if needed - + // For demonstration, we'll simulate some common root updates // In practice, this would come from the MCP client - + // Example: Update context with hypothetical root changes // this._context.updateEnvironmentRoots([ // { uri: 'file:///tmp/.X11-unix', name: 'X11 Sockets' }, // { uri: 'file:///home/user/project', name: 'Project Directory' } // ]); - + // const summary = this._environmentIntrospector.getEnvironmentSummary(); // Current environment would be logged here if needed } diff --git a/src/config.ts b/src/config.ts index 1c8440b..f6ccd90 100644 --- a/src/config.ts +++ b/src/config.ts @@ -24,6 +24,7 @@ import type { BrowserContextOptions, LaunchOptions } from 'playwright'; export type CLIOptions = { allowedOrigins?: string[]; + artifactDir?: string; blockedOrigins?: string[]; blockServiceWorkers?: boolean; browser?: string; @@ -81,6 +82,7 @@ export type FullConfig = Config & { }, network: NonNullable, outputDir: string; + artifactDir?: string; server: NonNullable, }; @@ -131,9 +133,9 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config { channel, executablePath: cliOptions.executablePath, }; - if (cliOptions.headless !== undefined) { + if (cliOptions.headless !== undefined) launchOptions.headless = cliOptions.headless; - } + // --no-sandbox was passed, disable the sandbox if (cliOptions.sandbox === false) @@ -196,6 +198,7 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config { saveSession: cliOptions.saveSession, saveTrace: cliOptions.saveTrace, outputDir: cliOptions.outputDir, + artifactDir: cliOptions.artifactDir, imageResponses: cliOptions.imageResponses, }; @@ -205,6 +208,7 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config { function configFromEnv(): Config { const options: CLIOptions = {}; 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.blockServiceWorkers = envToBoolean(process.env.PLAYWRIGHT_MCP_BLOCK_SERVICE_WORKERS); options.browser = envToString(process.env.PLAYWRIGHT_MCP_BROWSER); diff --git a/src/context.ts b/src/context.ts index 846d7f0..d5d356b 100644 --- a/src/context.ts +++ b/src/context.ts @@ -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[] { return this._tabs; } @@ -350,19 +356,19 @@ export class Context { permissions?: string[]; }): Promise { const currentConfig = { ...this.config }; - + // Update the configuration - if (changes.headless !== undefined) { + if (changes.headless !== undefined) currentConfig.browser.launchOptions.headless = changes.headless; - } - + + // Handle device emulation - this overrides individual viewport/userAgent settings if (changes.device) { - if (!devices[changes.device]) { + if (!devices[changes.device]) throw new Error(`Unknown device: ${changes.device}`); - } + const deviceConfig = devices[changes.device]; - + // Apply all device properties to context options currentConfig.browser.contextOptions = { ...currentConfig.browser.contextOptions, @@ -370,12 +376,12 @@ export class Context { }; } else { // Apply individual settings only if no device is specified - if (changes.viewport) { + if (changes.viewport) currentConfig.browser.contextOptions.viewport = changes.viewport; - } - if (changes.userAgent) { + + if (changes.userAgent) currentConfig.browser.contextOptions.userAgent = changes.userAgent; - } + } // Apply additional context options @@ -386,33 +392,33 @@ export class Context { accuracy: changes.geolocation.accuracy || 100 }; } - - if (changes.locale) { + + if (changes.locale) currentConfig.browser.contextOptions.locale = changes.locale; - } - - if (changes.timezone) { + + + if (changes.timezone) currentConfig.browser.contextOptions.timezoneId = changes.timezone; - } - - if (changes.colorScheme) { + + + if (changes.colorScheme) currentConfig.browser.contextOptions.colorScheme = changes.colorScheme; - } - - if (changes.permissions) { + + + if (changes.permissions) currentConfig.browser.contextOptions.permissions = changes.permissions; - } + // Store the modified config (this as any).config = currentConfig; // Close the current browser context to force recreation with new settings await this.closeBrowserContext(); - + // Clear tabs since they're attached to the old context this._tabs = []; this._currentTab = undefined; - + testDebug(`browser config updated for session ${this.sessionId}: headless=${currentConfig.browser.launchOptions.headless}, viewport=${JSON.stringify(currentConfig.browser.contextOptions.viewport)}`); } diff --git a/src/program.ts b/src/program.ts index e897f18..c85b8c1 100644 --- a/src/program.ts +++ b/src/program.ts @@ -31,6 +31,7 @@ program .version('Version ' + packageJSON.version) .name(packageJSON.name) .option('--allowed-origins ', 'semicolon-separated list of origins to allow the browser to request. Default is to allow all.', semicolonSeparatedList) + .option('--artifact-dir ', 'path to the directory for centralized artifact storage with session-specific subdirectories.') .option('--blocked-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('--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); }; - if (serverConfig.port !== undefined) { + if (serverConfig.port !== undefined) process.stdin.on('close', handleExit); - } + process.on('SIGINT', handleExit); process.on('SIGTERM', handleExit); diff --git a/src/sessionManager.ts b/src/sessionManager.ts index 0c45fba..9ca8be3 100644 --- a/src/sessionManager.ts +++ b/src/sessionManager.ts @@ -31,9 +31,9 @@ export class SessionManager { private _sessions: Map = new Map(); static getInstance(): SessionManager { - if (!SessionManager._instance) { + if (!SessionManager._instance) SessionManager._instance = new SessionManager(); - } + return SessionManager._instance; } @@ -99,4 +99,4 @@ export class SessionManager { this._sessions.clear(); await Promise.all(contexts.map(context => context.dispose())); } -} \ No newline at end of file +} diff --git a/src/tools/configure.ts b/src/tools/configure.ts index a133468..9c38d0c 100644 --- a/src/tools/configure.ts +++ b/src/tools/configure.ts @@ -17,6 +17,7 @@ import { z } from 'zod'; import { devices } from 'playwright'; import { defineTool } from './tool.js'; +import { ArtifactManagerRegistry } from '../artifactManager.js'; import type { Context } from '../context.js'; import type { Response } from '../response.js'; @@ -41,6 +42,12 @@ const configureSchema = 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 [ defineTool({ capability: 'core', @@ -55,7 +62,7 @@ export default [ try { const deviceList = Object.keys(devices).sort(); const deviceCount = deviceList.length; - + // Organize devices by category for better presentation const categories = { 'iPhone': deviceList.filter(d => d.includes('iPhone')), @@ -63,17 +70,17 @@ export default [ 'Pixel': deviceList.filter(d => d.includes('Pixel')), 'Galaxy': deviceList.filter(d => d.includes('Galaxy')), 'Desktop': deviceList.filter(d => d.includes('Desktop')), - 'Other': deviceList.filter(d => - !d.includes('iPhone') && - !d.includes('iPad') && - !d.includes('Pixel') && - !d.includes('Galaxy') && + 'Other': deviceList.filter(d => + !d.includes('iPhone') && + !d.includes('iPad') && + !d.includes('Pixel') && + !d.includes('Galaxy') && !d.includes('Desktop') ) }; - + let result = `Available devices for emulation (${deviceCount} total):\n\n`; - + for (const [category, deviceNames] of Object.entries(categories)) { if (deviceNames.length > 0) { result += `**${category}:**\n`; @@ -84,11 +91,11 @@ export default [ result += '\n'; } } - + result += 'Use browser_configure with the "device" parameter to emulate any of these devices.'; - + response.addResult(result); - + } catch (error) { throw new Error(`Failed to list devices: ${error}`); } @@ -107,61 +114,61 @@ export default [ try { const currentConfig = context.config; const changes: string[] = []; - + // Track what's changing if (params.headless !== undefined) { const currentHeadless = currentConfig.browser.launchOptions.headless; - if (params.headless !== currentHeadless) { + if (params.headless !== currentHeadless) changes.push(`headless: ${currentHeadless} → ${params.headless}`); - } + } - + if (params.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}`); - } + } - + if (params.userAgent) { const currentUA = currentConfig.browser.contextOptions.userAgent; - if (params.userAgent !== currentUA) { + if (params.userAgent !== currentUA) changes.push(`userAgent: ${currentUA || 'default'} → ${params.userAgent}`); - } + } - + 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.`); - } + 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)`); - } - - if (params.locale) { + + + if (params.locale) changes.push(`locale: ${params.locale}`); - } - - if (params.timezone) { + + + if (params.timezone) changes.push(`timezone: ${params.timezone}`); - } - - if (params.colorScheme) { + + + if (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(', ')}`); - } - + + if (changes.length === 0) { response.addResult('No configuration changes detected. Current settings remain the same.'); return; } - + // Apply the configuration changes await context.updateBrowserConfig({ headless: params.headless, @@ -174,12 +181,127 @@ export default [ colorScheme: params.colorScheme, permissions: params.permissions, }); - + response.addResult(`Browser configuration updated successfully:\n${changes.map(c => `• ${c}`).join('\n')}\n\nThe browser has been restarted with the new settings.`); - + } catch (error) { throw new Error(`Failed to update browser configuration: ${error}`); } }, }), -]; \ No newline at end of file + 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, 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}`); + } + }, + }), +]; diff --git a/src/tools/pdf.ts b/src/tools/pdf.ts index d68b11d..49a317c 100644 --- a/src/tools/pdf.ts +++ b/src/tools/pdf.ts @@ -19,6 +19,7 @@ import { defineTabTool } from './tool.js'; import * as javascript from '../javascript.js'; import { outputFile } from '../config.js'; +import { ArtifactManagerRegistry } from '../artifactManager.js'; const pdfSchema = z.object({ 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) => { - 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(`await page.pdf(${javascript.formatObject({ path: fileName })});`); response.addResult(`Saved page as ${fileName}`); diff --git a/src/tools/screenshot.ts b/src/tools/screenshot.ts index 7df12db..1392c5e 100644 --- a/src/tools/screenshot.ts +++ b/src/tools/screenshot.ts @@ -20,6 +20,7 @@ import { defineTabTool } from './tool.js'; import * as javascript from '../javascript.js'; import { outputFile } from '../config.js'; import { generateLocator } from './utils.js'; +import { ArtifactManagerRegistry } from '../artifactManager.js'; import type * as playwright from 'playwright'; @@ -53,7 +54,19 @@ const screenshot = defineTabTool({ handle: async (tab, params, response) => { 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 = { type: fileType, quality: fileType === 'png' ? undefined : 50, diff --git a/src/tools/video.ts b/src/tools/video.ts index e970d85..3968aff 100644 --- a/src/tools/video.ts +++ b/src/tools/video.ts @@ -17,6 +17,7 @@ import path from 'path'; import { z } from 'zod'; import { defineTool } from './tool.js'; +import { ArtifactManagerRegistry } from '../artifactManager.js'; const startRecording = defineTool({ capability: 'core', @@ -38,7 +39,17 @@ const startRecording = defineTool({ handle: async (context, params, response) => { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); 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 const recordVideoOptions: any = {