[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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
"use strict";
const { listMcpTools } = require("../capabilities/codegen/toolSurfaces.cjs");
const mcpToolDescriptions = new Map(
listMcpTools().map((tool) => [tool.mcpTool, tool.description]),
);
function getCatalogToolDescription(toolName) {
return mcpToolDescriptions.get(toolName) || null;
}
function listCatalogMcpToolNames() {
return [...mcpToolDescriptions.keys()];
}
module.exports = {
getCatalogToolDescription,
listCatalogMcpToolNames,
};

View File

@@ -0,0 +1,52 @@
"use strict";
/**
* Bootstrap for external MCP clients.
* Reads the external discovery file written by Netcatty, sets env, then
* loads the existing catalog-backed stdio MCP server.
*/
const fs = require("node:fs");
const path = require("node:path");
const {
resolveExistingExternalMcpDiscoveryFilePath,
EXTERNAL_MCP_CHAT_SESSION_ID,
} = require("../cli/externalMcpDiscoveryPath.cjs");
const { readExternalDiscovery } = require("../cli/externalMcpDiscovery.cjs");
function resolveDiscoveryPath() {
return resolveExistingExternalMcpDiscoveryFilePath();
}
function main() {
const discoveryPath = resolveDiscoveryPath();
if (!fs.existsSync(discoveryPath)) {
process.stderr.write(
`[netcatty-external-mcp] Discovery file not found at ${discoveryPath}. ` +
"Enable External MCP in Netcatty Settings → AI and keep the app running.\n",
);
process.exit(1);
}
let discovery;
try {
discovery = readExternalDiscovery(discoveryPath);
} catch (error) {
process.stderr.write(
`[netcatty-external-mcp] Failed to read discovery: ${error?.message || error}\n`,
);
process.exit(1);
}
process.env.NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE = discoveryPath;
process.env.NETCATTY_MCP_PORT = String(discovery.port);
process.env.NETCATTY_MCP_TOKEN = discovery.token;
process.env.NETCATTY_MCP_CHAT_SESSION_ID = discovery.chatSessionId || EXTERNAL_MCP_CHAT_SESSION_ID;
process.env.NETCATTY_MCP_PERMISSION_MODE = discovery.permissionMode || "confirm";
// Load after env is set — netcatty-mcp-server reads env at module load.
require(path.join(__dirname, "netcatty-mcp-server.cjs"));
}
main();

View 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);
});

View File

@@ -0,0 +1,27 @@
"use strict";
/**
* MCP tools/call `arguments` is optional. Clients may omit it for no-arg
* tools or send {}. The SDK still parses omitted args as `undefined`, and
* Zod object schemas reject that. Treat missing/null as {}.
*/
function normalizeMcpToolArguments(args) {
return args == null ? {} : args;
}
function normalizeMcpJsonRpcMessage(message) {
if (!message || typeof message !== "object") return message;
if (message.method !== "tools/call") return message;
const params = message.params;
if (!params || typeof params !== "object" || Array.isArray(params)) return message;
if (params.arguments == null) {
params.arguments = {};
}
return message;
}
module.exports = {
normalizeMcpToolArguments,
normalizeMcpJsonRpcMessage,
};

View File

@@ -0,0 +1,66 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
normalizeMcpToolArguments,
normalizeMcpJsonRpcMessage,
} = require("./normalizeMcpCallArguments.cjs");
test("normalizeMcpToolArguments treats omitted and null as empty object", () => {
assert.deepEqual(normalizeMcpToolArguments(undefined), {});
assert.deepEqual(normalizeMcpToolArguments(null), {});
});
test("normalizeMcpToolArguments leaves provided arguments unchanged", () => {
const args = { sessionId: "s1" };
assert.equal(normalizeMcpToolArguments(args), args);
assert.deepEqual(normalizeMcpToolArguments({}), {});
});
test("tools/call with omitted arguments becomes an empty object (#3049)", () => {
const message = {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: "get_environment" },
};
const out = normalizeMcpJsonRpcMessage(message);
assert.equal(out, message);
assert.deepEqual(out.params.arguments, {});
assert.equal(out.params.name, "get_environment");
});
test("tools/call with null arguments becomes an empty object (#3049)", () => {
const message = {
method: "tools/call",
params: { name: "list_attachments", arguments: null },
};
normalizeMcpJsonRpcMessage(message);
assert.deepEqual(message.params.arguments, {});
});
test("tools/call with {} or real arguments is left alone", () => {
const empty = {};
const emptyMessage = {
method: "tools/call",
params: { name: "get_environment", arguments: empty },
};
assert.equal(normalizeMcpJsonRpcMessage(emptyMessage).params.arguments, empty);
const args = { sessionId: "s1", command: "uptime" };
const realMessage = {
method: "tools/call",
params: { name: "terminal_execute", arguments: args },
};
assert.equal(normalizeMcpJsonRpcMessage(realMessage).params.arguments, args);
});
test("non tools/call messages are unchanged", () => {
const initialize = { method: "initialize", params: { protocolVersion: "2024-11-05" } };
assert.equal(normalizeMcpJsonRpcMessage(initialize), initialize);
assert.equal(initialize.params.arguments, undefined);
const notification = { method: "notifications/initialized" };
assert.equal(normalizeMcpJsonRpcMessage(notification), notification);
});