UNPKG

@web-interact-mcp/client

Version:

A production-ready TypeScript library that transforms web applications into MCP (Model Context Protocol) servers with robust two-way communication via SignalR

1,194 lines (1,185 loc) 69.8 kB
/** * @fileoverview Web Interact MCP Controller - Production-ready controller for MCP tool execution and management * @description Enterprise-grade controller that transforms web applications into MCP servers with robust tool execution * @version 1.0.0 * @author Vijay Nirmal */ import { ShepherdBase } from 'shepherd.js'; import { createSuccessResult, createErrorResult, SuccessfulCallToolResult, LogLevel } from './types'; import { ToolRegistry } from './tool-registry'; import { WebInteractSignalRService } from './signalr.service'; import { ConsoleLogger } from './consoleLogger'; /** * Default Shepherd.js configuration for production use */ const DEFAULT_SHEPHERD_OPTIONS = { useModalOverlay: true, exitOnEsc: true, keyboardNavigation: true, defaultStepOptions: { classes: 'mcp-shepherd-tooltip', scrollTo: true, cancelIcon: { enabled: true } } }; /** * Default configuration options for production use */ const DEFAULT_OPTIONS = { serverUrl: 'http://localhost:8080', enableVisualFeedback: true, logLevel: LogLevel.WARN, stopOnFailure: false, elementTimeout: 5000, highlightDuration: 2000, focusEffectDuration: 1000, clickEffectDuration: 600, actionDelay: 500, defaultButtonlessDelay: 3000 }; /** * Production-ready controller class for Web Interact MCP * Provides comprehensive tool execution, registration, and management capabilities * with enterprise-grade logging, error handling, and monitoring */ export class WebInteractMCPController { /** * Creates a new production-ready WebInteractMCPController instance * @param options - Global configuration options * @param shepherdOptions - Optional Shepherd.js tour configuration (uses production defaults if not provided) * @param logger - Optional custom logger implementation (uses ConsoleLogger if not provided) */ constructor(options = {}, shepherdOptions = {}, logger) { // Tour and execution state this.shepherdTour = null; this.activeTool = null; this.toolQueue = []; this.currentStepIndex = 0; // Event handling this.eventListeners = new Map(); // Custom functionality this.customFunctions = new Map(); this.returnValueProviders = new Map(); // Execution tracking this.stepReturnValues = []; // Visual feedback this.customStyles = {}; this.styleElementId = 'mcp-visual-feedback-styles'; // Communication this.signalRService = null; // Initialize core components this.registry = new ToolRegistry(); // Set up logging this.logger = logger || new ConsoleLogger(options.logLevel || DEFAULT_OPTIONS.logLevel); // Configure global options this.globalOptions = { ...DEFAULT_OPTIONS, ...options }; // Update logger level if changed this.logger.setLevel(this.globalOptions.logLevel); // Configure Shepherd options with production defaults this.shepherdOptions = { ...DEFAULT_SHEPHERD_OPTIONS, ...shepherdOptions }; // Initialize visual styles from options if (options.visualEffectStyles) { this.customStyles = { ...options.visualEffectStyles }; } this.logger.info('WebInteractMCPController initialized', { logLevel: LogLevel[this.globalOptions.logLevel], enableVisualFeedback: this.globalOptions.enableVisualFeedback, serverUrl: this.globalOptions.serverUrl }); this.initializeEventListeners(); this.injectVisualFeedbackStyles(); } // ============================================================================= // PUBLIC API METHODS // ============================================================================= /** * Gets the tool registry instance for managing tool configurations * @returns The tool registry instance */ getRegistry() { return this.registry; } /** * Gets the current global configuration options * @returns A copy of the current global options */ getGlobalOptions() { return { ...this.globalOptions }; } /** * Gets the current logger instance * @returns The logger instance */ getLogger() { return this.logger; } /** * Updates global configuration options * @param options - Partial options to update */ updateGlobalOptions(options) { this.logger.debug('Updating global options', options); const previousOptions = { ...this.globalOptions }; this.globalOptions = { ...this.globalOptions, ...options }; // Update logger level if specified if (options.logLevel !== undefined) { this.logger.setLevel(options.logLevel); } // Update custom styles if provided if (options.visualEffectStyles) { this.customStyles = { ...this.customStyles, ...options.visualEffectStyles }; } // Re-inject styles if visual feedback was enabled if (options.enableVisualFeedback === true) { this.injectVisualFeedbackStyles(); } this.logger.info('Global options updated', { previous: previousOptions, current: this.globalOptions }); } /** * Updates the visual effect styles and regenerates the CSS * @param styles - Partial visual effect styles to update */ updateVisualEffectStyles(styles) { this.logger.debug('Updating visual effect styles', styles); this.customStyles = { ...this.customStyles, ...styles }; this.injectVisualFeedbackStyles(); this.logger.debug('Visual effect styles updated successfully', this.customStyles); } /** * Gets the current visual effect styles. * @returns The current visual effect styles. */ getVisualEffectStyles() { return { ...this.customStyles }; } /** * Resets visual effect styles to default values */ resetVisualEffectStyles() { this.logger.debug('Resetting visual effect styles to defaults'); this.customStyles = {}; this.injectVisualFeedbackStyles(); this.logger.debug('Visual effect styles reset successfully'); } /** * Sets a specific style property for a visual effect * @param effect - The visual effect to modify * @param property - The property to set * @param value - The value to set */ setVisualEffectProperty(effect, property, value) { this.logger.debug(`Setting visual effect property ${effect}.${property}`, value); if (!this.customStyles[effect]) { this.customStyles[effect] = {}; } this.customStyles[effect][property] = value; this.injectVisualFeedbackStyles(); this.logger.debug(`Visual effect property ${effect}.${property} set successfully`); } /** * Registers a custom function that can be called from tool steps * @param customFunction - The custom function configuration */ registerCustomFunction(customFunction) { this.logger.info(`Registering custom function: ${customFunction.name}`); this.customFunctions.set(customFunction.name, customFunction); this.logger.debug('Custom function registered successfully', customFunction); } /** * Registers multiple custom functions * @param functions - Array of custom function configurations */ registerCustomFunctions(functions) { this.logger.info(`Registering ${functions.length} custom functions`); functions.forEach(func => this.registerCustomFunction(func)); this.logger.debug('All custom functions registered successfully'); } /** * Unregisters a custom function * @param functionName - The name of the function to unregister */ unregisterCustomFunction(functionName) { const existed = this.customFunctions.has(functionName); this.customFunctions.delete(functionName); this.logger.info(`Custom function ${existed ? 'unregistered' : 'not found'}: ${functionName}`); } /** * Gets a registered custom function. * @param functionName - The name of the function to retrieve. * @returns The custom function configuration or undefined if not found. */ getCustomFunction(functionName) { return this.customFunctions.get(functionName); } /** * Gets all registered custom functions. * @returns Map of all registered custom functions. */ getAllCustomFunctions() { return new Map(this.customFunctions); } /** * Registers a return value provider function that can be called from tool steps * @param provider - The return value provider configuration */ registerReturnValueProvider(provider) { this.logger.info(`Registering return value provider: ${provider.name}`); this.returnValueProviders.set(provider.name, provider); this.logger.debug('Return value provider registered successfully', provider); } /** * Registers multiple return value provider functions * @param providers - Array of return value provider configurations */ registerReturnValueProviders(providers) { this.logger.info(`Registering ${providers.length} return value providers`); providers.forEach(provider => this.registerReturnValueProvider(provider)); this.logger.debug('All return value providers registered successfully'); } /** * Unregisters a return value provider function * @param providerName - The name of the provider to unregister */ unregisterReturnValueProvider(providerName) { const existed = this.returnValueProviders.has(providerName); this.returnValueProviders.delete(providerName); this.logger.info(`Return value provider ${existed ? 'unregistered' : 'not found'}: ${providerName}`); } /** * Gets a registered return value provider function. * @param providerName - The name of the provider to retrieve. * @returns The return value provider configuration or undefined if not found. */ getReturnValueProvider(providerName) { return this.returnValueProviders.get(providerName); } /** * Gets all registered return value provider functions. * @returns Map of all registered return value providers. */ getAllReturnValueProviders() { return new Map(this.returnValueProviders); } // ============================================================================= // PRIVATE UTILITY METHODS // ============================================================================= /** * Gets the effective options for a tool (merges global and tool-specific options) * @private * @param tool - The tool configuration * @returns The effective options for the tool */ getEffectiveOptions(tool) { return { ...this.globalOptions, ...tool.options }; } /** * Starts a sequence of Tools. * @param toolsToStart - An array of objects, each with a toolId and optional parameters to pass to the tool. * @returns Promise that resolves with an array of CallToolResult from each tool execution. */ async start(toolsToStart) { if (!Array.isArray(toolsToStart) || toolsToStart.length === 0) { this.logger.warn('No tools provided to start'); return []; } // Validate parameters for all tools before starting for (const toolConfig of toolsToStart) { const validation = this.validateToolParameters(toolConfig.toolId, toolConfig.params || {}); if (!validation.isValid) { const errorMsg = `Parameter validation failed for tool '${toolConfig.toolId}': ${validation.errors.join(', ')}`; this.logger.error(errorMsg); this.emit('cancel', { tool: this.registry.getToolById(toolConfig.toolId), error: errorMsg, validationErrors: validation.errors }); return [createErrorResult(errorMsg)]; } if (validation.warnings.length > 0) { this.logger.warn(`Parameter validation warnings for tool '${toolConfig.toolId}':`, validation.warnings); } // Apply default values to parameters toolConfig.params = this.applyParameterDefaults(toolConfig.toolId, toolConfig.params || {}); } // Stop any currently running tool this.stop(); // Queue the tools this.toolQueue = [...toolsToStart]; this.logger.debug('Starting tool sequence', toolsToStart.map(t => t.toolId)); const results = []; while (this.toolQueue.length > 0) { const { toolId, params = {} } = this.toolQueue.shift(); const result = await this.executeTool(toolId, params); results.push(result); } return results; } /** * Stops the currently active tool and clears the queue. */ stop() { if (this.shepherdTour) { this.shepherdTour.cancel(); this.shepherdTour = null; } const currentTool = this.activeTool; this.activeTool = null; this.toolQueue = []; this.currentStepIndex = 0; this.emit('cancel', { tool: currentTool }); } /** * Manually advances to the next step (for 'normal' mode). */ next() { if (this.shepherdTour && this.activeTool?.mode === 'normal') { this.shepherdTour.next(); } } /** * Manually goes back to the previous step (for 'normal' mode). */ back() { if (this.shepherdTour && this.activeTool?.mode === 'normal') { this.shepherdTour.back(); } } /** * Registers an event listener for tool events. * @param eventName - The name of the event. * @param handler - The callback function. */ on(eventName, handler) { if (!this.eventListeners.has(eventName)) { this.eventListeners.set(eventName, []); } this.eventListeners.get(eventName).push(handler); } /** * Removes an event listener. * @param eventName - The name of the event. * @param handler - The callback function to remove. */ off(eventName, handler) { const handlers = this.eventListeners.get(eventName); if (handlers) { const index = handlers.indexOf(handler); if (index > -1) { handlers.splice(index, 1); } } } /** * Emits an event to all registered listeners. * @private * @param eventName - The name of the event to emit. * @param data - Optional data to pass to the event handlers. */ emit(eventName, data) { const handlers = this.eventListeners.get(eventName) || []; handlers.forEach(handler => { try { handler(data); } catch (error) { this.logger.error(`Error in event handler for ${eventName}:`, error); } }); } /** * Executes the next tool in the queue. * @private */ async executeNextTool() { if (this.toolQueue.length === 0) { return; } const { toolId, params = {} } = this.toolQueue.shift(); await this.executeTool(toolId, params); } /** * Executes a single tool. * @private * @param toolId - The ID of the tool to execute. * @param params - Optional parameters to pass to the tool. * @returns Promise that resolves with the tool execution result. */ async executeTool(toolId, params = {}) { this.logger.debug(`Looking for tool: ${toolId}`); const tool = this.registry.getToolById(toolId); if (!tool) { this.logger.error(`Tool with ID '${toolId}' not found`); this.logger.debug('Available tools:', Array.from(this.registry.getAllTools().keys())); const error = new Error(`Tool with ID '${toolId}' not found`); return createErrorResult(error); } this.activeTool = tool; this.currentStepIndex = 0; this.stepReturnValues = []; // Reset step return values for new tool this.logger.debug(`Starting tool: ${tool.title} (${tool.mode} mode)`, { params }); this.emit('start', { tool, params }); try { let result; switch (tool.mode) { case 'normal': result = await this.executeNormalMode(tool, params); break; case 'buttonless': result = await this.executeButtonlessMode(tool, params); break; case 'silent': result = await this.executeSilentMode(tool, params); break; default: { const error = new Error(`Unknown tool mode: ${tool.mode}`); this.logger.error(error.message); this.emit('cancel', { tool, error }); return createErrorResult(error); } } return result; } catch (error) { this.logger.error('Error executing tool:', error); this.emit('cancel', { tool, error }); return createErrorResult(error instanceof Error ? error : new Error(String(error))); } } /** * Executes a tool in normal mode (with buttons). * @private */ async executeNormalMode(tool, params) { const steps = this.prepareSteps(tool, params); return new Promise((resolve) => { this.shepherdTour = new (new ShepherdBase()).Tour({ useModalOverlay: true, ...this.shepherdOptions }); steps.forEach((step, index) => { this.shepherdTour.addStep({ id: `step-${index}`, // Explicitly set ID for consistency title: tool.title, text: step.content, attachTo: { element: step.targetElement, on: 'bottom' }, buttons: this.createStepButtons(index, steps.length), ...step.shepherdOptions }); }); // Set up event handlers with return value support this.setupTourEventHandlersWithReturnValues(resolve); this.shepherdTour.start(); }); } /** * Executes a tool in buttonless mode (auto-advancing). * @private */ async executeButtonlessMode(tool, params) { const steps = this.prepareSteps(tool, params); const effectiveOptions = this.getEffectiveOptions(tool); return new Promise((resolve) => { this.shepherdTour = new (new ShepherdBase()).Tour({ useModalOverlay: true, ...this.shepherdOptions }); steps.forEach((step, index) => { this.shepherdTour.addStep({ id: `step-${index}`, // Explicitly set ID to match index title: tool.title, text: step.content, attachTo: { element: step.targetElement, on: 'bottom' }, buttons: [], // No buttons in buttonless mode ...step.shepherdOptions }); }); // Set up auto-advance with delays and return value calculation this.shepherdTour.on('show', async () => { try { const currentStep = this.shepherdTour.getCurrentStep(); if (!currentStep || !currentStep.id) { this.logger.warn('No current step found for buttonless mode'); return; } // Extract index from step ID (format: "step-0", "step-1", etc.) const stepIndexMatch = currentStep.id.match(/step-(\d+)/); if (!stepIndexMatch) { this.logger.warn('Could not parse step index from ID:', currentStep.id); return; } const stepId = stepIndexMatch[1]; if (!stepId) { this.logger.warn('No step ID found for buttonless mode'); return; } const stepIndex = parseInt(stepId, 10); const step = steps[stepIndex]; if (!step) { this.logger.warn('Step not found at index:', stepIndex); return; } // Calculate return value for the current step const stepReturnValue = await this.calculateStepReturnValue(step, params, stepIndex, undefined); this.stepReturnValues[stepIndex] = stepReturnValue; const delay = step.delay || effectiveOptions.defaultButtonlessDelay; setTimeout(() => { if (this.shepherdTour && this.activeTool) { if (stepIndex < steps.length - 1) { this.shepherdTour.next(); } else { this.shepherdTour.complete(); } } }, delay); } catch (error) { this.logger.error('Error in buttonless mode auto-advance:', error); } }); this.setupTourEventHandlersWithReturnValues(resolve); this.shepherdTour.start(); }); } /** * Executes a tool in silent mode (no UI, automated actions). * @private */ async executeSilentMode(tool, params) { const steps = this.prepareSteps(tool, params); const effectiveOptions = this.getEffectiveOptions(tool); this.logger.debug('Executing silent mode tool:', tool.title, 'with', steps.length, 'steps'); try { for (let i = 0; i < steps.length; i++) { const step = steps[i]; if (!step) { this.logger.warn(`Step ${i + 1} is undefined, skipping...`); continue; } this.currentStepIndex = i; this.logger.debug(`Executing step ${i + 1}/${steps.length}:`, step); this.emit('step:show', { step, index: i, tool }); let stepResult; let actionResult = undefined; if (step.action) { this.logger.debug('Performing action for step', i + 1); try { actionResult = await this.performAction(step.action, step.targetElement, params); stepResult = actionResult; // performAction now returns CallToolResult directly } catch (stepError) { const errorMessage = stepError instanceof Error ? stepError.message : String(stepError); this.logger.error(`Error in step ${i + 1}:`, stepError); stepResult = createErrorResult(stepError instanceof Error ? stepError : new Error(errorMessage)); // Check if we should stop on failure (step-level or tool-level) const shouldStop = step.stopOnFailure !== undefined ? step.stopOnFailure : effectiveOptions.stopOnFailure; if (shouldStop) { throw new Error(`Step ${i + 1} failed and stopOnFailure is enabled: ${errorMessage}`); } // Continue with next step if stopOnFailure is false this.logger.debug(`Step ${i + 1} failed but continuing due to stopOnFailure=false`); } } else { this.logger.debug('No action defined for step', i + 1); stepResult = createSuccessResult(`Step ${i + 1} completed (no action)`, { skipped: true }); } // Calculate step return value const stepReturnValue = await this.calculateStepReturnValue(step, params, i, actionResult, stepResult); this.stepReturnValues.push(stepReturnValue); this.logger.debug(`Step ${i + 1} completed with return value:`, stepReturnValue); // Delay between actions to ensure DOM updates (configurable) const actionDelay = step.action?.delay || effectiveOptions.actionDelay; await new Promise(resolve => setTimeout(resolve, actionDelay)); } this.logger.debug('Silent mode tool completed successfully'); const toolResult = await this.calculateToolReturnValue(tool, params, steps.length, true); this.emit('complete', { tool, result: toolResult }); return toolResult; } catch (error) { this.logger.error('Error executing silent mode tool:', error); const toolResult = await this.calculateToolReturnValue(tool, params, this.currentStepIndex + 1, false, error instanceof Error ? error : new Error(String(error))); this.emit('cancel', { tool, error, result: toolResult }); return toolResult; } } /** * Calculates the return value for a step based on its configuration. * @private * @param step - The step configuration. * @param toolParams - Tool-level parameters. * @param stepIndex - The current step index. * @returns The calculated return value. */ async calculateStepReturnValue(step, toolParams, stepIndex, actionResult, currentResult = SuccessfulCallToolResult) { if (currentResult.isError === true) { return currentResult; } if (step.action?.type === 'executeFunction' && !step.returnValue) { return actionResult || createSuccessResult("Function executed successfully"); } if (!step.returnValue) { return SuccessfulCallToolResult; } const returnValueConfig = step.returnValue; // If there's a hardcoded value, return it if (returnValueConfig.value !== undefined) { const substitutedValue = this.substituteParams(returnValueConfig.value, toolParams); return createSuccessResult("Step completed with hardcoded value", { value: substitutedValue }); } // If there's a provider function, call it if (returnValueConfig.provider || returnValueConfig.providerName) { const provider = returnValueConfig.provider || (returnValueConfig.providerName ? this.returnValueProviders.get(returnValueConfig.providerName)?.implementation : undefined); if (!provider) { console.warn(`Return value provider not found: ${returnValueConfig.providerName}`); return createErrorResult(`Return value provider not found: ${returnValueConfig.providerName}`); } const targetElement = await this.waitForElement(step.targetElement); if (!targetElement) { this.logger.warn(`Target element not found for return value calculation: ${step.targetElement}`); return createErrorResult(`Target element not found for return value calculation: ${step.targetElement}`); } const context = { element: targetElement, stepParams: returnValueConfig.providerParams || {}, toolParams, controller: this, logger: this.logger, activeTool: this.activeTool, currentStepIndex: stepIndex, previousStepReturnValue: stepIndex > 0 ? this.stepReturnValues[stepIndex - 1] : undefined, actionResult: actionResult }; try { return await provider(context); } catch (error) { this.logger.error('Error executing return value provider:', error); return createErrorResult(error instanceof Error ? error : new Error(String(error))); } } return SuccessfulCallToolResult; } /** * Calculates the tool-level return value based on the tool configuration. * @private * @param tool - The tool configuration. * @param toolParams - Tool-level parameters. * @param stepsExecuted - Number of steps executed. * @param toolExecutionSuccess - Whether the tool executed successfully. * @param toolExecutionError - Any error that occurred during tool execution. * @returns The calculated tool-level return value. */ async calculateToolReturnValue(tool, toolParams, stepsExecuted, toolExecutionSuccess, toolExecutionError) { if (!tool.returnValue) { return this.stepReturnValues.length > 0 ? this.stepReturnValues[this.stepReturnValues.length - 1] : SuccessfulCallToolResult; } const returnValueConfig = tool.returnValue; // If there's a hardcoded value, return it if (returnValueConfig.value !== undefined) { const substitutedValue = this.substituteParams(returnValueConfig.value, toolParams); return createSuccessResult("Tool completed with hardcoded value", { value: substitutedValue }); } // If there's a provider function, call it if (returnValueConfig.provider || returnValueConfig.providerName) { const provider = returnValueConfig.provider || (returnValueConfig.providerName ? this.returnValueProviders.get(returnValueConfig.providerName)?.implementation : undefined); if (!provider) { this.logger.warn(`Tool return value provider not found: ${returnValueConfig.providerName}`); // Fallback to last step's return value return this.stepReturnValues.length > 0 ? this.stepReturnValues[this.stepReturnValues.length - 1] : SuccessfulCallToolResult; } const context = { toolParams, controller: this, logger: this.logger, activeTool: tool, stepsExecuted, lastStepReturnValue: this.stepReturnValues.length > 0 ? this.stepReturnValues[this.stepReturnValues.length - 1] : SuccessfulCallToolResult, toolExecutionSuccess, toolExecutionError }; try { const result = await provider(context); this.logger.debug('Tool return value provider result:', result); return result; // Provider should return CallToolResult directly } catch (error) { this.logger.error('Error executing tool return value provider:', error); // Fallback to last step's return value return this.stepReturnValues.length > 0 ? this.stepReturnValues[this.stepReturnValues.length - 1] : SuccessfulCallToolResult; } } // Fallback to last step's return value return this.stepReturnValues.length > 0 ? this.stepReturnValues[this.stepReturnValues.length - 1] : SuccessfulCallToolResult; } /** * Prepares steps by substituting parameters. * @private */ prepareSteps(tool, params) { return tool.steps.map(step => ({ ...step, content: this.substituteParams(step.content, params), action: step.action ? { ...step.action, value: this.substituteParams(step.action.value, params) } : undefined })); } /** * Substitutes parameters in a string or value. * @private */ substituteParams(value, params) { if (typeof value === 'string') { return value.replace(/\{\{(\w+)\}\}/g, (match, paramName) => { return params[paramName] !== undefined ? params[paramName] : match; }); } return value; } /** * Creates buttons for a step in normal mode. * @private */ createStepButtons(stepIndex, totalSteps) { const buttons = []; if (stepIndex > 0) { buttons.push({ text: 'Back', action: () => this.shepherdTour?.back() }); } if (stepIndex < totalSteps - 1) { buttons.push({ text: 'Next', action: () => this.shepherdTour?.next() }); } else { buttons.push({ text: 'Complete', action: () => this.shepherdTour?.complete() }); } buttons.push({ text: 'Cancel', classes: 'shepherd-button-secondary', action: () => this.shepherdTour?.cancel() }); return buttons; } /** * Sets up event handlers for the Shepherd tour with return value support. * @private */ setupTourEventHandlersWithReturnValues(resolve) { if (!this.shepherdTour) return; this.shepherdTour.on('show', async () => { this.emit('step:show', { step: this.activeTool?.steps[this.currentStepIndex], index: this.currentStepIndex, tool: this.activeTool }); // For normal mode, calculate return value when step is shown if (this.activeTool?.mode === 'normal') { const step = this.activeTool.steps[this.currentStepIndex]; if (step) { const stepReturnValue = await this.calculateStepReturnValue(step, {}, this.currentStepIndex, undefined); this.stepReturnValues[this.currentStepIndex] = stepReturnValue; } } }); this.shepherdTour.on('complete', async () => { // Calculate tool-level return value (might override last step's return value) const toolReturnValue = await this.calculateToolReturnValue(this.activeTool, {}, // Tool params not available in Shepherd context, could be enhanced this.stepReturnValues.length, true); this.emit('complete', { tool: this.activeTool, result: toolReturnValue }); this.activeTool = null; this.shepherdTour = null; resolve(toolReturnValue); }); this.shepherdTour.on('cancel', async () => { // Calculate tool-level return value even on cancel await this.calculateToolReturnValue(this.activeTool, {}, // Tool params not available in Shepherd context this.stepReturnValues.length, false, new Error('Tool cancelled by user')); const cancelledResult = createErrorResult('Tool cancelled by user'); this.emit('cancel', { tool: this.activeTool, result: cancelledResult }); this.activeTool = null; this.shepherdTour = null; this.toolQueue = []; // Clear queue on cancel resolve(cancelledResult); }); } /** * Performs an automated action on a DOM element. * @private * @returns The result of the action as a CallToolResult. */ async performAction(action, targetElement, params) { const effectiveOptions = this.activeTool ? this.getEffectiveOptions(this.activeTool) : this.globalOptions; this.logger.debug('Performing action:', action.type, 'on element:', targetElement); // Wait for element to be available const element = await this.waitForElement(targetElement, effectiveOptions.elementTimeout); if (!element) { throw new Error(`Element not found: ${targetElement}`); } this.logger.debug('Element found:', element); // Highlight the target element before performing action this.highlightElement(element, effectiveOptions.highlightDuration, effectiveOptions); let actionResult; switch (action.type) { case 'click': this.logger.debug('Clicking element...'); // Show visual click effect before actual click this.showClickEffect(element, effectiveOptions); // Small delay to let visual effect show await new Promise(resolve => setTimeout(resolve, 200)); element.click(); actionResult = createSuccessResult('Element clicked successfully', { clicked: true, element: targetElement }); break; case 'fillInput': this.logger.debug('Filling input with value:', action.value); if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { // Use typing animation for visual feedback await this.showTypingEffect(element, String(action.value) || '', effectiveOptions); element.dispatchEvent(new Event('blur', { bubbles: true })); actionResult = createSuccessResult('Input filled successfully', { filled: true, value: action.value, element: targetElement }); } else { this.logger.error('Element is not an input or textarea:', element); throw new Error(`Element ${targetElement} is not an input or textarea`); } break; case 'selectOption': this.logger.debug('Selecting option with value:', action.value); if (element instanceof HTMLSelectElement) { // Show focus effect before selection this.showFocusEffect(element, effectiveOptions.focusEffectDuration, effectiveOptions); element.focus(); await new Promise(resolve => setTimeout(resolve, 300)); element.value = String(action.value) || ''; element.dispatchEvent(new Event('change', { bubbles: true })); actionResult = createSuccessResult('Option selected successfully', { selected: true, value: action.value, element: targetElement }); } else { this.logger.error('Element is not a select:', element); throw new Error(`Element ${targetElement} is not a select element`); } break; case 'navigate': this.logger.debug('Navigating to:', action.value); // Show click effect if element is clickable (like a link) this.showClickEffect(element, effectiveOptions); await new Promise(resolve => setTimeout(resolve, 500)); window.location.href = String(action.value) || ''; actionResult = createSuccessResult('Navigation completed successfully', { navigated: true, url: action.value }); break; case 'executeFunction': this.logger.debug('Executing custom function:', action.functionName || 'inline function'); actionResult = await this.executeCustomFunction(action, element, params); break; default: throw new Error(`Unknown action type: ${action.type}`); } // Add a small delay after each action (configurable) await new Promise(resolve => setTimeout(resolve, effectiveOptions.actionDelay)); return actionResult; } /** * Wait for an element to be available in the DOM * @private */ async waitForElement(selector, timeout = 5000) { const startTime = Date.now(); while (Date.now() - startTime < timeout) { const element = document.querySelector(selector); if (element) { return element; } await new Promise(resolve => setTimeout(resolve, 100)); } this.logger.error(`Element not found after ${timeout}ms:`, selector); return null; } /** * Executes a custom function as part of a tool action. * @private * @returns The return value from the custom function. */ async executeCustomFunction(action, targetElement, toolParams) { let functionToExecute; // Check if function is provided directly if (action.function) { functionToExecute = action.function; this.logger.debug('Using inline function'); } // Check if function name is provided and exists in registry else if (action.functionName) { const customFunction = this.customFunctions.get(action.functionName); if (customFunction) { functionToExecute = customFunction.implementation; this.logger.debug(`Using registered function: ${action.functionName}`); } else { const errorMsg = `Custom function '${action.functionName}' not found in registry`; return createErrorResult(errorMsg); } } else { const errorMsg = 'No function or functionName provided for executeFunction action'; return createErrorResult(errorMsg); } if (!functionToExecute) { return createErrorResult('Function to execute is undefined'); } try { // Prepare function context with proper typing const context = { element: targetElement, params: action.functionParams || {}, toolParams: toolParams, controller: this, logger: this.logger, activeTool: this.activeTool, currentStepIndex: this.currentStepIndex, previousStepReturnValue: this.currentStepIndex > 0 ? this.stepReturnValues[this.currentStepIndex - 1] : undefined }; // Execute the function with context const result = await Promise.resolve(functionToExecute.call(context, context)); this.logger.debug('Custom function executed successfully:', result); return result; // Function should return CallToolResult directly } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.debug('Error executing custom function:', errorMessage); return createErrorResult(`Custom function execution failed: ${errorMessage}`); } } /** * Initializes default event listeners. * @private */ initializeEventListeners() { // Initialize empty arrays for all event types this.eventListeners.set('start', []); this.eventListeners.set('complete', []); this.eventListeners.set('cancel', []); this.eventListeners.set('step:show', []); } /** * Injects CSS styles for visual feedback animations. * @private */ injectVisualFeedbackStyles() { if (!this.globalOptions.enableVisualFeedback) return; // Remove existing styles if they exist const existingStyle = document.getElementById(this.styleElementId); if (existingStyle) { existingStyle.remove(); } // Get style values with defaults const clickRipple = { backgroundColor: 'rgba(59, 130, 246, 0.6)', size: 20, duration: 0.6, borderRadius: '50%', ...this.customStyles.clickRipple }; const highlight = { primaryColor: 'rgba(59, 130, 246, 0.5)', secondaryColor: 'rgba(59, 130, 246, 0.8)', tertiaryColor: 'rgba(59, 130, 246, 0.6)', duration: 2, primaryBlur: 5, secondaryBlur: 20, tertiaryBlur: 30, ...this.customStyles.highlight }; const pulse = { duration: 1, scale: 1.05, opacity: 0.8, ...this.customStyles.pulse }; const typing = { shimmerGradient: 'linear-gradient(90deg, transparent, rgba(59, 130, 246, 0.2), transparent)', duration: 1.5, opacity: 0.2, ...this.customStyles.typing }; const focusRing = { color: 'rgba(59, 130, 246, 0.8)', width: 2, offset: 2, borderRadius: 4, ...this.customStyles.focusRing }; const style = document.createElement('style'); style.id = this.styleElementId; style.textContent = ` @keyframes mcpClickRipple { 0% { transform: scale(0); opacity: 1; } 100% { transform: scale(4); opacity: 0; } } @keyframes mcpGlow { 0%, 100% { box-shadow: 0 0 ${highlight.primaryBlur}px ${highlight.primaryColor}; } 50% { box-shadow: 0 0 ${highlight.secondaryBlur}px ${highlight.secondaryColor}, 0 0 ${highlight.tertiaryBlur}px ${highlight.tertiaryColor}; } } @keyframes mcpPulse { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(${pulse.scale}); opacity: ${pulse.opacity}; } } @keyframes mcpTypingShimmer { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } } .mcp-click-ripple { position: absolute; border-radius: ${clickRipple.borderRadius}; background: ${clickRipple.backgroundColor}; animation: mcpClickRipple ${clickRipple.duration}s ease-out; pointer-events: none; z-index: 9999; width: ${clickRipple.size}px; height: ${clickRipple.size}px; margin-left: -${clickRipple.size / 2}px; margin-top: -${clickRipple.size / 2}px; } .mcp-typing-indicator { position: relative; overflow: hidden; } .mcp-typing-indicator::after { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: ${typing.shimmerGradient}; animation: mcpTypingShimmer ${typing.duration}s infinite; pointer-events: none; } .mcp-highlight { animation: mcpGlow ${highlight.duration}s ease-in-out infinite; transition: all 0.3s ease; } .mcp-pulse { animation: mcpPulse ${pulse.duration}s ease-in-out infinite; } .mcp-focus-ring { outline: ${focusRing.width}px solid ${focusRing.color}; outline-offset: ${focusRing.offset}px; border-radius: ${focusRing.borderRadius}px; } `; document.head.appendChild(style); this.logger.debug('Injected visual feedback styles with custom configuration'); } /** * Shows a click visual effect on an element. * @private */ showClickEffect(element, options) { const effectiveOptions = options || this.globalOptions; if (!effectiveOptions.enableVisualFeedback) return; const clickRipple = { size: 20, duration: 0.6, ...this.customStyles.clickRipple }; const pulse = { duration: 1, ...this.customStyles.pulse }; const rect = element.getBoundingClientRect(); const ripple = document.createElement('div'); ripple.className = 'mcp-click-ripple'; ripple.style.left = (rect.left + rect.width / 2) + 'px'; ripple.style.top = (rect.top + rect.height / 2) + 'px'; document.body.appendChild(ripple); // Add pulse effect to the target element element.classList.add('mcp-pulse'); // Use custom duration for cleanup const cleanupDuration = Math.max(clickRipple.duration * 1000, pulse.duration * 1000); setTimeout(() => { ripple.remove(); element.classList.remove('mcp-pulse'); }, cleanupDuration); } /** * Shows typing animation on an input element. * @private */ async showTypingEffect(element, text, options, delay = 10) { const effectiveOptions = options || this.globalOptions; if (!effectiveOptions.enableVisualFeedback) { element.value = text; element.dispatchEvent(new Event('input', { bubbles: true })); return; } // Add typing indicator element.classList.add('mcp-typing-indicator', 'mcp-focus-ring'); element.focus(); // Clear existing content element.value = ''; // Type character by character for (let i = 0; i <= text.length; i++) { element.value = text.substring(0, i); delay = delay