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 };