@niroai/niro 0.17.0 → 0.18.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.
Files changed (177) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +123 -302
  3. package/bin/niro.js +12 -1
  4. package/docs/agent-antigravity.md +37 -0
  5. package/docs/agent-claude-code.md +48 -0
  6. package/docs/agent-claude-desktop.md +47 -0
  7. package/docs/agent-cline.md +42 -0
  8. package/docs/agent-codex.md +45 -0
  9. package/docs/agent-copilot-cli.md +42 -0
  10. package/docs/agent-cursor.md +44 -0
  11. package/docs/agent-factory.md +42 -0
  12. package/docs/agent-gemini-cli.md +42 -0
  13. package/docs/agent-goose.md +42 -0
  14. package/docs/agent-kiro.md +37 -0
  15. package/docs/agent-opencode.md +37 -0
  16. package/docs/agent-vscode.md +45 -0
  17. package/docs/agent-windsurf.md +38 -0
  18. package/docs/answer-shapes.md +69 -0
  19. package/docs/builds-and-updates.md +68 -0
  20. package/docs/cloud-README.md +372 -0
  21. package/docs/configuring-a-folder.md +51 -0
  22. package/docs/connect-ci.md +24 -0
  23. package/docs/connect-overview.md +20 -0
  24. package/docs/connect-review-bots.md +17 -0
  25. package/docs/connect-team-server.md +12 -0
  26. package/docs/connect-terminal.md +49 -0
  27. package/docs/connect-web-agents.md +9 -0
  28. package/docs/connect-your-ai-assistant.md +59 -175
  29. package/docs/coverage-audit-and-consent.md +51 -0
  30. package/docs/evaluate-what-niro-did.md +271 -0
  31. package/docs/faq-assistants-and-mcp.md +122 -0
  32. package/docs/faq-feedback-and-receipts.md +33 -0
  33. package/docs/faq-indexing.md +127 -0
  34. package/docs/faq-install-and-setup.md +103 -0
  35. package/docs/faq-licence-and-plans.md +86 -0
  36. package/docs/faq-login-and-account.md +69 -0
  37. package/docs/faq-offline.md +34 -0
  38. package/docs/faq-performance.md +33 -0
  39. package/docs/faq-privacy-and-security.md +113 -0
  40. package/docs/faq-uninstall.md +47 -0
  41. package/docs/faq-updates.md +59 -0
  42. package/docs/faq-worktrees-and-branches.md +59 -0
  43. package/docs/faq-wrong-or-missing-answers.md +275 -0
  44. package/docs/hosted-only.md +33 -0
  45. package/docs/how-the-graph-works.md +47 -0
  46. package/docs/index.md +157 -0
  47. package/docs/licence-and-plans.md +72 -0
  48. package/docs/lifecycle-debug.md +48 -0
  49. package/docs/lifecycle-onboarding.md +32 -0
  50. package/docs/lifecycle-overview.md +77 -0
  51. package/docs/lifecycle-plan-and-design.md +44 -0
  52. package/docs/lifecycle-review.md +30 -0
  53. package/docs/lifecycle-security-and-privacy.md +35 -0
  54. package/docs/lifecycle-test.md +28 -0
  55. package/docs/lifecycle-write-code.md +35 -0
  56. package/docs/llms-full.txt +8015 -0
  57. package/docs/llms.txt +126 -0
  58. package/docs/playbook-add-a-feature.md +108 -0
  59. package/docs/playbook-change-safely.md +93 -0
  60. package/docs/playbook-data-and-events.md +107 -0
  61. package/docs/playbook-debug-after-a-change.md +96 -0
  62. package/docs/playbook-debug-config.md +95 -0
  63. package/docs/playbook-debug-exception.md +124 -0
  64. package/docs/playbook-debug-failing-service-call.md +119 -0
  65. package/docs/playbook-debug-slow-path.md +80 -0
  66. package/docs/playbook-debug-wrong-value.md +137 -0
  67. package/docs/playbook-onboard.md +95 -0
  68. package/docs/playbook-review-a-change.md +72 -0
  69. package/docs/playbook-test-a-change.md +73 -0
  70. package/docs/reference-cli.md +113 -0
  71. package/docs/reference-config-files.md +42 -0
  72. package/docs/reference-engine-commands.md +56 -0
  73. package/docs/reference-environment-variables.md +89 -0
  74. package/docs/reference-exit-codes.md +67 -0
  75. package/docs/reference-limits.md +75 -0
  76. package/docs/reference-mcp-tools.md +326 -0
  77. package/docs/reference-messages.md +180 -0
  78. package/docs/secrets-and-masking.md +48 -0
  79. package/docs/supported-c.md +130 -0
  80. package/docs/supported-cpp.md +157 -0
  81. package/docs/supported-csharp.md +211 -0
  82. package/docs/supported-go.md +258 -0
  83. package/docs/supported-java.md +226 -0
  84. package/docs/supported-javascript.md +223 -0
  85. package/docs/supported-kotlin.md +124 -0
  86. package/docs/supported-overview.md +89 -0
  87. package/docs/supported-php.md +157 -0
  88. package/docs/supported-python.md +224 -0
  89. package/docs/supported-ruby.md +162 -0
  90. package/docs/supported-rust.md +150 -0
  91. package/docs/supported-swift.md +133 -0
  92. package/docs/supported-typescript.md +220 -0
  93. package/docs/tokens-speed-and-accuracy.md +99 -0
  94. package/docs/troubleshooting.md +42 -0
  95. package/docs/uninstall.md +74 -0
  96. package/docs/what-leaves-your-machine.md +71 -0
  97. package/docs/where-things-are-stored.md +59 -0
  98. package/docs/worktrees-and-branches.md +55 -0
  99. package/package.json +5 -3
  100. package/src/commands/add-repo.js +5 -0
  101. package/src/commands/auditSend.js +67 -0
  102. package/src/commands/build.js +6 -0
  103. package/src/commands/docsSend.js +73 -0
  104. package/src/commands/engine.js +120 -0
  105. package/src/commands/engineRoutes.js +105 -0
  106. package/src/commands/guide.js +254 -0
  107. package/src/commands/init.js +6 -2
  108. package/src/commands/initLocal.js +96 -0
  109. package/src/commands/journalSend.js +47 -0
  110. package/src/commands/login.js +89 -12
  111. package/src/commands/mcp.js +22 -6
  112. package/src/commands/plan.js +99 -0
  113. package/src/commands/repoPush.js +33 -0
  114. package/src/commands/requestIntegration.js +38 -0
  115. package/src/commands/share.js +56 -0
  116. package/src/commands/start.js +451 -371
  117. package/src/commands/status.js +57 -0
  118. package/src/commands/takeover.js +6 -0
  119. package/src/commands/uninstall.js +180 -0
  120. package/src/commands/upgrade.js +38 -8
  121. package/src/commands/watch.js +420 -6
  122. package/src/index.js +174 -3
  123. package/src/lib/agentApi.js +32 -1
  124. package/src/lib/api.js +20 -6
  125. package/src/lib/auditUpload.js +293 -0
  126. package/src/lib/branchDrift.js +149 -0
  127. package/src/lib/choose.js +116 -0
  128. package/src/lib/cloudGuard.js +74 -0
  129. package/src/lib/credentials.js +2 -2
  130. package/src/lib/docsMarked.js +55 -0
  131. package/src/lib/docsState.js +213 -0
  132. package/src/lib/docsUpload.js +481 -0
  133. package/src/lib/engine.js +619 -0
  134. package/src/lib/folderSync.js +2 -1
  135. package/src/lib/graphCheck.js +40 -0
  136. package/src/lib/guide.js +293 -0
  137. package/src/lib/guideSearch.js +252 -0
  138. package/src/lib/integrationRequest.js +89 -0
  139. package/src/lib/journalUpload.js +147 -0
  140. package/src/lib/languages.js +129 -0
  141. package/src/lib/lease.js +375 -0
  142. package/src/lib/localBuild.js +285 -0
  143. package/src/lib/localEngine.js +210 -0
  144. package/src/lib/localOnboard.js +124 -0
  145. package/src/lib/localWorktrees.js +243 -0
  146. package/src/lib/machineCheck.js +58 -0
  147. package/src/lib/mcpClients.js +10 -1
  148. package/src/lib/migrationNotice.js +118 -0
  149. package/src/lib/modelFetch.js +153 -0
  150. package/src/lib/nestedOutput.js +23 -9
  151. package/src/lib/onboardingLog.js +34 -0
  152. package/src/lib/planUsage.js +165 -0
  153. package/src/lib/progressBar.js +46 -0
  154. package/src/lib/prompt.js +1 -1
  155. package/src/lib/redact.js +155 -0
  156. package/src/lib/release.js +33 -0
  157. package/src/lib/repoPush.js +495 -0
  158. package/src/lib/secret.js +1 -1
  159. package/src/lib/service.js +372 -63
  160. package/src/lib/shareCode.js +367 -0
  161. package/src/lib/shareExclusions.js +114 -0
  162. package/src/lib/shareFiles.js +129 -0
  163. package/src/lib/spinner.js +9 -5
  164. package/src/lib/watchDaemon.js +33 -10
  165. package/src/lib/watchState.js +12 -1
  166. package/src/lib/windowsPaths.js +67 -0
  167. package/src/mcp/lineCheck.js +382 -0
  168. package/src/mcp/lineName.js +92 -0
  169. package/src/mcp/localBackend.js +751 -0
  170. package/src/mcp/localMode.js +383 -0
  171. package/src/mcp/localSkill.js +189 -0
  172. package/src/mcp/server.js +610 -79
  173. package/src/mcp/setup.js +519 -131
  174. package/src/mcp/shellSearchHook.js +509 -0
  175. package/src/mcp/teardown.js +173 -91
  176. package/src/mcp/usingNiro.js +39 -0
  177. package/docs/working-on-branches.md +0 -162
