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 +91 -2
- package/package.json +1 -1
- package/src/cli.js +186 -0
- package/src/mcp.js +266 -0
- package/src/mcpTools.js +341 -0
- package/src/watch.js +640 -0
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.
|
|
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.
|
|
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.
|
|
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 };
|