diff --git a/config.js b/config.js new file mode 100644 index 0000000..e7eb5e4 --- /dev/null +++ b/config.js @@ -0,0 +1,118 @@ +import * as path from 'path'; +import * as dotenv from 'dotenv'; +import * as os from 'os'; +import * as fs from 'fs/promises'; +import envPaths from 'env-paths'; + +// Create config directory in user's home folder +const HOME_DIR = os.homedir(); +const CONFIG_DIR = path.join(HOME_DIR, '.webmcp'); + +// Ensure config directory exists +const ensureConfigDir = async () => { + try { + await fs.mkdir(CONFIG_DIR, {recursive: true}); + } catch (error) { + console.error(`Error creating config directory at ${CONFIG_DIR}:`, error); + } +}; + +// Process ID file path +const PID_FILE = path.join(CONFIG_DIR, '.webmcp-server.pid'); +// Environment file path +const ENV_FILE = path.join(CONFIG_DIR, '.env'); +// Tokens file path +const TOKENS_FILE = path.join(CONFIG_DIR, '.webmcp-tokens.json'); + +// Load environment variables +dotenv.config({path: ENV_FILE}); + +// Server token for MCP authentication +const SERVER_TOKEN = process.env.WEBMCP_SERVER_TOKEN || ''; + +const HOST = "localhost"; + +const CONFIG = {}; + +function setConfig(args) { + Object.entries(args).forEach(([key, value]) => { + CONFIG[key] = value; + }); +} + +function formatChannel(channel) { + return `/${channel.replace(/[.:]/g, '_')}` +} + +async function exists(somePath) { + try { + await fs.access(somePath); + return true; + } catch (e) { + return false; + } +} + +async function configureMcpClientWithPath(clientConfigPath) { + const directory = path.dirname(clientConfigPath); + if (!await exists(directory)) { + await fs.mkdir(directory, { recursive: true }); + } + + const webmcpConfig = { + "webmcp": { + "command": "npx", + "args": [ + "-y", + "@nucleoriofrio/webmcp@latest", + "--mcp" + ] + } + }; + + let json = { mcpServers: {} }; + + // If one already exists, we'll want to update it + if (await exists(clientConfigPath)) { + const rawJSON = await fs.readFile(clientConfigPath); + try { + json = JSON.parse(rawJSON); + } catch (e) { + throw new Error(`Failed to update MCP client configuration: ${e}`); + } + } + + json.mcpServers = { ...json.mcpServers, ...webmcpConfig}; + await fs.writeFile(clientConfigPath, JSON.stringify(json, null, 2)); +} + +const availableClientConfigs = { + "claude": [envPaths("Claude", { suffix: "" }).data, "claude_desktop_config.json"], + "cline": [envPaths("Code", { suffix: "" }).data, "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json"], + "cursor": [HOME_DIR, ".cursor", "mcp.json"], + "windsurf": [HOME_DIR, ".codeium", "windsurf", "mcp_config.json"] +}; + +async function configureMcpClient(clientType) { + let clientConfigPath = availableClientConfigs[clientType]; + if (clientConfigPath) { + await configureMcpClientWithPath(clientConfigPath); + } else { + console.error("Unsupported client - treating it like a path...") + await configureMcpClientWithPath(clientType); + } +} + +export { + CONFIG, + HOST, + PID_FILE, + ENV_FILE, + TOKENS_FILE, + SERVER_TOKEN, + ensureConfigDir, + formatChannel, + setConfig, + configureMcpClientWithPath, + configureMcpClient, +}; diff --git a/server.js b/server.js new file mode 100644 index 0000000..d15bc44 --- /dev/null +++ b/server.js @@ -0,0 +1,889 @@ +import {Server} from "@modelcontextprotocol/sdk/server/index.js"; +import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js'; +import WebSocket from 'ws'; +import { + CallToolRequestSchema, + CreateMessageRequestSchema, + GetPromptRequestSchema, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema +} from '@modelcontextprotocol/sdk/types.js'; +import {generateNewRegistrationToken} from "./tokens.js"; +import {CONFIG} from "./config.js"; +import {execSync} from "child_process"; + +// Create a central MCP server that communicates over stdio +const mcpServer = new Server( + { + name: "@nucleoriofrio/webmcp", + version: "0.2.0", + }, + { + capabilities: { + tools: { + listChanged: true + }, + prompts: { + listChanged: true + }, + resources: { + listChanged: true, + subscribe: true + }, + sampling: {} + } + } +); + +// WebSocket client connection +let wsClient = null; + +// MCP specific channel path +const MCP_PATH = '/mcp'; + +// Map to store pending requests from WebSocket to MCP +const pendingRequests = new Map(); +let requestIdCounter = 1; + +// Debounce timers for list changed notifications +let toolListChangedTimer = null; +let promptListChangedTimer = null; +let resourceListChangedTimer = null; +const LIST_CHANGED_DEBOUNCE_MS = 500; + +function debouncedToolListChanged() { + if (toolListChangedTimer) clearTimeout(toolListChangedTimer); + toolListChangedTimer = setTimeout(async () => { + toolListChangedTimer = null; + try { await mcpServer.sendToolListChanged(); } catch (e) { console.error('Error sending tool list changed:', e); } + }, LIST_CHANGED_DEBOUNCE_MS); +} + +function debouncedPromptListChanged() { + if (promptListChangedTimer) clearTimeout(promptListChangedTimer); + promptListChangedTimer = setTimeout(async () => { + promptListChangedTimer = null; + try { await mcpServer.sendPromptListChanged(); } catch (e) { console.error('Error sending prompt list changed:', e); } + }, LIST_CHANGED_DEBOUNCE_MS); +} + +function debouncedResourceListChanged() { + if (resourceListChangedTimer) clearTimeout(resourceListChangedTimer); + resourceListChangedTimer = setTimeout(async () => { + resourceListChangedTimer = null; + try { await mcpServer.sendResourceListChanged(); } catch (e) { console.error('Error sending resource list changed:', e); } + }, LIST_CHANGED_DEBOUNCE_MS); +} + +// Function to handle WebSocket messages +async function handleWebSocketMessage(message) { + try { + const data = JSON.parse(message); + console.error(`Received message: ${data.type}`); + + if (data.type === 'toolResponse') { + // Handle tool response from WebSocket server + const {id, result, error} = data; + + // Check if this is a response to a pending request + if (pendingRequests.has(id)) { + const {resolve, reject} = pendingRequests.get(id); + pendingRequests.delete(id); + + if (error) { + reject(new Error(error)); + } else { + resolve(result); + } + } else { + console.error(`No pending request found for ID: ${id}`); + } + } else if (data.type === 'promptResponse') { + // Handle prompt response from WebSocket server + const {id, result, error} = data; + + // Check if this is a response to a pending request + if (pendingRequests.has(id)) { + const {resolve, reject} = pendingRequests.get(id); + pendingRequests.delete(id); + + if (error) { + reject(new Error(error)); + } else { + resolve(result); + } + } else { + console.error(`No pending request found for ID: ${id}`); + } + } else if (data.type === 'resourceResponse') { + // Handle resource response from WebSocket server + const {id, result, error} = data; + + // Check if this is a response to a pending request + if (pendingRequests.has(id)) { + const {resolve, reject} = pendingRequests.get(id); + pendingRequests.delete(id); + + if (error) { + reject(new Error(error)); + } else { + resolve(result); + } + } else { + console.error(`No pending request found for ID: ${id}`); + } + } else if (data.type === 'samplingResponse') { + // Handle sampling response from WebSocket server + const {id, result, error} = data; + + // Check if this is a response to a pending request + if (pendingRequests.has(id)) { + const {resolve, reject} = pendingRequests.get(id); + pendingRequests.delete(id); + + if (error) { + reject(new Error(error)); + } else { + resolve(result); + } + } else { + console.error(`No pending request found for ID: ${id}`); + } + } else if (data.type === 'listToolsResponse') { + // Handle list tools response from WebSocket server + const {id, tools, error} = data; + + // Check if this is a response to a pending request + if (pendingRequests.has(id)) { + const {resolve, reject} = pendingRequests.get(id); + pendingRequests.delete(id); + + if (error) { + reject(new Error(error)); + } else { + resolve(tools); + } + } else { + console.error(`No pending request found for ID: ${id}`); + } + } else if (data.type === 'listPromptsResponse') { + // Handle list prompts response from WebSocket server + const {id, prompts, error} = data; + + // Check if this is a response to a pending request + if (pendingRequests.has(id)) { + const {resolve, reject} = pendingRequests.get(id); + pendingRequests.delete(id); + + if (error) { + reject(new Error(error)); + } else { + resolve(prompts); + } + } else { + console.error(`No pending request found for ID: ${id}`); + } + } else if (data.type === 'listResourcesResponse') { + // Handle list resources response from WebSocket server + const {id, resources, resourceTemplates, error} = data; + + // Check if this is a response to a pending request + if (pendingRequests.has(id)) { + const {resolve, reject} = pendingRequests.get(id); + pendingRequests.delete(id); + + if (error) { + reject(new Error(error)); + } else { + resolve({resources, resourceTemplates}); + } + } else { + console.error(`No pending request found for ID: ${id}`); + } + } else if (data.type === 'toolRegistered') { + debouncedToolListChanged(); + } else if (data.type === 'resourceRegistered') { + debouncedResourceListChanged(); + } else if (data.type === 'promptRegistered') { + debouncedPromptListChanged(); + } else if (data.type === 'welcome') { + // Welcome message from the server, we're already connected to the MCP path + console.error(`Connected to path: ${data.channel}`); + } else if (data.type === 'pong') { + // Pong response + console.error(`Received pong with timestamp: ${data.timestamp}`); + } else if (data.type === 'error') { + // Error message + console.error(`Received error: ${data.message}`); + } + } catch (error) { + console.error('Error processing WebSocket message:', error); + } +} + +// Function to connect to the WebSocket server +function connectToWebSocketServer(serverToken) { + // Connect to the MCP path directly with server token + const serverUrl = `ws://localhost:${CONFIG.port}${MCP_PATH}?token=${serverToken}`; + + console.error(`Connecting to WebSocket server at ${MCP_PATH} with authentication...`); + + wsClient = new WebSocket(serverUrl); + + // Handle connection opening + wsClient.on('open', () => { + console.error(`Connected to WebSocket server on path: ${MCP_PATH}`); + }); + + // Handle incoming messages + wsClient.on('message', (message) => { + handleWebSocketMessage(message); + }); + + // Handle connection closing + wsClient.on('close', (code, reason) => { + console.error(`WebSocket connection closed: ${code} ${reason}`); + wsClient = null; + + // Try to reconnect after a delay + setTimeout(connectToWebSocketServer, 5000); + }); + + // Handle connection errors + wsClient.on('error', (error) => { + console.error('WebSocket connection error:', error); + }); +} + +// Function to send a message to the WebSocket server +function sendMessage(message) { + if (!wsClient || wsClient.readyState !== WebSocket.OPEN) { + console.error('Cannot send message: WebSocket not connected'); + return Promise.reject(new Error('WebSocket not connected')); + } + + try { + wsClient.send(JSON.stringify(message)); + return Promise.resolve(); + } catch (error) { + console.error('Error sending message:', error); + return Promise.reject(error); + } +} + +// Set up the MCP server to handle tool calls by sending them to the WebSocket server +mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === "_webmcp_get-token") { + const token = await generateNewRegistrationToken(); + + // Copiar token al portapapeles del sistema + try { + execSync('clip.exe', { input: token, stdio: ['pipe', 'ignore', 'ignore'] }); + } catch (e) { + try { + execSync('xclip -selection clipboard', { input: token, stdio: ['pipe', 'ignore', 'ignore'] }); + } catch (e2) { + // No hay clipboard disponible + } + } + + // Enviar token a los navegadores conectados para copiar al portapapeles + try { + await sendMessage({ + type: 'clipboardCopy', + text: token + }); + } catch (e) { + // Ignorar si no hay clientes conectados + } + + return { + content: [{ + type: "text", + text: `Token copiado al portapapeles.\n${token}`, + }] + }; + } + + if (request.params.name === "_webmcp_clear-cache") { + if (!wsClient || wsClient.readyState !== WebSocket.OPEN) { + return { content: [{ type: "text", text: "No hay conexion al servidor WebSocket" }], isError: true }; + } + + const requestId = (requestIdCounter++).toString(); + const responsePromise = new Promise((resolve, reject) => { + pendingRequests.set(requestId, { resolve, reject }); + setTimeout(() => { + if (pendingRequests.has(requestId)) { + pendingRequests.delete(requestId); + reject(new Error('Clear cache timeout')); + } + }, 10000); + }); + + try { + await sendMessage({ id: requestId, type: 'clearRegistry' }); + const result = await responsePromise; + await mcpServer.sendToolListChanged(); + await mcpServer.sendPromptListChanged(); + await mcpServer.sendResourceListChanged(); + return { content: [{ type: "text", text: result }] }; + } catch (e) { + return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true }; + } + } + + if (request.params.name === "_webmcp_browser-info") { + if (!wsClient || wsClient.readyState !== WebSocket.OPEN) { + return { content: [{ type: "text", text: "No hay conexion al servidor WebSocket" }], isError: true }; + } + + const requestId = (requestIdCounter++).toString(); + const responsePromise = new Promise((resolve, reject) => { + pendingRequests.set(requestId, { resolve, reject }); + setTimeout(() => { + if (pendingRequests.has(requestId)) { + pendingRequests.delete(requestId); + reject(new Error('Timeout obteniendo info de navegadores')); + } + }, 10000); + }); + + try { + await sendMessage({ id: requestId, type: 'getClientInfo' }); + const result = await responsePromise; + return result; + } catch (e) { + return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true }; + } + } + + if (request.params.name === "_webmcp_agregar-tool") { + if (!CONFIG.dev) { + return { content: [{ type: "text", text: "agregar-tool solo esta disponible en modo desarrollo (--dev)" }], isError: true }; + } + if (!wsClient || wsClient.readyState !== WebSocket.OPEN) { + return { content: [{ type: "text", text: "No hay conexion al servidor WebSocket" }], isError: true }; + } + const { nombre, descripcion, codigo, parametros } = request.params.arguments; + if (!nombre || !descripcion || !codigo) { + return { content: [{ type: "text", text: "Se requieren: nombre, descripcion y codigo" }], isError: true }; + } + + const requestId = (requestIdCounter++).toString(); + const responsePromise = new Promise((resolve, reject) => { + pendingRequests.set(requestId, { resolve, reject }); + setTimeout(() => { + if (pendingRequests.has(requestId)) { + pendingRequests.delete(requestId); + reject(new Error('Timeout creando herramienta')); + } + }, 15000); + }); + + try { + await sendMessage({ + id: requestId, + type: 'createTool', + name: nombre, + description: descripcion, + code: codigo, + parametros: parametros + }); + const result = await responsePromise; + await mcpServer.sendToolListChanged(); + return result; + } catch (e) { + return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true }; + } + } + + if (request.params.name === "_webmcp_quitar-tool") { + if (!CONFIG.dev) { + return { content: [{ type: "text", text: "quitar-tool solo esta disponible en modo desarrollo (--dev)" }], isError: true }; + } + if (!wsClient || wsClient.readyState !== WebSocket.OPEN) { + return { content: [{ type: "text", text: "No hay conexion al servidor WebSocket" }], isError: true }; + } + const { nombre, listar, todas } = request.params.arguments || {}; + + const requestId = (requestIdCounter++).toString(); + const responsePromise = new Promise((resolve, reject) => { + pendingRequests.set(requestId, { resolve, reject }); + setTimeout(() => { + if (pendingRequests.has(requestId)) { + pendingRequests.delete(requestId); + reject(new Error('Timeout')); + } + }, 10000); + }); + + try { + await sendMessage({ + id: requestId, + type: 'removeTool', + name: nombre, + listar: !!listar, + todas: !!todas + }); + const result = await responsePromise; + await mcpServer.sendToolListChanged(); + return result; + } catch (e) { + return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true }; + } + } + + if (request.params.name === "_webmcp_server-info") { + if (!CONFIG.dev) { + return { content: [{ type: "text", text: "server-info solo esta disponible en modo desarrollo (--dev)" }], isError: true }; + } + if (!wsClient || wsClient.readyState !== WebSocket.OPEN) { + return { content: [{ type: "text", text: "No hay conexion al servidor WebSocket" }], isError: true }; + } + + const requestId = (requestIdCounter++).toString(); + const responsePromise = new Promise((resolve, reject) => { + pendingRequests.set(requestId, { resolve, reject }); + setTimeout(() => { + if (pendingRequests.has(requestId)) { + pendingRequests.delete(requestId); + reject(new Error('Timeout obteniendo info del servidor')); + } + }, 10000); + }); + + try { + await sendMessage({ id: requestId, type: 'getServerInfo' }); + const result = await responsePromise; + return result; + } catch (e) { + return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true }; + } + } + + if (!wsClient || wsClient.readyState !== WebSocket.OPEN) { + return { + content: [{ + type: "text", + text: "Not connected to WebSocket server" + }], + isError: true + }; + } + + // Create a unique request ID + const requestId = (requestIdCounter++).toString(); + + // Create a promise that will be resolved when we get a response + const responsePromise = new Promise((resolve, reject) => { + // Store the resolver functions + pendingRequests.set(requestId, {resolve, reject}); + + // Set a timeout to prevent hanging requests + setTimeout(() => { + if (pendingRequests.has(requestId)) { + pendingRequests.delete(requestId); + reject(new Error(`Tool call timed out: ${request.params.name}`)); + } + }, 30000); // 30 second timeout + }); + + // Send the request to the WebSocket server + try { + await sendMessage({ + id: requestId, + type: 'callTool', + tool: request.params.name, + arguments: request.params.arguments + }); + + // Wait for the response + return await responsePromise; + } catch (error) { + return { + content: [{ + type: "text", + text: `Error: ${error.message}` + }], + isError: true + }; + } +}); + +// Set up the MCP server to handle list tools by querying the WebSocket server +mcpServer.setRequestHandler(ListToolsRequestSchema, async () => { + const builtInTools = [ + { + name: "_webmcp_get-token", + description: "Genera un token de conexion para vincular un navegador con @nucleoriofrio/webmcp. El usuario puede decir 'registrar token' o 'conectar webmcp'.", + inputSchema: { + type: "object", + properties: {}, + }, + }, + { + name: "_webmcp_clear-cache", + description: "Limpia todas las herramientas, prompts y recursos registrados en el cache del servidor @nucleoriofrio/webmcp. Usar para forzar un estado limpio.", + inputSchema: { + type: "object", + properties: {}, + }, + }, + { + name: "_webmcp_browser-info", + description: "Muestra informacion de los navegadores conectados: user agent, URL, hostname, idioma, resolucion y tiempo de conexion.", + inputSchema: { + type: "object", + properties: {}, + }, + }, + ]; + + // Tools de desarrollo: solo disponibles con --dev o WEBMCP_DEV=true + if (CONFIG.dev) { + builtInTools.push( + { + name: "_webmcp_agregar-tool", + description: "[DEV] Registra una nueva herramienta dinamicamente. El agente puede usar esto para crear herramientas nuevas en tiempo real.", + inputSchema: { + type: "object", + properties: { + nombre: { type: "string", description: "Nombre de la herramienta" }, + descripcion: { type: "string", description: "Descripcion de lo que hace" }, + parametros: { type: "string", description: "JSON string con las properties del schema, ej: {\"msg\":{\"type\":\"string\",\"description\":\"mensaje\"}}" }, + codigo: { type: "string", description: "Codigo JavaScript del body de la funcion. Recibe \"args\" como parametro. Debe retornar un string." } + }, + required: ["nombre", "descripcion", "codigo"] + }, + }, + { + name: "_webmcp_quitar-tool", + description: "[DEV] Desregistra herramientas. Usa listar=true para ver las disponibles, todas=true para quitar todas, o nombre para quitar una especifica.", + inputSchema: { + type: "object", + properties: { + nombre: { type: "string", description: "Nombre de la herramienta a quitar" }, + listar: { type: "boolean", description: "Si es true, lista las herramientas en vez de quitar" }, + todas: { type: "boolean", description: "Si es true, quita todas las herramientas" } + } + }, + }, + { + name: "_webmcp_server-info", + description: "[DEV] Muestra estado del servidor @nucleoriofrio/webmcp: version, commit, host, puerto, canales activos, clientes, registry de tools/prompts/resources, uptime y requests pendientes.", + inputSchema: { + type: "object", + properties: {}, + }, + } + ); + } + + if (!wsClient || wsClient.readyState !== WebSocket.OPEN) { + return {tools: builtInTools}; + } + + // Create a unique request ID + const requestId = (requestIdCounter++).toString(); + + // Create a promise that will be resolved when we get a response + const responsePromise = new Promise((resolve, reject) => { + // Store the resolver functions + pendingRequests.set(requestId, {resolve, reject}); + + // Set a timeout to prevent hanging requests + setTimeout(() => { + if (pendingRequests.has(requestId)) { + pendingRequests.delete(requestId); + reject(new Error('List tools request timed out')); + } + }, 10000); // 10 second timeout + }); + + // Send the request to the WebSocket server + try { + await sendMessage({ + id: requestId, + type: 'listTools' + }); + + const tools = await responsePromise; + + // Wait for the response + return { tools: [...tools, ...builtInTools] }; + } catch (error) { + console.error('Error listing tools:', error); + return {tools: []}; // Return empty list on error + } +}); + +// Set up the MCP server to handle list prompts by querying the WebSocket server +mcpServer.setRequestHandler(ListPromptsRequestSchema, async () => { + const builtInPrompts = []; + + if (!wsClient || wsClient.readyState !== WebSocket.OPEN) { + return { + prompts: builtInPrompts + }; + } + + // Create a unique request ID + const requestId = (requestIdCounter++).toString(); + + // Create a promise that will be resolved when we get a response + const responsePromise = new Promise((resolve, reject) => { + // Store the resolver functions + pendingRequests.set(requestId, {resolve, reject}); + + // Set a timeout to prevent hanging requests + setTimeout(() => { + if (pendingRequests.has(requestId)) { + pendingRequests.delete(requestId); + reject(new Error('List prompts request timed out')); + } + }, 10000); // 10 second timeout + }); + + // Send the request to the WebSocket server + try { + await sendMessage({ + id: requestId, + type: 'listPrompts' + }); + + const prompts = await responsePromise; + + // Wait for the response + return { + prompts: [ + ...prompts, + ...builtInPrompts + ], + }; + } catch (error) { + console.error('Error listing prompts:', error); + return {prompts: []}; // Return empty list on error + } +}); + +// Set up the MCP server to handle get prompt by querying the WebSocket server +mcpServer.setRequestHandler(GetPromptRequestSchema, async (request) => { + if (!wsClient || wsClient.readyState !== WebSocket.OPEN) { + throw new Error("Not connected to WebSocket server"); + } + + // Create a unique request ID + const requestId = (requestIdCounter++).toString(); + + // Create a promise that will be resolved when we get a response + const responsePromise = new Promise((resolve, reject) => { + // Store the resolver functions + pendingRequests.set(requestId, {resolve, reject}); + + // Set a timeout to prevent hanging requests + setTimeout(() => { + if (pendingRequests.has(requestId)) { + pendingRequests.delete(requestId); + reject(new Error(`Get prompt request timed out: ${request.params.name}`)); + } + }, 30000); // 30 second timeout + }); + + // Send the request to the WebSocket server + try { + await sendMessage({ + id: requestId, + type: 'getPrompt', + name: request.params.name, + arguments: request.params.arguments + }); + + // Wait for the response + return await responsePromise; + } catch (error) { + console.error('Error getting prompt:', error); + throw error; + } +}); + +// Set up the MCP server to handle list resources by querying the WebSocket server +mcpServer.setRequestHandler(ListResourcesRequestSchema, async () => { + if (!wsClient || wsClient.readyState !== WebSocket.OPEN) { + return {resources: []}; + } + + // Create a unique request ID + const requestId = (requestIdCounter++).toString(); + + // Create a promise that will be resolved when we get a response + const responsePromise = new Promise((resolve, reject) => { + // Store the resolver functions + pendingRequests.set(requestId, {resolve, reject}); + + // Set a timeout to prevent hanging requests + setTimeout(() => { + if (pendingRequests.has(requestId)) { + pendingRequests.delete(requestId); + reject(new Error('List resources request timed out')); + } + }, 10000); // 10 second timeout + }); + + // Send the request to the WebSocket server + try { + await sendMessage({ + id: requestId, + type: 'listResources' + }); + + const {resources} = await responsePromise; + + // Wait for the response + return {resources}; + } catch (error) { + console.error('Error listing resources:', error); + return {resources: []}; // Return empty list on error + } +}); + +// Set up the MCP server to handle list resource templates by querying the WebSocket server +mcpServer.setRequestHandler(ListResourceTemplatesRequestSchema, async () => { + if (!wsClient || wsClient.readyState !== WebSocket.OPEN) { + return {resourceTemplates: []}; + } + + // Create a unique request ID + const requestId = (requestIdCounter++).toString(); + + // Create a promise that will be resolved when we get a response + const responsePromise = new Promise((resolve, reject) => { + // Store the resolver functions + pendingRequests.set(requestId, {resolve, reject}); + + // Set a timeout to prevent hanging requests + setTimeout(() => { + if (pendingRequests.has(requestId)) { + pendingRequests.delete(requestId); + reject(new Error('List resource templates request timed out')); + } + }, 10000); // 10 second timeout + }); + + // Send the request to the WebSocket server + try { + await sendMessage({ + id: requestId, + type: 'listResources' + }); + + const {resourceTemplates} = await responsePromise; + + // Wait for the response + return {resourceTemplates}; + } catch (error) { + console.error('Error listing resource templates:', error); + return {resourceTemplates: []}; // Return empty list on error + } +}); + +// Set up the MCP server to handle read resource by querying the WebSocket server +mcpServer.setRequestHandler(ReadResourceRequestSchema, async (request) => { + if (!wsClient || wsClient.readyState !== WebSocket.OPEN) { + throw new Error("Not connected to WebSocket server"); + } + + // Create a unique request ID + const requestId = (requestIdCounter++).toString(); + + // Create a promise that will be resolved when we get a response + const responsePromise = new Promise((resolve, reject) => { + // Store the resolver functions + pendingRequests.set(requestId, {resolve, reject}); + + // Set a timeout to prevent hanging requests + setTimeout(() => { + if (pendingRequests.has(requestId)) { + pendingRequests.delete(requestId); + reject(new Error(`Read resource request timed out: ${request.params.uri}`)); + } + }, 30000); // 30 second timeout + }); + + // Send the request to the WebSocket server + try { + await sendMessage({ + id: requestId, + type: 'readResource', + uri: request.params.uri + }); + + // Wait for the response + return await responsePromise; + } catch (error) { + console.error('Error reading resource:', error); + throw error; + } +}); + +// Set up the MCP server to handle sampling by querying the WebSocket server +mcpServer.setRequestHandler(CreateMessageRequestSchema, async (request) => { + if (!wsClient || wsClient.readyState !== WebSocket.OPEN) { + throw new Error("Not connected to WebSocket server"); + } + + // Create a unique request ID + const requestId = (requestIdCounter++).toString(); + + // Create a promise that will be resolved when we get a response + const responsePromise = new Promise((resolve, reject) => { + // Store the resolver functions + pendingRequests.set(requestId, {resolve, reject}); + + // Set a timeout to prevent hanging requests + setTimeout(() => { + if (pendingRequests.has(requestId)) { + pendingRequests.delete(requestId); + reject(new Error(`Sampling request timed out`)); + } + }, 120000); // 120 second timeout (sampling can take longer) + }); + + // Send the request to the WebSocket server with all parameters from the request + try { + await sendMessage({ + id: requestId, + type: 'createSamplingMessage', + messages: request.params.messages, + systemPrompt: request.params.systemPrompt, + includeContext: request.params.includeContext, + temperature: request.params.temperature, + maxTokens: request.params.maxTokens, + stopSequences: request.params.stopSequences, + metadata: request.params.metadata, + modelPreferences: request.params.modelPreferences + }); + + // Wait for the response + return await responsePromise; + } catch (error) { + console.error('Error creating sampling message:', error); + throw error; + } +}); + +async function runMcpServer(serverToken) { + // Connect to the WebSocket server + connectToWebSocketServer(serverToken); + const transport = new StdioServerTransport(); + await mcpServer.connect(transport); + console.error("@nucleoriofrio/webmcp v0.2.0 — MCP server running with stdio transport"); + if (CONFIG.dev) { + console.error("[DEV] Modo desarrollo activo — agregar-tool y quitar-tool habilitados"); + } +} + +export { runMcpServer }; diff --git a/tokens.js b/tokens.js new file mode 100644 index 0000000..18a4875 --- /dev/null +++ b/tokens.js @@ -0,0 +1,118 @@ +import * as fs from 'fs/promises'; +import * as crypto from 'crypto'; +import {ENV_FILE, formatChannel, HOST, TOKENS_FILE,CONFIG} from "./config.js"; + +// Function to generate a secure random token +function generateToken() { + return crypto.randomBytes(16).toString('hex'); +} + + +// Authorized channel-token pairs - Only channels with valid tokens can connect +// Format: { "/channel1": "token123" } +let authorizedTokens = {}; + +function getToken(channel) { + return authorizedTokens[channel]; +} + +function setToken(channel, value) { + authorizedTokens[channel] = value; +} + +function deleteToken(channel) { + delete authorizedTokens[channel]; +} + +function clearTokens(channel) { + authorizedTokens = {}; +} + +// Load authorized tokens from disk +async function loadAuthorizedTokens() { + try { + const data = await fs.readFile(TOKENS_FILE, 'utf8'); + authorizedTokens = JSON.parse(data || "{}"); + + // console.error(`Loaded ${Object.keys(authorizedTokens).length} authorized channel-token pairs from ${TOKENS_FILE}`); + return true; + } catch (error) { + // If file doesn't exist, start with empty tokens + if (error.code === 'ENOENT') { + authorizedTokens = {}; + return true; + } + console.error('Error loading authorized tokens:', error); + return false; + } +} + +// Save authorized tokens to disk +async function saveAuthorizedTokens() { + try { + // Convert Map to object for JSON serialization + const stringified = JSON.stringify(authorizedTokens, null, 2); + await fs.writeFile(TOKENS_FILE, stringified, 'utf8'); + // console.error(`Saved ${stringified} authorized channel-token pairs to ${TOKENS_FILE}`); + return true; + } catch (error) { + console.error('Error saving authorized tokens:', error); + return false; + } +} + +// Function to save server token to .env file +async function saveServerTokenToEnv(token) { + try { + let envContent = ''; + + try { + // Try to read existing .env file + envContent = await fs.readFile(ENV_FILE, 'utf8'); + + // Check if WEBMCP_SERVER_TOKEN is already defined + if (envContent.includes('WEBMCP_SERVER_TOKEN=')) { + // Replace the existing token + envContent = envContent.replace(/WEBMCP_SERVER_TOKEN=.*(\r?\n|$)/g, `WEBMCP_SERVER_TOKEN=${token}$1`); + } else { + // Add the token to the end + envContent += `\nWEBMCP_SERVER_TOKEN=${token}\n`; + } + } catch (err) { + // File doesn't exist, create new content + envContent = `WEBMCP_SERVER_TOKEN=${token}\n`; + } + + // Write the content to the .env file + await fs.writeFile(ENV_FILE, envContent, 'utf8'); + console.error(`Server token saved to ${ENV_FILE}`); + return true; + } catch (error) { + console.error('Error saving server token to .env file:', error); + return false; + } +} + +async function generateNewRegistrationToken() { + // Generate a random token for registration + const token = generateToken(); + + // Create a connection object with server address and token + const address = `${HOST}:${CONFIG.port}`; + const serverAddress = `ws://${address}`; + const connectionData = { + server: serverAddress, + token: token + }; + + // Convert to JSON and base64 encode + const jsonStr = JSON.stringify(connectionData); + const encodedData = Buffer.from(jsonStr).toString('base64'); + + setToken(formatChannel(address), token); + await saveAuthorizedTokens(); + + return encodedData; +} + +export {generateToken, getToken, setToken, loadAuthorizedTokens, saveAuthorizedTokens, clearTokens, deleteToken, saveServerTokenToEnv, generateNewRegistrationToken}; diff --git a/webmcp.d.ts b/webmcp.d.ts new file mode 100644 index 0000000..3076d4f --- /dev/null +++ b/webmcp.d.ts @@ -0,0 +1,78 @@ +export interface WebMCPOptions { + color?: string; + position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'; + size?: string; + padding?: string; + inactivityTimeout?: number; + headless?: boolean; +} + +export interface ToolInputSchema { + type: string; + properties: Record; + required?: string[]; +} + +export interface ResourceOptions { + uri?: string; + uriTemplate?: string; + mimeType?: string; +} + +export interface PromptArgument { + name: string; + description?: string; + required?: boolean; +} + +export type ToolHandler = (args: any) => string | object | Promise; +export type PromptHandler = (args: any) => object | Promise; +export type ResourceHandler = (uri: string) => object | Promise; + +export interface ConnectionInfo { + isConnected: boolean; + channel: string; + server: string; + status: string; + tools: string[]; + prompts: string[]; + resources: string[]; +} + +export interface WebMCPEventMap { + connected: { channel: string; server: string }; + disconnected: { code: number; reason: string }; + reconnecting: { attempt: number; maxAttempts: number; delay: number }; + statusChange: { status: string; message: string }; + toolRegistered: { name: string }; + toolCreated: { name: string }; + toolRemoved: { name: string }; + error: { message: string }; +} + +declare class WebMCP { + readonly isConnected: boolean; + readonly isExpanded: boolean; + + constructor(options?: WebMCPOptions); + + connect(connectionToken: string): Promise; + disconnect(): void; + + on(event: K, callback: (data: WebMCPEventMap[K]) => void): () => void; + off(event: K, callback: (data: WebMCPEventMap[K]) => void): void; + getConnectionInfo(): ConnectionInfo; + + registerTool(name: string, description: string, schema: ToolInputSchema, executeFn: ToolHandler): void; + unregisterTool(name: string): void; + unregisterAllTools(): void; + + registerPrompt(name: string, description: string, args: PromptArgument[], executeFn: PromptHandler): void; + unregisterPrompt(name: string): void; + + registerResource(name: string, description: string, options: ResourceOptions, provideFn: ResourceHandler): void; + unregisterResource(name: string): void; +} + +export default WebMCP; +export { WebMCP }; diff --git a/webmcp.js b/webmcp.js new file mode 100644 index 0000000..13f0cf6 --- /dev/null +++ b/webmcp.js @@ -0,0 +1,2326 @@ +/** + * 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; +}