feat(servers): hot-reload server list on external mcp.json edits (#1345) (#1357)

* feat(servers): hot-reload server list on external mcp.json edits (#1345)

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>

* fix(servers): address PR review on hot-reload (#1357)

- 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>

* fix(servers): address pass-2 review on hot-reload (#1357)

- 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>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-05-25 11:30:05 -04:00 • committed by GitHub
commit 5f9bfdc490
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -19,12 +19,13 @@ interface Harness {
fetchFn: typeof fetch;
configPath: string;
tempDir: string;
closeApi: () => Promise<void>;
}
function setupHarness(): Harness {
const tempDir = mkdtempSync(join(tmpdir(), "inspector-useServers-"));
const configPath = join(tempDir, "mcp.json");
const { app } = createRemoteApp({
const { app, close: closeApi } = createRemoteApp({
dangerouslyOmitAuth: true,
mcpConfigPath: configPath,
initialConfig: { defaultEnvironment: {} },
@ -38,10 +39,16 @@ function setupHarness(): Harness {
: new Request(input as string | URL, init);
return app.fetch(req) as Promise<Response>;
};
return { fetchFn, configPath, tempDir };
return { fetchFn, configPath, tempDir, closeApi };
}
function teardownHarness(h: Harness): void {
async function teardownHarness(h: Harness): Promise<void> {
// Closing here releases the lazy chokidar watcher started by the SSE
// subscription useServers opens on mount. Without it the watcher would
// hang around for the lifetime of the vitest worker — harmless for the
// suite as a whole, but it would slow worker exit and could leak inotify
// watches on Linux.
await h.closeApi();
try {
rmSync(h.tempDir, { recursive: true });
} catch {
@ -60,8 +67,8 @@ describe("useServers", () => {
h = setupHarness();
});
afterEach(() => {
teardownHarness(h);
afterEach(async () => {
await teardownHarness(h);
});
it("starts in loading state, then loads and converts the seed config", async () => {
@ -218,6 +225,83 @@ describe("useServers", () => {
expect(result.current.servers.map((s) => s.id)).toEqual(["hand"]);
});
it("auto-refreshes when the /api/servers/events SSE channel signals a change", async () => {
// Mount the hook (which also opens the SSE subscription) and let the
// initial GET settle so we're observing a steady state before mutating
// the file.
const { result } = renderHook(() =>
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),
);
await waitFor(() => expect(result.current.loading).toBe(false));
// Give chokidar a beat to register with the now-seeded file. The lazy
// watcher inside createRemoteApp starts on the first SSE subscriber, so
// by the time the initial GET resolves the watcher is already attached.
// The pause covers any platform-specific scan-stabilization window
// before we mutate.
await new Promise((r) => setTimeout(r, 200));
// Simulate an external editor save. The watcher fires → SSE broadcasts
// → the hook's reader-loop calls refresh() without us touching the
// exposed refresh() API.
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: { external: { type: "stdio", command: "outside" } },
}) + "\n",
);
await waitFor(
() => {
expect(result.current.servers.map((s) => s.id)).toEqual(["external"]);
},
{ timeout: 3000 },
);
});
it("SSE-driven refresh does not flip loading back to true (no skeleton flicker)", async () => {
// Regression guard for the background-refresh split: a consumer
// rendering a loading spinner / skeleton must not see `loading` go
// true on every external mcp.json edit. The hook's SSE handler calls
// refreshInternal(true), which skips the setLoading toggles entirely;
// sampling a single point can miss the bug under React batching, so
// record every observed `loading` value across the SSE cycle and
// assert none of them is true.
const loadingHistory: boolean[] = [];
const { result } = renderHook(() => {
const r = useServers({
baseUrl: "http://test.local",
fetchFn: h.fetchFn,
});
loadingHistory.push(r.loading);
return r;
});
await waitFor(() => expect(result.current.loading).toBe(false));
// Slice off the mount-phase renders; only renders after the initial
// load are subject to the no-flicker contract.
const baselineLen = loadingHistory.length;
await new Promise((r) => setTimeout(r, 200));
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: { flicker: { type: "stdio", command: "outside" } },
}) + "\n",
);
await waitFor(
() => {
expect(result.current.servers.map((s) => s.id)).toEqual(["flicker"]);
},
{ timeout: 3000 },
);
const postRefreshLoadings = loadingHistory.slice(baselineLen);
expect(postRefreshLoadings.length).toBeGreaterThan(0);
expect(postRefreshLoadings.every((v) => v === false)).toBe(true);
});
it("captures the error message on fetch network failure", async () => {
const failingFetch = vi
.fn<typeof fetch>()