Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 7eed27c4fd fix(mcp): reconnect external stdio bridge after Netcatty restart
Long-lived external MCP processes kept the startup port and token, so tool
calls failed after Netcatty came back on a new endpoint. Refresh discovery
on demand, coalesce reconnects, and leave in-flight RPCs rejected.
Managed children still fail fast.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 03:01:03 +00:00
2 changed files with 447 additions and 25 deletions

No files matched your search

+346
View File
@@ -0,0 +1,346 @@
"use strict";
const { test } = require("node:test");
const assert = require("node:assert/strict");
const net = require("node:net");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { Client } = require("@modelcontextprotocol/sdk/client/index.js");
const { StdioClientTransport } = require("@modelcontextprotocol/sdk/client/stdio.js");
const EXTERNAL_SERVER = path.join(__dirname, "netcatty-external-mcp-server.cjs");
const MANAGED_SERVER = path.join(__dirname, "netcatty-mcp-server.cjs");
function errorText(result) {
return (result?.content || []).map((part) => part?.text || "").join("\n");
}
function writeDiscovery(filePath, { port, token }) {
fs.writeFileSync(filePath, `${JSON.stringify({
version: 1,
host: "127.0.0.1",
port,
token,
permissionMode: "confirm",
chatSessionId: "__external_mcp__",
updatedAt: new Date().toISOString(),
})}\n`);
}
function startBridge({ token, authDelayMs = 0, onGetContext = null }) {
const calls = [];
const sockets = new Set();
let connections = 0;
const server = net.createServer((socket) => {
connections += 1;
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
let buffer = "";
socket.setEncoding("utf8");
socket.on("data", (chunk) => {
buffer += chunk;
let newlineIdx;
while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, newlineIdx);
buffer = buffer.slice(newlineIdx + 1);
if (!line.trim()) continue;
const msg = JSON.parse(line);
calls.push({
id: msg.id,
method: msg.method,
token: msg.params?.token,
});
const respond = () => {
if (msg.method === "auth/verify") {
if (msg.params?.token !== token) {
socket.write(`${JSON.stringify({
jsonrpc: "2.0",
id: msg.id,
error: { code: -32001, message: "Authentication required. Send auth/verify with valid token first." },
})}\n`);
socket.destroy();
return;
}
socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { ok: true } })}\n`);
return;
}
if (msg.method === "netcatty/getContext" && onGetContext) {
const action = onGetContext(msg, socket);
if (action === "hold-and-close") {
socket.destroy();
return;
}
}
socket.write(`${JSON.stringify({
jsonrpc: "2.0",
id: msg.id,
result: { ok: true, marker: token },
})}\n`);
};
if (msg.method === "auth/verify" && authDelayMs > 0) {
setTimeout(respond, authDelayMs);
} else {
respond();
}
}
});
});
const ready = new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
});
return {
calls,
ready,
get connections() {
return connections;
},
destroySockets() {
for (const socket of sockets) socket.destroy();
},
close() {
for (const socket of sockets) socket.destroy();
if (!server.listening) return Promise.resolve();
return new Promise((resolve) => server.close(() => resolve()));
},
};
}
async function connectClient(entry, env) {
const client = new Client({ name: "external-mcp-reconnect-test", version: "1" });
const transport = new StdioClientTransport({
command: process.execPath,
args: [entry],
env,
stderr: "pipe",
});
const stderr = [];
transport.stderr.on("data", (chunk) => stderr.push(String(chunk)));
try {
await client.connect(transport);
} catch (error) {
error.message = `${error.message}\nstderr:\n${stderr.join("")}`;
throw error;
}
return {
client,
stderr,
async close() {
await client.close();
},
};
}
function childEnv(overrides) {
const env = { ...process.env };
delete env.NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE;
delete env.NETCATTY_MCP_PORT;
delete env.NETCATTY_MCP_TOKEN;
delete env.NETCATTY_MCP_PERMISSION_MODE;
delete env.NETCATTY_MCP_CHAT_SESSION_ID;
delete env.NETCATTY_MCP_SESSION_IDS;
return { ...env, ...overrides };
}
async function callEnvironment(client) {
return client.callTool({ name: "get_environment", arguments: {} });
}
test("external stdio bridge reconnects with refreshed discovery after the app endpoint changes", async (t) => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mcp-reconnect-"));
const discoveryPath = path.join(tmp, "discovery.json");
const first = startBridge({ token: "token-first" });
const firstPort = await first.ready;
writeDiscovery(discoveryPath, { port: firstPort, token: "token-first" });
const child = await connectClient(EXTERNAL_SERVER, childEnv({
NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE: discoveryPath,
}));
t.after(async () => {
await child.close().catch(() => {});
await first.close().catch(() => {});
fs.rmSync(tmp, { recursive: true, force: true });
});
const live = await callEnvironment(child.client);
assert.equal(live.isError, undefined);
assert.match(errorText(live), /token-first/);
await first.close();
const second = startBridge({ token: "token-restarted" });
t.after(() => second.close().catch(() => {}));
const secondPort = await second.ready;
writeDiscovery(discoveryPath, { port: secondPort, token: "token-restarted" });
const recovered = await callEnvironment(child.client);
assert.notEqual(recovered.isError, true, errorText(recovered));
assert.match(errorText(recovered), /token-restarted/);
assert.deepEqual(
second.calls.map((call) => call.method),
["auth/verify", "netcatty/getContext"],
);
assert.equal(second.calls[0].token, "token-restarted");
assert.equal(second.connections, 1);
});
test("concurrent external calls share one reconnect and one re-auth", async (t) => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mcp-reconnect-"));
const discoveryPath = path.join(tmp, "discovery.json");
const first = startBridge({ token: "token-first" });
const firstPort = await first.ready;
writeDiscovery(discoveryPath, { port: firstPort, token: "token-first" });
const child = await connectClient(EXTERNAL_SERVER, childEnv({
NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE: discoveryPath,
}));
let second = null;
t.after(async () => {
await child.close().catch(() => {});
await first.close().catch(() => {});
if (second) await second.close().catch(() => {});
fs.rmSync(tmp, { recursive: true, force: true });
});
assert.notEqual((await callEnvironment(child.client)).isError, true);
await first.close();
// Hold auth open so both tool calls enter recovery before the socket exists.
second = startBridge({ token: "token-restarted", authDelayMs: 300 });
const secondPort = await second.ready;
writeDiscovery(discoveryPath, { port: secondPort, token: "token-restarted" });
const [left, right] = await Promise.all([
callEnvironment(child.client),
callEnvironment(child.client),
]);
assert.notEqual(left.isError, true, errorText(left));
assert.notEqual(right.isError, true, errorText(right));
assert.match(errorText(left), /token-restarted/);
assert.match(errorText(right), /token-restarted/);
assert.equal(second.connections, 1);
assert.equal(second.calls.filter((call) => call.method === "auth/verify").length, 1);
assert.equal(second.calls.filter((call) => call.method === "netcatty/getContext").length, 2);
assert.equal(second.calls[0].token, "token-restarted");
});
test("in-flight external calls are rejected on disconnect and are not replayed", async (t) => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mcp-reconnect-"));
const discoveryPath = path.join(tmp, "discovery.json");
let holdNext = false;
const first = startBridge({
token: "token-first",
onGetContext: () => (holdNext ? "hold-and-close" : null),
});
const firstPort = await first.ready;
writeDiscovery(discoveryPath, { port: firstPort, token: "token-first" });
const child = await connectClient(EXTERNAL_SERVER, childEnv({
NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE: discoveryPath,
}));
let second = null;
t.after(async () => {
await child.close().catch(() => {});
await first.close().catch(() => {});
if (second) await second.close().catch(() => {});
fs.rmSync(tmp, { recursive: true, force: true });
});
assert.notEqual((await callEnvironment(child.client)).isError, true);
holdNext = true;
const dropped = await callEnvironment(child.client);
assert.equal(dropped.isError, true);
assert.match(errorText(dropped), /TCP connection (closed|lost)/);
const droppedCall = first.calls.filter((call) => call.method === "netcatty/getContext").at(-1);
assert.ok(droppedCall);
await first.close();
second = startBridge({ token: "token-restarted" });
const secondPort = await second.ready;
writeDiscovery(discoveryPath, { port: secondPort, token: "token-restarted" });
await new Promise((resolve) => setTimeout(resolve, 150));
assert.equal(second.connections, 0);
const recovered = await callEnvironment(child.client);
assert.notEqual(recovered.isError, true, errorText(recovered));
assert.match(errorText(recovered), /token-restarted/);
assert.deepEqual(
second.calls.map((call) => call.method),
["auth/verify", "netcatty/getContext"],
);
assert.equal(second.calls.some((call) => call.id === droppedCall.id), false);
});
test("missing or rejected discovery stays recoverable without killing the external process", async (t) => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mcp-reconnect-"));
const discoveryPath = path.join(tmp, "discovery.json");
const first = startBridge({ token: "token-first" });
const firstPort = await first.ready;
writeDiscovery(discoveryPath, { port: firstPort, token: "token-first" });
const child = await connectClient(EXTERNAL_SERVER, childEnv({
NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE: discoveryPath,
}));
let second = null;
t.after(async () => {
await child.close().catch(() => {});
await first.close().catch(() => {});
if (second) await second.close().catch(() => {});
fs.rmSync(tmp, { recursive: true, force: true });
});
assert.notEqual((await callEnvironment(child.client)).isError, true);
await first.close();
fs.rmSync(discoveryPath, { force: true });
const missing = await callEnvironment(child.client);
assert.equal(missing.isError, true);
assert.match(errorText(missing), /temporarily unavailable|unavailable or invalid/i);
second = startBridge({ token: "token-restarted" });
const secondPort = await second.ready;
writeDiscovery(discoveryPath, { port: secondPort, token: "wrong-token" });
const rejected = await callEnvironment(child.client);
assert.equal(rejected.isError, true);
assert.match(errorText(rejected), /temporarily unavailable/i);
writeDiscovery(discoveryPath, { port: secondPort, token: "token-restarted" });
const recovered = await callEnvironment(child.client);
assert.notEqual(recovered.isError, true, `${errorText(recovered)}\n${child.stderr.join("")}`);
assert.match(errorText(recovered), /token-restarted/);
});
test("managed stdio children fail fast without rereading discovery", async (t) => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mcp-reconnect-"));
const discoveryPath = path.join(tmp, "discovery.json");
const first = startBridge({ token: "managed-token" });
const firstPort = await first.ready;
const child = await connectClient(MANAGED_SERVER, childEnv({
NETCATTY_MCP_PORT: String(firstPort),
NETCATTY_MCP_TOKEN: "managed-token",
}));
let second = null;
t.after(async () => {
await child.close().catch(() => {});
await first.close().catch(() => {});
if (second) await second.close().catch(() => {});
fs.rmSync(tmp, { recursive: true, force: true });
});
const live = await callEnvironment(child.client);
assert.notEqual(live.isError, true, errorText(live));
await first.close();
second = startBridge({ token: "token-restarted" });
const secondPort = await second.ready;
writeDiscovery(discoveryPath, { port: secondPort, token: "token-restarted" });
const started = Date.now();
const failed = await callEnvironment(child.client);
assert.ok(Date.now() - started < 1000, "managed disconnect must not wait on discovery refresh");
assert.equal(failed.isError, true);
assert.match(errorText(failed), /Not connected to Netcatty/);
assert.equal(second.connections, 0);
const stillAlive = await callEnvironment(child.client);
assert.equal(stillAlive.isError, true);
assert.match(errorText(stillAlive), /Not connected to Netcatty/);
assert.equal(second.connections, 0);
});
+101 -25
View File
@@ -38,17 +38,21 @@ function debugLog(...args) {
// ── 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);
// External stdio clients outlive Netcatty. Their port and token stay mutable so
// a later call can refresh discovery. Managed children receive a fixed env and
// keep the original fail-fast exit.
const externalDiscoveryPath = process.env.NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE;
let NETCATTY_MCP_PORT = parseInt(process.env.NETCATTY_MCP_PORT, 10);
let NETCATTY_MCP_TOKEN = process.env.NETCATTY_MCP_TOKEN || "";
if (!externalDiscoveryPath) {
if (!NETCATTY_MCP_PORT) {
process.stderr.write("[netcatty-mcp] NETCATTY_MCP_PORT not set\n");
process.exit(1);
}
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
@@ -132,6 +136,7 @@ function connectTcp() {
sock.setEncoding("utf-8");
const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10 MB
sock.on("data", (chunk) => {
if (tcpSocket !== sock) return;
tcpBuffer += chunk;
if (tcpBuffer.length > MAX_BUFFER_SIZE) {
process.stderr.write(`[netcatty-mcp] TCP buffer exceeded ${MAX_BUFFER_SIZE} bytes, clearing buffer\n`);
@@ -167,7 +172,8 @@ function connectTcp() {
sock.on("error", (err) => {
debugLog("TCP socket error", { message: err?.message || String(err) });
reject(err);
// Reject all pending
if (tcpSocket && tcpSocket !== sock) return;
// Reject in-flight calls only. They are not replayed after reconnect.
for (const { reject: rej } of pendingRequests.values()) {
rej(new Error("TCP connection lost"));
}
@@ -175,20 +181,21 @@ function connectTcp() {
});
sock.on("close", () => {
debugLog("TCP socket closed");
// Reject all pending requests on clean close
if (tcpSocket && tcpSocket !== sock) return;
for (const { reject: rej } of pendingRequests.values()) {
rej(new Error("TCP connection closed"));
}
pendingRequests.clear();
tcpSocket = null;
if (tcpSocket === sock) tcpSocket = null;
});
});
}
function rpcCall(method, params) {
function sendRpc(method, params) {
return new Promise((resolve, reject) => {
if (!tcpSocket || tcpSocket.destroyed) {
return reject(new Error("Not connected to Netcatty"));
reject(new Error("Not connected to Netcatty"));
return;
}
const id = nextRpcId++;
pendingRequests.set(id, { resolve, reject });
@@ -198,8 +205,73 @@ function rpcCall(method, params) {
});
}
function temporaryUnavailable(error) {
const message = error?.message || String(error || "connection failed");
if (message.startsWith("Netcatty is temporarily unavailable")) {
return error instanceof Error ? error : new Error(message);
}
return new Error(`Netcatty is temporarily unavailable: ${message}`);
}
function refreshExternalEndpoint() {
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(externalDiscoveryPath, "utf8"));
} catch {
throw new Error("Netcatty is temporarily unavailable: discovery file is missing or unreadable");
}
const port = parseInt(parsed?.port, 10);
const token = typeof parsed?.token === "string" ? parsed.token.trim() : "";
if (!port || !token) {
throw new Error("Netcatty is temporarily unavailable: discovery endpoint is incomplete");
}
NETCATTY_MCP_PORT = port;
NETCATTY_MCP_TOKEN = token;
}
function dropActiveSocket() {
const sock = tcpSocket;
tcpSocket = null;
tcpBuffer = "";
if (sock && !sock.destroyed) sock.destroy();
}
// One reconnect at a time. Auth uses sendRpc so callers waiting on this promise
// cannot deadlock, and calls already rejected by close/error stay rejected.
let connecting = null;
async function connectAndAuth() {
if (tcpSocket?.destroyed) tcpSocket = null;
refreshExternalEndpoint();
tcpBuffer = "";
try {
await connectTcp();
const authResult = await sendRpc("auth/verify", { token: NETCATTY_MCP_TOKEN });
if (!authResult?.ok) {
throw new Error("Netcatty is temporarily unavailable: TCP bridge authentication failed");
}
} catch (error) {
dropActiveSocket();
throw temporaryUnavailable(error);
}
process.stderr.write("[netcatty-mcp] Authenticated with TCP bridge\n");
}
function ensureConnected() {
if (tcpSocket && !tcpSocket.destroyed) return Promise.resolve();
if (connecting) return connecting;
connecting = connectAndAuth().finally(() => {
connecting = null;
});
return connecting;
}
function rpcCall(method, params) {
if (!externalDiscoveryPath) return sendRpc(method, params);
return ensureConnected().then(() => sendRpc(method, params));
}
// External clients own connection maps here; managed agents use the main-process chat boundary.
const externalDiscoveryPath = process.env.NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE;
const exposedRpcCall = externalDiscoveryPath
? createPrivacyBoundary(rpcCall, () => {
const discovery = JSON.parse(fs.readFileSync(externalDiscoveryPath, "utf8"));
@@ -258,16 +330,20 @@ async function main() {
scopedSessionIds: SCOPED_SESSION_IDS,
chatSessionId: CHAT_SESSION_ID,
permissionMode: readPermissionMode(),
externalDiscovery: Boolean(externalDiscoveryPath),
});
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");
if (externalDiscoveryPath) {
await ensureConnected();
} else {
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");
}
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.