[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
269
electron/mcp/netcatty-mcp-server.cjs
Normal file
269
electron/mcp/netcatty-mcp-server.cjs
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
Reference in New Issue
Block a user