sentinel-ir-mcp 0.4.3 → 0.4.4
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 +72 -5
- package/bin/sentinel-ir-mcp.js +24 -6
- package/package.json +2 -2
- package/src/auditLog.js +204 -0
- package/src/cli.js +27 -5
- package/src/clients.js +34 -13
- package/src/doctor.js +57 -8
- package/src/logView.js +149 -0
- package/src/server.js +37 -7
- package/src/setup.js +52 -5
package/README.md
CHANGED
|
@@ -25,6 +25,25 @@ of safety. Other languages are not supported yet. What is detected in Python
|
|
|
25
25
|
(and what is not) is listed in the
|
|
26
26
|
[sentinel-ir README](https://gitlab.com/jakubrojicek11/sentinel-ir#python).
|
|
27
27
|
|
|
28
|
+
**0.4.4** can keep a log of what the agent asked for (off unless you turn it on):
|
|
29
|
+
|
|
30
|
+
- **One question in `setup`:** *"Keep a log of what the agent asks for (file
|
|
31
|
+
names and results, never your code)? [y/N]"*. Or `setup --audit-log`;
|
|
32
|
+
`--no-audit-log` turns it off. Running `setup` again keeps the setting.
|
|
33
|
+
- **One command to read it:** `npx -y sentinel-ir-mcp@0.4 log` prints the last
|
|
34
|
+
50 entries as plain lines, `--all` everything, `--json` for scripts:
|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
2026-10-18 10:32:05 [cursor] file src/app.js: critical
|
|
38
|
+
2026-10-18 10:32:09 [cursor] REFUSED file secrets/aws.js: excluded by your settings
|
|
39
|
+
2026-10-18 10:33:40 [cursor] folder .: 42 file(s) (1 critical, 6 medium, 35 low), 3 excluded
|
|
40
|
+
critical src/app.js
|
|
41
|
+
```
|
|
42
|
+
- `doctor` says whether the log is on, where it is and how big, and proves the
|
|
43
|
+
log can be written. Details: [Audit log](#audit-log).
|
|
44
|
+
|
|
45
|
+
Also uses `sentinel-ir` 0.4.5 (Python parse errors point where Python does).
|
|
46
|
+
|
|
28
47
|
**0.4.3** lets you exclude files and folders:
|
|
29
48
|
|
|
30
49
|
- **One question in `setup`:** *"Files or folders the agent must not read"*,
|
|
@@ -187,7 +206,7 @@ code 0 when everything passes, 1 otherwise; `--json` gives a machine-readable
|
|
|
187
206
|
report.
|
|
188
207
|
|
|
189
208
|
Only `setup` writes files, and only the client config. The MCP server itself
|
|
190
|
-
|
|
209
|
+
reads only; the one exception is the [audit log](#audit-log), if you turn it on.
|
|
191
210
|
|
|
192
211
|
**Cursor:** a server from a project `.cursor/mcp.json` starts switched off.
|
|
193
212
|
Open Cursor Settings → Customize → MCPs and turn on the switch next to
|
|
@@ -317,12 +336,59 @@ npx -y sentinel-ir-mcp@0.4 setup --client cursor --exclude secrets --exclude "*.
|
|
|
317
336
|
The exclusions only bind this server. An agent that can read files with
|
|
318
337
|
other tools of its client is told about them, but this server cannot stop it.
|
|
319
338
|
|
|
339
|
+
### Audit log
|
|
340
|
+
|
|
341
|
+
```bash
|
|
342
|
+
npx -y sentinel-ir-mcp@0.4 setup --client cursor --audit-log # or answer "y" in setup
|
|
343
|
+
npx -y sentinel-ir-mcp@0.4 log # what the agent asked for
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
| system | folder |
|
|
347
|
+
|---|---|
|
|
348
|
+
| Windows | `%LOCALAPPDATA%\sentinel-ir-mcp\audit-log\` |
|
|
349
|
+
| macOS | `~/Library/Logs/sentinel-ir-mcp/` |
|
|
350
|
+
| Linux | `~/.local/state/sentinel-ir-mcp/audit-log/` (or `$XDG_STATE_HOME`) |
|
|
351
|
+
|
|
352
|
+
`--audit-log=<folder>` uses another folder: a full path outside the project
|
|
353
|
+
(the characters `" & | < > ^ % !` are refused because Windows starts the
|
|
354
|
+
server through `cmd`). Then read it with `log --dir <folder>`.
|
|
355
|
+
|
|
356
|
+
- **What is written:** one JSON line per tool call in `audit-YYYY-MM-DD.jsonl`
|
|
357
|
+
(local date): time, client name, tool, the path the agent asked for, the
|
|
358
|
+
result (risk, `analysisStatus`, file counts, up to 20 `critical`/`high` file
|
|
359
|
+
paths of a folder) or why it was refused (`excluded`, `outside`,
|
|
360
|
+
`not-found`, `too-large`), and the duration. A line when the server starts.
|
|
361
|
+
- **What is not written:** source code. For `analyze_code` only the size in
|
|
362
|
+
bytes, the language and the risk. Names of excluded files are not written
|
|
363
|
+
either, only their count. Long values are cut to 300 characters.
|
|
364
|
+
- **Cleanup:** day files older than 30 days are deleted; a day stops 1 MB
|
|
365
|
+
below 50 MB with one line saying so, so parallel servers stay under 50 MB
|
|
366
|
+
too. Other files in the folder are never touched.
|
|
367
|
+
- **Set by you only,** like exclusions: the agent and the project cannot turn
|
|
368
|
+
it on, off or move it. The log is never written inside the project.
|
|
369
|
+
- **Safe to read:** a path with a newline cannot add a fake line (JSON
|
|
370
|
+
escapes it), and `log` shows control and bidi characters as `\u{1b}`, so a
|
|
371
|
+
path cannot recolor or rewrite your terminal.
|
|
372
|
+
- **Never breaks the tools:** when the log cannot be written (disk full, no
|
|
373
|
+
permission) the tools answer as usual and the server says so once on stderr;
|
|
374
|
+
`doctor` reports it as `[FAIL]`. Several servers can share the folder: each
|
|
375
|
+
line is one append, so lines do not mix.
|
|
376
|
+
- `doctor` adds one line, `doctor checked that the log can be written`, and
|
|
377
|
+
none of its own tool calls.
|
|
378
|
+
- A day file that is a symlink, junction or hard link is not written to, on
|
|
379
|
+
Windows too, so the log cannot be used to overwrite another file.
|
|
380
|
+
- It records only what the agent asked **this server** for. Other file tools
|
|
381
|
+
of your AI app are not in it.
|
|
382
|
+
- Files are created readable by you only (`0600`, folder `0700`; on Windows
|
|
383
|
+
the folder in `%LOCALAPPDATA%` is private to your account).
|
|
384
|
+
|
|
320
385
|
### Options
|
|
321
386
|
|
|
322
387
|
```text
|
|
323
|
-
sentinel-ir-mcp [--exclude <name>]... [--max-file-bytes <n>] [--max-files <n>] [--time-budget-ms <n>] [folder ...]
|
|
388
|
+
sentinel-ir-mcp [--exclude <name>]... [--audit-log[=<folder>]] [--max-file-bytes <n>] [--max-files <n>] [--time-budget-ms <n>] [folder ...]
|
|
324
389
|
|
|
325
390
|
--exclude <name> file or folder the file tools refuse (repeatable, max 100); see Excluding files
|
|
391
|
+
--audit-log[=<folder>] keep a log of the tool calls (never source code); see Audit log
|
|
326
392
|
|
|
327
393
|
--max-file-bytes <n> largest file to analyze (default 1048576 = 1 MiB, max 2097152 = 2 MiB)
|
|
328
394
|
--time-budget-ms <n> analyze_directory starts no new file after this (default 30000, max 600000)
|
|
@@ -331,9 +397,10 @@ sentinel-ir-mcp [--exclude <name>]... [--max-file-bytes <n>] [--max-files <n>] [
|
|
|
331
397
|
|
|
332
398
|
## What the server can and cannot do
|
|
333
399
|
|
|
334
|
-
- **Reads only.** The MCP server never writes files, runs commands or
|
|
335
|
-
network connections
|
|
336
|
-
|
|
400
|
+
- **Reads only.** The MCP server never writes project files, runs commands or
|
|
401
|
+
opens network connections. It writes only the [audit log](#audit-log) when
|
|
402
|
+
you turn it on (outside the project). The separate `setup` command writes
|
|
403
|
+
only the client config file you confirm; `doctor` starts this package's own server. The only files it opens are `.js`/`.mjs`/`.cjs`/`.ts`/`.mts`/`.cts`/`.tsx`/`.py`/`.pyi` files up to
|
|
337
404
|
`--max-file-bytes`.
|
|
338
405
|
- **Only inside the allowed folders.** Every path is resolved to its real path
|
|
339
406
|
(symlinks followed) and must lie inside a folder given on the command line,
|
package/bin/sentinel-ir-mcp.js
CHANGED
|
@@ -9,33 +9,40 @@ const path = require("path");
|
|
|
9
9
|
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
10
10
|
const { createServer, DEFAULT_LIMITS } = require("../src/server");
|
|
11
11
|
const { normalizePattern, ExcludeError, MAX_PATTERNS } = require("../src/exclude");
|
|
12
|
+
const { AuditLogError, defaultAuditDir, normalizeAuditDir, assertOutsideProjects } = require("../src/auditLog");
|
|
12
13
|
const { version } = require("../package.json");
|
|
13
14
|
|
|
14
|
-
const USAGE = `sentinel-ir-mcp ${version} — MCP server for sentinel-ir (JavaScript and
|
|
15
|
+
const USAGE = `sentinel-ir-mcp ${version} — MCP server for sentinel-ir (JavaScript, TypeScript and Python)
|
|
15
16
|
|
|
16
17
|
Usage: sentinel-ir-mcp [options] [folder ...]
|
|
17
|
-
sentinel-ir-mcp setup | doctor | guide [--help]
|
|
18
|
+
sentinel-ir-mcp setup | doctor | guide | log [--help]
|
|
18
19
|
|
|
19
20
|
setup Connect Sentinel-IR to an AI client (shows the change first)
|
|
20
21
|
doctor Check Node.js, the client config and a real MCP connection
|
|
21
22
|
guide Print short usage instructions for AI agents
|
|
23
|
+
log Show what the agent asked for (needs --audit-log)
|
|
22
24
|
|
|
23
25
|
folder Folder(s) the file tools may read. A folder named like a
|
|
24
|
-
command must be written as ./setup, ./doctor
|
|
25
|
-
server uses the MCP roots offered by the client; if the
|
|
26
|
+
command must be written as ./setup, ./doctor, ./guide or
|
|
27
|
+
./log. Without folders the server uses the MCP roots offered by the client; if the
|
|
26
28
|
client offers none, only analyze_code works.
|
|
27
29
|
--exclude <name> A file or folder the file tools must refuse; repeat for more.
|
|
28
30
|
secrets = any file or folder named so, config/prod.js =
|
|
29
31
|
this path in an allowed folder, *.min.js = by mask,
|
|
30
32
|
** = any folders. Set by you; the project cannot change it.
|
|
33
|
+
--audit-log Write a log of what the agent asks this server for (paths,
|
|
34
|
+
results, refusals; never source code) to
|
|
35
|
+
${defaultAuditDir()}
|
|
36
|
+
--audit-log=<folder> The same log in another folder (a full path outside the project)
|
|
31
37
|
--max-file-bytes <n> Largest file to analyze (default ${DEFAULT_LIMITS.maxFileBytes}, max ${DEFAULT_LIMITS.hardMaxFileBytes})
|
|
32
38
|
--time-budget-ms <n> analyze_directory starts no new file after this time (default ${DEFAULT_LIMITS.timeBudgetMs}, max ${DEFAULT_LIMITS.hardMaxTimeBudgetMs})
|
|
33
39
|
--max-files <n> Default file limit for analyze_directory (default ${DEFAULT_LIMITS.maxFiles}, max ${DEFAULT_LIMITS.hardMaxFiles})
|
|
34
40
|
-h, --help Show this help
|
|
35
41
|
-v, --version Show the version
|
|
36
42
|
|
|
37
|
-
The server only reads files. It never
|
|
38
|
-
|
|
43
|
+
The server only reads files. It never executes code or uses the network, and it
|
|
44
|
+
writes nothing except the audit log when you turn it on. "setup" writes only the
|
|
45
|
+
client config file you confirm. See the log with: sentinel-ir-mcp log`;
|
|
39
46
|
|
|
40
47
|
function die(message) {
|
|
41
48
|
process.stderr.write(`sentinel-ir-mcp: ${message}\n\n${USAGE}\n`);
|
|
@@ -70,6 +77,11 @@ function parseArgs(argv) {
|
|
|
70
77
|
die(e.message);
|
|
71
78
|
}
|
|
72
79
|
if (opts.exclude.length > MAX_PATTERNS) die(`at most ${MAX_PATTERNS} --exclude options`);
|
|
80
|
+
} else if (arg === "--audit-log") {
|
|
81
|
+
try { opts.auditDir = value === undefined ? defaultAuditDir() : normalizeAuditDir(value); } catch (e) {
|
|
82
|
+
if (!(e instanceof AuditLogError)) throw e;
|
|
83
|
+
die(e.message);
|
|
84
|
+
}
|
|
73
85
|
} else if (arg.startsWith("-")) {
|
|
74
86
|
die(`unknown option ${arg}`);
|
|
75
87
|
} else {
|
|
@@ -80,6 +92,12 @@ function parseArgs(argv) {
|
|
|
80
92
|
opts.roots.push(dir);
|
|
81
93
|
}
|
|
82
94
|
}
|
|
95
|
+
if (opts.auditDir) {
|
|
96
|
+
try { assertOutsideProjects(opts.auditDir, opts.roots); } catch (e) {
|
|
97
|
+
if (!(e instanceof AuditLogError)) throw e;
|
|
98
|
+
die(e.message);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
83
101
|
return opts;
|
|
84
102
|
}
|
|
85
103
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sentinel-ir-mcp",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "MCP server for sentinel-ir: lets AI agents (Claude Desktop, Cursor, Windsurf, ...) read a compact structural IR of JavaScript, TypeScript and Python files instead of the raw source. Read-only, sandboxed to allowed folders.",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"bin": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
22
|
-
"sentinel-ir": "^0.4.
|
|
22
|
+
"sentinel-ir": "^0.4.5",
|
|
23
23
|
"zod": "^4.1.0"
|
|
24
24
|
},
|
|
25
25
|
"keywords": [
|
package/src/auditLog.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { homeOf } = require("./clients");
|
|
6
|
+
|
|
7
|
+
// An optional record, for the user, of what the agent asked this server for.
|
|
8
|
+
// One JSON object per line in one file per local day; never any source code.
|
|
9
|
+
// Turned on only by the user (--audit-log on the command line the client
|
|
10
|
+
// starts), never by the agent or by files in the project.
|
|
11
|
+
|
|
12
|
+
const FILE_RE = /^audit-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
|
|
13
|
+
const KEEP_DAYS = 30;
|
|
14
|
+
const MAX_DAY_BYTES = 50 * 1024 * 1024;
|
|
15
|
+
// A day stops this far below the limit, so lines that parallel servers append
|
|
16
|
+
// in the same instant still end below MAX_DAY_BYTES (a line is at most ~10 kB).
|
|
17
|
+
const DAY_RESERVE = 1024 * 1024;
|
|
18
|
+
const MAX_TEXT = 300;
|
|
19
|
+
const MAX_LISTED = 20;
|
|
20
|
+
// Characters cmd.exe acts on: an MCP entry on Windows runs through "cmd /c".
|
|
21
|
+
const UNSAFE_DIR = /["&|<>^%!\u0000-\u001f]/;
|
|
22
|
+
const OPEN_FLAGS = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW || 0);
|
|
23
|
+
|
|
24
|
+
class AuditLogError extends Error {}
|
|
25
|
+
|
|
26
|
+
function defaultAuditDir(env = process.env, platform = process.platform) {
|
|
27
|
+
if (platform === "win32") {
|
|
28
|
+
const local = env.LOCALAPPDATA || path.join(homeOf(env), "AppData", "Local");
|
|
29
|
+
return path.join(local, "sentinel-ir-mcp", "audit-log");
|
|
30
|
+
}
|
|
31
|
+
if (platform === "darwin") return path.join(homeOf(env), "Library", "Logs", "sentinel-ir-mcp");
|
|
32
|
+
const state = env.XDG_STATE_HOME || path.join(homeOf(env), ".local", "state");
|
|
33
|
+
return path.join(state, "sentinel-ir-mcp", "audit-log");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// "--audit-log" (default folder) or "--audit-log=<folder>"; must be absolute
|
|
37
|
+
// because a client starts the server from a folder you do not choose.
|
|
38
|
+
function normalizeAuditDir(raw) {
|
|
39
|
+
if (typeof raw !== "string" || raw.trim() === "") throw new AuditLogError("--audit-log=<folder> needs a folder");
|
|
40
|
+
const dir = raw.trim();
|
|
41
|
+
if (UNSAFE_DIR.test(dir)) throw new AuditLogError(`audit log folder "${raw}" contains a character that is not allowed (" & | < > ^ % !)`);
|
|
42
|
+
if (!path.isAbsolute(dir)) throw new AuditLogError(`audit log folder "${raw}" must be a full path, e.g. ${process.platform === "win32" ? "C:\\logs\\sentinel" : "/home/me/logs"}`);
|
|
43
|
+
return path.resolve(dir);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isInside(child, parent) {
|
|
47
|
+
const rel = path.relative(parent, child);
|
|
48
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function realOrResolved(p) {
|
|
52
|
+
// The folder may not exist yet: resolve its nearest existing parent.
|
|
53
|
+
let current = path.resolve(p);
|
|
54
|
+
const rest = [];
|
|
55
|
+
for (;;) {
|
|
56
|
+
try { return path.join(fs.realpathSync(current), ...rest.reverse()); } catch { /* go up */ }
|
|
57
|
+
const parent = path.dirname(current);
|
|
58
|
+
if (parent === current) return path.resolve(p);
|
|
59
|
+
rest.push(path.basename(current));
|
|
60
|
+
current = parent;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A log inside the project could be committed or read by the agent.
|
|
65
|
+
function assertOutsideProjects(dir, projects, caseInsensitive = process.platform === "win32" || process.platform === "darwin") {
|
|
66
|
+
const fold = (p) => (caseInsensitive ? p.toLowerCase() : p);
|
|
67
|
+
const real = fold(realOrResolved(dir));
|
|
68
|
+
for (const project of projects) {
|
|
69
|
+
if (isInside(real, fold(realOrResolved(project)))) {
|
|
70
|
+
throw new AuditLogError(`the audit log folder ${dir} is inside the project folder ${project}; pick a folder outside it`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function pad(n) { return String(n).padStart(2, "0"); }
|
|
76
|
+
function dayName(date) { return `audit-${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}.jsonl`; }
|
|
77
|
+
|
|
78
|
+
function cut(value, max = MAX_TEXT) {
|
|
79
|
+
if (typeof value !== "string") return value;
|
|
80
|
+
if (value.length <= max) return value;
|
|
81
|
+
let end = max;
|
|
82
|
+
const code = value.charCodeAt(end - 1);
|
|
83
|
+
if (code >= 0xd800 && code <= 0xdbff) end--;
|
|
84
|
+
return value.slice(0, end) + "…";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function logFiles(dir) {
|
|
88
|
+
let names;
|
|
89
|
+
try { names = fs.readdirSync(dir); } catch { return []; }
|
|
90
|
+
return names.filter((n) => FILE_RE.test(n)).sort();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function pruneOld(dir, now) {
|
|
94
|
+
const limit = new Date(now);
|
|
95
|
+
limit.setHours(0, 0, 0, 0);
|
|
96
|
+
limit.setDate(limit.getDate() - KEEP_DAYS);
|
|
97
|
+
for (const name of logFiles(dir)) {
|
|
98
|
+
const [, y, m, d] = FILE_RE.exec(name);
|
|
99
|
+
if (new Date(Number(y), Number(m) - 1, Number(d)) < limit) {
|
|
100
|
+
try {
|
|
101
|
+
if (fs.lstatSync(path.join(dir, name)).isFile()) fs.unlinkSync(path.join(dir, name));
|
|
102
|
+
} catch { /* another server may have removed it */ }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// What a tool call is logged as: what was asked and the outcome, no code.
|
|
108
|
+
function describeCall(tool, args, outcome) {
|
|
109
|
+
const entry = { tool };
|
|
110
|
+
if (tool === "analyze_code") {
|
|
111
|
+
entry.bytes = Buffer.byteLength(typeof args.code === "string" ? args.code : "", "utf8");
|
|
112
|
+
if (typeof args.target === "string") entry.target = cut(args.target);
|
|
113
|
+
if (typeof args.language === "string") entry.language = args.language;
|
|
114
|
+
} else {
|
|
115
|
+
entry.path = cut(typeof args.path === "string" ? args.path : ".");
|
|
116
|
+
if (tool === "analyze_directory") {
|
|
117
|
+
for (const [k, v] of [["max_files", args.max_files], ["recursive", args.recursive], ["detail", args.detail]]) if (v !== undefined) entry[k] = v;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (outcome.error) {
|
|
121
|
+
entry.ok = false;
|
|
122
|
+
entry.reason = outcome.reason;
|
|
123
|
+
entry.message = cut(outcome.error);
|
|
124
|
+
return entry;
|
|
125
|
+
}
|
|
126
|
+
entry.ok = true;
|
|
127
|
+
const r = outcome.result;
|
|
128
|
+
if (tool === "analyze_directory") {
|
|
129
|
+
entry.analyzed = r.analyzed;
|
|
130
|
+
entry.riskCounts = r.riskCounts;
|
|
131
|
+
entry.incomplete = r.incomplete.length;
|
|
132
|
+
entry.excluded = r.skipped.excluded;
|
|
133
|
+
const notRead = r.skipped.tooLarge.length + r.skipped.unreadable.length + r.skipped.hardlinks.length;
|
|
134
|
+
if (notRead) entry.notRead = notRead;
|
|
135
|
+
if (r.truncated) entry.truncated = true;
|
|
136
|
+
if (r.timedOut) entry.timedOut = true;
|
|
137
|
+
const top = r.files.filter((f) => f.risk === "critical" || f.risk === "high").slice(0, MAX_LISTED);
|
|
138
|
+
if (top.length) entry.top = top.map((f) => ({ path: cut(f.path), risk: f.risk }));
|
|
139
|
+
} else {
|
|
140
|
+
entry.risk = r.world && r.world.risk;
|
|
141
|
+
entry.analysisStatus = r.analysisStatus;
|
|
142
|
+
entry.language = r.language;
|
|
143
|
+
if (tool === "analyze_file" && r.mission && typeof r.mission.target === "string") entry.file = cut(r.mission.target);
|
|
144
|
+
}
|
|
145
|
+
return entry;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function createAuditLog({ dir, now = () => Date.now(), onError = (m) => process.stderr.write(`sentinel-ir-mcp: ${m}\n`) } = {}) {
|
|
149
|
+
if (!dir) return { active: false, record() {}, flush: async () => {} };
|
|
150
|
+
let queue = Promise.resolve();
|
|
151
|
+
let prunedFor = null;
|
|
152
|
+
let fullDay = null;
|
|
153
|
+
let failed = false;
|
|
154
|
+
|
|
155
|
+
async function append(line, file) {
|
|
156
|
+
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
157
|
+
// Once per server and day, so a server running for weeks still cleans up.
|
|
158
|
+
if (prunedFor !== file) { prunedFor = file; pruneOld(dir, now()); }
|
|
159
|
+
// Windows has no O_NOFOLLOW, so the checks below refuse a day file that
|
|
160
|
+
// is a symlink, junction or hard link on every system before any write.
|
|
161
|
+
const before = await fs.promises.lstat(file, { bigint: true }).catch((e) => {
|
|
162
|
+
if (e.code === "ENOENT") return null;
|
|
163
|
+
throw e;
|
|
164
|
+
});
|
|
165
|
+
if (before && !before.isFile()) throw new Error(`${file} is not a regular file`);
|
|
166
|
+
const handle = await fs.promises.open(file, OPEN_FLAGS, 0o600);
|
|
167
|
+
try {
|
|
168
|
+
const stat = await handle.stat({ bigint: true });
|
|
169
|
+
if (!stat.isFile()) throw new Error(`${file} is not a regular file`);
|
|
170
|
+
if (stat.nlink > 1n) throw new Error(`${file} has a second name (hard link); not writing`);
|
|
171
|
+
const after = await fs.promises.lstat(file, { bigint: true });
|
|
172
|
+
const same = (a) => a.isFile() && a.ino === stat.ino && a.dev === stat.dev;
|
|
173
|
+
if (!same(after) || (before && !same(before))) throw new Error(`${file} was replaced while opening; not writing`);
|
|
174
|
+
if (stat.size + BigInt(Buffer.byteLength(line)) > BigInt(MAX_DAY_BYTES - DAY_RESERVE)) {
|
|
175
|
+
if (fullDay !== file) {
|
|
176
|
+
fullDay = file;
|
|
177
|
+
await handle.write(JSON.stringify({ ts: new Date(now()).toISOString(), event: "full", limitBytes: MAX_DAY_BYTES }) + "\n");
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// One write per line: with O_APPEND lines of parallel servers do not interleave.
|
|
182
|
+
await handle.write(line);
|
|
183
|
+
} finally {
|
|
184
|
+
await handle.close();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function record(entry) {
|
|
189
|
+
const date = new Date(now());
|
|
190
|
+
const line = JSON.stringify({ ts: date.toISOString(), pid: process.pid, ...entry }) + "\n";
|
|
191
|
+
const file = path.join(dir, dayName(date));
|
|
192
|
+
queue = queue.then(() => append(line, file)).then(() => { failed = false; }, (err) => {
|
|
193
|
+
if (!failed) onError(`cannot write the audit log in ${dir}: ${err.message}`);
|
|
194
|
+
failed = true;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { active: true, dir, record, flush: () => queue };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
AuditLogError, KEEP_DAYS, MAX_DAY_BYTES, FILE_RE,
|
|
203
|
+
defaultAuditDir, normalizeAuditDir, assertOutsideProjects, createAuditLog, describeCall, logFiles, dayName, cut
|
|
204
|
+
};
|
package/src/cli.js
CHANGED
|
@@ -4,12 +4,13 @@ const readline = require("readline");
|
|
|
4
4
|
const { runSetup } = require("./setup");
|
|
5
5
|
const { runDoctor } = require("./doctor");
|
|
6
6
|
const { GUIDE } = require("./guide");
|
|
7
|
+
const { runLog } = require("./logView");
|
|
7
8
|
const { CLIENT_NAMES, PACKAGE_SPEC } = require("./clients");
|
|
8
9
|
|
|
9
|
-
const COMMANDS = Object.freeze(["setup", "doctor", "guide"]);
|
|
10
|
+
const COMMANDS = Object.freeze(["setup", "doctor", "guide", "log"]);
|
|
10
11
|
|
|
11
12
|
const HELP = {
|
|
12
|
-
setup: `Usage: sentinel-ir-mcp setup [--client <name>] [--project <folder>] [--exclude <name>]... [--no-exclude] [--global] [--config <file>] [--dry-run] [--yes]
|
|
13
|
+
setup: `Usage: sentinel-ir-mcp setup [--client <name>] [--project <folder>] [--exclude <name>]... [--no-exclude] [--audit-log[=<folder>]] [--no-audit-log] [--global] [--config <file>] [--dry-run] [--yes]
|
|
13
14
|
|
|
14
15
|
Adds a "sentinel-ir" server to an AI client's MCP config. Shows the change first,
|
|
15
16
|
backs up the old file and touches only the "sentinel-ir" entry.
|
|
@@ -21,6 +22,11 @@ backs up the old file and touches only the "sentinel-ir" entry.
|
|
|
21
22
|
path, *.min.js = by mask. Without it, setup asks (Enter = none)
|
|
22
23
|
and running setup again keeps the current list.
|
|
23
24
|
--no-exclude remove all exclusions
|
|
25
|
+
--audit-log keep a log of what the agent asks for (never your code);
|
|
26
|
+
see it with "sentinel-ir-mcp log". Without it, setup asks
|
|
27
|
+
(Enter = no) and running setup again keeps the setting.
|
|
28
|
+
--audit-log=<folder> the same, in this folder (full path, outside the project)
|
|
29
|
+
--no-audit-log stop the log (files already written stay)
|
|
24
30
|
--global Cursor only: ~/.cursor/mcp.json instead of <project>/.cursor/mcp.json
|
|
25
31
|
--config <file> write this config file instead of the client's default
|
|
26
32
|
--dry-run show the change, write nothing
|
|
@@ -29,6 +35,14 @@ backs up the old file and touches only the "sentinel-ir" entry.
|
|
|
29
35
|
|
|
30
36
|
Checks Node.js, the client config and a real MCP connection to this server.
|
|
31
37
|
Exit code 0 when all checks pass, 1 otherwise.`,
|
|
38
|
+
log: `Usage: sentinel-ir-mcp log [--all] [--json] [--dir <folder>]
|
|
39
|
+
|
|
40
|
+
Shows what the agent asked this server for, newest last (the last 50 entries).
|
|
41
|
+
Needs the audit log turned on: sentinel-ir-mcp setup --audit-log
|
|
42
|
+
|
|
43
|
+
--all every entry of the last 30 days
|
|
44
|
+
--json the entries as JSON
|
|
45
|
+
--dir <folder> a log folder set with --audit-log=<folder>`,
|
|
32
46
|
guide: `Usage: sentinel-ir-mcp guide
|
|
33
47
|
|
|
34
48
|
Prints short instructions for AI agents (for AGENTS.md or client rules).
|
|
@@ -36,12 +50,16 @@ Nothing is written.`
|
|
|
36
50
|
};
|
|
37
51
|
|
|
38
52
|
const REPEATABLE = new Set(["--exclude"]);
|
|
53
|
+
// Flags that may carry a value with "=", but never take the next argument.
|
|
54
|
+
const OPTIONAL_VALUE = new Set(["--audit-log"]);
|
|
39
55
|
|
|
40
56
|
const FLAGS = {
|
|
41
57
|
setup: {
|
|
42
58
|
"--client": "client", "--project": "project", "--config": "config", "--exclude": "exclude",
|
|
43
|
-
"--no-exclude": true, "--
|
|
59
|
+
"--no-exclude": true, "--audit-log": "auditLog", "--no-audit-log": true,
|
|
60
|
+
"--global": true, "--dry-run": true, "--yes": true, "-y": true
|
|
44
61
|
},
|
|
62
|
+
log: { "--dir": "dir", "--all": true, "--json": true },
|
|
45
63
|
doctor: { "--client": "client", "--project": "project", "--config": "config", "--global": true, "--json": true },
|
|
46
64
|
guide: {}
|
|
47
65
|
};
|
|
@@ -49,7 +67,7 @@ const FLAGS = {
|
|
|
49
67
|
class UsageError extends Error {}
|
|
50
68
|
|
|
51
69
|
function camel(flag) {
|
|
52
|
-
return { "-y": "yes", "--dry-run": "dryRun", "--no-exclude": "noExclude" }[flag] || flag.replace(/^--/, "");
|
|
70
|
+
return { "-y": "yes", "--dry-run": "dryRun", "--no-exclude": "noExclude", "--no-audit-log": "noAuditLog" }[flag] || flag.replace(/^--/, "");
|
|
53
71
|
}
|
|
54
72
|
|
|
55
73
|
function parse(command, argv) {
|
|
@@ -61,7 +79,10 @@ function parse(command, argv) {
|
|
|
61
79
|
if (arg === "-h" || arg === "--help") return { help: true };
|
|
62
80
|
if (arg.startsWith("--") && arg.includes("=")) [arg, value] = [arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)];
|
|
63
81
|
if (!Object.prototype.hasOwnProperty.call(spec, arg)) throw new UsageError(`unknown ${arg.startsWith("-") ? "option" : "argument"} ${arg}`);
|
|
64
|
-
if (
|
|
82
|
+
if (OPTIONAL_VALUE.has(arg)) {
|
|
83
|
+
if (value === "") throw new UsageError(`${arg}= needs a folder`);
|
|
84
|
+
opts[spec[arg]] = value === undefined ? true : value;
|
|
85
|
+
} else if (spec[arg] === true) {
|
|
65
86
|
if (value !== undefined) throw new UsageError(`${arg} takes no value`);
|
|
66
87
|
opts[camel(arg)] = true;
|
|
67
88
|
} else {
|
|
@@ -108,6 +129,7 @@ async function runCommand(command, argv, io = defaultIo()) {
|
|
|
108
129
|
return 0;
|
|
109
130
|
}
|
|
110
131
|
if (command === "setup") return runSetup(opts, io);
|
|
132
|
+
if (command === "log") return runLog(opts, io);
|
|
111
133
|
return runDoctor(opts, io);
|
|
112
134
|
}
|
|
113
135
|
|
package/src/clients.js
CHANGED
|
@@ -60,6 +60,26 @@ function excludeArgs(exclude = []) {
|
|
|
60
60
|
return exclude.map((p) => `--exclude=${p}`);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// audit: null = off, { dir: null } = the default folder, { dir } = that folder.
|
|
64
|
+
function auditArgs(audit) {
|
|
65
|
+
if (!audit) return [];
|
|
66
|
+
return [audit.dir ? `--audit-log=${audit.dir}` : "--audit-log"];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function serverFlags(exclude = [], audit = null) {
|
|
70
|
+
return [...excludeArgs(exclude), ...auditArgs(audit)];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function entryAuditLog(args) {
|
|
74
|
+
let audit = null;
|
|
75
|
+
for (const raw of args) {
|
|
76
|
+
const a = String(raw);
|
|
77
|
+
if (a === "--audit-log") audit = { dir: null };
|
|
78
|
+
else if (a.startsWith("--audit-log=")) audit = { dir: a.slice("--audit-log=".length) };
|
|
79
|
+
}
|
|
80
|
+
return audit;
|
|
81
|
+
}
|
|
82
|
+
|
|
63
83
|
// Exclusions in an entry's arguments, in both spellings the server accepts.
|
|
64
84
|
function entryExcludes(args) {
|
|
65
85
|
const out = [];
|
|
@@ -71,14 +91,14 @@ function entryExcludes(args) {
|
|
|
71
91
|
return out;
|
|
72
92
|
}
|
|
73
93
|
|
|
74
|
-
function npxEntry(folder, exclude = []) {
|
|
75
|
-
return { command: "npx", args: ["-y", PACKAGE_SPEC, ...
|
|
94
|
+
function npxEntry(folder, exclude = [], audit = null) {
|
|
95
|
+
return { command: "npx", args: ["-y", PACKAGE_SPEC, ...serverFlags(exclude, audit), folder] };
|
|
76
96
|
}
|
|
77
97
|
|
|
78
98
|
// On Windows npx is npx.cmd, which a client cannot start without a shell
|
|
79
99
|
// (measured: spawn("npx") fails with ENOENT), so the entry goes through cmd /c.
|
|
80
|
-
function serverEntry(folder, platform = process.platform, exclude = []) {
|
|
81
|
-
const { command, args } = npxEntry(folder, exclude);
|
|
100
|
+
function serverEntry(folder, platform = process.platform, exclude = [], audit = null) {
|
|
101
|
+
const { command, args } = npxEntry(folder, exclude, audit);
|
|
82
102
|
return platform === "win32" ? { command: "cmd", args: ["/c", command, ...args] } : { command, args };
|
|
83
103
|
}
|
|
84
104
|
|
|
@@ -100,8 +120,8 @@ function windowsArgQuote(value) {
|
|
|
100
120
|
return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1")}"`;
|
|
101
121
|
}
|
|
102
122
|
|
|
103
|
-
function vscodeConfig(project, exclude = []) {
|
|
104
|
-
return { name: SERVER_NAME, type: "stdio", ...npxEntry(project, exclude) };
|
|
123
|
+
function vscodeConfig(project, exclude = [], audit = null) {
|
|
124
|
+
return { name: SERVER_NAME, type: "stdio", ...npxEntry(project, exclude, audit) };
|
|
105
125
|
}
|
|
106
126
|
|
|
107
127
|
// code.cmd/claude.cmd re-read their arguments through cmd.exe, which acts on
|
|
@@ -113,22 +133,23 @@ function cmdSafeJson(value) {
|
|
|
113
133
|
|
|
114
134
|
// Windows forms were checked in CI against a .cmd shim (like code.cmd and
|
|
115
135
|
// claude.cmd) from PowerShell 5.1, PowerShell 7 and cmd.exe.
|
|
116
|
-
// Exclusions never contain " & | < > ^ % ! (
|
|
117
|
-
|
|
118
|
-
|
|
136
|
+
// Exclusions and audit log folders never contain " & | < > ^ % ! (both are
|
|
137
|
+
// checked before they get here).
|
|
138
|
+
function installCommand(client, project, platform = process.platform, shell = "powershell", exclude = [], audit = null) {
|
|
139
|
+
const json = JSON.stringify(vscodeConfig(project, exclude, audit));
|
|
119
140
|
const win = platform === "win32";
|
|
120
141
|
if (client === "vscode") {
|
|
121
142
|
if (!win) return `code --add-mcp ${shellQuote(json)}`;
|
|
122
|
-
return `code ${shell === "cmd" ? "" : "--% "}--add-mcp ${windowsArgQuote(cmdSafeJson(vscodeConfig(project, exclude)))}`;
|
|
143
|
+
return `code ${shell === "cmd" ? "" : "--% "}--add-mcp ${windowsArgQuote(cmdSafeJson(vscodeConfig(project, exclude, audit)))}`;
|
|
123
144
|
}
|
|
124
145
|
if (client === "claude-code") {
|
|
125
|
-
const ex =
|
|
146
|
+
const ex = serverFlags(exclude, audit);
|
|
126
147
|
if (win && shell !== "cmd") {
|
|
127
148
|
// PowerShell prefers npm's claude.ps1 shim: a script gets `--` only when quoted, and --% does not apply to it.
|
|
128
149
|
return `claude mcp add --transport stdio ${SERVER_NAME} '--' cmd /c npx -y ${PACKAGE_SPEC} ${ex.map((a) => powershellQuote(a) + " ").join("")}${powershellQuote(project)}`;
|
|
129
150
|
}
|
|
130
151
|
if (win && project.includes("%")) {
|
|
131
|
-
const config = { type: "stdio", ...serverEntry(project, "win32", exclude) };
|
|
152
|
+
const config = { type: "stdio", ...serverEntry(project, "win32", exclude, audit) };
|
|
132
153
|
return `claude mcp add-json ${SERVER_NAME} ${windowsArgQuote(cmdSafeJson(config))}`;
|
|
133
154
|
}
|
|
134
155
|
const quote = win ? (a) => `"${a}"` : shellQuote;
|
|
@@ -149,5 +170,5 @@ function installLinks() {
|
|
|
149
170
|
|
|
150
171
|
module.exports = {
|
|
151
172
|
SERVER_NAME, PACKAGE_SPEC, WORKSPACE_VAR, CLIENTS, CLIENT_NAMES, FILE_CLIENTS,
|
|
152
|
-
homeOf, npxEntry, serverEntry, excludeArgs, entryExcludes, isWindowsNpxWrapper, shellQuote, powershellQuote, installCommand, installLinks
|
|
173
|
+
homeOf, npxEntry, serverEntry, excludeArgs, entryExcludes, serverFlags, entryAuditLog, isWindowsNpxWrapper, shellQuote, powershellQuote, installCommand, installLinks
|
|
153
174
|
};
|
package/src/doctor.js
CHANGED
|
@@ -5,7 +5,8 @@ const path = require("path");
|
|
|
5
5
|
const { Client } = require("@modelcontextprotocol/sdk/client/index.js");
|
|
6
6
|
const { StdioClientTransport } = require("@modelcontextprotocol/sdk/client/stdio.js");
|
|
7
7
|
const { version } = require("../package.json");
|
|
8
|
-
const { SERVER_NAME, PACKAGE_SPEC, WORKSPACE_VAR, CLIENTS, FILE_CLIENTS, isWindowsNpxWrapper, entryExcludes,
|
|
8
|
+
const { SERVER_NAME, PACKAGE_SPEC, WORKSPACE_VAR, CLIENTS, FILE_CLIENTS, isWindowsNpxWrapper, entryExcludes, entryAuditLog, serverFlags } = require("./clients");
|
|
9
|
+
const { AuditLogError, defaultAuditDir, normalizeAuditDir, assertOutsideProjects, logFiles, dayName, createAuditLog } = require("./auditLog");
|
|
9
10
|
const { readConfig } = require("./setup");
|
|
10
11
|
const { createExcluder, ExcludeError } = require("./exclude");
|
|
11
12
|
const { SKIP_DIRS } = require("./analyze");
|
|
@@ -49,7 +50,7 @@ function checkExcludes(client, label, args, file, add) {
|
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
function checkConfig(client, file, explicit, project, add, platform) {
|
|
53
|
+
function checkConfig(client, file, explicit, project, add, platform, env) {
|
|
53
54
|
const label = CLIENTS[client].label;
|
|
54
55
|
if (!file) {
|
|
55
56
|
if (explicit) add("fail", `${client}.config`, `${label}: config location unknown on this system`, "Pass the config path with --config <file>.");
|
|
@@ -102,7 +103,39 @@ function checkConfig(client, file, explicit, project, add, platform) {
|
|
|
102
103
|
if (folders.length === 0) add("warn", `${client}.folder`, `${label}: no folder given; the file tools rely on the client's MCP roots`);
|
|
103
104
|
const exclude = checkExcludes(client, label, args, file, add);
|
|
104
105
|
if (exclude === null) return null;
|
|
105
|
-
|
|
106
|
+
let audit = entryAuditLog(args);
|
|
107
|
+
if (audit) {
|
|
108
|
+
try {
|
|
109
|
+
const dir = audit.dir ? normalizeAuditDir(audit.dir) : defaultAuditDir(env, platform);
|
|
110
|
+
assertOutsideProjects(dir, resolved);
|
|
111
|
+
audit = { dir: audit.dir ? dir : null, path: dir };
|
|
112
|
+
} catch (e) {
|
|
113
|
+
if (!(e instanceof AuditLogError)) throw e;
|
|
114
|
+
add("fail", `${client}.audit`, `${label}: ${e.message}; the server will not start`,
|
|
115
|
+
`Run: npx -y ${PACKAGE_SPEC} setup --client ${client} --audit-log (or --no-audit-log)`);
|
|
116
|
+
return { folders: resolved, exclude, audit: null, auditBroken: true };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { folders: resolved, exclude, audit };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function formatBytes(n) {
|
|
123
|
+
if (n < 1024) return `${n} B`;
|
|
124
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} kB`;
|
|
125
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function describeAuditFolder(dir) {
|
|
129
|
+
const files = logFiles(dir);
|
|
130
|
+
let bytes = 0;
|
|
131
|
+
for (const f of files) {
|
|
132
|
+
try { bytes += fs.statSync(path.join(dir, f)).size; } catch { /* removed meanwhile */ }
|
|
133
|
+
}
|
|
134
|
+
return files.length ? `${files.length} day(s), ${formatBytes(bytes)}` : "empty so far";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function fileSize(file) {
|
|
138
|
+
try { return fs.statSync(file).size; } catch { return -1; }
|
|
106
139
|
}
|
|
107
140
|
|
|
108
141
|
function withTimeout(promise, ms, what) {
|
|
@@ -113,8 +146,13 @@ function withTimeout(promise, ms, what) {
|
|
|
113
146
|
]).finally(() => clearTimeout(timer));
|
|
114
147
|
}
|
|
115
148
|
|
|
116
|
-
async function checkHandshake(folders, exclude, add, timeoutMs) {
|
|
117
|
-
|
|
149
|
+
async function checkHandshake(folders, exclude, audit, add, timeoutMs) {
|
|
150
|
+
// Without the audit log, so the check does not fill it with its own calls;
|
|
151
|
+
// the log is proved below with one "check" line from the same writer.
|
|
152
|
+
const flags = serverFlags(exclude, null);
|
|
153
|
+
const transport = new StdioClientTransport({ command: process.execPath, args: [BIN, ...flags, ...folders], stderr: "pipe" });
|
|
154
|
+
const today = audit ? path.join(audit.path, dayName(new Date())) : null;
|
|
155
|
+
const before = today ? fileSize(today) : -1;
|
|
118
156
|
const client = new Client({ name: "sentinel-ir-mcp-doctor", version });
|
|
119
157
|
try {
|
|
120
158
|
await withTimeout(client.connect(transport), timeoutMs, "MCP handshake");
|
|
@@ -133,6 +171,13 @@ async function checkHandshake(folders, exclude, add, timeoutMs) {
|
|
|
133
171
|
} finally {
|
|
134
172
|
await client.close().catch(() => {});
|
|
135
173
|
}
|
|
174
|
+
if (!audit) return;
|
|
175
|
+
const log = createAuditLog({ dir: audit.path, onError: () => {} });
|
|
176
|
+
log.record({ event: "check", version });
|
|
177
|
+
await log.flush();
|
|
178
|
+
if (fileSize(today) > before) add("ok", "audit.write", `Audit log: can be written (doctor added one "check" line to ${today})`);
|
|
179
|
+
else add("fail", "audit.write", `Audit log: nothing could be written to ${audit.path}`,
|
|
180
|
+
"Check that the folder exists or can be created and that you may write to it (disk full?). The tools still work without the log.");
|
|
136
181
|
}
|
|
137
182
|
|
|
138
183
|
const MARK = { ok: "[ok] ", warn: "[warn]", fail: "[FAIL]", skip: "[skip]" };
|
|
@@ -155,6 +200,8 @@ async function runDoctor(opts, io) {
|
|
|
155
200
|
const names = opts.client ? (CLIENTS[opts.client] && CLIENTS[opts.client].kind === "file" ? [opts.client] : []) : FILE_CLIENTS;
|
|
156
201
|
let folders = null;
|
|
157
202
|
let exclude = [];
|
|
203
|
+
let audit = null;
|
|
204
|
+
let auditBroken = false;
|
|
158
205
|
for (const client of names) {
|
|
159
206
|
const ctx = { project, global: Boolean(opts.global), env: io.env, platform: io.platform };
|
|
160
207
|
let file = opts.config ? path.resolve(io.cwd, opts.config) : CLIENTS[client].configPath(ctx);
|
|
@@ -164,8 +211,8 @@ async function runDoctor(opts, io) {
|
|
|
164
211
|
`Current ${CLIENTS[client].label} reads ${file || "a new location"}. Run: npx -y ${PACKAGE_SPEC} setup --client ${client}`);
|
|
165
212
|
file = legacy;
|
|
166
213
|
}
|
|
167
|
-
const found = checkConfig(client, file, Boolean(opts.client), project, add, io.platform);
|
|
168
|
-
if (found && folders === null) ({ folders, exclude } = found);
|
|
214
|
+
const found = checkConfig(client, file, Boolean(opts.client), project, add, io.platform, io.env);
|
|
215
|
+
if (found && folders === null) ({ folders, exclude, audit, auditBroken = false } = found);
|
|
169
216
|
}
|
|
170
217
|
if (folders === null) {
|
|
171
218
|
let isDir = false;
|
|
@@ -177,7 +224,9 @@ async function runDoctor(opts, io) {
|
|
|
177
224
|
}
|
|
178
225
|
}
|
|
179
226
|
for (const line of describeExcluded(exclude, folders, SKIP_DIRS)) add("ok", "exclude", line);
|
|
180
|
-
|
|
227
|
+
if (audit) add("ok", "audit", `Audit log: on, in ${audit.path} (${describeAuditFolder(audit.path)}); see it: npx -y ${PACKAGE_SPEC} log${audit.dir ? ` --dir "${audit.dir}"` : ""}`);
|
|
228
|
+
else if (!auditBroken) add("ok", "audit", `Audit log: off (turn it on: npx -y ${PACKAGE_SPEC} setup --audit-log)`);
|
|
229
|
+
await checkHandshake(folders, exclude, audit, add, opts.timeoutMs || 30000);
|
|
181
230
|
|
|
182
231
|
const failed = checks.some((c) => c.status === "fail");
|
|
183
232
|
if (opts.json) {
|
package/src/logView.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { PACKAGE_SPEC } = require("./clients");
|
|
6
|
+
const { AuditLogError, defaultAuditDir, normalizeAuditDir, logFiles, KEEP_DAYS } = require("./auditLog");
|
|
7
|
+
|
|
8
|
+
const RECENT = 50;
|
|
9
|
+
// Control, bidi and line-separator characters could rewrite the terminal or
|
|
10
|
+
// fake a line: shown as \u{..} instead.
|
|
11
|
+
const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/g;
|
|
12
|
+
|
|
13
|
+
function safe(value) {
|
|
14
|
+
return String(value).replace(UNSAFE, (c) => `\\u{${c.codePointAt(0).toString(16)}}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const REASONS = {
|
|
18
|
+
excluded: "excluded by your settings",
|
|
19
|
+
outside: "outside the project folder",
|
|
20
|
+
"not-found": "does not exist",
|
|
21
|
+
"too-large": "file too large",
|
|
22
|
+
"no-folders": "no project folder is set"
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const TOOL_WORDS = { analyze_file: "file", analyze_directory: "folder", analyze_code: "pasted code" };
|
|
26
|
+
|
|
27
|
+
function pad(n) { return String(n).padStart(2, "0"); }
|
|
28
|
+
|
|
29
|
+
function localTime(ts) {
|
|
30
|
+
const d = new Date(ts);
|
|
31
|
+
if (Number.isNaN(d.getTime())) return "????-??-?? ??:??:??";
|
|
32
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function riskCounts(counts) {
|
|
36
|
+
if (!counts || typeof counts !== "object") return "";
|
|
37
|
+
const parts = ["critical", "high", "medium", "low"].filter((k) => Number(counts[k]) > 0).map((k) => `${Number(counts[k])} ${k}`);
|
|
38
|
+
return parts.length ? ` (${parts.join(", ")})` : "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function describe(e) {
|
|
42
|
+
if (e.event === "start") {
|
|
43
|
+
const folders = Array.isArray(e.folders) && e.folders.length ? ` for ${e.folders.map(safe).join(", ")}` : "";
|
|
44
|
+
return [`server started (sentinel-ir-mcp ${safe(e.version)})${folders}`];
|
|
45
|
+
}
|
|
46
|
+
if (e.event === "check") return [`doctor checked that the log can be written`];
|
|
47
|
+
if (e.event === "full") return [`the log for this day reached its size limit; nothing more is written today`];
|
|
48
|
+
const what = TOOL_WORDS[e.tool] ? TOOL_WORDS[e.tool] : safe(e.tool);
|
|
49
|
+
const target = e.tool === "analyze_code" ? `${what} (${Number(e.bytes) || 0} bytes${e.language ? `, ${safe(e.language)}` : ""})` : `${what} ${safe(e.path)}`;
|
|
50
|
+
if (e.ok === false) {
|
|
51
|
+
const reason = REASONS[e.reason] || safe(e.message || e.reason || "failed");
|
|
52
|
+
return [`REFUSED ${target}: ${reason}`];
|
|
53
|
+
}
|
|
54
|
+
if (e.tool === "analyze_directory") {
|
|
55
|
+
const extra = [];
|
|
56
|
+
if (Number(e.excluded) > 0) extra.push(`${Number(e.excluded)} excluded`);
|
|
57
|
+
if (Number(e.notRead) > 0) extra.push(`${Number(e.notRead)} not read (too large or unreadable)`);
|
|
58
|
+
if (e.truncated) extra.push("stopped at the file limit");
|
|
59
|
+
if (e.timedOut) extra.push("stopped at the time limit");
|
|
60
|
+
const lines = [`${target}: ${Number(e.analyzed) || 0} file(s)${riskCounts(e.riskCounts)}${extra.length ? `, ${extra.join(", ")}` : ""}`];
|
|
61
|
+
if (Array.isArray(e.top)) for (const f of e.top) lines.push(` ${safe(f.risk).padEnd(8)} ${safe(f.path)}`);
|
|
62
|
+
return lines;
|
|
63
|
+
}
|
|
64
|
+
const incomplete = e.analysisStatus && e.analysisStatus !== "complete" ? " (incomplete: broken code)" : "";
|
|
65
|
+
return [`${target}: ${safe(e.risk)}${incomplete}`];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseFile(file) {
|
|
69
|
+
const entries = [];
|
|
70
|
+
let damaged = 0;
|
|
71
|
+
let text;
|
|
72
|
+
try {
|
|
73
|
+
if (!fs.lstatSync(file).isFile()) return { entries, damaged };
|
|
74
|
+
text = fs.readFileSync(file, "utf8");
|
|
75
|
+
} catch { return { entries, damaged }; }
|
|
76
|
+
for (const line of text.split("\n")) {
|
|
77
|
+
if (line === "") continue;
|
|
78
|
+
try {
|
|
79
|
+
const e = JSON.parse(line);
|
|
80
|
+
if (e && typeof e === "object" && !Array.isArray(e) && (typeof e.tool === "string" || typeof e.event === "string")) entries.push(e);
|
|
81
|
+
else damaged++;
|
|
82
|
+
} catch { damaged++; }
|
|
83
|
+
}
|
|
84
|
+
return { entries, damaged };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function printEntry(io, e) {
|
|
88
|
+
const client = e.client ? ` [${safe(e.client).slice(0, 40)}]` : "";
|
|
89
|
+
const [first, ...rest] = describe(e);
|
|
90
|
+
io.stdout.write(`${localTime(e.ts)}${client} ${first}\n`);
|
|
91
|
+
for (const line of rest) io.stdout.write(line + "\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Files are read one at a time (a day can hold 50 MB), newest first for the
|
|
95
|
+
// default view, which stops once it has enough entries.
|
|
96
|
+
function runLog(opts, io) {
|
|
97
|
+
let dir;
|
|
98
|
+
try {
|
|
99
|
+
dir = opts.dir ? normalizeAuditDir(path.resolve(io.cwd, opts.dir)) : defaultAuditDir(io.env, io.platform);
|
|
100
|
+
} catch (e) {
|
|
101
|
+
if (!(e instanceof AuditLogError)) throw e;
|
|
102
|
+
io.stderr.write(`sentinel-ir-mcp log: ${e.message}\n`);
|
|
103
|
+
return 2;
|
|
104
|
+
}
|
|
105
|
+
const files = logFiles(dir).map((n) => path.join(dir, n));
|
|
106
|
+
let damaged = 0;
|
|
107
|
+
let count = 0;
|
|
108
|
+
let more = false;
|
|
109
|
+
let first = true;
|
|
110
|
+
const emit = opts.json
|
|
111
|
+
? (e) => { io.stdout.write(`${first ? "" : ","}\n ${JSON.stringify(e)}`); first = false; }
|
|
112
|
+
: (e) => printEntry(io, e);
|
|
113
|
+
if (opts.json) io.stdout.write(`{\n "folder": ${JSON.stringify(dir)},\n "entries": [`);
|
|
114
|
+
if (opts.all) {
|
|
115
|
+
for (const file of files) {
|
|
116
|
+
const r = parseFile(file);
|
|
117
|
+
damaged += r.damaged;
|
|
118
|
+
for (const e of r.entries) emit(e);
|
|
119
|
+
count += r.entries.length;
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
let recent = [];
|
|
123
|
+
for (let i = files.length - 1; i >= 0 && recent.length < RECENT; i--) {
|
|
124
|
+
const r = parseFile(files[i]);
|
|
125
|
+
damaged += r.damaged;
|
|
126
|
+
recent = r.entries.concat(recent);
|
|
127
|
+
if (recent.length >= RECENT && i > 0) more = true;
|
|
128
|
+
}
|
|
129
|
+
if (recent.length > RECENT) { more = true; recent = recent.slice(-RECENT); }
|
|
130
|
+
for (const e of recent) emit(e);
|
|
131
|
+
count = recent.length;
|
|
132
|
+
}
|
|
133
|
+
if (opts.json) {
|
|
134
|
+
io.stdout.write(`${first ? "" : "\n "}],\n "damaged": ${damaged}\n}\n`);
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
if (count === 0) {
|
|
138
|
+
io.stdout.write(`No audit log in ${dir}.\n` +
|
|
139
|
+
`Turn it on with: npx -y ${PACKAGE_SPEC} setup --audit-log\n` +
|
|
140
|
+
`Then restart the AI app; entries appear when the agent uses the tools.\n`);
|
|
141
|
+
} else {
|
|
142
|
+
const summary = more ? `Showing the last ${count} entries; --all shows everything.` : `${count} entries.`;
|
|
143
|
+
io.stdout.write(`\n${summary} Log folder: ${dir} (kept ${KEEP_DAYS} days)\n`);
|
|
144
|
+
}
|
|
145
|
+
if (damaged) io.stdout.write(`${damaged} damaged line(s) skipped.\n`);
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { runLog, safe, describe };
|
package/src/server.js
CHANGED
|
@@ -6,6 +6,7 @@ const { version } = require("../package.json");
|
|
|
6
6
|
const { AccessError, canonicalRoots, rootsFromUris } = require("./paths");
|
|
7
7
|
const { analyzeCode, analyzeFile, analyzeDirectory } = require("./analyze");
|
|
8
8
|
const { createExcluder } = require("./exclude");
|
|
9
|
+
const { createAuditLog, describeCall } = require("./auditLog");
|
|
9
10
|
|
|
10
11
|
const DEFAULT_LIMITS = Object.freeze({
|
|
11
12
|
maxFileBytes: 1024 * 1024,
|
|
@@ -61,12 +62,27 @@ function fail(message) {
|
|
|
61
62
|
return { content: [{ type: "text", text: message }], isError: true };
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
function refusalReason(err) {
|
|
66
|
+
if (!(err instanceof AccessError)) return "error";
|
|
67
|
+
const m = err.message;
|
|
68
|
+
if (m.startsWith("Excluded by the user")) return "excluded";
|
|
69
|
+
if (m.startsWith("File too large")) return "too-large";
|
|
70
|
+
if (m.includes("outside the allowed folders")) return "outside";
|
|
71
|
+
if (m.startsWith("No allowed folders")) return "no-folders";
|
|
72
|
+
if (m.startsWith("Not found")) return "not-found";
|
|
73
|
+
return "refused";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function guarded(fn, audit) {
|
|
77
|
+
const started = Date.now();
|
|
65
78
|
try {
|
|
66
|
-
|
|
79
|
+
const result = await fn();
|
|
80
|
+
audit({ result }, Date.now() - started);
|
|
81
|
+
return ok(result);
|
|
67
82
|
} catch (err) {
|
|
68
|
-
|
|
69
|
-
|
|
83
|
+
const message = err instanceof AccessError ? err.message : `Analysis failed: ${err && err.message ? err.message : String(err)}`;
|
|
84
|
+
audit({ error: message, reason: refusalReason(err) }, Date.now() - started);
|
|
85
|
+
return fail(message);
|
|
70
86
|
}
|
|
71
87
|
}
|
|
72
88
|
|
|
@@ -83,8 +99,21 @@ function createServer(options = {}) {
|
|
|
83
99
|
timeBudgetMs: Math.min(options.timeBudgetMs ?? DEFAULT_LIMITS.timeBudgetMs, DEFAULT_LIMITS.hardMaxTimeBudgetMs)
|
|
84
100
|
};
|
|
85
101
|
const fixedRoots = canonicalRoots(options.roots || []);
|
|
102
|
+
const auditLog = options.auditLog || createAuditLog({ dir: options.auditDir });
|
|
86
103
|
|
|
87
104
|
const server = new McpServer({ name: "sentinel-ir-mcp", version }, { instructions: INSTRUCTIONS + excludeNote });
|
|
105
|
+
if (auditLog.active) auditLog.record({ event: "start", version, folders: fixedRoots, exclusions: excluder.patterns.length });
|
|
106
|
+
|
|
107
|
+
function auditor(tool, args) {
|
|
108
|
+
if (!auditLog.active) return () => {};
|
|
109
|
+
return (outcome, ms) => {
|
|
110
|
+
try {
|
|
111
|
+
const info = server.server.getClientVersion();
|
|
112
|
+
const client = info && typeof info.name === "string" ? info.name.slice(0, 100) : undefined;
|
|
113
|
+
auditLog.record({ client, ...describeCall(tool, args, outcome), ms });
|
|
114
|
+
} catch { /* logging must never break a tool call */ }
|
|
115
|
+
};
|
|
116
|
+
}
|
|
88
117
|
|
|
89
118
|
// Folders given on the command line win. Otherwise ask the client for its
|
|
90
119
|
// MCP roots on every call (they can change while the session runs).
|
|
@@ -112,7 +141,7 @@ function createServer(options = {}) {
|
|
|
112
141
|
language: z.enum(["javascript", "typescript", "tsx", "python"]).optional().describe("Overrides the language picked from target (default javascript)")
|
|
113
142
|
},
|
|
114
143
|
annotations: { title: "Analyze JavaScript, TypeScript or Python source", ...READ_ONLY }
|
|
115
|
-
}, ({ code, target, language }) => guarded(() => analyzeCode(code, target, language)));
|
|
144
|
+
}, ({ code, target, language }) => guarded(() => analyzeCode(code, target, language), auditor("analyze_code", { code, target, language })));
|
|
116
145
|
|
|
117
146
|
server.registerTool("analyze_file", {
|
|
118
147
|
title: "Analyze a JavaScript, TypeScript or Python file",
|
|
@@ -124,7 +153,7 @@ function createServer(options = {}) {
|
|
|
124
153
|
path: z.string().min(1).describe("File path, absolute or relative to the first allowed folder")
|
|
125
154
|
},
|
|
126
155
|
annotations: { title: "Analyze a JavaScript, TypeScript or Python file", ...READ_ONLY }
|
|
127
|
-
}, async ({ path }) => guarded(async () => analyzeFile(path, await allowedRoots(), { ...limits, excluder })));
|
|
156
|
+
}, async ({ path }) => guarded(async () => analyzeFile(path, await allowedRoots(), { ...limits, excluder }), auditor("analyze_file", { path })));
|
|
128
157
|
|
|
129
158
|
server.registerTool("analyze_directory", {
|
|
130
159
|
title: "Analyze a folder of JavaScript, TypeScript and Python files",
|
|
@@ -161,8 +190,9 @@ function createServer(options = {}) {
|
|
|
161
190
|
recursive: recursive ?? true,
|
|
162
191
|
detail: detail ?? "summary",
|
|
163
192
|
excluder
|
|
164
|
-
})));
|
|
193
|
+
}), auditor("analyze_directory", { path, max_files, recursive, detail })));
|
|
165
194
|
|
|
195
|
+
server.auditLog = auditLog;
|
|
166
196
|
return server;
|
|
167
197
|
}
|
|
168
198
|
|
package/src/setup.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
const fs = require("fs");
|
|
4
4
|
const path = require("path");
|
|
5
5
|
const { isDeepStrictEqual } = require("util");
|
|
6
|
-
const { SERVER_NAME, PACKAGE_SPEC, CLIENTS, homeOf, serverEntry, installCommand, shellQuote, powershellQuote, entryExcludes } = require("./clients");
|
|
6
|
+
const { SERVER_NAME, PACKAGE_SPEC, CLIENTS, homeOf, serverEntry, installCommand, shellQuote, powershellQuote, entryExcludes, entryAuditLog } = require("./clients");
|
|
7
|
+
const { AuditLogError, defaultAuditDir, normalizeAuditDir, assertOutsideProjects } = require("./auditLog");
|
|
7
8
|
const { createExcluder, ExcludeError } = require("./exclude");
|
|
8
9
|
const { describeExcluded } = require("./excludeReport");
|
|
9
10
|
const { SKIP_DIRS } = require("./analyze");
|
|
@@ -138,6 +139,48 @@ async function chooseExcludes(opts, io, previous) {
|
|
|
138
139
|
return toPatterns(answer.split(",").map((s) => s.trim()).filter(Boolean));
|
|
139
140
|
}
|
|
140
141
|
|
|
142
|
+
// --no-audit-log turns it off, --audit-log[=<folder>] on, otherwise the current
|
|
143
|
+
// setting is kept; an interactive run asks, with Enter keeping it.
|
|
144
|
+
async function chooseAuditLog(opts, io, previous, project) {
|
|
145
|
+
if (opts.noAuditLog && opts.auditLog) throw new SetupError("Use either --audit-log or --no-audit-log, not both.");
|
|
146
|
+
let audit;
|
|
147
|
+
if (opts.noAuditLog) audit = null;
|
|
148
|
+
else if (opts.auditLog === true) audit = { dir: null };
|
|
149
|
+
else if (typeof opts.auditLog === "string") audit = { dir: opts.auditLog };
|
|
150
|
+
else {
|
|
151
|
+
const current = previous && Array.isArray(previous.args) ? entryAuditLog(previous.args) : null;
|
|
152
|
+
audit = current;
|
|
153
|
+
if (!(opts.yes || opts.dryRun || !io.isTTY || !io.prompt)) {
|
|
154
|
+
const question = current
|
|
155
|
+
? "Keep a log of what the agent asks for (file names and results, never your code)? (now: on; Enter = keep, n = off) "
|
|
156
|
+
: "Keep a log of what the agent asks for (file names and results, never your code)? [y/N] ";
|
|
157
|
+
const answer = (await io.prompt(question)).trim().toLowerCase();
|
|
158
|
+
if (answer === "y" || answer === "yes") audit = current || { dir: null };
|
|
159
|
+
else if (answer === "n" || answer === "no") audit = null;
|
|
160
|
+
else if (answer !== "") throw new SetupError(`Answer y or n, not "${answer}". Nothing was changed.`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!audit) return null;
|
|
164
|
+
try {
|
|
165
|
+
const dir = audit.dir ? normalizeAuditDir(audit.dir) : null;
|
|
166
|
+
assertOutsideProjects(dir || defaultAuditDir(io.env, io.platform), [project], io.platform === "win32" || io.platform === "darwin");
|
|
167
|
+
return { dir };
|
|
168
|
+
} catch (e) {
|
|
169
|
+
if (e instanceof AuditLogError) throw new SetupError(`${e.message}. Nothing was changed.`);
|
|
170
|
+
throw e;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function describeAudit(audit, io) {
|
|
175
|
+
if (!audit) return `Audit log: off (turn it on with: npx -y ${PACKAGE_SPEC} setup --audit-log)
|
|
176
|
+
`;
|
|
177
|
+
const dir = audit.dir || defaultAuditDir(io.env, io.platform);
|
|
178
|
+
return `Audit log: on, in ${dir} (only what the agent asked for and the results, never your code)
|
|
179
|
+
` +
|
|
180
|
+
` see it: npx -y ${PACKAGE_SPEC} log${audit.dir ? ` --dir ${hintArg(audit.dir, io.platform)}` : ""}
|
|
181
|
+
`;
|
|
182
|
+
}
|
|
183
|
+
|
|
141
184
|
function describePlan(client, plan, project) {
|
|
142
185
|
const lines = [
|
|
143
186
|
`Client: ${CLIENTS[client].label}`,
|
|
@@ -186,15 +229,17 @@ async function runSetup(opts, io) {
|
|
|
186
229
|
|
|
187
230
|
if (spec.kind === "command") {
|
|
188
231
|
const exclude = await chooseExcludes(opts, io, undefined);
|
|
232
|
+
const audit = await chooseAuditLog(opts, io, undefined, project);
|
|
189
233
|
if (opts.config) throw new SetupError(`--config does not apply to ${spec.label}; it installs servers with its own command.`);
|
|
190
234
|
if (io.platform === "win32") {
|
|
191
|
-
out(`${spec.label} adds MCP servers with its own command. Nothing was written. Run in PowerShell:\n\n ${installCommand(client, project, "win32", "powershell", exclude)}\n\n` +
|
|
192
|
-
`or in Command Prompt (cmd.exe):\n\n ${installCommand(client, project, "win32", "cmd", exclude)}\n\n`);
|
|
235
|
+
out(`${spec.label} adds MCP servers with its own command. Nothing was written. Run in PowerShell:\n\n ${installCommand(client, project, "win32", "powershell", exclude, audit)}\n\n` +
|
|
236
|
+
`or in Command Prompt (cmd.exe):\n\n ${installCommand(client, project, "win32", "cmd", exclude, audit)}\n\n`);
|
|
193
237
|
} else {
|
|
194
|
-
out(`${spec.label} adds MCP servers with its own command. Nothing was written. Run:\n\n ${installCommand(client, project, io.platform, undefined, exclude)}\n\n`);
|
|
238
|
+
out(`${spec.label} adds MCP servers with its own command. Nothing was written. Run:\n\n ${installCommand(client, project, io.platform, undefined, exclude, audit)}\n\n`);
|
|
195
239
|
}
|
|
196
240
|
out(`Access: ${project} (only this folder, read-only)\n`);
|
|
197
241
|
out(describeExcluded(exclude, [project], SKIP_DIRS).join("\n") + "\n");
|
|
242
|
+
out(describeAudit(audit, io));
|
|
198
243
|
out(`Then check the connection${io.platform === "win32" ? " (PowerShell)" : ""}: npx -y ${PACKAGE_SPEC} doctor --client ${client} --project ${hintArg(project, io.platform)}\n`);
|
|
199
244
|
out(percentNote(project));
|
|
200
245
|
return 0;
|
|
@@ -209,9 +254,11 @@ async function runSetup(opts, io) {
|
|
|
209
254
|
const seed = !opts.config && spec.legacyConfigPath ? spec.legacyConfigPath(ctx) : null;
|
|
210
255
|
const { previous } = planFileChange(file, serverEntry(folder, io.platform), seed);
|
|
211
256
|
const exclude = await chooseExcludes(opts, io, previous);
|
|
212
|
-
const
|
|
257
|
+
const audit = await chooseAuditLog(opts, io, previous, project);
|
|
258
|
+
const plan = planFileChange(file, serverEntry(folder, io.platform, exclude, audit), seed);
|
|
213
259
|
out(describePlan(client, plan, project));
|
|
214
260
|
out(describeExcluded(exclude, [project], SKIP_DIRS).join("\n") + "\n");
|
|
261
|
+
out(describeAudit(audit, io));
|
|
215
262
|
out(percentNote(project));
|
|
216
263
|
|
|
217
264
|
if (plan.action === "unchanged") {
|