Skip to content

Commit 83f4139

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

13 files changed

Lines changed: 576 additions & 20 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: 46 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,40 @@ 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+
221262
it("captures the error message on fetch network failure", async () => {
222263
const failingFetch = vi
223264
.fn<typeof fetch>()

0 commit comments

Comments
 (0)