package/src/mcp/server.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Niro MCP Server — stdio-to-SSE bridge.
4
+ * Niro MCP Server — stdio-to-HTTP bridge.
5
5
  *
6
- * Reads MCP JSON-RPC messages from stdin, forwards them to the
7
- * Niro ai-assistant SSE transport, and writes responses to stdout.
6
+ * Reads MCP JSON-RPC messages from stdin, forwards them to the Niro ai-assistant over
7
+ * Streamable HTTP (POST /mcp) or, on servers without it, the SSE transport (/mcp/sse),
8
+ * and writes responses to stdout.
8
9
  *
9
10
  * Required env vars:
10
11
  * NIRO_API_KEY — Your Niro API key (from console.niroai.dev)
@@ -13,6 +14,8 @@
13
14
  * NIRO_MCP_SERVER_URL — ai-assistant base URL the bridge connects to
14
15
  * (default: https://aiassistant.niroai.dev). Falls back to
15
16
  * the `mcpServerUrl` field in ~/.niro/config.json.
17
+ * NIRO_MCP_TRANSPORT — http | sse | auto (default). auto uses Streamable HTTP (POST /mcp)
18
+ * when the server offers it, else the SSE transport (/mcp/sse).
16
19
  *
17
20
  * Project detection (in priority order):
18
21
  * 1. `.niro-project` file — walks up from process.cwd(); first non-comment line = project_id.
@@ -52,6 +55,11 @@ function niroProjectsFilePath() {
52
55
  return path.join(home, ".niro", "projects.json");
53
56
  }
54
57
  const { createPendingTracker, isTrackableRequest, resolveTimeoutMs } = require("../lib/pendingRequests");
58
+ const { LocalBackend } = require("./localBackend");
59
+ const integrationRequest = require("../lib/integrationRequest");
60
+ const release = require("../lib/release");
61
+ const localBuild = require("../lib/localBuild");
62
+ const branchDrift = require("../lib/branchDrift");
55
63
  const { createImpactRecorder } = require("../lib/impactLog");
56
64
 
57
65
  // Evidence for the Edit|Write impact gate (docs/usecases/impact-gate.md): which impact tools this
@@ -343,19 +351,33 @@ function fatal(message) {
343
351
  process.exit(1);
344
352
  }
345
353
 
346
- if (!API_KEY && !CLI_TOKEN) {
354
+ /**
355
+ * Local mode: see localSend. Read here because a cloud credential is not needed in it. A release without the cloud is
356
+ * always local mode, whatever the registration says: one registered by an earlier release carries no NIRO_MCP_MODE, and
357
+ * with no login it stopped at the credential check below before the engine was started (the checker's note 1 on
358
+ * fix/cli-small, 2026-11-06).
359
+ */
360
+ const LOCAL_MODE = String(process.env.NIRO_MCP_MODE || "").toLowerCase() === "local" || !release.cloud;
361
+
362
+ // Local mode answers without a credential: nothing is asked of the cloud to answer. The licence a
363
+ // released engine needs is fetched with the login when there is one, and the engine itself says what
364
+ // to do when there is none (verifier, 2026-11-02: this check used to stop local mode at start).
365
+ if (!LOCAL_MODE && !API_KEY && !CLI_TOKEN) {
347
366
  fatal("No Niro credential found. Run `niro login` to authenticate (preferred), or set NIRO_API_KEY.");
348
367
  }
349
- process.stderr.write(
350
- `[niro-mcp] Auth: ${CLI_TOKEN ? "CLI token (~/.niro/credentials.json)" : "NIRO_API_KEY"}\n`);
351
- if (PROJECT_ID) {
352
- process.stderr.write(`[niro-mcp] Project: ${PROJECT_ID} (from ${PROJECT_ID_SOURCE})\n`);
353
- } else if (GIT_REMOTE) {
354
- process.stderr.write(`[niro-mcp] Git remote detected: ${GIT_REMOTE} — server will auto-resolve project.\n`);
355
- } else {
368
+ // What the bridge answers with, for whoever reads its log. Local mode uses none of it.
369
+ if (!LOCAL_MODE) {
356
370
  process.stderr.write(
357
- "[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"
358
- );
371
+ `[niro-mcp] Auth: ${CLI_TOKEN ? "CLI token (~/.niro/credentials.json)" : "NIRO_API_KEY"}\n`);
372
+ if (PROJECT_ID) {
373
+ process.stderr.write(`[niro-mcp] Project: ${PROJECT_ID} (from ${PROJECT_ID_SOURCE})\n`);
374
+ } else if (GIT_REMOTE) {
375
+ process.stderr.write(`[niro-mcp] Git remote detected: ${GIT_REMOTE} — server will auto-resolve project.\n`);
376
+ } else {
377
+ process.stderr.write(
378
+ "[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"
379
+ );
380
+ }
359
381
  }
360
382
 
361
383
  const parsedUrl = new URL(API_URL);
@@ -404,6 +426,36 @@ const pending = createPendingTracker({
404
426
  onLog: (m) => process.stderr.write(`[niro-mcp] WARN: ${m}\n`),
405
427
  });
406
428
 
429
+ /**
430
+ * The X-Niro-* identity headers every session carries: credential, bridge marker + version, project
431
+ * (or cwd) and the startup git context. Shared by the SSE GET and every Streamable HTTP request.
432
+ */
433
+ function niroHeaders() {
434
+ const h = {};
435
+ if (CLI_TOKEN) h["X-Niro-Cli-Token"] = CLI_TOKEN;
436
+ if (API_KEY) h["X-Niro-Api-Key"] = API_KEY;
437
+ // Sent on every session so the server can spot an out-of-date install (see CLI_VERSION). The
438
+ // marker is what identifies THIS bridge: @niroai/mcp-server is a separate published bridge that
439
+ // hits the same /mcp/sse endpoint and sends neither header, so the server must not infer "old
440
+ // niro CLI" from a missing version alone — it would tell those users to upgrade a package they do
441
+ // not have installed, on every session, with no way to stop it.
442
+ h["X-Niro-Bridge"] = "niro-cli";
443
+ if (CLI_VERSION) h["X-Niro-Cli-Version"] = CLI_VERSION;
444
+ if (PROJECT_ID) {
445
+ h["X-Niro-Project-Id"] = PROJECT_ID;
446
+ } else {
447
+ h["X-Niro-Cwd"] = process.cwd();
448
+ }
449
+ // Git context on every session (see the SSE query params) so the freshness notice works for pinned projects too.
450
+ if (GIT_REMOTE) h["X-Niro-Git-Remote"] = GIT_REMOTE;
451
+ if (GIT_BRANCH) h["X-Niro-Git-Branch"] = GIT_BRANCH;
452
+ if (GIT_COMMIT) {
453
+ h["X-Niro-Git-Commit"] = GIT_COMMIT;
454
+ h["X-Niro-Git-Dirty"] = GIT_DIRTY ? "true" : "false";
455
+ }
456
+ return h;
457
+ }
458
+
407
459
  /**
408
460
  * Connect to the SSE endpoint and listen for responses.
409
461
  *
@@ -450,28 +502,8 @@ function connectSse() {
450
502
 
451
503
  const sseHeaders = {
452
504
  "Accept": "text/event-stream",
505
+ ...niroHeaders(),
453
506
  };
454
- if (CLI_TOKEN) sseHeaders["X-Niro-Cli-Token"] = CLI_TOKEN;
455
- if (API_KEY) sseHeaders["X-Niro-Api-Key"] = API_KEY;
456
- // Sent on every session so the server can spot an out-of-date install (see CLI_VERSION). The
457
- // marker is what identifies THIS bridge: @niroai/mcp-server is a separate published bridge that
458
- // hits the same /mcp/sse endpoint and sends neither header, so the server must not infer "old
459
- // niro CLI" from a missing version alone — it would tell those users to upgrade a package they do
460
- // not have installed, on every session, with no way to stop it.
461
- sseHeaders["X-Niro-Bridge"] = "niro-cli";
462
- if (CLI_VERSION) sseHeaders["X-Niro-Cli-Version"] = CLI_VERSION;
463
- if (PROJECT_ID) {
464
- sseHeaders["X-Niro-Project-Id"] = PROJECT_ID;
465
- } else {
466
- sseHeaders["X-Niro-Cwd"] = process.cwd();
467
- }
468
- // Git context on every session (see params above) so the freshness notice works for pinned projects too.
469
- if (GIT_REMOTE) sseHeaders["X-Niro-Git-Remote"] = GIT_REMOTE;
470
- if (GIT_BRANCH) sseHeaders["X-Niro-Git-Branch"] = GIT_BRANCH;
471
- if (GIT_COMMIT) {
472
- sseHeaders["X-Niro-Git-Commit"] = GIT_COMMIT;
473
- sseHeaders["X-Niro-Git-Dirty"] = GIT_DIRTY ? "true" : "false";
474
- }
475
507
 
476
508
  const req = httpModule.get(sseUrl, {
477
509
  headers: sseHeaders,
@@ -521,39 +553,7 @@ function connectSse() {
521
553
  resolve();
522
554
  }
523
555
  } else if (eventName === "message") {
524
- // Swallow the response to an internally-replayed initialize (sentinel id) so the host
525
- // never sees a duplicate initialize result after a transparent reconnect.
526
- let swallowed = false;
527
- let rewritten = null;
528
- try {
529
- const msg = JSON.parse(data);
530
- // ADR-018 handshake: only an initialize result carries capabilities. When the server
531
- // advertises niroTempProjectRouting, the bridge sends the folder pin as _niroPin (the
532
- // server's routing ladder decides which project answers); otherwise it keeps injecting
533
- // the pin as project_id — byte-identical pre-ADR-018 behaviour against old servers.
534
- const exp = msg && msg.result && msg.result.capabilities
535
- && msg.result.capabilities.experimental;
536
- // Presence check (truthy), not === true: the MCP spec requires experimental capability
537
- // VALUES to be objects (a boolean fails the host's schema validation and kills the whole
538
- // connection — found live), so the server sends {} and this must accept it.
539
- if (exp && exp.niroTempProjectRouting && !routingCapable) {
540
- routingCapable = true;
541
- process.stderr.write("[niro-mcp] Server supports temp-project routing (_niroPin).\n");
542
- }
543
- if (msg && SWALLOW.has(msg.id)) {
544
- SWALLOW.delete(msg.id);
545
- swallowed = true;
546
- } else {
547
- // Cancel the client-side deadline when a delivered result matches an in-flight request.
548
- pending.onIncoming(msg);
549
- impact.noteResult(msg);
550
- impact.touchAlive();
551
- rewritten = captureReceipt(msg);
552
- }
553
- } catch (_) { /* not correlatable JSON — forward unchanged */ }
554
- // captureReceipt returns a rewritten payload when it stripped the receipt field so the
555
- // Stop hook can print it instead; otherwise the original bytes go through untouched.
556
- if (!swallowed) process.stdout.write((rewritten || data) + "\n");
556
+ relayServerMessage(data);
557
557
  }
