/** * 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; addServer: (id: string, config: MCPServerConfig) => Promise; updateServer: ( originalId: string, newId: string, config: MCPServerConfig, ) => Promise; /** * 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; removeServer: (id: string) => Promise; } function buildHeaders( authToken: string | undefined, includeJsonBody: boolean, ): Record { const headers: Record = {}; if (includeJsonBody) headers["Content-Type"] = "application/json"; if (authToken) headers["x-mcp-remote-auth"] = `Bearer ${authToken}`; return headers; } async function readErrorMessage(res: Response): Promise { 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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 => { 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 => 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 => { 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 => { // `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 => { // 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 => { 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, }; }