Skip to content

Commit 5f9bfdc

Browse files
cliffhallclaude
andauthored
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>
1 parent 85a4b4c commit 5f9bfdc

13 files changed

Lines changed: 823 additions & 38 deletions

File tree

clients/web/package-lock.json

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

clients/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"@modelcontextprotocol/sdk": "^1.29.0",
3434
"ajv": "^8.17.1",
3535
"atomically": "^2.1.1",
36+
"chokidar": "^4.0.3",
3637
"hono": "^4.12.18",
3738
"open": "^10.2.0",
3839
"pino": "^9.14.0",

clients/web/server/server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export async function startHonoServer(
4545

4646
const rootPath = config.staticRoot ?? __dirname;
4747

48-
const { app: apiApp } = createRemoteApp({
48+
const { app: apiApp, close: closeApi } = createRemoteApp({
4949
authToken: config.dangerouslyOmitAuth ? undefined : resolvedAuthToken,
5050
dangerouslyOmitAuth: config.dangerouslyOmitAuth,
5151
storageDir: config.storageDir,
@@ -117,6 +117,7 @@ export async function startHonoServer(
117117

118118
return {
119119
async close(): Promise<void> {
120+
await closeApi();
120121
await sandboxController.close();
121122
if ("closeAllConnections" in httpServer) {
122123
httpServer.closeAllConnections();

clients/web/server/vite-base-config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ export function getViteBaseConfig() {
2323
// keeps Vite's dev-time scanner from chasing it through the plugin's
2424
// node-only import chain.
2525
"atomically",
26+
// `chokidar` is only loaded inside `core/mcp/remote/node/server.ts`
27+
// when the lazy mcp.json watcher starts. It transitively imports
28+
// `readdirp` and core node fs/os modules; excluding it keeps Vite's
29+
// dep scanner from walking into them during dev startup.
30+
"chokidar",
2631
"cross-spawn",
2732
"which",
2833
],

clients/web/server/vite-hono-plugin.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,11 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
5353
});
5454
await sandboxController.start();
5555

56-
const originalClose = server.close.bind(server);
57-
server.close = async () => {
58-
await sandboxController.close();
59-
return originalClose();
60-
};
61-
62-
const { app: honoApp, authToken: resolvedToken } = createRemoteApp({
56+
const {
57+
app: honoApp,
58+
authToken: resolvedToken,
59+
close: closeApi,
60+
} = createRemoteApp({
6361
authToken: config.dangerouslyOmitAuth ? undefined : config.authToken,
6462
dangerouslyOmitAuth: config.dangerouslyOmitAuth,
6563
storageDir: config.storageDir,
@@ -69,6 +67,15 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
6967
initialConfig: webServerConfigToInitialPayload(config),
7068
});
7169

70+
// Chain the API close (mcp.json watcher) and the sandbox into the
71+
// Vite server's close so dev-server restarts release both resources.
72+
const originalClose = server.close.bind(server);
73+
server.close = async () => {
74+
await closeApi();
75+
await sandboxController.close();
76+
return originalClose();
77+
};
78+
7279
const sandboxUrl = sandboxController.getUrl();
7380

7481
const logBanner = () => {

clients/web/src/test/core/react/useServers.test.tsx

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,13 @@ interface Harness {
1919
fetchFn: typeof fetch;
2020
configPath: string;
2121
tempDir: string;
22+
closeApi: () => Promise<void>;
2223
}
2324

2425
function setupHarness(): Harness {
2526
const tempDir = mkdtempSync(join(tmpdir(), "inspector-useServers-"));
2627
const configPath = join(tempDir, "mcp.json");
27-
const { app } = createRemoteApp({
28+
const { app, close: closeApi } = createRemoteApp({
2829
dangerouslyOmitAuth: true,
2930
mcpConfigPath: configPath,
3031
initialConfig: { defaultEnvironment: {} },
@@ -38,10 +39,16 @@ function setupHarness(): Harness {
3839
: new Request(input as string | URL, init);
3940
return app.fetch(req) as Promise<Response>;
4041
};
41-
return { fetchFn, configPath, tempDir };
42+
return { fetchFn, configPath, tempDir, closeApi };
4243
}
4344

44-
function teardownHarness(h: Harness): void {
45+
async function teardownHarness(h: Harness): Promise<void> {
46+
// Closing here releases the lazy chokidar watcher started by the SSE
47+
// subscription useServers opens on mount. Without it the watcher would
48+
// hang around for the lifetime of the vitest worker — harmless for the
49+
// suite as a whole, but it would slow worker exit and could leak inotify
50+
// watches on Linux.
51+
await h.closeApi();
4552
try {
4653
rmSync(h.tempDir, { recursive: true });
4754
} catch {
@@ -60,8 +67,8 @@ describe("useServers", () => {
6067
h = setupHarness();
6168
});
6269

63-
afterEach(() => {
64-
teardownHarness(h);
70+
afterEach(async () => {
71+
await teardownHarness(h);
6572
});
6673

6774
it("starts in loading state, then loads and converts the seed config", async () => {
@@ -218,6 +225,83 @@ describe("useServers", () => {
218225
expect(result.current.servers.map((s) => s.id)).toEqual(["hand"]);
219226
});
220227

228+
it("auto-refreshes when the /api/servers/events SSE channel signals a change", async () => {
229+
// Mount the hook (which also opens the SSE subscription) and let the
230+
// initial GET settle so we're observing a steady state before mutating
231+
// the file.
232+
const { result } = renderHook(() =>
233+
useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }),
234+
);
235+
await waitFor(() => expect(result.current.loading).toBe(false));
236+
237+
// Give chokidar a beat to register with the now-seeded file. The lazy
238+
// watcher inside createRemoteApp starts on the first SSE subscriber, so
239+
// by the time the initial GET resolves the watcher is already attached.
240+
// The pause covers any platform-specific scan-stabilization window
241+
// before we mutate.
242+
await new Promise((r) => setTimeout(r, 200));
243+
244+
// Simulate an external editor save. The watcher fires → SSE broadcasts
245+
// → the hook's reader-loop calls refresh() without us touching the
246+
// exposed refresh() API.
247+
writeFileSync(
248+
h.configPath,
249+
JSON.stringify({
250+
mcpServers: { external: { type: "stdio", command: "outside" } },
251+
}) + "\n",
252+
);
253+
254+
await waitFor(
255+
() => {
256+
expect(result.current.servers.map((s) => s.id)).toEqual(["external"]);
257+
},
258+
{ timeout: 3000 },
259+
);
260+
});
261+
262+
it("SSE-driven refresh does not flip loading back to true (no skeleton flicker)", async () => {
263+
// Regression guard for the background-refresh split: a consumer
264+
// rendering a loading spinner / skeleton must not see `loading` go
265+
// true on every external mcp.json edit. The hook's SSE handler calls
266+
// refreshInternal(true), which skips the setLoading toggles entirely;
267+
// sampling a single point can miss the bug under React batching, so
268+
// record every observed `loading` value across the SSE cycle and
269+
// assert none of them is true.
270+
const loadingHistory: boolean[] = [];
271+
const { result } = renderHook(() => {
272+
const r = useServers({
273+
baseUrl: "http://test.local",
274+
fetchFn: h.fetchFn,
275+
});
276+
loadingHistory.push(r.loading);
277+
return r;
278+
});
279+
280+
await waitFor(() => expect(result.current.loading).toBe(false));
281+
// Slice off the mount-phase renders; only renders after the initial
282+
// load are subject to the no-flicker contract.
283+
const baselineLen = loadingHistory.length;
284+
285+
await new Promise((r) => setTimeout(r, 200));
286+
writeFileSync(
287+
h.configPath,
288+
JSON.stringify({
289+
mcpServers: { flicker: { type: "stdio", command: "outside" } },
290+
}) + "\n",
291+
);
292+
293+
await waitFor(
294+
() => {
295+
expect(result.current.servers.map((s) => s.id)).toEqual(["flicker"]);
296+
},
297+
{ timeout: 3000 },
298+
);
299+
300+
const postRefreshLoadings = loadingHistory.slice(baselineLen);
301+
expect(postRefreshLoadings.length).toBeGreaterThan(0);
302+
expect(postRefreshLoadings.every((v) => v === false)).toBe(true);
303+
});
304+
221305
it("captures the error message on fetch network failure", async () => {
222306
const failingFetch = vi
223307
.fn<typeof fetch>()

0 commit comments

Comments
 (0)