558
558
  }
559
559
  });
@@ -587,6 +587,49 @@ function connectSse() {
587
587
  });
588
588
  }
589
589
 
590
+ /**
591
+ * Hand one server→client JSON-RPC message to the host (stdout), shared by both transports: detects the
592
+ * ADR-018 routing capability, swallows replayed-initialize replies, cancels the request deadline, feeds the
593
+ * impact gate and strips a disabled receipt. Returns the parsed message (null if it was not JSON).
594
+ */
595
+ function relayServerMessage(data) {
596
+ // Swallow the response to an internally-replayed initialize (sentinel id) so the host
597
+ // never sees a duplicate initialize result after a transparent reconnect.
598
+ let swallowed = false;
599
+ let rewritten = null;
600
+ let msg = null;
601
+ try {
602
+ msg = JSON.parse(data);
603
+ // ADR-018 handshake: only an initialize result carries capabilities. When the server
604
+ // advertises niroTempProjectRouting, the bridge sends the folder pin as _niroPin (the
605
+ // server's routing ladder decides which project answers); otherwise it keeps injecting
606
+ // the pin as project_id — byte-identical pre-ADR-018 behaviour against old servers.
607
+ const exp = msg && msg.result && msg.result.capabilities
608
+ && msg.result.capabilities.experimental;
609
+ // Presence check (truthy), not === true: the MCP spec requires experimental capability
610
+ // VALUES to be objects (a boolean fails the host's schema validation and kills the whole
611
+ // connection — found live), so the server sends {} and this must accept it.
612
+ if (exp && exp.niroTempProjectRouting && !routingCapable) {
613
+ routingCapable = true;
614
+ process.stderr.write("[niro-mcp] Server supports temp-project routing (_niroPin).\n");
615
+ }
616
+ if (msg && SWALLOW.has(msg.id)) {
617
+ SWALLOW.delete(msg.id);
618
+ swallowed = true;
619
+ } else {
620
+ // Cancel the client-side deadline when a delivered result matches an in-flight request.
621
+ pending.onIncoming(msg);
622
+ impact.noteResult(msg);
623
+ impact.touchAlive();
624
+ rewritten = captureReceipt(msg);
625
+ }
626
+ } catch (_) { /* not correlatable JSON — forward unchanged */ }
627
+ // captureReceipt returns a rewritten payload when it stripped the receipt field so the
628
+ // Stop hook can print it instead; otherwise the original bytes go through untouched.
629
+ if (!swallowed) process.stdout.write((rewritten || data) + "\n");
630
+ return msg;
631
+ }
632
+
590
633
  /**
591
634
  * Handle a lost SSE stream: mark reconnecting, drop the stale session (so POSTs during the gap
592
635
  * fail fast rather than hang), and start the reconnect loop. Guarded so overlapping 'end'/'error'
@@ -628,9 +671,9 @@ async function reconnectLoop() {
628
671
  * Replay the host's initialize on the freshly reconnected session so tools/call re-authenticate
629
672
  * (the server authenticates the session during initialize, not at SSE connect). The replay uses a
630
673
  * sentinel id whose response is swallowed (see SWALLOW) so the host never sees a second init
631
- * result. A no-op if the host has not initialized yet.
674
+ * result. A no-op if the host has not initialized yet. `send` is the transport's sender (SSE by default).
632
675
  */
