Files
NetMesh/electron/mcp/netcatty-mcp-server.cjs

270 lines
9.1 KiB
JavaScript
Raw Permalink Normal View History

/**
* 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);
});