@datafrog-io/n2n-nexus 0.3.5 → 0.3.7
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/build/config/cli.js +11 -0
- package/build/config/index.js +81 -0
- package/build/config/paths.js +38 -0
- package/build/constants.js +22 -0
- package/build/index.js +86 -244
- package/build/network/election.js +94 -0
- package/build/network/guest.js +110 -0
- package/build/network/host.js +79 -0
- package/build/network/index.js +6 -0
- package/build/resources/index.js +6 -5
- package/build/storage/index.js +15 -14
- package/build/storage/meetings.js +12 -11
- package/build/storage/sqlite.js +1 -1
- package/build/storage/tasks.js +2 -1
- package/build/tools/handlers.js +1 -1
- package/build/utils/auth.js +1 -1
- package/package.json +1 -1
- package/build/config.js +0 -252
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Argument Parsing
|
|
3
|
+
*/
|
|
4
|
+
const args = process.argv.slice(2);
|
|
5
|
+
export function getArg(k) {
|
|
6
|
+
const i = args.indexOf(k);
|
|
7
|
+
return i !== -1 && args[i + 1] ? args[i + 1] : "";
|
|
8
|
+
}
|
|
9
|
+
export function hasFlag(k) {
|
|
10
|
+
return args.includes(k) || args.includes(k.charAt(1) === "-" ? k : k.substring(0, 2));
|
|
11
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config Module - Central Configuration
|
|
3
|
+
*/
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import { FILE_ENCODING, PACKAGE_JSON } from "../constants.js";
|
|
8
|
+
import { getArg, hasFlag } from "./cli.js";
|
|
9
|
+
import { getRootPath } from "./paths.js";
|
|
10
|
+
import { isHostAutoElection } from "../network/election.js";
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
// Load version from package.json
|
|
13
|
+
const pkgPath = path.resolve(__dirname, `../../${PACKAGE_JSON}`);
|
|
14
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, FILE_ENCODING));
|
|
15
|
+
export { pkg };
|
|
16
|
+
// --- CLI Commands Handlers ---
|
|
17
|
+
if (hasFlag("--help") || hasFlag("-h")) {
|
|
18
|
+
console.error(`
|
|
19
|
+
n2ns Nexus 🚀 - Local Digital Asset Hub (MCP Server) v${pkg.version}
|
|
20
|
+
|
|
21
|
+
USAGE:
|
|
22
|
+
npx -y @datafrog-io/n2n-nexus [options]
|
|
23
|
+
|
|
24
|
+
DESCRIPTION:
|
|
25
|
+
A local-first project management and collaboration hub designed for
|
|
26
|
+
multi-AI assistant coordination across different IDEs (Cursor, VS Code, etc.).
|
|
27
|
+
|
|
28
|
+
OPTIONS:
|
|
29
|
+
--root <path> Directory for data persistence. Default: ~/.n2n-nexus
|
|
30
|
+
--version, -v Show version number.
|
|
31
|
+
--help, -h Show this message.
|
|
32
|
+
|
|
33
|
+
MCP CONFIG EXAMPLE (claude_desktop_config.json):
|
|
34
|
+
{
|
|
35
|
+
"mcpServers": {
|
|
36
|
+
"n2n-nexus": {
|
|
37
|
+
"command": "npx",
|
|
38
|
+
"args": ["-y", "@datafrog-io/n2n-nexus", "--root", "/path/to/storage"]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
ENVIRONMENT VARIABLES:
|
|
44
|
+
NEXUS_ROOT Override default storage path.
|
|
45
|
+
`);
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
if (hasFlag("--version") || hasFlag("-v")) {
|
|
49
|
+
console.error(pkg.version);
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Automatic Project Name Detection
|
|
54
|
+
*/
|
|
55
|
+
function getAutoProjectName() {
|
|
56
|
+
try {
|
|
57
|
+
const localPkgPath = path.join(process.cwd(), PACKAGE_JSON);
|
|
58
|
+
if (fs.existsSync(localPkgPath)) {
|
|
59
|
+
const localPkg = JSON.parse(fs.readFileSync(localPkgPath, FILE_ENCODING));
|
|
60
|
+
if (localPkg.name)
|
|
61
|
+
return localPkg.name.split("/").pop() || localPkg.name;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch { /* ignore */ }
|
|
65
|
+
const base = path.basename(process.cwd()) || "Assistant";
|
|
66
|
+
const suffix = Math.random().toString(36).substring(2, 6);
|
|
67
|
+
return `${base}-${suffix}`;
|
|
68
|
+
}
|
|
69
|
+
// Run election at module load
|
|
70
|
+
const rootPath = getRootPath();
|
|
71
|
+
const election = await isHostAutoElection(rootPath);
|
|
72
|
+
const projectName = getAutoProjectName();
|
|
73
|
+
export const hostServer = election.server;
|
|
74
|
+
export const CONFIG = {
|
|
75
|
+
instanceId: getArg("--id") || projectName,
|
|
76
|
+
isHost: election.isHost,
|
|
77
|
+
rootStorage: election.isHost ? rootPath : (election.rootStorage || rootPath),
|
|
78
|
+
port: election.port
|
|
79
|
+
};
|
|
80
|
+
// Re-export for Guest reconnection
|
|
81
|
+
export { isHostAutoElection } from "../network/election.js";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path Resolution Logic
|
|
3
|
+
*/
|
|
4
|
+
import path from "path";
|
|
5
|
+
import os from "os";
|
|
6
|
+
import { SERVICE_NAME } from "../constants.js";
|
|
7
|
+
import { getArg } from "./cli.js";
|
|
8
|
+
/**
|
|
9
|
+
* Normalize and resolve the root storage path
|
|
10
|
+
*/
|
|
11
|
+
export function normalizeRootPath(inputPath) {
|
|
12
|
+
// Priority: CLI --root > ENV NEXUS_ROOT > System Default
|
|
13
|
+
let root = inputPath || process.env.NEXUS_ROOT || getDefaultDataDir();
|
|
14
|
+
// Resolve ~ to home directory
|
|
15
|
+
if (root.startsWith("~")) {
|
|
16
|
+
root = path.join(os.homedir(), root.slice(1));
|
|
17
|
+
}
|
|
18
|
+
// Cross-platform adaptation (WSL <-> Windows)
|
|
19
|
+
if (process.platform === "linux" && /^[a-zA-Z]:[/\\]/.test(root)) {
|
|
20
|
+
const drive = root[0].toLowerCase();
|
|
21
|
+
root = `/mnt/${drive}${root.slice(2).replace(/\\/g, "/")}`;
|
|
22
|
+
}
|
|
23
|
+
return path.resolve(root);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Get the default data directory
|
|
27
|
+
*/
|
|
28
|
+
export function getDefaultDataDir() {
|
|
29
|
+
const home = os.homedir();
|
|
30
|
+
// Use ~/.n2n-nexus for all platforms (developer-friendly convention)
|
|
31
|
+
return path.join(home, `.${SERVICE_NAME}`);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Get the root storage path from CLI or environment
|
|
35
|
+
*/
|
|
36
|
+
export function getRootPath() {
|
|
37
|
+
return normalizeRootPath(getArg("--root"));
|
|
38
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nexus Network Constants
|
|
3
|
+
*
|
|
4
|
+
* Centralized configuration for network-related settings.
|
|
5
|
+
*/
|
|
6
|
+
// Service identification
|
|
7
|
+
export const SERVICE_NAME = "n2n-nexus";
|
|
8
|
+
// Host address for binding and connecting
|
|
9
|
+
// Use "0.0.0.0" to allow connections from any interface
|
|
10
|
+
// Use "127.0.0.1" to restrict to localhost only
|
|
11
|
+
export const NEXUS_HOST = "0.0.0.0";
|
|
12
|
+
// Port range for auto-election
|
|
13
|
+
export const PORT_RANGE_START = 5688;
|
|
14
|
+
export const PORT_RANGE_END = 5800;
|
|
15
|
+
// Timeouts (milliseconds)
|
|
16
|
+
export const HANDSHAKE_TIMEOUT = 500;
|
|
17
|
+
export const HEARTBEAT_INTERVAL = 30000;
|
|
18
|
+
// Task cleanup
|
|
19
|
+
export const TASK_CLEANUP_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
20
|
+
// File I/O
|
|
21
|
+
export const FILE_ENCODING = "utf-8";
|
|
22
|
+
export const PACKAGE_JSON = "package.json";
|
package/build/index.js
CHANGED
|
@@ -1,31 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* n2ns Nexus: Unified Project Asset & Collaboration Hub
|
|
4
|
+
*
|
|
5
|
+
* Modular MCP Server for multi-AI assistant coordination.
|
|
6
|
+
*/
|
|
2
7
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
8
|
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ErrorCode, McpError, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
-
import {
|
|
6
|
-
import { readFileSync } from "fs";
|
|
7
|
-
import { join } from "path";
|
|
8
|
-
import { fileURLToPath } from "url";
|
|
9
|
-
import http from "http";
|
|
10
|
-
import { CONFIG, hostServer } from "./config.js";
|
|
9
|
+
import { CONFIG, hostServer, pkg } from "./config/index.js";
|
|
11
10
|
import { StorageManager } from "./storage/index.js";
|
|
12
11
|
import { TOOL_DEFINITIONS, handleToolCall } from "./tools/index.js";
|
|
13
12
|
import { listResources, getResourceContent } from "./resources/index.js";
|
|
14
13
|
import { sanitizeErrorMessage } from "./utils/error.js";
|
|
15
14
|
import { checkHostPermission } from "./utils/auth.js";
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* n2ns Nexus: Unified Project Asset & Collaboration Hub
|
|
20
|
-
*
|
|
21
|
-
* Modular MCP Server for multi-AI assistant coordination.
|
|
22
|
-
*/
|
|
15
|
+
import { SERVICE_NAME } from "./constants.js";
|
|
16
|
+
import { startHost, startGuest } from "./network/index.js";
|
|
23
17
|
class NexusServer {
|
|
24
18
|
server;
|
|
25
19
|
currentProject = null;
|
|
26
20
|
sseTransports = new Map();
|
|
27
21
|
constructor() {
|
|
28
|
-
this.server = new Server({ name:
|
|
22
|
+
this.server = new Server({ name: SERVICE_NAME, version: pkg.version }, { capabilities: { resources: {}, tools: {}, prompts: {} } });
|
|
29
23
|
this.setupHandlers();
|
|
30
24
|
}
|
|
31
25
|
setupHandlers() {
|
|
@@ -41,103 +35,100 @@ class NexusServer {
|
|
|
41
35
|
});
|
|
42
36
|
// --- Resource Reading ---
|
|
43
37
|
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
44
|
-
const { uri } = request.params;
|
|
45
38
|
try {
|
|
46
|
-
await
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return { contents: [{ uri, mimeType: content.mimeType, text: content.text }] };
|
|
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}`);
|
|
50
42
|
}
|
|
51
|
-
|
|
43
|
+
return { contents: [{ uri: request.params.uri, ...result }] };
|
|
52
44
|
}
|
|
53
45
|
catch (error) {
|
|
54
|
-
if (error instanceof McpError)
|
|
55
|
-
throw error;
|
|
56
46
|
const msg = error instanceof Error ? error.message : String(error);
|
|
57
|
-
throw new McpError(ErrorCode.InternalError, `Nexus
|
|
47
|
+
throw new McpError(ErrorCode.InternalError, `Nexus Read Error: ${sanitizeErrorMessage(msg)}`);
|
|
58
48
|
}
|
|
59
49
|
});
|
|
60
50
|
// --- Tool Listing ---
|
|
61
|
-
this.server.setRequestHandler(ListToolsRequestSchema, async () =>
|
|
62
|
-
tools: TOOL_DEFINITIONS
|
|
63
|
-
})
|
|
64
|
-
// --- Tool
|
|
51
|
+
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
52
|
+
return { tools: TOOL_DEFINITIONS };
|
|
53
|
+
});
|
|
54
|
+
// --- Tool Calling ---
|
|
65
55
|
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
66
|
-
const
|
|
56
|
+
const agentId = CONFIG.instanceId;
|
|
67
57
|
try {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
58
|
+
// Special handling for switch_project
|
|
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 = {
|
|
71
82
|
currentProject: this.currentProject,
|
|
72
83
|
setCurrentProject: (id) => { this.currentProject = id; },
|
|
73
|
-
notifyResourceUpdate: (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
});
|
|
84
|
+
notifyResourceUpdate: (_uri) => { }
|
|
85
|
+
};
|
|
86
|
+
const result = await handleToolCall(request.params.name, request.params.arguments, ctx);
|
|
77
87
|
return result;
|
|
78
88
|
}
|
|
79
89
|
catch (error) {
|
|
80
|
-
|
|
81
|
-
throw error;
|
|
82
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
90
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
83
91
|
return {
|
|
84
|
-
|
|
85
|
-
|
|
92
|
+
content: [{ type: "text", text: sanitizeErrorMessage(`Tool Error: ${msg}`) }],
|
|
93
|
+
isError: true
|
|
86
94
|
};
|
|
87
95
|
}
|
|
88
96
|
});
|
|
89
97
|
// --- Prompt Listing ---
|
|
90
|
-
this.server.setRequestHandler(ListPromptsRequestSchema, async () =>
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}));
|
|
102
|
-
// --- Prompt Retrieval ---
|
|
98
|
+
this.server.setRequestHandler(ListPromptsRequestSchema, async () => {
|
|
99
|
+
return {
|
|
100
|
+
prompts: [
|
|
101
|
+
{
|
|
102
|
+
name: "nexus_status",
|
|
103
|
+
description: "Get a comprehensive status report of the current Nexus Hub state"
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
// --- Prompt Getting ---
|
|
103
109
|
this.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
const projectId = `${projectType}_${technicalName}`;
|
|
110
|
+
if (request.params.name === "nexus_status") {
|
|
111
|
+
const registry = await StorageManager.listRegistry();
|
|
112
|
+
const projectCount = Object.keys(registry.projects).length;
|
|
113
|
+
const logs = await StorageManager.getRecentLogs(5);
|
|
109
114
|
return {
|
|
110
|
-
|
|
111
|
-
messages: [
|
|
112
|
-
{
|
|
115
|
+
messages: [{
|
|
113
116
|
role: "user",
|
|
114
117
|
content: {
|
|
115
118
|
type: "text",
|
|
116
|
-
text: `
|
|
119
|
+
text: `Nexus Hub Status:
|
|
120
|
+
- Role: ${CONFIG.isHost ? "Host" : "Guest"}
|
|
121
|
+
- Instance: ${CONFIG.instanceId}
|
|
122
|
+
- Port: ${CONFIG.port}
|
|
123
|
+
- Active Projects: ${projectCount}
|
|
124
|
+
- Recent Activity: ${logs.length} entries`
|
|
117
125
|
}
|
|
118
|
-
}
|
|
119
|
-
{
|
|
120
|
-
role: "assistant",
|
|
121
|
-
content: {
|
|
122
|
-
type: "text",
|
|
123
|
-
text: `## Project ID Convention\n\nBased on your input, the correct Project ID is:\n\n\`\`\`\n${projectId}\n\`\`\`\n\n### Prefix Dictionary\n| Prefix | Use Case |\n|--------|----------|\n| web_ | Websites/Domains |\n| api_ | Backend Services |\n| chrome_ | Chrome Extensions |\n| vscode_ | VSCode Extensions |\n| mcp_ | MCP Servers |\n| android_ | Native Android |\n| ios_ | Native iOS |\n| flutter_ | Cross-platform Mobile |\n| desktop_ | Desktop Apps |\n| lib_ | Libraries/SDKs |\n| bot_ | Bots |\n| infra_ | Infrastructure as Code |\n| doc_ | Technical Docs |\n\n### Next Steps\n1. Call \`register_session_context\` with projectId: \`${projectId}\`\n2. Call \`sync_project_assets\` with your manifest and internal docs.`
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
]
|
|
126
|
+
}]
|
|
127
127
|
};
|
|
128
128
|
}
|
|
129
|
-
throw new McpError(ErrorCode.InvalidRequest, `
|
|
129
|
+
throw new McpError(ErrorCode.InvalidRequest, `Prompt not found: ${request.params.name}`);
|
|
130
130
|
});
|
|
131
131
|
}
|
|
132
|
-
async run() {
|
|
133
|
-
this.setupShutdownHandlers();
|
|
134
|
-
if (CONFIG.isHost && hostServer) {
|
|
135
|
-
await this.startHost(hostServer);
|
|
136
|
-
}
|
|
137
|
-
else {
|
|
138
|
-
await this.startGuest(CONFIG.port);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
132
|
setupShutdownHandlers() {
|
|
142
133
|
const shutdown = async (signal) => {
|
|
143
134
|
console.error(`\n[Nexus] Received ${signal}. Shutting down...`);
|
|
@@ -158,169 +149,20 @@ class NexusServer {
|
|
|
158
149
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
159
150
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
160
151
|
}
|
|
161
|
-
async
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
if (req.method === "GET") {
|
|
169
|
-
const transport = new SSEServerTransport("/mcp", res);
|
|
170
|
-
this.sseTransports.set(transport.sessionId, transport);
|
|
171
|
-
const msg = `Guest Joined: ${guestId}`;
|
|
172
|
-
await StorageManager.addGlobalLog(`HOST:${CONFIG.instanceId}`, msg, "UPDATE");
|
|
173
|
-
console.error(`[Nexus Hub] ${msg} (Session: ${transport.sessionId})`);
|
|
174
|
-
// Heartbeat: keep connection alive
|
|
175
|
-
const heartbeat = setInterval(() => {
|
|
176
|
-
try {
|
|
177
|
-
res.write(": ping\n\n");
|
|
178
|
-
}
|
|
179
|
-
catch {
|
|
180
|
-
clearInterval(heartbeat);
|
|
181
|
-
}
|
|
182
|
-
}, 30000);
|
|
183
|
-
transport.onclose = () => {
|
|
184
|
-
this.sseTransports.delete(transport.sessionId);
|
|
185
|
-
clearInterval(heartbeat);
|
|
186
|
-
console.error(`[Nexus Hub] Guest Left: ${guestId}`);
|
|
187
|
-
};
|
|
188
|
-
await this.server.connect(transport);
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
else if (req.method === "POST") {
|
|
192
|
-
const sessionId = url.searchParams.get("sessionId");
|
|
193
|
-
const transport = sessionId ? this.sseTransports.get(sessionId) : null;
|
|
194
|
-
if (transport) {
|
|
195
|
-
await transport.handlePostMessage(req, res);
|
|
196
|
-
}
|
|
197
|
-
else {
|
|
198
|
-
res.writeHead(404).end("Session unknown");
|
|
199
|
-
}
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
});
|
|
204
|
-
// Support local stdio for the host's own IDE
|
|
205
|
-
const transport = new StdioServerTransport();
|
|
206
|
-
await this.server.connect(transport);
|
|
207
|
-
const onlineMsg = `Nexus Hub Active. Playing Host.`;
|
|
208
|
-
await StorageManager.addGlobalLog(`SYSTEM:${CONFIG.instanceId}`, onlineMsg, "UPDATE");
|
|
209
|
-
console.error(`[Nexus:${CONFIG.instanceId}] ${onlineMsg} (Port: ${server.address() && typeof server.address() === 'object' ? server.address().port : '?'})`);
|
|
210
|
-
}
|
|
211
|
-
async startGuest(targetPort) {
|
|
212
|
-
// --- GUEST MODE: SSE Proxy ---
|
|
213
|
-
const guestId = CONFIG.instanceId;
|
|
214
|
-
let retryCount = 0;
|
|
215
|
-
const maxRetries = 10;
|
|
216
|
-
const blacklistPorts = [];
|
|
217
|
-
// Random delay function to prevent thundering herd during re-election
|
|
218
|
-
const randomDelay = () => Math.floor(Math.random() * 3000);
|
|
219
|
-
const connect = () => {
|
|
220
|
-
// ZOMBIE HOST DETECTION: If we hit max retries, it means the Host is unstable (Zombie).
|
|
221
|
-
// Trigger re-election ignoring the bad port.
|
|
222
|
-
if (retryCount >= maxRetries) {
|
|
223
|
-
console.error(`[Nexus Guest] Host at ${targetPort} is unstable (Zombie). Triggering re-election...`);
|
|
224
|
-
import("./config.js").then(async ({ isHostAutoElection }) => {
|
|
225
|
-
blacklistPorts.push(targetPort);
|
|
226
|
-
// Re-run election with blacklist
|
|
227
|
-
const result = await isHostAutoElection(CONFIG.rootStorage, blacklistPorts);
|
|
228
|
-
if (result.isHost && result.server) {
|
|
229
|
-
// We became Host!
|
|
230
|
-
console.error(`[Nexus] Promoted to Host on port ${result.port}!`);
|
|
231
|
-
// Update global config (hack but necessary for singleton logs)
|
|
232
|
-
CONFIG.isHost = true;
|
|
233
|
-
CONFIG.port = result.port;
|
|
234
|
-
await this.startHost(result.server);
|
|
235
|
-
}
|
|
236
|
-
else {
|
|
237
|
-
// Found a new Host
|
|
238
|
-
console.error(`[Nexus] Found new Host at ${result.port}. Reconnecting...`);
|
|
239
|
-
CONFIG.port = result.port;
|
|
240
|
-
this.startGuest(result.port);
|
|
241
|
-
}
|
|
242
|
-
});
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
retryCount++;
|
|
246
|
-
// Clear any stale stdin listeners before starting
|
|
247
|
-
process.stdin.removeAllListeners("data");
|
|
248
|
-
console.error(`[Nexus:${guestId}] Global Hub detected at ${targetPort}. Joining... (attempt ${retryCount})`);
|
|
249
|
-
let sessionId = null;
|
|
250
|
-
let lastActivity = Date.now();
|
|
251
|
-
// Watchdog: trigger re-election if Host is silent for too long
|
|
252
|
-
const watchdog = setInterval(() => {
|
|
253
|
-
if (Date.now() - lastActivity > 60000) {
|
|
254
|
-
console.error("[Nexus Guest] Host stale. Reconnecting...");
|
|
255
|
-
cleanup();
|
|
256
|
-
// Use setImmediate to break call stack, then delay
|
|
257
|
-
setImmediate(() => setTimeout(connect, randomDelay()));
|
|
258
|
-
}
|
|
259
|
-
}, 10000);
|
|
260
|
-
const cleanup = () => {
|
|
261
|
-
clearInterval(watchdog);
|
|
262
|
-
process.stdin.removeAllListeners("data");
|
|
263
|
-
};
|
|
264
|
-
const stdioHandler = (chunk) => {
|
|
265
|
-
if (!sessionId)
|
|
266
|
-
return;
|
|
267
|
-
try {
|
|
268
|
-
const req = http.request({
|
|
269
|
-
hostname: "127.0.0.1",
|
|
270
|
-
port: targetPort,
|
|
271
|
-
path: `/mcp?sessionId=${sessionId}&id=${encodeURIComponent(guestId)}`,
|
|
272
|
-
method: "POST",
|
|
273
|
-
headers: { "Content-Type": "application/json" }
|
|
274
|
-
});
|
|
275
|
-
// Handle request errors to prevent unhandled exceptions
|
|
276
|
-
req.on("error", () => { });
|
|
277
|
-
req.write(chunk);
|
|
278
|
-
req.end();
|
|
279
|
-
}
|
|
280
|
-
catch { /* suppress */ }
|
|
281
|
-
};
|
|
282
|
-
process.stdin.on("data", stdioHandler);
|
|
283
|
-
http.get(`http://127.0.0.1:${targetPort}/mcp?id=${encodeURIComponent(guestId)}`, (res) => {
|
|
284
|
-
// INTELLIGENT RETRY: Only reset retryCount if connection held for > 5 seconds
|
|
285
|
-
// This prevents infinite loops with "Zombie Hosts" that accept then immediately close
|
|
286
|
-
const stableTimer = setTimeout(() => {
|
|
287
|
-
retryCount = 0;
|
|
288
|
-
}, 5000);
|
|
289
|
-
let buffer = "";
|
|
290
|
-
res.on("data", (chunk) => {
|
|
291
|
-
lastActivity = Date.now();
|
|
292
|
-
const str = chunk.toString();
|
|
293
|
-
buffer += str;
|
|
294
|
-
if (!sessionId && buffer.includes("event: endpoint")) {
|
|
295
|
-
const match = buffer.match(/sessionId=([a-f0-9-]+)/);
|
|
296
|
-
if (match)
|
|
297
|
-
sessionId = match[1];
|
|
298
|
-
}
|
|
299
|
-
if (str.includes("event: message")) {
|
|
300
|
-
const lines = str.split("\n");
|
|
301
|
-
const dataLine = lines.find((l) => l.startsWith("data: "));
|
|
302
|
-
if (dataLine) {
|
|
303
|
-
try {
|
|
304
|
-
process.stdout.write(dataLine.substring(6) + "\n");
|
|
305
|
-
}
|
|
306
|
-
catch { /* ignore stdout errors */ }
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
});
|
|
310
|
-
res.on("end", () => {
|
|
311
|
-
clearTimeout(stableTimer);
|
|
312
|
-
console.error("[Nexus Guest] Lost connection to Host. Reconnecting...");
|
|
313
|
-
cleanup();
|
|
314
|
-
// Use setImmediate to break call stack
|
|
315
|
-
setImmediate(() => setTimeout(connect, randomDelay()));
|
|
316
|
-
});
|
|
317
|
-
}).on("error", () => {
|
|
318
|
-
console.error("[Nexus Guest] Proxy Receive Error. Retrying...");
|
|
319
|
-
cleanup();
|
|
320
|
-
setImmediate(() => setTimeout(connect, 1000 + randomDelay()));
|
|
321
|
-
});
|
|
152
|
+
async run() {
|
|
153
|
+
this.setupShutdownHandlers();
|
|
154
|
+
const context = {
|
|
155
|
+
config: CONFIG,
|
|
156
|
+
pkg,
|
|
157
|
+
mcpServer: this.server,
|
|
158
|
+
sseTransports: this.sseTransports
|
|
322
159
|
};
|
|
323
|
-
|
|
160
|
+
if (CONFIG.isHost && hostServer) {
|
|
161
|
+
await startHost(hostServer, context);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
await startGuest(CONFIG.port, context);
|
|
165
|
+
}
|
|
324
166
|
}
|
|
325
167
|
}
|
|
326
168
|
const server = new NexusServer();
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host Election Logic
|
|
3
|
+
*
|
|
4
|
+
* Handles port scanning, handshake probing, and Host/Guest election.
|
|
5
|
+
*/
|
|
6
|
+
import http from "http";
|
|
7
|
+
import { NEXUS_HOST, PORT_RANGE_START, PORT_RANGE_END, HANDSHAKE_TIMEOUT, SERVICE_NAME } from "../constants.js";
|
|
8
|
+
import { getArg } from "../config/cli.js";
|
|
9
|
+
// We need pkg version for handshake - import dynamically to avoid circular dep
|
|
10
|
+
let pkgVersion = "0.0.0";
|
|
11
|
+
import("../config/index.js").then(m => { pkgVersion = m.pkg.version; }).catch(() => { });
|
|
12
|
+
/**
|
|
13
|
+
* Probe a port to see if it's a Nexus Host using the Custom Handshake Protocol
|
|
14
|
+
*/
|
|
15
|
+
export async function probeHost(port, myId) {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
const postData = JSON.stringify({
|
|
18
|
+
clientVersion: pkgVersion,
|
|
19
|
+
instanceId: myId
|
|
20
|
+
});
|
|
21
|
+
const req = http.request({
|
|
22
|
+
hostname: NEXUS_HOST,
|
|
23
|
+
port: port,
|
|
24
|
+
path: "/nexus/handshake",
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: {
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
"Content-Length": Buffer.byteLength(postData)
|
|
29
|
+
},
|
|
30
|
+
timeout: HANDSHAKE_TIMEOUT
|
|
31
|
+
}, (res) => {
|
|
32
|
+
let data = "";
|
|
33
|
+
res.on("data", (chunk) => data += chunk);
|
|
34
|
+
res.on("end", () => {
|
|
35
|
+
try {
|
|
36
|
+
const info = JSON.parse(data);
|
|
37
|
+
if (info.service === SERVICE_NAME && info.role === "host") {
|
|
38
|
+
resolve({ isNexus: true, rootStorage: info.rootStorage });
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
resolve({ isNexus: false });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
resolve({ isNexus: false });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
req.on("error", () => resolve({ isNexus: false }));
|
|
50
|
+
req.on("timeout", () => {
|
|
51
|
+
req.destroy();
|
|
52
|
+
resolve({ isNexus: false });
|
|
53
|
+
});
|
|
54
|
+
req.write(postData);
|
|
55
|
+
req.end();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Automatic Host Election
|
|
60
|
+
*
|
|
61
|
+
* For each port in range:
|
|
62
|
+
* 1. Try to bind → Success → I am Host
|
|
63
|
+
* 2. Bind fails → Try handshake → Success → I am Guest
|
|
64
|
+
* 3. Handshake fails → Port occupied by non-Nexus → Next port
|
|
65
|
+
*/
|
|
66
|
+
export async function isHostAutoElection(_root, blacklistPorts = []) {
|
|
67
|
+
const startPort = PORT_RANGE_START;
|
|
68
|
+
const endPort = PORT_RANGE_END;
|
|
69
|
+
const myId = getArg("--id") || `node-${Math.random().toString(36).substring(2, 6)}`;
|
|
70
|
+
for (let port = startPort; port <= endPort; port++) {
|
|
71
|
+
if (blacklistPorts.includes(port))
|
|
72
|
+
continue;
|
|
73
|
+
// 1. Try to bind port
|
|
74
|
+
const bindResult = await new Promise((resolve) => {
|
|
75
|
+
const server = http.createServer();
|
|
76
|
+
server.on("error", () => resolve({ success: false }));
|
|
77
|
+
server.listen(port, NEXUS_HOST, () => resolve({ success: true, server }));
|
|
78
|
+
});
|
|
79
|
+
if (bindResult.success) {
|
|
80
|
+
// Bind success → I am Host
|
|
81
|
+
return { isHost: true, port, server: bindResult.server };
|
|
82
|
+
}
|
|
83
|
+
// 2. Bind failed → Try handshake
|
|
84
|
+
const probe = await probeHost(port, myId);
|
|
85
|
+
if (probe.isNexus) {
|
|
86
|
+
// Handshake success → I am Guest
|
|
87
|
+
return { isHost: false, port, rootStorage: probe.rootStorage };
|
|
88
|
+
}
|
|
89
|
+
// 3. Handshake failed → Port occupied by non-Nexus → Continue
|
|
90
|
+
}
|
|
91
|
+
// All ports unavailable
|
|
92
|
+
console.error(`[Nexus] All ports ${startPort}-${endPort} occupied by non-Nexus processes.`);
|
|
93
|
+
throw new Error("No available port for Nexus");
|
|
94
|
+
}
|