@cgaspard/webappmcp
Version:
WebApp MCP - Model Context Protocol integration for web applications with server-side debugging tools
876 lines • 34.6 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.WebAppMCPClient = void 0;
const devtools_1 = require("./devtools");
class WebAppMCPClient {
get isConnected() {
return this._isConnected && this.ws?.readyState === WebSocket.OPEN;
}
constructor(config) {
this.ws = null;
this.reconnectAttempts = 0;
this.messageHandlers = new Map();
this.consoleLogs = [];
this._isConnected = false;
this.devTools = null;
this.pluginHandlers = {};
this.config = {
reconnectInterval: 5000,
maxReconnectAttempts: 10,
enableDevTools: true,
debug: false,
enableConnection: true,
interceptConsole: true,
enabledTools: [], // Empty array means all tools enabled
devToolsPosition: 'bottom-right',
devToolsTheme: 'dark',
...config,
};
// Only intercept console if enabled
if (this.config.interceptConsole) {
this.setupConsoleInterception();
}
// Auto-load html2canvas for screenshot functionality
this.loadHtml2Canvas();
if (this.config.enableDevTools && this.config.enableConnection) {
this.devTools = new devtools_1.MCPDevTools({
position: this.config.devToolsPosition,
theme: this.config.devToolsTheme,
});
this.devTools.setConnectionStatus('disconnected');
}
}
log(...args) {
if (this.config.debug) {
console.log('[webappmcp]', ...args);
}
}
logError(...args) {
// Always log errors regardless of debug setting
console.error('[webappmcp]', ...args);
}
connect() {
// Bypass connection if disabled (e.g., in production)
if (!this.config.enableConnection) {
this.log('Connection disabled by configuration');
return;
}
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
return;
}
this.devTools?.setConnectionStatus('connecting');
this.devTools?.logWebSocketEvent('Attempting to connect', { url: this.config.serverUrl });
try {
const url = new URL(this.config.serverUrl);
const headers = {};
if (this.config.authToken) {
headers['Authorization'] = `Bearer ${this.config.authToken}`;
}
// WebSocket in browser doesn't support custom headers directly
// So we'll pass the token in the URL
if (this.config.authToken) {
url.searchParams.set('token', this.config.authToken);
}
this.ws = new WebSocket(url.toString());
this.setupWebSocketHandlers();
}
catch (error) {
this.logError('Failed to connect to WebApp MCP server:', error);
this.devTools?.logError('websocket', 'Failed to connect', error);
this.scheduleReconnect();
}
}
disconnect() {
this._isConnected = false;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
setupWebSocketHandlers() {
if (!this.ws)
return;
this.ws.onopen = () => {
this.log('Connected to WebApp MCP server');
this._isConnected = true;
this.reconnectAttempts = 0;
this.devTools?.setConnectionStatus('connected');
this.devTools?.logWebSocketEvent('Connected to WebApp MCP server');
this.sendMessage({
type: 'init',
url: window.location.href,
});
};
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
this.devTools?.logWebSocketEvent('Message received', message);
this.handleMessage(message);
}
catch (error) {
this.logError('Failed to parse WebSocket message:', error);
this.devTools?.logError('websocket', 'Failed to parse message', error);
}
};
this.ws.onerror = (error) => {
this.logError('WebSocket error:', error);
this.devTools?.logError('websocket', 'WebSocket error', error);
};
this.ws.onclose = () => {
this.log('Disconnected from WebApp MCP server');
this._isConnected = false;
this.devTools?.setConnectionStatus('disconnected');
this.devTools?.logWebSocketEvent('Disconnected from WebApp MCP server');
this.scheduleReconnect();
};
}
scheduleReconnect() {
if (this.reconnectAttempts >= (this.config.maxReconnectAttempts || 10) ||
this.reconnectTimer) {
return;
}
this.reconnectAttempts++;
this.log(`Scheduling reconnect attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts}`);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, this.config.reconnectInterval);
}
sendMessage(message) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
this.devTools?.logWebSocketEvent('Message sent', message);
}
else {
this.logError('WebSocket is not connected');
}
}
handleMessage(message) {
const { type, requestId, tool, args } = message;
this.log('[WebApp Client] Received message:', JSON.stringify(message));
if (type === 'connected') {
this.log('WebApp MCP client registered:', message.clientId);
this.devTools?.logMCPEvent('Client registered', { clientId: message.clientId });
return;
}
if (type === 'execute_tool') {
this.log(`[WebApp Client] Executing tool: ${tool} with requestId: ${requestId}`);
this.devTools?.logMCPEvent(`Executing tool: ${tool}`, { requestId, args });
this.executeToolHandler(requestId, tool, args);
return;
}
if (type === 'plugin_extension') {
this.log(`[WebApp Client] Loading plugin extension`);
this.loadPluginExtension(message.extension);
return;
}
const handler = this.messageHandlers.get(type);
if (handler) {
handler(message);
}
}
async executeToolHandler(requestId, toolName, args) {
// Check if tool is enabled
if (this.config.enabledTools && this.config.enabledTools.length > 0) {
if (!this.config.enabledTools.includes(toolName)) {
const error = `Tool ${toolName} is not enabled`;
this.logError(error);
this.devTools?.logToolExecution(toolName, args, false, error);
this.sendMessage({
type: 'tool_response',
requestId,
success: false,
error,
});
return;
}
}
this.log(`[WebApp Client] Executing tool handler for ${toolName}`);
this.log(`[WebApp Client] Tool args:`, JSON.stringify(args));
this.devTools?.logToolExecution(toolName, args, null, 'Started');
const startTime = Date.now();
try {
let result;
switch (toolName) {
case 'dom_query':
result = await this.domQuery(args);
break;
case 'dom_get_properties':
result = await this.domGetProperties(args);
break;
case 'dom_get_text':
result = await this.domGetText(args);
break;
case 'dom_get_html':
result = await this.domGetHTML(args);
break;
case 'interaction_click':
result = await this.interactionClick(args);
break;
case 'interaction_type':
result = await this.interactionType(args);
break;
case 'interaction_scroll':
result = await this.interactionScroll(args);
break;
case 'interaction_hover':
result = await this.interactionHover(args);
break;
case 'capture_screenshot':
result = await this.captureScreenshot(args);
break;
case 'capture_element_screenshot':
result = await this.captureElementScreenshot(args);
break;
case 'state_get_variable':
result = await this.stateGetVariable(args);
break;
case 'state_local_storage':
result = await this.stateLocalStorage(args);
break;
case 'console_get_logs':
result = await this.consoleGetLogs(args);
break;
case 'console_save_to_file':
result = await this.consoleSaveToFile(args);
break;
case 'dom_manipulate':
result = await this.domManipulate(args);
break;
case 'javascript_inject':
result = await this.javascriptInject(args);
break;
case 'webapp_list_clients':
result = await this.webappListClients(args);
break;
case 'execute_javascript':
result = await this.executeJavascript(args);
break;
default:
// Check if this is a plugin-provided tool
if (this.pluginHandlers && this.pluginHandlers[toolName]) {
result = await this.pluginHandlers[toolName](args);
}
else {
throw new Error(`Unknown tool: ${toolName}`);
}
}
const executionTime = Date.now() - startTime;
this.log(`[WebApp Client] Tool execution successful, sending response`);
this.devTools?.logToolExecution(toolName, args, true, 'Success', executionTime, result);
this.sendMessage({
type: 'tool_response',
requestId,
result,
success: true,
});
}
catch (error) {
const executionTime = Date.now() - startTime;
const errorMessage = error instanceof Error ? error.message : String(error);
this.logError(`[WebApp Client] Tool execution failed:`, error);
this.devTools?.logToolExecution(toolName, args, false, errorMessage, executionTime);
this.sendMessage({
type: 'tool_response',
requestId,
success: false,
error: errorMessage,
});
}
}
loadHtml2Canvas() {
// Check if html2canvas is already loaded
if (typeof window.html2canvas !== 'undefined') {
return;
}
// Create script element
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js';
script.async = true;
script.onload = () => {
if (this.config.debug) {
console.log('[WebAppMCP] html2canvas loaded successfully');
}
};
script.onerror = () => {
console.warn('[WebAppMCP] Failed to load html2canvas - screenshots will use fallback mode');
};
document.head.appendChild(script);
}
setupConsoleInterception() {
const originalConsole = {
log: console.log,
info: console.info,
warn: console.warn,
error: console.error,
};
const interceptor = (level) => {
return (...args) => {
this.consoleLogs.push({
level,
timestamp: new Date().toISOString(),
args: args.map((arg) => {
try {
return typeof arg === 'object' ? JSON.stringify(arg) : String(arg);
}
catch {
return String(arg);
}
}),
});
if (this.consoleLogs.length > 1000) {
this.consoleLogs.shift();
}
originalConsole[level](...args);
};
};
console.log = interceptor('log');
console.info = interceptor('info');
console.warn = interceptor('warn');
console.error = interceptor('error');
}
async domQuery(args) {
const { selector, limit = 10 } = args;
const elements = Array.from(document.querySelectorAll(selector)).slice(0, limit);
return {
elements: elements.map((el) => ({
selector,
tagName: el.tagName.toLowerCase(),
id: el.id || undefined,
className: el.className || undefined,
text: el.textContent?.trim().substring(0, 100),
attributes: (() => {
const attrs = {};
for (let i = 0; i < el.attributes.length; i++) {
const attr = el.attributes[i];
attrs[attr.name] = attr.value;
}
return attrs;
})(),
})),
};
}
async domGetProperties(args) {
const { selector, properties = [] } = args;
const element = document.querySelector(selector);
if (!element) {
throw new Error(`Element not found: ${selector}`);
}
const result = {};
for (const prop of properties) {
try {
result[prop] = element[prop];
}
catch {
result[prop] = undefined;
}
}
return result;
}
async domGetText(args) {
const { selector, includeHidden = false } = args;
const elements = document.querySelectorAll(selector);
const texts = [];
elements.forEach((el) => {
if (includeHidden || el.offsetParent !== null) {
const text = el.textContent?.trim();
if (text)
texts.push(text);
}
});
return { texts };
}
async domGetHTML(args) {
const { selector, outerHTML = false } = args;
const element = document.querySelector(selector);
if (!element) {
throw new Error(`Element not found: ${selector}`);
}
return {
html: outerHTML ? element.outerHTML : element.innerHTML,
};
}
async interactionClick(args) {
const { selector, button = 'left' } = args;
const element = document.querySelector(selector);
if (!element) {
throw new Error(`Element not found: ${selector}`);
}
const event = new MouseEvent('click', {
view: window,
bubbles: true,
cancelable: true,
button: button === 'right' ? 2 : button === 'middle' ? 1 : 0,
});
element.dispatchEvent(event);
return { success: true };
}
async interactionType(args) {
const { selector, text, clear = false } = args;
const element = document.querySelector(selector);
if (!element) {
throw new Error(`Element not found: ${selector}`);
}
if (clear) {
element.value = '';
}
element.focus();
element.value += text;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return { success: true };
}
async interactionScroll(args) {
const { selector, direction, amount = 100 } = args;
const element = selector ? document.querySelector(selector) : window;
if (!element && selector) {
throw new Error(`Element not found: ${selector}`);
}
const scrollOptions = {
behavior: 'smooth',
};
if (direction === 'up' || direction === 'down') {
scrollOptions.top = direction === 'down' ? amount : -amount;
}
else {
scrollOptions.left = direction === 'right' ? amount : -amount;
}
if (element === window) {
window.scrollBy(scrollOptions);
}
else {
element.scrollBy(scrollOptions);
}
return { success: true };
}
async interactionHover(args) {
const { selector } = args;
const element = document.querySelector(selector);
if (!element) {
throw new Error(`Element not found: ${selector}`);
}
element.dispatchEvent(new MouseEvent('mouseenter', {
view: window,
bubbles: true,
cancelable: true,
}));
element.dispatchEvent(new MouseEvent('mouseover', {
view: window,
bubbles: true,
cancelable: true,
}));
return { success: true };
}
async captureScreenshot(args) {
const { fullPage = true, format = 'png' } = args;
try {
// Use a more sophisticated approach to capture actual content
const width = fullPage ? Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, document.documentElement.offsetWidth, document.body.offsetWidth, document.documentElement.clientWidth) : window.innerWidth;
const height = fullPage ? Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, document.documentElement.offsetHeight, document.body.offsetHeight, document.documentElement.clientHeight) : window.innerHeight;
// Create a canvas to draw the content
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to create canvas context');
}
// Try to use html2canvas if available
if (typeof window.html2canvas !== 'undefined') {
const html2canvas = window.html2canvas;
const capturedCanvas = await html2canvas(document.body, {
width: width,
height: height,
windowWidth: width,
windowHeight: height,
x: 0,
y: 0,
scrollX: fullPage ? 0 : window.scrollX,
scrollY: fullPage ? 0 : window.scrollY,
useCORS: true,
allowTaint: true
});
const dataUrl = capturedCanvas.toDataURL(`image/${format}`);
return {
success: true,
dataUrl,
width,
height,
message: 'Screenshot captured successfully'
};
}
// Fallback: Create a more detailed representation
// This is still a fallback but provides more information than a blank placeholder
// Fill background
const bgColor = window.getComputedStyle(document.body).backgroundColor || '#ffffff';
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, width, height);
// Add some context about the page
ctx.fillStyle = '#666';
ctx.font = '14px system-ui, -apple-system, sans-serif';
ctx.textAlign = 'left';
const info = [
`Page Title: ${document.title}`,
`URL: ${window.location.href}`,
`Dimensions: ${width}x${height}`,
'',
'Note: For full screenshot functionality, include html2canvas library:',
'<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>'
];
let y = 30;
info.forEach(line => {
ctx.fillText(line, 20, y);
y += 25;
});
// Draw a border
ctx.strokeStyle = '#ddd';
ctx.lineWidth = 2;
ctx.strokeRect(1, 1, width - 2, height - 2);
const dataUrl = canvas.toDataURL(`image/${format}`);
return {
success: true,
dataUrl,
width,
height,
message: 'Screenshot captured (basic mode - add html2canvas for full rendering)'
};
}
catch (error) {
throw new Error(`Failed to capture screenshot: ${error}`);
}
}
async captureElementScreenshot(args) {
const { selector, format = 'png' } = args;
if (!selector) {
throw new Error('Selector is required for element screenshot');
}
const element = document.querySelector(selector);
if (!element) {
throw new Error(`Element not found: ${selector}`);
}
try {
const rect = element.getBoundingClientRect();
// Try to use html2canvas if available
if (typeof window.html2canvas !== 'undefined') {
const html2canvas = window.html2canvas;
const capturedCanvas = await html2canvas(element, {
width: rect.width,
height: rect.height,
x: rect.left + window.scrollX,
y: rect.top + window.scrollY,
scrollX: -rect.left,
scrollY: -rect.top,
useCORS: true,
allowTaint: true
});
const dataUrl = capturedCanvas.toDataURL(`image/${format}`);
return {
success: true,
dataUrl,
width: rect.width,
height: rect.height,
selector,
message: 'Element screenshot captured successfully'
};
}
// Fallback approach
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to create canvas context');
}
canvas.width = rect.width;
canvas.height = rect.height;
// Get element styles
const styles = window.getComputedStyle(element);
const bgColor = styles.backgroundColor || '#ffffff';
// Draw element representation
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, rect.width, rect.height);
// Draw border
ctx.strokeStyle = styles.borderColor || '#ddd';
ctx.lineWidth = parseInt(styles.borderWidth) || 1;
ctx.strokeRect(0, 0, rect.width, rect.height);
// Add element info
ctx.fillStyle = '#666';
ctx.font = '12px system-ui, -apple-system, sans-serif';
ctx.textAlign = 'center';
const lines = [
`Element: ${selector}`,
`Size: ${Math.round(rect.width)}x${Math.round(rect.height)}`,
`Tag: ${element.tagName.toLowerCase()}`,
element.className ? `Class: ${element.className}` : '',
'Add html2canvas for full rendering'
].filter(Boolean);
let y = Math.max(20, rect.height / 2 - (lines.length * 15) / 2);
lines.forEach(line => {
ctx.fillText(line, rect.width / 2, y);
y += 15;
});
const dataUrl = canvas.toDataURL(`image/${format}`);
return {
success: true,
dataUrl,
width: rect.width,
height: rect.height,
selector,
message: 'Element screenshot captured (basic mode - add html2canvas for full rendering)'
};
}
catch (error) {
throw new Error(`Failed to capture element screenshot: ${error}`);
}
}
async stateGetVariable(args) {
const { path } = args;
const parts = path.split('.');
let current = window;
for (const part of parts) {
if (current && typeof current === 'object' && part in current) {
current = current[part];
}
else {
throw new Error(`Variable not found: ${path}`);
}
}
return { value: current };
}
async stateLocalStorage(args) {
const { operation, key, value } = args;
switch (operation) {
case 'get':
return { value: localStorage.getItem(key) };
case 'set':
localStorage.setItem(key, value);
return { success: true };
case 'remove':
localStorage.removeItem(key);
return { success: true };
case 'clear':
localStorage.clear();
return { success: true };
case 'getAll':
const items = {};
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k)
items[k] = localStorage.getItem(k) || '';
}
return { items };
default:
throw new Error(`Unknown localStorage operation: ${operation}`);
}
}
async consoleGetLogs(args) {
const { level = 'all', limit = 100, regex } = args;
let logs = this.consoleLogs;
if (level !== 'all') {
logs = logs.filter((log) => log.level === level);
}
// Apply regex filtering if provided
if (regex) {
try {
const pattern = new RegExp(regex);
logs = logs.filter((log) => {
// Concatenate all log arguments into a single string for matching
const logMessage = log.args.map((arg) => {
try {
return typeof arg === 'object' ? JSON.stringify(arg) : String(arg);
}
catch {
return String(arg);
}
}).join(' ');
return pattern.test(logMessage);
});
}
catch (e) {
throw new Error(`Invalid regex pattern: ${regex}`);
}
}
return { logs: logs.slice(-limit) };
}
async consoleSaveToFile(args) {
const { level = 'all', format = 'json' } = args;
let logs = this.consoleLogs;
if (level !== 'all') {
logs = logs.filter((log) => log.level === level);
}
// Return logs with format for server-side processing
return {
logs: logs,
format: format
};
}
async domManipulate(args) {
const { action, selector, value, attribute, property } = args;
if (!selector) {
throw new Error('Selector is required for DOM manipulation');
}
const element = document.querySelector(selector);
if (!element) {
throw new Error(`Element not found: ${selector}`);
}
switch (action) {
case 'setAttribute':
if (!attribute || value === undefined) {
throw new Error('Attribute name and value are required for setAttribute');
}
element.setAttribute(attribute, value);
return { success: true, message: `Set attribute ${attribute}="${value}" on ${selector}` };
case 'removeAttribute':
if (!attribute) {
throw new Error('Attribute name is required for removeAttribute');
}
element.removeAttribute(attribute);
return { success: true, message: `Removed attribute ${attribute} from ${selector}` };
case 'setProperty':
if (!property || value === undefined) {
throw new Error('Property name and value are required for setProperty');
}
element[property] = value;
return { success: true, message: `Set property ${property}=${value} on ${selector}` };
case 'addClass':
if (!value) {
throw new Error('Class name is required for addClass');
}
element.classList.add(value);
return { success: true, message: `Added class "${value}" to ${selector}` };
case 'removeClass':
if (!value) {
throw new Error('Class name is required for removeClass');
}
element.classList.remove(value);
return { success: true, message: `Removed class "${value}" from ${selector}` };
case 'setInnerHTML':
if (value === undefined) {
throw new Error('HTML content is required for setInnerHTML');
}
element.innerHTML = value;
return { success: true, message: `Set innerHTML on ${selector}` };
case 'setTextContent':
if (value === undefined) {
throw new Error('Text content is required for setTextContent');
}
element.textContent = value;
return { success: true, message: `Set textContent on ${selector}` };
case 'setStyle':
if (!property || value === undefined) {
throw new Error('Style property and value are required for setStyle');
}
element.style[property] = value;
return { success: true, message: `Set style ${property}=${value} on ${selector}` };
case 'remove':
element.remove();
return { success: true, message: `Removed element ${selector}` };
default:
throw new Error(`Unknown DOM manipulation action: ${action}`);
}
}
async javascriptInject(args) {
const { code, returnValue = false } = args;
if (!code) {
throw new Error('JavaScript code is required');
}
try {
let result;
if (returnValue) {
// Use eval to return a value
result = eval(code);
}
else {
// Use Function constructor for execution without return
const func = new Function(code);
result = func();
}
return {
success: true,
result: result !== undefined ? result : null,
message: 'JavaScript executed successfully'
};
}
catch (error) {
throw new Error(`JavaScript execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
async webappListClients(args) {
// This returns information about the current client
return {
clients: [{
id: 'browser-client',
type: 'browser',
url: window.location.href,
userAgent: navigator.userAgent,
connected: this.isConnected,
timestamp: new Date().toISOString()
}]
};
}
async executeJavascript(args) {
const { code, returnValue = false, async = false } = args;
if (!code) {
throw new Error('JavaScript code is required');
}
try {
let result;
if (async) {
// Execute asynchronously
if (returnValue) {
result = await eval(`(async () => { return ${code}; })()`);
}
else {
result = await eval(`(async () => { ${code}; })()`);
}
}
else {
// Execute synchronously
if (returnValue) {
result = eval(`(() => { return ${code}; })()`);
}
else {
eval(code);
result = undefined;
}
}
return {
success: true,
result: result !== undefined ? result : null,
message: 'JavaScript executed successfully',
executionTime: new Date().toISOString()
};
}
catch (error) {
throw new Error(`JavaScript execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
loadPluginExtension(extension) {
try {
// Execute the plugin code
const pluginCode = `
(function() {
${extension.code}
})();
`;
eval(pluginCode);
this.log(`[WebApp Client] Plugin extension loaded successfully`);
}
catch (error) {
this.logError(`[WebApp Client] Failed to load plugin extension:`, error);
}
}
registerPluginHandler(toolName, handler) {
this.pluginHandlers[toolName] = handler;
this.log(`[WebApp Client] Registered plugin handler for tool: ${toolName}`);
}
}
exports.WebAppMCPClient = WebAppMCPClient;
// Default export
exports.default = WebAppMCPClient;
if (typeof window !== 'undefined') {
window.WebAppMCP = { WebAppMCPClient };
}
//# sourceMappingURL=index.js.map