Skip to content

chore(mcp-server): restart the dev server only on real source changes - #224

Merged
vinikjkkj merged 3 commits into
masterfrom
chore/mcp-dev-watch-runner
Aug 1, 2026
Merged

vinikjkkj merged 3 commits into
masterfrom
chore/mcp-dev-watch-runner

Conversation

@vinikjkkj

@vinikjkkj vinikjkkj commented Aug 1, 2026 •

Copy link
Copy Markdown
Owner

On Windows libuv subscribes fs.watch to FILE_NOTIFY_CHANGE_LAST_ACCESS, so merely reading a file emits a change event once NTFS flushes a new last-access time. node --watch restarts on any event for a file in its module graph without checking whether the contents moved, so the MCP server restarted once or twice during boot, on the first lazy better-sqlite3 import (the first connect() of every launch), and whenever another tool walked the tree.

Add scripts/dev-watch.cjs: it spawns the child, watches the source trees with the same recursive fs.watch, and compares mtime + size against a cache primed at startup before restarting, so last-access-only events are dropped. Bursts are debounced, the file that triggered the restart is logged, and generated folders (node_modules, dist, target, tests, test, bench, .turbo, coverage) are skipped.

Measured on this repo: boot goes from 1-2 restarts to none, the first connect() no longer restarts, and real content edits still reload in about 1.5s.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added a more reliable development file watcher that automatically restarts the application when relevant source files change.
    • Development output now identifies triggering files and restart activity for easier troubleshooting.
    • Improved graceful shutdown, restart handling, and cross-platform behavior during development.
  • Documentation

    • Updated development guidance with setup details, watched paths, platform considerations, and watcher limitations.

On Windows libuv subscribes fs.watch to FILE_NOTIFY_CHANGE_LAST_ACCESS, so
merely reading a file emits a change event once NTFS flushes a new last-access
time. `node --watch` restarts on any event for a file in its module graph
without checking whether the contents moved, so the MCP server restarted once
or twice during boot, on the first lazy `better-sqlite3` import (the first
connect() of every launch), and whenever another tool walked the tree.

Add scripts/dev-watch.cjs: it spawns the child, watches the source trees with
the same recursive fs.watch, and compares mtime + size against a cache primed
at startup before restarting, so last-access-only events are dropped. Bursts
are debounced, the file that triggered the restart is logged, and generated
folders (node_modules, dist, target, __tests__, __test__, bench, .turbo,
coverage) are skipped.

Measured on this repo: boot goes from 1-2 restarts to none, the first connect()
no longer restarts, and real content edits still reload in about 1.5s.
@github-actions github-actions Bot added the chore Chore label Aug 1, 2026
@coderabbitai

coderabbitai Bot commented Aug 1, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@vinikjkkj, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 26 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 08e2079e-8250-4c6b-b15b-806842c79109

📥 Commits

Reviewing files that changed from the base of the PR and between 9ac7a7d and 4b70171.

📒 Files selected for processing (4)
  • AGENTS.md
  • packages/mcp-server/README.md
  • packages/mcp-server/package.json
  • scripts/dev-watch.cjs
📝 Walkthrough

Walkthrough

The PR adds scripts/dev-watch.cjs for recursive TypeScript source watching and child-process restarts. It updates the MCP server script and documentation to describe the runner’s filtering, restart behavior, Windows rationale, and child-exit limitation.

Changes

MCP development watch workflow

Layer / File(s) Summary
Watcher configuration and change detection
scripts/dev-watch.cjs
Adds command parsing, source filtering, file fingerprinting, filesystem watching, and debounced reactions to meaningful metadata changes.
Child-process lifecycle
scripts/dev-watch.cjs
Adds child startup, restart handling, termination escalation, spawn-error logging, and signal-driven shutdown.
MCP workflow integration
packages/mcp-server/package.json, AGENTS.md, packages/mcp-server/README.md
The MCP development command uses the custom watcher for src and packages. Documentation describes its behavior and limitations.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Developer
  participant dev-watch.cjs
  participant MCPServer
  Developer->>dev-watch.cjs: Start watcher for src and packages
  dev-watch.cjs->>MCPServer: Spawn development command
  Developer->>dev-watch.cjs: Modify watched TypeScript file
  dev-watch.cjs->>MCPServer: Terminate and restart child process
