* 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:
parent
5efe9ddc7b
commit
97b1a7fbff
1 changed files with 4238 additions and 176 deletions
308
clients/web/src/hooks/useServerJsonImport.ts
Normal file
308
clients/web/src/hooks/useServerJsonImport.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue