Modo headless, event emitter y state getters en el cliente WebMCP

Permite crear instancias sin widget DOM (headless: true), suscribirse
a eventos de conexion/tools (on/off) y consultar el estado con
getConnectionInfo().
This commit is contained in:
2026-02-13 14:32:57 -06:00
parent d5a912be97
commit cec5be355d
2 changed files with 115 additions and 10 deletions

26
src/webmcp.d.ts vendored
View File

@@ -4,6 +4,7 @@ export interface WebMCPOptions {
size?: string; size?: string;
padding?: string; padding?: string;
inactivityTimeout?: number; inactivityTimeout?: number;
headless?: boolean;
} }
export interface ToolInputSchema { export interface ToolInputSchema {
@@ -28,6 +29,27 @@ export type ToolHandler = (args: any) => string | object | Promise<string | obje
export type PromptHandler = (args: any) => object | Promise<object>; export type PromptHandler = (args: any) => object | Promise<object>;
export type ResourceHandler = (uri: string) => object | Promise<object>; export type ResourceHandler = (uri: string) => object | Promise<object>;
export interface ConnectionInfo {
isConnected: boolean;
channel: string;
server: string;
status: string;
tools: string[];
prompts: string[];
resources: string[];
}
export interface WebMCPEventMap {
connected: { channel: string; server: string };
disconnected: { code: number; reason: string };
reconnecting: { attempt: number; maxAttempts: number; delay: number };
statusChange: { status: string; message: string };
toolRegistered: { name: string };
toolCreated: { name: string };
toolRemoved: { name: string };
error: { message: string };
}
declare class WebMCP { declare class WebMCP {
readonly isConnected: boolean; readonly isConnected: boolean;
readonly isExpanded: boolean; readonly isExpanded: boolean;
@@ -37,6 +59,10 @@ declare class WebMCP {
connect(connectionToken: string): Promise<void>; connect(connectionToken: string): Promise<void>;
disconnect(): void; disconnect(): void;
on<K extends keyof WebMCPEventMap>(event: K, callback: (data: WebMCPEventMap[K]) => void): () => void;
off<K extends keyof WebMCPEventMap>(event: K, callback: (data: WebMCPEventMap[K]) => void): void;
getConnectionInfo(): ConnectionInfo;
registerTool(name: string, description: string, schema: ToolInputSchema, executeFn: ToolHandler): void; registerTool(name: string, description: string, schema: ToolInputSchema, executeFn: ToolHandler): void;
unregisterTool(name: string): void; unregisterTool(name: string): void;
unregisterAllTools(): void; unregisterAllTools(): void;

View File

@@ -15,9 +15,14 @@ class WebMCP {
size: '30px', size: '30px',
padding: '20px', padding: '20px',
inactivityTimeout: 5 * 60 * 1000, // 5 minutes in milliseconds inactivityTimeout: 5 * 60 * 1000, // 5 minutes in milliseconds
headless: false,
...options ...options
}; };
// Event emitter
this._listeners = {};
this._currentStatus = 'disconnected';
// State variables // State variables
this.isConnected = false; this.isConnected = false;
this.isExpanded = false; this.isExpanded = false;
@@ -52,6 +57,62 @@ class WebMCP {
this._init(); this._init();
} }
/**
* Register an event listener
* @public
* @param {string} event - Event name
* @param {Function} callback - Callback function
* @returns {Function} Unsubscribe function
*/
on(event, callback) {
if (!this._listeners[event]) {
this._listeners[event] = [];
}
this._listeners[event].push(callback);
return () => this.off(event, callback);
}
/**
* Remove an event listener
* @public
* @param {string} event - Event name
* @param {Function} callback - Callback function to remove
*/
off(event, callback) {
if (!this._listeners[event]) return;
this._listeners[event] = this._listeners[event].filter(cb => cb !== callback);
}
/**
* Emit an event to all registered listeners
* @private
* @param {string} event - Event name
* @param {Object} data - Event data
*/
_emit(event, data) {
if (!this._listeners[event]) return;
this._listeners[event].forEach(cb => {
try { cb(data); } catch (e) { console.error(`Error in ${event} listener:`, e); }
});
}
/**
* Get current connection info snapshot
* @public
* @returns {Object} Connection state snapshot
*/
getConnectionInfo() {
return {
isConnected: this.isConnected,
channel: this.currentChannel,
server: this.currentServer,
status: this._currentStatus,
tools: Array.from(this.availableTools.keys()),
prompts: Array.from(this.availablePrompts.keys()),
resources: Array.from(this.availableResources.keys()),
};
}
_format(s) { _format(s) {
return s.replace(/[.:]/g, '_'); return s.replace(/[.:]/g, '_');
} }
@@ -61,18 +122,20 @@ class WebMCP {
* @private * @private
*/ */
_init() { _init() {
// Check if already initialized on this page if (!this.options.headless) {
if (document.querySelector('[data-webmcp-widget]')) { // Check if already initialized on this page
console.warn('WebMCP widget already initialized on this page'); if (document.querySelector('[data-webmcp-widget]')) {
return; console.warn('WebMCP widget already initialized on this page');
return;
}
// Create and inject the widget
this._createWidget();
// Set up event listeners
this._setupEventListeners();
} }
// Create and inject the widget
this._createWidget();
// Set up event listeners
this._setupEventListeners();
// Start inactivity timer // Start inactivity timer
this._resetInactivityTimer(); this._resetInactivityTimer();
@@ -668,6 +731,9 @@ class WebMCP {
* @private * @private
*/ */
_updateStatus(status, message) { _updateStatus(status, message) {
this._currentStatus = status;
this._emit('statusChange', { status, message: message || status });
const container = document.getElementById(this.elementId); const container = document.getElementById(this.elementId);
if (!container) return; if (!container) return;
@@ -1147,6 +1213,7 @@ class WebMCP {
this._reconnectAttempts = 0; this._reconnectAttempts = 0;
this._updateStatus('connected', `Connected to ${this.currentChannel}`); this._updateStatus('connected', `Connected to ${this.currentChannel}`);
this._updateConnectionUI(true); this._updateConnectionUI(true);
this._emit('connected', { channel: this.currentChannel, server: this.currentServer });
console.log('WebMCP connection established'); console.log('WebMCP connection established');
this._registerItemsWithServer(); this._registerItemsWithServer();
}); });
@@ -1160,6 +1227,7 @@ class WebMCP {
if (event.code === 1000) { if (event.code === 1000) {
this._updateStatus('disconnected', 'Disconnected'); this._updateStatus('disconnected', 'Disconnected');
this._updateConnectionUI(false); this._updateConnectionUI(false);
this._emit('disconnected', { code: event.code, reason: event.reason });
return; return;
} }
@@ -1169,6 +1237,11 @@ class WebMCP {
const delay = this._reconnectDelay * this._reconnectAttempts; const delay = this._reconnectDelay * this._reconnectAttempts;
console.log(`Reconnecting (attempt ${this._reconnectAttempts}/${this._maxReconnectAttempts}) in ${delay}ms...`); console.log(`Reconnecting (attempt ${this._reconnectAttempts}/${this._maxReconnectAttempts}) in ${delay}ms...`);
this._updateStatus('connecting', `Reconectando (${this._reconnectAttempts}/${this._maxReconnectAttempts})...`); this._updateStatus('connecting', `Reconectando (${this._reconnectAttempts}/${this._maxReconnectAttempts})...`);
this._emit('reconnecting', {
attempt: this._reconnectAttempts,
maxAttempts: this._maxReconnectAttempts,
delay
});
setTimeout(() => { setTimeout(() => {
this.connect(this._lastConnectionToken); this.connect(this._lastConnectionToken);
}, delay); }, delay);
@@ -1178,6 +1251,7 @@ class WebMCP {
// Max retries exceeded or no token — give up // Max retries exceeded or no token — give up
this._updateStatus('disconnected', 'Disconnected'); this._updateStatus('disconnected', 'Disconnected');
this._updateConnectionUI(false); this._updateConnectionUI(false);
this._emit('disconnected', { code: event.code, reason: event.reason });
this.currentToken = ''; this.currentToken = '';
this.currentServer = ''; this.currentServer = '';
this.currentChannel = ''; this.currentChannel = '';
@@ -1234,6 +1308,7 @@ class WebMCP {
case 'toolRegistered': case 'toolRegistered':
console.log(`Tool registered with server: ${message.name}`); console.log(`Tool registered with server: ${message.name}`);
this._emit('toolRegistered', { name: message.name });
break; break;
case 'promptRegistered': case 'promptRegistered':
@@ -1298,6 +1373,7 @@ class WebMCP {
this.registeredTools.delete(message.name); this.registeredTools.delete(message.name);
this._saveItemsToStorage(); this._saveItemsToStorage();
this._updateToolsList(); this._updateToolsList();
this._emit('toolRemoved', { name: message.name });
console.log(`Tool removed by server: ${message.name}`); console.log(`Tool removed by server: ${message.name}`);
} }
break; break;
@@ -1307,6 +1383,7 @@ class WebMCP {
this.registeredTools.clear(); this.registeredTools.clear();
this._saveItemsToStorage(); this._saveItemsToStorage();
this._updateToolsList(); this._updateToolsList();
this._emit('toolRemoved', { name: '*' });
console.log('All tools removed by server'); console.log('All tools removed by server');
break; break;
@@ -1336,6 +1413,7 @@ class WebMCP {
case 'error': case 'error':
console.error(`Server error: ${message.message}`); console.error(`Server error: ${message.message}`);
this._emit('error', { message: message.message });
break; break;
default: default:
@@ -1361,6 +1439,7 @@ class WebMCP {
type: 'toolResponse', type: 'toolResponse',
result: { content: [{ type: 'text', text: `Herramienta "${name}" registrada exitosamente` }] } result: { content: [{ type: 'text', text: `Herramienta "${name}" registrada exitosamente` }] }
}); });
this._emit('toolCreated', { name });
console.log(`Tool created by agent: ${name}`); console.log(`Tool created by agent: ${name}`);
} catch (e) { } catch (e) {
this._sendMessage({ this._sendMessage({