270 lines
9.1 KiB
JavaScript
270 lines
9.1 KiB
JavaScript
|
|
/**
|
||
|
|
* 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);
|
||
|
|
});
|