feat(servers): hot-reload server list on external mcp.json edits (#1345)#1357
Conversation
Watch ~/.mcp-inspector/mcp.json from the backend and push a change event on a new /api/servers/events SSE channel. The web client subscribes via fetch + ReadableStream (so the existing bearer-auth contract is preserved — EventSource can't send custom headers) and re-fetches on each event. The watcher is lazy: it starts on the first SSE subscriber and stops when the last one disconnects, so tests that never open the channel don't spin up a real fs watcher. Self-fires from the backend's own POST/PUT/DELETE are suppressed by capturing the post-write mtime and comparing it on each watcher event. Chokidar is used for cross-platform editor-save semantics (temp-file + rename produces unlink+add sequences that bare fs.watch handles poorly on macOS/Linux); awaitWriteFinish coalesces those into a single event. createRemoteApp now returns a close() to release the watcher, which the standalone server and the vite dev plugin chain into their existing close paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@claude review |
|
Claude finished @cliffhall's task in 2m 45s —— View job PR Review — hot-reload server list on external mcp.json edits
TL;DRSolid, well-documented PR. Architecture is sound: chokidar + A few things worth considering before merge — none blocking. Correctness / race concerns1. Narrow race: external edit masked by backend write (self-fire suppression)
The merged write on disk is correct (backend read the external edit), but other connected subscribers never learn — they have stale state until another external edit or refresh. Data isn't lost, but a peer tab sees a silent revert. For a single-user local dev tool this is acceptable. If you want to tighten it: snapshot 2.
|
- writeMcpAndTrackMtime now stat()s the file before writing; if the mtime differs from the last value we recorded, an external editor slipped in between our writes and chokidar would coalesce both into one event whose mtime matches our own write — broadcast directly so peer subscribers learn about the external edit (review finding 1, 5). - Split useServers.refresh into a public refresh() (loading toggle) and an internal refreshInternal(background) the SSE handler calls; the list now stays visible during background refreshes instead of flashing skeleton state on every external mcp.json edit (finding 2). - Coalesce multiple \n\n frames landing in one decode chunk to a single refresh call so back-to-back broadcasts don't race two concurrent GETs whose setState order is unspecified (finding 3). - Clarify createRemoteApp's close() docblock about in-flight SSE stream callbacks (finding 7). - Add integration coverage for PUT/DELETE self-suppression and for the external-edit-between-backend-writes broadcast (finding 6). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks for the review. Addressed in d90f8e6; running through each finding: 1. External edit masked by backend write — addressed. 2. 3. Rapid SSE frames → concurrent fetches — addressed (for the in-chunk case). The reader loop now counts frames in a decode chunk and fires a single 4. No SSE reconnect on transient failure — skipping. The existing inline comment already calls this out as a deliberate dev-tool choice. Surfacing an 5. 6. PUT/DELETE self-suppression coverage — added. Two new cases in 7. 8. 9. Banner / endpoint docs — checked All checks green:
|
|
@claude review |
|
Claude encountered an error after 22s —— View job I'll analyze this and get back to you. |
|
Claude finished @cliffhall's task in 2m 8s —— View job Follow-up review — d90f8e6 (response to prior findings)
TL;DRThe fixes do what they say. All nine prior findings are either addressed correctly or have a reasoned skip. Verdict: approve; no blockers. Three small notes below — none worth holding the PR. Verification of prior findings
New notes on the fix commitA. The pre-stat fix catches notifications, not lost updates
This matches the stated goal ("peer-tab visibility") and the new integration test only asserts notification. It is the right tradeoff for a single-user local dev tool. Worth an inline comment in B. PUT/DELETE test comment is slightly off
// A GET first so the backend records `lastWrittenMtimeMs` from any
// seed-write path it owns and so writeMcpAndTrackMtime's pre-stat below
// doesn't trip the external-edit-detected branch.
await fetch(`${h.baseUrl}/api/servers`);The pre-stat branch is guarded by C. Missing regression test for finding #2The fix for the loading flicker ( // After the initial load completes, SSE-triggered refreshes must not
// re-toggle loading — consumers rendering a skeleton would flash.
await new Promise((r) => setTimeout(r, 200));
writeFileSync(h.configPath, /* external edit */);
// Sample `loading` while the refetch is in-flight:
expect(result.current.loading).toBe(false);
await waitFor(() => expect(result.current.servers.map((s) => s.id)).toEqual(["external"]));
expect(result.current.loading).toBe(false);Would lock in the behavior so a future refactor that reaches for VerdictFixes land cleanly, race analysis holds up under interleaving walk-through, tests are a real improvement. LGTM — items A, B, C are polish, not blockers. |
- Clarify writeMcpAndTrackMtime comment so a future reader can't mistake the broadcast for a content-preservation guarantee: the external edit's content may already have been read-around and overwritten depending on the interleave with readMcpConfig. - Drop the no-op GET in the PUT/DELETE self-suppression tests. The pre-stat branch is short-circuited by `lastWrittenMtimeMs === null` with or without it; matches the POST test's structure. - Add explicit no-flicker regression test: tracks every `loading` value across renders during an SSE-driven refresh and asserts none flipped back to true. Catches a future regression that reaches for setLoading(true) in the background path even under React batching. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks for the second pass. All three items addressed in fd7faa4: A. B. PUT/DELETE test comment — addressed by removing the GET entirely. You're right that the pre-stat branch is short-circuited by C. Loading-flicker regression test — addressed. Used a render-history array rather than a single-point sample so the assertion is deterministic under React batching: the hook is wrapped in a tracker that pushes All checks green:
|
|
@claude review |
|
Claude finished @cliffhall's task in 1m 18s —— View job Follow-up review — fd7faa4 (pass 3)
TL;DRAll three items from pass 2 land cleanly. No new issues. LGTM — ship it. VerificationA.
B. PUT/DELETE test comment ✅
C. Loading-flicker regression test ✅
Walked the failure mode: if a future refactor reinstated New notes (none blocking)No new issues spotted. Diff is small and surgical; nothing introduced that wasn't explicitly called out in the response comment. One minor observation for future work (not actionable here): the SSE handler in VerdictThree passes in, race analysis held up, test gaps closed. Approve for merge. |
Summary
~/.mcp-inspector/mcp.jsonvia chokidar and emits change events on a newGET /api/servers/eventsSSE channel.fetch()+ReadableStreamfromuseServers(so the existingx-mcp-remote-auth: Bearer …header still flows — EventSource can't send custom headers) and re-fetches on each event.POST/PUT/DELETEwrites are suppressed by capturing the post-write mtime and comparing on each watcher event; only external edits trigger a broadcast.createRemoteAppnow returns aclose()that prod callers chain into their shutdown paths so the watcher is released on exit.Closes #1345.
Decisions (per the issue's "Risks" section)
fs.watch— editors save via temp-file + rename, which producesunlink+addsequences that barefs.watchhandles poorly on macOS/Linux.awaitWriteFinishcoalesces those into a single event./api/servers/eventschannel, not piggyback — the existing/api/mcp/eventsis per-MCP-session (only open while a transport session is active).mcp.jsonchanges are app-scoped; piggybacking would miss changes while the user is on the Servers screen with no active connection.stat().mtimeMsright after every backend write inside the existingwithWriteLock; the watcher handler stat()s and bails when the value matches.Test plan
npm run test(1243 tests)npm run test:integration(437 tests, including 4 new inservers-events.test.ts)npm run test:storybook(306 tests)npm run test:coverageper-file thresholds passnpm run devboots, banner prints, browser opens🤖 Generated with Claude Code