feat: add Chrome extension support with session-based isolation

- Add browser_install_extension, browser_list_extensions, browser_uninstall_extension tools
- Support session-based extension isolation between MCP clients
- Extensions loaded via --load-extension Chrome flags at browser startup
- Browser auto-restarts when extensions are added/removed
- Validation ensures extensions only work with Chromium browser
- Warning system for Chrome channel vs pure Chromium compatibility
- Extension management persists across page navigations within session
- Updated README with complete extension tool documentation

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ryan Malloy 2025-08-21 15:02:00 -06:00
parent d8202f6694
commit b3dbe55a9d
4 changed files with 335 additions and 20 deletions

View File

@ -621,6 +621,16 @@ http.createServer(async (req, res) => {
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
- **browser_install_extension**
- Title: Install Chrome extension
- Description: Install a Chrome extension in the current browser session. Only works with Chromium browser. For best results, use pure Chromium without the "chrome" channel. The extension must be an unpacked directory containing manifest.json.
- Parameters:
- `path` (string): Path to the Chrome extension directory (containing manifest.json)
- `name` (string, optional): Optional friendly name for the extension
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_list_devices** - **browser_list_devices**
- Title: List available devices for emulation - 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. - 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.
@ -629,6 +639,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_extensions**
- Title: List installed Chrome extensions
- Description: List all Chrome extensions currently installed in the browser session. Only works with Chromium browser.
- 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
@ -752,6 +770,15 @@ http.createServer(async (req, res) => {
<!-- NOTE: This has been generated via update-readme.js --> <!-- NOTE: This has been generated via update-readme.js -->
- **browser_uninstall_extension**
- Title: Uninstall Chrome extension
- Description: Uninstall a Chrome extension from the current browser session. Only works with Chromium browser.
- Parameters:
- `path` (string): Path to the Chrome extension directory to uninstall
- Read-only: **false**
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_wait_for** - **browser_wait_for**
- Title: Wait for - Title: Wait for
- Description: Wait for text to appear or disappear or a specified time to pass - Description: Wait for text to appear or disappear or a specified time to pass

View File

@ -36,7 +36,7 @@ export function contextFactory(browserConfig: FullConfig['browser']): BrowserCon
} }
export interface BrowserContextFactory { export interface BrowserContextFactory {
createContext(clientInfo: { name: string, version: string }): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>; createContext(clientInfo: { name: string, version: string }, extensionPaths?: string[]): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
} }
class BaseContextFactory implements BrowserContextFactory { class BaseContextFactory implements BrowserContextFactory {
@ -68,14 +68,14 @@ class BaseContextFactory implements BrowserContextFactory {
throw new Error('Not implemented'); throw new Error('Not implemented');
} }
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> { async createContext(clientInfo: { name: string, version: string }, extensionPaths?: string[]): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
testDebug(`create browser context (${this.name})`); testDebug(`create browser context (${this.name})`);
const browser = await this._obtainBrowser(); const browser = await this._obtainBrowser();
const browserContext = await this._doCreateContext(browser); const browserContext = await this._doCreateContext(browser, extensionPaths);
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) }; return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
} }
protected async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> { protected async _doCreateContext(browser: playwright.Browser, extensionPaths?: string[]): Promise<playwright.BrowserContext> {
throw new Error('Not implemented'); throw new Error('Not implemented');
} }
@ -92,25 +92,52 @@ class BaseContextFactory implements BrowserContextFactory {
} }
class IsolatedContextFactory extends BaseContextFactory { class IsolatedContextFactory extends BaseContextFactory {
private _extensionPaths: string[] = [];
constructor(browserConfig: FullConfig['browser']) { constructor(browserConfig: FullConfig['browser']) {
super('isolated', browserConfig); super('isolated', browserConfig);
} }
async createContext(clientInfo: { name: string, version: string }, extensionPaths?: string[]): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
// Update extension paths and recreate browser if extensions changed
const hasExtensionsChanged = JSON.stringify(this._extensionPaths) !== JSON.stringify(extensionPaths || []);
if (hasExtensionsChanged) {
this._extensionPaths = extensionPaths || [];
// Force browser recreation with new extensions
this._browserPromise = undefined;
}
return super.createContext(clientInfo, extensionPaths);
}
protected override async _doObtainBrowser(): Promise<playwright.Browser> { protected override async _doObtainBrowser(): Promise<playwright.Browser> {
await injectCdpPort(this.browserConfig); await injectCdpPort(this.browserConfig);
const browserType = playwright[this.browserConfig.browserName]; const browserType = playwright[this.browserConfig.browserName];
return browserType.launch({
const launchOptions = {
...this.browserConfig.launchOptions, ...this.browserConfig.launchOptions,
handleSIGINT: false, handleSIGINT: false,
handleSIGTERM: false, handleSIGTERM: false,
}).catch(error => { };
// Add Chrome extension support for Chromium
if (this.browserConfig.browserName === 'chromium' && this._extensionPaths.length > 0) {
testDebug(`Launching browser with ${this._extensionPaths.length} Chrome extensions: ${this._extensionPaths.join(', ')}`);
launchOptions.args = [
...(launchOptions.args || []),
...this._extensionPaths.map(path => `--load-extension=${path}`)
];
}
return browserType.launch(launchOptions).catch(error => {
if (error.message.includes('Executable doesn\'t exist')) if (error.message.includes('Executable doesn\'t exist'))
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`); throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
throw error; throw error;
}); });
} }
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> { protected override async _doCreateContext(browser: playwright.Browser, extensionPaths?: string[]): Promise<playwright.BrowserContext> {
return browser.newContext(this.browserConfig.contextOptions); return browser.newContext(this.browserConfig.contextOptions);
} }
} }
@ -124,7 +151,9 @@ class CdpContextFactory extends BaseContextFactory {
return playwright.chromium.connectOverCDP(this.browserConfig.cdpEndpoint!); return playwright.chromium.connectOverCDP(this.browserConfig.cdpEndpoint!);
} }
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> { protected override async _doCreateContext(browser: playwright.Browser, extensionPaths?: string[]): Promise<playwright.BrowserContext> {
if (extensionPaths && extensionPaths.length > 0)
testDebug('Warning: Chrome extensions are not supported with CDP connections');
return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0]; return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
} }
} }
@ -142,7 +171,9 @@ class RemoteContextFactory extends BaseContextFactory {
return playwright[this.browserConfig.browserName].connect(String(url)); return playwright[this.browserConfig.browserName].connect(String(url));
} }
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> { protected override async _doCreateContext(browser: playwright.Browser, extensionPaths?: string[]): Promise<playwright.BrowserContext> {
if (extensionPaths && extensionPaths.length > 0)
testDebug('Warning: Chrome extensions are not supported with remote browser connections');
return browser.newContext(); return browser.newContext();
} }
} }
@ -155,7 +186,7 @@ class PersistentContextFactory implements BrowserContextFactory {
this.browserConfig = browserConfig; this.browserConfig = browserConfig;
} }
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> { async createContext(clientInfo: { name: string, version: string }, extensionPaths?: string[]): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
await injectCdpPort(this.browserConfig); await injectCdpPort(this.browserConfig);
testDebug('create browser context (persistent)'); testDebug('create browser context (persistent)');
const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir(); const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir();
@ -163,15 +194,26 @@ class PersistentContextFactory implements BrowserContextFactory {
this._userDataDirs.add(userDataDir); this._userDataDirs.add(userDataDir);
testDebug('lock user data dir', userDataDir); testDebug('lock user data dir', userDataDir);
const launchOptions = {
...this.browserConfig.launchOptions,
...this.browserConfig.contextOptions,
handleSIGINT: false,
handleSIGTERM: false,
};
// Add Chrome extension support for Chromium
if (this.browserConfig.browserName === 'chromium' && extensionPaths && extensionPaths.length > 0) {
testDebug(`Loading ${extensionPaths.length} Chrome extensions in persistent context: ${extensionPaths.join(', ')}`);
launchOptions.args = [
...(launchOptions.args || []),
...extensionPaths.map(path => `--load-extension=${path}`)
];
}
const browserType = playwright[this.browserConfig.browserName]; const browserType = playwright[this.browserConfig.browserName];
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
try { try {
const browserContext = await browserType.launchPersistentContext(userDataDir, { const browserContext = await browserType.launchPersistentContext(userDataDir, launchOptions);
...this.browserConfig.launchOptions,
...this.browserConfig.contextOptions,
handleSIGINT: false,
handleSIGTERM: false,
});
const close = () => this._closeBrowserContext(browserContext, userDataDir); const close = () => this._closeBrowserContext(browserContext, userDataDir);
return { browserContext, close }; return { browserContext, close };
} catch (error: any) { } catch (error: any) {

View File

@ -48,6 +48,9 @@ export class Context {
readonly sessionId: string; readonly sessionId: string;
private _sessionStartTime: number; private _sessionStartTime: number;
// Chrome extension management
private _installedExtensions: Array<{ path: string; name: string; version?: string }> = [];
constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory, environmentIntrospector?: EnvironmentIntrospector) { constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory, environmentIntrospector?: EnvironmentIntrospector) {
this.tools = tools; this.tools = tools;
this.config = config; this.config = config;
@ -250,7 +253,7 @@ export class Context {
result = await this._createVideoEnabledContext(); result = await this._createVideoEnabledContext();
} else { } else {
// Use the standard browser context factory // Use the standard browser context factory
result = await this._browserContextFactory.createContext(this.clientVersion!); result = await this._browserContextFactory.createContext(this.clientVersion!, this._getExtensionPaths());
} }
const { browserContext } = result; const { browserContext } = result;
await this._setupRequestInterception(browserContext); await this._setupRequestInterception(browserContext);
@ -275,12 +278,24 @@ export class Context {
// Get environment-specific browser options // Get environment-specific browser options
const envOptions = this._environmentIntrospector.getRecommendedBrowserOptions(); const envOptions = this._environmentIntrospector.getRecommendedBrowserOptions();
const browser = await browserType.launch({ const launchOptions = {
...this.config.browser.launchOptions, ...this.config.browser.launchOptions,
...envOptions, // Include environment-detected options ...envOptions, // Include environment-detected options
handleSIGINT: false, handleSIGINT: false,
handleSIGTERM: false, handleSIGTERM: false,
}); };
// Add Chrome extension support for Chromium
const extensionPaths = this._getExtensionPaths();
if (this.config.browser.browserName === 'chromium' && extensionPaths.length > 0) {
testDebug(`Loading ${extensionPaths.length} Chrome extensions in video context: ${extensionPaths.join(', ')}`);
launchOptions.args = [
...(launchOptions.args || []),
...extensionPaths.map(path => `--load-extension=${path}`)
];
}
const browser = await browserType.launch(launchOptions);
// Use environment-specific video directory if available // Use environment-specific video directory if available
const videoConfig = envOptions.recordVideo ? const videoConfig = envOptions.recordVideo ?
@ -451,4 +466,81 @@ export class Context {
return videoPaths; return videoPaths;
} }
// Chrome Extension Management
async installExtension(extensionPath: string, extensionName: string): Promise<void> {
if (this.config.browser.browserName !== 'chromium')
throw new Error('Chrome extensions are only supported with Chromium browser.');
// Check if extension is already installed
const existingExtension = this._installedExtensions.find(ext => ext.path === extensionPath);
if (existingExtension)
throw new Error(`Extension is already installed: ${extensionName} (${extensionPath})`);
// Read extension manifest to get version info
const fs = await import('fs');
const path = await import('path');
const manifestPath = path.join(extensionPath, 'manifest.json');
let version: string | undefined;
try {
const manifestContent = fs.readFileSync(manifestPath, 'utf8');
const manifest = JSON.parse(manifestContent);
version = manifest.version;
} catch (error) {
testDebug('Could not read extension version:', error);
}
// Add to installed extensions list
this._installedExtensions.push({
path: extensionPath,
name: extensionName,
version
});
testDebug(`Installing Chrome extension: ${extensionName} from ${extensionPath}`);
// Restart browser with updated extension list
await this._restartBrowserWithExtensions();
}
getInstalledExtensions(): Array<{ path: string; name: string; version?: string }> {
return [...this._installedExtensions];
}
async uninstallExtension(extensionPath: string): Promise<{ path: string; name: string; version?: string } | null> {
const extensionIndex = this._installedExtensions.findIndex(ext => ext.path === extensionPath);
if (extensionIndex === -1)
return null;
const removedExtension = this._installedExtensions.splice(extensionIndex, 1)[0];
testDebug(`Uninstalling Chrome extension: ${removedExtension.name} from ${extensionPath}`);
// Restart browser with updated extension list
await this._restartBrowserWithExtensions();
return removedExtension;
}
private async _restartBrowserWithExtensions(): Promise<void> {
// Close existing browser context if open
if (this._browserContextPromise) {
const { close } = await this._browserContextPromise;
await close();
this._browserContextPromise = undefined;
}
// Clear all tabs as they will be recreated
this._tabs = [];
this._currentTab = undefined;
testDebug(`Restarting browser with ${this._installedExtensions.length} extensions`);
}
private _getExtensionPaths(): string[] {
return this._installedExtensions.map(ext => ext.path);
}
} }

View File

@ -48,6 +48,17 @@ const configureArtifactsSchema = z.object({
sessionId: z.string().optional().describe('Custom session ID for artifact organization (auto-generated if not provided)') sessionId: z.string().optional().describe('Custom session ID for artifact organization (auto-generated if not provided)')
}); });
const installExtensionSchema = z.object({
path: z.string().describe('Path to the Chrome extension directory (containing manifest.json)'),
name: z.string().optional().describe('Optional friendly name for the extension')
});
const listExtensionsSchema = z.object({});
const uninstallExtensionSchema = z.object({
path: z.string().describe('Path to the Chrome extension directory to uninstall')
});
export default [ export default [
defineTool({ defineTool({
capability: 'core', capability: 'core',
@ -267,7 +278,7 @@ export default [
} else { } else {
// Show current status - re-check after potential changes // Show current status - re-check after potential changes
const currentManager = currentSessionId ? registry.getManager(currentSessionId) : undefined; const currentManager = currentSessionId ? registry.getManager(currentSessionId) : undefined;
if (currentManager && currentSessionId) { if (currentManager && currentSessionId) {
const stats = currentManager.getSessionStats(); const stats = currentManager.getSessionStats();
response.addResult( response.addResult(
@ -304,4 +315,147 @@ export default [
} }
}, },
}), }),
defineTool({
capability: 'core',
schema: {
name: 'browser_install_extension',
title: 'Install Chrome extension',
description: 'Install a Chrome extension in the current browser session. Only works with Chromium browser. For best results, use pure Chromium without the "chrome" channel. The extension must be an unpacked directory containing manifest.json.',
inputSchema: installExtensionSchema,
type: 'destructive',
},
handle: async (context: Context, params: z.output<typeof installExtensionSchema>, response: Response) => {
try {
// Validate that we're using Chromium
if (context.config.browser.browserName !== 'chromium')
throw new Error('Chrome extensions are only supported with Chromium browser. Use browser_configure to switch to chromium.');
// Additional validation for Chrome channel
const hasChannel = context.config.browser.launchOptions.channel;
if (hasChannel === 'chrome') {
response.addResult(
'⚠️ **Important**: You are using Chrome via the "chrome" channel.\n\n' +
'Chrome extensions work best with pure Chromium (no channel).\n' +
'If extensions don\'t load properly, consider:\n\n' +
'1. Installing pure Chromium: `sudo apt install chromium-browser` (Linux)\n' +
'2. Using browser_configure to remove the chrome channel\n' +
'3. Ensuring unpacked extensions are enabled in your browser settings\n\n' +
'Continuing with Chrome channel (extensions may not load)...\n'
);
}
// Validate extension path exists and contains manifest.json
const fs = await import('fs');
const path = await import('path');
if (!fs.existsSync(params.path))
throw new Error(`Extension directory not found: ${params.path}`);
const manifestPath = path.join(params.path, 'manifest.json');
if (!fs.existsSync(manifestPath))
throw new Error(`manifest.json not found in extension directory: ${params.path}`);
// Read and validate manifest
const manifestContent = fs.readFileSync(manifestPath, 'utf8');
let manifest;
try {
manifest = JSON.parse(manifestContent);
} catch (error) {
throw new Error(`Invalid manifest.json: ${error}`);
}
if (!manifest.name)
throw new Error('Extension manifest must contain a "name" field');
// Install the extension by updating browser configuration
await context.installExtension(params.path, params.name || manifest.name);
response.addResult(
`✅ Chrome extension installed successfully!\n\n` +
`Extension: ${params.name || manifest.name}\n` +
`Path: ${params.path}\n` +
`Manifest version: ${manifest.manifest_version || 'unknown'}\n\n` +
`The browser has been restarted with the extension loaded.\n` +
`Use browser_list_extensions to see all installed extensions.`
);
} catch (error) {
throw new Error(`Failed to install Chrome extension: ${error}`);
}
},
}),
defineTool({
capability: 'core',
schema: {
name: 'browser_list_extensions',
title: 'List installed Chrome extensions',
description: 'List all Chrome extensions currently installed in the browser session. Only works with Chromium browser.',
inputSchema: listExtensionsSchema,
type: 'readOnly',
},
handle: async (context: Context, params: z.output<typeof listExtensionsSchema>, response: Response) => {
try {
// Validate that we're using Chromium
if (context.config.browser.browserName !== 'chromium')
throw new Error('Chrome extensions are only supported with Chromium browser.');
const extensions = context.getInstalledExtensions();
if (extensions.length === 0) {
response.addResult('No Chrome extensions are currently installed.\n\nUse browser_install_extension to install extensions.');
return;
}
let result = `Installed Chrome extensions (${extensions.length}):\n\n`;
extensions.forEach((ext, index) => {
result += `${index + 1}. **${ext.name}**\n`;
result += ` Path: ${ext.path}\n`;
if (ext.version)
result += ` Version: ${ext.version}\n`;
result += '\n';
});
result += 'Use browser_uninstall_extension to remove extensions.';
response.addResult(result);
} catch (error) {
throw new Error(`Failed to list Chrome extensions: ${error}`);
}
},
}),
defineTool({
capability: 'core',
schema: {
name: 'browser_uninstall_extension',
title: 'Uninstall Chrome extension',
description: 'Uninstall a Chrome extension from the current browser session. Only works with Chromium browser.',
inputSchema: uninstallExtensionSchema,
type: 'destructive',
},
handle: async (context: Context, params: z.output<typeof uninstallExtensionSchema>, response: Response) => {
try {
// Validate that we're using Chromium
if (context.config.browser.browserName !== 'chromium')
throw new Error('Chrome extensions are only supported with Chromium browser.');
const removedExtension = await context.uninstallExtension(params.path);
if (!removedExtension)
throw new Error(`Extension not found: ${params.path}`);
response.addResult(
`✅ Chrome extension uninstalled successfully!\n\n` +
`Extension: ${removedExtension.name}\n` +
`Path: ${params.path}\n\n` +
`The browser has been restarted without this extension.\n` +
`Use browser_list_extensions to see remaining extensions.`
);
} catch (error) {
throw new Error(`Failed to uninstall Chrome extension: ${error}`);
}
},
}),
]; ];