@democratize-quality/mcp-server
Version:
MCP Server for democratizing quality through browser automation and comprehensive API testing capabilities
481 lines (428 loc) • 16.8 kB
JavaScript
const ToolBase = require('../../base/ToolBase');
const browserService = require('../../../services/browserService');
const fs = require('fs').promises;
const path = require('path');
/**
* Enhanced File Tool - Handle file uploads, downloads, and file system interactions
* Inspired by Playwright MCP file handling capabilities
*/
class BrowserFileTool extends ToolBase {
static definition = {
name: "browser_file",
description: "Handle file operations including uploads, downloads, and file input interactions. Supports various file formats and validation.",
input_schema: {
type: "object",
properties: {
browserId: {
type: "string",
description: "The ID of the browser instance"
},
action: {
type: "string",
enum: ["upload", "download", "setDownloadPath", "getDownloads", "clearDownloads"],
description: "The file operation to perform"
},
selector: {
type: "string",
description: "CSS selector for file input element (for upload action)"
},
filePath: {
type: "string",
description: "Path to the file to upload or download location"
},
files: {
type: "array",
items: { type: "string" },
description: "Array of file paths for multiple file upload"
},
downloadPath: {
type: "string",
description: "Directory path for downloads (for setDownloadPath action)"
},
url: {
type: "string",
description: "Direct download URL (for download action without clicking)"
},
fileName: {
type: "string",
description: "Specific filename for download"
},
timeout: {
type: "number",
default: 30000,
description: "Timeout in milliseconds for file operations"
},
waitForDownload: {
type: "boolean",
default: true,
description: "Whether to wait for download completion"
},
overwrite: {
type: "boolean",
default: false,
description: "Whether to overwrite existing files"
}
},
required: ["browserId", "action"]
},
output_schema: {
type: "object",
properties: {
success: { type: "boolean", description: "Whether the operation was successful" },
action: { type: "string", description: "The action that was performed" },
filePath: { type: "string", description: "Path of the uploaded/downloaded file" },
files: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
path: { type: "string" },
size: { type: "number" },
type: { type: "string" },
url: { type: "string" }
}
},
description: "List of files"
},
downloadInfo: {
type: "object",
properties: {
url: { type: "string" },
filename: { type: "string" },
state: { type: "string" },
totalBytes: { type: "number" },
receivedBytes: { type: "number" }
},
description: "Download progress information"
},
message: { type: "string", description: "Operation result message" },
browserId: { type: "string", description: "Browser instance ID" }
},
required: ["success", "action", "browserId"]
}
};
constructor() {
super();
this.downloadCallbacks = new Map(); // browserId -> callback functions
this.downloadStates = new Map(); // browserId -> download states
}
async execute(parameters) {
const {
browserId,
action,
selector,
filePath,
files = [],
downloadPath,
url,
fileName,
timeout = 30000,
waitForDownload = true,
overwrite = false
} = parameters;
const browser = browserService.getBrowserInstance(browserId);
if (!browser) {
throw new Error(`Browser instance '${browserId}' not found`);
}
const client = browser.client;
let result = {
success: false,
action: action,
browserId: browserId
};
switch (action) {
case 'upload':
if (!selector) {
throw new Error('Selector is required for upload action');
}
const uploadPaths = filePath ? [filePath] : files;
if (uploadPaths.length === 0) {
throw new Error('Either filePath or files array is required for upload');
}
await this.uploadFiles(client, selector, uploadPaths);
result.success = true;
result.files = await this.getFileInfo(uploadPaths);
result.message = `Uploaded ${uploadPaths.length} file(s)`;
break;
case 'download':
if (!url && !selector) {
throw new Error('Either URL or selector is required for download action');
}
const downloadResult = await this.downloadFile(client, url, selector, downloadPath, fileName, timeout, waitForDownload);
result.success = true;
result.downloadInfo = downloadResult;
result.filePath = downloadResult.path;
result.message = 'Download completed';
break;
case 'setDownloadPath':
if (!downloadPath) {
throw new Error('Download path is required for setDownloadPath action');
}
await this.setDownloadPath(client, downloadPath);
result.success = true;
result.message = `Download path set to: ${downloadPath}`;
break;
case 'getDownloads':
const downloads = this.getDownloadHistory(browserId);
result.success = true;
result.files = downloads;
result.message = `Found ${downloads.length} download(s)`;
break;
case 'clearDownloads':
this.clearDownloadHistory(browserId);
result.success = true;
result.message = 'Download history cleared';
break;
default:
throw new Error(`Unsupported file action: ${action}`);
}
return result;
}
/**
* Upload files to a file input element
*/
async uploadFiles(client, selector, filePaths) {
// Validate files exist
for (const filePath of filePaths) {
try {
await fs.access(filePath);
} catch (error) {
throw new Error(`File not found: ${filePath}`);
}
}
// Enable DOM and Runtime domains
await client.DOM.enable();
await client.Runtime.enable();
// Find the file input element
const document = await client.DOM.getDocument();
const element = await client.DOM.querySelector({
nodeId: document.root.nodeId,
selector: selector
});
if (!element.nodeId) {
throw new Error(`File input element not found with selector: ${selector}`);
}
// Get element attributes to verify it's a file input
const attributes = await client.DOM.getAttributes({
nodeId: element.nodeId
});
const attrMap = {};
for (let i = 0; i < attributes.attributes.length; i += 2) {
attrMap[attributes.attributes[i]] = attributes.attributes[i + 1];
}
if (attrMap.type !== 'file') {
throw new Error(`Element is not a file input: ${JSON.stringify(attrMap)}`);
}
// Set files on the input element
await client.DOM.setFileInputFiles({
files: filePaths,
nodeId: element.nodeId
});
// Trigger change event
await client.Runtime.evaluate({
expression: `
(function() {
const element = document.querySelector('${selector}');
if (element) {
const event = new Event('change', { bubbles: true });
element.dispatchEvent(event);
return true;
}
return false;
})()
`
});
}
/**
* Download a file either by URL or by clicking an element
*/
async downloadFile(client, url, selector, downloadPath, fileName, timeout, waitForDownload) {
// Enable Page domain for download events
await client.Page.enable();
let downloadInfo = null;
const downloadPromise = new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error(`Download timeout after ${timeout}ms`));
}, timeout);
// Listen for download events
client.Page.downloadWillBegin((params) => {
clearTimeout(timeoutId);
downloadInfo = {
url: params.url,
filename: params.suggestedFilename,
guid: params.guid
};
});
client.Page.downloadProgress((params) => {
if (downloadInfo && params.guid === downloadInfo.guid) {
downloadInfo.state = params.state;
downloadInfo.totalBytes = params.totalBytes;
downloadInfo.receivedBytes = params.receivedBytes;
if (params.state === 'completed') {
resolve(downloadInfo);
} else if (params.state === 'canceled' || params.state === 'interrupted') {
reject(new Error(`Download ${params.state}: ${downloadInfo.filename}`));
}
}
});
});
// Set download behavior if path is specified
if (downloadPath) {
await this.setDownloadPath(client, downloadPath);
}
// Initiate download
if (url) {
// Direct URL download
await client.Page.navigate({ url: url });
} else if (selector) {
// Click element to trigger download
await client.Runtime.evaluate({
expression: `
(function() {
const element = document.querySelector('${selector}');
if (element) {
element.click();
return true;
}
return false;
})()
`
});
}
// Wait for download if requested
if (waitForDownload) {
downloadInfo = await downloadPromise;
// Store download info
this.addDownloadToHistory(browserService.getBrowserInstance(client.browserId)?.id || 'unknown', downloadInfo);
}
return downloadInfo || { state: 'initiated' };
}
/**
* Set the download directory
*/
async setDownloadPath(client, downloadPath) {
// Ensure directory exists
try {
await fs.mkdir(downloadPath, { recursive: true });
} catch (error) {
throw new Error(`Could not create download directory: ${error.message}`);
}
// Set download behavior
await client.Page.setDownloadBehavior({
behavior: 'allow',
downloadPath: downloadPath
});
}
/**
* Get file information for uploaded files
*/
async getFileInfo(filePaths) {
const fileInfos = [];
for (const filePath of filePaths) {
try {
const stats = await fs.stat(filePath);
const info = {
name: path.basename(filePath),
path: filePath,
size: stats.size,
type: this.getFileType(filePath),
lastModified: stats.mtime
};
fileInfos.push(info);
} catch (error) {
fileInfos.push({
name: path.basename(filePath),
path: filePath,
error: error.message
});
}
}
return fileInfos;
}
/**
* Get file type based on extension
*/
getFileType(filePath) {
const ext = path.extname(filePath).toLowerCase();
const mimeTypes = {
'.txt': 'text/plain',
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.mp4': 'video/mp4',
'.zip': 'application/zip',
'.json': 'application/json',
'.csv': 'text/csv'
};
return mimeTypes[ext] || 'application/octet-stream';
}
/**
* Add download to history
*/
addDownloadToHistory(browserId, downloadInfo) {
if (!this.downloadStates.has(browserId)) {
this.downloadStates.set(browserId, []);
}
const downloads = this.downloadStates.get(browserId);
downloads.push({
...downloadInfo,
timestamp: new Date().toISOString()
});
// Keep only last 100 downloads
if (downloads.length > 100) {
downloads.splice(0, downloads.length - 100);
}
}
/**
* Get download history for a browser
*/
getDownloadHistory(browserId) {
return this.downloadStates.get(browserId) || [];
}
/**
* Clear download history for a browser
*/
clearDownloadHistory(browserId) {
this.downloadStates.delete(browserId);
}
/**
* Create a temporary file for testing
*/
async createTempFile(content, extension = '.txt') {
const tempDir = process.env.TMPDIR || '/tmp';
const fileName = `temp-${Date.now()}${extension}`;
const filePath = path.join(tempDir, fileName);
await fs.writeFile(filePath, content);
return filePath;
}
/**
* Validate file upload constraints
*/
validateFileUpload(filePath, constraints = {}) {
const stats = require('fs').statSync(filePath);
const ext = path.extname(filePath).toLowerCase();
const validation = {
valid: true,
errors: []
};
// Check file size
if (constraints.maxSize && stats.size > constraints.maxSize) {
validation.valid = false;
validation.errors.push(`File size ${stats.size} exceeds maximum ${constraints.maxSize}`);
}
// Check file type
if (constraints.allowedTypes && !constraints.allowedTypes.includes(ext)) {
validation.valid = false;
validation.errors.push(`File type ${ext} not allowed. Allowed: ${constraints.allowedTypes.join(', ')}`);
}
return validation;
}
}
module.exports = BrowserFileTool;