@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.
- package/CHANGELOG.md +136 -0
- package/README.md +123 -302
- package/bin/niro.js +12 -1
- package/docs/agent-antigravity.md +37 -0
- package/docs/agent-claude-code.md +48 -0
- package/docs/agent-claude-desktop.md +47 -0
- package/docs/agent-cline.md +42 -0
- package/docs/agent-codex.md +45 -0
- package/docs/agent-copilot-cli.md +42 -0
- package/docs/agent-cursor.md +44 -0
- package/docs/agent-factory.md +42 -0
- package/docs/agent-gemini-cli.md +42 -0
- package/docs/agent-goose.md +42 -0
- package/docs/agent-kiro.md +37 -0
- package/docs/agent-opencode.md +37 -0
- package/docs/agent-vscode.md +45 -0
- package/docs/agent-windsurf.md +38 -0
- package/docs/answer-shapes.md +69 -0
- package/docs/builds-and-updates.md +68 -0
- package/docs/cloud-README.md +372 -0
- package/docs/configuring-a-folder.md +51 -0
- package/docs/connect-ci.md +24 -0
- package/docs/connect-overview.md +20 -0
- package/docs/connect-review-bots.md +17 -0
- package/docs/connect-team-server.md +12 -0
- package/docs/connect-terminal.md +49 -0
- package/docs/connect-web-agents.md +9 -0
- package/docs/connect-your-ai-assistant.md +59 -175
- package/docs/coverage-audit-and-consent.md +51 -0
- package/docs/evaluate-what-niro-did.md +271 -0
- package/docs/faq-assistants-and-mcp.md +122 -0
- package/docs/faq-feedback-and-receipts.md +33 -0
- package/docs/faq-indexing.md +127 -0
- package/docs/faq-install-and-setup.md +103 -0
- package/docs/faq-licence-and-plans.md +86 -0
- package/docs/faq-login-and-account.md +69 -0
- package/docs/faq-offline.md +34 -0
- package/docs/faq-performance.md +33 -0
- package/docs/faq-privacy-and-security.md +113 -0
- package/docs/faq-uninstall.md +47 -0
- package/docs/faq-updates.md +59 -0
- package/docs/faq-worktrees-and-branches.md +59 -0
- package/docs/faq-wrong-or-missing-answers.md +275 -0
- package/docs/hosted-only.md +33 -0
- package/docs/how-the-graph-works.md +47 -0
- package/docs/index.md +157 -0
- package/docs/licence-and-plans.md +72 -0
- package/docs/lifecycle-debug.md +48 -0
- package/docs/lifecycle-onboarding.md +32 -0
- package/docs/lifecycle-overview.md +77 -0
- package/docs/lifecycle-plan-and-design.md +44 -0
- package/docs/lifecycle-review.md +30 -0
- package/docs/lifecycle-security-and-privacy.md +35 -0
- package/docs/lifecycle-test.md +28 -0
- package/docs/lifecycle-write-code.md +35 -0
- package/docs/llms-full.txt +8015 -0
- package/docs/llms.txt +126 -0
- package/docs/playbook-add-a-feature.md +108 -0
- package/docs/playbook-change-safely.md +93 -0
- package/docs/playbook-data-and-events.md +107 -0
- package/docs/playbook-debug-after-a-change.md +96 -0
- package/docs/playbook-debug-config.md +95 -0
- package/docs/playbook-debug-exception.md +124 -0
- package/docs/playbook-debug-failing-service-call.md +119 -0
- package/docs/playbook-debug-slow-path.md +80 -0
- package/docs/playbook-debug-wrong-value.md +137 -0
- package/docs/playbook-onboard.md +95 -0
- package/docs/playbook-review-a-change.md +72 -0
- package/docs/playbook-test-a-change.md +73 -0
- package/docs/reference-cli.md +113 -0
- package/docs/reference-config-files.md +42 -0
- package/docs/reference-engine-commands.md +56 -0
- package/docs/reference-environment-variables.md +89 -0
- package/docs/reference-exit-codes.md +67 -0
- package/docs/reference-limits.md +75 -0
- package/docs/reference-mcp-tools.md +326 -0
- package/docs/reference-messages.md +180 -0
- package/docs/secrets-and-masking.md +48 -0
- package/docs/supported-c.md +130 -0
- package/docs/supported-cpp.md +157 -0
- package/docs/supported-csharp.md +211 -0
- package/docs/supported-go.md +258 -0
- package/docs/supported-java.md +226 -0
- package/docs/supported-javascript.md +223 -0
- package/docs/supported-kotlin.md +124 -0
- package/docs/supported-overview.md +89 -0
- package/docs/supported-php.md +157 -0
- package/docs/supported-python.md +224 -0
- package/docs/supported-ruby.md +162 -0
- package/docs/supported-rust.md +150 -0
- package/docs/supported-swift.md +133 -0
- package/docs/supported-typescript.md +220 -0
- package/docs/tokens-speed-and-accuracy.md +99 -0
- package/docs/troubleshooting.md +42 -0
- package/docs/uninstall.md +74 -0
- package/docs/what-leaves-your-machine.md +71 -0
- package/docs/where-things-are-stored.md +59 -0
- package/docs/worktrees-and-branches.md +55 -0
- package/package.json +5 -3
- package/src/commands/add-repo.js +5 -0
- package/src/commands/auditSend.js +67 -0
- package/src/commands/build.js +6 -0
- package/src/commands/docsSend.js +73 -0
- package/src/commands/engine.js +120 -0
- package/src/commands/engineRoutes.js +105 -0
- package/src/commands/guide.js +254 -0
- package/src/commands/init.js +6 -2
- package/src/commands/initLocal.js +96 -0
- package/src/commands/journalSend.js +47 -0
- package/src/commands/login.js +89 -12
- package/src/commands/mcp.js +22 -6
- package/src/commands/plan.js +99 -0
- package/src/commands/repoPush.js +33 -0
- package/src/commands/requestIntegration.js +38 -0
- package/src/commands/share.js +56 -0
- package/src/commands/start.js +451 -371
- package/src/commands/status.js +57 -0
- package/src/commands/takeover.js +6 -0
- package/src/commands/uninstall.js +180 -0
- package/src/commands/upgrade.js +38 -8
- package/src/commands/watch.js +420 -6
- package/src/index.js +174 -3
- package/src/lib/agentApi.js +32 -1
- package/src/lib/api.js +20 -6
- package/src/lib/auditUpload.js +293 -0
- package/src/lib/branchDrift.js +149 -0
- package/src/lib/choose.js +116 -0
- package/src/lib/cloudGuard.js +74 -0
- package/src/lib/credentials.js +2 -2
- package/src/lib/docsMarked.js +55 -0
- package/src/lib/docsState.js +213 -0
- package/src/lib/docsUpload.js +481 -0
- package/src/lib/engine.js +619 -0
- package/src/lib/folderSync.js +2 -1
- package/src/lib/graphCheck.js +40 -0
- package/src/lib/guide.js +293 -0
- package/src/lib/guideSearch.js +252 -0
- package/src/lib/integrationRequest.js +89 -0
- package/src/lib/journalUpload.js +147 -0
- package/src/lib/languages.js +129 -0
- package/src/lib/lease.js +375 -0
- package/src/lib/localBuild.js +285 -0
- package/src/lib/localEngine.js +210 -0
- package/src/lib/localOnboard.js +124 -0
- package/src/lib/localWorktrees.js +243 -0
- package/src/lib/machineCheck.js +58 -0
- package/src/lib/mcpClients.js +10 -1
- package/src/lib/migrationNotice.js +118 -0
- package/src/lib/modelFetch.js +153 -0
- package/src/lib/nestedOutput.js +23 -9
- package/src/lib/onboardingLog.js +34 -0
- package/src/lib/planUsage.js +165 -0
- package/src/lib/progressBar.js +46 -0
- package/src/lib/prompt.js +1 -1
- package/src/lib/redact.js +155 -0
- package/src/lib/release.js +33 -0
- package/src/lib/repoPush.js +495 -0
- package/src/lib/secret.js +1 -1
- package/src/lib/service.js +372 -63
- package/src/lib/shareCode.js +367 -0
- package/src/lib/shareExclusions.js +114 -0
- package/src/lib/shareFiles.js +129 -0
- package/src/lib/spinner.js +9 -5
- package/src/lib/watchDaemon.js +33 -10
- package/src/lib/watchState.js +12 -1
- package/src/lib/windowsPaths.js +67 -0
- package/src/mcp/lineCheck.js +382 -0
- package/src/mcp/lineName.js +92 -0
- package/src/mcp/localBackend.js +751 -0
- package/src/mcp/localMode.js +383 -0
- package/src/mcp/localSkill.js +189 -0
- package/src/mcp/server.js +610 -79
- package/src/mcp/setup.js +519 -131
- package/src/mcp/shellSearchHook.js +509 -0
- package/src/mcp/teardown.js +173 -91
- package/src/mcp/usingNiro.js +39 -0
- 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-
|
|
4
|
+
* Niro MCP Server — stdio-to-HTTP bridge.
|
|
5
5
|
*
|
|
6
|
-
* Reads MCP JSON-RPC messages from stdin, forwards them to the
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
684
|
+
await send(reinit);
|
|
642
685
|
// The initialized notification has no id/response; safe to (re)send after every init.
|
|
643
|
-
await
|
|
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
|
-
*
|
|
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
|
-
|
|
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")
|
|
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
|
|
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", () =>
|
|
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();
|