Files
webmcp/webmcp.js
2026-03-06 02:41:54 -06:00

2327 lines
80 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* WebMCP - Snippet to add MCP functionality to any website
*
* Shows as a small blue square in bottom right corner
* On click, expands to allow connection with token
* Auto-disconnects after 5 minutes of inactivity
*/
class WebMCP {
constructor(options = {}) {
// Options with defaults
this.options = {
color: '#007bff',
position: 'bottom-right',
size: '30px',
padding: '20px',
inactivityTimeout: 5 * 60 * 1000, // 5 minutes in milliseconds
headless: false,
...options
};
// Event emitter
this._listeners = {};
this._currentStatus = 'disconnected';
// State variables
this.isConnected = false;
this.isExpanded = false;
this.socket = null;
this.inactivityTimer = null;
this.availableTools = new Map();
this.availablePrompts = new Map();
this.availableResources = new Map();
this.samplingCallbacks = new Map(); // For storing sampling callbacks
this.currentToken = '';
this.currentServer = '';
this.currentChannel = '';
this.elementId = 'webmcp-widget-' + Math.random().toString(36).substr(2, 9);
this.registeredTools = new Set();
this.registeredPrompts = new Set();
this.registeredResources = new Set();
this._reconnectAttempts = 0;
this._maxReconnectAttempts = 3;
this._reconnectDelay = 1000;
this._lastConnectionToken = null;
// Storage keys for sessionStorage
this.SESSION_STORAGE_KEY = 'webmcp_token';
this.TOOLS_STORAGE_KEY = 'webmcp_tools';
this.PROMPTS_STORAGE_KEY = 'webmcp_prompts';
this.RESOURCES_STORAGE_KEY = 'webmcp_resources';
// Constants
this.REGISTER_PATH = '/register';
// Initialize
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) {
return s.replace(/[.:]/g, '_');
}
/**
* Initialize the WebMCP widget
* @private
*/
_init() {
if (!this.options.headless) {
// Check if already initialized on this page
if (document.querySelector('[data-webmcp-widget]')) {
console.warn('WebMCP widget already initialized on this page');
return;
}
// Create and inject the widget
this._createWidget();
// Set up event listeners
this._setupEventListeners();
}
// Start inactivity timer
this._resetInactivityTimer();
// Check for stored token and connect if available
this._checkStoredToken();
}
/**
* Check for stored connection info in sessionStorage and connect if found
* @private
*/
_checkStoredToken() {
const storedConnectionInfo = sessionStorage.getItem(this.SESSION_STORAGE_KEY);
if (storedConnectionInfo) {
try {
const connectionInfo = JSON.parse(storedConnectionInfo);
if (connectionInfo.token) {
console.log('Found stored connection info, attempting to connect');
// Set the connection properties directly
this.currentServer = connectionInfo.server;
this.currentChannel = `/${connectionInfo.channelHost || this._format(window.location.host)}`;
// Set the current token from connection info
if (connectionInfo.token.includes('{')) {
// It's already parsed JSON
const tokenData = JSON.parse(connectionInfo.token);
this.currentToken = tokenData.token;
} else {
// It's a base64 encoded string
try {
const jsonStr = atob(connectionInfo.token);
const tokenData = JSON.parse(jsonStr);
this.currentToken = tokenData.token;
} catch (e) {
this.currentToken = connectionInfo.token;
}
}
// Load stored items before connecting
this._loadStoredItems();
// Connect using the stored token
this.connect(connectionInfo.token);
}
} catch (error) {
console.error('Error parsing stored connection info:', error);
sessionStorage.removeItem(this.SESSION_STORAGE_KEY);
this._clearStoredItems();
}
}
}
/**
* Save tools, prompts, and resources to session storage
* @private
*/
_saveItemsToStorage() {
try {
// Save tools
const toolsData = {};
this.availableTools.forEach((tool, name) => {
toolsData[name] = {
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
// We don't store the execution function as it can't be serialized
};
});
sessionStorage.setItem(this.TOOLS_STORAGE_KEY, JSON.stringify(toolsData));
// Save prompts
const promptsData = {};
this.availablePrompts.forEach((prompt, name) => {
promptsData[name] = {
name: prompt.name,
description: prompt.description,
arguments: prompt.arguments,
// We don't store the execution function as it can't be serialized
};
});
sessionStorage.setItem(this.PROMPTS_STORAGE_KEY, JSON.stringify(promptsData));
// Save resources
const resourcesData = {};
this.availableResources.forEach((resource, name) => {
resourcesData[name] = {
name: resource.name,
description: resource.description,
uri: resource.uri,
uriTemplate: resource.uriTemplate,
isTemplate: resource.isTemplate,
mimeType: resource.mimeType,
// We don't store the provide function as it can't be serialized
};
});
sessionStorage.setItem(this.RESOURCES_STORAGE_KEY, JSON.stringify(resourcesData));
console.log('Saved items to session storage:', {
tools: Object.keys(toolsData).length,
prompts: Object.keys(promptsData).length,
resources: Object.keys(resourcesData).length
});
} catch (error) {
console.error('Error saving items to session storage:', error);
}
}
/**
* Load tools, prompts, and resources from session storage
* @private
*/
_loadStoredItems() {
try {
// Load tools
const storedTools = sessionStorage.getItem(this.TOOLS_STORAGE_KEY);
if (storedTools) {
const toolsData = JSON.parse(storedTools);
Object.entries(toolsData).forEach(([name, tool]) => {
// Add to the available tools with placeholder execute function
this.availableTools.set(name, {
...tool,
execute: function (args) {
console.warn(`Tool ${name} was loaded from storage but has not been re-registered with an execution function`);
return `Tool ${name} needs to be re-registered`;
}
});
});
}
// Load prompts
const storedPrompts = sessionStorage.getItem(this.PROMPTS_STORAGE_KEY);
if (storedPrompts) {
const promptsData = JSON.parse(storedPrompts);
Object.entries(promptsData).forEach(([name, prompt]) => {
// Add to the available prompts with placeholder execute function
this.availablePrompts.set(name, {
...prompt,
execute: function (args) {
console.warn(`Prompt ${name} was loaded from storage but has not been re-registered with an execution function`);
return {
messages: [{
role: "user",
content: {
type: "text",
text: `Prompt ${name} needs to be re-registered`
}
}]
};
}
});
});
}
// Load resources
const storedResources = sessionStorage.getItem(this.RESOURCES_STORAGE_KEY);
if (storedResources) {
const resourcesData = JSON.parse(storedResources);
Object.entries(resourcesData).forEach(([name, resource]) => {
// Add to the available resources with placeholder provide function
this.availableResources.set(name, {
...resource,
provide: function (uri) {
console.warn(`Resource ${name} was loaded from storage but has not been re-registered with a provider function`);
return {
contents: [{
uri: uri,
text: `Resource ${name} needs to be re-registered`,
mimeType: resource.mimeType || "text/plain"
}]
};
}
});
});
}
console.log('Loaded items from session storage:', {
tools: this.availableTools.size,
prompts: this.availablePrompts.size,
resources: this.availableResources.size
});
// Update the UI
this._updateToolsList();
this._updatePromptsList();
this._updateResourcesList();
} catch (error) {
console.error('Error loading items from session storage:', error);
this._clearStoredItems();
}
}
/**
* Clear all stored items from session storage
* @private
*/
_clearStoredItems() {
sessionStorage.removeItem(this.TOOLS_STORAGE_KEY);
sessionStorage.removeItem(this.PROMPTS_STORAGE_KEY);
sessionStorage.removeItem(this.RESOURCES_STORAGE_KEY);
console.log('Cleared stored items from session storage');
}
/**
* Create and inject the WebMCP widget into the DOM
* @private
*/
_createWidget() {
// Create main container
const container = document.createElement('div');
container.id = this.elementId;
container.dataset.webmcpWidget = true;
// Apply styles
Object.assign(container.style, {
position: 'fixed',
zIndex: '9999',
display: 'flex',
flexDirection: 'column',
fontFamily: 'Arial, sans-serif',
fontSize: '14px',
transition: 'all 0.3s ease'
});
// Set position based on option
this._setWidgetPosition(container);
// Create trigger button (blue square)
const triggerButton = document.createElement('div');
triggerButton.className = 'webmcp-trigger';
Object.assign(triggerButton.style, {
width: this.options.size,
height: this.options.size,
backgroundColor: this.options.color,
borderRadius: '4px',
cursor: 'pointer',
boxShadow: '0 2px 10px rgba(0,0,0,0.2)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
alignSelf: 'flex-end'
});
// Create content panel (initially hidden) - positioned above the trigger
const contentPanel = document.createElement('div');
contentPanel.className = 'webmcp-content';
Object.assign(contentPanel.style, {
backgroundColor: '#ffffff',
border: '1px solid #e1e1e1',
borderRadius: '5px',
padding: '15px',
marginBottom: '10px',
boxShadow: '0 5px 15px rgba(0,0,0,0.1)',
width: '250px',
display: 'none',
overflow: 'hidden',
position: 'absolute',
bottom: '40px'
});
// Add header with title and close button
const header = document.createElement('div');
Object.assign(header.style, {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '15px'
});
const title = document.createElement('div');
title.textContent = 'WebMCP';
Object.assign(title.style, {
fontWeight: 'bold',
fontSize: '16px'
});
const closeButton = document.createElement('button');
closeButton.innerHTML = '×'; // × symbol
closeButton.className = 'webmcp-close';
Object.assign(closeButton.style, {
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '20px',
padding: '0',
lineHeight: '1',
color: '#999'
});
header.appendChild(title);
header.appendChild(closeButton);
contentPanel.appendChild(header);
// Add connection form
this._createConnectionForm(contentPanel);
// Add status indicator
const statusIndicator = document.createElement('div');
statusIndicator.className = 'webmcp-status';
statusIndicator.textContent = 'Disconnected';
Object.assign(statusIndicator.style, {
padding: '8px',
borderRadius: '3px',
backgroundColor: '#f8d7da',
color: '#721c24',
textAlign: 'center',
marginBottom: '10px',
fontSize: '12px'
});
contentPanel.appendChild(statusIndicator);
// Add connection panel
const connectionPanel = document.createElement('div');
connectionPanel.className = 'webmcp-connection-panel';
contentPanel.appendChild(connectionPanel);
// Create a single container for all registered items
const registeredItemsContainer = document.createElement('div');
registeredItemsContainer.className = 'webmcp-registered-items';
Object.assign(registeredItemsContainer.style, {
marginTop: '15px',
fontSize: '12px',
display: 'none',
maxHeight: '200px',
overflow: 'auto',
border: '1px solid #eee',
borderRadius: '4px'
});
contentPanel.appendChild(registeredItemsContainer);
// Add features lists (initially empty)
// Tools list
const toolsList = document.createElement('div');
toolsList.className = 'webmcp-tools-list';
Object.assign(toolsList.style, {
padding: '10px',
borderBottom: '1px solid #eee'
});
const toolsHeader = document.createElement('div');
toolsHeader.textContent = 'Registered Tools:';
Object.assign(toolsHeader.style, {
fontWeight: 'bold',
marginBottom: '5px'
});
const toolsContainer = document.createElement('ul');
toolsContainer.className = 'webmcp-tools-container';
Object.assign(toolsContainer.style, {
listStyle: 'none',
padding: '0',
margin: '0'
});
toolsList.appendChild(toolsHeader);
toolsList.appendChild(toolsContainer);
registeredItemsContainer.appendChild(toolsList);
// Prompts list
const promptsList = document.createElement('div');
promptsList.className = 'webmcp-prompts-list';
Object.assign(promptsList.style, {
padding: '10px',
borderBottom: '1px solid #eee'
});
const promptsHeader = document.createElement('div');
promptsHeader.textContent = 'Registered Prompts:';
Object.assign(promptsHeader.style, {
fontWeight: 'bold',
marginBottom: '5px'
});
const promptsContainer = document.createElement('ul');
promptsContainer.className = 'webmcp-prompts-container';
Object.assign(promptsContainer.style, {
listStyle: 'none',
padding: '0',
margin: '0'
});
promptsList.appendChild(promptsHeader);
promptsList.appendChild(promptsContainer);
registeredItemsContainer.appendChild(promptsList);
// Resources list
const resourcesList = document.createElement('div');
resourcesList.className = 'webmcp-resources-list';
Object.assign(resourcesList.style, {
padding: '10px'
});
const resourcesHeader = document.createElement('div');
resourcesHeader.textContent = 'Registered Resources:';
Object.assign(resourcesHeader.style, {
fontWeight: 'bold',
marginBottom: '5px'
});
const resourcesContainer = document.createElement('ul');
resourcesContainer.className = 'webmcp-resources-container';
Object.assign(resourcesContainer.style, {
listStyle: 'none',
padding: '0',
margin: '0'
});
resourcesList.appendChild(resourcesHeader);
resourcesList.appendChild(resourcesContainer);
registeredItemsContainer.appendChild(resourcesList);
// Add to main container and then to document - content panel first so it appears above trigger
container.appendChild(contentPanel);
container.appendChild(triggerButton);
document.body.appendChild(container);
}
/**
* Set widget position based on option
* @private
*/
_setWidgetPosition(container) {
const {position, padding} = this.options;
switch (position) {
case 'bottom-right':
Object.assign(container.style, {
bottom: padding,
right: padding,
alignItems: 'flex-end'
});
break;
case 'bottom-left':
Object.assign(container.style, {
bottom: padding,
left: padding,
alignItems: 'flex-start'
});
break;
case 'top-right':
Object.assign(container.style, {
top: padding,
right: padding,
alignItems: 'flex-end'
});
break;
case 'top-left':
Object.assign(container.style, {
top: padding,
left: padding,
alignItems: 'flex-start'
});
break;
default:
// Default to bottom-right
Object.assign(container.style, {
bottom: padding,
right: padding,
alignItems: 'flex-end'
});
}
}
/**
* Create the connection form
* @private
*/
_createConnectionForm(container) {
const form = document.createElement('div');
Object.assign(form.style, {
marginBottom: '8px',
});
// Token input field
const inputGroup = document.createElement('div');
Object.assign(inputGroup.style, {
display: 'flex',
marginBottom: '8px',
});
const tokenInput = document.createElement('input');
tokenInput.type = 'text';
tokenInput.className = 'webmcp-token-input';
tokenInput.placeholder = 'Paste connection token';
Object.assign(tokenInput.style, {
flex: '1',
padding: '8px',
border: '1px solid #ccc',
borderRadius: '4px 0 0 4px',
fontSize: '12px'
});
const connectButton = document.createElement('button');
connectButton.className = 'webmcp-connect-btn';
connectButton.textContent = 'Connect';
Object.assign(connectButton.style, {
padding: '8px 12px',
backgroundColor: this.options.color,
color: 'white',
border: 'none',
borderRadius: '0 4px 4px 0',
cursor: 'pointer',
fontSize: '12px'
});
inputGroup.appendChild(tokenInput);
inputGroup.appendChild(connectButton);
const disconnectButton = document.createElement('button');
disconnectButton.className = 'webmcp-disconnect-btn';
disconnectButton.textContent = 'Disconnect';
Object.assign(disconnectButton.style, {
padding: '8px 12px',
backgroundColor: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
width: '100%',
display: 'none'
});
form.appendChild(inputGroup);
form.appendChild(disconnectButton);
container.appendChild(form);
}
/**
* Set up event listeners for the widget
* @private
*/
_setupEventListeners() {
const container = document.getElementById(this.elementId);
if (!container) return;
// Trigger button click - expand/collapse
const trigger = container.querySelector('.webmcp-trigger');
trigger.addEventListener('click', () => {
this._toggleExpanded();
});
// Close button click - collapse
const closeBtn = container.querySelector('.webmcp-close');
closeBtn.addEventListener('click', () => {
this._toggleExpanded(false);
});
// Connect button click
const connectBtn = container.querySelector('.webmcp-connect-btn');
connectBtn.addEventListener('click', () => {
const tokenInput = container.querySelector('.webmcp-token-input');
this.connect(tokenInput.value);
});
// Disconnect button click
const disconnectBtn = container.querySelector('.webmcp-disconnect-btn');
disconnectBtn.addEventListener('click', () => {
this.disconnect();
});
// User activity detection to reset inactivity timer
document.addEventListener('mousemove', () => this._resetInactivityTimer());
document.addEventListener('keypress', () => this._resetInactivityTimer());
document.addEventListener('click', () => this._resetInactivityTimer());
document.addEventListener('scroll', () => this._resetInactivityTimer());
}
/**
* Toggle the expanded state of the widget
* @private
*/
_toggleExpanded(force = null) {
const container = document.getElementById(this.elementId);
if (!container) return;
const contentPanel = container.querySelector('.webmcp-content');
this.isExpanded = force !== null ? force : !this.isExpanded;
if (this.isExpanded) {
contentPanel.style.display = 'block';
} else {
contentPanel.style.display = 'none';
}
this._resetInactivityTimer();
}
/**
* Update the status indicator
* @private
*/
_updateStatus(status, message) {
this._currentStatus = status;
this._emit('statusChange', { status, message: message || status });
const container = document.getElementById(this.elementId);
if (!container) return;
const statusIndicator = container.querySelector('.webmcp-status');
if (!statusIndicator) return;
// Clear existing classes
statusIndicator.classList.remove('connected', 'disconnected', 'connecting', 'pending-auth');
// Set new status
statusIndicator.textContent = message || status;
// Apply styling based on status
switch (status) {
case 'connected':
Object.assign(statusIndicator.style, {
backgroundColor: '#d4edda',
color: '#155724'
});
break;
case 'disconnected':
Object.assign(statusIndicator.style, {
backgroundColor: '#f8d7da',
color: '#721c24'
});
break;
case 'connecting':
Object.assign(statusIndicator.style, {
backgroundColor: '#fff3cd',
color: '#856404'
});
break;
case 'pending-auth':
Object.assign(statusIndicator.style, {
backgroundColor: '#d1ecf1',
color: '#0c5460'
});
break;
}
}
/**
* Update UI based on connection state
* @private
*/
_updateConnectionUI(isConnected) {
const container = document.getElementById(this.elementId);
if (!container) return;
const tokenInput = container.querySelector('.webmcp-token-input');
const connectBtn = container.querySelector('.webmcp-connect-btn');
const disconnectBtn = container.querySelector('.webmcp-disconnect-btn');
const registeredItemsContainer = container.querySelector('.webmcp-registered-items');
if (isConnected) {
tokenInput.style.display = 'none';
connectBtn.style.display = 'none';
disconnectBtn.style.display = 'block';
registeredItemsContainer.style.display = 'block';
// Update the trigger button to show connected state
const trigger = container.querySelector('.webmcp-trigger');
trigger.innerHTML = '✓';
trigger.style.color = 'white';
trigger.style.fontWeight = 'bold';
} else {
tokenInput.style.display = 'block';
connectBtn.style.display = 'block';
disconnectBtn.style.display = 'none';
registeredItemsContainer.style.display = 'none';
// Reset the trigger button
const trigger = container.querySelector('.webmcp-trigger');
trigger.innerHTML = '';
}
}
/**
* Update tools list in UI
* @private
*/
_updateToolsList() {
const container = document.getElementById(this.elementId);
if (!container) return;
const toolsContainer = container.querySelector('.webmcp-tools-container');
if (!toolsContainer) return;
// Clear current list
toolsContainer.innerHTML = '';
if (this.availableTools.size === 0) {
const emptyMessage = document.createElement('li');
emptyMessage.textContent = 'No tools registered';
emptyMessage.style.fontStyle = 'italic';
emptyMessage.style.color = '#666';
toolsContainer.appendChild(emptyMessage);
return;
}
// Add each tool to the list
this.availableTools.forEach((tool, name) => {
const toolItem = document.createElement('li');
Object.assign(toolItem.style, {
padding: '5px 0',
borderBottom: '1px solid #eee'
});
const toolName = document.createElement('strong');
toolName.textContent = name;
const toolDesc = document.createElement('div');
toolDesc.textContent = tool.description;
toolDesc.style.fontSize = '10px';
toolDesc.style.color = '#666';
toolItem.appendChild(toolName);
toolItem.appendChild(toolDesc);
toolsContainer.appendChild(toolItem);
});
}
/**
* Update prompts list in UI
* @private
*/
_updatePromptsList() {
const container = document.getElementById(this.elementId);
if (!container) return;
const promptsContainer = container.querySelector('.webmcp-prompts-container');
if (!promptsContainer) return;
// Clear current list
promptsContainer.innerHTML = '';
if (this.availablePrompts.size === 0) {
const emptyMessage = document.createElement('li');
emptyMessage.textContent = 'No prompts registered';
emptyMessage.style.fontStyle = 'italic';
emptyMessage.style.color = '#666';
promptsContainer.appendChild(emptyMessage);
return;
}
// Add each prompt to the list
this.availablePrompts.forEach((prompt, name) => {
const promptItem = document.createElement('li');
Object.assign(promptItem.style, {
padding: '5px 0',
borderBottom: '1px solid #eee'
});
const promptName = document.createElement('strong');
promptName.textContent = name;
const promptDesc = document.createElement('div');
promptDesc.textContent = prompt.description;
promptDesc.style.fontSize = '10px';
promptDesc.style.color = '#666';
promptItem.appendChild(promptName);
promptItem.appendChild(promptDesc);
promptsContainer.appendChild(promptItem);
});
}
/**
* Update resources list in UI
* @private
*/
_updateResourcesList() {
const container = document.getElementById(this.elementId);
if (!container) return;
const resourcesContainer = container.querySelector('.webmcp-resources-container');
if (!resourcesContainer) return;
// Clear current list
resourcesContainer.innerHTML = '';
if (this.availableResources.size === 0) {
const emptyMessage = document.createElement('li');
emptyMessage.textContent = 'No resources registered';
emptyMessage.style.fontStyle = 'italic';
emptyMessage.style.color = '#666';
resourcesContainer.appendChild(emptyMessage);
return;
}
// Add each resource to the list
this.availableResources.forEach((resource, name) => {
const resourceItem = document.createElement('li');
Object.assign(resourceItem.style, {
padding: '5px 0',
borderBottom: '1px solid #eee'
});
const resourceName = document.createElement('strong');
resourceName.textContent = name;
const resourceDesc = document.createElement('div');
resourceDesc.textContent = resource.description +
(resource.isTemplate ? ' (Template)' : '');
resourceDesc.style.fontSize = '10px';
resourceDesc.style.color = '#666';
resourceItem.appendChild(resourceName);
resourceItem.appendChild(resourceDesc);
resourcesContainer.appendChild(resourceItem);
});
}
/**
* Reset the inactivity timer
* @private
*/
_resetInactivityTimer() {
// Clear existing timer
if (this.inactivityTimer) {
clearTimeout(this.inactivityTimer);
}
// Set new timer
this.inactivityTimer = setTimeout(() => {
this._handleInactivity();
}, this.options.inactivityTimeout);
}
/**
* Handle user inactivity
* @private
*/
_handleInactivity() {
console.log('Inactivity timeout reached, disconnecting');
// Disconnect if connected
if (this.isConnected) {
this.disconnect();
}
// Minimize UI
this._toggleExpanded(false);
// Clear the stored token
sessionStorage.removeItem(this.SESSION_STORAGE_KEY);
}
/**
* Connect to the WebSocket server
* @public
* @param {string} connectionToken - The encoded connection token
*/
async connect(connectionToken) {
if (!connectionToken) {
this._updateStatus('disconnected', 'Error: No token provided');
return;
}
// Store for reconnection attempts
this._lastConnectionToken = connectionToken;
// Update UI to show connecting state
this._updateStatus('connecting', 'Connecting...');
try {
// Process the connection token
if (!this._processConnectionToken(connectionToken)) {
return;
}
// Store the connection info in sessionStorage for page navigations
const connectionInfo = {
token: connectionToken,
server: this.currentServer,
host: this._format(window.location.host)
};
// Check if we have connection data already in sessionStorage
const storedConnectionInfo = sessionStorage.getItem(this.SESSION_STORAGE_KEY);
let skipRegistration = false;
if (storedConnectionInfo) {
try {
const connectionInfo = JSON.parse(storedConnectionInfo);
// If we already have a valid token and server, we can skip registration
if (connectionInfo.server === this.currentServer &&
connectionInfo.host === this._format(window.location.host)) {
skipRegistration = true;
}
} catch (error) {
console.error('Error parsing stored connection info:', error);
}
}
if (!skipRegistration) {
// First register with server
const response = await this._registerWithServer(connectionToken);
if (!response.token) {
this._updateStatus('disconnected', 'Registration failed');
return;
}
// Save the new token
connectionInfo.token = response.token;
this.currentToken = response.token;
sessionStorage.setItem(this.SESSION_STORAGE_KEY, JSON.stringify(connectionInfo));
}
// Now connect to the actual channel
const serverUrl = `${this.currentServer}${this.currentChannel}?token=${this.currentToken}`;
// Update UI
this._updateStatus('connecting', 'Connecting to channel...');
// Create WebSocket connection with the path and token
this.socket = new WebSocket(serverUrl);
// Set up socket event listeners
this._setupSocketListeners();
// Reset inactivity timer
this._resetInactivityTimer();
} catch (error) {
console.error('Connection error:', error);
this._updateStatus('disconnected', `Error: ${error.message}`);
}
}
/**
* Disconnect from WebSocket server
* @public
*/
disconnect() {
// Close the WebSocket connection if it exists
if (this.socket) {
this.socket.close();
this.socket = null;
}
this.isConnected = false;
this._updateStatus('disconnected', 'Disconnected');
this._updateConnectionUI(false);
// Reset state
this.currentToken = '';
this.currentServer = '';
this.currentChannel = '';
// Remove the token from sessionStorage
sessionStorage.removeItem(this.SESSION_STORAGE_KEY);
// Clear items from sessionStorage
this._clearStoredItems();
}
/**
* Process connection token
* @private
* @param {string} encodedToken - The encoded connection token
* @returns {boolean} - True if processing was successful
*/
_processConnectionToken(encodedToken) {
try {
// Decode the base64 token
const jsonStr = atob(encodedToken);
const connectionData = JSON.parse(jsonStr);
// Extract server and token
const {server, token} = connectionData;
if (!server || !token) {
this._updateStatus('disconnected', 'Invalid token');
return false;
}
// Store connection info
this.currentServer = server;
this.currentToken = token;
// Format channel based on hostname
this.currentChannel = `/${this._format(window.location.host)}`;
return true;
} catch (error) {
this._updateStatus('disconnected', `Unable to parse token`);
return false;
}
}
/**
* Register with server using connection token
* @private
* @param {string} encodedToken - The encoded connection token
* @returns {Promise<{ token: string }>} - Resolves to true if registration was successful
*/
_registerWithServer(encodedToken) {
// Update UI
this._updateStatus('pending-auth', 'Registering...');
// Connect to the registration endpoint
const regSocket = new WebSocket(`${this.currentServer}${this.REGISTER_PATH}`);
return new Promise((resolve, reject) => {
// Connection opened - send the token
regSocket.addEventListener('open', (event) => {
console.log('Registration connection established');
// Send the original encoded token back to the server
const jsonStr = atob(encodedToken);
const connectionData = JSON.parse(jsonStr);
connectionData.host = this._format(window.location.host);
regSocket.send(btoa(JSON.stringify(connectionData)));
});
// Listen for registration response
regSocket.addEventListener('message', (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'registerSuccess' && message.token) {
console.log(`Registration successful: ${message.message}`);
// Registration complete, can now connect to channel
resolve({ token: message.token });
} else if (message.type === 'error') {
console.error(`Registration failed: ${message.message}`);
this._updateStatus('disconnected', `Registration failed: ${message.message}`);
reject(new Error(message.message));
}
} catch (error) {
console.error(`Error parsing registration response: ${error.message}`);
this._updateStatus('disconnected', 'Error parsing server response');
reject(error);
}
});
// Handle registration errors
regSocket.addEventListener('error', (event) => {
console.error('Registration connection error');
this._updateStatus('disconnected', 'Registration connection error');
sessionStorage.removeItem(this.SESSION_STORAGE_KEY);
reject(new Error('Connection error'));
});
// Handle registration connection close
regSocket.addEventListener('close', (event) => {
console.log(`Registration connection closed: ${event.code} ${event.reason}`);
if (event.code !== 1000) {
// If it wasn't a normal closure, show an error
this._updateStatus('disconnected', 'Registration failed');
sessionStorage.removeItem(this.SESSION_STORAGE_KEY);
reject(new Error('Connection closed'));
}
});
});
}
/**
* Set up WebSocket event listeners for direct connection
* @private
*/
_setupSocketListeners() {
if (!this.socket) {
console.error('Cannot set up socket listeners: WebSocket not available');
return;
}
// Set up socket open handler
this.socket.addEventListener('open', () => {
this.isConnected = true;
this._reconnectAttempts = 0;
this._updateStatus('connected', `Connected to ${this.currentChannel}`);
this._updateConnectionUI(true);
this._emit('connected', { channel: this.currentChannel, server: this.currentServer });
console.log('WebMCP connection established');
this._registerItemsWithServer();
});
// Set up socket close handler
this.socket.addEventListener('close', (event) => {
this.isConnected = false;
console.log(`Connection closed: ${event.code} ${event.reason}`);
// Normal closure — no retry
if (event.code === 1000) {
this._updateStatus('disconnected', 'Disconnected');
this._updateConnectionUI(false);
this._emit('disconnected', { code: event.code, reason: event.reason });
return;
}
// Abnormal closure — try to reconnect
if (this._reconnectAttempts < this._maxReconnectAttempts && this._lastConnectionToken) {
this._reconnectAttempts++;
const delay = this._reconnectDelay * this._reconnectAttempts;
console.log(`Reconnecting (attempt ${this._reconnectAttempts}/${this._maxReconnectAttempts}) in ${delay}ms...`);
this._updateStatus('connecting', `Reconectando (${this._reconnectAttempts}/${this._maxReconnectAttempts})...`);
this._emit('reconnecting', {
attempt: this._reconnectAttempts,
maxAttempts: this._maxReconnectAttempts,
delay
});
setTimeout(() => {
this.connect(this._lastConnectionToken);
}, delay);
return;
}
// Max retries exceeded or no token — give up
this._updateStatus('disconnected', 'Disconnected');
this._updateConnectionUI(false);
this._emit('disconnected', { code: event.code, reason: event.reason });
this.currentToken = '';
this.currentServer = '';
this.currentChannel = '';
sessionStorage.removeItem(this.SESSION_STORAGE_KEY);
});
// Set up socket error handler
this.socket.addEventListener('error', () => {
// Suppress noisy errors during reconnection attempts
if (this._reconnectAttempts > 0) return;
console.error('WebSocket error');
if (this.isConnected) {
this._updateStatus('disconnected', 'Connection error occurred');
} else {
this._updateStatus('disconnected', 'Connection failed');
}
sessionStorage.removeItem(this.SESSION_STORAGE_KEY);
});
// Set up socket message handler
this.socket.addEventListener('message', (event) => {
try {
const message = JSON.parse(event.data);
this._handleServerMessage(message);
} catch (error) {
console.error(`Error parsing message: ${error.message}`);
}
});
}
/**
* Handle messages from the server
* @private
* @param {Object} message - The parsed message object
*/
_handleServerMessage(message) {
switch (message.type) {
case 'welcome':
console.log(`Server says: ${message.message}`);
this._sendMessage({
type: 'clientInfo',
userAgent: navigator.userAgent,
url: window.location.href,
hostname: window.location.hostname,
language: navigator.language,
screenWidth: window.innerWidth,
screenHeight: window.innerHeight,
timestamp: Date.now()
});
break;
case 'toolRegistered':
console.log(`Tool registered with server: ${message.name}`);
this._emit('toolRegistered', { name: message.name });
break;
case 'promptRegistered':
console.log(`Prompt registered with server: ${message.name}`);
break;
case 'resourceRegistered':
console.log(`Resource registered with server: ${message.name}`);
break;
case 'callTool':
// Server is asking us to execute a tool
this._handleToolCall(message);
break;
case 'getPrompt':
// Server is asking us to provide a prompt
this._handleGetPrompt(message);
break;
case 'readResource':
// Server is asking us to provide a resource
this._handleReadResource(message);
break;
case 'createSamplingMessage':
// Server is asking us to create a sampling message
this._handleCreateSamplingMessage(message);
break;
case 'listTools':
// Server is asking for available tools
this._sendToolsList(message.id);
break;
case 'listPrompts':
// Server is asking for available prompts
this._sendPromptsList(message.id);
break;
case 'listResources':
// Server is asking for available resources
this._sendResourcesList(message.id);
break;
case 'ping':
// Respond to ping
this._sendMessage({
type: 'pong',
id: message.id,
timestamp: Date.now()
});
break;
case 'createTool':
this._handleCreateTool(message);
break;
case 'removeTool':
if (message.name && this.availableTools.has(message.name)) {
this.availableTools.delete(message.name);
this.registeredTools.delete(message.name);
this._saveItemsToStorage();
this._updateToolsList();
this._emit('toolRemoved', { name: message.name });
console.log(`Tool removed by server: ${message.name}`);
}
break;
case 'removeAllTools':
this.availableTools.clear();
this.registeredTools.clear();
this._saveItemsToStorage();
this._updateToolsList();
this._emit('toolRemoved', { name: '*' });
console.log('All tools removed by server');
break;
case 'getClientInfo':
this._sendMessage({
id: message.id,
type: 'clientInfoResponse',
userAgent: navigator.userAgent,
url: window.location.href,
hostname: window.location.hostname,
language: navigator.language,
screenWidth: window.innerWidth,
screenHeight: window.innerHeight,
timestamp: Date.now()
});
break;
case 'clipboardCopy':
if (message.text && navigator.clipboard) {
navigator.clipboard.writeText(message.text).then(() => {
console.log('Token copiado al portapapeles');
}).catch(err => {
console.error('Error copiando al portapapeles:', err);
});
}
break;
case 'error':
console.error(`Server error: ${message.message}`);
this._emit('error', { message: message.message });
break;
default:
console.warn(`Unknown message type: ${message.type}`);
}
}
/**
* Handle createTool request from server (via built-in agregar-tool)
* @private
* @param {Object} message - The parsed message object
*/
_handleCreateTool(message) {
const {id, name, description, code, parametros} = message;
try {
let props = {};
if (parametros) props = JSON.parse(parametros);
const schema = { type: 'object', properties: props };
const fn = new Function('args', code);
this.registerTool(name, description, schema, fn);
this._sendMessage({
id,
type: 'toolResponse',
result: { content: [{ type: 'text', text: `Herramienta "${name}" registrada exitosamente` }] }
});
this._emit('toolCreated', { name });
console.log(`Tool created by agent: ${name}`);
} catch (e) {
this._sendMessage({
id,
type: 'toolResponse',
error: `Error creando herramienta: ${e.message}`
});
}
}
/**
* Handle tool call from server
* @private
* @param {Object} message - The parsed message object
*/
_handleToolCall(message) {
const {id, tool, arguments: args} = message;
console.log(`Tool call: ${tool} with args:`, args);
if (!this.availableTools.has(tool)) {
this._sendMessage({
id,
type: 'toolResponse',
error: `Tool not found: ${tool}`
});
return;
}
// Execute the tool
try {
const toolObj = this.availableTools.get(tool);
// Normalize result to MCP content format
const wrapResult = (raw) => {
if (raw && raw.content && Array.isArray(raw.content)) return raw;
const text = typeof raw === 'string' ? raw : JSON.stringify(raw);
return { content: [{ type: "text", text }] };
};
// Call the tool's execute function
const result = toolObj.execute(args);
// Handle promises
if (result instanceof Promise) {
result
.then(resolvedResult => {
this._sendMessage({
id,
type: 'toolResponse',
result: wrapResult(resolvedResult)
});
})
.catch(error => {
this._sendMessage({
id,
type: 'toolResponse',
error: error.message || 'Tool execution error'
});
});
} else {
// Send immediate result
this._sendMessage({
id,
type: 'toolResponse',
result: wrapResult(result)
});
}
console.log(`Tool response sent for ${tool}`);
} catch (error) {
this._sendMessage({
id,
type: 'toolResponse',
error: error.message || 'Tool execution error'
});
console.error(`Tool execution error:`, error);
}
}
/**
* Handle prompt request from server
* @private
* @param {Object} message - The parsed message object
*/
_handleGetPrompt(message) {
const {id, name, arguments: args} = message;
console.log(`Prompt request: ${name} with args:`, args);
if (!this.availablePrompts.has(name)) {
this._sendMessage({
id,
type: 'promptResponse',
error: `Prompt not found: ${name}`
});
return;
}
// Execute the prompt
try {
const promptObj = this.availablePrompts.get(name);
// Call the prompt's execute function
const result = promptObj.execute(args);
// Handle promises
if (result instanceof Promise) {
result
.then(resolvedResult => {
this._sendMessage({
id,
type: 'promptResponse',
result: resolvedResult
});
})
.catch(error => {
this._sendMessage({
id,
type: 'promptResponse',
error: error.message || 'Prompt execution error'
});
});
} else {
// Send immediate result
this._sendMessage({
id,
type: 'promptResponse',
result
});
}
console.log(`Prompt response sent for ${name}`);
} catch (error) {
this._sendMessage({
id,
type: 'promptResponse',
error: error.message || 'Prompt execution error'
});
console.error(`Prompt execution error:`, error);
}
}
/**
* Handle resource request from server
* @private
* @param {Object} message - The parsed message object
*/
_handleReadResource(message) {
const {id, uri} = message;
console.log(`Resource request: ${uri}`);
// Find resource that handles this URI
let resourceObj = null;
// First check for direct URI match
for (const resource of this.availableResources.values()) {
if (!resource.isTemplate && resource.uri === uri) {
resourceObj = resource;
break;
}
}
// If no direct match, check for template match
if (!resourceObj) {
for (const resource of this.availableResources.values()) {
if (resource.isTemplate) {
// Simple check - if URI starts with template prefix (before any parameters)
const templatePrefix = resource.uriTemplate.split('{')[0];
if (uri.startsWith(templatePrefix)) {
resourceObj = resource;
break;
}
}
}
}
if (!resourceObj) {
this._sendMessage({
id,
type: 'resourceResponse',
error: `No resource handler found for URI: ${uri}`
});
return;
}
// Execute the resource provider
try {
// Call the resource's provide function
const result = resourceObj.provide(uri);
// Handle promises
if (result instanceof Promise) {
result
.then(resolvedResult => {
this._sendMessage({
id,
type: 'resourceResponse',
result: resolvedResult
});
})
.catch(error => {
this._sendMessage({
id,
type: 'resourceResponse',
error: error.message || 'Resource read error'
});
});
} else {
// Send immediate result
this._sendMessage({
id,
type: 'resourceResponse',
result
});
}
console.log(`Resource response sent for ${uri}`);
} catch (error) {
this._sendMessage({
id,
type: 'resourceResponse',
error: error.message || 'Resource read error'
});
console.error(`Resource read error:`, error);
}
}
/**
* Send available tools list
* @private
* @param {string} requestId - The request ID to respond to
*/
_sendToolsList(requestId) {
const toolsList = Array.from(this.availableTools.values()).map(tool => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
}));
this._sendMessage({
id: requestId,
type: 'listToolsResponse',
tools: toolsList
});
console.log(`Sent tools list: ${toolsList.length} tools`);
}
/**
* Send available prompts list
* @private
* @param {string} requestId - The request ID to respond to
*/
_sendPromptsList(requestId) {
const promptsList = Array.from(this.availablePrompts.values()).map(prompt => ({
name: prompt.name,
description: prompt.description,
arguments: prompt.arguments,
}));
this._sendMessage({
id: requestId,
type: 'listPromptsResponse',
prompts: promptsList
});
console.log(`Sent prompts list: ${promptsList.length} prompts`);
}
/**
* Send available resources list
* @private
* @param {string} requestId - The request ID to respond to
*/
_sendResourcesList(requestId) {
const resources = [];
const resourceTemplates = [];
// Split resources and templates
this.availableResources.forEach((resource) => {
if (resource.isTemplate) {
resourceTemplates.push({
name: resource.name,
description: resource.description,
uriTemplate: resource.uriTemplate,
mimeType: resource.mimeType
});
} else {
resources.push({
name: resource.name,
description: resource.description,
uri: resource.uri,
mimeType: resource.mimeType
});
}
});
this._sendMessage({
id: requestId,
type: 'listResourcesResponse',
resources,
resourceTemplates
});
console.log(`Sent resources list: ${resources.length} resources, ${resourceTemplates.length} templates`);
}
/**
* Send a message to the server via direct WebSocket
* @private
* @param {Object} message - The message object to send
*/
_sendMessage(message) {
if (!this.isConnected || !this.socket) {
console.error('Cannot send message: not connected');
return;
}
try {
// Send the message directly through the WebSocket
this.socket.send(JSON.stringify(message));
return Promise.resolve();
} catch (error) {
console.error(`Error sending message: ${error.message}`);
return Promise.reject(error);
}
}
/**
* Register all items with server that were registered while disconnected
* @private
*/
_registerItemsWithServer() {
if (!this.isConnected) return;
// Clear registration tracking sets - we'll re-register everything
this.registeredTools = new Set();
this.registeredPrompts = new Set();
this.registeredResources = new Set();
// Register all tools with the server
this.availableTools.forEach((tool, name) => {
this._sendMessage({
type: 'registerTool',
name,
description: tool.description,
inputSchema: tool.inputSchema
});
this.registeredTools.add(name);
console.log(`Registering tool with server: ${name}`);
});
// Register all prompts with the server
this.availablePrompts.forEach((prompt, name) => {
this._sendMessage({
type: 'registerPrompt',
name,
description: prompt.description,
arguments: prompt.arguments
});
this.registeredPrompts.add(name);
console.log(`Registering prompt with server: ${name}`);
});
// Register all resources with the server
this.availableResources.forEach((resource, name) => {
this._sendMessage({
type: 'registerResource',
name,
description: resource.description,
uri: resource.uri,
uriTemplate: resource.uriTemplate,
isTemplate: resource.isTemplate,
mimeType: resource.mimeType
});
this.registeredResources.add(name);
console.log(`Registering resource with server: ${name}`);
});
}
/**
* Register a tool
* @public
* @param {string} name - The name of the tool
* @param {string} description - The description of the tool
* @param {Object} schema - The schema for the tool's input
* @param {Function} executeFn - The function to execute when the tool is called
*/
registerTool(name, description, schema, executeFn) {
if (!name) {
console.error('Tool name is required');
return;
}
// Add the tool to local registry
this.availableTools.set(name, {
name,
description: description || `Tool: ${name}`,
execute: executeFn || function (args) {
return `Default implementation of ${name} with args: ${JSON.stringify(args)}`;
},
inputSchema: schema || {
type: "object",
properties: {}
}
});
// Register the tool with the server if connected
if (this.isConnected) {
this._sendMessage({
type: 'registerTool',
name,
description: description || `Tool: ${name}`,
inputSchema: schema || {
type: "object",
properties: {}
},
});
this.registeredTools.add(name);
}
// Save to session storage
this._saveItemsToStorage();
// Update tools display
this._updateToolsList();
console.log(`Tool registered: ${name}`);
}
/**
* Unregister a tool
* @public
* @param {string} name - The name of the tool to unregister
*/
unregisterTool(name) {
if (!name) {
console.error('Tool name is required');
return;
}
if (!this.availableTools.has(name)) {
console.warn(`Tool not found: ${name}`);
return;
}
this.availableTools.delete(name);
if (this.isConnected) {
this._sendMessage({
type: 'unregisterTool',
name
});
}
this.registeredTools.delete(name);
this._saveItemsToStorage();
this._updateToolsList();
console.log(`Tool unregistered: ${name}`);
}
/**
* Unregister all tools at once (single notification)
* @public
*/
unregisterAllTools() {
if (this.availableTools.size === 0) return;
this.availableTools.clear();
this.registeredTools.clear();
if (this.isConnected) {
this._sendMessage({ type: 'unregisterAllTools' });
}
this._saveItemsToStorage();
this._updateToolsList();
console.log('All tools unregistered');
}
/**
* Register a prompt
* @public
* @param {string} name - The name of the prompt
* @param {string} description - The description of the prompt
* @param {Array} promptArgs - The arguments for the prompt
* @param {Function} executeFn - The function to execute when the prompt is called
*/
registerPrompt(name, description, promptArgs, executeFn) {
if (!name) {
console.error('Prompt name is required');
return;
}
// Add the prompt to local registry
this.availablePrompts.set(name, {
name,
description: description || `Prompt: ${name}`,
execute: executeFn || function (args) {
return {
messages: [{
role: "user",
content: {
type: "text",
text: `Default implementation of prompt ${name} with args: ${JSON.stringify(args)}`
}
}]
};
},
arguments: promptArgs || []
});
// Register the prompt with the server if connected
if (this.isConnected) {
this._sendMessage({
type: 'registerPrompt',
name,
description: description || `Prompt: ${name}`,
arguments: promptArgs || []
});
this.registeredPrompts.add(name);
}
// Save to session storage
this._saveItemsToStorage();
// Update prompts display
this._updatePromptsList();
console.log(`Prompt registered: ${name}`);
}
/**
* Unregister a prompt
* @public
* @param {string} name - The name of the prompt to unregister
*/
unregisterPrompt(name) {
if (!name) {
console.error('Prompt name is required');
return;
}
if (!this.availablePrompts.has(name)) {
console.warn(`Prompt not found: ${name}`);
return;
}
this.availablePrompts.delete(name);
if (this.isConnected) {
this._sendMessage({
type: 'unregisterPrompt',
name
});
}
this.registeredPrompts.delete(name);
this._saveItemsToStorage();
this._updatePromptsList();
console.log(`Prompt unregistered: ${name}`);
}
/**
* Register a resource
* @public
* @param {string} name - The name of the resource
* @param {string} description - The description of the resource
* @param {Object} options - The resource options including uri, uriTemplate, and mimeType
* @param {Function} provideFn - The function to execute when the resource is requested
*/
registerResource(name, description, options, provideFn) {
if (!name) {
console.error('Resource name is required');
return;
}
if (!options.uri && !options.uriTemplate) {
console.error('Either uri or uriTemplate is required for a resource');
return;
}
const isTemplate = !!options.uriTemplate;
// Add the resource to local registry
this.availableResources.set(name, {
name,
description: description || `Resource: ${name}`,
uri: options.uri,
uriTemplate: options.uriTemplate,
isTemplate,
mimeType: options.mimeType,
provide: provideFn || function (uri) {
return {
contents: [{
uri: uri,
text: `Default implementation of resource ${name} for URI: ${uri}`,
mimeType: options.mimeType || "text/plain"
}]
};
}
});
// Register the resource with the server if connected
if (this.isConnected) {
this._sendMessage({
type: 'registerResource',
name,
description: description || `Resource: ${name}`,
uri: options.uri,
uriTemplate: options.uriTemplate,
isTemplate,
mimeType: options.mimeType
});
this.registeredResources.add(name);
}
// Save to session storage
this._saveItemsToStorage();
// Update resources display
this._updateResourcesList();
console.log(`Resource registered: ${name}`);
}
/**
* Unregister a resource
* @public
* @param {string} name - The name of the resource to unregister
*/
unregisterResource(name) {
if (!name) {
console.error('Resource name is required');
return;
}
if (!this.availableResources.has(name)) {
console.warn(`Resource not found: ${name}`);
return;
}
this.availableResources.delete(name);
if (this.isConnected) {
this._sendMessage({
type: 'unregisterResource',
name
});
}
this.registeredResources.delete(name);
this._saveItemsToStorage();
this._updateResourcesList();
console.log(`Resource unregistered: ${name}`);
}
/**
* Handle sampling message creation request
* @private
* @param {Object} message - The parsed message object
*/
_handleCreateSamplingMessage(message) {
const {
id,
messages,
systemPrompt,
includeContext,
temperature,
maxTokens,
stopSequences,
metadata,
modelPreferences
} = message;
console.log(`Sampling request received with ${messages?.length || 0} messages`);
// Create a modal dialog to show the sampling request
const modal = document.createElement('div');
Object.assign(modal.style, {
position: 'fixed',
top: '0',
left: '0',
width: '100%',
height: '100%',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: '10000'
});
// Create modal content
const modalContent = document.createElement('div');
Object.assign(modalContent.style, {
backgroundColor: 'white',
padding: '20px',
borderRadius: '5px',
maxWidth: '500px',
width: '90%',
maxHeight: '80%',
overflow: 'auto'
});
// Create header
const header = document.createElement('h3');
header.textContent = 'Sampling Request';
Object.assign(header.style, {
margin: '0 0 15px 0',
padding: '0 0 10px 0',
borderBottom: '1px solid #ddd'
});
// Create content area to show messages
const content = document.createElement('div');
Object.assign(content.style, {
marginBottom: '15px',
maxHeight: '300px',
overflow: 'auto',
border: '1px solid #ddd',
padding: '10px',
backgroundColor: '#f9f9f9'
});
// Display messages
if (messages && messages.length > 0) {
messages.forEach(msg => {
const msgDiv = document.createElement('div');
Object.assign(msgDiv.style, {
marginBottom: '10px',
padding: '5px',
borderRadius: '3px',
backgroundColor: msg.role === 'user' ? '#e1f5fe' : '#f1f8e9'
});
const roleSpan = document.createElement('strong');
roleSpan.textContent = msg.role === 'user' ? 'User: ' : 'Assistant: ';
const contentSpan = document.createElement('span');
if (msg.content.type === 'text') {
contentSpan.textContent = msg.content.text;
} else if (msg.content.type === 'image') {
contentSpan.textContent = '[Image data]';
}
msgDiv.appendChild(roleSpan);
msgDiv.appendChild(contentSpan);
content.appendChild(msgDiv);
});
} else {
content.textContent = 'No messages provided in sampling request';
}
// System prompt if available
if (systemPrompt) {
const sysPromptDiv = document.createElement('div');
Object.assign(sysPromptDiv.style, {
marginBottom: '10px',
padding: '5px',
backgroundColor: '#fff8e1'
});
const sysPromptLabel = document.createElement('strong');
sysPromptLabel.textContent = 'System Prompt: ';
const sysPromptContent = document.createElement('span');
sysPromptContent.textContent = systemPrompt;
sysPromptDiv.appendChild(sysPromptLabel);
sysPromptDiv.appendChild(sysPromptContent);
content.appendChild(sysPromptDiv);
}
// Create response input
const responseLabel = document.createElement('label');
responseLabel.textContent = 'Assistant Response:';
Object.assign(responseLabel.style, {
display: 'block',
marginBottom: '5px',
fontWeight: 'bold'
});
const responseInput = document.createElement('textarea');
Object.assign(responseInput.style, {
width: '100%',
minHeight: '100px',
padding: '10px',
marginBottom: '15px',
boxSizing: 'border-box'
});
// Create buttons
const buttonContainer = document.createElement('div');
Object.assign(buttonContainer.style, {
display: 'flex',
justifyContent: 'space-between'
});
const submitButton = document.createElement('button');
submitButton.textContent = 'Submit Response';
Object.assign(submitButton.style, {
padding: '8px 15px',
backgroundColor: '#4CAF50',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
});
const cancelButton = document.createElement('button');
cancelButton.textContent = 'Cancel';
Object.assign(cancelButton.style, {
padding: '8px 15px',
backgroundColor: '#f44336',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
});
// Add elements to modal
buttonContainer.appendChild(cancelButton);
buttonContainer.appendChild(submitButton);
modalContent.appendChild(header);
modalContent.appendChild(content);
modalContent.appendChild(responseLabel);
modalContent.appendChild(responseInput);
modalContent.appendChild(buttonContainer);
modal.appendChild(modalContent);
document.body.appendChild(modal);
// Focus the response input
responseInput.focus();
// Setup button handlers
submitButton.addEventListener('click', () => {
const responseText = responseInput.value.trim();
if (responseText) {
// Send response back to server
this._sendMessage({
id,
type: 'samplingResponse',
result: {
model: 'web-user-input',
role: 'assistant',
content: {
type: 'text',
text: responseText
}
}
});
// Remove modal
document.body.removeChild(modal);
} else {
alert('Please enter a response');
}
});
cancelButton.addEventListener('click', () => {
// Send error response
this._sendMessage({
id,
type: 'samplingResponse',
error: 'User cancelled sampling request'
});
// Remove modal
document.body.removeChild(modal);
});
}
}
// Export for module usage
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
module.exports = WebMCP;
}