Loading

Possibly related PRs

  • vinikjkkj/zapo#56: Replaced the MCP server’s earlier node --watch development workflow.
  • vinikjkkj/zapo#116: Modified the same MCP server development script and documentation areas.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: restarting the MCP development server only when source files change.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/mcp-dev-watch-runner

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
scripts/dev-watch.cjs (2)

194-201: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Wait for the child to exit before the parent exits.

shutdown sends SIGTERM and then calls process.exit(0) in the same tick. The child receives the signal, but the parent does not wait for the child to finish its cleanup, and the shell prompt returns while the HTTP listener may still hold port 3737. Exit after the child exit event, with a timeout fallback.

♻️ Proposed graceful shutdown
 const shutdown = () => {
     if (shuttingDown) return
     shuttingDown = true
-    if (child) child.kill('SIGTERM')
-    process.exit(0)
+    if (!child) {
+        process.exit(0)
+        return
+    }
+    const dying = child
+    dying.once('exit', () => process.exit(0))
+    dying.kill('SIGTERM')
+    setTimeout(() => {
+        dying.kill('SIGKILL')
+        process.exit(0)
+    }, KILL_TIMEOUT_MS).unref()
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/dev-watch.cjs` around lines 194 - 201, Update shutdown in
dev-watch.cjs to defer process.exit(0) until the child process emits its exit
event after SIGTERM. Add a timeout fallback that exits if the child does not
terminate promptly, while preserving the shuttingDown guard and handling
shutdown when no child exists.

70-73: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Match ignored directories relative to the watched root.

isWatchable splits the absolute path. Every ancestor segment outside the repository is tested against IGNORED_DIRS. If a developer clones the repository under a folder named dist, coverage, or target, then every file is filtered and the watcher never restarts. Test only the segments below the watched directory.

♻️ Proposed relative-path filter
-const isWatchable = (filePath) => {
-    if (!WATCHED_EXTENSIONS.has(path.extname(filePath))) return false
-    return !filePath.split(path.sep).some((segment) => IGNORED_DIRS.has(segment))
-}
+const isWatchable = (filePath, root = REPO_ROOT) => {
+    if (!WATCHED_EXTENSIONS.has(path.extname(filePath))) return false
+    const relative = path.relative(root, filePath)
+    return !relative.split(path.sep).some((segment) => IGNORED_DIRS.has(segment))
+}

Note that REPO_ROOT is declared at Line 63, so keep the declaration order valid if you reference it as a default value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/dev-watch.cjs` around lines 70 - 73, Update isWatchable to evaluate
ignored-directory segments from the path relative to REPO_ROOT, rather than
splitting the absolute file path; preserve the existing extension check and
ignored-directory filtering for repository descendants. Ensure REPO_ROOT remains
declared before any default-value reference used by the updated logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/mcp-server/package.json`:
- Line 37: Update the development-loop documentation in the MCP server README to
match the package.json dev script: describe scripts/dev-watch.cjs watching
source changes and restarting the process, and remove the outdated HTTP/node
--watch behavior and Windows last-access-time discussion.

In `@scripts/dev-watch.cjs`:
- Around line 186-192: Update the watchDirs loop in the development watcher to
handle recursive fs.watch failures explicitly: either abort startup by
propagating or clearly terminating on the error, or implement a per-directory
watcher fallback before invoking startChild(). Do not allow execution to
continue with no registered watchers after the catch.

---

Nitpick comments:
In `@scripts/dev-watch.cjs`:
- Around line 194-201: Update shutdown in dev-watch.cjs to defer process.exit(0)
until the child process emits its exit event after SIGTERM. Add a timeout
fallback that exits if the child does not terminate promptly, while preserving
the shuttingDown guard and handling shutdown when no child exists.
- Around line 70-73: Update isWatchable to evaluate ignored-directory segments
from the path relative to REPO_ROOT, rather than splitting the absolute file
path; preserve the existing extension check and ignored-directory filtering for
repository descendants. Ensure REPO_ROOT remains declared before any
default-value reference used by the updated logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cd266adf-dd63-4422-821f-4baa24cef536

📥 Commits

Reviewing files that changed from the base of the PR and between 5b1a8b9 and 1229a2f.

📒 Files selected for processing (3)
  • AGENTS.md
  • packages/mcp-server/package.json
  • scripts/dev-watch.cjs

Comment thread packages/mcp-server/package.json Outdated
Comment thread scripts/dev-watch.cjs

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="scripts/dev-watch.cjs">

<violation number="1" location="scripts/dev-watch.cjs:135">
P2: Editing several files while a reload is already in progress can spawn duplicate server processes. `restart()` doesn't guard against being called while `restarting` is true, and `dying` is the same ChildProcess throughout the kill window (up to KILL_TIMEOUT_MS = 3s). Events that land during that window get debounced and call `restart()` again, which registers another `dying.once('exit', ...)` listener; since `once` fires every registered listener, every extra call runs `startChild()` again once the process exits. The result is two (or more) node servers started back-to-back, with the stragglers failing to bind port 3737 and confusing the dev loop. Add a coalescing guard at the top of `restart()` so a second trigger during an active restart is dropped (the edit is already on disk and covered by the restart in progress).</violation>

<violation number="2" location="scripts/dev-watch.cjs:188">
P2: Replacing or removing a watched root can crash the dev runner because asynchronous watcher errors are unhandled. Keep each watcher and handle its `error` event so failure is logged instead of terminating the process.</violation>

<violation number="3" location="scripts/dev-watch.cjs:197">
P2: A Ctrl+C/SIGTERM issued during an in-progress restart can leave the server holding port 3737. `shutdown()` kills `child` (which is `null` in the window where the old process is being torn down and the new one isn't started yet) and then calls `process.exit(0)` immediately. The pending `killTimer` that would escalate to SIGKILL is `unref()`'d, so it won't keep the parent alive to enforce the kill. If a child swallows SIGTERM during that window it is orphaned and the port stays bound. Consider awaiting the child's exit (or clearing/overriding the pending kill with an immediate SIGKILL) before exiting.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread scripts/dev-watch.cjs Outdated

for (const dir of watchDirs) {
try {
watch(dir, { recursive: true }, (_eventType, name) => onFsEvent(dir, name))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Replacing or removing a watched root can crash the dev runner because asynchronous watcher errors are unhandled. Keep each watcher and handle its error event so failure is logged instead of terminating the process.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/dev-watch.cjs, line 188:

<comment>Replacing or removing a watched root can crash the dev runner because asynchronous watcher errors are unhandled. Keep each watcher and handle its `error` event so failure is logged instead of terminating the process.</comment>

<file context>
@@ -0,0 +1,204 @@
+
+for (const dir of watchDirs) {
+    try {
+        watch(dir, { recursive: true }, (_eventType, name) => onFsEvent(dir, name))
+    } catch (error) {
+        log(`cannot watch ${dir}: ${error.message}`)
</file context>

Comment thread scripts/dev-watch.cjs Outdated
const shutdown = () => {
if (shuttingDown) return
shuttingDown = true
if (child) child.kill('SIGTERM')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A Ctrl+C/SIGTERM issued during an in-progress restart can leave the server holding port 3737. shutdown() kills child (which is null in the window where the old process is being torn down and the new one isn't started yet) and then calls process.exit(0) immediately. The pending killTimer that would escalate to SIGKILL is unref()'d, so it won't keep the parent alive to enforce the kill. If a child swallows SIGTERM during that window it is orphaned and the port stays bound. Consider awaiting the child's exit (or clearing/overriding the pending kill with an immediate SIGKILL) before exiting.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/dev-watch.cjs, line 197:

<comment>A Ctrl+C/SIGTERM issued during an in-progress restart can leave the server holding port 3737. `shutdown()` kills `child` (which is `null` in the window where the old process is being torn down and the new one isn't started yet) and then calls `process.exit(0)` immediately. The pending `killTimer` that would escalate to SIGKILL is `unref()`'d, so it won't keep the parent alive to enforce the kill. If a child swallows SIGTERM during that window it is orphaned and the port stays bound. Consider awaiting the child's exit (or clearing/overriding the pending kill with an immediate SIGKILL) before exiting.</comment>

<file context>
@@ -0,0 +1,204 @@
+const shutdown = () => {
+    if (shuttingDown) return
+    shuttingDown = true
+    if (child) child.kill('SIGTERM')
+    process.exit(0)
+}
</file context>

Comment thread scripts/dev-watch.cjs
})
}

const restart = () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Editing several files while a reload is already in progress can spawn duplicate server processes. restart() doesn't guard against being called while restarting is true, and dying is the same ChildProcess throughout the kill window (up to KILL_TIMEOUT_MS = 3s). Events that land during that window get debounced and call restart() again, which registers another dying.once('exit', ...) listener; since once fires every registered listener, every extra call runs startChild() again once the process exits. The result is two (or more) node servers started back-to-back, with the stragglers failing to bind port 3737 and confusing the dev loop. Add a coalescing guard at the top of restart() so a second trigger during an active restart is dropped (the edit is already on disk and covered by the restart in progress).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/dev-watch.cjs, line 135:

<comment>Editing several files while a reload is already in progress can spawn duplicate server processes. `restart()` doesn't guard against being called while `restarting` is true, and `dying` is the same ChildProcess throughout the kill window (up to KILL_TIMEOUT_MS = 3s). Events that land during that window get debounced and call `restart()` again, which registers another `dying.once('exit', ...)` listener; since `once` fires every registered listener, every extra call runs `startChild()` again once the process exits. The result is two (or more) node servers started back-to-back, with the stragglers failing to bind port 3737 and confusing the dev loop. Add a coalescing guard at the top of `restart()` so a second trigger during an active restart is dropped (the edit is already on disk and covered by the restart in progress).</comment>

<file context>
@@ -0,0 +1,204 @@
+    })
+}
+
+const restart = () => {
+    const changed = Array.from(pendingChanges)
+    pendingChanges.clear()
</file context>

…on exit

Review follow-ups on the dev runner.

restart() had no guard against being called while a restart was in flight: a
change landing inside the kill window stacked another exit listener on the
dying child, so each extra trigger spawned one more server once it finally
exited. Drop those triggers instead - the child the in-flight restart spawns
already reads the newer file from disk.

shutdown() killed the child and exited in the same tick with the SIGKILL
escalation unref'd, so signalling the runner alone left the child holding the
HTTP port and the next run failed to bind. Wait for the child's exit and keep
the escalation timer alive to enforce the kill.

fs.watch errors (a watched root deleted or replaced while running) had no
listener and would throw out of the runner; log them instead. When no
directory can be watched at all, abort rather than starting a child in a dev
loop that silently never reloads.

Also update packages/mcp-server/README.md, which still documented the
node --watch dev loop.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/mcp-server/README.md`:
- Around line 108-109: Update the documentation sentence near the node --watch
description to replace “contents moved” with “file contents changed,” preserving
the surrounding explanation and Windows rationale.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 61990ca9-bd9a-431b-a8ae-42b6bbb71858

📥 Commits

Reviewing files that changed from the base of the PR and between 1229a2f and 9ac7a7d.

📒 Files selected for processing (2)
  • packages/mcp-server/README.md
  • scripts/dev-watch.cjs
🚧 Files skipped from review as they are similar to previous changes (1)
  • scripts/dev-watch.cjs

Comment thread packages/mcp-server/README.md Outdated
Comment on lines +108 to +109
> test run, editor indexing, grep). `node --watch` restarts on any event for a
> file in its module graph without checking whether the contents moved, which

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Replace “contents moved” with “file contents changed”.

