inspector/core/react/useServers.ts
Cliff Hall 97b1a7fbff
feat(web,core): import client configs and registry server.json (#1348) (#1511)
* feat(web,core): import client configs and registry server.json (#1348)

Wire up the two server-import affordances that were previously todoNoop:

- Import config: source picker (Claude Desktop / Cursor / Cline / VS Code)
  read on the backend via a new GET /api/import-source, plus a file-upload
  fallback. Per-server review with import/skip on new servers and
  overwrite/skip/rename on id conflicts, then a per-server outcome summary.
- Import server.json: parse the MCP registry single-server format
  (npm/pypi/oci packages + remotes, env vars, package/runtime args, URL
  template vars) into a runnable config, with a file picker and debounced
  live validation; Add Server is disabled until validation passes.

Core gains an isomorphic import strategy layer (core/mcp/import) with a
strategy registry, client-config + server.json parsers, a strategy-agnostic
merge, and a dependency-injected source resolver. useServers exposes
importSource(). Modal headers are bold and the redundant server.json section
header is folded into the modal title.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): collapse the server.json textarea into a File Contents disclosure

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): auto-collapse File Contents disclosure after content loads

Once content is pasted or a file is loaded, collapse the File Contents
disclosure after ~1s with Mantine's built-in accordion animation, signaling
the input was accepted and surfacing the validation/env-var sections. The
disclosure becomes controlled by the modal; clearing the textarea re-opens it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): flash File Contents hover highlight before auto-collapse

Before the File Contents disclosure auto-collapses, paint it with its hover
background for 250ms, then run the collapse over a 325ms animation, so the
collapse reads as intentional.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): rename "Import server.json" to "Import from registry config"

Rename the Add Servers menu entry and the modal header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): rename "Import config" to "Import client config"

Rename the Add Servers menu entry and the modal header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(web,core): move import orchestration into core helpers + web hooks

Make the import logic reusable by CLI/TUI and keep the modals display-only:

- core/mcp/import: add parseClientConfig (mcpServers→servers fallback),
  resolveServerId, selectServerJsonOption, and buildServerConfigForSelection,
  so the full import (parse → select → build → merge) is callable from any
  environment.
- core/storage: extract the pure validateStoreId into a Node-free store-id
  module (store-io re-exports it) so the isomorphic import layer can validate
  ids without pulling Node deps into the browser bundle.
- clients/web/hooks: useServerJsonImport and useImportClientConfig own all the
  wizard/parse/debounce/apply state; ServerImportConfigModal and
  ServerImportJsonModal are now thin display shells over the dumb panel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): rename "Import client config" to "Import from client config"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): remove the Cancel button from the registry import modal

The modal header X (and Escape) still dismiss it; drop the redundant Cancel
button (and the panel onCancel prop).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): hide validation results + name override until content is present

In the registry import modal, the Validation Results section and the Server
Name override field stay hidden until something has been pasted/loaded into the
textarea.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): make Validation Results a collapsible disclosure

Wrap the registry import modal's validation results in a Mantine accordion
disclosure (default open), matching the File Contents section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): Server Name disclosure with read-only derived name + override

Make Server Name a collapsible disclosure containing a read-only "From
configuration" field (the id derived from the server.json name) and the
editable Override field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): scroll to and highlight a freshly-added server

After a manual add/clone or an import (client config / registry), the new
server's card scrolls into view and draws an animated border (Mantine Border
Animate); clicking anywhere on the card dismisses it.

- App tracks highlightedServerId; addServerHighlighted sets it (edits and
  conflict-overwrites don't). Threaded through InspectorView -> ServerListScreen
  -> ServerCard.
- ServerCard scrolls into view on highlight and wraps in <BorderAnimate>;
  onClick clears the highlight.
- Pin @gfazioli/mantine-border-animate@2.0.3 (Mantine 8 compatible; 2.0.4+
  requires Mantine 9).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): highlighted card fills its cell and its controls work on first click

- The BorderAnimate wrapper is display:flex, so the card was shrinking to
  content width while the border spanned the cell. Stretch the card with
  w=100% (and the wrapper).
