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>
This commit is contained in:
Cliff Hall 2026-06-23 19:52:08 -04:00 • committed by GitHub
commit 97b1a7fbff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -0,0 +1,308 @@
import { useEffect, useMemo, useState } from "react";
import {
parseServerJson,
buildServerConfigForSelection,
selectServerJsonOption,
type ParsedServerJson,
} from "@inspector/core/mcp/import/index.js";
import type {
InspectorServerJsonDraft,
MCPServerConfig,
} from "@inspector/core/mcp/types.js";
import type {
EnvVarInfo,
PackageInfo,
ValidationResult,
} from "../components/groups/ImportServerJsonPanel/ImportServerJsonPanel";
/** Debounce (ms) before a textarea edit re-triggers parse/validation. */
const VALIDATE_DEBOUNCE_MS = 300;
/**
* Delay (ms) after content is loaded/pasted before the File Contents disclosure
* auto-collapses — long enough to read as "the content was accepted".
*/
const COLLAPSE_DELAY_MS = 1000;
/**
* How long (ms) to flash the disclosure with its hover background just before it
* collapses, so the collapse reads as intentional rather than abrupt.
*/
const HIGHLIGHT_DURATION_MS = 250;
const EMPTY_DRAFT: InspectorServerJsonDraft = {
rawText: "",
envOverrides: {},
};
/** Result of parsing the current draft text. */
type ParseState =
| { ok: true; parsed: ParsedServerJson }
| { ok: false; error: string }
| { ok: null };
function parseDraft(rawText: string): ParseState {
if (!rawText.trim()) return { ok: null };
try {
return { ok: true, parsed: parseServerJson(rawText) };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
export interface UseServerJsonImportOptions {
/** Whether the host modal is open — drives the per-open reset. */
opened: boolean;
/** Ids already in use — drives the duplicate-id warning. */
existingIds: string[];
/** Persist the chosen server. Resolves once the catalog has been updated. */
onAddServer: (id: string, config: MCPServerConfig) => Promise<void> | void;
}
/** View-model the dumb `ImportServerJsonPanel` renders from. */
export interface ServerJsonImportViewModel {
draft: InspectorServerJsonDraft;
validation: ValidationResult[];
packages?: PackageInfo[];
envVars: EnvVarInfo[];
canAdd: boolean;
/** Derived id from the server.json `name` (before any override). */
defaultServerName: string;
fileContentsOpen: boolean;
fileContentsHighlight: boolean;
setRawText: (content: string) => void;
selectPackage: (index: number) => void;
setEnvVar: (name: string, value: string) => void;
setServerName: (name: string) => void;
pickFile: (file: File | null) => Promise<void>;
setFileContentsOpen: (open: boolean) => void;
/** Build + persist the selected server. Resolves to true when it was added. */
submit: () => Promise<boolean>;
}
/**
* Wiring for the registry `server.json` import (#1348): owns the draft + parse
* state, the debounced validation, and the File Contents auto-collapse, and
* builds/persists the selected server on submit. Keeps `ServerImportJsonModal`
* a thin display component; the reusable parsing/selection lives in
* `core/mcp/import`.
*/
export function useServerJsonImport({
opened,
existingIds,
onAddServer,
}: UseServerJsonImportOptions): ServerJsonImportViewModel {
const [draft, setDraft] = useState<InspectorServerJsonDraft>(EMPTY_DRAFT);
const [submitError, setSubmitError] = useState<string | undefined>(undefined);
// Debounced copy of the raw text that drives parsing/validation, so typing
// doesn't re-parse on every keystroke. The textarea stays bound to live text.
const [debouncedText, setDebouncedText] = useState<string>(
EMPTY_DRAFT.rawText,
);
// The File Contents disclosure starts open and auto-collapses shortly after
// content is loaded/pasted; `highlight` flashes its hover bg before collapse.
const [fileContentsOpen, setFileContentsOpen] = useState(true);
const [fileContentsHighlight, setFileContentsHighlight] = useState(false);
// Reset each time the modal transitions to open. Done during render (React's
// "adjust state when a prop changes" pattern) so there's no extra commit.
const [prevOpened, setPrevOpened] = useState(opened);
if (opened !== prevOpened) {
setPrevOpened(opened);
if (opened) {
setDraft(EMPTY_DRAFT);
setSubmitError(undefined);
setDebouncedText(EMPTY_DRAFT.rawText);
setFileContentsOpen(true);
setFileContentsHighlight(false);
}
}
// Re-validate after the user pauses typing. setState lives in the timeout
// callback (not the effect body), so this doesn't cascade-render.
useEffect(() => {
const id = setTimeout(
() => setDebouncedText(draft.rawText),
VALIDATE_DEBOUNCE_MS,
);
return () => clearTimeout(id);
}, [draft.rawText]);
// Once there's content, flash the disclosure's hover highlight and then
// collapse it, signalling the paste/file load was accepted and surfacing the
// validation / env-var sections. Clearing re-opens it (see setRawText).
useEffect(() => {
if (!draft.rawText.trim()) return;
let collapseTimer: ReturnType<typeof setTimeout> | undefined;
const highlightTimer = setTimeout(() => {
setFileContentsHighlight(true);
collapseTimer = setTimeout(() => {
setFileContentsHighlight(false);
setFileContentsOpen(false);
}, HIGHLIGHT_DURATION_MS);
}, COLLAPSE_DELAY_MS);
return () => {
clearTimeout(highlightTimer);
if (collapseTimer) clearTimeout(collapseTimer);
};
}, [draft.rawText]);
const parseState = useMemo(() => parseDraft(debouncedText), [debouncedText]);
const selection = useMemo(() => {
if (parseState.ok !== true) return undefined;
return selectServerJsonOption(parseState.parsed, {
selectedIndex: draft.selectedPackageIndex,
idOverride: draft.nameOverride,
existingIds,
});
}, [parseState, draft.selectedPackageIndex, draft.nameOverride, existingIds]);
const selectedIndex = selection?.selectedIndex ?? 0;
const selectedOption = selection?.selectedOption;
const parsed = parseState.ok === true ? parseState.parsed : undefined;
// The Add button is enabled only when the (debounced) content validates.
const canAdd =
selection !== undefined && selection.idIsValid && !selection.idIsDuplicate;
const packages: PackageInfo[] | undefined = parsed?.options.map((o) => ({
registryType: o.registryType,
identifier: o.identifier,
runtimeHint: o.runtimeHint,
}));
const envVars: EnvVarInfo[] = (selectedOption?.envVars ?? []).map((v) => ({
name: v.name,
description: v.description,
required: v.required,
value: draft.envOverrides[v.name] ?? v.default ?? "",
}));
const validation: ValidationResult[] = useMemo(() => {
if (parseState.ok === null) {
return [
{ type: "info", message: "Paste server.json content to validate." },
];
}
if (parseState.ok === false) {
return [{ type: "error", message: parseState.error }];
}
const results: ValidationResult[] = [
{
type: "success",
message: `Valid server.json for "${parseState.parsed.fullName}".`,
},
{
type: "info",
message: `${parseState.parsed.options.length} runnable option(s) found.`,
},
];
if (selection && !selection.idIsValid) {
results.push({
type: "error",
message:
"Server id must use only letters, numbers, hyphens, underscores.",
});
} else if (selection?.idIsDuplicate) {
results.push({
type: "warning",
message: `A server with id "${selection.serverId}" already exists.`,
});
}
return results;
}, [parseState, selection]);
const allValidation = submitError
? [...validation, { type: "error" as const, message: submitError }]
: validation;
function setRawText(content: string) {
setSubmitError(undefined);
setDraft((d) => ({ ...d, rawText: content }));
// A fresh edit cancels any in-flight pre-collapse highlight.
setFileContentsHighlight(false);
// Re-open the disclosure when the textarea is cleared so it's ready to paste
// into again.
if (!content.trim()) setFileContentsOpen(true);
}
function selectPackage(index: number) {
setDraft((d) => ({ ...d, selectedPackageIndex: index }));
}
function setEnvVar(name: string, value: string) {
setDraft((d) => ({
...d,
envOverrides: { ...d.envOverrides, [name]: value },
}));
}
function setServerName(name: string) {
setDraft((d) => ({ ...d, nameOverride: name }));
}
async function pickFile(file: File | null) {
if (!file) return;
setSubmitError(undefined);
setFileContentsHighlight(false);
try {
const text = await file.text();
setDraft((d) => ({ ...d, rawText: text }));
} catch (err) {
setSubmitError(err instanceof Error ? err.message : String(err));
}
}
async function submit(): Promise<boolean> {
setSubmitError(undefined);
// Parse the live text (not the debounced copy) so a click in the brief
// window after an edit (before the button re-disables) sees the latest
// content rather than importing stale input.
const live = parseDraft(draft.rawText);
if (live.ok !== true) {
setSubmitError("Fix the validation errors before adding the server.");
return false;
}
const sel = selectServerJsonOption(live.parsed, {
selectedIndex: draft.selectedPackageIndex,
idOverride: draft.nameOverride,
existingIds,
});
if (!sel.idIsValid || sel.idIsDuplicate) {
setSubmitError("Fix the validation errors before adding the server.");
return false;
}
const config = buildServerConfigForSelection(
sel.selectedOption,
draft.envOverrides,
);
try {
await onAddServer(sel.serverId, config);
return true;
} catch (err) {
setSubmitError(err instanceof Error ? err.message : String(err));
return false;
}
}
return {
draft: { ...draft, selectedPackageIndex: selectedIndex },
validation: allValidation,
packages,
envVars,
canAdd,
defaultServerName: parsed?.serverName ?? "",
fileContentsOpen,
fileContentsHighlight,
setRawText,
selectPackage,
setEnvVar,
setServerName,
pickFile,
setFileContentsOpen,
submit,
};
}