claude-computer-use-mcp
Version:
MCP server providing browser automation capabilities to Claude Code
415 lines • 17.3 kB
JavaScript
import { validateUrl, validateSelector, validateText, SecurityError, ValidationError } from './validation.js';
import { SECURITY_CONFIG } from './config.js';
import { monitoring } from './monitoring.js';
import * as fs from 'fs/promises';
import * as path from 'path';
export class AdvancedBrowserController {
sessions;
tabManagement = new Map();
networkLogs = new Map();
downloadPaths = new Map();
constructor(sessions) {
this.sessions = sessions;
}
async createNewTab(sessionId, url) {
const session = this.sessions.get(sessionId);
if (!session)
throw new Error(`Session ${sessionId} not found`);
if (url) {
validateUrl(url, SECURITY_CONFIG);
}
const newPage = await session.context.newPage();
if (!this.tabManagement.has(sessionId)) {
this.tabManagement.set(sessionId, [session.page]);
}
const tabs = this.tabManagement.get(sessionId);
tabs.push(newPage);
if (url) {
await newPage.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
}
monitoring.auditLog({
level: 'info',
sessionId,
action: 'tab_created',
details: { tabIndex: tabs.length - 1, url: url || 'about:blank' }
});
return {
tabIndex: tabs.length - 1,
url: url || 'about:blank'
};
}
async switchToTab(sessionId, tabIndex) {
const tabs = this.tabManagement.get(sessionId);
if (!tabs || tabIndex < 0 || tabIndex >= tabs.length) {
throw new Error(`Tab ${tabIndex} not found in session ${sessionId}`);
}
const targetPage = tabs[tabIndex];
await targetPage.bringToFront();
// Update the session's active page reference
const session = this.sessions.get(sessionId);
session.page = targetPage;
const url = targetPage.url();
const title = await targetPage.title();
monitoring.auditLog({
level: 'info',
sessionId,
action: 'tab_switched',
details: { tabIndex, url, title }
});
return { success: true, url, title };
}
async closeTab(sessionId, tabIndex) {
const tabs = this.tabManagement.get(sessionId);
if (!tabs || tabIndex < 0 || tabIndex >= tabs.length) {
throw new Error(`Tab ${tabIndex} not found in session ${sessionId}`);
}
if (tabs.length === 1) {
throw new Error('Cannot close the last tab in a session');
}
const pageToClose = tabs[tabIndex];
await pageToClose.close();
tabs.splice(tabIndex, 1);
// If we closed the active tab, switch to the first tab
const session = this.sessions.get(sessionId);
if (session.page === pageToClose) {
session.page = tabs[0];
await tabs[0].bringToFront();
}
monitoring.auditLog({
level: 'info',
sessionId,
action: 'tab_closed',
details: { tabIndex }
});
return { success: true };
}
async listTabs(sessionId) {
const tabs = this.tabManagement.get(sessionId);
const session = this.sessions.get(sessionId);
if (!tabs || !session) {
return [];
}
const tabInfos = [];
for (let i = 0; i < tabs.length; i++) {
const page = tabs[i];
try {
const url = page.url();
const title = await page.title();
const active = page === session.page;
tabInfos.push({ index: i, url, title, active });
}
catch (error) {
tabInfos.push({
index: i,
url: 'error',
title: 'Error retrieving tab info',
active: false
});
}
}
return tabInfos;
}
async fillForm(sessionId, formData, submitSelector) {
const session = this.sessions.get(sessionId);
if (!session)
throw new Error(`Session ${sessionId} not found`);
let fieldsProcessed = 0;
for (const field of formData) {
validateSelector(field.selector, SECURITY_CONFIG);
validateText(field.value, SECURITY_CONFIG);
try {
const element = await session.page.waitForSelector(field.selector, { timeout: 5000 });
if (!element)
continue;
// Clear field first
await element.fill('');
// Handle different input types
if (field.type === 'password') {
await element.type(field.value, { delay: 50 }); // Slight delay for password fields
}
else {
await element.fill(field.value);
}
fieldsProcessed++;
}
catch (error) {
console.warn(`Failed to fill field ${field.selector}:`, error);
}
}
// Submit form if selector provided
if (submitSelector) {
validateSelector(submitSelector, SECURITY_CONFIG);
try {
await session.page.click(submitSelector);
}
catch (error) {
console.warn(`Failed to submit form with selector ${submitSelector}:`, error);
}
}
monitoring.auditLog({
level: 'info',
sessionId,
action: 'form_filled',
details: { fieldsProcessed, totalFields: formData.length, submitted: !!submitSelector }
});
return { success: true, fieldsProcessed };
}
async uploadFile(sessionId, fileSelector, filePath) {
const session = this.sessions.get(sessionId);
if (!session)
throw new Error(`Session ${sessionId} not found`);
validateSelector(fileSelector, SECURITY_CONFIG);
// Validate file path and extension
const fileExtension = path.extname(filePath).toLowerCase();
if (!SECURITY_CONFIG.allowedFileExtensions.includes(fileExtension)) {
throw new SecurityError(`File extension ${fileExtension} not allowed`);
}
// Check file size
try {
const stats = await fs.stat(filePath);
if (stats.size > SECURITY_CONFIG.maxFileSize) {
throw new SecurityError(`File size ${stats.size} exceeds maximum allowed size ${SECURITY_CONFIG.maxFileSize}`);
}
}
catch (error) {
throw new ValidationError(`Cannot access file: ${filePath}`);
}
const fileInput = await session.page.waitForSelector(fileSelector, { timeout: 10000 });
if (!fileInput) {
throw new Error(`File input selector '${fileSelector}' not found`);
}
await fileInput.setInputFiles(filePath);
monitoring.auditLog({
level: 'info',
sessionId,
action: 'file_uploaded',
details: { fileSelector, filePath: path.basename(filePath) }
});
return { success: true };
}
async downloadFile(sessionId, downloadSelector, downloadPath) {
const session = this.sessions.get(sessionId);
if (!session)
throw new Error(`Session ${sessionId} not found`);
validateSelector(downloadSelector, SECURITY_CONFIG);
const defaultDownloadPath = downloadPath || `./downloads/${sessionId}`;
await fs.mkdir(defaultDownloadPath, { recursive: true });
this.downloadPaths.set(sessionId, defaultDownloadPath);
// Set up download handling
const downloadPromise = session.page.waitForEvent('download');
// Trigger download
await session.page.click(downloadSelector);
const download = await downloadPromise;
const filename = download.suggestedFilename() || `download_${Date.now()}`;
const filePath = path.join(defaultDownloadPath, filename);
await download.saveAs(filePath);
monitoring.auditLog({
level: 'info',
sessionId,
action: 'file_downloaded',
details: { downloadSelector, filePath: filename }
});
return { success: true, filePath };
}
async takeAdvancedScreenshot(sessionId, options = {}) {
const session = this.sessions.get(sessionId);
if (!session)
throw new Error(`Session ${sessionId} not found`);
const { element, quality = 90, format = 'png', clip } = options;
let screenshotOptions = {
type: format,
quality: format === 'jpeg' ? quality : undefined,
clip
};
let screenshot;
let metadata = {
format,
timestamp: Date.now(),
url: session.page.url(),
viewport: await session.page.viewportSize()
};
if (element) {
validateSelector(element, SECURITY_CONFIG);
const elementHandle = await session.page.waitForSelector(element, { timeout: 10000 });
if (!elementHandle) {
throw new Error(`Element '${element}' not found`);
}
screenshot = await elementHandle.screenshot(screenshotOptions);
metadata.element = element;
}
else {
screenshot = await session.page.screenshot(screenshotOptions);
}
return {
screenshot: screenshot.toString('base64'),
mimeType: `image/${format}`,
metadata
};
}
async scroll(sessionId, direction, distance = 500) {
const session = this.sessions.get(sessionId);
if (!session)
throw new Error(`Session ${sessionId} not found`);
const scrollMappings = {
up: `window.scrollBy(0, -${distance})`,
down: `window.scrollBy(0, ${distance})`,
left: `window.scrollBy(-${distance}, 0)`,
right: `window.scrollBy(${distance}, 0)`
};
await session.page.evaluate(scrollMappings[direction]);
return { success: true };
}
async dragAndDrop(sessionId, sourceSelector, targetSelector) {
const session = this.sessions.get(sessionId);
if (!session)
throw new Error(`Session ${sessionId} not found`);
validateSelector(sourceSelector, SECURITY_CONFIG);
validateSelector(targetSelector, SECURITY_CONFIG);
const sourceElement = await session.page.waitForSelector(sourceSelector, { timeout: 10000 });
const targetElement = await session.page.waitForSelector(targetSelector, { timeout: 10000 });
if (!sourceElement || !targetElement) {
throw new Error('Source or target element not found for drag and drop');
}
// Drag and drop using mouse actions
const sourceBox = await sourceElement.boundingBox();
const targetBox = await targetElement.boundingBox();
if (sourceBox && targetBox) {
await session.page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2);
await session.page.mouse.down();
await session.page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2);
await session.page.mouse.up();
}
monitoring.auditLog({
level: 'info',
sessionId,
action: 'drag_drop',
details: { sourceSelector, targetSelector }
});
return { success: true };
}
async waitForNavigation(sessionId, timeout = 30000, waitUntil = 'load') {
const session = this.sessions.get(sessionId);
if (!session)
throw new Error(`Session ${sessionId} not found`);
await session.page.waitForLoadState(waitUntil, { timeout });
const url = session.page.url();
return { success: true, url };
}
async enableNetworkLogging(sessionId) {
const session = this.sessions.get(sessionId);
if (!session)
throw new Error(`Session ${sessionId} not found`);
if (!this.networkLogs.has(sessionId)) {
this.networkLogs.set(sessionId, []);
}
const logs = this.networkLogs.get(sessionId);
session.page.on('request', (request) => {
const startTime = Date.now();
request.response().then((response) => {
if (response) {
logs.push({
url: request.url(),
method: request.method(),
status: response.status(),
headers: response.headers(),
size: 0, // TODO: Calculate actual size
duration: Date.now() - startTime,
timestamp: startTime
});
}
}).catch(() => {
// Ignore failed requests for logging
});
});
return { success: true };
}
async getNetworkLogs(sessionId, includeHeaders = false) {
const logs = this.networkLogs.get(sessionId) || [];
if (!includeHeaders) {
return logs.map(log => ({ ...log, headers: {} }));
}
return logs;
}
async getPerformanceMetrics(sessionId) {
const session = this.sessions.get(sessionId);
if (!session)
throw new Error(`Session ${sessionId} not found`);
const metrics = await session.page.evaluate(() => {
const perf = performance.getEntriesByType('navigation')[0];
const paintEntries = performance.getEntriesByType('paint');
const lcpEntries = performance.getEntriesByType('largest-contentful-paint');
return {
loadTime: perf.loadEventEnd - perf.navigationStart,
domContentLoaded: perf.domContentLoadedEventEnd - perf.navigationStart,
firstContentfulPaint: paintEntries.find(e => e.name === 'first-contentful-paint')?.startTime || 0,
largestContentfulPaint: lcpEntries[lcpEntries.length - 1]?.startTime || 0,
totalBytes: 0, // Will be calculated from network logs
requestCount: performance.getEntriesByType('resource').length,
jsHeapUsedSize: performance.memory?.usedJSHeapSize || 0
};
});
// Add network data from logs
const networkLogs = this.networkLogs.get(sessionId) || [];
metrics.totalBytes = networkLogs.reduce((total, log) => total + log.size, 0);
return metrics;
}
async runAccessibilityAudit(sessionId, selector) {
const session = this.sessions.get(sessionId);
if (!session)
throw new Error(`Session ${sessionId} not found`);
if (selector) {
validateSelector(selector, SECURITY_CONFIG);
}
// Inject axe-core for accessibility testing
await session.page.addScriptTag({
url: 'https://cdn.jsdelivr.net/npm/axe-core@4.7.2/axe.min.js'
});
const results = await session.page.evaluate((targetSelector) => {
return new Promise((resolve) => {
const axe = window.axe;
const options = targetSelector ? { include: [targetSelector] } : {};
axe.run(options, (err, results) => {
if (err) {
resolve({ violations: [], summary: { total: 0, critical: 0, serious: 0, moderate: 0, minor: 0 } });
return;
}
const violations = results.violations.map((violation) => ({
id: violation.id,
impact: violation.impact,
description: violation.description,
nodes: violation.nodes.map((node) => ({
target: node.target.join(' '),
html: node.html
}))
}));
const summary = violations.reduce((acc, violation) => {
acc.total += violation.nodes.length;
acc[violation.impact] = (acc[violation.impact] || 0) + violation.nodes.length;
return acc;
}, { total: 0, critical: 0, serious: 0, moderate: 0, minor: 0 });
resolve({ violations, summary });
});
});
}, selector);
return results;
}
async cleanup(sessionId) {
// Clean up tabs
const tabs = this.tabManagement.get(sessionId);
if (tabs) {
for (const tab of tabs) {
try {
await tab.close();
}
catch (error) {
console.warn('Error closing tab:', error);
}
}
this.tabManagement.delete(sessionId);
}
// Clean up network logs
this.networkLogs.delete(sessionId);
this.downloadPaths.delete(sessionId);
}
}
//# sourceMappingURL=advanced-browser.js.map