- Always render BorderAnimate and toggle show/animate instead of conditionally
  wrapping: swapping the element type on clear remounted the card, swallowing
  the click that triggered it (e.g. the connect toggle fired clear but not
  connect, needing a second click). Verified: one click now connects + clears.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web,core): address review — OCI env, untrack fixture, rename validation

- Untrack the stray inspector-v1-multi.server.json (accidentally committed) and
  gitignore root *.server.json fixtures.
- core/mcp/import/serverJson: forward declared env vars into OCI containers via
  '-e KEY' docker args (they previously only set the docker CLI process env);
  add a test. Clarify buildServerConfig (destructure env out instead of
  spread-then-delete) and document value-vs-default env handling.
- useImportClientConfig: validate 'rename' targets inline (invalid charset /
  collision with existing, additions, or other renames) and gate the Import
  button on canImport; surface the error on the field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): highlight all servers from a batch import, not just the last

Track highlighted servers as a set of ids instead of one. addServerHighlighted
accumulates each id (a multi-server client-config import highlights them all);
opening an add/import modal starts a fresh batch. Clicking a card clears only
that card's highlight; only the first highlighted card scrolls into view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:52:08 -04:00

336 lines
12 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 { ImportSourceResult } from "../mcp/import/types.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>;
/**
* Persist a new ordering for the server list. `orderedIds` must be the
* complete set of current server ids in the desired order. The local list
* is reordered optimistically so the grid reflows instantly; on backend
* failure (e.g. the on-disk set changed underneath us) we re-fetch to snap
* back to disk truth. Routes through `PUT /api/servers/order`.
*/
reorderServers: (orderedIds: string[]) => Promise<void>;
/**
* Read another MCP client's well-known config on the backend host and return
* its servers in canonical form (#1348). `type` is an import-strategy id
* (e.g. "claude-desktop"). Used by the "Import config" source picker; the
* resulting servers are written via `addServer` / `updateServer`.
*/
importSource: (type: string) => Promise<ImportSourceResult>;
}
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 importSource = useCallback(
async (type: string): Promise<ImportSourceResult> => {
const res = await doFetch(
`${base}/api/import-source?type=${encodeURIComponent(type)}`,
{ headers: buildHeaders(authToken, false) },
);
if (!res.ok) {
throw new Error(await readErrorMessage(res));
}
return (await res.json()) as ImportSourceResult;
},
[base, authToken, doFetch],
);
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],
);
const reorderServers = useCallback(
async (orderedIds: string[]): Promise<void> => {
// Optimistic reorder: rebuild the local list in the requested order so
// the grid reflows immediately, before the round-trip resolves. Built
// from a lookup off the previous state (inside the setter) so we never
// capture a stale snapshot, and any id not currently present is simply
// skipped rather than producing an `undefined` hole.
setServers((prev) => {
const byId = new Map(prev.map((s) => [s.id, s]));
const reordered = orderedIds
.map((id) => byId.get(id))
.filter((s): s is ServerEntry => s !== undefined);
// Defensive: if the requested order dropped any entries (shouldn't
// happen — callers pass the full set), keep the strays at the end so
// nothing vanishes from the UI before the refresh reconciles.
if (reordered.length !== prev.length) {
const seen = new Set(orderedIds);
for (const s of prev) if (!seen.has(s.id)) reordered.push(s);
}
return reordered;
});
try {
const res = await doFetch(`${base}/api/servers/order`, {
method: "PUT",
headers: buildHeaders(authToken, true),
body: JSON.stringify({ order: orderedIds }),
});
if (!res.ok) {
throw new Error(await readErrorMessage(res));
}
} catch (err) {
// Revert to disk truth — the optimistic order may not have landed.
await refresh();
throw err instanceof Error ? err : new Error(String(err));
}
},
[base, authToken, doFetch, refresh],
);
return {
servers,
loading,
error,
refresh,
addServer,
updateServer,
updateServerSettings,
removeServer,
reorderServers,
importSource,
};
}