633
- async function replayInitialize() {
676
+ async function replayInitialize(send = sendMessage) {
634
677
  if (!initializeRaw) return;
635
678
  const reinit = JSON.parse(initializeRaw);
636
679
  // Negative sentinel id: JSON-RPC ids may be numbers, and host ids are small non-negative
@@ -638,9 +681,9 @@ async function replayInitialize() {
638
681
  const sentinelId = -(900000000 + (++reinitCounter));
639
682
  reinit.id = sentinelId;
640
683
  SWALLOW.add(sentinelId);
641
- await sendMessage(reinit);
684
+ await send(reinit);
642
685
  // The initialized notification has no id/response; safe to (re)send after every init.
643
- await sendMessage({ jsonrpc: "2.0", method: "notifications/initialized" });
686
+ await send({ jsonrpc: "2.0", method: "notifications/initialized" });
644
687
  process.stderr.write("[niro-mcp] Re-sent initialize on the new session.\n");
645
688
  }
646
689
 
@@ -795,10 +838,350 @@ function injectProjectId(message) {
795
838
  return message;
796
839
  }
797
840
 
841
+ // --- Streamable HTTP transport (MCP 2025-03-26 / 2025-06-18) --------------------------------
842
+ // One POST to ${API_URL}/mcp per host message; the server answers in that POST's own response —
843
+ // application/json (one message) or text/event-stream (server messages, then the response). Any
844
+ // ai-assistant task can serve any call, so there is no long-lived stream to babysit. Chosen by the host's initialize
845
+ // when the server offers it (NIRO_MCP_TRANSPORT=http|sse forces one; default auto), else the SSE path
846
+ // above runs unchanged. (Kept in lockstep with the standalone bridge — niro-mcp-server-npm/index.js.)
847
+ const MCP_URL = `${API_URL}/mcp`;
848
+ const DEFAULT_PROTOCOL_VERSION = "2025-06-18";
849
+ const HTTP_RETRY_MS = 500;
850
+ const HTTP_PROBE_TIMEOUT_MS = 10000;
851
+ const HTTP_DELETE_TIMEOUT_MS = 2000;
852
+ let httpSessionId = null;
853
+ let httpProtocolVersion = null;
854
+ let httpRenewing = null; // the in-flight session renewal, shared by every request that hit the 404
855
+ let getStream = null; // AbortController of the standalone GET stream
856
+
857
+ function httpHeaders(sid, extra) {
858
+ const h = { ...niroHeaders(), ...extra };
859
+ if (sid) {
860
+ h["Mcp-Session-Id"] = sid;
861
+ if (httpProtocolVersion) h["MCP-Protocol-Version"] = httpProtocolVersion;
862
+ }
863
+ return h;
864
+ }
865
+
866
+ /** fetch() hides the useful part (ECONNREFUSED, ENOTFOUND, ...) in err.cause. */
867
+ function netError(err) {
868
+ return (err && err.cause && (err.cause.code || err.cause.message)) || (err && err.message) || String(err);
869
+ }
870
+
871
+ function drain(res) {
872
+ if (res.body) res.body.cancel().catch(() => {});
873
+ }
874
+
875
+ // A failed POST is retried once only when that cannot run a call twice: the connection never opened
876
+ // (nothing was sent), or the method is safe to repeat. A tools/call that may have reached the server
877
+ // gets a JSON-RPC error instead — a second run could repeat its side effects.
878
+ const NOT_SENT_CODES = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN"]);
879
+ const SAFE_TO_REPEAT = new Set(["initialize", "ping", "tools/list", "resources/list", "prompts/list"]);
880
+
881
+ function netCode(err) {
882
+ const c = err && err.cause;
883
+ return c && (c.code || (c.errors && c.errors[0] && c.errors[0].code)); // AggregateError: happy eyeballs
884
+ }
885
+
886
+ function retryable(err, method) {
887
+ const m = String(method || "");
888
+ return NOT_SENT_CODES.has(netCode(err)) || SAFE_TO_REPEAT.has(m) || m.startsWith("notifications/");
889
+ }
890
+
891
+ /** One POST, retried once after a short backoff on a network error when retryable() (never on an HTTP status). */
892
+ async function httpPost(body, sid, method, signal) {
893
+ const opts = {
894
+ method: "POST",
895
+ body,
896
+ headers: httpHeaders(sid, { "Content-Type": "application/json", "Accept": "application/json, text/event-stream" }),
897
+ signal,
898
+ };
899
+ for (let attempt = 1; ; attempt++) {
900
+ try {
901
+ return await fetch(MCP_URL, opts);
902
+ } catch (err) {
903
+ if (attempt > 1 || !retryable(err, method)) {
904
+ throw new Error(`could not reach ${MCP_URL}: ${netError(err)}${attempt > 1 ? "" : " (not retried: it may have reached the server)"}`);
905
+ }
906
+ process.stderr.write(`[niro-mcp] POST /mcp failed (${netError(err)}); retrying once\n`);
907
+ await sleep(HTTP_RETRY_MS);
908
+ }
909
+ }
910
+ }
911
+
912
+ /** Yields the data of each SSE `message` event in order (an event with no name is a message, per spec). */
913
+ async function* sseMessages(body) {
914
+ const decoder = new TextDecoder();
915
+ let buffer = "";
916
+ for await (const chunk of body) {
917
+ buffer = (buffer + decoder.decode(chunk, { stream: true })).replace(/\r\n/g, "\n");
918
+ let end;
919
+ while ((end = buffer.indexOf("\n\n")) !== -1) {
920
+ const block = buffer.slice(0, end);
921
+ buffer = buffer.slice(end + 2);
922
+ let eventName = "message";
923
+ const dataLines = [];
924
+ for (const line of block.split("\n")) {
925
+ if (line.startsWith("event:")) eventName = line.slice(6).trim();
926
+ else if (line.startsWith("data:")) dataLines.push(line.slice(5));
927
+ }
928
+ const data = dataLines.join("\n").trim();
929
+ if (eventName === "message" && data) yield data;
930
+ }
931
+ }
932
+ }
933
+
934
+ /**
935
+ * The engine on this machine, started on the first call that needs it.
936
+ *
937
+ * Not at startup: most sessions are in a cloud-indexed folder and would pay for an engine they
938
+ * never ask anything, and a machine with no engine must not have a failure at connect.
939
+ */
940
+ const localBackend = new LocalBackend();
941
+
798
942
  /**
799
- * Main: connect SSE, then read stdin line by line and forward to server.
943
+ * Is this session's folder answered by the engine on this machine?
944
+ *
945
+ * Asked once per process and remembered. The answer cannot change without a command being run
946
+ * in that folder, and `localBuild.isLocallyIndexed` already caches it per folder and answers
947
+ * "not local" for a machine with no engine, which is what keeps every existing customer on the
948
+ * cloud path.
800
949
  */
801
- async function main() {
950
+ let locality = null;
951
+
952
+ /** Whether the branch notice has been given in this session. One line, once. */
953
+ let driftSaid = false;
954
+
955
+ async function answeredLocally() {
956
+ if (locality === null) {
957
+ locality = await localBuild.isLocallyIndexed(process.cwd());
958
+ if (locality) {
959
+ process.stderr.write(
960
+ "[niro-mcp] This folder is indexed locally; tool calls are answered on this machine\n");
961
+ }
962
+ }
963
+ return locality;
964
+ }
965
+
966
+ /**
967
+ * Answer a `tools/call` from the local graph, and write the result to stdout.
968
+ *
969
+ * Returns true when it answered, false when the call should go to the cloud after all. Nothing
970
+ * here can throw: a failure of the local engine becomes a tool result saying so, because an
971
+ * exception in the bridge would look to the assistant like the whole server died.
972
+ *
973
+ * `initialize` and `tools/list` are deliberately NOT routed here. The cloud answers both, so
974
+ * the assistant sees one toolset whichever graph is behind it, and a tool the local graph
975
+ * cannot serve says so when it is called (Niraj, 2026-10-14).
976
+ */
977
+ async function answerLocally(message) {
978
+ const toolName = message.params && message.params.name;
979
+ if (!toolName) return false;
980
+ const started = Date.now();
981
+ let result;
982
+ try {
983
+ await localBackend.start();
984
+ if (!localBackend.serves(toolName)) {
985
+ result = LocalBackend.cannotServe(toolName);
986
+ } else {
987
+ result = await localBackend.call(toolName, (message.params || {}).arguments || {});
988
+ }
989
+ } catch (err) {
990
+ // The engine would not start, was refused a licence, or did not answer. Said as a tool
991
+ // result rather than an error: an assistant reads a result and can act on it, while a
992
+ // JSON-RPC error reads as the server being broken.
993
+ result = {
994
+ content: [
995
+ {
996
+ type: "text",
997
+ text:
998
+ "The local engine could not answer: " + ((err && err.message) || String(err)) +
999
+ "\n\nThis folder is indexed on this machine, so nothing was asked of the cloud. " +
1000
+ "Check it with: " + (release.cloud ? "niro-local status" : "niro status"),
1001
+ },
1002
+ ],
1003
+ isError: true,
1004
+ };
1005
+ }
1006
+ // A `niro mcp receipt --off` line was already taken out of a mark_task_complete answer by
1007
+ // `localBackend.call` (LocalBackend.withoutLine), before anything was added in front of it.
1008
+ // Said once per session, in front of the first local answer: the folders of this project
1009
+ // may have moved onto different branches since they were attached, which no guard can
1010
+ // prevent and which makes every answer crossing them suspect. Asked for lazily and never
1011
+ // allowed to fail an answer.
1012
+ if (!driftSaid) {
1013
+ driftSaid = true;
1014
+ try {
1015
+ const said = await branchDrift.noticeFor(process.cwd());
1016
+ if (said) result = LocalBackend.prepend(result, said);
1017
+ } catch (err) {
1018
+ process.stderr.write(`[niro-mcp] branch check skipped: ${err.message}\n`);
1019
+ }
1020
+ }
1021
+ process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result }) + "\n");
1022
+ impact.noteResult({ jsonrpc: "2.0", id: message.id, result });
1023
+ // The edit hook reads this to know the bridge is answering; the cloud path writes it on each reply.
1024
+ impact.touchAlive();
1025
+ // The engine says, on the first tool call of a session, when its graph is behind the files.
1026
+ // Act on it instead of only passing it along: the watcher normally rebuilds, and on a machine
1027
+ // where the watcher is not running nobody else will. After the answer is written, so the
1028
+ // assistant waits for nothing, and local folders only.
1029
+ localBuild.catchUpIfBehind(process.cwd(), result, {
1030
+ log: (line) => process.stderr.write(`[niro-mcp] ${line}\n`),
1031
+ });
1032
+ process.stderr.write(
1033
+ `[niro-mcp] local ${toolName} answered in ${Date.now() - started}ms\n`);
1034
+ return true;
1035
+ }
1036
+
1037
+ /**
1038
+ * POST one message on `sid` and relay everything the server answers to stdout, in order. Returns the
1039
+ * response to `message` (null for a notification). Throws when there is no answer — the caller turns that
1040
+ * into a JSON-RPC error for the host. A 404 on a session throws with `sessionGone` set.
1041
+ * `probe` (auto mode's first initialize): any non-2xx throws before anything is relayed, and the POST is
1042
+ * time-bounded, so the caller can fall back to SSE and run the same initialize there.
1043
+ */
1044
+ async function postAndRelay(message, sid, probe = false) {
1045
+ const signal = probe ? AbortSignal.timeout(HTTP_PROBE_TIMEOUT_MS) : undefined;
1046
+ const res = await httpPost(JSON.stringify(injectProjectId(message)), sid, message.method, signal);
1047
+ if (res.status === 404 && sid) {
1048
+ drain(res);
1049
+ throw Object.assign(new Error("Niro session expired (HTTP 404)"), { sessionGone: true });
1050
+ }
1051
+ const isInit = message.method === "initialize";
1052
+ const newSid = isInit && res.ok && res.headers.get("mcp-session-id");
1053
+ if (newSid) {
1054
+ // Adopted BEFORE the reply is relayed: the host answers it with notifications/initialized at once.
1055
+ httpSessionId = newSid;
1056
+ httpProtocolVersion = res.headers.get("mcp-protocol-version");
1057
+ process.stderr.write(`[niro-mcp] Session established: ${newSid}\n`);
1058
+ }
1059
+ const trackable = isTrackableRequest(message);
1060
+ if (res.status === 202) {
1061
+ drain(res);
1062
+ return null;
1063
+ }
1064
+ if (!res.ok && probe) {
1065
+ drain(res);
1066
+ throw new Error(`Streamable HTTP not available (HTTP ${res.status})`);
1067
+ }
1068
+ if (!res.ok) {
1069
+ // The server may answer with its own JSON-RPC error (e.g. 503 session store down): relay that.
1070
+ const text = (await res.text().catch(() => "")).trim();
1071
+ let msg = null;
1072
+ try { msg = JSON.parse(text); } catch (_) {}
1073
+ if (trackable && msg && msg.error && msg.id === message.id) return relayServerMessage(text);
1074
+ throw new Error(`HTTP ${res.status} from ${MCP_URL}`);
1075
+ }
1076
+ let reply = null;
1077
+ const note = (msg) => {
1078
+ if (msg && msg.id === message.id && (msg.result !== undefined || msg.error !== undefined)) reply = msg;
1079
+ };
1080
+ if ((res.headers.get("content-type") || "").includes("text/event-stream")) {
1081
+ try {
1082
+ for await (const data of sseMessages(res.body)) note(relayServerMessage(data));
1083
+ } catch (err) {
1084
+ if (!reply) throw err; // a drop after the answer arrived costs nothing
1085
+ }
1086
+ } else {
1087
+ const text = (await res.text()).trim();
1088
+ if (text) note(relayServerMessage(text));
1089
+ }
1090
+ if (trackable && !reply) throw new Error("the server closed the response without answering");
1091
+ if (newSid) {
1092
+ if (!httpProtocolVersion) {
1093
+ httpProtocolVersion = (reply && reply.result && reply.result.protocolVersion)
1094
+ || (message.params && message.params.protocolVersion) || DEFAULT_PROTOCOL_VERSION;
1095
+ }
1096
+ openGetStream(newSid).catch((err) => process.stderr.write(`[niro-mcp] GET /mcp stream failed: ${err.message}\n`));
1097
+ }
1098
+ return reply;
1099
+ }
1100
+
1101
+ /**
1102
+ * Forward one host message over Streamable HTTP. A 404 on the session means the server forgot it
1103
+ * (TTL, restart): renew it once by replaying the host's initialize, then resend; a second failure throws.
1104
+ */
1105
+ async function httpSend(message) {
1106
+ if (message.method === "initialize") return postAndRelay(message, null);
1107
+ if (httpRenewing) await httpRenewing.catch(() => {});
1108
+ const sid = httpSessionId;
1109
+ try {
1110
+ return await postAndRelay(message, sid);
1111
+ } catch (err) {
1112
+ if (!err.sessionGone) throw err;
1113
+ process.stderr.write("[niro-mcp] Streamable HTTP session expired; re-initializing\n");
1114
+ await renewHttpSession(sid);
1115
+ return postAndRelay(message, httpSessionId);
1116
+ }
1117
+ }
1118
+
1119
+ function renewHttpSession(staleSid) {
1120
+ if (httpRenewing) return httpRenewing;
1121
+ if (httpSessionId !== staleSid) return Promise.resolve(); // another request already renewed it
1122
+ httpSessionId = null;
1123
+ routingCapable = false; // re-detected from the replayed initialize, as on an SSE reconnect
1124
+ httpRenewing = replayInitialize((m) => postAndRelay(m, m.method === "initialize" ? null : httpSessionId))
1125
+ .finally(() => { httpRenewing = null; });
1126
+ return httpRenewing;
1127
+ }
1128
+
1129
+ /**
1130
+ * The optional standalone GET stream for server-initiated notifications. 405 = the server has none
1131
+ * (skip silently); 404 = the session is gone (the next POST renews it and reopens this). A drop
1132
+ * reopens with backoff for as long as the session lives.
1133
+ */
1134
+ async function openGetStream(sid) {
1135
+ if (getStream) getStream.abort();
1136
+ const ctrl = new AbortController();
1137
+ getStream = ctrl;
1138
+ const log = (m) => process.stderr.write(`[niro-mcp] GET /mcp stream ${m}\n`);
1139
+ let backoff = RECONNECT_INITIAL_BACKOFF_MS;
1140
+ while (httpSessionId === sid && !ctrl.signal.aborted) {
1141
+ let why = "closed";
1142
+ try {
1143
+ // fetch resolves at the response headers; the server may hold them until its first event.
1144
+ log("opening");
1145
+ const res = await fetch(MCP_URL, {
1146
+ headers: httpHeaders(sid, { "Accept": "text/event-stream" }),
1147
+ signal: ctrl.signal,
1148
+ });
1149
+ if (res.status === 405) { log("not offered by the server (HTTP 405)"); return drain(res); }
1150
+ if (res.status === 404) { log("not opened: session gone (HTTP 404); the next call renews it"); return drain(res); }
1151
+ if (res.ok) {
1152
+ log("opened");
1153
+ backoff = RECONNECT_INITIAL_BACKOFF_MS;
1154
+ for await (const data of sseMessages(res.body)) relayServerMessage(data);
1155
+ } else {
1156
+ why = `refused (HTTP ${res.status})`;
1157
+ drain(res);
1158
+ }
1159
+ } catch (err) {
1160
+ why = `dropped (${netError(err)})`;
1161
+ }
1162
+ if (ctrl.signal.aborted || httpSessionId !== sid) return log("closed (session ended)");
1163
+ log(`${why}; reopening in ${backoff}ms`);
1164
+ await sleep(backoff);
1165
+ backoff = Math.min(backoff * 2, RECONNECT_MAX_BACKOFF_MS);
1166
+ }
1167
+ }
1168
+
1169
+ /** Best-effort end of a session; never blocks shutdown for long. */
1170
+ async function deleteHttpSession(sid) {
1171
+ try {
1172
+ drain(await fetch(MCP_URL, {
1173
+ method: "DELETE", headers: httpHeaders(sid), signal: AbortSignal.timeout(HTTP_DELETE_TIMEOUT_MS) }));
1174
+ } catch (_) { /* best effort */ }
1175
+ }
1176
+
1177
+ /** Streamable HTTP chosen: `send` is httpSend, and the session is ended on SIGTERM too. */
1178
+ function useHttpTransport() {
1179
+ process.on("SIGTERM", shutdown);
1180
+ return httpSend;
1181
+ }
1182
+
1183
+ /** SSE chosen: connect (fatal on failure, as always), start the liveness watchdog; `send` is sendMessage. */
1184
+ async function useSseTransport() {
802
1185
  try {
803
1186
  await connectSse();
804
1187
  } catch (err) {
@@ -819,7 +1202,126 @@ async function main() {
819
1202
  }
820
1203
  }, Math.max(5000, Math.floor(SSE_LIVENESS_MS / 3)));
