@datafrog-io/n2n-nexus 0.3.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/build/config/index.js +17 -7
- package/build/constants.js +1 -1
- package/build/index.js +294 -103
- package/build/network/election.js +75 -16
- package/build/network/guest.js +245 -92
- package/build/network/index.js +1 -1
- package/build/resources/index.js +1 -0
- package/build/tools/definitions.js +12 -0
- package/build/tools/handlers.js +32 -1
- package/build/tools/schemas.js +11 -0
- package/docs/ARCHITECTURE.md +15 -0
- package/docs/ARCHITECTURE_zh.md +15 -0
- package/docs/ASSISTANT_GUIDE.md +1 -1
- package/docs/CHANGELOG_zh.md +13 -0
- package/docs/README_zh.md +3 -3
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -92,9 +92,9 @@
|
|
|
92
92
|
```
|
|
93
93
|
|
|
94
94
|
- **Zero Config**: Just run `npx @datafrog-io/n2n-nexus` - no `--id` or `--host` required.
|
|
95
|
-
- **
|
|
96
|
-
- **
|
|
97
|
-
- **Hot Failover**: If Host disconnects, a Guest automatically promotes
|
|
95
|
+
- **Immediate Handshake**: Stdio connects instantly (<10ms) for static requests (`tools/list`), buffering dynamic requests until election completes.
|
|
96
|
+
- **Parallel Election**: Concurrent port scanning ensures Host/Guest resolution in <300ms.
|
|
97
|
+
- **Hot Failover**: If Host disconnects, a Guest automatically promotes itself to Host and others reconnect.
|
|
98
98
|
|
|
99
99
|
## 🚀 Quick Start
|
|
100
100
|
|
package/build/config/index.js
CHANGED
|
@@ -7,7 +7,6 @@ import { fileURLToPath } from "url";
|
|
|
7
7
|
import { FILE_ENCODING, PACKAGE_JSON } from "../constants.js";
|
|
8
8
|
import { getArg, hasFlag } from "./cli.js";
|
|
9
9
|
import { getRootPath } from "./paths.js";
|
|
10
|
-
import { isHostAutoElection } from "../network/election.js";
|
|
11
10
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
11
|
// Load version from package.json
|
|
13
12
|
const pkgPath = path.resolve(__dirname, `../../${PACKAGE_JSON}`);
|
|
@@ -66,16 +65,27 @@ function getAutoProjectName() {
|
|
|
66
65
|
const suffix = Math.random().toString(36).substring(2, 6);
|
|
67
66
|
return `${base}-${suffix}`;
|
|
68
67
|
}
|
|
69
|
-
// Run election at module load
|
|
68
|
+
// Run election at module load - REMOVED for Online First architecture
|
|
69
|
+
// const rootPath = getRootPath();
|
|
70
|
+
// const election = await isHostAutoElection(rootPath);
|
|
71
|
+
// const projectName = getAutoProjectName();
|
|
72
|
+
// export const hostServer = election.server;
|
|
73
|
+
// Mutable Config for Online First
|
|
70
74
|
const rootPath = getRootPath();
|
|
71
|
-
const election = await isHostAutoElection(rootPath);
|
|
72
75
|
const projectName = getAutoProjectName();
|
|
73
|
-
export const hostServer = election.server;
|
|
74
76
|
export const CONFIG = {
|
|
75
77
|
instanceId: getArg("--id") || projectName,
|
|
76
|
-
isHost:
|
|
77
|
-
rootStorage:
|
|
78
|
-
port: election
|
|
78
|
+
isHost: false, // Default to Guest until elected
|
|
79
|
+
rootStorage: rootPath, // Default to local until updated
|
|
80
|
+
port: 0 // Will be set after election
|
|
79
81
|
};
|
|
82
|
+
// Export mutable hostServer container
|
|
83
|
+
export let hostServer;
|
|
84
|
+
export function updateConfig(update) {
|
|
85
|
+
Object.assign(CONFIG, update);
|
|
86
|
+
}
|
|
87
|
+
export function setHostServer(server) {
|
|
88
|
+
hostServer = server;
|
|
89
|
+
}
|
|
80
90
|
// Re-export for Guest reconnection
|
|
81
91
|
export { isHostAutoElection } from "../network/election.js";
|
package/build/constants.js
CHANGED
|
@@ -13,7 +13,7 @@ export const NEXUS_HOST = "0.0.0.0";
|
|
|
13
13
|
export const PORT_RANGE_START = 5688;
|
|
14
14
|
export const PORT_RANGE_END = 5800;
|
|
15
15
|
// Timeouts (milliseconds)
|
|
16
|
-
export const HANDSHAKE_TIMEOUT =
|
|
16
|
+
export const HANDSHAKE_TIMEOUT = 200;
|
|
17
17
|
export const HEARTBEAT_INTERVAL = 30000;
|
|
18
18
|
// Task cleanup
|
|
19
19
|
export const TASK_CLEANUP_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
package/build/index.js
CHANGED
|
@@ -3,98 +3,54 @@
|
|
|
3
3
|
* n2ns Nexus: Unified Project Asset & Collaboration Hub
|
|
4
4
|
*
|
|
5
5
|
* Modular MCP Server for multi-AI assistant coordination.
|
|
6
|
+
* Refactored for Robust Host-Guest Architecture (v2).
|
|
6
7
|
*/
|
|
7
8
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
9
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
10
|
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ErrorCode, McpError, } from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
-
import { CONFIG,
|
|
11
|
+
import { CONFIG, pkg, updateConfig, setHostServer, isHostAutoElection } from "./config/index.js";
|
|
10
12
|
import { StorageManager } from "./storage/index.js";
|
|
11
13
|
import { TOOL_DEFINITIONS, handleToolCall } from "./tools/index.js";
|
|
12
14
|
import { listResources, getResourceContent } from "./resources/index.js";
|
|
13
15
|
import { sanitizeErrorMessage } from "./utils/error.js";
|
|
14
16
|
import { checkHostPermission } from "./utils/auth.js";
|
|
15
17
|
import { SERVICE_NAME } from "./constants.js";
|
|
16
|
-
import { startHost
|
|
18
|
+
import { startHost } from "./network/index.js";
|
|
17
19
|
class NexusServer {
|
|
18
20
|
server;
|
|
19
21
|
currentProject = null;
|
|
20
22
|
sseTransports = new Map();
|
|
23
|
+
// Election State
|
|
24
|
+
isElectionDone = false;
|
|
25
|
+
role = "PENDING";
|
|
26
|
+
requestBuffer = [];
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
28
|
+
guestClient = null; // Will be set if Guest
|
|
21
29
|
constructor() {
|
|
22
30
|
this.server = new Server({ name: SERVICE_NAME, version: pkg.version }, { capabilities: { resources: {}, tools: {}, prompts: {} } });
|
|
23
31
|
this.setupHandlers();
|
|
24
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* "Static Pass-through" Handlers
|
|
35
|
+
* These handlers are registered immediately and can respond BEFORE election.
|
|
36
|
+
* Dynamic requests are buffered.
|
|
37
|
+
*/
|
|
25
38
|
setupHandlers() {
|
|
26
|
-
// ---
|
|
27
|
-
this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
28
|
-
try {
|
|
29
|
-
return await listResources();
|
|
30
|
-
}
|
|
31
|
-
catch (error) {
|
|
32
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
33
|
-
throw new McpError(ErrorCode.InternalError, `Nexus Registry Error: ${sanitizeErrorMessage(msg)}`);
|
|
34
|
-
}
|
|
35
|
-
});
|
|
36
|
-
// --- Resource Reading ---
|
|
37
|
-
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
38
|
-
try {
|
|
39
|
-
const result = await getResourceContent(request.params.uri, this.currentProject);
|
|
40
|
-
if (!result) {
|
|
41
|
-
throw new McpError(ErrorCode.InvalidRequest, `Resource not found: ${request.params.uri}`);
|
|
42
|
-
}
|
|
43
|
-
return { contents: [{ uri: request.params.uri, ...result }] };
|
|
44
|
-
}
|
|
45
|
-
catch (error) {
|
|
46
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
47
|
-
throw new McpError(ErrorCode.InternalError, `Nexus Read Error: ${sanitizeErrorMessage(msg)}`);
|
|
48
|
-
}
|
|
49
|
-
});
|
|
50
|
-
// --- Tool Listing ---
|
|
39
|
+
// --- 1. Static: Tool Listing (SAFE TO REPLY IMMEDIATELY) ---
|
|
51
40
|
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
52
41
|
return { tools: TOOL_DEFINITIONS };
|
|
53
42
|
});
|
|
54
|
-
// ---
|
|
55
|
-
this.server.setRequestHandler(
|
|
56
|
-
const agentId = CONFIG.instanceId;
|
|
43
|
+
// --- 2. Static: Resource Listing (SAFE TO REPLY IMMEDIATELY) ---
|
|
44
|
+
this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
57
45
|
try {
|
|
58
|
-
|
|
59
|
-
if (request.params.name === "switch_project") {
|
|
60
|
-
const args = request.params.arguments;
|
|
61
|
-
if (args.project_id) {
|
|
62
|
-
const manifest = await StorageManager.getProjectManifest(args.project_id);
|
|
63
|
-
if (manifest) {
|
|
64
|
-
this.currentProject = args.project_id;
|
|
65
|
-
return { content: [{ type: "text", text: `Switched to project: ${args.project_id}` }] };
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
return { content: [{ type: "text", text: `Project '${args.project_id}' not found.` }] };
|
|
69
|
-
}
|
|
70
|
-
// Host permission check for privileged tools
|
|
71
|
-
const hostOnlyTools = ["delete_project", "rename_project", "clear_global_logs", "archive_meeting"];
|
|
72
|
-
if (hostOnlyTools.includes(request.params.name)) {
|
|
73
|
-
try {
|
|
74
|
-
checkHostPermission(request.params.name);
|
|
75
|
-
}
|
|
76
|
-
catch {
|
|
77
|
-
return { content: [{ type: "text", text: `[Permission Denied] Tool '${request.params.name}' requires Host privileges.` }] };
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
// Delegate to tool handler
|
|
81
|
-
const ctx = {
|
|
82
|
-
currentProject: this.currentProject,
|
|
83
|
-
setCurrentProject: (id) => { this.currentProject = id; },
|
|
84
|
-
notifyResourceUpdate: (_uri) => { }
|
|
85
|
-
};
|
|
86
|
-
const result = await handleToolCall(request.params.name, request.params.arguments, ctx);
|
|
87
|
-
return result;
|
|
46
|
+
return await listResources();
|
|
88
47
|
}
|
|
89
48
|
catch (error) {
|
|
90
49
|
const msg = error instanceof Error ? error.message : String(error);
|
|
91
|
-
|
|
92
|
-
content: [{ type: "text", text: sanitizeErrorMessage(`Tool Error: ${msg}`) }],
|
|
93
|
-
isError: true
|
|
94
|
-
};
|
|
50
|
+
throw new McpError(ErrorCode.InternalError, `Nexus Registry Error: ${sanitizeErrorMessage(msg)}`);
|
|
95
51
|
}
|
|
96
52
|
});
|
|
97
|
-
// --- Prompt Listing ---
|
|
53
|
+
// --- 3. Static: Prompt Listing ---
|
|
98
54
|
this.server.setRequestHandler(ListPromptsRequestSchema, async () => {
|
|
99
55
|
return {
|
|
100
56
|
prompts: [
|
|
@@ -105,64 +61,299 @@ class NexusServer {
|
|
|
105
61
|
]
|
|
106
62
|
};
|
|
107
63
|
});
|
|
108
|
-
// ---
|
|
64
|
+
// --- 4. Dynamic: Tool Calling (BUFFERED) ---
|
|
65
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
66
|
+
// Attempt to retrieve Request ID from extra info or cast request
|
|
67
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
68
|
+
const reqId = request.id || extra?.id || `req-${Date.now()}`;
|
|
69
|
+
if (!this.isElectionDone) {
|
|
70
|
+
// Buffer!
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
this.requestBuffer.push({
|
|
73
|
+
method: "tools/call",
|
|
74
|
+
params: request.params,
|
|
75
|
+
requestId: reqId,
|
|
76
|
+
resolve,
|
|
77
|
+
reject
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// Route based on Role
|
|
82
|
+
if (this.role === "HOST") {
|
|
83
|
+
return this.handleHostToolCall(request.params, reqId);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
return this.forwardToHost("tools/call", request.params);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
// --- 5. Dynamic: Read Resource (BUFFERED) ---
|
|
90
|
+
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
91
|
+
if (!this.isElectionDone) {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
this.requestBuffer.push({
|
|
94
|
+
method: "resources/read",
|
|
95
|
+
params: request.params,
|
|
96
|
+
resolve,
|
|
97
|
+
reject
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (this.role === "HOST") {
|
|
102
|
+
return this.handleHostReadResource(request.params);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
return this.forwardToHost("resources/read", request.params);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
// --- 6. Dynamic: Get Prompt (BUFFERED) ---
|
|
109
109
|
this.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
110
|
+
if (!this.isElectionDone) {
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
this.requestBuffer.push({
|
|
113
|
+
method: "prompts/get",
|
|
114
|
+
params: request.params,
|
|
115
|
+
resolve,
|
|
116
|
+
reject
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (this.role === "HOST") {
|
|
121
|
+
// Local logic for prompt
|
|
122
|
+
if (request.params.name === "nexus_status") {
|
|
123
|
+
const registry = await StorageManager.listRegistry();
|
|
124
|
+
const projectCount = Object.keys(registry.projects).length;
|
|
125
|
+
const logs = await StorageManager.getRecentLogs(5);
|
|
126
|
+
return {
|
|
127
|
+
messages: [{
|
|
128
|
+
role: "user",
|
|
129
|
+
content: {
|
|
130
|
+
type: "text",
|
|
131
|
+
text: `Nexus Hub Status:
|
|
132
|
+
- Role: Host
|
|
121
133
|
- Instance: ${CONFIG.instanceId}
|
|
122
134
|
- Port: ${CONFIG.port}
|
|
123
135
|
- Active Projects: ${projectCount}
|
|
124
136
|
- Recent Activity: ${logs.length} entries`
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
137
|
+
}
|
|
138
|
+
}]
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
throw new McpError(ErrorCode.InvalidRequest, `Prompt not found: ${request.params.name}`);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
return this.forwardToHost("prompts/get", request.params);
|
|
128
145
|
}
|
|
129
|
-
throw new McpError(ErrorCode.InvalidRequest, `Prompt not found: ${request.params.name}`);
|
|
130
146
|
});
|
|
131
147
|
}
|
|
148
|
+
// --- Host Logic Implementation ---
|
|
149
|
+
async handleHostReadResource(params) {
|
|
150
|
+
try {
|
|
151
|
+
const result = await getResourceContent(params.uri, this.currentProject);
|
|
152
|
+
if (!result) {
|
|
153
|
+
throw new McpError(ErrorCode.InvalidRequest, `Resource not found: ${params.uri}`);
|
|
154
|
+
}
|
|
155
|
+
return { contents: [{ uri: params.uri, ...result }] };
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
159
|
+
throw new McpError(ErrorCode.InternalError, `Nexus Read Error: ${sanitizeErrorMessage(msg)}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async handleHostToolCall(params, requestId) {
|
|
163
|
+
try {
|
|
164
|
+
// Special handling for switch_project
|
|
165
|
+
if (params.name === "switch_project") {
|
|
166
|
+
const args = params.arguments;
|
|
167
|
+
if (args.project_id) {
|
|
168
|
+
const manifest = await StorageManager.getProjectManifest(args.project_id);
|
|
169
|
+
if (manifest) {
|
|
170
|
+
this.currentProject = args.project_id;
|
|
171
|
+
return { content: [{ type: "text", text: `Switched to project: ${args.project_id}` }] };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return { content: [{ type: "text", text: `Project '${args.project_id}' not found.` }] };
|
|
175
|
+
}
|
|
176
|
+
// Host permission check
|
|
177
|
+
const hostOnlyTools = ["delete_project", "rename_project", "clear_global_logs", "archive_meeting"];
|
|
178
|
+
if (hostOnlyTools.includes(params.name)) {
|
|
179
|
+
try {
|
|
180
|
+
checkHostPermission(params.name);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return { content: [{ type: "text", text: `[Permission Denied] Tool '${params.name}' requires Host privileges.` }] };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const ctx = {
|
|
187
|
+
currentProject: this.currentProject,
|
|
188
|
+
setCurrentProject: (id) => { this.currentProject = id; },
|
|
189
|
+
notifyResourceUpdate: (_uri) => {
|
|
190
|
+
// TODO: Notify all clients via SSE
|
|
191
|
+
},
|
|
192
|
+
requestId // Pass the ID to the context
|
|
193
|
+
};
|
|
194
|
+
const result = await handleToolCall(params.name, params.arguments, ctx);
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
199
|
+
const idPrefix = requestId ? `[Req:${requestId}] ` : "";
|
|
200
|
+
// We return a text response for tool errors to maintain conversation flow, but flag it as error
|
|
201
|
+
return {
|
|
202
|
+
content: [{ type: "text", text: sanitizeErrorMessage(`${idPrefix}Tool Error: ${msg}`) }],
|
|
203
|
+
isError: true
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// --- Guest Logic Implementation ---
|
|
208
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
209
|
+
async forwardToHost(method, params) {
|
|
210
|
+
if (!this.guestClient)
|
|
211
|
+
throw new Error("Guest Client not initialized");
|
|
212
|
+
// Delegate to the Guest Client to making the HTTP call
|
|
213
|
+
return this.guestClient.sendRequest(method, params);
|
|
214
|
+
}
|
|
215
|
+
// --- Lifecycle ---
|
|
216
|
+
async run() {
|
|
217
|
+
this.setupShutdownHandlers();
|
|
218
|
+
// 1. IMMEDIATE: Connect Stdio (Handshake Ready)
|
|
219
|
+
const transport = new StdioServerTransport();
|
|
220
|
+
await this.server.connect(transport);
|
|
221
|
+
console.error(`[Nexus] Stdio Connected (Pending Election)...`);
|
|
222
|
+
// 2. Start Election (Parallel)
|
|
223
|
+
isHostAutoElection(CONFIG.rootStorage).then(async (election) => {
|
|
224
|
+
console.error(`[Nexus] Election Finished. Role: ${election.isHost ? "HOST" : "GUEST"}`);
|
|
225
|
+
this.role = election.isHost ? "HOST" : "GUEST";
|
|
226
|
+
this.isElectionDone = true;
|
|
227
|
+
// Global Config Update
|
|
228
|
+
updateConfig({
|
|
229
|
+
isHost: election.isHost,
|
|
230
|
+
port: election.port,
|
|
231
|
+
rootStorage: election.isHost ? CONFIG.rootStorage : (election.rootStorage || CONFIG.rootStorage)
|
|
232
|
+
});
|
|
233
|
+
if (election.isHost && election.server) {
|
|
234
|
+
// --- HOST MODE ---
|
|
235
|
+
if (election.server)
|
|
236
|
+
setHostServer(election.server);
|
|
237
|
+
await startHost(election.server, {
|
|
238
|
+
config: CONFIG,
|
|
239
|
+
pkg,
|
|
240
|
+
mcpServer: this.server,
|
|
241
|
+
sseTransports: this.sseTransports
|
|
242
|
+
});
|
|
243
|
+
// Flush Buffer (Process locally)
|
|
244
|
+
this.flushBufferAsHost();
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
// --- GUEST MODE ---
|
|
248
|
+
// We do NOT close the Stdio server. We keep it to receive requests from IDE.
|
|
249
|
+
// But we start the Guest Client to forward those requests.
|
|
250
|
+
const { createGuestClient } = await import("./network/guest.js");
|
|
251
|
+
this.guestClient = createGuestClient(election.port, CONFIG.instanceId, () => {
|
|
252
|
+
this.handleReElection();
|
|
253
|
+
});
|
|
254
|
+
// Flush Buffer (Forward to Host)
|
|
255
|
+
this.flushBufferAsGuest();
|
|
256
|
+
}
|
|
257
|
+
}).catch(err => {
|
|
258
|
+
console.error(`[Nexus CRITICAL] Election failed:`, err);
|
|
259
|
+
// In case of failure, reject all buffered requests
|
|
260
|
+
this.requestBuffer.forEach(req => req.reject(new Error("Nexus Startup Failed")));
|
|
261
|
+
process.exit(1);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
flushBufferAsHost() {
|
|
265
|
+
console.error(`[Nexus] Flushing ${this.requestBuffer.length} buffered requests (Local)...`);
|
|
266
|
+
while (this.requestBuffer.length > 0) {
|
|
267
|
+
const req = this.requestBuffer.shift();
|
|
268
|
+
if (!req)
|
|
269
|
+
break;
|
|
270
|
+
// Re-route to local handlers based on method
|
|
271
|
+
if (req.method === "tools/call") {
|
|
272
|
+
this.handleHostToolCall(req.params, req.requestId).then(req.resolve).catch(req.reject);
|
|
273
|
+
}
|
|
274
|
+
else if (req.method === "resources/read") {
|
|
275
|
+
this.handleHostReadResource(req.params).then(req.resolve).catch(req.reject);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
// Generic handling or error
|
|
279
|
+
req.reject(new Error(`Unknown buffered method: ${req.method}`));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
flushBufferAsGuest() {
|
|
284
|
+
console.error(`[Nexus] Flushing ${this.requestBuffer.length} buffered requests (Remote)...`);
|
|
285
|
+
while (this.requestBuffer.length > 0) {
|
|
286
|
+
const req = this.requestBuffer.shift();
|
|
287
|
+
if (!req)
|
|
288
|
+
break;
|
|
289
|
+
this.forwardToHost(req.method, req.params).then(req.resolve).catch(req.reject);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
132
292
|
setupShutdownHandlers() {
|
|
133
293
|
const shutdown = async (signal) => {
|
|
134
294
|
console.error(`\n[Nexus] Received ${signal}. Shutting down...`);
|
|
135
295
|
try {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
296
|
+
if (this.role === "HOST") {
|
|
297
|
+
await StorageManager.addGlobalLog(`SYSTEM:${CONFIG.instanceId}`, `Nexus Host Terminated (${signal}).`, "UPDATE");
|
|
298
|
+
}
|
|
299
|
+
if (this.guestClient && typeof this.guestClient.close === 'function') {
|
|
300
|
+
this.guestClient.close();
|
|
301
|
+
}
|
|
139
302
|
}
|
|
140
303
|
catch { /* ignore */ }
|
|
141
304
|
process.exit(0);
|
|
142
305
|
};
|
|
143
|
-
process.on("uncaughtException", (err) => {
|
|
144
|
-
console.error("[Nexus CRITICAL] Uncaught Exception:", err);
|
|
145
|
-
});
|
|
146
|
-
process.on("unhandledRejection", (reason, promise) => {
|
|
147
|
-
console.error("[Nexus WARNING] Unhandled Rejection at:", promise, "reason:", reason);
|
|
148
|
-
});
|
|
149
306
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
150
307
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
151
308
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
await startHost(hostServer, context);
|
|
162
|
-
}
|
|
163
|
-
else {
|
|
164
|
-
await startGuest(CONFIG.port, context);
|
|
309
|
+
handleReElection() {
|
|
310
|
+
console.error(`[Nexus] Host Unreachable! Triggering Auto-Re-Election...`);
|
|
311
|
+
// 1. Reset State
|
|
312
|
+
this.role = "PENDING";
|
|
313
|
+
this.isElectionDone = false;
|
|
314
|
+
if (this.guestClient) {
|
|
315
|
+
if (typeof this.guestClient.close === 'function')
|
|
316
|
+
this.guestClient.close();
|
|
317
|
+
this.guestClient = null;
|
|
165
318
|
}
|
|
319
|
+
// 2. Retry Election
|
|
320
|
+
// Use a short random delay to avoid collision if multiple Guests retry at once
|
|
321
|
+
const delay = Math.floor(Math.random() * 500) + 200;
|
|
322
|
+
setTimeout(() => {
|
|
323
|
+
// 3. Re-run startup logic (simplified inline version of run() election part)
|
|
324
|
+
isHostAutoElection(CONFIG.rootStorage).then(async (election) => {
|
|
325
|
+
console.error(`[Nexus] Re-Election Finished. New Role: ${election.isHost ? "HOST" : "GUEST"}`);
|
|
326
|
+
this.role = election.isHost ? "HOST" : "GUEST";
|
|
327
|
+
this.isElectionDone = true;
|
|
328
|
+
updateConfig({
|
|
329
|
+
isHost: election.isHost,
|
|
330
|
+
port: election.port,
|
|
331
|
+
rootStorage: election.isHost ? CONFIG.rootStorage : (election.rootStorage || CONFIG.rootStorage)
|
|
332
|
+
});
|
|
333
|
+
if (election.isHost && election.server) {
|
|
334
|
+
if (election.server)
|
|
335
|
+
setHostServer(election.server);
|
|
336
|
+
await startHost(election.server, {
|
|
337
|
+
config: CONFIG,
|
|
338
|
+
pkg,
|
|
339
|
+
mcpServer: this.server,
|
|
340
|
+
sseTransports: this.sseTransports
|
|
341
|
+
});
|
|
342
|
+
this.flushBufferAsHost();
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
const { createGuestClient } = await import("./network/guest.js");
|
|
346
|
+
this.guestClient = createGuestClient(election.port, CONFIG.instanceId, () => {
|
|
347
|
+
this.handleReElection();
|
|
348
|
+
});
|
|
349
|
+
this.flushBufferAsGuest();
|
|
350
|
+
}
|
|
351
|
+
}).catch(err => {
|
|
352
|
+
console.error("[Nexus] Re-Election Failed:", err);
|
|
353
|
+
// Retry again? Or just die? Let's retry indefinitely for now.
|
|
354
|
+
setTimeout(() => this.handleReElection(), 2000);
|
|
355
|
+
});
|
|
356
|
+
}, delay);
|
|
166
357
|
}
|
|
167
358
|
}
|
|
168
359
|
const server = new NexusServer();
|
|
@@ -62,33 +62,92 @@ export async function probeHost(port, myId) {
|
|
|
62
62
|
* 1. Try to bind → Success → I am Host
|
|
63
63
|
* 2. Bind fails → Try handshake → Success → I am Guest
|
|
64
64
|
* 3. Handshake fails → Port occupied by non-Nexus → Next port
|
|
65
|
+
*
|
|
66
|
+
* Optimized for speed: Fast-fail on bind, short timeout on handshake.
|
|
67
|
+
*/
|
|
68
|
+
/**
|
|
69
|
+
* Automatic Host Election
|
|
70
|
+
*
|
|
71
|
+
* Strategy:
|
|
72
|
+
* 1. Parallel Scan (0-200ms): Race to bind or handshake on the first batch of ports.
|
|
73
|
+
* 2. Sequential Scan: If all preferred ports are busy/zombie, try remaining ports.
|
|
74
|
+
* 3. Fallback: Bind to port 0 (OS assigned) to guarantee isolation.
|
|
65
75
|
*/
|
|
66
76
|
export async function isHostAutoElection(_root, blacklistPorts = []) {
|
|
67
77
|
const startPort = PORT_RANGE_START;
|
|
68
78
|
const endPort = PORT_RANGE_END;
|
|
69
79
|
const myId = getArg("--id") || `node-${Math.random().toString(36).substring(2, 6)}`;
|
|
70
|
-
|
|
80
|
+
// 1. Parallel Scan of first 5 ports (High Probability Zone)
|
|
81
|
+
const BATCH_SIZE = 5;
|
|
82
|
+
const batchEnd = Math.min(startPort + BATCH_SIZE, endPort);
|
|
83
|
+
const checks = [];
|
|
84
|
+
for (let port = startPort; port < batchEnd; port++) {
|
|
71
85
|
if (blacklistPorts.includes(port))
|
|
72
86
|
continue;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
87
|
+
checks.push(checkPort(port, myId));
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
// Wait for all checks to complete, then pick the first valid result (Win-Win)
|
|
91
|
+
const results = await Promise.all(checks);
|
|
92
|
+
// Priority: Prefer Host (Bind Success) over Guest (Existing Nexus) if both happen?
|
|
93
|
+
// Actually, 'checkPort' tries Bind FIRST, then Handshake.
|
|
94
|
+
// So if we get a result from checkPort, it's a definitive state for that port.
|
|
95
|
+
// We pick the first non-null result based on port order (implicitly by array index if we iterated, but here we scan).
|
|
96
|
+
const winner = results.find(r => r !== null);
|
|
97
|
+
if (winner) {
|
|
98
|
+
return winner;
|
|
82
99
|
}
|
|
83
|
-
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Continue to sequential
|
|
103
|
+
}
|
|
104
|
+
// 2. Sequential Scan for the rest (Low Probability)
|
|
105
|
+
for (let port = batchEnd; port <= endPort; port++) {
|
|
106
|
+
if (blacklistPorts.includes(port))
|
|
107
|
+
continue;
|
|
108
|
+
const result = await checkPort(port, myId);
|
|
109
|
+
if (result)
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
// 3. Fallback: Isolated Host
|
|
113
|
+
// 3. Fallback: Isolated Host
|
|
114
|
+
console.error(`[Nexus] Preferred ports ${startPort}-${endPort} busy. Starting isolated Host...`);
|
|
115
|
+
const fallbackServer = http.createServer();
|
|
116
|
+
const fallbackPort = await new Promise((resolve, reject) => {
|
|
117
|
+
fallbackServer.listen(0, NEXUS_HOST, () => {
|
|
118
|
+
const addr = fallbackServer.address();
|
|
119
|
+
if (addr && typeof addr !== 'string')
|
|
120
|
+
resolve(addr.port);
|
|
121
|
+
else
|
|
122
|
+
reject("Failed to bind fallback port");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
return { isHost: true, port: fallbackPort, server: fallbackServer };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Atomic Port Check:
|
|
129
|
+
* Returns result if we successfully Bind (Host) or Handshake (Guest).
|
|
130
|
+
* Returns null if port is busy with non-Nexus service.
|
|
131
|
+
*/
|
|
132
|
+
async function checkPort(port, myId) {
|
|
133
|
+
// A. Try Bind
|
|
134
|
+
const bindResult = await new Promise((resolve) => {
|
|
135
|
+
const server = http.createServer();
|
|
136
|
+
server.on("error", () => resolve({ success: false }));
|
|
137
|
+
server.listen(port, NEXUS_HOST, () => resolve({ success: true, server }));
|
|
138
|
+
});
|
|
139
|
+
if (bindResult.success) {
|
|
140
|
+
return { isHost: true, port, server: bindResult.server };
|
|
141
|
+
}
|
|
142
|
+
// B. Try Handshake (if bind failed)
|
|
143
|
+
try {
|
|
84
144
|
const probe = await probeHost(port, myId);
|
|
85
145
|
if (probe.isNexus) {
|
|
86
|
-
// Handshake success → I am Guest
|
|
87
146
|
return { isHost: false, port, rootStorage: probe.rootStorage };
|
|
88
147
|
}
|
|
89
|
-
// 3. Handshake failed → Port occupied by non-Nexus → Continue
|
|
90
148
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
149
|
+
catch {
|
|
150
|
+
// Handshake failed/timeout
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
94
153
|
}
|
package/build/network/guest.js
CHANGED
|
@@ -1,110 +1,263 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Guest Mode Client
|
|
3
|
-
*
|
|
4
|
-
* Connects to a Host server via SSE and proxies stdio.
|
|
5
|
-
*/
|
|
6
1
|
import http from "http";
|
|
7
2
|
import { NEXUS_HOST } from "../constants.js";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
3
|
+
export class GuestClient {
|
|
4
|
+
targetPort;
|
|
5
|
+
guestId;
|
|
6
|
+
sessionId = null;
|
|
7
|
+
requestQueue = [];
|
|
8
|
+
pendingRequests = new Map();
|
|
9
|
+
isConnected = false;
|
|
10
|
+
reconnectTimer = null;
|
|
11
|
+
sseRequest = null;
|
|
12
|
+
onReElectionNeeded = null;
|
|
13
|
+
connectionFailures = 0;
|
|
14
|
+
constructor(targetPort, guestId, onReElectionNeeded) {
|
|
15
|
+
this.targetPort = targetPort;
|
|
16
|
+
this.guestId = guestId;
|
|
17
|
+
this.onReElectionNeeded = onReElectionNeeded || null;
|
|
18
|
+
this.start();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Public API to send a request (buffered if not connected)
|
|
22
|
+
*/
|
|
23
|
+
async sendRequest(method, params) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
if (this.sessionId) {
|
|
26
|
+
this.doPost(method, params, resolve, reject);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
this.requestQueue.push({ method, params, resolve, reject });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Terminate the client (useful for testing/shutdown)
|
|
35
|
+
*/
|
|
36
|
+
close() {
|
|
37
|
+
this.isConnected = false;
|
|
38
|
+
if (this.reconnectTimer) {
|
|
39
|
+
clearTimeout(this.reconnectTimer);
|
|
40
|
+
this.reconnectTimer = null;
|
|
30
41
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
42
|
+
this.pendingRequests.forEach(p => p.reject(new Error("GuestClient closed")));
|
|
43
|
+
this.pendingRequests.clear();
|
|
44
|
+
if (this.sseRequest) {
|
|
45
|
+
this.sseRequest.destroy();
|
|
46
|
+
this.sseRequest = null;
|
|
36
47
|
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
let sessionId = null;
|
|
42
|
-
let pendingStdin = [];
|
|
43
|
-
let sseBuffer = "";
|
|
44
|
-
// Client connection should use 127.0.0.1 if host is 0.0.0.0
|
|
48
|
+
}
|
|
49
|
+
start() {
|
|
50
|
+
console.error(`[Nexus:${this.guestId}] Connecting to Host at ${this.targetPort}...`);
|
|
51
|
+
// Connect SSE
|
|
45
52
|
const connectHost = NEXUS_HOST === "0.0.0.0" ? "127.0.0.1" : NEXUS_HOST;
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
const options = {
|
|
54
|
+
hostname: connectHost,
|
|
55
|
+
port: this.targetPort,
|
|
56
|
+
path: `/mcp?id=${encodeURIComponent(this.guestId)}`,
|
|
57
|
+
method: "GET",
|
|
58
|
+
headers: {
|
|
59
|
+
"Accept": "text/event-stream",
|
|
60
|
+
"Connection": "keep-alive"
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const req = http.request(options, (res) => {
|
|
64
|
+
if (res.statusCode !== 200) {
|
|
65
|
+
console.error(`[Nexus Guest] Handshake failed: ${res.statusCode}`);
|
|
66
|
+
this.scheduleReconnect(true); // Treat as failure
|
|
49
67
|
return;
|
|
50
68
|
}
|
|
69
|
+
this.isConnected = true;
|
|
70
|
+
this.connectionFailures = 0; // Reset counter
|
|
71
|
+
// Setup robust stream reading
|
|
72
|
+
const state = { buffer: "" };
|
|
73
|
+
res.on("data", (chunk) => this.handleSSEChunk(chunk.toString(), state));
|
|
74
|
+
res.on("end", () => {
|
|
75
|
+
console.error("[Nexus Guest] Connection closed (end) by Host.");
|
|
76
|
+
this.isConnected = false;
|
|
77
|
+
this.scheduleReconnect(true);
|
|
78
|
+
});
|
|
79
|
+
res.on("close", () => {
|
|
80
|
+
if (this.isConnected) {
|
|
81
|
+
console.error("[Nexus Guest] Connection closed (close) by Host.");
|
|
82
|
+
this.isConnected = false;
|
|
83
|
+
this.scheduleReconnect(true);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
req.on("close", () => {
|
|
88
|
+
if (this.isConnected) {
|
|
89
|
+
console.error("[Nexus Guest] Request closed.");
|
|
90
|
+
this.isConnected = false;
|
|
91
|
+
this.scheduleReconnect(true);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
req.on("error", (err) => {
|
|
95
|
+
const code = err.code;
|
|
96
|
+
console.error(`[Nexus Guest] Connect error: ${err.message} (Code: ${code})`);
|
|
97
|
+
this.isConnected = false;
|
|
98
|
+
// Check for connection refusal (Host down) or Reset
|
|
99
|
+
if (code === 'ECONNREFUSED' || code === 'ECONNRESET') {
|
|
100
|
+
this.scheduleReconnect(true);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
this.scheduleReconnect(false); // Maybe temporary network blip
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
req.end();
|
|
107
|
+
this.sseRequest = req;
|
|
108
|
+
}
|
|
109
|
+
handleSSEChunk(chunk, state) {
|
|
110
|
+
state.buffer += chunk;
|
|
111
|
+
const lines = state.buffer.split("\n");
|
|
112
|
+
// Keep the last partial line in buffer
|
|
113
|
+
state.buffer = lines.pop() || "";
|
|
114
|
+
let eventType = "message";
|
|
115
|
+
for (const line of lines) {
|
|
116
|
+
const trimmed = line.trim();
|
|
117
|
+
if (!trimmed) {
|
|
118
|
+
// Empty line = Event dispatch
|
|
119
|
+
eventType = "message"; // reset
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (trimmed.startsWith("event: ")) {
|
|
123
|
+
eventType = trimmed.substring(7).trim();
|
|
124
|
+
}
|
|
125
|
+
else if (trimmed.startsWith("data: ")) {
|
|
126
|
+
const data = trimmed.substring(6);
|
|
127
|
+
this.processEvent(eventType, data);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
processEvent(type, data) {
|
|
132
|
+
if (type === "endpoint") {
|
|
133
|
+
const match = data.match(/sessionId=([a-f0-9-]+)/);
|
|
134
|
+
if (match) {
|
|
135
|
+
this.sessionId = match[1];
|
|
136
|
+
this.flushQueue();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
else if (type === "message") {
|
|
51
140
|
try {
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
141
|
+
const msg = JSON.parse(data);
|
|
142
|
+
// 1. Handle Responses to our Requests
|
|
143
|
+
if (msg.id !== undefined && this.pendingRequests.has(msg.id)) {
|
|
144
|
+
const { resolve, reject } = this.pendingRequests.get(msg.id);
|
|
145
|
+
this.pendingRequests.delete(msg.id);
|
|
146
|
+
if (msg.error) {
|
|
147
|
+
reject(msg.error);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
resolve(msg.result);
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// 2. Handle Notifications (log to stdout)
|
|
155
|
+
// If it's a notification (no id) or we aren't tracking it
|
|
156
|
+
if (!msg.id) {
|
|
157
|
+
process.stdout.write(JSON.stringify(msg) + "\n");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// Fallback for non-JSON messages?
|
|
162
|
+
// process.stdout.write(data + "\n");
|
|
62
163
|
}
|
|
63
|
-
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async doPost(method, params, resolve, reject) {
|
|
167
|
+
if (!this.sessionId)
|
|
168
|
+
return;
|
|
169
|
+
const id = Math.floor(Math.random() * 1000000); // Req ID
|
|
170
|
+
const payload = {
|
|
171
|
+
jsonrpc: "2.0",
|
|
172
|
+
id,
|
|
173
|
+
method,
|
|
174
|
+
params
|
|
64
175
|
};
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
176
|
+
// Track request
|
|
177
|
+
this.pendingRequests.set(id, { resolve, reject });
|
|
178
|
+
const postData = JSON.stringify(payload);
|
|
179
|
+
const connectHost = NEXUS_HOST === "0.0.0.0" ? "127.0.0.1" : NEXUS_HOST;
|
|
180
|
+
const req = http.request({
|
|
181
|
+
hostname: connectHost,
|
|
182
|
+
port: this.targetPort,
|
|
183
|
+
path: `/mcp?sessionId=${this.sessionId}`,
|
|
184
|
+
method: "POST",
|
|
185
|
+
headers: {
|
|
186
|
+
"Content-Type": "application/json",
|
|
187
|
+
"Content-Length": Buffer.byteLength(postData)
|
|
188
|
+
}
|
|
189
|
+
}, (res) => {
|
|
190
|
+
let data = "";
|
|
191
|
+
res.on("data", c => data += c);
|
|
192
|
+
res.on("end", () => {
|
|
193
|
+
try {
|
|
194
|
+
// 202 Accepted -> Expect response via SSE later
|
|
195
|
+
if (res.statusCode === 202) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (res.statusCode !== 200) {
|
|
199
|
+
this.pendingRequests.delete(id);
|
|
200
|
+
reject(new Error(`Host returned ${res.statusCode}: ${data}`));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// 200 OK -> Response might be in body
|
|
204
|
+
if (data) {
|
|
205
|
+
const json = JSON.parse(data);
|
|
206
|
+
if (json.id !== undefined && this.pendingRequests.has(json.id)) {
|
|
207
|
+
// Resolved immediately
|
|
208
|
+
this.pendingRequests.delete(json.id);
|
|
209
|
+
if (json.error) {
|
|
210
|
+
reject(json.error);
|
|
88
211
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
// Assume JSON-RPC message
|
|
92
|
-
try {
|
|
93
|
-
process.stdout.write(content + "\n");
|
|
212
|
+
else {
|
|
213
|
+
resolve(json.result);
|
|
94
214
|
}
|
|
95
|
-
catch { /* ignore */ }
|
|
96
215
|
}
|
|
97
216
|
}
|
|
98
217
|
}
|
|
218
|
+
catch (e) {
|
|
219
|
+
this.pendingRequests.delete(id);
|
|
220
|
+
reject(e);
|
|
221
|
+
}
|
|
99
222
|
});
|
|
100
|
-
res.on("end", () => {
|
|
101
|
-
process.stdin.removeAllListeners("data");
|
|
102
|
-
reconnect();
|
|
103
|
-
});
|
|
104
|
-
}).on("error", () => {
|
|
105
|
-
process.stdin.removeAllListeners("data");
|
|
106
|
-
reconnect();
|
|
107
223
|
});
|
|
108
|
-
|
|
109
|
-
|
|
224
|
+
req.on("error", (e) => {
|
|
225
|
+
this.pendingRequests.delete(id);
|
|
226
|
+
reject(e);
|
|
227
|
+
});
|
|
228
|
+
req.write(postData);
|
|
229
|
+
req.end();
|
|
230
|
+
}
|
|
231
|
+
flushQueue() {
|
|
232
|
+
console.error(`[Nexus Guest] Session established (${this.sessionId}). Flushing ${this.requestQueue.length} requests...`);
|
|
233
|
+
while (this.requestQueue.length > 0) {
|
|
234
|
+
const req = this.requestQueue.shift();
|
|
235
|
+
if (req)
|
|
236
|
+
this.doPost(req.method, req.params, req.resolve, req.reject);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
scheduleReconnect(isHardFailure = false) {
|
|
240
|
+
if (this.reconnectTimer)
|
|
241
|
+
return;
|
|
242
|
+
this.sessionId = null;
|
|
243
|
+
if (isHardFailure) {
|
|
244
|
+
this.connectionFailures++;
|
|
245
|
+
}
|
|
246
|
+
// If Host is offline, initiate Re-Election almost immediately if callback provided.
|
|
247
|
+
// We use a small threshold > 0 to avoid blips, but for test speed, 1 is fine if it's ECONNREFUSED.
|
|
248
|
+
if (this.onReElectionNeeded && isHardFailure) {
|
|
249
|
+
console.error(`[Nexus Guest] Connection lost/refused. Triggering Re-Election.`);
|
|
250
|
+
this.onReElectionNeeded();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const delay = this.connectionFailures > 5 ? 5000 : 1000;
|
|
254
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
255
|
+
this.reconnectTimer = null;
|
|
256
|
+
this.start();
|
|
257
|
+
}, delay);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// Factory function to maintain compatibility with index.ts
|
|
261
|
+
export function createGuestClient(port, id, onReElectionNeeded) {
|
|
262
|
+
return new GuestClient(port, id, onReElectionNeeded);
|
|
110
263
|
}
|
package/build/network/index.js
CHANGED
package/build/resources/index.js
CHANGED
|
@@ -95,6 +95,7 @@ export async function getResourceContent(uri, currentProject) {
|
|
|
95
95
|
* Uses resourceTemplates instead - AI should query registry first.
|
|
96
96
|
*/
|
|
97
97
|
export async function listResources() {
|
|
98
|
+
await StorageManager.init(); // Ensure storage is ready (Online First)
|
|
98
99
|
const registry = await StorageManager.listRegistry();
|
|
99
100
|
const projectCount = Object.keys(registry.projects).length;
|
|
100
101
|
return {
|
|
@@ -95,6 +95,18 @@ const ALL_TOOLS = [
|
|
|
95
95
|
}
|
|
96
96
|
},
|
|
97
97
|
// --- Global Collaboration ---
|
|
98
|
+
{
|
|
99
|
+
name: "search_projects",
|
|
100
|
+
description: "Search project registry by name or description. Use this instead of reading the full registry.",
|
|
101
|
+
inputSchema: {
|
|
102
|
+
type: "object",
|
|
103
|
+
properties: {
|
|
104
|
+
query: { type: "string" },
|
|
105
|
+
limit: { type: "integer", default: 10 }
|
|
106
|
+
},
|
|
107
|
+
required: ["query"]
|
|
108
|
+
}
|
|
109
|
+
},
|
|
98
110
|
{
|
|
99
111
|
name: "get_global_topology",
|
|
100
112
|
description: "Default: project list + stats. With projectId: detailed subgraph.",
|
package/build/tools/handlers.js
CHANGED
|
@@ -22,7 +22,8 @@ export async function handleToolCall(name, toolArgs, ctx) {
|
|
|
22
22
|
}
|
|
23
23
|
catch (e) {
|
|
24
24
|
const error = e;
|
|
25
|
-
|
|
25
|
+
const idPrefix = ctx.requestId ? `[Req:${ctx.requestId}] ` : "";
|
|
26
|
+
throw new McpError(ErrorCode.InvalidParams, `${idPrefix}Schema validation failed: ${error.message}`);
|
|
26
27
|
}
|
|
27
28
|
switch (name) {
|
|
28
29
|
case "register_session_context":
|
|
@@ -31,6 +32,8 @@ export async function handleToolCall(name, toolArgs, ctx) {
|
|
|
31
32
|
return handleSyncProjectAssets(validatedArgs, ctx);
|
|
32
33
|
case "upload_project_asset":
|
|
33
34
|
return handleUploadAsset(validatedArgs, ctx);
|
|
35
|
+
case "search_projects":
|
|
36
|
+
return handleSearchProjects(validatedArgs);
|
|
34
37
|
case "get_global_topology":
|
|
35
38
|
return handleGetTopology(validatedArgs);
|
|
36
39
|
case "send_message":
|
|
@@ -225,6 +228,34 @@ async function handleRenameProject(args, ctx) {
|
|
|
225
228
|
}]
|
|
226
229
|
};
|
|
227
230
|
}
|
|
231
|
+
async function handleSearchProjects(args) {
|
|
232
|
+
if (!args.query)
|
|
233
|
+
throw new McpError(ErrorCode.InvalidParams, "Query is required.");
|
|
234
|
+
const limit = args.limit || 10;
|
|
235
|
+
const registry = await StorageManager.listRegistry();
|
|
236
|
+
const query = args.query.toLowerCase();
|
|
237
|
+
// Search in ID, Name, and Description
|
|
238
|
+
const matches = Object.entries(registry.projects)
|
|
239
|
+
.filter(([id, p]) => id.toLowerCase().includes(query) ||
|
|
240
|
+
(p.name && p.name.toLowerCase().includes(query)) ||
|
|
241
|
+
(p.summary && p.summary.toLowerCase().includes(query)))
|
|
242
|
+
.map(([id, p]) => ({
|
|
243
|
+
id: id,
|
|
244
|
+
name: p.name || id,
|
|
245
|
+
description: p.summary
|
|
246
|
+
}))
|
|
247
|
+
.slice(0, limit);
|
|
248
|
+
return {
|
|
249
|
+
content: [{
|
|
250
|
+
type: "text",
|
|
251
|
+
text: JSON.stringify({
|
|
252
|
+
query: args.query,
|
|
253
|
+
count: matches.length,
|
|
254
|
+
results: matches
|
|
255
|
+
}, null, 2)
|
|
256
|
+
}]
|
|
257
|
+
};
|
|
258
|
+
}
|
|
228
259
|
// --- Global Handlers ---
|
|
229
260
|
async function handleGetTopology(args) {
|
|
230
261
|
const topo = await StorageManager.calculateTopology(args?.projectId);
|
package/build/tools/schemas.js
CHANGED
|
@@ -68,6 +68,13 @@ export const TopologySchema = z.object({
|
|
|
68
68
|
projectId: ProjectIdSchema.optional().describe("Focus on specific project's subgraph. Omit for summary list.")
|
|
69
69
|
});
|
|
70
70
|
export const EmptySchema = z.object({});
|
|
71
|
+
/**
|
|
72
|
+
* 5. search_projects
|
|
73
|
+
*/
|
|
74
|
+
export const SearchProjectsSchema = z.object({
|
|
75
|
+
query: z.string().min(1, "Query is required"),
|
|
76
|
+
limit: z.number().int().positive().optional().default(10)
|
|
77
|
+
});
|
|
71
78
|
/**
|
|
72
79
|
* 7. send_message
|
|
73
80
|
*/
|
|
@@ -206,6 +213,10 @@ export const TOOL_REGISTRY = {
|
|
|
206
213
|
description: "Project topology. Default: list summary. With projectId: detailed subgraph.",
|
|
207
214
|
schema: TopologySchema
|
|
208
215
|
},
|
|
216
|
+
search_projects: {
|
|
217
|
+
description: "Search project registry by name or description. Use this instead of reading the full registry.",
|
|
218
|
+
schema: SearchProjectsSchema
|
|
219
|
+
},
|
|
209
220
|
send_message: {
|
|
210
221
|
description: "Post a message to the Nexus collaboration space. If an active meeting exists, the message is automatically routed to that meeting. Otherwise, it goes to the global discussion log. Use this for proposals, decisions, or general coordination.",
|
|
211
222
|
schema: SendMessageSchema
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -61,3 +61,18 @@ To ensure clarity and prevent collisions in the flat local namespace, all Projec
|
|
|
61
61
|
| `bot_` | Bots (Discord, Slack, DingTalk, etc.) | `bot_auto-moderator` |
|
|
62
62
|
| `infra_` | Infrastructure as Code, CI/CD, DevOps scripts | `infra_k8s-config` |
|
|
63
63
|
| `doc_` | Pure technical handbooks, strategies, roadmaps | `doc_coding-guide` |
|
|
64
|
+
|
|
65
|
+
## 🌐 Host-Guest Network Architecture (v2)
|
|
66
|
+
|
|
67
|
+
### Zero-Config Startup & Election
|
|
68
|
+
The system aims for a "magic" user experience where multiple instances on the same machine automatically discover each other without manual configuration.
|
|
69
|
+
|
|
70
|
+
1. **Parallel Race Election**: Upon startup, every instance scans the first 5 ports (5688-5692) concurrently (<300ms).
|
|
71
|
+
2. **Role Resolution**:
|
|
72
|
+
* **Host**: If a port is free, the instance binds it and becomes the Host.
|
|
73
|
+
* **Guest**: If a Host is found, the instance connects via SSE (Server-Sent Events) and becomes a Guest.
|
|
74
|
+
3. **Immediate Handshake**: The `StdioServerTransport` connects immediately (<10ms) to the IDE. Static requests (`tools/list`) are served locally, preventing IDE timeouts. Dynamic requests are **buffered** until the election concludes.
|
|
75
|
+
|
|
76
|
+
### Failover & Resilience
|
|
77
|
+
* **Smart Proxy**: Guest instances proxy IDE requests to the Host via HTTP/SSE. They handle backpressure and buffering to prevent data loss during connection blips.
|
|
78
|
+
* **Auto-Failover**: If the Host process terminates (e.g., user closes the Host IDE window), Guest instances detect the connection loss (via `ECONNREFUSED` or SSE `end`) and trigger a **Re-Election**. One of the surviving Guests will promote itself to become the new Host, restoring the cluster automatically.
|
package/docs/ARCHITECTURE_zh.md
CHANGED
|
@@ -41,3 +41,18 @@ Nexus_Storage/
|
|
|
41
41
|
**自我修复 (Self-healing)**: 核心数据文件(如 `registry.json`, `discussion.json`)具备自动检测与修复机制。如果文件损坏或意外丢失,系统会自动重建初始状态,确保服务不中断。
|
|
42
42
|
|
|
43
43
|
**多并发安全 (Concurrency Safety)**: 对共享文件(`discussion.json`, `registry.json`)的所有写入操作均受 `AsyncMutex` 锁保护,防止多个 AI 代理同时通信时发生竞争条件。
|
|
44
|
+
|
|
45
|
+
## 🌐 Host-Guest 网络架构 (v2)
|
|
46
|
+
|
|
47
|
+
### 零配置启动与选举
|
|
48
|
+
系统旨在提供“魔法般”的用户体验,同一台机器上的多个实例无需手动配置即可自动发现并组网。
|
|
49
|
+
|
|
50
|
+
1. **并行竞速选举**: 启动时,每个实例并发扫描前 5 个端口 (5688-5692) (<300ms)。
|
|
51
|
+
2. **角色解析**:
|
|
52
|
+
* **Host**: 如果发现空闲端口,实例绑定该端口并成为 Host。
|
|
53
|
+
* **Guest**: 如果发现已有 Host,实例通过 SSE (Server-Sent Events) 连接并成为 Guest。
|
|
54
|
+
3. **立即握手**: `StdioServerTransport` 对 IDE 的连接是立即完成的 (<10ms)。静态请求(如 `tools/list`)由本地直接响应,避免 IDE 超时;动态请求被**缓冲**,直到选举完成。
|
|
55
|
+
|
|
56
|
+
### 故障转移与高可用
|
|
57
|
+
* **智能代理 (Smart Proxy)**: Guest 实例通过 HTTP/SSE 将 IDE 请求代理给 Host。它们处理背压和缓冲,防止网络抖动导致数据丢失。
|
|
58
|
+
* **自动故障转移**: 如果 Host 进程终止(例如用户关闭了 Host IDE 窗口),Guest 实例会检测到连接断开(通过 `ECONNREFUSED` 或 SSE `end`),并触发 **重新选举**。其中一个幸存的 Guest 将自动晋升为新的 Host,自动恢复集群服务。
|
package/docs/ASSISTANT_GUIDE.md
CHANGED
package/docs/CHANGELOG_zh.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
本项目的所有重大变更都将记录在此文件中。
|
|
4
4
|
|
|
5
|
+
## [0.4.0] - 2026-01-14
|
|
6
|
+
### 🚀 高可用与高扩展 (High Availability & Scalability)
|
|
7
|
+
- **Failover Mechanism**: 实现了自动的 Guest-to-Host 故障转移。如果 Host 进程意外退出,Guest 会检测到端口/锁释放并立即接管 Host 角色,同时保证数据持久性。
|
|
8
|
+
- **Progressive Discovery**: 新增 `search_projects` 工具。允许 AI 根据意图搜索相关项目,而无需读取包含 1000+ 项目的完整注册表,大幅节省 Token。
|
|
9
|
+
- **Immediate Handshake**: 实现了请求缓冲机制 (Request Buffering),允许 Server 在 Host 选举 (~300ms) 完成前就立即接受 (<10ms) IDE 的请求,彻底解决了启动时的竞态条件。
|
|
10
|
+
- **Stability**: 增加了针对进程故障转移 (`tests/failover.test.ts`) 和请求缓冲 (`tests/buffered_requests.test.ts`) 的端到端测试。
|
|
11
|
+
|
|
12
|
+
## [0.3.9] - 2026-01-14
|
|
13
|
+
### 🚀 故障切换与立即握手
|
|
14
|
+
- **立即握手**: 实现了基于缓冲区的设计,Stdio 启动后 <10ms 内即可响应,不再阻塞 IDE 等待选举。
|
|
15
|
+
- **自动故障切换**: 新增了 `Failover` 机制。如果 Host 进程被杀或崩溃,存活的 Guest 会在检测到连接断开后立即触发重新选举,并自动晋升为新 Host。
|
|
16
|
+
- **零配置 V2**: 进一步优化了启动流程,完全无需用户干预即可组建高可用集群。
|
|
17
|
+
|
|
5
18
|
## [0.3.5] - 2026-01-10
|
|
6
19
|
### 协议与稳定性
|
|
7
20
|
- **修复 (僵尸 Host)**: 实现了“智能重试”和“重新选举”逻辑。如果 Guest 反复连接到僵尸 Host(握手成功但 SSE 断开),现在会自动触发重新选举流程,将坏端口加入黑名单,并在必要时在从端口上将自身提升为 Host。
|
package/docs/README_zh.md
CHANGED
|
@@ -92,9 +92,9 @@
|
|
|
92
92
|
```
|
|
93
93
|
|
|
94
94
|
- **零配置**: 只需运行 `npx @datafrog-io/n2n-nexus` — 无需 `--id` 或 `--host`。
|
|
95
|
-
-
|
|
96
|
-
-
|
|
97
|
-
- **热故障转移**: 若 Host 断开,Guest
|
|
95
|
+
- **立即握手**: Stdio 瞬间响应 (<10ms) 静态请求 (`tools/list`),动态请求自动缓冲。
|
|
96
|
+
- **并行选举**: 并发端口探测保证选举过程在 <300ms 完成。
|
|
97
|
+
- **热故障转移**: 若 Host 断开,Guest 自动检测并选举成为新 Host,实现自动修复。
|
|
98
98
|
|
|
99
99
|
## 🚀 快速启动
|
|
100
100
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@datafrog-io/n2n-nexus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Modular MCP Server for multi-AI assistant coordination",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"start": "node build/index.js",
|
|
42
42
|
"dev": "tsc -w",
|
|
43
43
|
"test": "vitest run",
|
|
44
|
+
"test:integration": "vitest run tests/integration.test.ts tests/failover.test.ts tests/meeting.test.ts tests/guest_connection.test.ts",
|
|
44
45
|
"lint": "eslint src/**",
|
|
45
46
|
"prepublishOnly": "npm run build"
|
|
46
47
|
},
|