WebMCP
by jasonjmcghee
Verified
- src
/**
* 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
...options
};
// 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();
// 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();
}
_format(s) {
return s.replace(/[.:]/g, '_');
}
/**
* Initialize the WebMCP widget
* @private
*/
_init() {
// 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) {
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;
}
// 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._updateStatus('connected', `Connected to ${this.currentChannel}`);
this._updateConnectionUI(true);
console.log('WebMCP connection established');
this._registerItemsWithServer();
});
// Set up socket close handler
this.socket.addEventListener('close', (event) => {
this.isConnected = false;
this._updateStatus('disconnected', 'Disconnected');
this._updateConnectionUI(false);
console.log(`Connection closed: ${event.code} ${event.reason}`);
// Check if it was an authorization error
if (event.code === 1001 || event.code === 401) {
this._updateStatus('disconnected', 'Authorization failed');
this.currentToken = '';
this.currentServer = '';
this.currentChannel = '';
sessionStorage.removeItem(this.SESSION_STORAGE_KEY);
}
});
// Set up socket error handler
this.socket.addEventListener('error', () => {
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}`);
break;
case 'toolRegistered':
console.log(`Tool registered with server: ${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 'error':
console.error(`Server error: ${message.message}`);
break;
default:
console.warn(`Unknown message type: ${message.type}`);
}
}
/**
* 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);
// 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: resolvedResult
});
})
.catch(error => {
this._sendMessage({
id,
type: 'toolResponse',
error: error.message || 'Tool execution error'
});
});
} else {
// Send immediate result
this._sendMessage({
id,
type: 'toolResponse',
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}`);
}
/**
* 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}`);
}
/**
* 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}`);
}
/**
* 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;
}