[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:
20
electron/mcp/catalogToolMetadata.cjs
Normal file
20
electron/mcp/catalogToolMetadata.cjs
Normal 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,
|
||||
};
|
||||
52
electron/mcp/netcatty-external-mcp-server.cjs
Normal file
52
electron/mcp/netcatty-external-mcp-server.cjs
Normal 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();
|
||||
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);
|
||||
});
|
||||
27
electron/mcp/normalizeMcpCallArguments.cjs
Normal file
27
electron/mcp/normalizeMcpCallArguments.cjs
Normal 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,
|
||||
};
|
||||
66
electron/mcp/normalizeMcpCallArguments.test.cjs
Normal file
66
electron/mcp/normalizeMcpCallArguments.test.cjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user