@datafrog-io/n2n-nexus 0.2.0 → 0.3.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 +59 -81
- package/build/config.js +176 -4
- package/build/index.js +133 -16
- package/build/resources/index.js +21 -29
- package/build/storage/index.js +64 -9
- package/build/storage/sqlite-meeting.js +38 -8
- package/build/storage/sqlite.js +10 -0
- package/build/storage/store.js +11 -4
- package/build/tools/definitions.js +295 -0
- package/build/tools/handlers.js +38 -27
- package/build/tools/index.js +6 -9
- package/build/tools/schemas.js +18 -15
- package/build/utils/auth.js +5 -5
- package/docs/ARCHITECTURE.md +63 -0
- package/docs/ARCHITECTURE_zh.md +43 -0
- package/docs/ASSISTANT_GUIDE.md +23 -17
- package/docs/CHANGELOG_zh.md +94 -166
- package/{README_zh.md → docs/README_zh.md} +60 -57
- package/docs/TODO_zh.md +71 -0
- package/package.json +2 -2
- package/docs/CHANGELOG.md +0 -170
package/README.md
CHANGED
|
@@ -10,73 +10,14 @@
|
|
|
10
10
|
|
|
11
11
|
> **Works with:** Claude Code · Claude Desktop · VS Code · Cursor · Windsurf · Zed · JetBrains · Theia · Google Antigravity
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
1. **Nexus Room (Discussion)**: Unified public channel for all IDE assistants to coordinate across projects.
|
|
16
|
-
2. **Asset Vault (Archives)**:
|
|
17
|
-
- **Manifest**: Technical details, billing, topology, and API specs for each project.
|
|
18
|
-
- **Internal Docs**: Detailed technical implementation plans.
|
|
19
|
-
- **Assets**: Local physical assets (Logos, UI screenshots, etc.).
|
|
20
|
-
3. **Global Knowledge**:
|
|
21
|
-
- **Master Strategy**: Top-level strategic blueprint.
|
|
22
|
-
- **Global Docs**: Cross-project common documents (e.g., Coding Standards, Roadmaps).
|
|
23
|
-
4. **Topology Engine**: Automated dependency graph analysis.
|
|
24
|
-
|
|
25
|
-
## 💾 Data Persistence
|
|
26
|
-
|
|
27
|
-
Nexus stores all data in the local file system (customizable path), ensuring complete data sovereignty.
|
|
28
|
-
|
|
29
|
-
**Directory Structure Example**:
|
|
30
|
-
```text
|
|
31
|
-
Nexus_Storage/
|
|
32
|
-
├── global/
|
|
33
|
-
│ ├── blueprint.md # Master Strategy
|
|
34
|
-
│ ├── discussion.json # Chat History
|
|
35
|
-
│ ├── docs_index.json # Global Docs Metadata
|
|
36
|
-
│ └── docs/ # Global Markdown Docs
|
|
37
|
-
│ ├── coding-standards.md
|
|
38
|
-
│ └── deployment-flow.md
|
|
39
|
-
├── projects/
|
|
40
|
-
│ ├── my-app/
|
|
41
|
-
│ │ ├── manifest.json # Project Metadata
|
|
42
|
-
│ │ ├── internal_blueprint.md
|
|
43
|
-
│ │ └── assets/ # Binary Assets
|
|
44
|
-
│ └── ...
|
|
45
|
-
├── registry.json # Global Project Index
|
|
46
|
-
└── archives/ # (Reserved for backups)
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
**Self-healing**: Core data files (e.g., `registry.json`, `discussion.json`) include automatic detection and repair mechanisms. If files are corrupted or missing, the system automatically rebuilds the initial state to ensure uninterrupted service.
|
|
50
|
-
|
|
51
|
-
**Concurrency Safety**: All write operations to shared files (`discussion.json`, `registry.json`) are protected by an `AsyncMutex` lock, preventing race conditions when multiple AI agents communicate simultaneously.
|
|
52
|
-
|
|
53
|
-
## 🏷️ Project ID Conventions (Naming Standard)
|
|
54
|
-
|
|
55
|
-
To ensure clarity and prevent collisions in the flat local namespace, all Project IDs MUST follow the **Prefix Dictionary** format: `[prefix]_[project-name]`.
|
|
13
|
+
📖 **Documentation:** [CHANGELOG](CHANGELOG.md) | [TODO](TODO.md) | [中文文档](docs/README_zh.md) | [AI Assistant Guide](docs/ASSISTANT_GUIDE.md) | [Architecture](docs/ARCHITECTURE.md)
|
|
56
14
|
|
|
57
|
-
| Prefix | Category | Example |
|
|
58
|
-
| :--- | :--- | :--- |
|
|
59
|
-
| `web_` | Websites, landing pages, domain-based projects | `web_datafrog.io` |
|
|
60
|
-
| `api_` | Backend services, REST/gRPC APIs | `api_user-auth` |
|
|
61
|
-
| `chrome_` | Chrome extensions | `chrome_evisa-helper` |
|
|
62
|
-
| `vscode_` | VSCode extensions | `vscode_super-theme` |
|
|
63
|
-
| `mcp_` | MCP Servers and MCP-related tools | `mcp_github-repo` |
|
|
64
|
-
| `android_` | Native Android projects (Kotlin/Java) | `android_client-app` |
|
|
65
|
-
| `ios_` | Native iOS projects (Swift/ObjC) | `ios_client-app` |
|
|
66
|
-
| `flutter_` | **Mobile Cross-platform Special Case** | `flutter_unified-app` |
|
|
67
|
-
| `desktop_` | General desktop apps (Tauri, Electron, etc.) | `desktop_main-hub` |
|
|
68
|
-
| `lib_` | Shared libraries, SDKs, NPM/Python packages | `lib_crypto-core` |
|
|
69
|
-
| `bot_` | Bots (Discord, Slack, DingTalk, etc.) | `bot_auto-moderator` |
|
|
70
|
-
| `infra_` | Infrastructure as Code, CI/CD, DevOps scripts | `infra_k8s-config` |
|
|
71
|
-
| `doc_` | Pure technical handbooks, strategies, roadmaps | `doc_coding-guide` |
|
|
72
|
-
|
|
73
|
-
---
|
|
74
15
|
|
|
75
16
|
## 🛠️ Toolset
|
|
76
17
|
|
|
77
18
|
### A. Session & Context
|
|
78
19
|
- `register_session_context`: Declare the project ID currently active in the IDE to unlock write permissions.
|
|
79
|
-
- `mcp://nexus/session`: View current identity, role (
|
|
20
|
+
- `mcp://nexus/session`: View current identity, role (Host/Regular), and active project.
|
|
80
21
|
|
|
81
22
|
### B. Project Asset Management
|
|
82
23
|
- `sync_project_assets`: **[Core/ASYNC]** Submit full Project Manifest and Internal Docs. Returns `taskId`.
|
|
@@ -89,16 +30,16 @@ To ensure clarity and prevent collisions in the flat local namespace, all Projec
|
|
|
89
30
|
|
|
90
31
|
### C. Global Collaboration
|
|
91
32
|
- `send_message`: Post a message to the team (Auto-routes to active meeting).
|
|
92
|
-
- `read_messages`:
|
|
33
|
+
- `read_messages`: **[Incremental]** Returns only unread messages per IDE instance. Server tracks read cursor automatically.
|
|
93
34
|
- `update_global_strategy`: Update the core strategic blueprint (`# Master Plan`).
|
|
94
|
-
- `get_global_topology`:
|
|
35
|
+
- `get_global_topology`: **[Progressive]** Default: summary list. With `projectId`: detailed subgraph.
|
|
95
36
|
- `sync_global_doc`: Create or update a shared cross-project document.
|
|
96
37
|
|
|
97
38
|
### D. Meeting Management
|
|
98
39
|
- `start_meeting`: Start a new tactical session for focused collaboration.
|
|
99
40
|
- `reopen_meeting`: Reactivate a `closed` or `archived` session to continue discussion.
|
|
100
|
-
- `end_meeting`: Conclude a meeting, lock history (**
|
|
101
|
-
- `archive_meeting`: Move closed meetings to cold storage (**
|
|
41
|
+
- `end_meeting`: Conclude a meeting, lock history (**Host only**).
|
|
42
|
+
- `archive_meeting`: Move closed meetings to cold storage (**Host only**).
|
|
102
43
|
|
|
103
44
|
### E. Task Management (Phase 2 - ASYNC)
|
|
104
45
|
- `create_task`: Create a new background task. Link to meeting for traceability.
|
|
@@ -107,20 +48,53 @@ To ensure clarity and prevent collisions in the flat local namespace, all Projec
|
|
|
107
48
|
- `update_task`: Update progress or result (typically for workers).
|
|
108
49
|
- `cancel_task`: Cancel a pending or running task.
|
|
109
50
|
|
|
110
|
-
### F.
|
|
111
|
-
- `
|
|
112
|
-
- `
|
|
51
|
+
### F. Host (Host Only)
|
|
52
|
+
- `host_maintenance`: Prune or clear system logs.
|
|
53
|
+
- `host_delete_project`: Completely remove a project and its assets.
|
|
113
54
|
|
|
114
55
|
## 📄 Resources (URI)
|
|
115
56
|
|
|
57
|
+
**Core Resources (Static):**
|
|
116
58
|
- `mcp://nexus/chat/global`: Real-time conversation history.
|
|
117
|
-
- `mcp://nexus/hub/registry`: Global project registry
|
|
59
|
+
- `mcp://nexus/hub/registry`: Global project registry - **read this first to discover project IDs**.
|
|
118
60
|
- `mcp://nexus/docs/global-strategy`: Strategic blueprint.
|
|
61
|
+
- `mcp://nexus/docs/list`: Index of shared documents.
|
|
62
|
+
- `mcp://nexus/meetings/list`: List of active and closed meetings.
|
|
119
63
|
- `mcp://nexus/session`: Current session status and identity.
|
|
120
64
|
- `mcp://nexus/status`: System operational status and storage mode.
|
|
121
65
|
- `mcp://nexus/active-meeting`: Real-time transcript of the current active meeting.
|
|
122
|
-
|
|
123
|
-
|
|
66
|
+
|
|
67
|
+
**Resource Templates (Use registry to discover IDs):**
|
|
68
|
+
- `mcp://nexus/projects/{projectId}/manifest`: Full metadata for a specific project.
|
|
69
|
+
- `mcp://nexus/projects/{projectId}/internal-docs`: Internal technical docs for a project.
|
|
70
|
+
- `mcp://nexus/docs/{docId}`: Read a specific shared document.
|
|
71
|
+
- `mcp://nexus/meetings/{meetingId}`: Full transcript for a specific meeting.
|
|
72
|
+
|
|
73
|
+
## 🌐 Global Hub Architecture
|
|
74
|
+
|
|
75
|
+
**v0.3.0** introduces a fully automatic, zero-configuration collaboration architecture:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
79
|
+
│ Global Nexus Hub │
|
|
80
|
+
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
|
81
|
+
│ │ Cursor │ │ VS Code │ │ Claude │ │ Zed │ │
|
|
82
|
+
│ │ (Guest) │ │ (Guest) │ │ (Host) │ │ (Guest) │ │
|
|
83
|
+
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
|
|
84
|
+
│ │ │ │ │ │
|
|
85
|
+
│ └─────────────┴──────┬──────┴─────────────┘ │
|
|
86
|
+
│ │ SSE │
|
|
87
|
+
│ ┌───────▼───────┐ │
|
|
88
|
+
│ │ Port 5688 │ │
|
|
89
|
+
│ │ (Auto-Elected)│ │
|
|
90
|
+
│ └───────────────┘ │
|
|
91
|
+
└─────────────────────────────────────────────────────────────┘
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
- **Zero Config**: Just run `npx @datafrog-io/n2n-nexus` - no `--id` or `--host` required.
|
|
95
|
+
- **Auto Election**: First instance binds port 5688 and becomes Host; others join as Guests.
|
|
96
|
+
- **Cross-Project Sync**: All IDEs share the same Hub, enabling real-time cross-project meetings.
|
|
97
|
+
- **Hot Failover**: If Host disconnects, a Guest automatically promotes within 10 seconds.
|
|
124
98
|
|
|
125
99
|
## 🚀 Quick Start
|
|
126
100
|
|
|
@@ -128,7 +102,7 @@ To ensure clarity and prevent collisions in the flat local namespace, all Projec
|
|
|
128
102
|
|
|
129
103
|
Add to your MCP config file (e.g., `claude_desktop_config.json` or Cursor MCP settings):
|
|
130
104
|
|
|
131
|
-
####
|
|
105
|
+
#### Leader AI
|
|
132
106
|
```json
|
|
133
107
|
{
|
|
134
108
|
"mcpServers": {
|
|
@@ -137,8 +111,6 @@ Add to your MCP config file (e.g., `claude_desktop_config.json` or Cursor MCP se
|
|
|
137
111
|
"args": [
|
|
138
112
|
"-y",
|
|
139
113
|
"@datafrog-io/n2n-nexus",
|
|
140
|
-
"--id", "Master-AI",
|
|
141
|
-
"--moderator",
|
|
142
114
|
"--root", "D:/DevSpace/Nexus_Storage"
|
|
143
115
|
]
|
|
144
116
|
}
|
|
@@ -146,7 +118,7 @@ Add to your MCP config file (e.g., `claude_desktop_config.json` or Cursor MCP se
|
|
|
146
118
|
}
|
|
147
119
|
```
|
|
148
120
|
|
|
149
|
-
####
|
|
121
|
+
#### Collaborator AI
|
|
150
122
|
```json
|
|
151
123
|
{
|
|
152
124
|
"mcpServers": {
|
|
@@ -155,7 +127,6 @@ Add to your MCP config file (e.g., `claude_desktop_config.json` or Cursor MCP se
|
|
|
155
127
|
"args": [
|
|
156
128
|
"-y",
|
|
157
129
|
"@datafrog-io/n2n-nexus",
|
|
158
|
-
"--id", "Assistant-AI",
|
|
159
130
|
"--root", "D:/DevSpace/Nexus_Storage"
|
|
160
131
|
]
|
|
161
132
|
}
|
|
@@ -166,11 +137,9 @@ Add to your MCP config file (e.g., `claude_desktop_config.json` or Cursor MCP se
|
|
|
166
137
|
### CLI Arguments
|
|
167
138
|
| Argument | Description | Default |
|
|
168
139
|
|----------|-------------|---------|
|
|
169
|
-
| `--id` | Instance identifier for this AI agent | `Assistant` |
|
|
170
|
-
| `--moderator` | Grant admin privileges to this instance | `false` |
|
|
171
140
|
| `--root` | Local storage path for all Nexus data | `./storage` |
|
|
172
141
|
|
|
173
|
-
> **Note:**
|
|
142
|
+
> **Note:** Host identity and Instance ID are determined automatically based on the project folder name and startup order.
|
|
174
143
|
|
|
175
144
|
### Local Development
|
|
176
145
|
```bash
|
|
@@ -178,7 +147,7 @@ git clone https://github.com/n2ns/n2n-nexus.git
|
|
|
178
147
|
cd n2n-nexus
|
|
179
148
|
npm install
|
|
180
149
|
npm run build
|
|
181
|
-
npm start -- --
|
|
150
|
+
npm start -- --root ./my-storage
|
|
182
151
|
```
|
|
183
152
|
|
|
184
153
|
---
|
|
@@ -202,4 +171,13 @@ The following files demonstrate a real orchestration session where **4 AI agents
|
|
|
202
171
|
> *This is what AI-native development looks like.*
|
|
203
172
|
|
|
204
173
|
---
|
|
205
|
-
|
|
174
|
+
|
|
175
|
+
## ⭐ Support This Project
|
|
176
|
+
|
|
177
|
+
If **n2ns Nexus** helps you build better AI workflows, consider giving it a star! Your support helps us improve and motivates continued development.
|
|
178
|
+
|
|
179
|
+
[](https://github.com/n2ns/n2n-nexus)
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
© 2026 datafrog.io. Built for Local-Only AI Workflows.
|
package/build/config.js
CHANGED
|
@@ -1,14 +1,186 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import { fileURLToPath } from "url";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import http from "http";
|
|
3
6
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
4
7
|
const args = process.argv.slice(2);
|
|
8
|
+
// Load version from package.json
|
|
9
|
+
const pkgPath = path.resolve(__dirname, "../package.json");
|
|
10
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
5
11
|
const getArg = (k) => {
|
|
6
12
|
const i = args.indexOf(k);
|
|
7
13
|
return i !== -1 && args[i + 1] ? args[i + 1] : "";
|
|
8
14
|
};
|
|
9
|
-
const hasFlag = (k) => args.includes(k);
|
|
15
|
+
const hasFlag = (k) => args.includes(k) || args.includes(k.charAt(1) === "-" ? k : k.substring(0, 2));
|
|
16
|
+
// --- CLI Commands Handlers ---
|
|
17
|
+
if (hasFlag("--help") || hasFlag("-h")) {
|
|
18
|
+
console.log(`
|
|
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: ./storage
|
|
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.log(pkg.version);
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
// --- Path Normalization Logic ---
|
|
53
|
+
function normalizeRootPath(inputPath) {
|
|
54
|
+
// 1. Priority: CLI --root > ENV NEXUS_ROOT > Default ./storage
|
|
55
|
+
let root = inputPath || process.env.NEXUS_ROOT || path.join(__dirname, "../storage");
|
|
56
|
+
// 2. Resolve ~ to home directory
|
|
57
|
+
if (root.startsWith("~")) {
|
|
58
|
+
root = path.join(os.homedir(), root.slice(1));
|
|
59
|
+
}
|
|
60
|
+
// 3. Cross-platform adaptation (WSL <-> Windows)
|
|
61
|
+
// If running on Linux (WSL) but path looks like Windows (D:/ or C:\\)
|
|
62
|
+
if (process.platform === "linux" && /^[a-zA-Z]:[/\\]/.test(root)) {
|
|
63
|
+
const drive = root[0].toLowerCase();
|
|
64
|
+
root = `/mnt/${drive}${root.slice(2).replace(/\\/g, "/")}`;
|
|
65
|
+
}
|
|
66
|
+
return path.resolve(root);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Probe a port to see if it's a Nexus Host
|
|
70
|
+
*/
|
|
71
|
+
async function probeHost(port) {
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
const req = http.get(`http://127.0.0.1:${port}/hello`, { timeout: 500 }, (res) => {
|
|
74
|
+
let data = "";
|
|
75
|
+
res.on("data", (chunk) => data += chunk);
|
|
76
|
+
res.on("end", () => {
|
|
77
|
+
try {
|
|
78
|
+
const info = JSON.parse(data);
|
|
79
|
+
if (info.service === "n2n-nexus" && info.role === "host") {
|
|
80
|
+
resolve({ isNexus: true, rootStorage: info.rootStorage });
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
resolve({ isNexus: false });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
resolve({ isNexus: false });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
req.on("error", () => resolve({ isNexus: false }));
|
|
92
|
+
req.on("timeout", () => {
|
|
93
|
+
req.destroy();
|
|
94
|
+
resolve({ isNexus: false });
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Automatic Host Election (Port-Based 5688-5700)
|
|
100
|
+
* Strategy: Probe-First + Atomic Bind + Join Winner on Failure
|
|
101
|
+
*
|
|
102
|
+
* 1. First, scan all ports to find existing Host
|
|
103
|
+
* 2. If found, join it immediately
|
|
104
|
+
* 3. If not found, try to become Host
|
|
105
|
+
* 4. If bind fails, wait and re-probe (give winner time to start)
|
|
106
|
+
*/
|
|
107
|
+
async function isHostAutoElection(root) {
|
|
108
|
+
const startPort = 5688;
|
|
109
|
+
const endPort = 5700;
|
|
110
|
+
// Phase 1: Probe-First - Check if any Host already exists
|
|
111
|
+
for (let port = startPort; port <= endPort; port++) {
|
|
112
|
+
const probe = await probeHost(port);
|
|
113
|
+
if (probe.isNexus) {
|
|
114
|
+
return { isHost: false, port, rootStorage: probe.rootStorage };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Phase 2: No Host found, attempt to become Host
|
|
118
|
+
for (let port = startPort; port <= endPort; port++) {
|
|
119
|
+
const result = await new Promise((resolve) => {
|
|
120
|
+
const server = http.createServer((req, res) => {
|
|
121
|
+
if (req.url === "/hello") {
|
|
122
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
123
|
+
res.end(JSON.stringify({
|
|
124
|
+
service: "n2n-nexus",
|
|
125
|
+
role: "host",
|
|
126
|
+
version: pkg.version,
|
|
127
|
+
rootStorage: root
|
|
128
|
+
}));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
res.writeHead(404);
|
|
132
|
+
res.end();
|
|
133
|
+
});
|
|
134
|
+
server.on("error", (err) => {
|
|
135
|
+
if (err.code === "EADDRINUSE") {
|
|
136
|
+
resolve({ isHost: false });
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
resolve({ isHost: false });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
server.listen(port, "127.0.0.1", () => {
|
|
143
|
+
resolve({ isHost: true, server });
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
if (result.isHost) {
|
|
147
|
+
return { isHost: true, port, server: result.server };
|
|
148
|
+
}
|
|
149
|
+
// Phase 3: Bind failed - another Guest won. Wait then join winner.
|
|
150
|
+
await new Promise(r => setTimeout(r, 10000)); // Give winner 10s to start /hello
|
|
151
|
+
const probe = await probeHost(port);
|
|
152
|
+
if (probe.isNexus) {
|
|
153
|
+
return { isHost: false, port, rootStorage: probe.rootStorage };
|
|
154
|
+
}
|
|
155
|
+
// If still not Nexus, try next port (occupied by non-Nexus service)
|
|
156
|
+
}
|
|
157
|
+
// Fallback: become Host on startPort (should rarely happen)
|
|
158
|
+
return { isHost: true, port: startPort };
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Automatic Project Name Detection
|
|
162
|
+
*/
|
|
163
|
+
function getAutoProjectName() {
|
|
164
|
+
try {
|
|
165
|
+
const pkgPath = path.join(process.cwd(), "package.json");
|
|
166
|
+
if (fs.existsSync(pkgPath)) {
|
|
167
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
168
|
+
if (pkg.name)
|
|
169
|
+
return pkg.name.split("/").pop() || pkg.name;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch { /* ignore */ }
|
|
173
|
+
return path.basename(process.cwd()) || "Assistant";
|
|
174
|
+
}
|
|
175
|
+
const rootPath = normalizeRootPath(getArg("--root"));
|
|
176
|
+
const election = await isHostAutoElection(rootPath);
|
|
177
|
+
const projectName = getAutoProjectName();
|
|
178
|
+
export const hostServer = election.server;
|
|
10
179
|
export const CONFIG = {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
180
|
+
// Priority: CLI --id > Auto-named (Project Name only)
|
|
181
|
+
instanceId: getArg("--id") || projectName,
|
|
182
|
+
isHost: election.isHost,
|
|
183
|
+
// Inherit storage path if Guest, otherwise use local resolved path
|
|
184
|
+
rootStorage: election.isHost ? rootPath : (election.rootStorage || rootPath),
|
|
185
|
+
port: election.port
|
|
14
186
|
};
|
package/build/index.js
CHANGED
|
@@ -2,15 +2,17 @@
|
|
|
2
2
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ErrorCode, McpError, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
5
6
|
import { readFileSync } from "fs";
|
|
6
7
|
import { join } from "path";
|
|
7
8
|
import { fileURLToPath } from "url";
|
|
8
|
-
import
|
|
9
|
+
import http from "http";
|
|
10
|
+
import { CONFIG, hostServer } from "./config.js";
|
|
9
11
|
import { StorageManager } from "./storage/index.js";
|
|
10
12
|
import { TOOL_DEFINITIONS, handleToolCall } from "./tools/index.js";
|
|
11
13
|
import { listResources, getResourceContent } from "./resources/index.js";
|
|
12
14
|
import { sanitizeErrorMessage } from "./utils/error.js";
|
|
13
|
-
import {
|
|
15
|
+
import { checkHostPermission } from "./utils/auth.js";
|
|
14
16
|
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
15
17
|
const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
|
|
16
18
|
/**
|
|
@@ -21,6 +23,7 @@ const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8")
|
|
|
21
23
|
class NexusServer {
|
|
22
24
|
server;
|
|
23
25
|
currentProject = null;
|
|
26
|
+
sseTransports = new Map();
|
|
24
27
|
constructor() {
|
|
25
28
|
this.server = new Server({ name: "n2n-nexus", version: pkg.version }, { capabilities: { resources: {}, tools: {}, prompts: {} } });
|
|
26
29
|
this.setupHandlers();
|
|
@@ -62,8 +65,8 @@ class NexusServer {
|
|
|
62
65
|
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
63
66
|
const { name, arguments: toolArgs } = request.params;
|
|
64
67
|
try {
|
|
65
|
-
if (name.startsWith("
|
|
66
|
-
|
|
68
|
+
if (name.startsWith("host_"))
|
|
69
|
+
checkHostPermission(name);
|
|
67
70
|
const result = await handleToolCall(name, toolArgs, {
|
|
68
71
|
currentProject: this.currentProject,
|
|
69
72
|
setCurrentProject: (id) => { this.currentProject = id; },
|
|
@@ -131,29 +134,143 @@ class NexusServer {
|
|
|
131
134
|
const shutdown = async (signal) => {
|
|
132
135
|
console.error(`\n[Nexus] Received ${signal}. Shutting down...`);
|
|
133
136
|
try {
|
|
134
|
-
// Post-departure log
|
|
135
137
|
const msg = `Nexus Session Terminated (IDE Closed).`;
|
|
136
138
|
await StorageManager.addGlobalLog(`SYSTEM:${CONFIG.instanceId}`, msg, "UPDATE");
|
|
137
139
|
console.error(`[Nexus:${CONFIG.instanceId}] Goodbye!`);
|
|
138
140
|
}
|
|
139
|
-
catch {
|
|
140
|
-
// Ignore if storage is already cleaned up
|
|
141
|
-
}
|
|
141
|
+
catch { /* ignore */ }
|
|
142
142
|
process.exit(0);
|
|
143
143
|
};
|
|
144
144
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
145
145
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
// Announce presence
|
|
149
|
-
try {
|
|
146
|
+
if (CONFIG.isHost && hostServer) {
|
|
147
|
+
// --- HOST MODE: Central Hub ---
|
|
150
148
|
await StorageManager.init();
|
|
151
|
-
|
|
149
|
+
hostServer.on("request", async (req, res) => {
|
|
150
|
+
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
|
151
|
+
if (url.pathname === "/mcp") {
|
|
152
|
+
const guestId = url.searchParams.get("id") || "UnknownGuest";
|
|
153
|
+
if (req.method === "GET") {
|
|
154
|
+
const transport = new SSEServerTransport("/mcp", res);
|
|
155
|
+
this.sseTransports.set(transport.sessionId, transport);
|
|
156
|
+
const msg = `Guest Joined: ${guestId}`;
|
|
157
|
+
await StorageManager.addGlobalLog(`HOST:${CONFIG.instanceId}`, msg, "UPDATE");
|
|
158
|
+
console.error(`[Nexus Hub] ${msg} (Session: ${transport.sessionId})`);
|
|
159
|
+
// Heartbeat: keep connection alive
|
|
160
|
+
const heartbeat = setInterval(() => {
|
|
161
|
+
try {
|
|
162
|
+
res.write(": ping\n\n");
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
clearInterval(heartbeat);
|
|
166
|
+
}
|
|
167
|
+
}, 30000);
|
|
168
|
+
transport.onclose = () => {
|
|
169
|
+
this.sseTransports.delete(transport.sessionId);
|
|
170
|
+
clearInterval(heartbeat);
|
|
171
|
+
console.error(`[Nexus Hub] Guest Left: ${guestId}`);
|
|
172
|
+
};
|
|
173
|
+
await this.server.connect(transport);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
else if (req.method === "POST") {
|
|
177
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
178
|
+
const transport = sessionId ? this.sseTransports.get(sessionId) : null;
|
|
179
|
+
if (transport) {
|
|
180
|
+
await transport.handlePostMessage(req, res);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
res.writeHead(404).end("Session unknown");
|
|
184
|
+
}
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
// Support local stdio for the host's own IDE
|
|
190
|
+
const transport = new StdioServerTransport();
|
|
191
|
+
await this.server.connect(transport);
|
|
192
|
+
const onlineMsg = `Nexus Hub Active. Playing Host.`;
|
|
152
193
|
await StorageManager.addGlobalLog(`SYSTEM:${CONFIG.instanceId}`, onlineMsg, "UPDATE");
|
|
153
|
-
console.error(`[Nexus:${CONFIG.instanceId}] ${onlineMsg}`);
|
|
194
|
+
console.error(`[Nexus:${CONFIG.instanceId}] ${onlineMsg} (Port: ${CONFIG.port})`);
|
|
154
195
|
}
|
|
155
|
-
|
|
156
|
-
|
|
196
|
+
else {
|
|
197
|
+
// --- GUEST MODE: SSE Proxy ---
|
|
198
|
+
const guestId = CONFIG.instanceId;
|
|
199
|
+
// Random delay function to prevent thundering herd during re-election
|
|
200
|
+
const randomDelay = () => Math.floor(Math.random() * 3000);
|
|
201
|
+
const startProxy = () => {
|
|
202
|
+
// Clear any stale stdin listeners before starting
|
|
203
|
+
process.stdin.removeAllListeners("data");
|
|
204
|
+
console.error(`[Nexus:${guestId}] Global Hub detected at ${CONFIG.port}. Joining...`);
|
|
205
|
+
let sessionId = null;
|
|
206
|
+
let lastActivity = Date.now();
|
|
207
|
+
// Watchdog: trigger re-election if Host is silent for too long
|
|
208
|
+
const watchdog = setInterval(() => {
|
|
209
|
+
if (Date.now() - lastActivity > 60000) {
|
|
210
|
+
console.error("[Nexus Guest] Host stale. Triggering re-election...");
|
|
211
|
+
cleanup();
|
|
212
|
+
// Random delay to prevent all guests from racing for the port
|
|
213
|
+
setTimeout(() => this.run(), randomDelay());
|
|
214
|
+
}
|
|
215
|
+
}, 10000);
|
|
216
|
+
const cleanup = () => {
|
|
217
|
+
clearInterval(watchdog);
|
|
218
|
+
process.stdin.removeAllListeners("data");
|
|
219
|
+
};
|
|
220
|
+
const stdioHandler = (chunk) => {
|
|
221
|
+
if (!sessionId)
|
|
222
|
+
return;
|
|
223
|
+
try {
|
|
224
|
+
const req = http.request({
|
|
225
|
+
hostname: "127.0.0.1",
|
|
226
|
+
port: CONFIG.port,
|
|
227
|
+
path: `/mcp?sessionId=${sessionId}&id=${guestId}`,
|
|
228
|
+
method: "POST",
|
|
229
|
+
headers: { "Content-Type": "application/json" }
|
|
230
|
+
});
|
|
231
|
+
// Handle request errors to prevent unhandled exceptions
|
|
232
|
+
req.on("error", () => { });
|
|
233
|
+
req.write(chunk);
|
|
234
|
+
req.end();
|
|
235
|
+
}
|
|
236
|
+
catch { /* suppress */ }
|
|
237
|
+
};
|
|
238
|
+
process.stdin.on("data", stdioHandler);
|
|
239
|
+
http.get(`http://127.0.0.1:${CONFIG.port}/mcp?id=${guestId}`, (res) => {
|
|
240
|
+
let buffer = "";
|
|
241
|
+
res.on("data", (chunk) => {
|
|
242
|
+
lastActivity = Date.now();
|
|
243
|
+
const str = chunk.toString();
|
|
244
|
+
buffer += str;
|
|
245
|
+
if (!sessionId && buffer.includes("event: endpoint")) {
|
|
246
|
+
const match = buffer.match(/sessionId=([a-f0-9-]+)/);
|
|
247
|
+
if (match)
|
|
248
|
+
sessionId = match[1];
|
|
249
|
+
}
|
|
250
|
+
if (str.includes("event: message")) {
|
|
251
|
+
const lines = str.split("\n");
|
|
252
|
+
const dataLine = lines.find((l) => l.startsWith("data: "));
|
|
253
|
+
if (dataLine) {
|
|
254
|
+
try {
|
|
255
|
+
process.stdout.write(dataLine.substring(6) + "\n");
|
|
256
|
+
}
|
|
257
|
+
catch { }
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
res.on("end", () => {
|
|
262
|
+
console.error("[Nexus Guest] Lost connection to Host. Re-electing...");
|
|
263
|
+
cleanup();
|
|
264
|
+
// Random delay for re-election
|
|
265
|
+
setTimeout(() => this.run(), randomDelay());
|
|
266
|
+
});
|
|
267
|
+
}).on("error", () => {
|
|
268
|
+
console.error("[Nexus Guest] Proxy Receive Error. Retry with random delay...");
|
|
269
|
+
cleanup();
|
|
270
|
+
setTimeout(() => this.run(), 1000 + randomDelay());
|
|
271
|
+
});
|
|
272
|
+
};
|
|
273
|
+
startProxy();
|
|
157
274
|
}
|
|
158
275
|
}
|
|
159
276
|
}
|