UNPKG

node-red-contrib-mcp-server

Version:

A comprehensive Node-RED wrapper for Model Context Protocol (MCP) servers providing standardized AI agent tool interfaces, server lifecycle management, real-time communication capabilities, and visual MCP server creation

673 lines (593 loc) 23.9 kB
module.exports = function (RED) { "use strict"; const axios = require('axios'); const EventSource = require('eventsource'); const WebSocket = require('ws'); const { v4: uuidv4 } = require('uuid'); function MCPClientNode(config) { RED.nodes.createNode(this, config); const node = this; // Configuration node.serverUrl = config.serverUrl || "http://localhost:8000"; node.connectionType = config.connectionType || "http"; node.autoConnect = config.autoConnect || false; node.reconnect = config.reconnect || true; node.reconnectInterval = config.reconnectInterval || 5000; node.timeout = config.timeout || 30000; // Runtime state node.isConnected = false; node.connection = null; node.reconnectTimer = null; node.pendingRequests = new Map(); // Set initial status node.status({ fill: "grey", shape: "ring", text: "disconnected" }); // Connect function node.connect = function (callback = () => { }) { if (node.isConnected) { callback(null, { success: true, message: "Already connected" }); return; } node.status({ fill: "yellow", shape: "ring", text: "connecting..." }); try { switch (node.connectionType) { case "http": node.connectHTTP(callback); break; case "sse": node.connectSSE(callback); break; case "websocket": node.connectWebSocket(callback); break; default: const error = new Error(`Unsupported connection type: ${node.connectionType}`); node.error(error); callback(error); } } catch (error) { node.error(`Connection error: ${error.message}`); node.status({ fill: "red", shape: "dot", text: "error" }); callback(error); } }; // HTTP connection (polling-based) node.connectHTTP = function (callback) { // Test connection with a health check axios.get(`${node.serverUrl}/health`, { timeout: node.timeout }) .then(response => { node.isConnected = true; node.status({ fill: "green", shape: "dot", text: "connected (HTTP)" }); // Send connection event node.send({ topic: "connected", payload: { serverUrl: node.serverUrl, connectionType: "http", serverInfo: response.data } }); callback(null, { success: true, message: "HTTP connection established" }); }) .catch(error => { node.isConnected = false; node.status({ fill: "red", shape: "dot", text: "connection failed" }); callback(error); if (node.reconnect) { node.scheduleReconnect(); } }); }; // Server-Sent Events connection node.connectSSE = function (callback) { try { const sseUrl = `${node.serverUrl}/sse`; node.connection = new EventSource(sseUrl); node.connection.onopen = function (event) { node.isConnected = true; node.status({ fill: "green", shape: "dot", text: "connected (SSE)" }); // Send connection event node.send({ topic: "connected", payload: { serverUrl: node.serverUrl, connectionType: "sse", event: event } }); callback(null, { success: true, message: "SSE connection established" }); // Automatically fetch MCP handshake information setTimeout(() => { node.fetchMCPHandshake(); }, 500); // Small delay to ensure connection is fully established }; node.connection.onmessage = function (event) { try { const data = JSON.parse(event.data); // Handle MCP protocol messages if (data.id && node.pendingRequests.has(data.id)) { const request = node.pendingRequests.get(data.id); clearTimeout(request.timeout); node.pendingRequests.delete(data.id); // Send response node.send({ topic: "response", payload: data, requestId: data.id }); } else { // Send general message node.send({ topic: "message", payload: data }); } } catch (parseError) { node.warn(`Failed to parse SSE message: ${parseError.message}`); // Send raw message node.send({ topic: "raw", payload: event.data }); } }; node.connection.onerror = function (error) { node.error(`SSE connection error: ${error.message || 'Unknown error'}`); node.isConnected = false; node.status({ fill: "red", shape: "dot", text: "SSE error" }); if (node.reconnect) { node.scheduleReconnect(); } }; } catch (error) { callback(error); } }; // Fetch MCP handshake information (tools, capabilities, server info) node.fetchMCPHandshake = function () { if (!node.isConnected) { return; } node.log("Fetching MCP handshake information..."); // First, initialize the MCP session node.sendRequest("initialize", { protocolVersion: "2024-11-05", capabilities: { tools: {} }, clientInfo: { name: "node-red-mcp-client", version: "1.1.0" } }, (error, initResponse) => { if (error) { node.warn(`Failed to initialize MCP session: ${error.message}`); return; } // Send initialization response as handshake info node.send({ topic: "handshake", payload: { type: "initialization", serverInfo: initResponse.result || initResponse, timestamp: new Date().toISOString() } }); // Then fetch available tools node.sendRequest("tools/list", {}, (error, toolsResponse) => { if (error) { node.warn(`Failed to fetch tools list: ${error.message}`); return; } const tools = toolsResponse.result?.tools || toolsResponse.tools || []; // Send comprehensive handshake information node.send({ topic: "handshake", payload: { type: "tools_discovered", serverUrl: node.serverUrl, connectionType: node.connectionType, serverInfo: initResponse.result || initResponse, availableTools: tools, toolCount: tools.length, toolNames: tools.map(tool => tool.name), timestamp: new Date().toISOString(), mcpSyntax: { requestFormat: { topic: "request", payload: { method: "tool_name_here", params: "tool_parameters_object" } }, exampleCall: tools.length > 0 ? { topic: "request", payload: { method: tools[0].name, params: node.generateExampleParams(tools[0].inputSchema) } } : null } } }); node.log(`MCP handshake complete: found ${tools.length} tools`); }); }); }; // Generate example parameters from JSON schema node.generateExampleParams = function (schema) { if (!schema || !schema.properties) { return {}; } const example = {}; for (const [propName, propDef] of Object.entries(schema.properties)) { switch (propDef.type) { case "string": if (propDef.enum) { example[propName] = propDef.enum[0]; } else { example[propName] = propDef.description || "example_string"; } break; case "number": case "integer": example[propName] = 123; break; case "boolean": example[propName] = true; break; case "object": example[propName] = {}; break; case "array": example[propName] = []; break; default: example[propName] = propDef.description || "example_value"; } } return example; }; // WebSocket connection node.connectWebSocket = function (callback) { try { const wsUrl = node.serverUrl.replace(/^http/, 'ws') + '/ws'; node.connection = new WebSocket(wsUrl); node.connection.on('open', function () { node.isConnected = true; node.status({ fill: "green", shape: "dot", text: "connected (WS)" }); // Send connection event node.send({ topic: "connected", payload: { serverUrl: node.serverUrl, connectionType: "websocket" } }); callback(null, { success: true, message: "WebSocket connection established" }); // Automatically fetch MCP handshake information setTimeout(() => { node.fetchMCPHandshake(); }, 500); // Small delay to ensure connection is fully established }); node.connection.on('message', function (data) { try { const message = JSON.parse(data.toString()); // Handle MCP protocol messages if (message.id && node.pendingRequests.has(message.id)) { const request = node.pendingRequests.get(message.id); clearTimeout(request.timeout); node.pendingRequests.delete(message.id); // Send response node.send({ topic: "response", payload: message, requestId: message.id }); } else { // Send general message node.send({ topic: "message", payload: message }); } } catch (parseError) { node.warn(`Failed to parse WebSocket message: ${parseError.message}`); // Send raw message node.send({ topic: "raw", payload: data.toString() }); } }); node.connection.on('error', function (error) { node.error(`WebSocket connection error: ${error.message}`); node.isConnected = false; node.status({ fill: "red", shape: "dot", text: "WS error" }); if (node.reconnect) { node.scheduleReconnect(); } }); node.connection.on('close', function (code, reason) { node.log(`WebSocket connection closed: ${code} ${reason}`); node.isConnected = false; node.status({ fill: "grey", shape: "ring", text: "disconnected" }); if (node.reconnect && code !== 1000) { // 1000 = normal closure node.scheduleReconnect(); } }); } catch (error) { callback(error); } }; // Disconnect function node.disconnect = function (callback = () => { }) { if (!node.isConnected) { callback(null, { success: true, message: "Already disconnected" }); return; } node.status({ fill: "yellow", shape: "ring", text: "disconnecting..." }); if (node.reconnectTimer) { clearTimeout(node.reconnectTimer); node.reconnectTimer = null; } try { if (node.connection) { if (node.connectionType === "sse") { node.connection.close(); } else if (node.connectionType === "websocket") { node.connection.close(1000, "Normal closure"); } node.connection = null; } node.isConnected = false; node.status({ fill: "grey", shape: "ring", text: "disconnected" }); // Clear pending requests node.pendingRequests.forEach((request, id) => { clearTimeout(request.timeout); }); node.pendingRequests.clear(); callback(null, { success: true, message: "Disconnected" }); } catch (error) { node.error(`Disconnect error: ${error.message}`); callback(error); } }; // Schedule reconnection node.scheduleReconnect = function () { if (node.reconnectTimer) return; node.reconnectTimer = setTimeout(() => { node.reconnectTimer = null; if (!node.isConnected) { node.log("Attempting to reconnect..."); node.connect(); } }, node.reconnectInterval); }; // Send MCP request node.sendRequest = function (method, params = {}, callback) { if (!node.isConnected) { const error = new Error("Not connected to MCP server"); if (callback) callback(error); return; } const requestId = uuidv4(); const request = { jsonrpc: "2.0", id: requestId, method: method, params: params }; try { switch (node.connectionType) { case "http": // HTTP POST request axios.post(`${node.serverUrl}/mcp`, request, { timeout: node.timeout, headers: { 'Content-Type': 'application/json' } }) .then(response => { if (callback) callback(null, response.data); node.send({ topic: "response", payload: response.data, requestId: requestId }); }) .catch(error => { if (callback) callback(error); node.send({ topic: "error", payload: { error: error.message, requestId: requestId } }); }); break; case "sse": // For SSE, we need to use HTTP POST for requests and listen for responses axios.post(`${node.serverUrl}/mcp`, request, { timeout: node.timeout, headers: { 'Content-Type': 'application/json' } }) .then(response => { if (callback) callback(null, response.data); }) .catch(error => { if (callback) callback(error); }); break; case "websocket": // WebSocket send node.connection.send(JSON.stringify(request)); // Set up timeout for response const timeoutHandler = setTimeout(() => { node.pendingRequests.delete(requestId); const error = new Error("Request timeout"); if (callback) callback(error); node.send({ topic: "error", payload: { error: "Request timeout", requestId: requestId } }); }, node.timeout); // Store pending request node.pendingRequests.set(requestId, { callback: callback, timeout: timeoutHandler }); break; } } catch (error) { if (callback) callback(error); node.error(`Send request error: ${error.message}`); } }; // Handle input messages node.on('input', function (msg) { const command = msg.topic || msg.payload.command; switch (command) { case 'connect': node.connect((error, result) => { if (!error) { msg.payload = result; node.send(msg); } }); break; case 'disconnect': node.disconnect((error, result) => { if (!error) { msg.payload = result; node.send(msg); } }); break; case 'request': const method = msg.payload.method; const params = msg.payload.params || {}; if (!method) { node.warn("Request method is required"); return; } node.sendRequest(method, params, (error, response) => { if (error) { msg.payload = { error: error.message }; msg.topic = "error"; } else { msg.payload = response; msg.topic = "response"; } node.send(msg); }); break; case 'status': msg.payload = { isConnected: node.isConnected, serverUrl: node.serverUrl, connectionType: node.connectionType, pendingRequests: node.pendingRequests.size }; node.send(msg); break; default: node.warn(`Unknown command: ${command}`); } }); // Auto-connect if configured if (node.autoConnect) { setTimeout(() => { node.connect(); }, 1000); } // Cleanup on node close node.on('close', function (done) { node.disconnect(() => { done(); }); }); } // Register the node RED.nodes.registerType("mcp-client", MCPClientNode); };