@ai-coding-labs/playwright-mcp-plus
Version:
Enhanced Playwright Tools for MCP with Project Session Isolation
547 lines (542 loc) • 25.7 kB
JavaScript
/**
* 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 fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import os from 'os';
import { z } from 'zod';
// @ts-ignore - crx-util doesn't have TypeScript definitions
import * as crx from 'crx-util';
import { defineTool, defineTabTool } from './tool.js';
import { createSchemaWithProjectIsolation, ProjectIsolationManager } from '../projectIsolation.js';
const extensionInstallSchema = createSchemaWithProjectIsolation({
extensionId: z.string().optional().describe('Chrome extension ID (e.g., "cjpalhdlnbpafiamejdnhcphjbkeiagm")'),
extensionUrl: z.string().optional().describe('Full Chrome Web Store URL of the extension'),
waitForInstall: z.boolean().optional().default(true).describe('Whether to wait for installation to complete'),
loadImmediately: z.boolean().optional().default(true).describe('Whether to restart browser to load the extension immediately'),
}).refine(data => data.extensionId || data.extensionUrl, {
message: 'Either extensionId or extensionUrl must be provided'
});
const extensionListSchema = createSchemaWithProjectIsolation({});
const extensionUninstallSchema = createSchemaWithProjectIsolation({
extensionId: z.string().describe('Chrome extension ID to uninstall (e.g., "bcjindcccaagfpapjjmafapmmgkkhgoa")'),
restartImmediately: z.boolean().optional().default(true).describe('Whether to restart browser immediately after uninstalling'),
});
/**
* Extract extension ID from Chrome Web Store URL
*/
function extractExtensionId(url) {
const match = url.match(/\/detail\/[^/]+\/([a-z]{32})/);
if (!match)
throw new Error('Invalid Chrome Web Store URL. Expected format: https://chromewebstore.google.com/detail/extension-name/extension-id');
return match[1];
}
/**
* Get Chrome user data directory
*/
function getChromeUserDataDir() {
const platform = os.platform();
const homeDir = os.homedir();
switch (platform) {
case 'win32':
return path.join(homeDir, 'AppData', 'Local', 'Google', 'Chrome', 'User Data');
case 'darwin':
return path.join(homeDir, 'Library', 'Application Support', 'Google', 'Chrome');
case 'linux':
return path.join(homeDir, '.config', 'google-chrome');
default:
throw new Error(`Unsupported platform: ${platform}`);
}
}
/**
* Get session user data directory from context
* This ensures we use the same directory that the browser is actually using
*/
function getSessionUserDataDirFromContext(context) {
// Get the actual user data directory from the context
if (context && typeof context.getCurrentUserDataDir === 'function')
return context.getCurrentUserDataDir();
return undefined;
}
/**
* Get session user data directory using the same logic as browser context factory
* This uses the enhanced project isolation manager with proper configuration
*/
async function getSessionUserDataDirWithConfig(context, projectDrive, projectPath) {
// First try to get from context (preferred)
const contextUserDataDir = getSessionUserDataDirFromContext(context);
if (contextUserDataDir)
return contextUserDataDir;
// Fallback: use the same logic as browser context factory
if (!projectDrive || !projectPath || !context?.config)
return undefined;
try {
const { EnhancedProjectIsolationManager } = await import('../enhancedProjectIsolation.js');
const userDataDir = await EnhancedProjectIsolationManager.createUserDataDir(context.config, { projectDrive, projectPath });
return userDataDir;
}
catch (error) {
// If enhanced manager fails, fall back to original logic
try {
const projectInfo = { projectDrive, projectPath };
return ProjectIsolationManager.createUserDataDir(projectInfo);
}
catch (fallbackError) {
return undefined;
}
}
}
/**
* Get session user data directory from project isolation parameters (legacy)
* This is kept for backward compatibility but should be avoided
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function getSessionUserDataDir(projectDrive, projectPath) {
if (!projectDrive || !projectPath)
return undefined;
try {
// Use the enhanced project isolation manager to get the session directory
const projectInfo = { projectDrive, projectPath };
return ProjectIsolationManager.createUserDataDir(projectInfo);
}
catch (error) {
// If project isolation fails, return undefined to fall back to global storage
return undefined;
}
}
/**
* Download extension CRX file from Chrome Web Store using crx-util with fallback strategies
*/
async function downloadExtensionCrx(extensionId, outputPath) {
try {
// First try: Use crx-util to download the extension
const result = await crx.downloadById(extensionId, 'chrome', outputPath);
if (result.result) {
// Successfully downloaded extension
return;
}
// If crx-util fails, throw an error with helpful guidance
throw new Error(`Chrome Web Store blocked automated download (HTTP 204). This is a security measure by Google.
Recommended solutions:
1. Manual Installation: Visit https://chromewebstore.google.com/detail/${extensionId} and install manually
2. Developer Mode: If you have a local CRX file, enable Developer Mode in chrome://extensions/ and load it
3. Alternative Extensions: Consider using similar extensions that allow local installation
The extension ID ${extensionId} appears to be valid, but Chrome Web Store restricts automated downloads to prevent abuse.`);
}
catch (error) {
// Enhanced error handling with specific guidance for common issues
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('HTTP 204') || errorMessage.includes('204')) {
throw new Error(`Chrome Web Store blocked automated download (HTTP 204). This is a security measure by Google.
Recommended solutions:
1. Manual Installation: Visit https://chromewebstore.google.com/detail/${extensionId} and install manually
2. Developer Mode: If you have a local CRX file, enable Developer Mode in chrome://extensions/ and load it
3. Alternative Extensions: Consider using similar extensions that allow local installation
The extension ID ${extensionId} appears to be valid, but Chrome Web Store restricts automated downloads to prevent abuse.`);
}
throw new Error(`Failed to download extension ${extensionId}: ${errorMessage}`);
}
}
/**
* Extract CRX file to directory
* Use a more robust approach that handles different CRX formats
*/
function extractCrxFile(crxPath, extractDir) {
try {
// Create extraction directory
fs.mkdirSync(extractDir, { recursive: true });
// Try to extract directly with unzip (ignoring warnings about extra bytes, overwrite existing files)
try {
execSync(`unzip -o -q "${crxPath}" -d "${extractDir}" 2>/dev/null || unzip -o "${crxPath}" -d "${extractDir}"`, { stdio: 'pipe' });
return;
}
catch (e) {
// If direct unzip fails, try to parse CRX header
}
// Read CRX file and try to find ZIP data
const crxData = fs.readFileSync(crxPath);
// Look for ZIP file signature (PK\x03\x04)
const zipSignature = Buffer.from([0x50, 0x4B, 0x03, 0x04]);
let zipOffset = -1;
for (let i = 0; i <= crxData.length - 4; i++) {
if (crxData.subarray(i, i + 4).equals(zipSignature)) {
zipOffset = i;
break;
}
}
if (zipOffset === -1)
throw new Error('No ZIP data found in CRX file');
// Extract ZIP data
const zipData = crxData.subarray(zipOffset);
// Write ZIP data to temporary file
const tempZipPath = crxPath + '.zip';
fs.writeFileSync(tempZipPath, zipData);
// Extract ZIP file (overwrite existing files)
execSync(`unzip -o -q "${tempZipPath}" -d "${extractDir}"`, { stdio: 'pipe' });
// Clean up temporary ZIP file
fs.unlinkSync(tempZipPath);
}
catch (error) {
throw new Error(`Failed to extract CRX file: ${error}`);
}
}
/**
* Install extension to Chrome extensions directory (currently unused)
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function installExtensionToChrome(extensionId, userDataDir) {
const chromeUserDataDir = userDataDir || getChromeUserDataDir();
const extensionsDir = path.join(chromeUserDataDir, 'Default', 'Extensions', extensionId);
// Create temporary directory for download
const tempDir = path.join(os.tmpdir(), `chrome-ext-${extensionId}-${Date.now()}`);
const crxPath = path.join(tempDir, `${extensionId}.crx`);
try {
// Create temp directory
fs.mkdirSync(tempDir, { recursive: true });
// Download CRX file
await downloadExtensionCrx(extensionId, crxPath);
// Create version directory (remove existing if present)
const versionDir = path.join(extensionsDir, '1.0.0_0');
if (fs.existsSync(versionDir))
fs.rmSync(versionDir, { recursive: true, force: true });
// Extract CRX to version directory
extractCrxFile(crxPath, versionDir);
// Clean up temp files
fs.rmSync(tempDir, { recursive: true, force: true });
return versionDir;
}
catch (error) {
// Clean up on error
if (fs.existsSync(tempDir))
fs.rmSync(tempDir, { recursive: true, force: true });
throw error;
}
}
/**
* Get the MCP extensions directory for a specific session
* If sessionUserDataDir is provided, extensions are stored within that session
* Otherwise, falls back to global directory for backward compatibility
*/
function getMcpExtensionsDir(sessionUserDataDir) {
if (sessionUserDataDir) {
// Session-based extension storage
const extensionsDir = path.join(sessionUserDataDir, 'extensions');
if (!fs.existsSync(extensionsDir))
fs.mkdirSync(extensionsDir, { recursive: true });
return extensionsDir;
}
// Global extension storage (backward compatibility)
const homeDir = os.homedir();
const mcpExtensionsDir = path.join(homeDir, '.mcp-extensions');
if (!fs.existsSync(mcpExtensionsDir))
fs.mkdirSync(mcpExtensionsDir, { recursive: true });
return mcpExtensionsDir;
}
/**
* Get the extensions registry file path for a specific session
*/
function getExtensionsRegistryPath(sessionUserDataDir) {
return path.join(getMcpExtensionsDir(sessionUserDataDir), 'extensions.json');
}
/**
* Load extensions registry for a specific session
*/
function loadExtensionsRegistry(sessionUserDataDir) {
const registryPath = getExtensionsRegistryPath(sessionUserDataDir);
if (!fs.existsSync(registryPath)) {
const defaultRegistry = { extensions: [] };
fs.writeFileSync(registryPath, JSON.stringify(defaultRegistry, null, 2));
return defaultRegistry;
}
try {
const content = fs.readFileSync(registryPath, 'utf8');
return JSON.parse(content);
}
catch (error) {
// If registry is corrupted, create a new one
const defaultRegistry = { extensions: [] };
fs.writeFileSync(registryPath, JSON.stringify(defaultRegistry, null, 2));
return defaultRegistry;
}
}
/**
* Save extensions registry for a specific session
*/
function saveExtensionsRegistry(registry, sessionUserDataDir) {
const registryPath = getExtensionsRegistryPath(sessionUserDataDir);
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2));
}
/**
* Install extension by downloading CRX and extracting to session extensions directory
*/
async function installExtensionToMcp(extensionId, sessionUserDataDir) {
const tempDir = path.join(os.tmpdir(), `chrome-ext-${extensionId}-${Date.now()}`);
try {
// Create temp directory
fs.mkdirSync(tempDir, { recursive: true });
// Download CRX file
const crxPath = path.join(tempDir, `${extensionId}.crx`);
await downloadExtensionCrx(extensionId, crxPath);
// Get session extensions directory
const mcpExtensionsDir = getMcpExtensionsDir(sessionUserDataDir);
const extensionDir = path.join(mcpExtensionsDir, extensionId);
// Remove existing extension directory if present
if (fs.existsSync(extensionDir))
fs.rmSync(extensionDir, { recursive: true, force: true });
// Create extension directory
fs.mkdirSync(extensionDir, { recursive: true });
// Extract CRX to extension directory
extractCrxFile(crxPath, extensionDir);
// Read manifest to get extension name and version
const manifestPath = path.join(extensionDir, 'manifest.json');
let extensionName = extensionId;
let extensionVersion = '1.0.0';
if (fs.existsSync(manifestPath)) {
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
extensionName = manifest.name || extensionId;
extensionVersion = manifest.version || '1.0.0';
}
catch (e) {
// Use defaults if manifest is invalid
}
}
// Update extensions registry for this session
const registry = loadExtensionsRegistry(sessionUserDataDir);
// Remove existing entry if present
registry.extensions = registry.extensions.filter(ext => ext.id !== extensionId);
// Add new entry
registry.extensions.push({
id: extensionId,
name: extensionName,
path: extensionDir,
version: extensionVersion
});
saveExtensionsRegistry(registry, sessionUserDataDir);
// Clean up temp files
fs.rmSync(tempDir, { recursive: true, force: true });
return extensionDir;
}
catch (error) {
// Clean up on error
if (fs.existsSync(tempDir))
fs.rmSync(tempDir, { recursive: true, force: true });
throw error;
}
}
/**
* Get all installed extension paths for Chrome launch args for a specific session
*/
export function getInstalledExtensionPaths(sessionUserDataDir) {
try {
const registry = loadExtensionsRegistry(sessionUserDataDir);
return registry.extensions
.filter(ext => fs.existsSync(ext.path)) // Only include existing paths
.map(ext => ext.path);
}
catch (error) {
return [];
}
}
/**
* Uninstall extension by removing from session extensions directory and registry
*/
async function uninstallExtensionFromMcp(extensionId, sessionUserDataDir) {
const registry = loadExtensionsRegistry(sessionUserDataDir);
// Find the extension in registry
const extensionIndex = registry.extensions.findIndex(ext => ext.id === extensionId);
if (extensionIndex === -1)
throw new Error(`Extension ${extensionId} not found in session registry`);
const extension = registry.extensions[extensionIndex];
const extensionPath = extension.path;
// Remove extension directory if it exists
if (fs.existsSync(extensionPath))
fs.rmSync(extensionPath, { recursive: true, force: true });
// Remove from registry
registry.extensions.splice(extensionIndex, 1);
saveExtensionsRegistry(registry, sessionUserDataDir);
return { name: extension.name, path: extensionPath };
}
const installExtension = defineTabTool({
capability: 'core',
schema: {
name: 'browser_extension_install',
title: 'Install Chrome Extension',
description: 'Install a Chrome extension from the Chrome Web Store by downloading and managing it locally',
inputSchema: extensionInstallSchema,
type: 'destructive',
},
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
// Extract extension ID
let extensionId;
if (params.extensionId)
extensionId = params.extensionId;
else if (params.extensionUrl)
extensionId = extractExtensionId(params.extensionUrl);
else
throw new Error('Either extensionId or extensionUrl must be provided');
// Get session user data directory using the same logic as browser context factory
const sessionUserDataDir = await getSessionUserDataDirWithConfig(tab.context, params.projectDrive, params.projectPath);
response.addCode(`// Installing Chrome extension: ${extensionId}`);
if (sessionUserDataDir)
response.addResult(`📁 Using session-isolated extension storage: ${sessionUserDataDir}/extensions`);
else
response.addResult(`📁 Using global extension storage (no project isolation)`);
try {
const installPath = await installExtensionToMcp(extensionId, sessionUserDataDir);
response.addResult(`✅ Chrome extension ${extensionId} installed successfully to: ${installPath}`);
// Show current installed extensions for this session
const registry = loadExtensionsRegistry(sessionUserDataDir);
response.addResult(`📋 Total installed extensions in this session: ${registry.extensions.length}`);
// Restart browser to load the extension immediately if requested
if (params.loadImmediately) {
response.addCode(`// Restarting browser to load extension immediately`);
try {
// Close current browser context
response.addResult(`🔄 Restarting browser to load the new extension...`);
// Close the current page/context
await tab.waitForCompletion(async () => {
await tab.page.close();
});
// The browser context will be recreated automatically on next navigation
// with the new extension loaded via launch args
response.addResult(`✅ Browser restart initiated. The extension will be loaded automatically.`);
response.addResult(`🚀 Navigate to any page to see the extension in action!`);
}
catch (restartError) {
response.addResult(`⚠️ Could not restart browser: ${restartError}`);
response.addResult(`🔄 Please manually restart the browser to load the extension.`);
}
}
else {
response.addResult(`🔄 The extension will be automatically loaded when you restart the browser or start a new session.`);
}
response.addResult(`📁 Extension registry: ${getExtensionsRegistryPath(sessionUserDataDir)}`);
}
catch (error) {
throw new Error(`Failed to install extension ${extensionId}: ${error}`);
}
},
});
const listExtensions = defineTool({
capability: 'core',
schema: {
name: 'browser_extension_list',
title: 'List Installed Extensions',
description: 'List all MCP-managed Chrome extensions',
inputSchema: extensionListSchema,
type: 'readOnly',
},
handle: async (context, params, response) => {
try {
// Get session user data directory using the same logic as browser context factory
const sessionUserDataDir = await getSessionUserDataDirWithConfig(context, params.projectDrive, params.projectPath);
const registry = loadExtensionsRegistry(sessionUserDataDir);
if (sessionUserDataDir)
response.addResult(`📁 Session-isolated extension storage: ${sessionUserDataDir}/extensions`);
else
response.addResult(`📁 Global extension storage (no project isolation)`);
if (registry.extensions.length === 0) {
response.addResult(`📋 No MCP-managed extensions found in this session.`);
response.addResult(`📁 Extension registry: ${getExtensionsRegistryPath(sessionUserDataDir)}`);
response.addResult(`💡 Use browser_extension_install to install extensions.`);
return;
}
response.addResult(`📋 Found ${registry.extensions.length} MCP-managed extensions in this session:`);
for (const ext of registry.extensions) {
const exists = fs.existsSync(ext.path);
const status = exists ? '✅' : '❌';
response.addResult(` ${status} ${ext.name} (${ext.id}) - v${ext.version}`);
if (!exists)
response.addResult(` ⚠️ Path not found: ${ext.path}`);
}
response.addResult(`📁 Extension registry: ${getExtensionsRegistryPath(sessionUserDataDir)}`);
response.addResult(`📂 Extensions directory: ${getMcpExtensionsDir(sessionUserDataDir)}`);
// Show extension paths for Chrome launch args
const extensionPaths = getInstalledExtensionPaths(sessionUserDataDir);
if (extensionPaths.length > 0) {
response.addResult(`🚀 Chrome launch args will include:`);
response.addResult(` --load-extension=${extensionPaths.join(',')}`);
response.addResult(` --disable-extensions-except=${extensionPaths.join(',')}`);
}
}
catch (error) {
throw new Error(`Failed to list extensions: ${error}`);
}
},
});
const uninstallExtension = defineTabTool({
capability: 'core',
schema: {
name: 'browser_extension_uninstall',
title: 'Uninstall Chrome Extension',
description: 'Uninstall a Chrome extension from MCP management and restart browser',
inputSchema: extensionUninstallSchema,
type: 'destructive',
},
handle: async (tab, params, response) => {
response.setIncludeSnapshot();
// Get session user data directory using the same logic as browser context factory
const sessionUserDataDir = await getSessionUserDataDirWithConfig(tab.context, params.projectDrive, params.projectPath);
const extensionId = params.extensionId;
response.addCode(`// Uninstalling Chrome extension: ${extensionId}`);
if (sessionUserDataDir)
response.addResult(`📁 Using session-isolated extension storage: ${sessionUserDataDir}/extensions`);
else
response.addResult(`📁 Using global extension storage (no project isolation)`);
try {
const uninstallResult = await uninstallExtensionFromMcp(extensionId, sessionUserDataDir);
response.addResult(`✅ Chrome extension "${uninstallResult.name}" (${extensionId}) uninstalled successfully`);
response.addResult(`🗑️ Removed from: ${uninstallResult.path}`);
// Show current installed extensions for this session
const registry = loadExtensionsRegistry(sessionUserDataDir);
response.addResult(`📋 Remaining installed extensions in this session: ${registry.extensions.length}`);
// Restart browser to apply changes if requested
if (params.restartImmediately) {
response.addCode(`// Restarting browser to apply uninstall changes`);
try {
// Close current browser context
response.addResult(`🔄 Restarting browser to apply uninstall changes...`);
// Close the current page/context
await tab.waitForCompletion(async () => {
await tab.page.close();
});
// The browser context will be recreated automatically on next navigation
// without the uninstalled extension
response.addResult(`✅ Browser restart initiated. The extension has been removed.`);
response.addResult(`🚀 Navigate to any page to see the changes!`);
}
catch (restartError) {
response.addResult(`⚠️ Could not restart browser: ${restartError}`);
response.addResult(`🔄 Please manually restart the browser to apply changes.`);
}
}
else {
response.addResult(`🔄 The extension will be removed when you restart the browser or start a new session.`);
}
response.addResult(`📁 Extension registry: ${getExtensionsRegistryPath(sessionUserDataDir)}`);
}
catch (error) {
throw new Error(`Failed to uninstall extension ${extensionId}: ${error}`);
}
},
});
export default [
installExtension,
uninstallExtension,
listExtensions,
];