Skip to content

Commit d90f8e6

Browse files
cliffhallclaude
andcommitted
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>
1 parent 83f4139 commit d90f8e6

3 files changed

Lines changed: 203 additions & 23 deletions

File tree

clients/web/src/test/integration/mcp/remote/servers-events.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,126 @@ describe("GET /api/servers/events", () => {
225225
sink.close();
226226
});
227227

228+
it("does NOT emit when the backend's own PUT /api/servers/:id writes the file", async () => {
229+
writeFileSync(
230+
h.configPath,
231+
JSON.stringify(
232+
{ mcpServers: { alpha: { type: "stdio", command: "old" } } },
233+
null,
234+
2,
235+
) + "\n",
236+
);
237+
// A GET first so the backend records `lastWrittenMtimeMs` from any
238+
// seed-write path it owns and so writeMcpAndTrackMtime's pre-stat below
239+
// doesn't trip the external-edit-detected branch.
240+
await fetch(`${h.baseUrl}/api/servers`);
241+
const sink = await subscribe(h.baseUrl);
242+
await new Promise((r) => setTimeout(r, 150));
243+
244+
const putRes = await fetch(`${h.baseUrl}/api/servers/alpha`, {
245+
method: "PUT",
246+
headers: { "Content-Type": "application/json" },
247+
body: JSON.stringify({
248+
id: "alpha",
249+
config: { type: "stdio", command: "new" },
250+
}),
251+
});
252+
expect(putRes.status).toBe(200);
253+
254+
await new Promise((r) => setTimeout(r, 400));
255+
expect(sink.events).toEqual([]);
256+
sink.close();
257+
});
258+
259+
it("does NOT emit when the backend's own DELETE /api/servers/:id writes the file", async () => {
260+
writeFileSync(
261+
h.configPath,
262+
JSON.stringify(
263+
{ mcpServers: { alpha: { type: "stdio", command: "node" } } },
264+
null,
265+
2,
266+
) + "\n",
267+
);
268+
await fetch(`${h.baseUrl}/api/servers`);
269+
const sink = await subscribe(h.baseUrl);
270+
await new Promise((r) => setTimeout(r, 150));
271+
272+
const delRes = await fetch(`${h.baseUrl}/api/servers/alpha`, {
273+
method: "DELETE",
274+
});
275+
expect(delRes.status).toBe(200);
276+
277+
await new Promise((r) => setTimeout(r, 400));
278+
expect(sink.events).toEqual([]);
279+
sink.close();
280+
});
281+
282+
it("emits a broadcast when an external edit slips in between two backend writes (peer-tab visibility)", async () => {
283+
// This is the race the watcher-handler suppression cannot catch on its
284+
// own: external editor saves AFTER the backend has read the current
285+
// mtime but BEFORE chokidar's awaitWriteFinish has fired, so chokidar
286+
// coalesces the external mtime and our merged-write mtime into one
287+
// event whose mtime matches our own write — and we'd suppress.
288+
// `writeMcpAndTrackMtime` covers this by stat()ing on entry and
289+
// broadcasting itself if the pre-write mtime doesn't match its tracked
290+
// value, so a second connected subscriber learns about the change.
291+
writeFileSync(
292+
h.configPath,
293+
JSON.stringify(
294+
{ mcpServers: { alpha: { type: "stdio", command: "v1" } } },
295+
null,
296+
2,
297+
) + "\n",
298+
);
299+
// Drive the first write through the backend so lastWrittenMtimeMs is
300+
// populated with the mtime we expect on disk going into the next write.
301+
const firstPut = await fetch(`${h.baseUrl}/api/servers/alpha`, {
302+
method: "PUT",
303+
headers: { "Content-Type": "application/json" },
304+
body: JSON.stringify({
305+
id: "alpha",
306+
config: { type: "stdio", command: "v2" },
307+
}),
308+
});
309+
expect(firstPut.status).toBe(200);
310+
311+
// Subscribe AFTER the first write so the peer subscriber represents a
312+
// separate tab opened mid-session.
313+
const sink = await subscribe(h.baseUrl);
314+
await new Promise((r) => setTimeout(r, 150));
315+
316+
// External editor writes the file directly. Need a fresh mtime distinct
317+
// from the last backend write, so wait a beat (mtime granularity on
318+
// older filesystems is ms-coarse).
319+
await new Promise((r) => setTimeout(r, 20));
320+
writeFileSync(
321+
h.configPath,
322+
JSON.stringify(
323+
{ mcpServers: { alpha: { type: "stdio", command: "external" } } },
324+
null,
325+
2,
326+
) + "\n",
327+
);
328+
329+
// Backend write follows quickly enough that chokidar's
330+
// awaitWriteFinish coalesces both events. Without the pre-stat check
331+
// the peer subscriber would never see this edit.
332+
const secondPut = await fetch(`${h.baseUrl}/api/servers/alpha`, {
333+
method: "PUT",
334+
headers: { "Content-Type": "application/json" },
335+
body: JSON.stringify({
336+
id: "alpha",
337+
config: { type: "stdio", command: "v3-merged" },
338+
}),
339+
});
340+
expect(secondPut.status).toBe(200);
341+
342+
await sink.waitFor(1);
343+
expect(sink.events.length).toBeGreaterThanOrEqual(1);
344+
expect(JSON.parse(sink.events[0]!)).toEqual({ type: "change" });
345+
sink.close();
346+
});
347+
228348
it("emits when the user deletes the config file", async () => {
229349
writeFileSync(
230350
h.configPath,

core/mcp/remote/node/server.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ export interface CreateRemoteAppResult {
9393
* HTTP-server close so the watcher is released on shutdown. Tests that
9494
* exercise SSE should call it in teardown — tests that never subscribe
9595
* never start the watcher and can omit it without leaking.
96+
*
97+
* Resolves once the subscriber set is cleared and the watcher is closed.
98+
* Individual SSE stream callbacks held inside `streamSSE` are not awaited
99+
* here — they resolve on their own when the underlying socket aborts.
100+
* Callers that need to be sure those have settled (e.g. the standalone
101+
* server) should call `httpServer.closeAllConnections()` *after* this.
96102
*/
97103
close: () => Promise<void>;
98104
}
@@ -296,6 +302,33 @@ export function createRemoteApp(
296302
};
297303

298304
const writeMcpAndTrackMtime = async (data: string): Promise<void> => {
305+
// If an external editor wrote the file between our previous write and
306+
// this one, chokidar's `awaitWriteFinish` will coalesce both events
307+
// into a single watcher fire whose mtime matches what we're about to
308+
// write — and the watcher handler will then suppress the broadcast.
309+
// Peer subscribers (e.g. a second browser tab) would never learn about
310+
// the external edit. Detect that case here by comparing the current
311+
// on-disk mtime against our last tracked mtime; broadcast after the
312+
// write completes so peers re-fetch the merged state.
313+
//
314+
// The originating tab's mutator triggers its own refresh on PUT/POST
315+
// success, so this extra broadcast is intended for peers only — a
316+
// double refresh on the originating tab is cheap (same GET, same
317+
// payload) and acceptable.
318+
let externalEditDetected = false;
319+
if (lastWrittenMtimeMs !== null) {
320+
try {
321+
const s = await fsStat(mcpConfigPath);
322+
if (s.mtimeMs !== lastWrittenMtimeMs) {
323+
externalEditDetected = true;
324+
}
325+
} catch {
326+
// File missing → an external delete slipped in between our writes.
327+
// Treat as an external edit so peers learn about it.
328+
externalEditDetected = true;
329+
}
330+
}
331+
299332
await writeStoreFile(mcpConfigPath, data);
300333
try {
301334
const s = await fsStat(mcpConfigPath);
@@ -304,6 +337,10 @@ export function createRemoteApp(
304337
// If the stat fails the next watcher event will broadcast — that's the
305338
// correct fallback (the only cost is a redundant client refresh).
306339
}
340+
341+
if (externalEditDetected) {
342+
broadcastServerListChange();
343+
}
307344
};
308345

309346
const handleWatcherEvent = async (event: string): Promise<void> => {

core/react/useServers.ts

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -76,25 +76,39 @@ export function useServers(opts: UseServersOptions): UseServersResult {
7676
const [loading, setLoading] = useState<boolean>(true);
7777
const [error, setError] = useState<string | undefined>(undefined);
7878

79-
const refresh = useCallback(async (): Promise<void> => {
80-
setLoading(true);
81-
setError(undefined);
82-
try {
83-
const res = await doFetch(`${base}/api/servers`, {
84-
method: "GET",
85-
headers: buildHeaders(authToken, false),
86-
});
87-
if (!res.ok) {
88-
throw new Error(await readErrorMessage(res));
79+
// Inner refresh that the public `refresh` and the SSE-triggered background
80+
// refresh both share. `background` skips the loading-state toggle so
81+
// consumers rendering a spinner or skeleton don't flash on every external
82+
// mcp.json edit — the existing list stays on screen while the re-fetch
83+
// resolves. `error` is still reset / set either way so a real error
84+
// surfaces even from a background refresh.
85+
const refreshInternal = useCallback(
86+
async (background: boolean): Promise<void> => {
87+
if (!background) setLoading(true);
88+
setError(undefined);
89+
try {
90+
const res = await doFetch(`${base}/api/servers`, {
91+
method: "GET",
92+
headers: buildHeaders(authToken, false),
93+
});
94+
if (!res.ok) {
95+
throw new Error(await readErrorMessage(res));
96+
}
97+
const body = (await res.json()) as MCPConfig;
98+
setServers(mcpConfigToServerEntries(body));
99+
} catch (err) {
100+
setError(err instanceof Error ? err.message : String(err));
101+
} finally {
102+
if (!background) setLoading(false);
89103
}
90-
const body = (await res.json()) as MCPConfig;
91-
setServers(mcpConfigToServerEntries(body));
92-
} catch (err) {
93-
setError(err instanceof Error ? err.message : String(err));
94-
} finally {
95-
setLoading(false);
96-
}
97-
}, [base, authToken, doFetch]);
104+
},
105+
[base, authToken, doFetch],
106+
);
107+
108+
const refresh = useCallback(
109+
(): Promise<void> => refreshInternal(false),
110+
[refreshInternal],
111+
);
98112

99113
useEffect(() => {
100114
void refresh();
@@ -122,19 +136,28 @@ export function useServers(opts: UseServersOptions): UseServersResult {
122136
const decoder = new TextDecoder();
123137
let buffer = "";
124138
// SSE frames are separated by a blank line (`\n\n`). We don't parse
125-
// the event type or data — `refresh()` re-fetches the canonical
126-
// state regardless — so a single \n\n is enough to debounce a
127-
// single broadcast into a single refresh.
139+
// the event type or data — `refreshInternal()` re-fetches the
140+
// canonical state regardless — so we just count frames and fire a
141+
// single background refresh per decode chunk. Two `change`
142+
// broadcasts landing in the same chunk become one re-fetch instead
143+
// of two concurrent ones whose setState order is unspecified.
144+
// Cross-chunk debounce is not added: `awaitWriteFinish`'s 100ms
145+
// stability threshold already serializes external edits at the
146+
// source, and chained fetches against the same GET endpoint are
147+
// idempotent enough that a rare back-to-back pair just costs one
148+
// extra round-trip.
128149
while (true) {
129150
const { done, value } = await reader.read();
130151
if (done) break;
131152
buffer += decoder.decode(value, { stream: true });
153+
let sawFrame = false;
132154
let frameEnd = buffer.indexOf("\n\n");
133155
while (frameEnd !== -1) {
134156
buffer = buffer.slice(frameEnd + 2);
135-
void refresh();
157+
sawFrame = true;
136158
frameEnd = buffer.indexOf("\n\n");
137159
}
160+
if (sawFrame) void refreshInternal(true);
138161
}
139162
} catch {
140163
// AbortError on unmount, or a transient network blip. No reconnect:
@@ -143,7 +166,7 @@ export function useServers(opts: UseServersOptions): UseServersResult {
143166
}
144167
})();
145168
return () => controller.abort();
146-
}, [base, authToken, doFetch, refresh]);
169+
}, [base, authToken, doFetch, refreshInternal]);
147170

148171
const addServer = useCallback(
149172
async (id: string, config: MCPServerConfig): Promise<void> => {

0 commit comments

Comments
 (0)