From 17ee320494766136ff0b57d8a14d1bfeab613ebc Mon Sep 17 00:00:00 2001 From: nucleo000 Date: Fri, 6 Mar 2026 02:44:20 -0600 Subject: [PATCH] Subir archivos a "src" --- src/config.js | 118 ++ src/server.js | 889 +++++++++++++++ src/webmcp.d.ts | 78 ++ src/webmcp.js | 2326 +++++++++++++++++++++++++++++++++++++++ src/websocket-server.js | 1998 +++++++++++++++++++++++++++++++++ 5 files changed, 5409 insertions(+) create mode 100644 src/config.js create mode 100644 src/server.js create mode 100644 src/webmcp.d.ts create mode 100644 src/webmcp.js create mode 100644 src/websocket-server.js diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..e7eb5e4 --- /dev/null +++ b/src/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/src/server.js b/src/server.js new file mode 100644 index 0000000..d15bc44 --- /dev/null +++ b/src/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/src/webmcp.d.ts b/src/webmcp.d.ts new file mode 100644 index 0000000..3076d4f --- /dev/null +++ b/src/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/src/webmcp.js b/src/webmcp.js new file mode 100644 index 0000000..13f0cf6 --- /dev/null +++ b/src/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; +} diff --git a/src/websocket-server.js b/src/websocket-server.js new file mode 100644 index 0000000..3caa513 --- /dev/null +++ b/src/websocket-server.js @@ -0,0 +1,1998 @@ +import * as fs from 'fs/promises'; +import {WebSocketServer, WebSocket} from 'ws'; +import {createServer} from 'http'; +import {parse} from 'url'; +import {fork, execSync} from 'child_process'; +import {runMcpServer} from './server.js'; +import { + clearTokens, + deleteToken, generateNewRegistrationToken, + generateToken, + getToken, + loadAuthorizedTokens, + saveAuthorizedTokens, saveServerTokenToEnv, + setToken +} from "./tokens.js"; +import { + CONFIG, + HOST, + PID_FILE, + SERVER_TOKEN, + ensureConfigDir, + formatChannel, + setConfig, + configureMcpClient, +} from './config.js'; + +let serverToken = SERVER_TOKEN; + +// Create HTTP server with CORS headers +const httpServer = createServer(async (req, res) => { + // Set CORS headers + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + if (req.method === 'OPTIONS') { + // Handle preflight requests + res.writeHead(204); + res.end(); + return; + } + + // Parse URL + const url = new URL(`http://${HOST}${req.url}`); + + // Endpoint: POST /token - Genera un nuevo token de registro + if (url.pathname === '/token' && req.method === 'POST') { + try { + const token = await generateNewRegistrationToken(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + token: token + })); + } catch (error) { + console.error('Error generating token:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Failed to generate token' })); + } + return; + } + + // Endpoint: GET /status - Estado del servidor + if (url.pathname === '/status' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'running', + clients: wss.clients.size, + version: '0.2.0' + })); + return; + } + + // Default response + res.writeHead(200, {'Content-Type': 'text/plain'}); + res.end('MCP WebSocket server is running'); +}); + +// Create WebSocket server instance +const wss = new WebSocketServer({ + server: httpServer, + clientTracking: true, + verifyClient: verifyClientToken +}); + +// Store active WebSocket connections by channel +const channels = {}; + +// Store browser metadata per WebSocket connection +const clientMetadata = new Map(); + +// Special channel paths +const MCP_PATH = '/mcp'; +const REGISTER_PATH = '/register'; + +// Function to send notifications to a client and MCP (if connected) +function sendNotification(clientWs, channelPath, notificationType, data, mcpOnly = false) { + // Send to the client that initiated the action + if (clientWs && clientWs.readyState === WebSocket.OPEN) { + if (!mcpOnly) { + clientWs.send(JSON.stringify({ + type: notificationType, + ...data + })); + } + } + + // Also send to MCP if connected + if (channels[MCP_PATH] && channels[MCP_PATH].size > 0) { + try { + for (const mcpClient of channels[MCP_PATH]) { + if (mcpClient && mcpClient.readyState === WebSocket.OPEN) { + // For MCP, prefix names with the channel path + const mcpData = {...data}; + if (mcpData.name && channelPath) { + mcpData.name = `${channelPath.slice(1)}-${mcpData.name}`; + } + + mcpClient.send(JSON.stringify({ + type: notificationType, + ...mcpData + })); + } + } + } catch (e) { + console.error('Error sending notification to MCP:', e.message); + } + } +} + +// Track all available tools, prompts, and resources across all channels +const toolsRegistry = {}; +const promptsRegistry = {}; +const resourcesRegistry = {}; + +// Server start timestamp for uptime calculation +const serverStartTime = Date.now(); + +// Git commit hash (read once at startup) +let gitCommit = 'unknown'; +try { + gitCommit = execSync('git rev-parse --short HEAD', { stdio: ['pipe', 'pipe', 'ignore'] }).toString().trim(); +} catch (e) { + // Not in a git repo or git not available +} + +// Request counter for unique IDs +let requestIdCounter = 1; + +// Map to store pending requests +const pendingRequests = {}; + + +// Function to verify client token during WebSocket handshake +async function verifyClientToken(info, callback) { + const url = new URL(`https://${HOST}${info.req.url}`); + const clientToken = url.searchParams.get('token'); + const path = url.pathname || '/'; + + // Special case for MCP path - use server token from .env + if (path === MCP_PATH) { + // For MCP connections, we use the token from the .env file + if (clientToken === process.env.WEBMCP_SERVER_TOKEN) { + return callback(true); + } + console.error('Invalid MCP token provided'); + return callback(false, 401, 'Unauthorized - Invalid MCP token'); + } + + // Special case for registration path - allow all connections for now + // The actual authorization will happen in the connection handler + if (path === REGISTER_PATH) { + return callback(true); + } + + // For other paths, check if the channel-token pair is authorized + if (!clientToken) { + console.error('No token provided for path:', path); + return callback(false, 401, 'Unauthorized - No token provided'); + } + + await loadAuthorizedTokens(); + + // Check if this channel has a valid token and it matches + if (getToken(path) === clientToken) { + return callback(true); + } + + console.error(`Unauthorized connection attempt to ${path}`); + return callback(false, 401, 'Unauthorized - Invalid channel-token pair'); +} + +// Helper function to get or create a channel +function getOrCreateChannel(channelPath) { + if (!channels[channelPath]) { + channels[channelPath] = new Set(); + console.error(`Created new channel for path: ${channelPath}`); + } else if (channels[channelPath].closeTimeout) { + // Clear the timeout if it exists (someone is joining an empty channel) + clearTimeout(channels[channelPath].closeTimeout); + delete channels[channelPath].closeTimeout; + console.error(`Cancelled channel closure for ${channelPath} as a new client connected`); + } + return channels[channelPath]; +} + +// Handle WebSocket connections +wss.on('connection', (ws, req) => { + // Extract the path from the URL + const parsedUrl = parse(req.url); + const path = parsedUrl.pathname; + + // Set channel based on connection path + const clientChannel = path || '/'; + + console.error(`Client connected from ${req.socket.remoteAddress} to path: ${clientChannel}`); + + // Special handling for registration endpoint + if (clientChannel === REGISTER_PATH) { + console.error(`Registration request received from ${req.socket.remoteAddress}`); + + // Wait for the first message which should contain the registration data + let registrationTimeout = setTimeout(() => { + console.error('Registration timeout - closing connection'); + ws.close(1008, 'Registration timeout'); + }, 30000); // 30 second timeout + + // Register message handler specifically for registration + ws.once('message', async (message) => { + clearTimeout(registrationTimeout); + + try { + // The message should be base64 encoded JSON with server and token + const encodedData = message.toString(); + const decodedJson = Buffer.from(encodedData, 'base64').toString('utf8'); + const connectionData = JSON.parse(decodedJson); + + const {host, token} = connectionData; + + if (!token) { + console.error('Invalid registration data format - missing token'); + ws.send(JSON.stringify({ + type: 'error', + message: 'Invalid registration data format - missing token' + })); + ws.close(1008, 'Invalid registration data'); + return; + } + + // Format the channel path (replace : with _) + const channelPath = formatChannel(host); + + // Check if this is a valid token from a "--new" command + if (!token || token.length < 16) { + console.error('Invalid token provided'); + ws.send(JSON.stringify({ + type: 'error', + message: 'Invalid token provided' + })); + ws.close(1008, 'Invalid token'); + return; + } + + const serverChannel = formatChannel(`${HOST}:${CONFIG.port}`); + + await loadAuthorizedTokens(); + if (token !== getToken(serverChannel)) { + console.error('Invalid token provided'); + ws.send(JSON.stringify({ + type: 'error', + message: 'Invalid token provided' + })); + ws.close(1008, 'Invalid token'); + return; + } + + // Throw away registration token and make a session token. + deleteToken(serverChannel); + const sessionToken = generateToken(); + + // Authorize the channel-token pair + setToken(channelPath, sessionToken); + await saveAuthorizedTokens(); + + // Send success response + ws.send(JSON.stringify({ + type: 'registerSuccess', + channel: channelPath, + message: `Registration successful for ${channelPath}`, + token: sessionToken + })); + + console.error(`Registered channel: ${channelPath} with token: ${token}`); + + // Close the registration connection - they'll reconnect to their channel + ws.close(1000, 'Registration complete'); + } catch (error) { + console.error('Registration error:', error); + ws.send(JSON.stringify({ + type: 'error', + message: 'Registration error' + })); + ws.close(1011, 'Registration error'); + } + }); + + return; // Don't proceed with the normal connection handling + } + + // Add client to the channel based on path (for non-registration paths) + const channel = getOrCreateChannel(clientChannel); + channel.add(ws); + + // Send welcome message with channel info + ws.send(JSON.stringify({ + type: 'welcome', + channel: clientChannel, + message: `Connected to path: ${clientChannel}` + })); + + // Handle incoming messages + ws.on('message', (message) => { + try { + const data = JSON.parse(message); + console.error(`Received message: ${data.type} on ${clientChannel}`); + + // Process message based on type + switch (data.type) { + case 'ping': + handlePing(ws, data); + break; + + case 'registerTool': + handleRegisterTool(ws, clientChannel, data); + break; + + case 'registerPrompt': + handleRegisterPrompt(ws, clientChannel, data); + break; + + case 'registerResource': + handleRegisterResource(ws, clientChannel, data); + break; + + case 'unregisterTool': + handleUnregisterTool(ws, clientChannel, data); + break; + + case 'unregisterPrompt': + handleUnregisterPrompt(ws, clientChannel, data); + break; + + case 'unregisterResource': + handleUnregisterResource(ws, clientChannel, data); + break; + + case 'unregisterAllTools': + handleUnregisterAllTools(ws, clientChannel); + break; + + case 'listTools': + handleListTools(ws, clientChannel, data); + break; + + case 'listPrompts': + handleListPrompts(ws, clientChannel, data); + break; + + case 'listResources': + handleListResources(ws, clientChannel, data); + break; + + case 'callTool': + handleCallTool(ws, clientChannel, data); + break; + + case 'getPrompt': + handleGetPrompt(ws, clientChannel, data); + break; + + case 'readResource': + handleReadResource(ws, clientChannel, data); + break; + + case 'createSamplingMessage': + handleCreateSamplingMessage(ws, clientChannel, data); + break; + + case 'toolResponse': + handleToolResponse(data); + break; + + case 'promptResponse': + handlePromptResponse(data); + break; + + case 'resourceResponse': + handleResourceResponse(data); + break; + + case 'samplingResponse': + handleSamplingResponse(data); + break; + + case 'clipboardCopy': + handleClipboardCopy(data); + break; + + case 'clientInfo': + handleClientInfo(ws, data); + break; + + case 'clientInfoResponse': + handleToolResponse(data); + break; + + case 'getClientInfo': + handleGetClientInfo(ws, clientChannel, data); + break; + + case 'getServerInfo': + handleGetServerInfo(ws, data); + break; + + case 'clearRegistry': + handleClearRegistry(ws, data); + break; + + case 'createTool': + handleCreateTool(ws, data); + break; + + case 'removeTool': + handleRemoveTool(ws, data); + break; + + default: + ws.send(JSON.stringify({ + type: 'error', + message: `Unknown message type: ${data.type}` + })); + } + } catch (error) { + const msgPreview = typeof message === 'string' ? message.slice(0, 200) : String(message).slice(0, 200); + console.error(`Error processing WebSocket message on ${clientChannel}:`, error.message, '\nRaw message preview:', msgPreview); + try { + ws.send(JSON.stringify({ + type: 'error', + message: `Error: ${error.message}` + })); + } catch (sendError) { + console.error('Error sending error response:', sendError); + } + } + }); + + // Handle disconnection + ws.on('close', async () => { + console.error(`Client disconnected from path: ${clientChannel}`); + + // Remove browser metadata + clientMetadata.delete(ws); + + // Remove from channel + const channel = channels[clientChannel]; + if (channel) { + channel.delete(ws); + + // Set a timer to clean up empty channels after 1 minute + if (channel.size === 0) { + console.error(`Channel ${clientChannel} is empty, will close in 1 minute if no one joins`); + + // Store the timeout in the channel object so we can clear it if needed + channel.closeTimeout = setTimeout(async () => { + // Check if the channel is still empty after 1 minute + if (channels[clientChannel] && channels[clientChannel].size === 0) { + delete channels[clientChannel]; + console.error(`Removed empty channel for path: ${clientChannel} after 1 minute inactivity`); + + // Clean up tools, prompts, and resources for this channel + const itemsToRemove = { + tools: [], + prompts: [], + resources: [] + }; + + for (const [toolId, toolInfo] of Object.entries(toolsRegistry)) { + if (toolInfo.channel === clientChannel) { + itemsToRemove.tools.push(toolId); + } + } + + for (const [promptId, promptInfo] of Object.entries(promptsRegistry)) { + if (promptInfo.channel === clientChannel) { + itemsToRemove.prompts.push(promptId); + } + } + + for (const [resourceId, resourceInfo] of Object.entries(resourcesRegistry)) { + if (resourceInfo.channel === clientChannel) { + itemsToRemove.resources.push(resourceId); + } + } + + itemsToRemove.tools.forEach(toolId => { + delete toolsRegistry[toolId]; + console.error(`Removed tool: ${toolId} from path: ${clientChannel}`); + }); + + itemsToRemove.prompts.forEach(promptId => { + delete promptsRegistry[promptId]; + console.error(`Removed prompt: ${promptId} from path: ${clientChannel}`); + }); + + itemsToRemove.resources.forEach(resourceId => { + delete resourcesRegistry[resourceId]; + console.error(`Removed resource: ${resourceId} from path: ${clientChannel}`); + }); + + // Remove the authorized token for this channel if not MCP + if (clientChannel !== MCP_PATH) { + deleteToken(clientChannel); + await saveAuthorizedTokens(); + console.error(`Removed authorized token for channel: ${clientChannel}`); + } + + // Update them all + sendNotification(ws, undefined, 'toolRegistered', {}, true); + sendNotification(ws, undefined, 'promptRegistered', {}, true); + sendNotification(ws, undefined, 'resourceRegistered', {}, true); + } + }, 60000); // 1 minute timeout + } + } + }); + + // Handle errors + ws.on('error', (error) => { + console.error('WebSocket error:', error); + + // Remove browser metadata + clientMetadata.delete(ws); + + // Remove from channel + const channel = channels[clientChannel]; + if (channel) { + channel.delete(ws); + } + }); +}); + +// Handle ping messages +function handlePing(ws, data) { + ws.send(JSON.stringify({ + id: data.id, + type: 'pong', + timestamp: Date.now() + })); +} + +// Handle tool registration +function handleRegisterTool(ws, channelPath, data) { + const {name, description, inputSchema} = data; + + if (!name) { + ws.send(JSON.stringify({ + type: 'error', + message: 'Tool name is required' + })); + return; + } + + // Create a unique tool ID for internal tracking + const toolId = `${channelPath.slice(1)}-${name}`; + + // Register the tool + toolsRegistry[toolId] = { + channel: channelPath, + name, + description: description || `Tool: ${name}`, + inputSchema, + originalName: name + }; + + // Send registration notification to both client and MCP + sendNotification(ws, channelPath, 'toolRegistered', { + name, + toolId + }); + + console.error(`Tool registered: ${toolId}`); +} + +// Handle prompt registration +function handleRegisterPrompt(ws, channelPath, data) { + const {name, description, arguments: promptArgs} = data; + + if (!name) { + ws.send(JSON.stringify({ + type: 'error', + message: 'Prompt name is required' + })); + return; + } + + // Create a unique prompt ID for internal tracking + const promptId = `${channelPath.slice(1)}-${name}`; + + // Register the prompt + promptsRegistry[promptId] = { + channel: channelPath, + name, + description: description || `Prompt: ${name}`, + arguments: promptArgs || [], + originalName: name + }; + + // Send registration notification to both client and MCP + sendNotification(ws, channelPath, 'promptRegistered', { + name, + promptId + }); + + console.error(`Prompt registered: ${promptId}`); +} + +// Handle resource registration +function handleRegisterResource(ws, channelPath, data) { + const {uri, name, description, mimeType, isTemplate, uriTemplate} = data; + + if ((!uri && !uriTemplate) || !name) { + ws.send(JSON.stringify({ + type: 'error', + message: 'Resource URI/template and name are required' + })); + return; + } + + // Create a unique resource ID for internal tracking + const resourceId = `${channelPath.slice(1)}-${name}`; + + // Register the resource + resourcesRegistry[resourceId] = { + channel: channelPath, + name, + description: description || `Resource: ${name}`, + uri: uri, + uriTemplate: uriTemplate, + isTemplate: !!isTemplate, + mimeType, + originalName: name + }; + + // Send registration notification to both client and MCP + sendNotification(ws, channelPath, 'resourceRegistered', { + name, + resourceId + }); + + console.error(`Resource registered: ${resourceId}`); +} + +// Handle tool unregistration +function handleUnregisterTool(ws, channelPath, data) { + const {name} = data; + + if (!name) { + ws.send(JSON.stringify({ + type: 'error', + message: 'Tool name is required' + })); + return; + } + + const toolId = `${channelPath.slice(1)}-${name}`; + + if (!toolsRegistry[toolId]) { + ws.send(JSON.stringify({ + type: 'error', + message: `Tool not found: ${name}` + })); + return; + } + + delete toolsRegistry[toolId]; + + sendNotification(ws, channelPath, 'toolRegistered', { + name, + toolId + }); + + console.error(`Tool unregistered: ${toolId}`); +} + +// Handle prompt unregistration +function handleUnregisterPrompt(ws, channelPath, data) { + const {name} = data; + + if (!name) { + ws.send(JSON.stringify({ + type: 'error', + message: 'Prompt name is required' + })); + return; + } + + const promptId = `${channelPath.slice(1)}-${name}`; + + if (!promptsRegistry[promptId]) { + ws.send(JSON.stringify({ + type: 'error', + message: `Prompt not found: ${name}` + })); + return; + } + + delete promptsRegistry[promptId]; + + sendNotification(ws, channelPath, 'promptRegistered', { + name, + promptId + }); + + console.error(`Prompt unregistered: ${promptId}`); +} + +// Handle resource unregistration +function handleUnregisterResource(ws, channelPath, data) { + const {name} = data; + + if (!name) { + ws.send(JSON.stringify({ + type: 'error', + message: 'Resource name is required' + })); + return; + } + + const resourceId = `${channelPath.slice(1)}-${name}`; + + if (!resourcesRegistry[resourceId]) { + ws.send(JSON.stringify({ + type: 'error', + message: `Resource not found: ${name}` + })); + return; + } + + delete resourcesRegistry[resourceId]; + + sendNotification(ws, channelPath, 'resourceRegistered', { + name, + resourceId + }); + + console.error(`Resource unregistered: ${resourceId}`); +} + +// Handle batch unregistration of all tools for a channel +function handleUnregisterAllTools(ws, channelPath) { + const removed = []; + for (const [toolId, toolInfo] of Object.entries(toolsRegistry)) { + if (toolInfo.channel === channelPath) { + removed.push(toolId); + } + } + + removed.forEach(toolId => { + delete toolsRegistry[toolId]; + }); + + if (removed.length > 0) { + sendNotification(ws, channelPath, 'toolRegistered', {}, true); + console.error(`Batch unregistered ${removed.length} tools from ${channelPath}`); + } +} + +// Handle createTool - forward to web clients to create and register +function handleCreateTool(ws, data) { + const {id, name, description, code, parametros} = data; + + // Find a web client to forward to + let targetClient = null; + let targetChannel = null; + for (const [channelPath, clients] of Object.entries(channels)) { + if (channelPath === MCP_PATH) continue; + if (clients.size > 0) { + targetClient = clients.values().next().value; + targetChannel = channelPath; + break; + } + } + + if (!targetClient) { + ws.send(JSON.stringify({ + id, + type: 'toolResponse', + error: 'No hay navegadores conectados para crear la herramienta' + })); + return; + } + + const requestId = (requestIdCounter++).toString(); + pendingRequests[requestId] = { originalId: id, requesterWs: ws, timestamp: Date.now() }; + + setTimeout(() => { + if (pendingRequests[requestId]) { + const {requesterWs, originalId} = pendingRequests[requestId]; + delete pendingRequests[requestId]; + try { + requesterWs.send(JSON.stringify({ id: originalId, type: 'toolResponse', error: 'Timeout creando herramienta' })); + } catch (e) {} + } + }, 15000); + + targetClient.send(JSON.stringify({ + id: requestId, + type: 'createTool', + name, description, code, parametros + })); + + console.error(`createTool forwarded to ${targetChannel}: ${name}`); +} + +// Handle removeTool - list or remove tools from registry +function handleRemoveTool(ws, data) { + const {id, name, listar, todas} = data; + + if (listar) { + const tools = Object.entries(toolsRegistry) + .filter(([_, info]) => info.channel !== MCP_PATH) + .map(([toolId, info]) => info.originalName); + const msg = tools.length > 0 ? `Herramientas activas: ${tools.join(', ')}` : 'No hay herramientas registradas'; + ws.send(JSON.stringify({ id, type: 'toolResponse', result: { content: [{ type: 'text', text: msg }] } })); + return; + } + + if (todas) { + let count = 0; + for (const [toolId, info] of Object.entries(toolsRegistry)) { + if (info.channel !== MCP_PATH) { + delete toolsRegistry[toolId]; + count++; + } + } + // Notify web clients + for (const [channelPath, clients] of Object.entries(channels)) { + if (channelPath === MCP_PATH) continue; + clients.forEach(c => { + if (c.readyState === WebSocket.OPEN) { + c.send(JSON.stringify({ type: 'removeAllTools' })); + } + }); + } + ws.send(JSON.stringify({ id, type: 'toolResponse', result: { content: [{ type: 'text', text: `${count} herramientas desregistradas` }] } })); + return; + } + + if (!name) { + ws.send(JSON.stringify({ id, type: 'toolResponse', error: 'Especifica nombre, listar=true, o todas=true' })); + return; + } + + // Find and remove by originalName + let found = false; + for (const [toolId, info] of Object.entries(toolsRegistry)) { + if (info.originalName === name) { + const channel = info.channel; + delete toolsRegistry[toolId]; + found = true; + // Notify web client + if (channels[channel]) { + channels[channel].forEach(c => { + if (c.readyState === WebSocket.OPEN) { + c.send(JSON.stringify({ type: 'removeTool', name })); + } + }); + } + break; + } + } + + if (found) { + ws.send(JSON.stringify({ id, type: 'toolResponse', result: { content: [{ type: 'text', text: `Herramienta "${name}" desregistrada` }] } })); + } else { + ws.send(JSON.stringify({ id, type: 'toolResponse', error: `Herramienta "${name}" no encontrada` })); + } +} + +// Handle client info sent by browser on connect +function handleClientInfo(ws, data) { + const { userAgent, url, hostname, language, screenWidth, screenHeight, timestamp } = data; + clientMetadata.set(ws, { userAgent, url, hostname, language, screenWidth, screenHeight, timestamp }); + console.error(`Client info stored for ${hostname}: ${url}`); +} + +// Handle getClientInfo request - collect info from all connected browsers +function handleGetClientInfo(ws, callerChannel, data) { + const { id } = data; + + const clients = []; + for (const [channelPath, channelClients] of Object.entries(channels)) { + if (channelPath === MCP_PATH || channelPath === REGISTER_PATH) continue; + for (const clientWs of channelClients) { + const meta = clientMetadata.get(clientWs) || {}; + clients.push({ + channel: channelPath, + userAgent: meta.userAgent || 'unknown', + url: meta.url || 'unknown', + hostname: meta.hostname || 'unknown', + language: meta.language || 'unknown', + screenWidth: meta.screenWidth || 0, + screenHeight: meta.screenHeight || 0, + connectedSince: meta.timestamp || 0 + }); + } + } + + const text = clients.length > 0 + ? JSON.stringify(clients, null, 2) + : 'No hay navegadores conectados'; + + ws.send(JSON.stringify({ + id, + type: 'toolResponse', + result: { content: [{ type: 'text', text }] } + })); +} + +// Handle getServerInfo request - return server status and stats +function handleGetServerInfo(ws, data) { + const { id } = data; + + const uptimeMs = Date.now() - serverStartTime; + const uptimeSec = Math.floor(uptimeMs / 1000); + const hours = Math.floor(uptimeSec / 3600); + const minutes = Math.floor((uptimeSec % 3600) / 60); + const seconds = uptimeSec % 60; + + const channelsList = {}; + for (const [channelPath, channelClients] of Object.entries(channels)) { + channelsList[channelPath] = channelClients.size; + } + + const info = { + server: `@nucleoriofrio/webmcp v0.2.0`, + commit: gitCommit, + host: HOST, + port: CONFIG.port, + dev: !!CONFIG.dev, + uptime: `${hours}h ${minutes}m ${seconds}s`, + uptimeMs, + channels: channelsList, + totalClients: Object.values(channels).reduce((sum, ch) => sum + ch.size, 0), + registry: { + tools: Object.keys(toolsRegistry).length, + prompts: Object.keys(promptsRegistry).length, + resources: Object.keys(resourcesRegistry).length, + }, + toolsList: Object.entries(toolsRegistry).map(([id, info]) => ({ + id, + name: info.originalName, + channel: info.channel, + })), + pendingRequests: Object.keys(pendingRequests).length, + }; + + ws.send(JSON.stringify({ + id, + type: 'toolResponse', + result: { content: [{ type: 'text', text: JSON.stringify(info, null, 2) }] } + })); +} + +// Handle clearing all registries +function handleClearRegistry(ws, data) { + const {id} = data; + let toolCount = Object.keys(toolsRegistry).length; + let promptCount = Object.keys(promptsRegistry).length; + let resourceCount = Object.keys(resourcesRegistry).length; + + for (const key of Object.keys(toolsRegistry)) delete toolsRegistry[key]; + for (const key of Object.keys(promptsRegistry)) delete promptsRegistry[key]; + for (const key of Object.keys(resourcesRegistry)) delete resourcesRegistry[key]; + + const msg = `Cache limpiado: ${toolCount} tools, ${promptCount} prompts, ${resourceCount} resources eliminados`; + console.error(msg); + + ws.send(JSON.stringify({ + id, + type: 'toolResponse', + result: msg + })); +} + +// Handle clipboard copy - broadcast to all non-MCP channels +function handleClipboardCopy(data) { + const {text} = data; + if (!text) return; + + for (const [channelPath, channelClients] of Object.entries(channels)) { + if (channelPath === MCP_PATH) continue; + channelClients.forEach(client => { + if (client.readyState === WebSocket.OPEN) { + client.send(JSON.stringify({ + type: 'clipboardCopy', + text + })); + } + }); + } + console.error(`Clipboard copy broadcasted to web clients`); +} + +// Handle list tools requests +function handleListTools(ws, clientChannel, data) { + const {id} = data; + + // Special handling if the request is from the MCP client + const isMcpClient = (clientChannel === MCP_PATH); + + let tools; + + if (isMcpClient) { + // For MCP clients, return all tools across all paths with path prefixes + tools = Object.entries(toolsRegistry).map(([toolId, toolInfo]) => { + // Create a path-based fully qualified name - combine path and tool name + const pathBasedName = `${toolInfo.channel.slice(1)}-${toolInfo.originalName}`; + return { + name: pathBasedName, + description: toolInfo.description, + inputSchema: toolInfo.inputSchema, + }; + }); + console.error(`Sending all ${tools.length} tools to MCP client on path ${clientChannel}`); + } else { + // For regular clients, return only their own tools without path prefixes + tools = Object.entries(toolsRegistry) + .filter(([_, toolInfo]) => toolInfo.channel === clientChannel) + .map(([_, toolInfo]) => ({ + name: toolInfo.originalName, + description: toolInfo.description, + inputSchema: toolInfo.inputSchema, + })); + console.error(`Sending ${tools.length} tools from path ${clientChannel}`); + } + + ws.send(JSON.stringify({ + id, + type: 'listToolsResponse', + tools + })); +} + +// Handle list prompts requests +function handleListPrompts(ws, clientChannel, data) { + const {id} = data; + + // Special handling if the request is from the MCP client + const isMcpClient = (clientChannel === MCP_PATH); + + let prompts; + + if (isMcpClient) { + // For MCP clients, return all prompts across all paths with path prefixes + prompts = Object.entries(promptsRegistry).map(([promptId, promptInfo]) => { + // Create a path-based fully qualified name - combine path and prompt name + const pathBasedName = `${promptInfo.channel.slice(1)}-${promptInfo.originalName}`; + return { + name: pathBasedName, + description: promptInfo.description, + arguments: promptInfo.arguments, + }; + }); + console.error(`Sending all ${prompts.length} prompts to MCP client on path ${clientChannel}`); + } else { + // For regular clients, return only their own prompts without path prefixes + prompts = Object.entries(promptsRegistry) + .filter(([_, promptInfo]) => promptInfo.channel === clientChannel) + .map(([_, promptInfo]) => ({ + name: promptInfo.originalName, + description: promptInfo.description, + arguments: promptInfo.arguments, + })); + console.error(`Sending ${prompts.length} prompts from path ${clientChannel}`); + } + + ws.send(JSON.stringify({ + id, + type: 'listPromptsResponse', + prompts + })); +} + +// Handle list resources requests +function handleListResources(ws, clientChannel, data) { + const {id} = data; + + // Special handling if the request is from the MCP client + const isMcpClient = (clientChannel === MCP_PATH); + + let resources = []; + let resourceTemplates = []; + + if (isMcpClient) { + // For MCP clients, return all resources across all paths with path prefixes + Object.entries(resourcesRegistry).forEach(([resourceId, resourceInfo]) => { + // Create a path-based fully qualified name - combine path and resource name + const pathBasedName = `${resourceInfo.channel.slice(1)}-${resourceInfo.originalName}`; + + if (resourceInfo.isTemplate) { + resourceTemplates.push({ + name: pathBasedName, + description: resourceInfo.description, + uriTemplate: resourceInfo.uriTemplate, + mimeType: resourceInfo.mimeType, + }); + } else { + resources.push({ + name: pathBasedName, + description: resourceInfo.description, + uri: resourceInfo.uri, + mimeType: resourceInfo.mimeType, + }); + } + }); + console.error(`Sending all ${resources.length} resources and ${resourceTemplates.length} templates to MCP client on path ${clientChannel}`); + } else { + // For regular clients, return only their own resources without path prefixes + Object.entries(resourcesRegistry) + .filter(([_, resourceInfo]) => resourceInfo.channel === clientChannel) + .forEach(([_, resourceInfo]) => { + if (resourceInfo.isTemplate) { + resourceTemplates.push({ + name: resourceInfo.originalName, + description: resourceInfo.description, + uriTemplate: resourceInfo.uriTemplate, + mimeType: resourceInfo.mimeType, + }); + } else { + resources.push({ + name: resourceInfo.originalName, + description: resourceInfo.description, + uri: resourceInfo.uri, + mimeType: resourceInfo.mimeType, + }); + } + }); + console.error(`Sending ${resources.length} resources and ${resourceTemplates.length} templates from path ${clientChannel}`); + } + + ws.send(JSON.stringify({ + id, + type: 'listResourcesResponse', + resources, + resourceTemplates + })); +} + +// Handle tool call requests +function handleCallTool(ws, callerChannel, data) { + const {id, tool, arguments: args} = data; + + // Special handling if the caller is on the MCP path + const isMcpClient = (callerChannel === MCP_PATH); + + // If the caller is MCP, the tool name might include a path prefix + let targetChannel; + let toolName; + + if (isMcpClient && tool.startsWith('/')) { + // Extract the path and tool name from the fully qualified name + [targetChannel, toolName] = tool.slice(1).split("-").slice(1); + targetChannel = `/${targetChannel}`; + } else { + // Check if the tool exists in the registry + if (!toolsRegistry[tool]) { + ws.send(JSON.stringify({ + id, + type: 'toolResponse', + error: `Tool not found: ${tool}` + })); + return; + } + + const toolInfo = toolsRegistry[tool]; + targetChannel = toolInfo.channel; + toolName = toolInfo.originalName; + } + + // Get the target channel + if (!channels[targetChannel] || channels[targetChannel].size === 0) { + ws.send(JSON.stringify({ + id, + type: 'toolResponse', + error: `No clients available in channel ${targetChannel} to handle tool: ${toolName}` + })); + return; + } + + // Pick the first client in the target channel (you could implement more sophisticated routing) + const targetClient = channels[targetChannel].values().next().value; + + // Create a unique request ID for tracking + const requestId = (requestIdCounter++).toString(); + + // Store the pending request + pendingRequests[requestId] = { + originalId: id, + requesterWs: ws, + timestamp: Date.now() + }; + + // Set up timeout for the request + setTimeout(() => { + if (pendingRequests[requestId]) { + const {requesterWs, originalId} = pendingRequests[requestId]; + delete pendingRequests[requestId]; + + try { + requesterWs.send(JSON.stringify({ + id: originalId, + type: 'toolResponse', + error: `Tool call timed out: ${toolName}` + })); + } catch (error) { + console.error('Error sending timeout response:', error); + } + } + }, 30000); // 30 second timeout + + // Send the request to the target client + targetClient.send(JSON.stringify({ + id: requestId, + type: 'callTool', + tool: toolName, // Send just the tool name without channel prefix + arguments: args + })); + + console.error(`Tool call forwarded: ${toolName} to channel: ${targetChannel}`); +} + +// Handle get prompt requests +function handleGetPrompt(ws, callerChannel, data) { + const {id, name, arguments: args} = data; + + // Special handling if the caller is on the MCP path + const isMcpClient = (callerChannel === MCP_PATH); + + // If the caller is MCP, the prompt name might include a path prefix + let targetChannel; + let promptName; + + if (isMcpClient && name.startsWith('/')) { + // Extract the path and prompt name from the fully qualified name + [targetChannel, promptName] = name.slice(1).split("-").slice(1); + targetChannel = `/${targetChannel}`; + } else { + // Check if the prompt exists in the registry + const promptInfo = Object.values(promptsRegistry).find(p => + p.channel === callerChannel && p.originalName === name); + + if (!promptInfo) { + ws.send(JSON.stringify({ + id, + type: 'promptResponse', + error: `Prompt not found: ${name}` + })); + return; + } + + targetChannel = promptInfo.channel; + promptName = promptInfo.originalName; + } + + // Get the target channel + if (!channels[targetChannel] || channels[targetChannel].size === 0) { + ws.send(JSON.stringify({ + id, + type: 'promptResponse', + error: `No clients available in channel ${targetChannel} to handle prompt: ${promptName}` + })); + return; + } + + // Pick the first client in the target channel + const targetClient = channels[targetChannel].values().next().value; + + // Create a unique request ID for tracking + const requestId = (requestIdCounter++).toString(); + + // Store the pending request + pendingRequests[requestId] = { + originalId: id, + requesterWs: ws, + timestamp: Date.now() + }; + + // Set up timeout for the request + setTimeout(() => { + if (pendingRequests[requestId]) { + const {requesterWs, originalId} = pendingRequests[requestId]; + delete pendingRequests[requestId]; + + try { + requesterWs.send(JSON.stringify({ + id: originalId, + type: 'promptResponse', + error: `Prompt request timed out: ${promptName}` + })); + } catch (error) { + console.error('Error sending timeout response:', error); + } + } + }, 30000); // 30 second timeout + + // Send the request to the target client + targetClient.send(JSON.stringify({ + id: requestId, + type: 'getPrompt', + name: promptName, + arguments: args + })); + + console.error(`Prompt request forwarded: ${promptName} to channel: ${targetChannel}`); +} + +// Handle read resource requests +function handleReadResource(ws, callerChannel, data) { + const {id, uri} = data; + + // Find the resource that matches this URI + let targetChannel; + let resourceName; + let resourceInfo; + + // First, try to find an exact match for the URI + for (const [resId, info] of Object.entries(resourcesRegistry)) { + if (!info.isTemplate && info.uri === uri) { + resourceInfo = info; + targetChannel = info.channel; + resourceName = info.originalName; + break; + } + } + + // If no exact match, check for templates + if (!resourceInfo) { + // This is a simplistic approach; a real implementation would properly parse the URI template + for (const [resId, info] of Object.entries(resourcesRegistry)) { + if (info.isTemplate && uri.startsWith(info.uriTemplate.split('{')[0])) { + resourceInfo = info; + targetChannel = info.channel; + resourceName = info.originalName; + break; + } + } + } + + if (!resourceInfo) { + ws.send(JSON.stringify({ + id, + type: 'resourceResponse', + error: `Resource not found for URI: ${uri}` + })); + return; + } + + // Get the target channel + if (!channels[targetChannel] || channels[targetChannel].size === 0) { + ws.send(JSON.stringify({ + id, + type: 'resourceResponse', + error: `No clients available in channel ${targetChannel} to handle resource: ${resourceName}` + })); + return; + } + + // Pick the first client in the target channel + const targetClient = channels[targetChannel].values().next().value; + + // Create a unique request ID for tracking + const requestId = (requestIdCounter++).toString(); + + // Store the pending request + pendingRequests[requestId] = { + originalId: id, + requesterWs: ws, + timestamp: Date.now() + }; + + // Set up timeout for the request + setTimeout(() => { + if (pendingRequests[requestId]) { + const {requesterWs, originalId} = pendingRequests[requestId]; + delete pendingRequests[requestId]; + + try { + requesterWs.send(JSON.stringify({ + id: originalId, + type: 'resourceResponse', + error: `Resource request timed out: ${uri}` + })); + } catch (error) { + console.error('Error sending timeout response:', error); + } + } + }, 30000); // 30 second timeout + + // Send the request to the target client + targetClient.send(JSON.stringify({ + id: requestId, + type: 'readResource', + uri: uri + })); + + console.error(`Resource request forwarded: ${uri} to channel: ${targetChannel}`); +} + +// Handle tool response +function handleToolResponse(data) { + const {id, result, error} = data; + + // Check if this is a response to a pending request + if (!pendingRequests[id]) { + console.error(`No pending request found for ID: ${id}`); + return; + } + + // Get the original requester information + const {requesterWs, originalId} = pendingRequests[id]; + delete pendingRequests[id]; + + // Forward the response to the original requester + try { + requesterWs.send(JSON.stringify({ + id: originalId, + type: 'toolResponse', + result: result, + error: error + })); + } catch (error) { + console.error('Error forwarding tool response:', error); + } +} + +// Handle prompt response +function handlePromptResponse(data) { + const {id, result, error} = data; + + // Check if this is a response to a pending request + if (!pendingRequests[id]) { + console.error(`No pending request found for ID: ${id}`); + return; + } + + // Get the original requester information + const {requesterWs, originalId} = pendingRequests[id]; + delete pendingRequests[id]; + + // Forward the response to the original requester + try { + requesterWs.send(JSON.stringify({ + id: originalId, + type: 'promptResponse', + result: result, + error: error + })); + } catch (error) { + console.error('Error forwarding prompt response:', error); + } +} + +// Handle resource response +function handleResourceResponse(data) { + const {id, result, error} = data; + + // Check if this is a response to a pending request + if (!pendingRequests[id]) { + console.error(`No pending request found for ID: ${id}`); + return; + } + + // Get the original requester information + const {requesterWs, originalId} = pendingRequests[id]; + delete pendingRequests[id]; + + // Forward the response to the original requester + try { + requesterWs.send(JSON.stringify({ + id: originalId, + type: 'resourceResponse', + result: result, + error: error + })); + } catch (error) { + console.error('Error forwarding resource response:', error); + } +} + +// Handle sampling response +function handleSamplingResponse(data) { + const {id, result, error} = data; + + // Check if this is a response to a pending request + if (!pendingRequests[id]) { + console.error(`No pending request found for ID: ${id}`); + return; + } + + // Get the original requester information + const {requesterWs, originalId} = pendingRequests[id]; + delete pendingRequests[id]; + + // Forward the response to the original requester + try { + requesterWs.send(JSON.stringify({ + id: originalId, + type: 'samplingResponse', + result: result, + error: error + })); + } catch (error) { + console.error('Error forwarding sampling response:', error); + } +} + +// Handle create sampling message +function handleCreateSamplingMessage(ws, callerChannel, data) { + const { + id, + messages, + systemPrompt, + includeContext, + temperature, + maxTokens, + stopSequences, + metadata, + modelPreferences + } = data; + + // Special handling if the caller is on the MCP path + const isMcpClient = (callerChannel === MCP_PATH); + + // For non-MCP clients or if no client is available in any channel + if (!isMcpClient) { + ws.send(JSON.stringify({ + id, + type: 'samplingResponse', + error: `Sampling is only available through MCP path` + })); + return; + } + + // Find a client that can handle sampling - target the first available client + let targetClient = null; + let targetChannel = null; + + // Iterate through all channels to find one with clients + for (const [channel, clients] of Object.entries(channels)) { + if (channel !== MCP_PATH && clients.size > 0) { + targetClient = clients.values().next().value; + targetChannel = channel; + break; + } + } + + if (!targetClient) { + ws.send(JSON.stringify({ + id, + type: 'samplingResponse', + error: 'No clients available to handle sampling request' + })); + return; + } + + // Create a unique request ID for tracking + const requestId = (requestIdCounter++).toString(); + + // Store the pending request + pendingRequests[requestId] = { + originalId: id, + requesterWs: ws, + timestamp: Date.now() + }; + + // Set up timeout for the request (longer timeout for sampling) + setTimeout(() => { + if (pendingRequests[requestId]) { + const {requesterWs, originalId} = pendingRequests[requestId]; + delete pendingRequests[requestId]; + + try { + requesterWs.send(JSON.stringify({ + id: originalId, + type: 'samplingResponse', + error: 'Sampling request timed out' + })); + } catch (error) { + console.error('Error sending timeout response:', error); + } + } + }, 120000); // 120 second timeout for sampling + + // Forward the request to the target client + targetClient.send(JSON.stringify({ + id: requestId, + type: 'createSamplingMessage', + messages, + systemPrompt, + includeContext, + temperature, + maxTokens, + stopSequences, + metadata, + modelPreferences + })); + + console.error(`Sampling request forwarded to channel: ${targetChannel}`); +} + + +// Function to decode a base64 encoded channel-token pair +function decodeChannelTokenPair(encodedPair) { + try { + const decodedString = Buffer.from(encodedPair, 'base64').toString('utf8'); + const [channel, token] = decodedString.split(':'); + + if (!channel || !token) { + throw new Error('Invalid format'); + } + + // Ensure channel has leading slash + const formattedChannel = channel.startsWith('/') ? channel : `/${channel}`; + + return {channel: formattedChannel, token}; + } catch (error) { + console.error('Error decoding channel-token pair:', error); + return null; + } +} + +// Function to authorize a new channel-token pair +async function authorizeChannelToken(encodedPair) { + const decoded = decodeChannelTokenPair(encodedPair); + if (!decoded) { + return {success: false, message: 'Invalid encoded channel-token pair'}; + } + + const {channel, token} = decoded; + + // Check if this channel already has an active connection + if (channels[channel] && channels[channel].size > 0) { + return {success: false, message: `Channel ${channel} already has an active connection`}; + } + + // Add to authorized tokens + setToken(channel, token); + await saveAuthorizedTokens(); + + return { + success: true, + message: `Authorized channel: ${channel}`, + channel, + token + }; +} + +// Function to check if server is already running +async function isServerRunning() { + // If using "docker" and "startMCP" just assume the server is running + if (CONFIG.startMCP && CONFIG.docker) { + return true; + } + + try { + // Check if PID file exists + const pidData = await fs.readFile(PID_FILE, 'utf8'); + const pid = parseInt(pidData.trim(), 10); + + // Check if process with this PID is running + // This is platform-specific, using a simple approach + try { + process.kill(pid, 0); // This doesn't actually kill the process, just checks if it exists + return {running: true, pid}; + } catch (e) { + // Process not running, remove stale PID file + await fs.unlink(PID_FILE); + return {running: false}; + } + } catch (error) { + // PID file doesn't exist or other error + return {running: false}; + } +} + +// Function to save current PID to file +async function savePid() { + try { + await fs.writeFile(PID_FILE, process.pid.toString(), 'utf8'); + return true; + } catch (error) { + console.error('Error saving PID file:', error); + return false; + } +} + +// Function to run the server in the background +async function daemonize() { + // Fork a new process that will become the daemon + const args = process.argv.slice(2); + + // Make sure the --forked flag is included + if (!args.includes('--forked')) { + args.push('--forked'); + } + + // Create a detached child process + const child = fork(process.argv[1], args, { + detached: true, + stdio: 'ignore' + }); + + // Detach the child process so it can run independently + child.unref(); + + console.error(`Server started as daemon with PID: ${child.pid}`); + console.error(`Use 'node websocket-server.js --quit' to stop the server`); + console.error(`Use 'node websocket-server.js --new ' to authorize a channel-token pair`); + console.error(`Put 'npx @nucleoriofrio/webmcp --mcp' in your mcp client config`); + if (!CONFIG.startMCP) { + process.exit(0); + } +} + +const parseArgs = async () => { + const args = process.argv.slice(2); + let port = 4797; // Default port + let quit = false; + let newToken = false; + let startMCP = false; + let docker = false; + let cleanTokens = false; + let encodedPair = null; + let daemon = true; // Default to daemonize + let dev = process.env.WEBMCP_DEV === 'true' || process.env.WEBMCP_DEV === '1'; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '-h' || arg === '--help') { + showHelp(); + process.exit(0); + } else if (arg === '-p' || arg === '--port') { + if (i + 1 < args.length) { + const portArg = parseInt(args[i + 1], 10); + if (isNaN(portArg) || portArg < 1 || portArg > 65535) { + console.error('Error: Port must be a number between 1 and 65535'); + showHelp(); + process.exit(1); + } + port = portArg; + i++; // Skip the next argument as we've already processed it + } else { + console.error('Error: Port option requires a value'); + showHelp(); + process.exit(1); + } + } else if (arg === '--config') { + if (i + 1 < args.length) { + const config = args[i + 1]; + await configureMcpClient(config) + i++; // Skip the next argument as we've already processed it + } else { + console.error('Error: Config option requires a mcp client type or path to json'); + showHelp(); + process.exit(1); + } + } else if (arg === '-q' || arg === '--quit') { + quit = true; + } else if (arg === '-n' || arg === '--new') { + newToken = true; + } else if (arg === '-m' || arg === '--mcp') { + startMCP = true; + } else if (arg === '-d' || arg === '--docker') { + docker = true; + } else if (arg === '-c' || arg === '--clean') { + cleanTokens = true; + } else if (arg === '-f' || arg === '--foreground') { + daemon = false; + } else if (arg === '--dev') { + dev = true; + } else if (arg === '--forked') { + // This is an internal flag to indicate we're the forked child │ │ + // No need to do anything with it here, just don't error on it + } else { + console.error(`Error: Unknown option: ${arg}`); + showHelp(); + process.exit(1); + } + } + + return {port, quit, newToken, cleanTokens, encodedPair, daemon, startMCP, dev}; +}; + +const showHelp = () => { + console.log(` +Usage: node websocket-server.js [options] + +Options: + --config Automatically update MCP client configuration to add WebMCP + -p, --port Specify the port number (default: 4797) + -h, --help Display this help message + -q, --quit Stop the running server + -n, --new Generate a new token for client registration + -c, --clean Remove all authorized tokens + -f, --foreground Run in foreground (don't daemonize) + -m, --mcp Internal WebMCP Server codepath, likely only used in MCP client config + -d, --docker Tell the MCP client that WebMCP is running in docker + --dev Enable development mode (agregar-tool, quitar-tool) + +Use --new to generate a token which clients can use to register on the /register endpoint. +Use --clean to remove all authorized tokens when you want to start fresh. + `); +}; + +const main = async () => { + // Ensure the config directory exists + await ensureConfigDir(); + + // Load authorized tokens from disk + await loadAuthorizedTokens(); + + setConfig(await parseArgs()); + + // Check if server is already running + const serverStatus = await isServerRunning(); + + // Handle clean tokens command + if (CONFIG.cleanTokens) { + console.log(`Removing all authorized tokens...`); + clearTokens(); + await saveAuthorizedTokens(); + console.log(`All tokens have been removed. Tokens file cleared.`); + + // If server is running, we need to notify it to reload tokens + if (serverStatus.running) { + console.log(`Server is running with PID: ${serverStatus.pid}. Please restart it to apply changes.`); + } + + process.exit(0); + } + + // Handle quit command + if (CONFIG.quit) { + if (serverStatus.running) { + console.log(`Stopping server with PID: ${serverStatus.pid}`); + try { + process.kill(serverStatus.pid, 'SIGTERM'); + console.log('Server stopped successfully'); + } catch (error) { + console.error('Error stopping server:', error); + } + } else { + console.log('No running server found'); + } + process.exit(0); + } + + // Handle new token generation + if (CONFIG.newToken) { + const encodedData = await generateNewRegistrationToken(); + console.log(`\nCONNECTION TOKEN (paste this in your web client):`); + console.log(`${encodedData}\n`); + + // If server is running, exit + if (serverStatus.running) { + process.exit(0); + } + } + + // Check if we have a server token, generate one if not + if (!serverToken) { + // console.log('No server token found, generating a new one...'); + serverToken = generateToken(); + await saveServerTokenToEnv(serverToken); + // console.log(`New server token: "${serverToken}". Saved to .env`); + } + + // If server is already running and we're not authorizing a token, just show status and exit + if (serverStatus.running) { + console.error(`Server is already running with PID: ${serverStatus.pid}`); + console.error(`Use 'node websocket-server.js --quit' to stop the server`); + console.error(`Use 'node websocket-server.js --new ' to authorize a channel-token pair`); + console.error(`Put 'npx @nucleoriofrio/webmcp --mcp' in your mcp client config`); + if (CONFIG.startMCP) { + return; + } else { + process.exit(0); + } + } + + // Daemonize if requested + if (CONFIG.daemon) { + // We need to add a marker to args to prevent fork bombs + // If we already have the --forked flag, we're in the child process and should continue + if (!process.argv.includes('--forked')) { + // Add the --forked flag to the arguments before daemonizing + process.argv.push('--forked'); + return daemonize(); + } + } + + // If we have the --forked flag, we're already the daemon, continue execution + // Save PID file + await savePid(); + + // Start the server + const PORT = CONFIG.port; + httpServer.listen(PORT, () => { + console.error(`@nucleoriofrio/webmcp v0.2.0 — WebSocket server running at http://${HOST}:${PORT}`); + console.error(`WebMCP client token (for MCP path): ${serverToken}`); + console.error(`WebMCP client URL: ws://${HOST}:${PORT}${MCP_PATH}?token=${serverToken}`); + if (CONFIG.dev) { + console.error(`[DEV] Modo desarrollo activo — agregar-tool y quitar-tool habilitados`); + } + console.error(`Use 'node websocket-server.js --new ' to authorize a channel-token pair`); + }); + + // Handle graceful shutdown + const shutdownGracefully = async (signal) => { + console.error(`\nReceived ${signal}. Shutting down gracefully...`); + + // Save authorized tokens before shutting down + await saveAuthorizedTokens(); + + // Close all WebSocket connections in all channels + for (const channel of Object.values(channels)) { + for (const ws of channel) { + try { + ws.close(); + } catch (error) { + console.error('Error closing WebSocket connection:', error); + } + } + } + + // Close the HTTP server + httpServer.close(() => { + console.error('HTTP server closed'); + + // Remove PID file + fs.unlink(PID_FILE).catch(err => { + console.error('Error removing PID file:', err); + }); + + process.exit(0); + }); + }; + + // Handle CTRL+C (SIGINT) + process.on('SIGINT', () => shutdownGracefully('SIGINT')); + + // Handle SIGTERM + process.on('SIGTERM', () => shutdownGracefully('SIGTERM')); + + // Enable keyboard input handling for CTRL+C on Windows + if (process.platform === 'win32' && process.stdin.isTTY) { + try { + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.on('data', (data) => { + // Check for CTRL+C (03 in hex) + if (data.length === 1 && data[0] === 0x03) { + shutdownGracefully('CTRL+C'); + } + }); + } catch (e) { + // stdin not available, ignore + } + } +}; + +main().catch(error => { + console.error('Error in main:', error); + process.exit(1); +}).then(() => { + // Handle starting MCP + if (CONFIG.startMCP) { + setTimeout(() => { + console.error("Starting up MCP Server") + runMcpServer(serverToken).catch((error) => { + console.error("Fatal error in main():", error); + process.exit(1); + }); + }, 100); + } +});