@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
JavaScript
/**
* @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