“Contents moved” is not a defined file-change condition and makes the Windows rationale unclear.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/mcp-server/README.md` around lines 108 - 109, Update the
documentation sentence near the node --watch description to replace “contents
moved” with “file contents changed,” preserving the surrounding explanation and
Windows rationale.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 4 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="scripts/dev-watch.cjs">

<violation number="1" location="scripts/dev-watch.cjs:151">
P1: Stopping the watcher during an in-flight restart can leave the replacement MCP server orphaned. The restart exit listener unconditionally calls `startChild()`, while `shutdown()` can be invoked before that listener runs and registers its own exit handler that immediately exits the runner; the newly spawned child then survives the parent and can keep port 3737 bound. The restart completion path should skip spawning when `shuttingDown` is set.</violation>

<violation number="2" location="scripts/dev-watch.cjs:181">
P2: The watcher does not actually verify content changes: it treats `mtimeMs + size` as the file identity. A same-size edit whose timestamp is preserved can be ignored, while a metadata-only mtime update such as `touch` can trigger a restart even when bytes are unchanged. That means the runner can still miss real edits or restart for non-content changes, contrary to the stated content-only behavior; a content digest check (at least after the metadata fingerprint changes) would make this reliable.</violation>

<violation number="3" location="scripts/dev-watch.cjs:198">
P2: Generated directories are still in the recursive watch scope, so activity under `node_modules`, `dist`, or `coverage` still reaches this watcher despite the stated exclusion and can add unnecessary event pressure. Use non-recursive watchers only for eligible directories, adding eligible new directories as needed, when these trees must not be watched.</violation>
</file>

<file name="packages/mcp-server/package.json">

<violation number="1" location="packages/mcp-server/package.json:37">
P2: Changes to the runtime spec sources do not restart this dev server because the watcher only scans `src` and `packages`. For example, `src/proto.ts`, `src/mex.ts`, `src/appstate-spec.ts`, `src/abprops-spec.ts`, and `src/version-spec.ts` import runtime modules from `../spec`; editing `spec/version/index.js`, `spec/mex/index.js`, or generated protobuf output can therefore leave the running MCP process using stale protocol data until it is manually restarted. Including the `spec` tree in the watched roots would keep the advertised reload behavior complete.</violation>
</file>

<file name="packages/mcp-server/README.md">

<violation number="1" location="packages/mcp-server/README.md:109">
P3: Consider rewording "without checking whether the contents moved" to something like "without checking whether the file contents actually changed" — "contents moved" isn't a defined file-change condition and muddles the explanation of why `node --watch` over-restarts on Windows.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread scripts/dev-watch.cjs
log(`restarting: ${display(changed[0])}${extra}`)

if (!child) {
startChild()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Stopping the watcher during an in-flight restart can leave the replacement MCP server orphaned. The restart exit listener unconditionally calls startChild(), while shutdown() can be invoked before that listener runs and registers its own exit handler that immediately exits the runner; the newly spawned child then survives the parent and can keep port 3737 bound. The restart completion path should skip spawning when shuttingDown is set.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/dev-watch.cjs, line 151:

<comment>Stopping the watcher during an in-flight restart can leave the replacement MCP server orphaned. The restart exit listener unconditionally calls `startChild()`, while `shutdown()` can be invoked before that listener runs and registers its own exit handler that immediately exits the runner; the newly spawned child then survives the parent and can keep port 3737 bound. The restart completion path should skip spawning when `shuttingDown` is set.</comment>

<file context>
@@ -0,0 +1,239 @@
+    log(`restarting: ${display(changed[0])}${extra}`)
+
+    if (!child) {
+        startChild()
+        return
+    }
</file context>

Comment thread scripts/dev-watch.cjs
let watchedDirs = 0
for (const dir of watchDirs) {
try {
const watcher = watch(dir, { recursive: true }, (_eventType, name) => onFsEvent(dir, name))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Generated directories are still in the recursive watch scope, so activity under node_modules, dist, or coverage still reaches this watcher despite the stated exclusion and can add unnecessary event pressure. Use non-recursive watchers only for eligible directories, adding eligible new directories as needed, when these trees must not be watched.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/dev-watch.cjs, line 198:

<comment>Generated directories are still in the recursive watch scope, so activity under `node_modules`, `dist`, or `coverage` still reaches this watcher despite the stated exclusion and can add unnecessary event pressure. Use non-recursive watchers only for eligible directories, adding eligible new directories as needed, when these trees must not be watched.</comment>

<file context>
@@ -0,0 +1,239 @@
+let watchedDirs = 0
+for (const dir of watchDirs) {
+    try {
+        const watcher = watch(dir, { recursive: true }, (_eventType, name) => onFsEvent(dir, name))
+        // An unhandled 'error' (watched root deleted or replaced while running)
+        // is thrown by the EventEmitter and would take the runner down.
</file context>

Comment thread packages/mcp-server/package.json Outdated
"test": "node --import tsx --test \"src/**/__tests__/**/*.test.ts\"",
"start": "node --import tsx src/bin.ts",
"dev": "cross-env MCP_TRANSPORT=http MCP_EVAL_ENABLED=1 MCP_LOG_LEVEL=trace MCP_AUTH_PATH=../../.auth/state.sqlite node --watch --import tsx src/bin.ts"
"dev": "cross-env MCP_TRANSPORT=http MCP_EVAL_ENABLED=1 MCP_LOG_LEVEL=trace MCP_AUTH_PATH=../../.auth/state.sqlite node ../../scripts/dev-watch.cjs --watch ../../src --watch ../../packages -- node --import tsx src/bin.ts"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Changes to the runtime spec sources do not restart this dev server because the watcher only scans src and packages. For example, src/proto.ts, src/mex.ts, src/appstate-spec.ts, src/abprops-spec.ts, and src/version-spec.ts import runtime modules from ../spec; editing spec/version/index.js, spec/mex/index.js, or generated protobuf output can therefore leave the running MCP process using stale protocol data until it is manually restarted. Including the spec tree in the watched roots would keep the advertised reload behavior complete.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/mcp-server/package.json, line 37:

<comment>Changes to the runtime spec sources do not restart this dev server because the watcher only scans `src` and `packages`. For example, `src/proto.ts`, `src/mex.ts`, `src/appstate-spec.ts`, `src/abprops-spec.ts`, and `src/version-spec.ts` import runtime modules from `../spec`; editing `spec/version/index.js`, `spec/mex/index.js`, or generated protobuf output can therefore leave the running MCP process using stale protocol data until it is manually restarted. Including the `spec` tree in the watched roots would keep the advertised reload behavior complete.</comment>

<file context>
@@ -34,7 +34,7 @@
         "test": "node --import tsx --test \"src/**/__tests__/**/*.test.ts\"",
         "start": "node --import tsx src/bin.ts",
-        "dev": "cross-env MCP_TRANSPORT=http MCP_EVAL_ENABLED=1 MCP_LOG_LEVEL=trace MCP_AUTH_PATH=../../.auth/state.sqlite node --watch --import tsx src/bin.ts"
+        "dev": "cross-env MCP_TRANSPORT=http MCP_EVAL_ENABLED=1 MCP_LOG_LEVEL=trace MCP_AUTH_PATH=../../.auth/state.sqlite node ../../scripts/dev-watch.cjs --watch ../../src --watch ../../packages -- node --import tsx src/bin.ts"
     },
     "dependencies": {
</file context>
Suggested change
"dev": "cross-env MCP_TRANSPORT=http MCP_EVAL_ENABLED=1 MCP_LOG_LEVEL=trace MCP_AUTH_PATH=../../.auth/state.sqlite node ../../scripts/dev-watch.cjs --watch ../../src --watch ../../packages -- node --import tsx src/bin.ts"
"dev": "cross-env MCP_TRANSPORT=http MCP_EVAL_ENABLED=1 MCP_LOG_LEVEL=trace MCP_AUTH_PATH=../../.auth/state.sqlite node ../../scripts/dev-watch.cjs --watch ../../src --watch ../../packages --watch ../../spec -- node --import tsx src/bin.ts"

Comment thread scripts/dev-watch.cjs
const previous = fingerprints.get(filePath)
// Same mtime and size: a last-access-time flush or a metadata-only touch,
// not an edit. This is the event `node --watch` restarts on.
if (previous === next) return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The watcher does not actually verify content changes: it treats mtimeMs + size as the file identity. A same-size edit whose timestamp is preserved can be ignored, while a metadata-only mtime update such as touch can trigger a restart even when bytes are unchanged. That means the runner can still miss real edits or restart for non-content changes, contrary to the stated content-only behavior; a content digest check (at least after the metadata fingerprint changes) would make this reliable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/dev-watch.cjs, line 181:

<comment>The watcher does not actually verify content changes: it treats `mtimeMs + size` as the file identity. A same-size edit whose timestamp is preserved can be ignored, while a metadata-only mtime update such as `touch` can trigger a restart even when bytes are unchanged. That means the runner can still miss real edits or restart for non-content changes, contrary to the stated content-only behavior; a content digest check (at least after the metadata fingerprint changes) would make this reliable.</comment>

<file context>
@@ -0,0 +1,239 @@
+    const previous = fingerprints.get(filePath)
+    // Same mtime and size: a last-access-time flush or a metadata-only touch,
+    // not an edit. This is the event `node --watch` restarts on.
+    if (previous === next) return
+    if (next === null) {
+        fingerprints.delete(filePath)
</file context>

Comment thread packages/mcp-server/README.md Outdated
> `fs.watch` to `FILE_NOTIFY_CHANGE_LAST_ACCESS`, so merely **reading** a file
> emits a change event once NTFS flushes a new last-access time (module load,
> test run, editor indexing, grep). `node --watch` restarts on any event for a
> file in its module graph without checking whether the contents moved, which

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Consider rewording "without checking whether the contents moved" to something like "without checking whether the file contents actually changed" — "contents moved" isn't a defined file-change condition and muddles the explanation of why node --watch over-restarts on Windows.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/mcp-server/README.md, line 109:

<comment>Consider rewording "without checking whether the contents moved" to something like "without checking whether the file contents actually changed" — "contents moved" isn't a defined file-change condition and muddles the explanation of why `node --watch` over-restarts on Windows.</comment>

<file context>
@@ -81,27 +81,38 @@ processes only when you want isolated stores / auth files.
+> `fs.watch` to `FILE_NOTIFY_CHANGE_LAST_ACCESS`, so merely **reading** a file
+> emits a change event once NTFS flushes a new last-access time (module load,
+> test run, editor indexing, grep). `node --watch` restarts on any event for a
+> file in its module graph without checking whether the contents moved, which
+> makes the server restart on the first lazy import - `better-sqlite3`, loaded
+> on the first `connect()` - and whenever another tool walks the tree. The
</file context>
Suggested change
> file in its module graph without checking whether the contents moved, which
> file in its module graph without checking whether the file contents actually changed, which

… tree

Review follow-ups on the dev runner.

Stopping the runner during an in-flight restart left an orphan holding the HTTP
port: exit listeners fire in registration order, so the restart listener spawned
the replacement child before shutdown() got its own listener and exited the
runner. startChild() now returns early once a shutdown has started.

The watch roots missed spec/. The core bridges (proto, mex, appstate, abprops,
version) import from ../spec at runtime, so regenerating protobuf output or the
version json left the running server on stale protocol data - node --watch did
reload on those, since they sit in the module graph.

Also drop the "contents moved" phrasing from both docs and the script header:
what node --watch skips is checking whether the file was written at all.
@vinikjkkj
vinikjkkj merged commit 71b8cf5 into master Aug 1, 2026
9 of 10 checks passed
@vinikjkkj
vinikjkkj deleted the chore/mcp-dev-watch-runner branch August 1, 2026 16:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

chore Chore

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant