inspector/core/react/useServers.ts
Cliff Hall 5f9bfdc490
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>
2026-05-25 11:30:05 -04:00

265 lines
9.3 KiB
TypeScript

/**
* React hook over the `/api/servers` endpoints. Fetches the list on mount,
* holds it in local state, and exposes addServer / updateServer / removeServer
* mutators that round-trip through the backend (re-fetching after each write
* to stay in sync with the on-disk truth).
*/
import { useCallback, useEffect, useState } from "react";
import { mcpConfigToServerEntries } from "../mcp/serverList.js";
import type {
InspectorServerSettings,
MCPConfig,
MCPServerConfig,
ServerEntry,
} from "../mcp/types.js";
export interface UseServersOptions {
/** Base URL of the remote server (typically `window.location.origin`). */
baseUrl: string;
/** Optional auth token for the `x-mcp-remote-auth` header. */
authToken?: string;
/** Fetch function to use (default: globalThis.fetch). Useful in tests. */
fetchFn?: typeof fetch;
}
export interface UseServersResult {
servers: ServerEntry[];
loading: boolean;
error: string | undefined;
refresh: () => Promise<void>;
addServer: (id: string, config: MCPServerConfig) => Promise<void>;
updateServer: (
originalId: string,
newId: string,
config: MCPServerConfig,
) => Promise<void>;
/**
* Patch only the `settings` node on an existing server entry, leaving the
* transport config and id untouched. Routes through `PUT /api/servers/:id`
* with the current `config` plus the new settings.
*/
updateServerSettings: (
id: string,
settings: InspectorServerSettings,
) => Promise<void>;
removeServer: (id: string) => Promise<void>;
}
function buildHeaders(
authToken: string | undefined,
includeJsonBody: boolean,
): Record<string, string> {
const headers: Record<string, string> = {};
if (includeJsonBody) headers["Content-Type"] = "application/json";
if (authToken) headers["x-mcp-remote-auth"] = `Bearer ${authToken}`;
return headers;
}
async function readErrorMessage(res: Response): Promise<string> {
try {
const body = (await res.json()) as { error?: unknown };
if (typeof body?.error === "string") return body.error;
} catch {
/* ignore */
}
return `HTTP ${res.status}`;
}
export function useServers(opts: UseServersOptions): UseServersResult {
const { baseUrl, authToken, fetchFn } = opts;
const doFetch = fetchFn ?? globalThis.fetch;
// Normalize once — saves us from re-string-munging on every mutator call.
const base = baseUrl.replace(/\/$/, "");
const [servers, setServers] = useState<ServerEntry[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | undefined>(undefined);
// Inner refresh that the public `refresh` and the SSE-triggered background
// refresh both share. `background` skips the loading-state toggle so
// consumers rendering a spinner or skeleton don't flash on every external
// mcp.json edit — the existing list stays on screen while the re-fetch
// resolves. `error` is still reset / set either way so a real error
// surfaces even from a background refresh.
const refreshInternal = useCallback(
async (background: boolean): Promise<void> => {
if (!background) setLoading(true);
setError(undefined);
try {
const res = await doFetch(`${base}/api/servers`, {
method: "GET",
headers: buildHeaders(authToken, false),
});
if (!res.ok) {
throw new Error(await readErrorMessage(res));
}
const body = (await res.json()) as MCPConfig;
setServers(mcpConfigToServerEntries(body));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
if (!background) setLoading(false);
}
},
[base, authToken, doFetch],
);
const refresh = useCallback(
(): Promise<void> => refreshInternal(false),
[refreshInternal],
);
useEffect(() => {
void refresh();
}, [refresh]);
// Subscribe to `/api/servers/events` so external edits to mcp.json
// (the user editing the file by hand, or `mcp.json` written by another
// tool) propagate without a manual page refresh. We deliberately use
// fetch() + ReadableStream rather than EventSource because EventSource
// can't send the existing `x-mcp-remote-auth: Bearer …` header — the
// backend's auth contract is unchanged. The event payload itself is
// ignored: any signal triggers a re-fetch, since the GET handler is the
// sole source of truth for the list shape.
useEffect(() => {
const controller = new AbortController();
void (async () => {
try {
const res = await doFetch(`${base}/api/servers/events`, {
method: "GET",
headers: buildHeaders(authToken, false),
signal: controller.signal,
});
if (!res.ok || !res.body) return;
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
// SSE frames are separated by a blank line (`\n\n`). We don't parse
// the event type or data — `refreshInternal()` re-fetches the
// canonical state regardless — so we just count frames and fire a
// single background refresh per decode chunk. Two `change`
// broadcasts landing in the same chunk become one re-fetch instead
// of two concurrent ones whose setState order is unspecified.
// Cross-chunk debounce is not added: `awaitWriteFinish`'s 100ms
// stability threshold already serializes external edits at the
// source, and chained fetches against the same GET endpoint are
// idempotent enough that a rare back-to-back pair just costs one
// extra round-trip.
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sawFrame = false;
let frameEnd = buffer.indexOf("\n\n");
while (frameEnd !== -1) {
buffer = buffer.slice(frameEnd + 2);
sawFrame = true;
frameEnd = buffer.indexOf("\n\n");
}
if (sawFrame) void refreshInternal(true);
}
} catch {
// AbortError on unmount, or a transient network blip. No reconnect:
// a real failure leaves the hook in last-known-good state and the
// user can hit refresh manually. The dev-tool reload story is fine.
}
})();
return () => controller.abort();
}, [base, authToken, doFetch, refreshInternal]);
const addServer = useCallback(
async (id: string, config: MCPServerConfig): Promise<void> => {
const res = await doFetch(`${base}/api/servers`, {
method: "POST",
headers: buildHeaders(authToken, true),
body: JSON.stringify({ id, config }),
});
if (!res.ok) {
throw new Error(await readErrorMessage(res));
}
await refresh();
},
[base, authToken, doFetch, refresh],
);
const updateServer = useCallback(
async (
originalId: string,
newId: string,
config: MCPServerConfig,
): Promise<void> => {
// `settings` is intentionally omitted from the body. The backend route
// treats omission as "preserve the existing settings node on disk", so
// a config-only save (e.g. ServerConfigModal) cannot silently wipe
// persisted headers / metadata / OAuth credentials. To explicitly
// clear settings, send `settings: null`.
const res = await doFetch(
`${base}/api/servers/${encodeURIComponent(originalId)}`,
{
method: "PUT",
headers: buildHeaders(authToken, true),
body: JSON.stringify({ id: newId, config }),
},
);
if (!res.ok) {
throw new Error(await readErrorMessage(res));
}
await refresh();
},
[base, authToken, doFetch, refresh],
);
const updateServerSettings = useCallback(
async (id: string, settings: InspectorServerSettings): Promise<void> => {
// Settings-only PUT — we deliberately omit `config` so the route
// preserves the on-disk transport config inside its write lock.
// Reading `existing.config` from in-memory `servers` here would pin a
// stale snapshot at scheduling time and could silently revert a
// separate concurrent edit (e.g. a future file-watcher refreshing
// `servers` between debounce schedule and flush). The server is the
// single source of truth for config.
const res = await doFetch(
`${base}/api/servers/${encodeURIComponent(id)}`,
{
method: "PUT",
headers: buildHeaders(authToken, true),
body: JSON.stringify({ id, settings }),
},
);
if (!res.ok) {
throw new Error(await readErrorMessage(res));
}
await refresh();
},
[base, authToken, doFetch, refresh],
);
const removeServer = useCallback(
async (id: string): Promise<void> => {
const res = await doFetch(
`${base}/api/servers/${encodeURIComponent(id)}`,
{
method: "DELETE",
headers: buildHeaders(authToken, false),
},
);
if (!res.ok) {
throw new Error(await readErrorMessage(res));
}
await refresh();
},
[base, authToken, doFetch, refresh],
);
return {
servers,
loading,
error,
refresh,
addServer,
updateServer,
updateServerSettings,
removeServer,
};
}