residoo 0.4.14 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,6 +40,19 @@ Values are redacted in this report (first/last 4 characters only). Nothing
40
40
  scanned here left your machine; residoo makes no network calls.
41
41
  ```
42
42
 
43
+ That's one snapshot. `residoo watch` runs the same engine continuously and
44
+ alerts the moment a new secret lands, instead of waiting for you to
45
+ remember to scan again; no other tool in the field has anything like it
46
+ (see [Watch: continuous scanning](#watch-continuous-scanning)):
47
+
48
+ ```
49
+ $ residoo watch
50
+ watching 43 sources, 118 files · polling every 5s
51
+
52
+ 2026-09-03 14:02:11 [high] AWS Access Key ID AKIA****ABCD
53
+ claude-code · session-9f2c.jsonl:214 · rf1-8a3e91 Rotate: https://.../access_keys
54
+ ```
55
+
43
56
  > [!NOTE]
44
57
  > gitleaks and trufflehog scan **commits**. residoo scans the **conversation
45
58
  > transcripts** an AI agent leaves behind: a different, previously
@@ -113,6 +126,13 @@ reproduction; everything needed to rerun it ships in this repo.
113
126
  acknowledgement ledger. See [Rotation](#rotation-from-found-to-closed).
114
127
  - `--project <dir>` scans a repository checkout instead of the machine, for
115
128
  CI and pre-commit. See [CI and pre-commit](#ci-and-pre-commit).
129
+ - **`residoo watch`**: continuous scanning instead of one snapshot, alerting
130
+ the moment a new secret lands in a transcript. See
131
+ [Watch: continuous scanning](#watch-continuous-scanning) below.
132
+ - **`residoo mcp`**: query findings and manage rotation from inside Claude
133
+ Code itself, over a hand-rolled MCP server. See
134
+ [MCP: query findings from inside Claude Code](#mcp-query-findings-from-inside-claude-code)
135
+ below.
116
136
 
117
137
  ## Beyond transcripts: configs and planted persistence
118
138
 
@@ -285,7 +305,7 @@ As a GitHub Action (this repo doubles as a composite action):
285
305
  ```yaml
286
306
  steps:
287
307
  - uses: actions/checkout@v4
288
- - uses: dandovdub/residoo@v0.4.14
308
+ - uses: dandovdub/residoo@v0.6.0
289
309
  ```
290
310
 
291
311
  As a pre-commit hook:
@@ -293,7 +313,7 @@ As a pre-commit hook:
293
313
  ```yaml
294
314
  repos:
295
315
  - repo: https://github.com/dandovdub/residoo
296
- rev: v0.4.14
316
+ rev: v0.6.0
297
317
  hooks:
298
318
  - id: residoo
299
319
  ```
@@ -373,11 +393,80 @@ residoo ack <fingerprint> [--note <text>] mark one finding rotated
373
393
 
374
394
  residoo unseal <vault-dir> list a vault's contents
375
395
  residoo unseal <vault-dir> --restore <n> --out <p> restore one file, hash-verified
396
+
397
+ residoo watch [options]
398
+
399
+ --interval <seconds> how often to check for new content (default 5, minimum 1)
400
+ --json NDJSON events on stdout, one line per finding/re-exposure
401
+ --verify same opt-in vendor check as scan --verify, applied to
402
+ each newly found credential once
403
+ --include-noisy, --include-suppressed, --no-color same meaning as scan
376
404
  ```
377
405
 
378
406
  The vault passphrase comes from `RESIDOO_PASSPHRASE` or a hidden interactive
379
407
  prompt. There is no recovery if you lose it, so pick one you keep.
380
408
 
