@niroai/niro 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 +291 -0
- package/bin/niro.js +2 -0
- package/package.json +41 -0
- package/src/commands/_resolveProject.js +142 -0
- package/src/commands/add-project.js +190 -0
- package/src/commands/add-repo.js +270 -0
- package/src/commands/build.js +118 -0
- package/src/commands/delete.js +66 -0
- package/src/commands/edit.js +601 -0
- package/src/commands/info.js +172 -0
- package/src/commands/init.js +1166 -0
- package/src/commands/login.js +214 -0
- package/src/commands/logout.js +33 -0
- package/src/commands/mcp.js +40 -0
- package/src/commands/projects.js +76 -0
- package/src/commands/release.js +175 -0
- package/src/commands/remove.js +95 -0
- package/src/commands/status.js +75 -0
- package/src/commands/takeover.js +204 -0
- package/src/commands/watch.js +498 -0
- package/src/commands/whoami.js +74 -0
- package/src/index.js +293 -0
- package/src/lib/agentApi.js +276 -0
- package/src/lib/api.js +230 -0
- package/src/lib/browser.js +30 -0
- package/src/lib/color.js +46 -0
- package/src/lib/config.js +38 -0
- package/src/lib/credentials.js +73 -0
- package/src/lib/folderSync.js +503 -0
- package/src/lib/git.js +190 -0
- package/src/lib/moduleExcludeSuggestions.js +57 -0
- package/src/lib/niroProjectFile.js +76 -0
- package/src/lib/pendingRequests.js +99 -0
- package/src/lib/prompt.js +118 -0
- package/src/lib/resolveContext.js +108 -0
- package/src/lib/secret.js +114 -0
- package/src/lib/service.js +221 -0
- package/src/lib/spinner.js +109 -0
- package/src/lib/watchDaemon.js +93 -0
- package/src/lib/watchState.js +217 -0
- package/src/mcp/server.js +764 -0
- package/src/mcp/setup.js +1976 -0
- package/src/mcp/teardown.js +673 -0
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Niro MCP Server — stdio-to-SSE bridge.
|
|
5
|
+
*
|
|
6
|
+
* Reads MCP JSON-RPC messages from stdin, forwards them to the
|
|
7
|
+
* Niro ai-assistant SSE transport, and writes responses to stdout.
|
|
8
|
+
*
|
|
9
|
+
* Required env vars:
|
|
10
|
+
* NIRO_API_KEY — Your Niro API key (from console.niroai.dev)
|
|
11
|
+
*
|
|
12
|
+
* Optional env vars:
|
|
13
|
+
* NIRO_MCP_SERVER_URL — ai-assistant base URL the bridge connects to
|
|
14
|
+
* (default: https://aiassistant.niroai.dev). Falls back to
|
|
15
|
+
* the `mcpServerUrl` field in ~/.niro/config.json.
|
|
16
|
+
*
|
|
17
|
+
* Project detection (in priority order):
|
|
18
|
+
* 1. `.niro-project` file — walks up from process.cwd(); first non-comment line = project_id.
|
|
19
|
+
* 2. `~/.niro/projects.json` — written by the Niro desktop sync agent; path-prefix matched
|
|
20
|
+
* against cwd. Contains projectId for synced folders.
|
|
21
|
+
* 3. Git remote URL — `git remote get-url origin` is passed to the server for remote matching
|
|
22
|
+
* against stored project git mappings (RESOLVED / AMBIGUOUS → select_project / NONE).
|
|
23
|
+
* If none of the above resolve a project, falls back to multi-project mode.
|
|
24
|
+
*
|
|
25
|
+
* Flags:
|
|
26
|
+
* --setup — Interactive setup wizard (configures Claude, Cursor, Windsurf, etc.)
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
// Delegate to interactive setup if invoked with --setup
|
|
30
|
+
if (process.argv.includes("--setup")) {
|
|
31
|
+
require("./setup");
|
|
32
|
+
// setup.js handles its own exit; prevent server startup below
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const fs = require("fs");
|
|
37
|
+
const http = require("http");
|
|
38
|
+
const https = require("https");
|
|
39
|
+
const os = require("os");
|
|
40
|
+
const path = require("path");
|
|
41
|
+
const readline = require("readline");
|
|
42
|
+
const { execSync } = require("child_process");
|
|
43
|
+
const niroProjectFile = require("../lib/niroProjectFile");
|
|
44
|
+
const { redactRemoteCreds } = require("../lib/git");
|
|
45
|
+
|
|
46
|
+
/** Absolute path to ~/.niro/projects.json. Uses os.homedir() so it also resolves on Windows, where HOME is
|
|
47
|
+
* normally unset — `process.env.HOME || ""` there produced a relative path that never existed, silently
|
|
48
|
+
* no-op'ing local project resolution and the ADR-017 sibling-divergence feature. */
|
|
49
|
+
function niroProjectsFilePath() {
|
|
50
|
+
const home = process.env.HOME || process.env.USERPROFILE || os.homedir() || "";
|
|
51
|
+
return path.join(home, ".niro", "projects.json");
|
|
52
|
+
}
|
|
53
|
+
const { createPendingTracker, isTrackableRequest, resolveTimeoutMs } = require("../lib/pendingRequests");
|
|
54
|
+
|
|
55
|
+
const API_KEY = process.env.NIRO_API_KEY;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read the durable per-device CLI token from ~/.niro/credentials.json (written by `niro login`).
|
|
59
|
+
* This is the preferred MCP credential — it is sent as the X-Niro-Cli-Token header (never in the
|
|
60
|
+
* URL) and the server resolves the account/user from it. Returns the token or null; never logs it.
|
|
61
|
+
*/
|
|
62
|
+
function resolveCliToken() {
|
|
63
|
+
try {
|
|
64
|
+
const credsPath = path.join(
|
|
65
|
+
process.env.HOME || process.env.USERPROFILE || "", ".niro", "credentials.json");
|
|
66
|
+
if (!fs.existsSync(credsPath)) return null;
|
|
67
|
+
const creds = JSON.parse(fs.readFileSync(credsPath, "utf8"));
|
|
68
|
+
const token = creds && creds.cliToken;
|
|
69
|
+
return token && String(token).trim() ? String(token).trim() : null;
|
|
70
|
+
} catch (e) {
|
|
71
|
+
process.stderr.write(
|
|
72
|
+
`[niro-mcp] WARN: could not read CLI token from ~/.niro/credentials.json: ${e.message}\n`);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const CLI_TOKEN = resolveCliToken();
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Step 1: Walk up from cwd looking for a `.niro-project` file.
|
|
81
|
+
* Returns { id, source } or null. The parse (first non-comment, non-blank line)
|
|
82
|
+
* and walk-up live in ../lib/niroProjectFile so the writer and every reader share
|
|
83
|
+
* one definition — see that module's header for the drift this prevents.
|
|
84
|
+
*/
|
|
85
|
+
function resolveProjectIdFromWorkspace() {
|
|
86
|
+
return niroProjectFile.read(process.cwd());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Step 2: Check ~/.niro/projects.json for a path-prefix match against cwd.
|
|
91
|
+
* The file is written by the Niro desktop sync agent; keys are local directory paths.
|
|
92
|
+
* Returns { id, source } for the longest prefix match, or null.
|
|
93
|
+
*/
|
|
94
|
+
function resolveProjectIdFromNiroProjects() {
|
|
95
|
+
const projectsFile = niroProjectsFilePath();
|
|
96
|
+
const cwd = process.cwd();
|
|
97
|
+
try {
|
|
98
|
+
if (!fs.existsSync(projectsFile)) return null;
|
|
99
|
+
const projects = JSON.parse(fs.readFileSync(projectsFile, "utf8"));
|
|
100
|
+
let bestMatch = null;
|
|
101
|
+
let bestLen = 0;
|
|
102
|
+
for (const [dir, info] of Object.entries(projects)) {
|
|
103
|
+
// Normalise both sides to end with / for reliable prefix detection
|
|
104
|
+
const normalDir = dir.endsWith("/") ? dir : dir + "/";
|
|
105
|
+
const normalCwd = cwd.endsWith("/") ? cwd : cwd + "/";
|
|
106
|
+
if (normalCwd.startsWith(normalDir) && normalDir.length > bestLen && info && info.projectId) {
|
|
107
|
+
bestMatch = info;
|
|
108
|
+
bestLen = normalDir.length;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// ADR-018 (review MAJOR): for a taken-over folder this entry's projectId is the TEMP project —
|
|
112
|
+
// but the id resolved here travels as _niroPin, which is documented as a SOURCE binding. Sending
|
|
113
|
+
// the temp id would route verbatim forever (branch switches stop self-correcting). Prefer the
|
|
114
|
+
// recorded source, exactly like enumerateProjectRepos' sourceOf() does.
|
|
115
|
+
if (bestMatch) {
|
|
116
|
+
const id = (bestMatch.takeover && bestMatch.takeover.sourceProjectId) || bestMatch.projectId;
|
|
117
|
+
return { id, source: projectsFile };
|
|
118
|
+
}
|
|
119
|
+
} catch (e) {
|
|
120
|
+
process.stderr.write(`[niro-mcp] WARN: could not read ${projectsFile}: ${e.message}\n`);
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Step 3: Detect the git remote URL and current branch for server-side project resolution.
|
|
127
|
+
* Used when no project_id could be resolved locally. The server matches the remote URL
|
|
128
|
+
* against stored project git mappings.
|
|
129
|
+
* Returns { remote, branch } or null if not in a git repo or git is unavailable.
|
|
130
|
+
*/
|
|
131
|
+
function detectGitContext(dir) {
|
|
132
|
+
try {
|
|
133
|
+
const opts = { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] };
|
|
134
|
+
if (dir) opts.cwd = dir;
|
|
135
|
+
const remote = execSync("git remote get-url origin 2>/dev/null", opts).trim();
|
|
136
|
+
if (!remote) return null;
|
|
137
|
+
let branch = null;
|
|
138
|
+
try { branch = execSync("git rev-parse --abbrev-ref HEAD 2>/dev/null", opts).trim(); } catch (_) {}
|
|
139
|
+
// The freshness/takeover notices compare the local HEAD + dirty state against what Niro indexed, so
|
|
140
|
+
// the bridge must report them — without git_commit the server-side check no-ops entirely.
|
|
141
|
+
let commit = null;
|
|
142
|
+
try { commit = execSync("git rev-parse HEAD 2>/dev/null", opts).trim(); } catch (_) {}
|
|
143
|
+
let dirty = false;
|
|
144
|
+
try { dirty = execSync("git status --porcelain 2>/dev/null", opts).trim().length > 0; } catch (_) {}
|
|
145
|
+
return { remote, branch, commit, dirty };
|
|
146
|
+
} catch (_) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* ADR-017 #1 (robust): enumerate the git state of EVERY local repo of this session's project, not just
|
|
153
|
+
* the folder the bridge started in. The server compares each against the index to detect SIBLING
|
|
154
|
+
* divergence — so a session in `demo` that also branches `math` gets nudged to sandbox `math` too,
|
|
155
|
+
* instead of relying on the agent to remember. Siblings are found via ~/.niro/projects.json, related by
|
|
156
|
+
* shared SOURCE project: a sandboxed folder's `takeover.sourceProjectId`, else its `projectId`. This is
|
|
157
|
+
* BEST-EFFORT — only ONBOARDED folders are visible (a repo cloned but never `niro init`'d is invisible),
|
|
158
|
+
* and any failure yields [] so the single-folder `_niroGit` path (below) still fires. Capped to bound
|
|
159
|
+
* per-call git work.
|
|
160
|
+
*/
|
|
161
|
+
function enumerateProjectRepos() {
|
|
162
|
+
try {
|
|
163
|
+
const projectsFile = niroProjectsFilePath();
|
|
164
|
+
if (!fs.existsSync(projectsFile)) return [];
|
|
165
|
+
const entries = JSON.parse(fs.readFileSync(projectsFile, "utf8"));
|
|
166
|
+
const cwd = process.cwd();
|
|
167
|
+
// The session's own entry = the longest folderPath that is a prefix of cwd.
|
|
168
|
+
let sessionEntry = null, bestLen = 0;
|
|
169
|
+
for (const [dir, info] of Object.entries(entries)) {
|
|
170
|
+
if (!info) continue;
|
|
171
|
+
const nd = dir.endsWith("/") ? dir : dir + "/";
|
|
172
|
+
const nc = cwd.endsWith("/") ? cwd : cwd + "/";
|
|
173
|
+
if (nc.startsWith(nd) && nd.length > bestLen) { sessionEntry = info; bestLen = nd.length; }
|
|
174
|
+
}
|
|
175
|
+
const sourceOf = (e) => (e && e.takeover && e.takeover.sourceProjectId) || (e && e.projectId) || null;
|
|
176
|
+
const sourceProject = sourceOf(sessionEntry) || PROJECT_ID;
|
|
177
|
+
if (!sourceProject) return [];
|
|
178
|
+
// Backend scoping: project ids are human-chosen slugs that collide across a developer's backends, so
|
|
179
|
+
// relating siblings by project id ALONE would enumerate folders tracked only against ANOTHER stack and
|
|
180
|
+
// send their git state to a server that shouldn't see them. Scope to the session entry's own backend
|
|
181
|
+
// (its recorded apiUrl); legacy untagged entries (no apiUrl) stay wildcard for backward compatibility.
|
|
182
|
+
const sessionBackend = sessionEntry && sessionEntry.apiUrl;
|
|
183
|
+
const seen = new Set();
|
|
184
|
+
const repos = [];
|
|
185
|
+
for (const [dir, info] of Object.entries(entries)) {
|
|
186
|
+
if (!info || sourceOf(info) !== sourceProject) continue;
|
|
187
|
+
if (sessionBackend && info.apiUrl && info.apiUrl !== sessionBackend) continue; // different stack — skip
|
|
188
|
+
const folder = info.folderPath || dir;
|
|
189
|
+
if (seen.has(folder) || !fs.existsSync(folder)) continue;
|
|
190
|
+
seen.add(folder);
|
|
191
|
+
const git = detectGitContext(folder);
|
|
192
|
+
if (git && git.commit) {
|
|
193
|
+
// Redact any embedded credential before sending a sibling remote to the server — same as the
|
|
194
|
+
// single-repo _niroGit path (GIT_REMOTE above). Without this a token in a sibling remote leaks.
|
|
195
|
+
repos.push({ remote: redactRemoteCreds(git.remote), branch: git.branch || null, commit: git.commit, dirty: !!git.dirty });
|
|
196
|
+
}
|
|
197
|
+
if (repos.length >= 10) break; // safety cap on per-call git work
|
|
198
|
+
}
|
|
199
|
+
return repos;
|
|
200
|
+
} catch (_) {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Short-TTL cache over the per-call git enumeration. Every MCP tools/call runs detectGitContext (4 git
|
|
206
|
+
// spawns) + enumerateProjectRepos (up to ~40 more) — bursts of calls would run ~44 synchronous execSync
|
|
207
|
+
// each, blocking the bridge event loop and threatening the ≤2s MCP budget. A 2.5s TTL keyed by cwd collapses
|
|
208
|
+
// a burst to one enumeration while still catching a mid-session branch switch within the window.
|
|
209
|
+
const GIT_ENUM_TTL_MS = 2500;
|
|
210
|
+
let _gitCacheKey = null, _gitCacheAt = 0, _gitCacheVal;
|
|
211
|
+
let _reposCacheKey = null, _reposCacheAt = 0, _reposCacheVal;
|
|
212
|
+
function detectGitContextCached() {
|
|
213
|
+
const key = process.cwd();
|
|
214
|
+
const now = Date.now();
|
|
215
|
+
if (_gitCacheKey === key && now - _gitCacheAt < GIT_ENUM_TTL_MS) return _gitCacheVal;
|
|
216
|
+
_gitCacheVal = detectGitContext();
|
|
217
|
+
_gitCacheKey = key; _gitCacheAt = now;
|
|
218
|
+
return _gitCacheVal;
|
|
219
|
+
}
|
|
220
|
+
function enumerateProjectReposCached() {
|
|
221
|
+
const key = process.cwd();
|
|
222
|
+
const now = Date.now();
|
|
223
|
+
if (_reposCacheKey === key && now - _reposCacheAt < GIT_ENUM_TTL_MS) return _reposCacheVal;
|
|
224
|
+
_reposCacheVal = enumerateProjectRepos();
|
|
225
|
+
_reposCacheKey = key; _reposCacheAt = now;
|
|
226
|
+
return _reposCacheVal;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// --- Project detection chain ---
|
|
230
|
+
let PROJECT_ID = null;
|
|
231
|
+
let PROJECT_ID_SOURCE = null;
|
|
232
|
+
let GIT_REMOTE = null;
|
|
233
|
+
let GIT_BRANCH = null;
|
|
234
|
+
let GIT_COMMIT = null;
|
|
235
|
+
let GIT_DIRTY = false;
|
|
236
|
+
|
|
237
|
+
// ADR-018: set when the server's initialize result advertises capabilities.experimental
|
|
238
|
+
// .niroTempProjectRouting — only then does the bridge send the pin as _niroPin (routing ladder)
|
|
239
|
+
// instead of injecting it as project_id (pin-wins legacy behaviour). Reset on reconnect? No —
|
|
240
|
+
// the same backend answers the reconnected session, and initialize is replayed anyway (the
|
|
241
|
+
// re-advertised capability just re-sets it to true).
|
|
242
|
+
let routingCapable = false;
|
|
243
|
+
// Env escape hatch: NIRO_DISABLE_ROUTING=1 forces legacy pin-as-project_id even against a new
|
|
244
|
+
// server (operational rollback without downgrading the bridge).
|
|
245
|
+
function serverSupportsRouting() {
|
|
246
|
+
return routingCapable && process.env.NIRO_DISABLE_ROUTING !== "1";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const foundInFile = resolveProjectIdFromWorkspace();
|
|
250
|
+
if (foundInFile) {
|
|
251
|
+
PROJECT_ID = foundInFile.id;
|
|
252
|
+
PROJECT_ID_SOURCE = foundInFile.source;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (!PROJECT_ID) {
|
|
256
|
+
const foundInNiroProjects = resolveProjectIdFromNiroProjects();
|
|
257
|
+
if (foundInNiroProjects) {
|
|
258
|
+
PROJECT_ID = foundInNiroProjects.id;
|
|
259
|
+
PROJECT_ID_SOURCE = foundInNiroProjects.source;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Always detect the local git state — the freshness/takeover/release notices need branch + HEAD + dirty
|
|
264
|
+
// even when the project is ALREADY pinned (they compare the local checkout against what Niro indexed).
|
|
265
|
+
// The remote is additionally used for server-side project resolution when no project_id was found locally.
|
|
266
|
+
{
|
|
267
|
+
const gitCtx = detectGitContext();
|
|
268
|
+
if (gitCtx) {
|
|
269
|
+
GIT_REMOTE = redactRemoteCreds(gitCtx.remote); // never send/log an embedded token
|
|
270
|
+
GIT_BRANCH = gitCtx.branch;
|
|
271
|
+
GIT_COMMIT = gitCtx.commit;
|
|
272
|
+
GIT_DIRTY = gitCtx.dirty;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// The MCP bridge connects to ai-assistant. Resolution: env var, then
|
|
277
|
+
// ~/.niro/config.json (mcpServerUrl, written by the setup wizard), then prod default.
|
|
278
|
+
// Relative require from src/mcp/ resolves regardless of cwd.
|
|
279
|
+
function resolveMcpServerUrl() {
|
|
280
|
+
if (process.env.NIRO_MCP_SERVER_URL) return process.env.NIRO_MCP_SERVER_URL;
|
|
281
|
+
try {
|
|
282
|
+
const fromConfig = require("../lib/config").load().mcpServerUrl;
|
|
283
|
+
if (fromConfig) return fromConfig;
|
|
284
|
+
} catch (_) {
|
|
285
|
+
// No config / unreadable — fall through to default.
|
|
286
|
+
}
|
|
287
|
+
return "https://aiassistant.niroai.dev";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const API_URL = resolveMcpServerUrl();
|
|
291
|
+
|
|
292
|
+
function fatal(message) {
|
|
293
|
+
const error = {
|
|
294
|
+
jsonrpc: "2.0",
|
|
295
|
+
id: null,
|
|
296
|
+
error: { code: -32603, message },
|
|
297
|
+
};
|
|
298
|
+
process.stdout.write(JSON.stringify(error) + "\n");
|
|
299
|
+
process.stderr.write(`[niro-mcp] ERROR: ${message}\n`);
|
|
300
|
+
process.exit(1);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!API_KEY && !CLI_TOKEN) {
|
|
304
|
+
fatal("No Niro credential found. Run `niro login` to authenticate (preferred), or set NIRO_API_KEY.");
|
|
305
|
+
}
|
|
306
|
+
process.stderr.write(
|
|
307
|
+
`[niro-mcp] Auth: ${CLI_TOKEN ? "CLI token (~/.niro/credentials.json)" : "NIRO_API_KEY"}\n`);
|
|
308
|
+
if (PROJECT_ID) {
|
|
309
|
+
process.stderr.write(`[niro-mcp] Project: ${PROJECT_ID} (from ${PROJECT_ID_SOURCE})\n`);
|
|
310
|
+
} else if (GIT_REMOTE) {
|
|
311
|
+
process.stderr.write(`[niro-mcp] Git remote detected: ${GIT_REMOTE} — server will auto-resolve project.\n`);
|
|
312
|
+
} else {
|
|
313
|
+
process.stderr.write(
|
|
314
|
+
"[niro-mcp] No project detected — multi-project mode. Add a .niro-project file at the repo root (single line with project_id) or the Niro desktop agent will populate ~/.niro/projects.json.\n"
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const parsedUrl = new URL(API_URL);
|
|
319
|
+
const httpModule = parsedUrl.protocol === "https:" ? https : http;
|
|
320
|
+
|
|
321
|
+
let sessionId = null;
|
|
322
|
+
|
|
323
|
+
// --- SSE auto-reconnect + liveness ----------------------------------------------------
|
|
324
|
+
// niro is wired into MCP hosts as a STDIO server (via `niro mcp install` → `niro mcp serve`),
|
|
325
|
+
// and hosts (e.g. Claude Code) do NOT auto-reconnect stdio servers — they only reconnect native
|
|
326
|
+
// HTTP/SSE ones. So the bridge must heal its own upstream SSE stream: on a drop (or a silently
|
|
327
|
+
// half-open connection), re-establish the stream and replay `initialize` to re-authenticate the
|
|
328
|
+
// fresh server session, instead of exiting and forcing the user to manually reconnect the MCP.
|
|
329
|
+
// (Kept in lockstep with the standalone @niroai/mcp-server bridge — niro-mcp-server-npm/index.js.)
|
|
330
|
+
let generation = 0;
|
|
331
|
+
let reconnecting = false;
|
|
332
|
+
let currentReq = null;
|
|
333
|
+
let initializeRaw = null;
|
|
334
|
+
let reinitCounter = 0;
|
|
335
|
+
const SWALLOW = new Set();
|
|
336
|
+
let lastActivity = Date.now();
|
|
337
|
+
|
|
338
|
+
const RECONNECT_INITIAL_BACKOFF_MS = 1000;
|
|
339
|
+
const RECONNECT_MAX_BACKOFF_MS = 30000;
|
|
340
|
+
let reconnectBackoffMs = RECONNECT_INITIAL_BACKOFF_MS;
|
|
341
|
+
|
|
342
|
+
// A dead-but-open TCP stream looks alive until we try to use it. The server pings every ~15s,
|
|
343
|
+
// so a gap several times that long means the stream is gone. Default 45s; override with env.
|
|
344
|
+
const SSE_LIVENESS_MS = (() => {
|
|
345
|
+
const raw = parseInt(process.env.NIRO_SSE_LIVENESS_MS, 10);
|
|
346
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 45000;
|
|
347
|
+
})();
|
|
348
|
+
|
|
349
|
+
function sleep(ms) {
|
|
350
|
+
return new Promise((r) => {
|
|
351
|
+
const t = setTimeout(r, ms);
|
|
352
|
+
if (typeof t.unref === "function") t.unref();
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Client-side per-request deadline: turns a never-answered request into a clean JSON-RPC
|
|
357
|
+
// error instead of an indefinite hang. See src/lib/pendingRequests.js.
|
|
358
|
+
const pending = createPendingTracker({
|
|
359
|
+
timeoutMs: resolveTimeoutMs(process.env),
|
|
360
|
+
onExpire: (err) => process.stdout.write(JSON.stringify(err) + "\n"),
|
|
361
|
+
onLog: (m) => process.stderr.write(`[niro-mcp] WARN: ${m}\n`),
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Connect to the SSE endpoint and listen for responses.
|
|
366
|
+
*
|
|
367
|
+
* Resolves once the `endpoint` event arrives (sessionId set). After it resolves, a dropped or
|
|
368
|
+
* half-open stream routes to onConnectionLost() → reconnect, rather than exiting the process.
|
|
369
|
+
* Late callbacks from a superseded socket are ignored via the generation guard.
|
|
370
|
+
*/
|
|
371
|
+
function connectSse() {
|
|
372
|
+
return new Promise((resolve, reject) => {
|
|
373
|
+
const myGen = ++generation;
|
|
374
|
+
let settled = false;
|
|
375
|
+
// ADR-018: re-detect routing support per connection. A backend ROLLBACK mid-session (new→old)
|
|
376
|
+
// must drop the bridge back to legacy pin-as-project_id; the replayed initialize re-advertises
|
|
377
|
+
// the capability on a new backend (detection runs before the swallow), so upgrade re-detects too.
|
|
378
|
+
routingCapable = false;
|
|
379
|
+
|
|
380
|
+
// Abort any prior stream up front; its late 'end'/'error' now belong to an older generation
|
|
381
|
+
// and are ignored below, so they cannot trigger a second reconnect.
|
|
382
|
+
if (currentReq) {
|
|
383
|
+
try { currentReq.destroy(); } catch (_) {}
|
|
384
|
+
currentReq = null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// The CLI token is sent ONLY as a header (never in the URL/query, which gets logged by proxies).
|
|
388
|
+
// The legacy api_key is still passed for back-compat; the server prefers the CLI token.
|
|
389
|
+
const params = [];
|
|
390
|
+
if (API_KEY) params.push(`api_key=${encodeURIComponent(API_KEY)}`);
|
|
391
|
+
if (PROJECT_ID) {
|
|
392
|
+
params.push(`project_id=${encodeURIComponent(PROJECT_ID)}`);
|
|
393
|
+
} else {
|
|
394
|
+
params.push(`cwd=${encodeURIComponent(process.cwd())}`);
|
|
395
|
+
}
|
|
396
|
+
// Git context is sent regardless of how the project resolved — the freshness/takeover/release notices
|
|
397
|
+
// compare the local checkout (branch + HEAD + dirty) against the indexed graph even for a pinned project.
|
|
398
|
+
if (GIT_REMOTE) params.push(`git_remote=${encodeURIComponent(GIT_REMOTE)}`);
|
|
399
|
+
if (GIT_BRANCH) params.push(`git_branch=${encodeURIComponent(GIT_BRANCH)}`);
|
|
400
|
+
if (GIT_COMMIT) {
|
|
401
|
+
params.push(`git_commit=${encodeURIComponent(GIT_COMMIT)}`);
|
|
402
|
+
params.push(`git_dirty=${GIT_DIRTY ? "true" : "false"}`);
|
|
403
|
+
}
|
|
404
|
+
const sseUrl = `${API_URL}/mcp/sse${params.length ? "?" + params.join("&") : ""}`;
|
|
405
|
+
process.stderr.write(`[niro-mcp] Connecting to ${API_URL}/mcp/sse\n`);
|
|
406
|
+
process.stderr.write(`[niro-mcp] Session params: ${sseUrl.replace(/api_key=[^&]+/, "api_key=***")}\n`);
|
|
407
|
+
|
|
408
|
+
const sseHeaders = {
|
|
409
|
+
"Accept": "text/event-stream",
|
|
410
|
+
};
|
|
411
|
+
if (CLI_TOKEN) sseHeaders["X-Niro-Cli-Token"] = CLI_TOKEN;
|
|
412
|
+
if (API_KEY) sseHeaders["X-Niro-Api-Key"] = API_KEY;
|
|
413
|
+
if (PROJECT_ID) {
|
|
414
|
+
sseHeaders["X-Niro-Project-Id"] = PROJECT_ID;
|
|
415
|
+
} else {
|
|
416
|
+
sseHeaders["X-Niro-Cwd"] = process.cwd();
|
|
417
|
+
}
|
|
418
|
+
// Git context on every session (see params above) so the freshness notice works for pinned projects too.
|
|
419
|
+
if (GIT_REMOTE) sseHeaders["X-Niro-Git-Remote"] = GIT_REMOTE;
|
|
420
|
+
if (GIT_BRANCH) sseHeaders["X-Niro-Git-Branch"] = GIT_BRANCH;
|
|
421
|
+
if (GIT_COMMIT) {
|
|
422
|
+
sseHeaders["X-Niro-Git-Commit"] = GIT_COMMIT;
|
|
423
|
+
sseHeaders["X-Niro-Git-Dirty"] = GIT_DIRTY ? "true" : "false";
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const req = httpModule.get(sseUrl, {
|
|
427
|
+
headers: sseHeaders,
|
|
428
|
+
}, (res) => {
|
|
429
|
+
if (res.statusCode !== 200) {
|
|
430
|
+
settled = true;
|
|
431
|
+
try { req.destroy(); } catch (_) {}
|
|
432
|
+
reject(new Error(`SSE connection failed: HTTP ${res.statusCode}`));
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
currentReq = req;
|
|
437
|
+
lastActivity = Date.now();
|
|
438
|
+
let buffer = "";
|
|
439
|
+
|
|
440
|
+
res.on("data", (chunk) => {
|
|
441
|
+
if (myGen !== generation) return; // superseded socket
|
|
442
|
+
lastActivity = Date.now(); // ANY byte proves liveness — including the server ':hb' heartbeat comment
|
|
443
|
+
buffer += chunk.toString();
|
|
444
|
+
|
|
445
|
+
// SSE events are delimited by double newline
|
|
446
|
+
let doubleNewline;
|
|
447
|
+
while ((doubleNewline = buffer.indexOf("\n\n")) !== -1) {
|
|
448
|
+
const block = buffer.slice(0, doubleNewline);
|
|
449
|
+
buffer = buffer.slice(doubleNewline + 2);
|
|
450
|
+
|
|
451
|
+
// Parse the SSE block
|
|
452
|
+
let eventName = null;
|
|
453
|
+
let dataLines = [];
|
|
454
|
+
for (const line of block.split("\n")) {
|
|
455
|
+
if (line.startsWith("event:")) {
|
|
456
|
+
eventName = line.slice(6).trim();
|
|
457
|
+
} else if (line.startsWith("data:")) {
|
|
458
|
+
dataLines.push(line.slice(5));
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const data = dataLines.join("\n").trim();
|
|
463
|
+
if (!eventName || !data) continue; // comment/heartbeat blocks (no event:) — liveness already refreshed above
|
|
464
|
+
|
|
465
|
+
if (eventName === "endpoint") {
|
|
466
|
+
const match = data.match(/sessionId=([a-f0-9-]+)/);
|
|
467
|
+
if (match) {
|
|
468
|
+
sessionId = match[1];
|
|
469
|
+
settled = true;
|
|
470
|
+
process.stderr.write(`[niro-mcp] Session established: ${sessionId}\n`);
|
|
471
|
+
resolve();
|
|
472
|
+
}
|
|
473
|
+
} else if (eventName === "message") {
|
|
474
|
+
// Swallow the response to an internally-replayed initialize (sentinel id) so the host
|
|
475
|
+
// never sees a duplicate initialize result after a transparent reconnect.
|
|
476
|
+
let swallowed = false;
|
|
477
|
+
try {
|
|
478
|
+
const msg = JSON.parse(data);
|
|
479
|
+
// ADR-018 handshake: only an initialize result carries capabilities. When the server
|
|
480
|
+
// advertises niroTempProjectRouting, the bridge sends the folder pin as _niroPin (the
|
|
481
|
+
// server's routing ladder decides which project answers); otherwise it keeps injecting
|
|
482
|
+
// the pin as project_id — byte-identical pre-ADR-018 behaviour against old servers.
|
|
483
|
+
const exp = msg && msg.result && msg.result.capabilities
|
|
484
|
+
&& msg.result.capabilities.experimental;
|
|
485
|
+
// Presence check (truthy), not === true: the MCP spec requires experimental capability
|
|
486
|
+
// VALUES to be objects (a boolean fails the host's schema validation and kills the whole
|
|
487
|
+
// connection — found live), so the server sends {} and this must accept it.
|
|
488
|
+
if (exp && exp.niroTempProjectRouting && !routingCapable) {
|
|
489
|
+
routingCapable = true;
|
|
490
|
+
process.stderr.write("[niro-mcp] Server supports temp-project routing (_niroPin).\n");
|
|
491
|
+
}
|
|
492
|
+
if (msg && SWALLOW.has(msg.id)) {
|
|
493
|
+
SWALLOW.delete(msg.id);
|
|
494
|
+
swallowed = true;
|
|
495
|
+
} else {
|
|
496
|
+
// Cancel the client-side deadline when a delivered result matches an in-flight request.
|
|
497
|
+
pending.onIncoming(msg);
|
|
498
|
+
}
|
|
499
|
+
} catch (_) { /* not correlatable JSON — forward unchanged */ }
|
|
500
|
+
if (!swallowed) process.stdout.write(data + "\n");
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
res.on("error", (err) => {
|
|
506
|
+
if (myGen !== generation) return;
|
|
507
|
+
process.stderr.write(`[niro-mcp] SSE error: ${err.message}\n`);
|
|
508
|
+
onConnectionLost(myGen, err.message);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
res.on("end", () => {
|
|
512
|
+
if (myGen !== generation) return;
|
|
513
|
+
process.stderr.write("[niro-mcp] SSE connection closed\n");
|
|
514
|
+
onConnectionLost(myGen, "stream ended");
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
req.on("error", (err) => {
|
|
519
|
+
if (myGen !== generation) return;
|
|
520
|
+
// Redact api_key before it can reach an error string that fatal() writes to stdout/stderr.
|
|
521
|
+
const safeUrl = sseUrl.replace(/api_key=[^&]+/, "api_key=***");
|
|
522
|
+
if (!settled) {
|
|
523
|
+
settled = true;
|
|
524
|
+
reject(new Error(`Failed to connect to ${safeUrl}: ${err.message}`));
|
|
525
|
+
} else {
|
|
526
|
+
// Established stream failed later — reconnect rather than die.
|
|
527
|
+
process.stderr.write(`[niro-mcp] SSE request error: ${err.message}\n`);
|
|
528
|
+
onConnectionLost(myGen, err.message);
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Handle a lost SSE stream: mark reconnecting, drop the stale session (so POSTs during the gap
|
|
536
|
+
* fail fast rather than hang), and start the reconnect loop. Guarded so overlapping 'end'/'error'
|
|
537
|
+
* callbacks and the watchdog cannot start multiple loops.
|
|
538
|
+
*/
|
|
539
|
+
function onConnectionLost(gen, reason) {
|
|
540
|
+
if (gen !== generation) return; // stale socket
|
|
541
|
+
if (reconnecting) return; // already handling
|
|
542
|
+
reconnecting = true;
|
|
543
|
+
sessionId = null;
|
|
544
|
+
process.stderr.write(`[niro-mcp] SSE connection lost (${reason}); reconnecting...\n`);
|
|
545
|
+
reconnectLoop();
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Re-establish the SSE stream with capped exponential backoff, retrying indefinitely (a stdio
|
|
550
|
+
* bridge that gives up would force a manual host reconnect — the very thing we are removing).
|
|
551
|
+
* On success, replay initialize to re-authenticate the fresh server session.
|
|
552
|
+
*/
|
|
553
|
+
async function reconnectLoop() {
|
|
554
|
+
while (true) {
|
|
555
|
+
try {
|
|
556
|
+
await connectSse();
|
|
557
|
+
reconnectBackoffMs = RECONNECT_INITIAL_BACKOFF_MS;
|
|
558
|
+
await replayInitialize();
|
|
559
|
+
reconnecting = false;
|
|
560
|
+
process.stderr.write("[niro-mcp] Reconnected.\n");
|
|
561
|
+
return;
|
|
562
|
+
} catch (err) {
|
|
563
|
+
process.stderr.write(
|
|
564
|
+
`[niro-mcp] Reconnect attempt failed: ${err.message}; retrying in ${reconnectBackoffMs}ms\n`);
|
|
565
|
+
await sleep(reconnectBackoffMs);
|
|
566
|
+
reconnectBackoffMs = Math.min(reconnectBackoffMs * 2, RECONNECT_MAX_BACKOFF_MS);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Replay the host's initialize on the freshly reconnected session so tools/call re-authenticate
|
|
573
|
+
* (the server authenticates the session during initialize, not at SSE connect). The replay uses a
|
|
574
|
+
* sentinel id whose response is swallowed (see SWALLOW) so the host never sees a second init
|
|
575
|
+
* result. A no-op if the host has not initialized yet.
|
|
576
|
+
*/
|
|
577
|
+
async function replayInitialize() {
|
|
578
|
+
if (!initializeRaw) return;
|
|
579
|
+
const reinit = JSON.parse(initializeRaw);
|
|
580
|
+
// Negative sentinel id: JSON-RPC ids may be numbers, and host ids are small non-negative
|
|
581
|
+
// integers, so this cannot collide with a real in-flight request id.
|
|
582
|
+
const sentinelId = -(900000000 + (++reinitCounter));
|
|
583
|
+
reinit.id = sentinelId;
|
|
584
|
+
SWALLOW.add(sentinelId);
|
|
585
|
+
await sendMessage(reinit);
|
|
586
|
+
// The initialized notification has no id/response; safe to (re)send after every init.
|
|
587
|
+
await sendMessage({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
588
|
+
process.stderr.write("[niro-mcp] Re-sent initialize on the new session.\n");
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Send a JSON-RPC message to the server via HTTP POST.
|
|
593
|
+
*/
|
|
594
|
+
function sendMessage(message) {
|
|
595
|
+
return new Promise((resolve, reject) => {
|
|
596
|
+
// No live session (mid-reconnect): fail fast so the caller returns a clean transport error
|
|
597
|
+
// to the host instead of POSTing to sessionId=null and waiting out the deadline.
|
|
598
|
+
if (!sessionId) {
|
|
599
|
+
reject(new Error("no active Niro session (reconnecting)"));
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
const url = `${API_URL}/mcp/messages?sessionId=${sessionId}`;
|
|
603
|
+
const postData = JSON.stringify(injectProjectId(message));
|
|
604
|
+
const parsed = new URL(url);
|
|
605
|
+
|
|
606
|
+
const options = {
|
|
607
|
+
hostname: parsed.hostname,
|
|
608
|
+
port: parsed.port,
|
|
609
|
+
path: parsed.pathname + parsed.search,
|
|
610
|
+
method: "POST",
|
|
611
|
+
headers: {
|
|
612
|
+
"Content-Type": "application/json",
|
|
613
|
+
"Content-Length": Buffer.byteLength(postData),
|
|
614
|
+
},
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
const req = (parsed.protocol === "https:" ? https : http).request(options, (res) => {
|
|
618
|
+
res.resume(); // Drain response
|
|
619
|
+
resolve();
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
req.on("error", (err) => {
|
|
623
|
+
process.stderr.write(`[niro-mcp] POST error: ${err.message}\n`);
|
|
624
|
+
reject(err);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
req.write(postData);
|
|
628
|
+
req.end();
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Enrich an outgoing tools/call: send the folder's `.niro-project` pin as `_niroPin` (ADR-018 — a
|
|
634
|
+
* SOURCE binding the server's routing ladder consumes, NOT an explicit project choice), and
|
|
635
|
+
* re-detect the LIVE git state so routing + the freshness/takeover/release notices reflect a
|
|
636
|
+
* mid-session branch switch / new commit / edit. The git snapshot taken at SSE connect goes stale the
|
|
637
|
+
* moment the user changes branch, so it must be refreshed per call (cheap: rev-parse + status, a few ms).
|
|
638
|
+
* The server strips `_niroPin`/`_niroGit`/`_niroGitRepos` before the tool runs.
|
|
639
|
+
*
|
|
640
|
+
* ADR-018 contract: the pin must NOT travel as `project_id` — on the wire that field means "the
|
|
641
|
+
* agent deliberately chose this project" (step 0, never second-guessed). Sending the pin there
|
|
642
|
+
* would make every pinned call look explicit and the server's routing ladder would never run.
|
|
643
|
+
* An OLD server (pre-ADR-018) simply ignores unknown `_niroPin`+`_niroGit` extras… except it would
|
|
644
|
+
* then have NO project id at all — so for compatibility we still inject `project_id` when the
|
|
645
|
+
* server hasn't advertised routing support; see `serverSupportsRouting` below.
|
|
646
|
+
*/
|
|
647
|
+
function injectProjectId(message) {
|
|
648
|
+
if (message.method === "tools/call" && message.params) {
|
|
649
|
+
const args = message.params.arguments || {};
|
|
650
|
+
// Re-read `.niro-project` per call (cheap file walk, same per-call-refresh pattern used below for
|
|
651
|
+
// git state) so a MID-SESSION pin change — `niro init`, a hand edit, or an OLD CLI's pin swap —
|
|
652
|
+
// takes effect immediately instead of freezing at the startup value. (ADR-018 takeover no longer
|
|
653
|
+
// swaps the pin; server-side routing is what reflects a mid-session new-temp-project.)
|
|
654
|
+
let currentPin = null;
|
|
655
|
+
try {
|
|
656
|
+
const fromFile = resolveProjectIdFromWorkspace();
|
|
657
|
+
if (fromFile && fromFile.id) currentPin = fromFile.id;
|
|
658
|
+
} catch (_) { /* fall back to the startup value */ }
|
|
659
|
+
const effectiveProjectId = currentPin || PROJECT_ID;
|
|
660
|
+
if (!args.project_id && effectiveProjectId) {
|
|
661
|
+
if (serverSupportsRouting()) {
|
|
662
|
+
// New server: the pin rides in its own field; the routing ladder decides which project answers.
|
|
663
|
+
message.params.arguments = { ...args, _niroPin: effectiveProjectId };
|
|
664
|
+
} else {
|
|
665
|
+
// Old server: inject as project_id exactly as before (pin-wins behaviour, no routing).
|
|
666
|
+
message.params.arguments = { ...args, project_id: effectiveProjectId };
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
try {
|
|
670
|
+
const git = detectGitContextCached();
|
|
671
|
+
if (git && git.commit) {
|
|
672
|
+
message.params.arguments = {
|
|
673
|
+
...(message.params.arguments || {}),
|
|
674
|
+
_niroGit: { branch: git.branch || null, commit: git.commit, dirty: !!git.dirty },
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
// ADR-017 #1 (robust): also report every local repo of this project so the server can detect
|
|
678
|
+
// sibling divergence. Only attach when it found more than the session repo — keeps single-repo
|
|
679
|
+
// sessions byte-identical to before.
|
|
680
|
+
const repos = enumerateProjectReposCached();
|
|
681
|
+
if (repos.length > 1) {
|
|
682
|
+
message.params.arguments = {
|
|
683
|
+
...(message.params.arguments || {}),
|
|
684
|
+
_niroGitRepos: repos,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
} catch (_) { /* git unavailable — notices simply won't fire, never break the tool call */ }
|
|
688
|
+
}
|
|
689
|
+
return message;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Main: connect SSE, then read stdin line by line and forward to server.
|
|
694
|
+
*/
|
|
695
|
+
async function main() {
|
|
696
|
+
try {
|
|
697
|
+
await connectSse();
|
|
698
|
+
} catch (err) {
|
|
699
|
+
fatal(`Connection failed: ${err.message}`);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Liveness watchdog: a half-open TCP stream looks alive until we write to it, and its 'end'/
|
|
703
|
+
// 'error' may never fire. The server pings every ~15s, so a silence far longer than that means
|
|
704
|
+
// the stream is dead — force-drop it (its 'error' routes to onConnectionLost → reconnect).
|
|
705
|
+
const watchdog = setInterval(() => {
|
|
706
|
+
if (reconnecting || !sessionId) return;
|
|
707
|
+
if (Date.now() - lastActivity > SSE_LIVENESS_MS) {
|
|
708
|
+
process.stderr.write(
|
|
709
|
+
`[niro-mcp] No SSE activity for >${SSE_LIVENESS_MS}ms; forcing reconnect\n`);
|
|
710
|
+
if (currentReq) {
|
|
711
|
+
try { currentReq.destroy(new Error("liveness timeout")); } catch (_) {}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}, Math.max(5000, Math.floor(SSE_LIVENESS_MS / 3)));
|
|
715
|
+
if (typeof watchdog.unref === "function") watchdog.unref();
|
|
716
|
+
|
|
717
|
+
const rl = readline.createInterface({
|
|
718
|
+
input: process.stdin,
|
|
719
|
+
terminal: false,
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
rl.on("line", async (line) => {
|
|
723
|
+
line = line.trim();
|
|
724
|
+
if (!line) return;
|
|
725
|
+
|
|
726
|
+
let message;
|
|
727
|
+
try {
|
|
728
|
+
message = JSON.parse(line);
|
|
729
|
+
} catch (err) {
|
|
730
|
+
process.stderr.write(`[niro-mcp] Failed to parse message: ${err.message}\n`);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Remember the host's initialize so we can replay it verbatim to re-authenticate a fresh
|
|
735
|
+
// session after a transparent reconnect.
|
|
736
|
+
if (message.method === "initialize") initializeRaw = line;
|
|
737
|
+
|
|
738
|
+
// Arm the deadline for a real request before sending; a delivered result (or the
|
|
739
|
+
// send-failure path below) cancels it.
|
|
740
|
+
const trackable = isTrackableRequest(message);
|
|
741
|
+
if (trackable) pending.arm(message.id);
|
|
742
|
+
try {
|
|
743
|
+
await sendMessage(message);
|
|
744
|
+
} catch (err) {
|
|
745
|
+
process.stderr.write(`[niro-mcp] Failed to send message: ${err.message}\n`);
|
|
746
|
+
// The POST never reached the server, so no result will arrive: fail this request fast
|
|
747
|
+
// instead of waiting out the deadline.
|
|
748
|
+
if (trackable) {
|
|
749
|
+
pending.clear(message.id);
|
|
750
|
+
process.stdout.write(JSON.stringify({
|
|
751
|
+
jsonrpc: "2.0",
|
|
752
|
+
id: message.id,
|
|
753
|
+
error: { code: -32001, message: `Niro MCP transport error: ${err.message}` },
|
|
754
|
+
}) + "\n");
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
rl.on("close", () => {
|
|
760
|
+
process.exit(0);
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
main();
|