@noesis-brain/mcp-server 2.1.2 → 2.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noesis-brain/mcp-server",
3
- "version": "2.1.2",
3
+ "version": "2.1.5",
4
4
  "description": "Official MCP server for Noesis — the markdown notes manager for Claude Code. Search, sync, and manage your .md knowledge base via 19 AI-powered tools.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Start the Noesis Local Agent daemon, replacing any instance already running — and
4
+ # refuse to leave a daemon running that lacks the tool-boundary fix.
5
+ #
6
+ # Why this exists: the daemon is normally launched by hand
7
+ # (`npx @noesis-brain/mcp-server@latest agent`), and nothing stops a second one. On
8
+ # 2026-08-14 four were running at once, two rooted at $HOME. They all poll the same queue,
9
+ # so whichever won the race decided which build served the job — a stale daemon answered
10
+ # minutes after a fixed one started, which looked exactly like a fix that did not work.
11
+ #
12
+ # Killing is deliberately two passes because the launcher is TWO processes: an
13
+ # `npm exec @noesis-brain/mcp-server@<spec> agent` parent and a `node .../noesis-mcp agent`
14
+ # child. Killing only the child lets npm respawn it.
15
+ #
16
+ # WHY THE VERSION ASSERT: an earlier version of this script started `@latest` by default
17
+ # while the fix was still unpublished, so its no-argument path SIGKILLed the fixed daemon,
18
+ # started an UNFIXED one, and printed success — silently restoring the vulnerability it
19
+ # exists to prevent. Publishing lags code, so the script must verify what it actually
20
+ # started rather than trust the tag.
21
+ #
22
+ # Usage: ./scripts/start-agent.sh # published build via npx (default)
23
+ # ./scripts/start-agent.sh --local # this checkout's dist/ (after npm run build)
24
+ # ./scripts/start-agent.sh --status # report running daemons, exit 1 if any is unfixed
25
+ # ./scripts/start-agent.sh --stop # stop everything, start nothing
26
+
27
+ set -euo pipefail
28
+
29
+ # Resolve this script's own location BEFORE any cd — $BASH_SOURCE is relative when invoked
30
+ # as ./scripts/start-agent.sh, so reading it after the cd to $RUN_DIR resolves against the
31
+ # wrong directory and --local would fail.
32
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
33
+ PKG_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
34
+
35
+ # First release carrying `tools: []` + the canUseTool gate. A daemon below this exposes
36
+ # Claude Code's whole built-in tool set (Bash/Read/Write/Edit/Grep/Task) to every Navi.
37
+ MIN_FIXED_VERSION="2.1.5"
38
+
39
+ MODE="${1:-}"
40
+ case "$MODE" in
41
+ ''|--local|--stop|--status) ;;
42
+ *)
43
+ echo "usage: $(basename "$0") [--local | --status | --stop]" >&2
44
+ # Deliberately NOT a silent fallback: a typo'd flag used to start the PUBLISHED build
45
+ # instead of the local one — the same "wrong build served the job" signature.
46
+ exit 2
47
+ ;;
48
+ esac
49
+
50
+ RUN_DIR="${NOESIS_AGENT_RUN_DIR:-$HOME/.noesis-agent}"
51
+ LOG_FILE="$RUN_DIR/agent.log"
52
+
53
+ # Every shape the daemon can appear as, matched against the full command line.
54
+ # `@noesis-brain/mcp-server(@[^ ]*)? agent` covers the npm exec parent for a pinned spec,
55
+ # a tag, AND no spec. A plain MCP-server invocation has no ` agent` argument and is never
56
+ # matched: that process is the user's Claude Code integration, not a daemon.
57
+ PATTERNS=(
58
+ 'noesis-mcp agent'
59
+ '@noesis-brain/mcp-server(@[^ ]*)? agent'
60
+ 'dist/index.js agent'
61
+ )
62
+
63
+ # Only ever our own processes. Unscoped pgrep also matches other users' and root's, which
64
+ # `kill` cannot touch (EPERM) — those would be counted as survivors forever, wedging the
65
+ # guard into "refusing to start" after having already killed the real daemon.
66
+ pids_matching() {
67
+ for pattern in "${PATTERNS[@]}"; do
68
+ pgrep -U "$(id -u)" -f "$pattern" 2>/dev/null || true
69
+ done | sort -un
70
+ }
71
+
72
+ # `|| true` inside the braces is load-bearing: pgrep exits 1 on no match, and with
73
+ # `set -e` + `pipefail` that would abort. Dedupe, or a process matching two patterns
74
+ # inflates the count and can trip the refuse-to-start guard.
75
+ count_matching() { pids_matching | wc -l | tr -d ' '; }
76
+
77
+ kill_matching() {
78
+ local signal="$1" pid
79
+ while read -r pid; do
80
+ [ -z "$pid" ] && continue
81
+ [ "$pid" = "$$" ] && continue
82
+ [ "$pid" = "${PPID:-0}" ] && continue
83
+ if kill "$signal" "$pid" 2>/dev/null; then
84
+ echo " ${signal#-} pid $pid"
85
+ elif ! kill -0 "$pid" 2>/dev/null; then
86
+ : # already exited from the previous pass — the normal case, not worth a line
87
+ else
88
+ # Still alive and we could not signal it (EPERM). Do NOT swallow this: a kill we
89
+ # cannot perform is exactly what leaves a survivor and wedges the guard below.
90
+ echo " ${signal#-} pid $pid FAILED — still running and not killable by us" >&2
91
+ fi
92
+ done < <(pids_matching)
93
+ return 0
94
+ }
95
+
96
+ # `$1 >= $2` for plain x.y.z versions.
97
+ version_ge() {
98
+ [ "$(printf '%s\n%s\n' "$2" "$1" | sort -t. -k1,1n -k2,2n -k3,3n | head -1)" = "$2" ]
99
+ }
100
+
101
+ # The package directory backing a running daemon. Extracted WITHOUT a `[^ ]*` path regex:
102
+ # that could not cross a space, so an install under "/Users/x/My Repos/..." resolved to the
103
+ # wrong directory, was graded UNKNOWN, and the assert below killed a perfectly good daemon.
104
+ pkg_dir_for_pid() {
105
+ local pid="$1" cmd path
106
+ cmd="$(ps -o command= -p "$pid" 2>/dev/null || true)"
107
+ case "$cmd" in *" agent") cmd="${cmd% agent}" ;; *) ;; esac
108
+ path="${cmd#* }" # drop the interpreter, keep the (possibly spaced) path
109
+ case "$path" in
110
+ */dist/index.js) dirname "$(dirname "$path")"; return ;;
111
+ */node_modules/.bin/noesis-mcp)
112
+ printf '%s/@noesis-brain/mcp-server' "$(dirname "$(dirname "$path")")"; return ;;
113
+ esac
114
+ printf ''
115
+ }
116
+
117
+ # Comments are copied verbatim into dist (tsconfig sets no `removeComments`), and this
118
+ # codebase DOCUMENTS both markers heavily — `tools: []` occurs on 6 lines of the compiled
119
+ # output, only ONE of which is code. Grepping the raw file therefore certified a build with
120
+ # the boundary deleted as FIXED, which made the whole assurance layer theatre. Strip
121
+ # comments before looking for code.
122
+ strip_js_comments() { perl -0pe 's{/\*.*?\*/}{}gs; s{^[[:space:]]*//.*$}{}gm' "$1" 2>/dev/null || true; }
123
+
124
+ # Prints "<version> <FIXED|UNFIXED(reason)|PARENT>" for a pid.
125
+ #
126
+ # TWO INDEPENDENT SIGNALS, both required:
127
+ # - the package version is >= MIN_FIXED_VERSION (the same signal the backend's
128
+ # MIN_TOOL_AGENT_VERSION gate uses), and
129
+ # - the compiled output actually contains both halves of the fix, comments removed.
130
+ # An earlier version compared neither: MIN_FIXED_VERSION was assigned and never used, and
131
+ # the content check matched documentation. "v2.1.5 FIXED" was two non-facts side by side.
132
+ describe_pid() {
133
+ local pid="$1" dir ver stripped ver_ok=0 code_ok=0
134
+ dir="$(pkg_dir_for_pid "$pid")"
135
+ # No package dir = the `npm exec` wrapper, not a daemon. Its child is graded separately;
136
+ # counting the parent as "unknown, therefore exposed" cried wolf on every healthy npx
137
+ # launch, which is the mode that becomes correct once the fix is published.
138
+ if [ -z "$dir" ] || [ ! -f "$dir/package.json" ]; then echo "- PARENT"; return; fi
139
+ ver="$(node -e "process.stdout.write(String(require('$dir/package.json').version||'0.0.0'))" 2>/dev/null || echo '0.0.0')"
140
+ version_ge "$ver" "$MIN_FIXED_VERSION" && ver_ok=1
141
+ if [ -f "$dir/dist/agent/runner.js" ]; then
142
+ stripped="$(strip_js_comments "$dir/dist/agent/runner.js")"
143
+ if printf '%s' "$stripped" | grep -q 'tools: \[\]' \
144
+ && printf '%s' "$stripped" | grep -q 'canUseTool:'; then code_ok=1; fi
145
+ fi
146
+ if [ "$ver_ok" = 1 ] && [ "$code_ok" = 1 ]; then echo "$ver FIXED"
147
+ elif [ "$ver_ok" = 0 ]; then echo "$ver UNFIXED(v<$MIN_FIXED_VERSION)"
148
+ else echo "$ver UNFIXED(code)"; fi
149
+ }
150
+
151
+ # --status: the one command that answers "is my machine exposed right now?".
152
+ if [ "$MODE" = "--status" ]; then
153
+ exposed=0 total=0
154
+ while read -r pid; do
155
+ [ -z "$pid" ] && continue
156
+ read -r ver state <<<"$(describe_pid "$pid")"
157
+ if [ "$state" = "PARENT" ]; then
158
+ printf ' pid %-7s %-10s (npm exec wrapper — its child is graded below)\n' "$pid" "$state"
159
+ continue
160
+ fi
161
+ total=$((total + 1))
162
+ printf ' pid %-7s v%-8s %s\n' "$pid" "$ver" "$state"
163
+ [ "$state" = "FIXED" ] || exposed=$((exposed + 1))
164
+ done < <(pids_matching)
165
+ [ "$total" -eq 0 ] && echo " no daemon running"
166
+ # Honest about what this can and cannot see, rather than implying a clean bill of health.
167
+ echo " (grades the package on disk, for this user's processes only; a daemon started"
168
+ echo " before a rebuild may still be running older code in memory)"
169
+ if [ "$exposed" -gt 0 ]; then
170
+ echo "[start-agent] EXPOSED: $exposed of $total daemon process(es) lack the tool boundary." >&2
171
+ echo " They can read your filesystem. Run: $(basename "$0") --local" >&2
172
+ exit 1
173
+ fi
174
+ echo "[start-agent] ok — $total daemon process(es), all carry the fix."
175
+ exit 0
176
+ fi
177
+
178
+ # CHECK BEFORE KILLING. The published build is what the default mode is about to install,
179
+ # so verify it is new enough BEFORE tearing down a working daemon. Without this the default
180
+ # path was a guaranteed outage while the fix was unpublished: kill everything (including a
181
+ # healthy fixed daemon) -> install an unfixed one -> assert fails -> kill it -> exit 1,
182
+ # leaving the machine with NO daemon and subscription chat dead.
183
+ if [ "$MODE" = "" ]; then
184
+ published="$(npm view @noesis-brain/mcp-server dist-tags.latest 2>/dev/null || true)"
185
+ if [ -z "$published" ]; then
186
+ echo "[start-agent] Could not reach npm to check the published version; refusing to" >&2
187
+ echo " tear down a running daemon on a guess. Use --local." >&2
188
+ exit 1
189
+ fi
190
+ if ! version_ge "$published" "$MIN_FIXED_VERSION"; then
191
+ echo "[start-agent] Published @latest is v$published, which is older than v$MIN_FIXED_VERSION" >&2
192
+ echo " and lacks the tool boundary. NOT touching the running daemon." >&2
193
+ echo " Publish $MIN_FIXED_VERSION, or start this checkout: $(basename "$0") --local" >&2
194
+ exit 1
195
+ fi
196
+ fi
197
+
198
+ echo "[start-agent] stopping existing daemons..."
199
+ [ "$(count_matching)" -eq 0 ] && echo " none running"
200
+ kill_matching -TERM
201
+ sleep 2 # a parent can respawn its child on SIGTERM
202
+ kill_matching -KILL
203
+ sleep 1
204
+
205
+ remaining="$(count_matching)"
206
+ if [ "$MODE" = "--stop" ]; then
207
+ if [ "$remaining" -gt 0 ]; then
208
+ echo "[start-agent] WARNING: $remaining daemon process(es) survived --stop." >&2
209
+ exit 1
210
+ fi
211
+ echo "[start-agent] all stopped."
212
+ exit 0
213
+ fi
214
+ if [ "$remaining" -gt 0 ]; then
215
+ echo "[start-agent] ERROR: $remaining daemon process(es) still alive; refusing to start another." >&2
216
+ pids_matching | sed 's/^/ surviving pid /' >&2
217
+ exit 1
218
+ fi
219
+
220
+ # Credentials come from the same place the MCP server registration uses, so the token never
221
+ # has to be pasted into a shell or a process argv.
222
+ CLAUDE_JSON="$HOME/.claude.json"
223
+ if ! node -e "
224
+ const e = (require('$CLAUDE_JSON').mcpServers || {}).noesis?.env;
225
+ if (!e?.NOESIS_API_URL || !e?.NOESIS_API_TOKEN) process.exit(1);
226
+ " 2>/dev/null; then
227
+ echo "[start-agent] No Noesis credentials in $CLAUDE_JSON (mcpServers.noesis.env)." >&2
228
+ echo " claude mcp add -s user noesis -- npx -y @noesis-brain/mcp-server" >&2
229
+ exit 1
230
+ fi
231
+ NOESIS_API_URL="$(node -e "console.log(require('$CLAUDE_JSON').mcpServers.noesis.env.NOESIS_API_URL)")"
232
+ NOESIS_API_TOKEN="$(node -e "console.log(require('$CLAUDE_JSON').mcpServers.noesis.env.NOESIS_API_TOKEN)")"
233
+ export NOESIS_API_URL NOESIS_API_TOKEN
234
+
235
+ mkdir -p "$RUN_DIR"
236
+ cd "$RUN_DIR"
237
+
238
+ if [ "$MODE" = "--local" ]; then
239
+ DIST="$PKG_DIR/dist/index.js"
240
+ [ -f "$DIST" ] || { echo "[start-agent] $DIST missing — run 'npm run build' first." >&2; exit 1; }
241
+ echo "[start-agent] starting LOCAL build: $DIST"
242
+ nohup node "$DIST" agent >> "$LOG_FILE" 2>&1 &
243
+ else
244
+ echo "[start-agent] starting published @noesis-brain/mcp-server@latest"
245
+ nohup npx -y @noesis-brain/mcp-server@latest agent >> "$LOG_FILE" 2>&1 &
246
+ fi
247
+
248
+ # Wait for the DAEMON, not the npx parent. A cold `npx -y` install can take 10-60s, and the
249
+ # parent alone used to satisfy the old fixed 6s check — so a failed install reported success
250
+ # with zero daemons running.
251
+ started=0
252
+ for _ in $(seq 1 60); do
253
+ sleep 1
254
+ while read -r pid; do
255
+ [ -z "$pid" ] && continue
256
+ case "$(ps -o command= -p "$pid" 2>/dev/null || true)" in
257
+ *dist/index.js\ agent*|*noesis-mcp\ agent*) started="$pid"; break ;;
258
+ esac
259
+ done < <(pids_matching)
260
+ [ "$started" != 0 ] && break
261
+ done
262
+
263
+ if [ "$started" = 0 ]; then
264
+ echo "[start-agent] ERROR: daemon did not come up within 60s. Tail of $LOG_FILE:" >&2
265
+ tail -5 "$LOG_FILE" >&2 2>/dev/null || true
266
+ exit 1
267
+ fi
268
+
269
+ # THE ASSERT. Never leave a daemon running that lacks the tool boundary, whichever mode got
270
+ # us here — including the published default while the fix is unpublished.
271
+ read -r ver state <<<"$(describe_pid "$started")"
272
+ if [ "$state" != "FIXED" ]; then
273
+ echo "[start-agent] ERROR: started daemon pid $started is v$ver ($state) — it lacks the" >&2
274
+ echo " tool boundary and can read your filesystem. Stopping it." >&2
275
+ # Both passes over the whole pattern set, not just this pid: killing the child alone
276
+ # leaves the `npm exec` parent to respawn it — which is the exact failure this script's
277
+ # own header describes, and it would have happened right after printing "Stopping it."
278
+ kill_matching -TERM >&2
279
+ sleep 2
280
+ kill_matching -KILL >&2
281
+ sleep 1
282
+ if [ "$(count_matching)" -gt 0 ]; then
283
+ echo " WARNING: $(count_matching) process(es) survived; run --status." >&2
284
+ fi
285
+ echo " Publish $MIN_FIXED_VERSION or newer, or start the local build:" >&2
286
+ echo " $(basename "$0") --local" >&2
287
+ exit 1
288
+ fi
289
+
290
+ echo "[start-agent] running: pid $started (v$ver, $state)"
291
+ echo "[start-agent] log: $LOG_FILE"
292
+ tail -3 "$LOG_FILE" 2>/dev/null | sed 's/^/ /'