821
1204
  if (typeof watchdog.unref === "function") watchdog.unref();
1205
+ return sendMessage;
1206
+ }
1207
+
1208
+ /**
1209
+ * auto mode: the host's own initialize decides the transport — no throwaway probe session. It goes to
1210
+ * POST /mcp first; anything but a 2xx (404/405 from an old server or with the flag off, a 5xx, an
1211
+ * unreachable /mcp, a timeout) relays nothing, so SSE is connected and that same initialize runs there.
1212
+ * The host sees exactly one initialize response. Messages that arrive meanwhile wait for the choice.
1213
+ */
1214
+ let chosenSend = null; // Promise<send> once the first message started the choice
1215
+ async function autoSend(message) {
1216
+ if (chosenSend) return (await chosenSend)(message);
1217
+ let choose;
1218
+ chosenSend = new Promise((r) => (choose = r));
1219
+ if (message.method === "initialize") {
1220
+ try {
1221
+ const reply = await postAndRelay(message, null, true);
1222
+ process.stderr.write(`[niro-mcp] Transport: streamable-http (${MCP_URL})\n`);
1223
+ choose(useHttpTransport());
1224
+ return reply;
1225
+ } catch (err) {
1226
+ httpSessionId = null;
1227
+ if (getStream) getStream.abort();
1228
+ process.stderr.write(`[niro-mcp] ${err.message}; using SSE\n`);
1229
+ }
1230
+ }
1231
+ const send = await useSseTransport();
1232
+ process.stderr.write("[niro-mcp] Transport: sse\n");
1233
+ choose(send);
1234
+ return send(message);
1235
+ }
1236
+
1237
+ /**
1238
+ * Local mode (`niro mcp install --local` registers the bridge with NIRO_MCP_MODE=local): every request is
1239
+ * answered by the engine on this machine and nothing is sent to the cloud, initialize and tools/list
1240
+ * included, so the assistant sees the tools niro-local actually serves. Tool calls take the same path as
1241
+ * a locally indexed folder in cloud mode (answerLocally: the impact record, the receipt, the catch-up
1242
+ * build). Anything else is answered here and written the way a relayed answer is.
1243
+ */
1244
+ async function localSend(message) {
1245
+ // Notifications have no answer. The engine was sent its own `initialized` when it started.
1246
+ if (message.id === undefined || message.id === null) return;
1247
+ const answer = (body) => {
1248
+ const reply = { jsonrpc: "2.0", id: message.id, ...body };
1249
+ pending.clear(message.id);
1250
+ impact.noteResult(reply);
1251
+ impact.touchAlive();
1252
+ process.stdout.write(JSON.stringify(reply) + "\n");
1253
+ };
1254
+ try {
1255
+ if (message.method === "initialize") return answer({ result: await localBackend.initialize(message.params) });
1256
+ // niro-cli's own tool after the engine's: asking Niro to support a language, framework or tool. The engine
1257
+ // never goes on the network, so the bridge answers it (src/lib/integrationRequest.js).
1258
+ if (message.method === "tools/list") {
1259
+ const listed = await localBackend.listTools();
1260
+ return answer({ result: { ...listed, tools: [...((listed && listed.tools) || []), integrationRequest.TOOL] } });
1261
+ }
1262
+ if (message.method === "ping") return answer({ result: {} });
1263
+ if (message.method === "tools/call") {
1264
+ pending.clear(message.id);
1265
+ if (await answerLocally(message)) return;
1266
+ }
1267
+ return answer({ result: await localBackend.request(message.method, message.params) });
1268
+ } catch (err) {
1269
+ // An engine that will not start must not make the assistant drop the whole server: the handshake and an
1270
+ // empty tool list are answered here, and the log says why (kept from the plan-page release's offline answer).
1271
+ if (message.method === "initialize" || message.method === "tools/list") {
1272
+ process.stderr.write(`[niro-mcp] the local engine did not start: ${err.message}\n`);
1273
+ if (message.method === "tools/list") return answer({ result: { tools: [] } });
1274
+ return answer({
1275
+ result: {
1276
+ protocolVersion: (message.params && message.params.protocolVersion) || "2024-11-05",
1277
+ capabilities: { tools: {} },
1278
+ serverInfo: { name: "niro", version: require("../../package.json").version },
1279
+ },
1280
+ });
1281
+ }
1282
+ // The engine's own error code when it gave one (-32601 for a method it does not know).
1283
+ const code = Number.isInteger(err && err.code) ? err.code : -32603;
1284
+ return answer({ error: { code, message: `The local engine could not answer: ${err.message}` } });
1285
+ }
1286
+ }
1287
+
1288
+ /** Main: forced transport (NIRO_MCP_TRANSPORT=http|sse) or auto, then read stdin and forward to the server. */
1289
+ async function main() {
1290
+ // Two ways to the same path, and both answer every request from the engine on this machine with nothing sent to a
1291
+ // server: a release without the cloud (src/lib/release.js, decided 2026-11-05), and local mode chosen in a cloud
1292
+ // release (`niro mcp install --local`, Niraj 2026-11-02). Every tool call goes to the engine, a folder it does not
1293
+ // know included, so a new git worktree gets the engine's own answer and its temporary copy rather than a "not
1294
+ // indexed" reply from here (Niraj, 2026-11-06, Q6).
1295
+ if (!release.cloud || LOCAL_MODE) {
1296
+ locality = true;
1297
+ process.stderr.write(
1298
+ release.cloud
1299
+ ? "[niro-mcp] Local mode: every request is answered by niro-local on this machine\n"
1300
+ : "[niro-mcp] Local engine only: answered on this machine\n"
1301
+ );
1302
+ return serveStdio(localSend);
1303
+ }
1304
+ const forced = String(process.env.NIRO_MCP_TRANSPORT || "").toLowerCase();
1305
+ if (forced === "http" || forced === "sse") {
1306
+ process.stderr.write(`[niro-mcp] Transport: ${forced === "http" ? `streamable-http (${MCP_URL})` : "sse"}`
1307
+ + " (forced by NIRO_MCP_TRANSPORT)\n");
1308
+ return serveStdio(forced === "http" ? useHttpTransport() : await useSseTransport());
1309
+ }
1310
+ serveStdio(autoSend);
1311
+ }
1312
+
1313
+ // Streamable HTTP: end the server session (best effort, short timeout) before exiting.
1314
+ let closing = false;
1315
+ async function shutdown() {
1316
+ if (closing) return;
1317
+ closing = true;
1318
+ if (getStream) getStream.abort();
1319
+ if (httpSessionId) await deleteHttpSession(httpSessionId);
1320
+ process.exit(0);
1321
+ }
822
1322
 
