@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
322 lines • 11.9 kB
JavaScript
/**
* @fileoverview SignalR service for Web Interact MCP communication
* @description Framework-agnostic SignalR service for real-time communication with MCP servers
* @version 1.0.0
* @author Vijay Nirmal
*/
import * as signalR from '@microsoft/signalr';
import { createErrorResult, TransportType, LogLevel } from './types';
/**
* SignalR service for Web Interact MCP communication
* This is a framework-agnostic implementation that can be used with any JavaScript framework
*/
export class WebInteractSignalRService {
/**
* Maps our TransportType enum to SignalR's HttpTransportType
* Explicitly maps each transport type and handles flag combinations
*/
mapTransportType(transportType) {
let result = signalR.HttpTransportType.None;
if (transportType & TransportType.WebSockets) {
result |= signalR.HttpTransportType.WebSockets;
}
if (transportType & TransportType.ServerSentEvents) {
result |= signalR.HttpTransportType.ServerSentEvents;
}
if (transportType & TransportType.LongPolling) {
result |= signalR.HttpTransportType.LongPolling;
}
return result;
}
/**
* Maps our LogLevel enum to SignalR's LogLevel
*/
mapLogLevel(logLevel) {
switch (logLevel) {
case LogLevel.TRACE:
return signalR.LogLevel.Trace;
case LogLevel.DEBUG:
return signalR.LogLevel.Debug;
case LogLevel.INFO:
return signalR.LogLevel.Information;
case LogLevel.WARN:
return signalR.LogLevel.Warning;
case LogLevel.ERROR:
case LogLevel.FATAL:
return signalR.LogLevel.Error;
case LogLevel.OFF:
return signalR.LogLevel.None;
default:
return signalR.LogLevel.Information;
}
}
/**
* Creates a new WebInteractSignalRService instance
* @param serverUrl - The base URL of the MCP server
* @param mcpController - The MCP controller instance
* @param toolRegistry - The tool registry instance for tool discovery
* @param config - Configuration options for the service
*/
constructor(serverUrl, mcpController, toolRegistry, config = {}) {
this.serverUrl = serverUrl;
this.mcpController = mcpController;
this.toolRegistry = toolRegistry;
this.connection = null;
this.reconnectAttempts = 0;
this.isManuallyDisconnected = false;
this.config = {
hubPath: '/mcptools',
maxRetryAttempts: 10,
baseRetryDelayMs: 1000,
enableLogging: false,
logLevel: LogLevel.INFO,
transportTypes: TransportType.WebSockets | TransportType.ServerSentEvents | TransportType.LongPolling,
...config
};
this.logger = this.config.enableLogging ? console : this.createSilentLogger();
this.setupConnection();
}
/**
* Start the SignalR connection
* @returns Promise that resolves when the connection is established
*/
async start() {
if (!this.connection) {
throw new Error('SignalR connection not initialized');
}
if (this.connection.state === signalR.HubConnectionState.Connected) {
this.logger.log('SignalR connection already established');
return;
}
if (this.connection.state === signalR.HubConnectionState.Connecting) {
this.logger.log('SignalR connection attempt already in progress');
return;
}
try {
this.isManuallyDisconnected = false;
await this.connection.start();
this.reconnectAttempts = 0;
this.logger.log('SignalR connection started successfully');
}
catch (error) {
this.logger.error('Error starting SignalR connection:', error);
throw new Error(`Failed to start SignalR connection: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Stop the SignalR connection
* @returns Promise that resolves when the connection is stopped
*/
async stop() {
if (this.connection) {
this.isManuallyDisconnected = true;
await this.connection.stop();
this.logger.log('SignalR connection stopped');
}
}
/**
* Gets the SignalR connection ID which serves as the session ID
* @returns The connection ID or null if not connected
*/
getConnectionId() {
return this.connection?.connectionId || null;
}
/**
* Check if the connection is active
* @returns True if connected, false otherwise
*/
get isConnected() {
return this.connection?.state === signalR.HubConnectionState.Connected;
}
/**
* Get the current connection state
* @returns The current SignalR connection state
*/
get connectionState() {
return this.connection?.state || null;
}
/**
* Send a ping to test the connection
* @returns Promise that resolves with the server response
*/
async ping() {
if (!this.connection || this.connection.state !== signalR.HubConnectionState.Connected) {
throw new Error('SignalR connection not available');
}
try {
const response = await this.connection.invoke('Ping');
return response;
}
catch (error) {
this.logger.error('Error sending ping:', error);
throw error;
}
}
/**
* Dispose of the service and clean up resources
*/
dispose() {
this.isManuallyDisconnected = true;
if (this.connection) {
this.connection.stop().catch(error => {
this.logger.error('Error stopping connection during disposal:', error);
});
this.connection = null;
}
}
/**
* Setup SignalR connection with event handlers
* @private
*/
setupConnection() {
const hubUrl = new URL(this.config.hubPath, this.serverUrl).toString();
this.connection = new signalR.HubConnectionBuilder()
.withUrl(hubUrl, {
transport: this.mapTransportType(this.config.transportTypes)
})
.withAutomaticReconnect({
nextRetryDelayInMilliseconds: (retryContext) => {
// Exponential backoff with jitter
const delay = Math.min(this.config.baseRetryDelayMs * Math.pow(2, retryContext.previousRetryCount), 30000 // Max 30 seconds
);
return delay + Math.random() * 1000; // Add jitter
}
})
.configureLogging(this.mapLogLevel(this.config.logLevel))
.build();
this.setupEventHandlers();
}
/**
* Setup SignalR event handlers
* @private
*/
setupEventHandlers() {
if (!this.connection)
return;
// Listen for tool invocation requests from the server
this.connection.on('InvokeTool', async (toolName, toolArguments) => {
this.logger.log(`Received tool invocation request: ${toolName}`, { arguments: toolArguments });
try {
return await this.invokeTool(toolName, toolArguments);
}
catch (error) {
this.logger.error('Error executing tool:', error);
return createErrorResult(error instanceof Error ? error : new Error(String(error)));
}
});
// Listen for tool discovery requests from the server
this.connection.on('GetTools', () => {
this.logger.log('Received tools discovery request from server');
try {
const toolsJson = this.toolRegistry.getToolsAsJson();
this.logger.log(`Returning ${JSON.parse(toolsJson).length} tools to server`);
return toolsJson;
}
catch (error) {
this.logger.error('Error getting tools for discovery:', error);
return '[]';
}
});
// Connection lifecycle events
this.connection.onclose((error) => {
if (error) {
this.logger.error('SignalR connection closed with error:', error);
}
else {
this.logger.log('SignalR connection closed');
}
if (!this.isManuallyDisconnected) {
this.scheduleReconnect();
}
});
this.connection.onreconnecting((error) => {
this.logger.warn('SignalR reconnecting...', error);
});
this.connection.onreconnected((connectionId) => {
this.logger.log(`SignalR reconnected with ID: ${connectionId}`);
this.reconnectAttempts = 0;
});
}
/**
* Invoke a tool using the MCP controller
* @private
*/
async invokeTool(toolName, params = {}) {
if (!this.mcpController) {
throw new Error('MCP Controller not set. Call setMCPController() first.');
}
// Validate and sanitize parameters
const sanitizedParams = this.sanitizeParameters(params);
const toolConfig = {
toolId: toolName,
params: sanitizedParams
};
try {
const results = await this.mcpController.start([toolConfig]);
// Return the first result (since we're only starting one tool)
const result = results[0];
if (!result) {
return createErrorResult(new Error('No result returned from tool execution'));
}
return result;
}
catch (error) {
this.logger.error(`Tool execution failed for ${toolName}:`, error);
return createErrorResult(error instanceof Error ? error : new Error(String(error)));
}
}
/**
* Sanitize parameters to ensure they are safe and valid
* @private
*/
sanitizeParameters(params) {
if (params === null || params === undefined) {
return {};
}
if (typeof params === 'object' && !Array.isArray(params)) {
return params;
}
this.logger.warn('Invalid parameters type, using empty object:', typeof params);
return {};
}
/**
* Schedule a reconnect attempt with exponential backoff
* @private
*/
async scheduleReconnect() {
if (this.isManuallyDisconnected || this.reconnectAttempts >= this.config.maxRetryAttempts) {
this.logger.error(`Max reconnection attempts (${this.config.maxRetryAttempts}) reached`);
return;
}
this.reconnectAttempts++;
const delay = this.config.baseRetryDelayMs * Math.pow(2, this.reconnectAttempts - 1);
this.logger.log(`Scheduling reconnection attempt ${this.reconnectAttempts} in ${delay}ms`);
setTimeout(async () => {
if (!this.isManuallyDisconnected) {
try {
await this.start();
}
catch (error) {
this.logger.error(`Reconnection attempt ${this.reconnectAttempts} failed:`, error);
this.scheduleReconnect();
}
}
}, delay);
}
/**
* Creates a silent logger that doesn't output anything
* @private
*/
createSilentLogger() {
const silentFunction = () => { };
return {
log: silentFunction,
error: silentFunction,
warn: silentFunction,
info: silentFunction
};
}
}
// Legacy alias for backward compatibility
export { WebInteractSignalRService as MCPSignalRService };
//# sourceMappingURL=signalr.service.js.map