- Envolver notificacion al MCP en try-catch para que fallos no se propaguen al handler del browser como "Invalid message format" - Cambiar .values().forEach() por for...of (mas robusto) - Mejorar catch block: incluir error.message real en la respuesta al browser y preview del mensaje raw en el log del servidor
1967 lines
64 KiB
JavaScript
1967 lines
64 KiB
JavaScript
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((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');
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
// Handle preflight requests
|
|
res.writeHead(204);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
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 <encoded-pair>' 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 <number> 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 <encoded-pair>' 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 <encoded-pair>' 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);
|
|
}
|
|
});
|