1323
+ /** Read host messages from stdin line by line and forward each with `send` (the chosen transport). */
1324
+ function serveStdio(send) {
823
1325
  const rl = readline.createInterface({
824
1326
  input: process.stdin,
825
1327
  terminal: false,
@@ -839,15 +1341,40 @@ async function main() {
839
1341
 
840
1342
  // Remember the host's initialize so we can replay it verbatim to re-authenticate a fresh
841
1343
  // session after a transparent reconnect.
842
- if (message.method === "initialize") initializeRaw = line;
1344
+ if (message.method === "initialize") {
1345
+ initializeRaw = line;
1346
+ // Which assistant this is, for the engine's journal (agent_name), before the engine starts.
1347
+ localBackend.setClient(message.params && message.params.clientInfo);
1348
+ }
843
1349
  impact.noteRequest(message);
844
1350
 
845
1351
  // Arm the deadline for a real request before sending; a delivered result (or the
846
1352
  // send-failure path below) cancels it.
847
1353
  const trackable = isTrackableRequest(message);
848
1354
  if (trackable) pending.arm(message.id);
1355
+
1356
+ // niro-cli's own tool (request_integration) is answered here in every mode: it is not the engine's and not the
1357
+ // cloud's, and it sends nothing without the user's confirm.
1358
+ if (message.method === "tools/call" && message.params && message.params.name === integrationRequest.TOOL.name) {
1359
+ if (trackable) pending.clear(message.id);
1360
+ const reply = { jsonrpc: "2.0", id: message.id, result: await integrationRequest.callTool(message.params.arguments || {}) };
1361
+ impact.noteResult(reply);
1362
+ process.stdout.write(JSON.stringify(reply) + "\n");
1363
+ return;
1364
+ }
1365
+
1366
+ // The routing decision, and the only place it is taken: a tool call in a locally indexed
1367
+ // folder is answered here and never reaches the cloud, so a customer's code and their
1368
+ // questions about it stay on their machine. Everything else, including `initialize` and
1369
+ // `tools/list`, goes to the cloud exactly as it did before.
1370
+ if (message.method === "tools/call" && (await answeredLocally())) {
1371
+ if (trackable) pending.clear(message.id);
1372
+ if (await answerLocally(message)) return;
1373
+ if (trackable) pending.arm(message.id);
1374
+ }
1375
+
849
1376
  try {
850
- await sendMessage(message);
1377
+ await send(message);
851
1378
  } catch (err) {
852
1379
  process.stderr.write(`[niro-mcp] Failed to send message: ${err.message}\n`);
853
1380
  // The attempt still counts for the impact gate: a server that cannot be reached must not leave
@@ -866,12 +1393,16 @@ async function main() {
866
1393
  }
867
1394
  });
868
1395
 
869
- rl.on("close", () => {
870
- process.exit(0);
871
- });
1396
+ rl.on("close", shutdown);
872
1397
  // A bridge that is gone must not keep the impact gate denying edits for up to 30 minutes on the
873
1398
  // strength of a stale alive marker. A hard kill still leaves it; the hook ages it out.
874
- process.on("exit", () => impact.clearAlive());
1399
+ process.on("exit", () => {
1400
+ impact.clearAlive();
1401
+ // The engine is this process's child, so it would die with the bridge anyway; stopping it
1402
+ // explicitly also stops the licence renewal timer, which is what keeps a command from
1403
+ // hanging around waiting for one.
1404
+ localBackend.stop();
1405
+ });
875
1406
  }
876
1407
 
877
1408
  main();