409
+ ## Watch: continuous scanning
410
+
411
+ `residoo scan` is a snapshot. `residoo watch` is the same engine run
412
+ continuously: it polls every source `scan` already covers, and the moment a
413
+ new secret lands in a transcript, prints an alert with the redacted value,
414
+ the rule, the file, and the same rotation runbook a scan finding carries,
415
+ instead of waiting for you to remember to run `scan` again.
416
+
417
+ ```
418
+ $ residoo watch
419
+ watching 43 sources, 118 files (61 tailed, 57 rescanned on change)
420
+ polling every 5s; fs.watch is not used, every alert comes from polling
421
+
422
+ 2026-09-03 14:02:11 [high] AWS Access Key ID AKIA****ABCD
423
+ claude-code · session-9f2c.jsonl:214 · rf1-8a3e91 Rotate: https://.../access_keys
424
+ ```
425
+
426
+ It is watch-from-**now**: run `residoo scan` first for anything already on
427
+ disk, since a fresh `residoo watch` baselines whatever it finds on its first
428
+ sweep silently and only alerts on content written after it starts. A finding
429
+ already acknowledged or dismissed (`residoo ack` / `residoo dismiss`) stays
430
+ suppressed, checked against the same `~/.residoo/rotations.json` ledger, and
431
+ a ledger change made mid-watch takes effect within one poll interval, no
432
+ restart needed. A findings-free sweep prints nothing at all, including to
433
+ its own watched Claude Code session, by design: no other tool in this
434
+ project's own benchmark ([`bench/`](bench/)) has a continuous mode at all,
435
+ verified directly against each one's own `--help` output rather than
436
+ assumed; see [docs/comparison.md](docs/comparison.md) for how the one
437
+ adjacent thing, GitGuardian's `ggshield` AI hook, works differently.
438
+
439
+ ## MCP: query findings from inside Claude Code
440
+
441
+ `residoo mcp` runs residoo as an MCP server over stdio, so Claude Code can
442
+ query findings and manage the rotation ledger conversationally instead of
443
+ you running the CLI in a terminal:
444
+
445
+ ```bash
446
+ claude mcp add residoo -- residoo mcp
447
+ ```
448
+
449
+ or add it directly to `.mcp.json`:
450
+
451
+ ```json
452
+ {
453
+ "mcpServers": {
454
+ "residoo": { "type": "stdio", "command": "residoo", "args": ["mcp"] }
455
+ }
456
+ }
457
+ ```
458
+
459
+ Five tools, mirroring the CLI exactly: `residoo_scan` (a fresh scan,
460
+ merged with rotation status), `residoo_check` (only what's new since the
461
+ last check in this conversation, backed by the same engine as `watch`),
462
+ `residoo_explain` (a rule's rotation runbook), and `residoo_ack` /
463
+ `residoo_dismiss` (append to the local ledger). Every value returned is
464
+ redacted the same way the CLI's own output is; nothing here makes a
465
+ network call or touches the transcript files themselves. Like the rest of
466
+ residoo, this is hand-rolled against the MCP spec directly, not built on
467
+ `@modelcontextprotocol/sdk`: zero runtime dependencies stays true here
468
+ too.
469
+
381
470
  ## Sources supported today
382
471
 
383
472
  43 sources: 42 transcript stores plus the agent-config source above, in two
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.4.14",
3
+ "version": "0.6.0",
4
4
  "description": "Find secrets leaking through your AI coding agent's session history. Zero network calls in the scan path, zero dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "CloudRoam (https://cloudroam.io)",
package/src/cli.js CHANGED
@@ -10,6 +10,9 @@ const { checkIntegrity } = require("./integrity");
10
10
  const {
11
11
  ROTATION_GUIDANCE, guidanceFor, loadAcks, loadDismissed, ackFinding, dismissFinding, renderRotation,
12
12
  } = require("./rotation");
13
+ const { startWatch, isTailable } = require("./watch");
14
+ const { startMcpServer } = require("./mcp");
15
+ const { buildTools } = require("./mcpTools");
13
16
 
14
17
  /**
15
18
  * A source is unavailable for the ordinary reason (not installed — nothing
@@ -126,6 +129,40 @@ Scan options:
126
129
  with no network call at all, on by default, not
127
130
  part of --verify.
128
131
 
132
+ Watch:
133
+ residoo watch continuous scanning instead of one snapshot:
134
+ alerts the moment a new secret lands in a
135
+ transcript, instead of waiting for the next
136
+ "residoo scan". Covers the same sources as scan;
137
+ run scan first for anything already on disk,
138
+ since watch only ever looks at content written
139
+ AFTER it starts. No other tool in this project's
140
+ own benchmark (bench/) has anything like it.
141
+ --interval <seconds> how often to check for new content (default 5,
142
+ minimum 1)
143
+ --json NDJSON events on stdout instead of plain text,
144
+ one line per finding/re-exposure
145
+ --verify same opt-in vendor check as scan --verify,
146
+ applied to each newly found credential once,
147
+ never to one already seen
148
+ --include-noisy, --include-suppressed, --no-color same meaning as scan
149
+ Ctrl+C stops cleanly and prints a session summary (skipped with --json,
150
+ where the same information is one final NDJSON event).
151
+
152
+ MCP:
153
+ residoo mcp run residoo as an MCP server over stdio, so
154
+ Claude Code (or any other MCP client) can query
155
+ findings and manage rotation conversationally
156
+ instead of a human running the CLI. No network
157
+ calls, nothing destructive: the 5 exposed tools
158
+ (residoo_scan, residoo_check, residoo_explain,
159
+ residoo_ack, residoo_dismiss) mirror scan/watch/
160
+ explain/ack/dismiss exactly, and every value
161
+ returned is redacted the same way. Register it
162
+ with "claude mcp add residoo -- residoo mcp".
163
+ Zero runtime dependencies: the protocol is hand-
164
+ rolled, not the official SDK.
165
+
129
166
  Rotation:
130
167
  residoo explain <rule-id> full rotation runbook for one detection rule
131
168
  (where to revoke, steps, what revocation does)
@@ -441,6 +478,153 @@ function runDismiss(args) {
441
478
  return 0;
442
479
  }
443
480
 
481
+ /**
482
+ * One pass over each source's files() purely to describe what's about to
483
+ * be watched -- how many files will be tailed (byte-offset, only new
484
+ * content ever read) versus polled (whole-file rescan on any change). A
485
+ * separate, cheap enumeration from the watch itself; not wired into
486
+ * startWatch() so a banner-rendering concern never has to live inside the
487
+ * engine.
488
+ */
489
+ function printWatchBanner(sources) {
490
+ process.stderr.write("residoo watch: establishing a baseline over each source (this covers NEW content only -- run `residoo scan` first for anything already on disk)...\n");
491
+ for (const source of sources) {
492
+ let tail = 0, rescan = 0;
493
+ try {
494
+ for (const entry of source.files()) {
495
+ if (entry.broken) continue;
496
+ if (isTailable(entry.file)) tail++; else rescan++;
497
+ }
498
+ } catch {
499
+ // A source erroring while just being counted for the banner is not
500
+ // fatal to the watch itself -- sweepOnce has its own try/catch
501
+ // around files() and reports it there instead.
502
+ }
503
+ const parts = [];
504
+ if (tail) parts.push(`${tail} tailed`);
505
+ if (rescan) parts.push(`${rescan} polled (rescanned on change)`);
506
+ process.stderr.write(` ${source.label()} (${source.id()}): ${parts.join(", ") || "no files yet"}\n`);
507
+ }
508
+ process.stderr.write(
509
+ "fs.watch is not used; every alert comes from polling, so an alert can lag\n" +
510
+ "up to one --interval behind the actual write. SQLite-backed sources are\n" +
511
+ "always polled in full (no incremental read exists for them). Ctrl+C to stop.\n\n"
512
+ );
513
+ }
514
+
515
+ function printWatchSummary(stats) {
516
+ process.stderr.write(
517
+ `\nresidoo watch: stopped. ${stats.sweeps} sweep${stats.sweeps === 1 ? "" : "s"}, ` +
518
+ `${stats.loud} new finding${stats.loud === 1 ? "" : "s"}, ${stats.quiet} re-exposure${stats.quiet === 1 ? "" : "s"}` +
519
+ (stats.suppressedByLedger ? `, ${stats.suppressedByLedger} already acked or dismissed` : "") +
520
+ (stats.errors ? `, ${stats.errors} sweep error${stats.errors === 1 ? "" : "s"}` : "") + ".\n"
521
+ );
522
+ }
523
+
524
+ /**
525
+ * `residoo watch`: continuous scanning instead of one snapshot. See
526
+ * src/watch.js for the engine; this function is only argument parsing,
527
+ * the startup/shutdown banners, and wiring SIGINT/SIGTERM to a clean stop.
528
+ */
529
+ async function runWatch(args) {
530
+ const wantsJson = args.includes("--json");
531
+ const includeNoisy = args.includes("--include-noisy");
532
+ const includeSuppressed = args.includes("--include-suppressed");
533
+ const verify = args.includes("--verify");
534
+ const noColor = args.includes("--no-color");
535
+
536
+ let intervalSeconds = 5;
537
+ const intervalArg = argValue(args, "--interval");
538
+ if (intervalArg !== null) {
539
+ const n = Number(intervalArg);
540
+ if (!Number.isFinite(n) || n < 1) {
541
+ process.stderr.write(`--interval must be a number of seconds, at least 1; got "${intervalArg}".\n`);
542
+ return 2;
543
+ }
544
+ intervalSeconds = n;
545
+ }
546
+
547
+ const sources = availableSources();
548
+ if (sources.length === 0) {
549
+ process.stderr.write(
550
+ "No known transcript sources found on this machine; nothing to watch.\n" +
551
+ `Checked: ${sourceStatusList()}.\n`
552
+ );
553
+ return 0;
554
+ }
555
+
556
+ if (!wantsJson) printWatchBanner(sources);
557
+
558
+ const { promise, stop } = startWatch({
559
+ sources,
560
+ options: { includeNoisy, includeSuppressed, verify, noColor, json: wantsJson, pollMs: intervalSeconds * 1000 },
561
+ });
562
+
563
+ const printFinalSummary = (stats) => {
564
+ if (wantsJson) process.stdout.write(JSON.stringify({ type: "summary", at: new Date(), ...stats }) + "\n");
565
+ else printWatchSummary(stats);
566
+ };
567
+
568
+ // A second Ctrl+C must not hang waiting on a graceful stop that already
569
+ // started -- stop() itself is idempotent, but the listener only needs to
570
+ // act once.
571
+ let signalled = false;
572
+ const onSignal = () => {
573
+ if (signalled) return;
574
+ signalled = true;
575
+ printFinalSummary(stop());
576
+ };
577
+ process.once("SIGINT", onSignal);
578
+ process.once("SIGTERM", onSignal);
579
+
580
+ const stats = await promise;
581
+ process.removeListener("SIGINT", onSignal);
582
+ process.removeListener("SIGTERM", onSignal);
583
+ // promise can also resolve because something ELSE called stop() (not
584
+ // possible from outside this function today, but the contract allows
585
+ // it) -- print the summary exactly once regardless of which path got here.
586
+ if (!signalled) printFinalSummary(stats);
587
+ return 0;
588
+ }
589
+
590
+ /**
591
+ * `residoo mcp`: run residoo as an MCP server over stdio. See src/mcp.js
592
+ * for the protocol engine and src/mcpTools.js for the tool catalog; this
593
+ * function is only the startup banner (stderr only -- see mcp.js's own
594
+ * doc comment on why stdout must never carry anything but protocol
595
+ * messages) and wiring SIGINT/SIGTERM to a clean stop, mirroring runWatch.
596
+ */
597
+ async function runMcp(args) {
598
+ const { version } = require("../package.json");
599
+ const sources = availableSources();
600
+ process.stderr.write(
601
+ sources.length
602
+ ? `residoo mcp: ${sources.length} source(s) available on this machine: ${sources.map((s) => s.label()).join(", ")}\n`
603
+ : `residoo mcp: no known transcript sources found on this machine (residoo_scan will report none until one is installed and residoo mcp is restarted).\n`
604
+ );
605
+ process.stderr.write("residoo mcp: ready. Waiting for a client on stdin...\n");
606
+
607
+ const { promise, stop } = startMcpServer({
608
+ tools: buildTools({ sources }),
609
+ serverInfo: { name: "residoo", version },
610
+ instructions: "Find secrets leaking through this machine's AI coding agent session histories. All tool output is redacted; raw secret values are never returned. Nothing here is destructive: scanning is read-only, and ack/dismiss only append to a local audit ledger.",
611
+ });
612
+
613
+ let signalled = false;
614
+ const onSignal = () => {
615
+ if (signalled) return;
616
+ signalled = true;
617
+ stop();
618
+ };
619
+ process.once("SIGINT", onSignal);
620
+ process.once("SIGTERM", onSignal);
621
+
622
+ await promise;
623
+ process.removeListener("SIGINT", onSignal);
624
+ process.removeListener("SIGTERM", onSignal);
625
+ return 0;
626
+ }
627
+
444
628
  async function main(argv) {
445
629
  const args = argv.slice(2);
446
630
  if (args.includes("-h") || args.includes("--help") || args.length === 0) {
@@ -453,6 +637,8 @@ async function main(argv) {
453
637
  if (cmd === "explain") return runExplain(args);
454
638
  if (cmd === "ack") return runAck(args);
455
639
  if (cmd === "dismiss") return runDismiss(args);
640
+ if (cmd === "watch") return runWatch(args);
641
+ if (cmd === "mcp") return runMcp(args);
456
642
  if (cmd !== "scan") {
457
643
  process.stderr.write(`Unknown command "${cmd}". Try "residoo --help".\n`);
458
644
  return 2;
package/src/mcp.js ADDED
@@ -0,0 +1,266 @@
1
+ "use strict";
2
+
3
+ const { createInterface } = require("readline/promises");
4
+
5
+ /**
6
+ * `residoo mcp`: a hand-rolled MCP (Model Context Protocol) server over
7
+ * stdio, so Claude Code (or any other MCP client) can query residoo's
8
+ * findings and rotation ledger directly instead of a human running the
9
+ * CLI in a terminal. No `@modelcontextprotocol/sdk` dependency -- residoo
10
+ * has zero runtime dependencies, no exceptions (see CONTRIBUTING.md), and
11
+ * MCP's stdio transport is newline-delimited JSON-RPC 2.0, which Node's
12
+ * own `readline` already handles; every `.jsonl` source adapter in
13
+ * src/sources/ hand-rolls the same kind of line-delimited JSON parsing.
14
+ *
15
+ * This file is the protocol engine only: framing, dispatch, version
16
+ * negotiation, the outgoing writer, shutdown. It has zero domain
17
+ * knowledge of scanning/rotation -- see src/mcpTools.js for the actual
18
+ * tool catalog. `tools` here is just a `Map<name, {name, description,
19
+ * inputSchema, handler}>` handed in by the caller (mirrors `startWatch`'s
20
+ * `sources` being handed in by its caller rather than looked up itself).
21
+ *
22
+ * Implements the Legacy (initialize-handshake) MCP era only
23
+ * (`2025-11-25`/`2025-06-18`/`2025-03-26`), which is Claude Code's own
24
+ * documented DEFAULT for every stdio server -- it only probes stdio
25
+ * servers for the newer, per-request "Modern" era
26
+ * (`server/discover`-based) when a user explicitly sets
27
+ * `MCP_PROTOCOL_NEGOTIATION=auto`. In that opt-in case, the generic
28
+ * "unknown method" handler below answers `server/discover` instantly with
29
+ * a plain JSON-RPC -32601, which is exactly what the MCP spec defines as
30
+ * triggering a Dual-era client's fallback to the Legacy handshake -- so
31
+ * this server works either way, and implementing `server/discover` itself
32
+ * is a deliberate, non-blocking v1 scope decision, not an oversight.
33
+ *
34
+ * Per the spec's own words: "The server MUST NOT write anything to its
35
+ * stdout that is not a valid MCP message." `send()` below is the ONLY
36
+ * function in this file (or in mcpTools.js) allowed to touch `output`;
37
+ * every log line, startup banner, and shutdown summary goes to
38
+ * `errOutput` instead. This is the same "stdout is sacred" discipline
39
+ * watch.js already established, enforced even more strictly here, since
40
+ * a single stray byte on stdout doesn't just create noise -- it can
41
+ * corrupt the client's JSON-RPC stream.
42
+ */
43
+
44
+ const JSONRPC_VERSION = "2.0";
45
+
46
+ // All three are "Legacy"-era (initialize-handshake) protocol revisions;
47
+ // none of the wire shapes this file implements (initialize result,
48
+ // tools/list, tools/call, isError) differ across them, so supporting all
49
+ // three costs nothing beyond this list. If the client's requested version
50
+ // isn't one of these, DEFAULT_PROTOCOL_VERSION is what we claim instead.
51
+ const SUPPORTED_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26"];
52
+ const DEFAULT_PROTOCOL_VERSION = "2025-06-18";
53
+
54
+ function negotiateProtocolVersion(requested) {
55
+ if (typeof requested === "string" && SUPPORTED_PROTOCOL_VERSIONS.includes(requested)) return requested;
56
+ return DEFAULT_PROTOCOL_VERSION;
57
+ }
58
+
59
+ /**
60
+ * `startMcpServer({tools, serverInfo, instructions, input, output,
61
+ * errOutput})` -> `{promise, stop}`. All I/O injectable (mirrors
62
+ * `startWatch`'s `{sources, options, out, errOut}` contract) so this can
63
+ * be driven by tests with fake streams and a stub tool map, with no real
64
+ * scanning involved.
65
+ */
66
+ function startMcpServer({
67
+ tools,
68
+ serverInfo,
69
+ instructions,
70
+ input = process.stdin,
71
+ output = process.stdout,
72
+ errOutput = process.stderr,
73
+ } = {}) {
74
+ const rl = createInterface({ input, crlfDelay: Infinity });
75
+ let stopped = false;
76
+ let requestsHandled = 0;
77
+
78
+ /**
79
+ * The single write path. `JSON.stringify` already escapes any raw `\n`
80
+ * inside a string VALUE as the two-character sequence `\` `n` (JSON's
81
+ * own grammar, RFC 8259, forbids a literal control character in a
82
+ * string) -- no per-field newline scrubbing is needed here. The only
83
+ * raw 0x0A byte in the whole write is the one appended below, as the
84
+ * stdio transport's own frame delimiter.
85
+ */
86
+ function send(message) {
87
+ let text;
88
+ try {
89
+ text = JSON.stringify(message);
90
+ } catch (err) {
91
+ // `message` contained something JSON.stringify can't serialize (a
92
+ // circular reference, a BigInt) -- a bug in a tool handler, not a
93
+ // reason to crash the connection or silently drop a reply the
94
+ // client may be blocked waiting on. `message.id`, when present,
95
+ // always came from JSON.parse on the client's own request and
96
+ // already passed the id-type check in handleLine, so it's
97
+ // independently safe to reserialize on its own here.
98
+ errOutput.write(`residoo mcp: failed to serialize outgoing message: ${err instanceof Error ? err.message : String(err)}\n`);
99
+ const fallbackId = message && typeof message === "object" && "id" in message ? message.id : null;
100
+ text = JSON.stringify({
101
+ jsonrpc: JSONRPC_VERSION, id: fallbackId,
102
+ error: { code: -32603, message: "Internal error: response could not be serialized" },
103
+ });
104
+ }
105
+ output.write(text + "\n");
106
+ }
107
+
108
+ function sendError(id, code, message, data) {
109
+ send({ jsonrpc: JSONRPC_VERSION, id, error: data === undefined ? { code, message } : { code, message, data } });
110
+ }
111
+
112
+ function sendResult(id, result) {
113
+ send({ jsonrpc: JSONRPC_VERSION, id, result });
114
+ }
115
+
116
+ function handleInitialize(params, id) {
117
+ const protocolVersion = negotiateProtocolVersion(params && params.protocolVersion);
118
+ sendResult(id, {
119
+ protocolVersion,
120
+ capabilities: { tools: {} },
121
+ serverInfo,
122
+ ...(instructions ? { instructions } : {}),
123
+ });
124
+ }
125
+
126
+ function handleToolsList(id) {
127
+ const list = [];
128
+ for (const tool of tools.values()) {
129
+ list.push({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema });
130
+ }
131
+ sendResult(id, { tools: list });
132
+ }
133
+
134
+ async function handleToolsCall(params, id) {
135
+ if (!params || typeof params !== "object" || typeof params.name !== "string") {
136
+ sendError(id, -32602, "Invalid params: 'name' (string) is required");
137
+ return;
138
+ }
139
+ const tool = tools.get(params.name);
140
+ if (!tool) {
141
+ sendError(id, -32602, `Unknown tool: ${params.name}`);
142
+ return;
143
+ }
144
+
145
+ const args = params.arguments && typeof params.arguments === "object" && !Array.isArray(params.arguments)
146
+ ? params.arguments : {};
147
+ let result;
148
+ try {
149
+ // The handler owns its OWN input validation and is expected to
150
+ // return {content, isError:true} for both a bad-argument case and a
151
+ // genuine execution failure -- see SEP-1303: input validation
152
+ // errors are Tool Execution Errors, not Protocol Errors, so the
153
+ // model can see the message and self-correct in the same turn.
154
+ // This dispatcher does not and should not try to tell those two
155
+ // cases apart.
156
+ result = await tool.handler(args);
157
+ if (!result || !Array.isArray(result.content)) {
158
+ // Defensive: a handler bug in mcpTools.js must not corrupt the
159
+ // wire protocol -- fail safe into a well-formed isError result.
160
+ result = { content: [{ type: "text", text: "Tool returned no content (internal error)." }], isError: true };
161
+ }
162
+ } catch (err) {
163
+ // Anything a handler THROWS still lands as a tool execution error,
164
+ // not -32603 -- that code is reserved for bugs in this dispatcher
165
+ // itself, not in a tool. errOutput gets the full detail for
166
+ // operator debugging; the client only ever sees the message string.
167
+ errOutput.write(`residoo mcp: tool "${params.name}" threw: ${err instanceof Error ? (err.stack || err.message) : String(err)}\n`);
168
+ result = { content: [{ type: "text", text: `Failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
169
+ }
170
+ sendResult(id, result);
171
+ }
172
+
173
+ async function dispatch(method, params, hasId, id) {
174
+ switch (method) {
175
+ case "initialize":
176
+ if (!hasId) return;
177
+ return handleInitialize(params, id);
178
+ case "notifications/initialized":
179
+ // A true notification by the method's own contract: never reply,
180
+ // even if a client mistakenly attached an id -- nothing in v1
181
+ // depends on this flag anyway (no server-initiated requests).
182
+ return;
183
+ case "tools/list":
184
+ if (!hasId) return;
185
+ return handleToolsList(id);
186
+ case "tools/call":
187
+ if (!hasId) return;
188
+ return handleToolsCall(params, id);
189
+ default:
190
+ // Includes a `server/discover` probe from a client with
191
+ // MCP_PROTOCOL_NEGOTIATION=auto (see file doc comment) -- MUST
192
+ // reply fast (no I/O, no await, above) so that fallback resolves
193
+ // immediately rather than after a client-side timeout.
194
+ if (!hasId) return;
195
+ sendError(id, -32601, `Method not found: ${method}`);
196
+ }
197
+ }
198
+
199
+ async function handleLine(line) {
200
+ if (line.trim() === "") return false;
201
+ let msg;
202
+ try {
203
+ msg = JSON.parse(line);
204
+ } catch {
205
+ // Per JSON-RPC 2.0 section 5: if the id could not even be
206
+ // determined (a parse failure means we never got that far), the
207
+ // error response's id MUST be null.
208
+ sendError(null, -32700, "Parse error");
209
+ errOutput.write(`residoo mcp: received unparseable line (${line.length} bytes): ${line.slice(0, 200)}\n`);
210
+ return false;
211
+ }
212
+ if (msg === null || typeof msg !== "object" || Array.isArray(msg)) {
213
+ sendError(null, -32600, "Invalid Request: expected a JSON object");
214
+ return false;
215
+ }
216
+
217
+ // Deliberately `hasOwnProperty`, never `msg.id` truthiness -- id:0 is
218
+ // a valid, falsy request id, and conflating "id key absent" (a
219
+ // notification: never reply, even with an error) with "id present but
220
+ // falsy" is exactly the kind of one-character bug that silently
221
+ // breaks a client's parser.
222
+ const hasId = Object.prototype.hasOwnProperty.call(msg, "id");
223
+ const id = hasId ? msg.id : undefined;
224
+ const idIsValidType = id === null || typeof id === "string" || typeof id === "number";
225
+
226
+ if (msg.jsonrpc !== JSONRPC_VERSION || typeof msg.method !== "string") {
227
+ if (!hasId) return false; // malformed NOTIFICATION: JSON-RPC promises no reply, ever
228
+ sendError(idIsValidType ? id : null, -32600, "Invalid Request");
229
+ return false;
230
+ }
231
+ if (hasId && !idIsValidType) {
232
+ // id present but not string/number/null: can't trust echoing it
233
+ // back (and can't safely re-serialize it if it's e.g. an object).
234
+ sendError(null, -32600, "Invalid Request: id must be a string, number, or null");
235
+ return false;
236
+ }
237
+
238
+ try {
239
+ await dispatch(msg.method, msg.params, hasId, id);
240
+ } catch (err) {
241
+ errOutput.write(`residoo mcp: internal error handling "${msg.method}": ${err instanceof Error ? (err.stack || err.message) : String(err)}\n`);
242
+ if (hasId) sendError(id, -32603, "Internal error");
243
+ }
244
+ return hasId;
245
+ }
246
+
247
+ function stop() {
248
+ if (stopped) return;
249
+ stopped = true;
250
+ rl.close(); // unblocks the `for await` loop below on its next iteration
251
+ }
252
+
253
+ const promise = (async () => {
254
+ for await (const line of rl) {
255
+ if (stopped) break;
256
+ if (await handleLine(line)) requestsHandled++;
257
+ }
258
+ if (!stopped) stopped = true;
259
+ errOutput.write(`residoo mcp: shutting down. Handled ${requestsHandled} request(s).\n`);
260
+ return { requestsHandled };
261
+ })();
262
+
263
+ return { promise, stop };
264
+ }
265
+
266
+ module.exports = { startMcpServer, SUPPORTED_PROTOCOL_VERSIONS, DEFAULT_PROTOCOL_VERSION };