/** * Netcatty MCP Server (stdio transport) * * Spawned by managed SDK agents as a child process. * Communicates with the Netcatty main process via TCP (JSON-RPC over newline-delimited JSON). * Exposes Netcatty terminal tools so external agents can operate on scoped sessions. */ "use strict"; const fs = require("node:fs"); const net = require("node:net"); const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js"); const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js"); const { getCatalogToolDescription } = require("./catalogToolMetadata.cjs"); const { registerMcpTools } = require("../capabilities/codegen/mcpToolRegistry.cjs"); const { normalizeMcpJsonRpcMessage } = require("./normalizeMcpCallArguments.cjs"); function catalogDescription(toolName, fallback) { return getCatalogToolDescription(toolName) || fallback; } const DEBUG_MCP = process.env.NETCATTY_MCP_DEBUG === "1"; function debugLog(...args) { if (!DEBUG_MCP) return; process.stderr.write(`[netcatty-mcp:debug] ${args.map(arg => { if (typeof arg === "string") return arg; try { return JSON.stringify(arg); } catch { return String(arg); } }).join(" ")}\n`); } // ── TCP Bridge to Netcatty main process ── const NETCATTY_MCP_PORT = parseInt(process.env.NETCATTY_MCP_PORT, 10); if (!NETCATTY_MCP_PORT) { process.stderr.write("[netcatty-mcp] NETCATTY_MCP_PORT not set\n"); process.exit(1); } // Auth token for TCP bridge authentication const NETCATTY_MCP_TOKEN = process.env.NETCATTY_MCP_TOKEN || ""; if (!NETCATTY_MCP_TOKEN) { process.stderr.write("[netcatty-mcp] NETCATTY_MCP_TOKEN not set\n"); process.exit(1); } // Scoped session IDs (comma-separated). When set (even if empty), only listed // sessions are accessible. When unset, scope enforcement falls back to the // TCP bridge's own scoping (which also defaults to no-access when empty). const SCOPED_SESSION_IDS = process.env.NETCATTY_MCP_SESSION_IDS != null ? process.env.NETCATTY_MCP_SESSION_IDS.split(",").map(s => s.trim()).filter(Boolean) : null; // Chat session ID for per-scope metadata isolation const CHAT_SESSION_ID = process.env.NETCATTY_MCP_CHAT_SESSION_ID || null; // Permission mode: 'observer' | 'confirm' | 'auto' (defense-in-depth, TCP bridge also checks). // External MCP clients may keep a long-lived stdio process; re-read discovery so // Settings → AI → Safety changes apply without restarting the client. function readPermissionMode() { const discoveryPath = process.env.NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE; if (discoveryPath) { try { const parsed = JSON.parse(fs.readFileSync(discoveryPath, "utf8")); if (typeof parsed?.permissionMode === "string" && parsed.permissionMode.trim()) { return parsed.permissionMode.trim(); } } catch { // Fall through to env / default. } } return process.env.NETCATTY_MCP_PERMISSION_MODE || "confirm"; } // Default command blocklist (defense-in-depth, TCP bridge also checks) const DEFAULT_COMMAND_BLOCKLIST = require("../../lib/commandBlocklist.cjs"); // Pre-compile blocklist regexes once at module load time const compiledBlocklist = DEFAULT_COMMAND_BLOCKLIST.map(pattern => { try { return new RegExp(pattern, "i"); } catch { return null; // placeholder for invalid patterns } }); function checkCommandSafety(command) { for (let i = 0; i < compiledBlocklist.length; i++) { const re = compiledBlocklist[i]; if (re && re.test(command)) { return { blocked: true, matchedPattern: DEFAULT_COMMAND_BLOCKLIST[i] }; } } return { blocked: false }; } /** Guard for write tools: blocks in observer mode, optionally checks command safety. */ function guardWriteOperation(command, { skipBlocklist = false } = {}) { if (readPermissionMode() === "observer") { return 'Operation denied: permission mode is "observer" (read-only). Change to "confirm" or "auto" in Settings → AI → Safety to allow this action.'; } // When skipBlocklist is true, the caller relies on the TCP bridge layer for // session-aware blocklist checks (e.g. serial and network device sessions skip shell patterns). if (!skipBlocklist && command) { const safety = checkCommandSafety(command); if (safety.blocked) { return `Command blocked by safety policy. Pattern: ${safety.matchedPattern}`; } } return null; } let tcpSocket = null; let pendingRequests = new Map(); // id -> { resolve, reject } let nextRpcId = 1; let tcpBuffer = ""; function connectTcp() { return new Promise((resolve, reject) => { const sock = net.createConnection({ host: "127.0.0.1", port: NETCATTY_MCP_PORT }, () => { tcpSocket = sock; debugLog("Connected to TCP bridge", { port: NETCATTY_MCP_PORT }); resolve(); }); sock.setEncoding("utf-8"); const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10 MB sock.on("data", (chunk) => { tcpBuffer += chunk; if (tcpBuffer.length > MAX_BUFFER_SIZE) { process.stderr.write(`[netcatty-mcp] TCP buffer exceeded ${MAX_BUFFER_SIZE} bytes, clearing buffer\n`); tcpBuffer = ""; return; } let newlineIdx; while ((newlineIdx = tcpBuffer.indexOf("\n")) !== -1) { const line = tcpBuffer.slice(0, newlineIdx); tcpBuffer = tcpBuffer.slice(newlineIdx + 1); if (!line.trim()) continue; try { const msg = JSON.parse(line); debugLog("TCP message received", { id: msg?.id, hasError: Boolean(msg?.error), keys: msg ? Object.keys(msg) : [], }); if (msg.id != null && pendingRequests.has(msg.id)) { const { resolve: res, reject: rej } = pendingRequests.get(msg.id); pendingRequests.delete(msg.id); if (msg.error) { rej(new Error(msg.error.message || JSON.stringify(msg.error))); } else { res(msg.result); } } } catch { // ignore malformed lines } } }); sock.on("error", (err) => { debugLog("TCP socket error", { message: err?.message || String(err) }); reject(err); // Reject all pending for (const { reject: rej } of pendingRequests.values()) { rej(new Error("TCP connection lost")); } pendingRequests.clear(); }); sock.on("close", () => { debugLog("TCP socket closed"); // Reject all pending requests on clean close for (const { reject: rej } of pendingRequests.values()) { rej(new Error("TCP connection closed")); } pendingRequests.clear(); tcpSocket = null; }); }); } function rpcCall(method, params) { return new Promise((resolve, reject) => { if (!tcpSocket || tcpSocket.destroyed) { return reject(new Error("Not connected to Netcatty")); } const id = nextRpcId++; pendingRequests.set(id, { resolve, reject }); const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"; debugLog("rpcCall", { id, method, params }); tcpSocket.write(msg); }); } // ── MCP Server ── const server = new McpServer({ name: "netcatty-remote-hosts", version: "1.0.0", }); // Scope params shared by all tool calls. // When chatSessionId is present, let the main process resolve the current // workspace membership dynamically so mid-session workspace changes are visible // without restarting the MCP subprocess. const scopeParams = CHAT_SESSION_ID ? { chatSessionId: CHAT_SESSION_ID } : { scopedSessionIds: SCOPED_SESSION_IDS, chatSessionId: CHAT_SESSION_ID }; // Resource: environment context server.resource( "environment", "netcatty://context", { description: "Current Netcatty workspace context: connected hosts, session IDs, and environment description." }, async () => { const ctx = await rpcCall("netcatty/getContext", scopeParams); return { contents: [{ uri: "netcatty://context", mimeType: "application/json", text: JSON.stringify(ctx, null, 2), }], }; }, ); // Register catalog-driven MCP tools (terminal, SFTP, attachments, vault, portforward). registerMcpTools(server, { rpcCall, scopeParams, guardWriteOperation, catalogDescription, }); // ── Start ── async function main() { debugLog("Starting MCP server", { port: NETCATTY_MCP_PORT, hasToken: Boolean(NETCATTY_MCP_TOKEN), scopedSessionIds: SCOPED_SESSION_IDS, chatSessionId: CHAT_SESSION_ID, permissionMode: readPermissionMode(), }); await connectTcp(); // Authenticate with the TCP bridge before accepting any tool calls const authResult = await rpcCall("auth/verify", { token: NETCATTY_MCP_TOKEN }); debugLog("auth/verify result", authResult); if (!authResult?.ok) { throw new Error("TCP bridge authentication failed"); } process.stderr.write("[netcatty-mcp] Authenticated with TCP bridge\n"); const transport = new StdioServerTransport(); // Protocol.connect keeps this callback and runs it before request dispatch. transport.onmessage = (message) => { normalizeMcpJsonRpcMessage(message); }; await server.connect(transport); } main().catch((err) => { process.stderr.write(`[netcatty-mcp] Fatal: ${err.message}\n`); process.exit(1); });