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
2 changed files with 4238 additions and 176 deletions

View file

@ -0,0 +1,321 @@
import { useMemo, useState } from "react";
import {
parseClientConfig,
planImport,
uniqueId,
type ConflictResolution,
type ImportPlan,
type ImportSourceResult,
} from "@inspector/core/mcp/import/index.js";
import { validateStoreId } from "@inspector/core/storage/store-id.js";
import type { MCPConfig, MCPServerConfig } from "@inspector/core/mcp/types.js";
export type ImportPhase = "select" | "loading" | "review" | "summary";
export interface ConflictResolutionState {
action: ConflictResolution;
renameTo: string;
}
/** Per-row choice for a brand-new (non-conflicting) server. */
export type AdditionAction = "import" | "skip";
export interface ImportOutcome {
id: string;
status: "added" | "overwritten" | "renamed" | "skipped" | "failed";
detail?: string;
}
export interface UseImportClientConfigOptions {
opened: boolean;
/** Ids already in the catalog — drives conflict detection + rename defaults. */
existingIds: string[];
/** Read a client's well-known config on the backend (authed GET). */
onFetchSource: (type: string) => Promise<ImportSourceResult>;
onAddServer: (id: string, config: MCPServerConfig) => Promise<void>;
onUpdateServer: (
originalId: string,
newId: string,
config: MCPServerConfig,
) => Promise<void>;
}
export interface ImportClientConfigViewModel {
phase: ImportPhase;
error?: string;
notice?: string;
plan: ImportPlan | null;
resolutions: Record<string, ConflictResolutionState>;
additionActions: Record<string, AdditionAction>;
outcomes: ImportOutcome[];
selectedType: string | null;
importCount: number;
/** Per-conflict validation message for an invalid/colliding rename target. */
renameErrors: Record<string, string>;
/** True when there's something to import and no rename is invalid. */
canImport: boolean;
setSelectedType: (type: string | null) => void;
pickSource: (type: string) => Promise<void>;
pickFile: (file: File | null) => Promise<void>;
setResolution: (id: string, action: ConflictResolution) => void;
setRenameTo: (id: string, renameTo: string) => void;
setAdditionAction: (id: string, action: AdditionAction) => void;
runImport: () => Promise<void>;
back: () => void;
}
/**
* Wiring for the "Import from client config" flow (#1348): owns the wizard phase,
* the parsed plan, per-server resolutions, and the apply loop. Keeps
* `ServerImportConfigModal` a thin display component; the reusable parsing +
* merge logic lives in `core/mcp/import`.
*/
export function useImportClientConfig({
opened,
existingIds,
onFetchSource,
onAddServer,
onUpdateServer,
}: UseImportClientConfigOptions): ImportClientConfigViewModel {
const [phase, setPhase] = useState<ImportPhase>("select");
const [error, setError] = useState<string | undefined>(undefined);
const [notice, setNotice] = useState<string | undefined>(undefined);
const [incoming, setIncoming] = useState<MCPConfig | null>(null);
const [resolutions, setResolutions] = useState<
Record<string, ConflictResolutionState>
>({});
const [additionActions, setAdditionActions] = useState<
Record<string, AdditionAction>
>({});
const [outcomes, setOutcomes] = useState<ImportOutcome[]>([]);
const [selectedType, setSelectedType] = useState<string | null>(null);
// Reset the wizard each time the modal transitions to open. Done during
// render (React's "adjust state when a prop changes" pattern) rather than in
// an effect so there's no extra commit/cascade.
const [prevOpened, setPrevOpened] = useState(opened);
if (opened !== prevOpened) {
setPrevOpened(opened);
if (opened) {
setPhase("select");
setError(undefined);
setNotice(undefined);
setIncoming(null);
setResolutions({});
setAdditionActions({});
setOutcomes([]);
setSelectedType(null);
}
}
const plan = useMemo(
() => (incoming ? planImport(incoming, existingIds) : null),
[incoming, existingIds],
);
function beginReview(config: MCPConfig) {
const fresh = planImport(config, existingIds);
if (fresh.additions.length === 0 && fresh.conflicts.length === 0) {
setError("No servers found in the selected source.");
setPhase("select");
return;
}
const taken = [...existingIds, ...fresh.additions.map((a) => a.id)];
const initial: Record<string, ConflictResolutionState> = {};
for (const conflict of fresh.conflicts) {
initial[conflict.id] = {
action: "skip",
renameTo: uniqueId(conflict.id, taken),
};
}
// New servers default to "import"; the user can opt individual ones out.
const initialAdds: Record<string, AdditionAction> = {};
for (const add of fresh.additions) {
initialAdds[add.id] = "import";
}
setIncoming(config);
setResolutions(initial);
setAdditionActions(initialAdds);
setError(undefined);
setPhase("review");
}
async function pickSource(type: string) {
setPhase("loading");
setError(undefined);
setNotice(undefined);
try {
const result = await onFetchSource(type);
if (result.error) {
setError(result.error);
setPhase("select");
return;
}
if (!result.found || !result.config) {
setNotice(
`No config found. Searched: ${result.searched.join(", ")}. Try uploading a file instead.`,
);
setPhase("select");
return;
}
beginReview(result.config);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setPhase("select");
}
}
async function pickFile(file: File | null) {
if (!file) return;
setError(undefined);
setNotice(undefined);
try {
const raw = await file.text();
beginReview(parseClientConfig(raw));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}
function setResolution(id: string, action: ConflictResolution) {
setResolutions((r) => ({ ...r, [id]: { ...r[id], action } }));
}
function setRenameTo(id: string, renameTo: string) {
setResolutions((r) => ({ ...r, [id]: { ...r[id], renameTo } }));
}
function setAdditionAction(id: string, action: AdditionAction) {
setAdditionActions((a) => ({ ...a, [id]: action }));
}
async function applyEntry(
id: string,
outcome: ImportOutcome["status"],
write: () => Promise<void>,
): Promise<ImportOutcome> {
try {
await write();
return { id, status: outcome };
} catch (err) {
return {
id,
status: "failed",
detail: err instanceof Error ? err.message : String(err),
};
}
}
async function runImport() {
if (!plan) return;
setPhase("loading");
const results: ImportOutcome[] = [];
for (const add of plan.additions) {
if ((additionActions[add.id] ?? "import") === "skip") {
results.push({ id: add.id, status: "skipped" });
continue;
}
results.push(
await applyEntry(add.id, "added", () =>
Promise.resolve(onAddServer(add.id, add.config)),
),
);
}
for (const conflict of plan.conflicts) {
const res = resolutions[conflict.id] ?? {
action: "skip",
renameTo: conflict.id,
};
if (res.action === "skip") {
results.push({ id: conflict.id, status: "skipped" });
} else if (res.action === "overwrite") {
results.push(
await applyEntry(conflict.id, "overwritten", () =>
onUpdateServer(conflict.id, conflict.id, conflict.config),
),
);
} else {
const newId = res.renameTo.trim() || conflict.id;
results.push(
await applyEntry(newId, "renamed", () =>
Promise.resolve(onAddServer(newId, conflict.config)),
),
);
}
}
setOutcomes(results);
setPhase("summary");
}
const importCount = plan
? plan.additions.filter(
(a) => (additionActions[a.id] ?? "import") === "import",
).length +
plan.conflicts.filter(
(c) => (resolutions[c.id]?.action ?? "skip") !== "skip",
).length
: 0;
// Validate each "rename" target inline: it must be a syntactically valid id
// that doesn't collide with an existing server, an imported addition, or
// another rename target — so the user learns before submitting rather than
// getting a 409 in the summary.
const renameErrors = useMemo<Record<string, string>>(() => {
if (!plan) return {};
const errors: Record<string, string> = {};
for (const conflict of plan.conflicts) {
const res = resolutions[conflict.id];
if (res?.action !== "rename") continue;
const newId = res.renameTo.trim();
if (!newId) {
errors[conflict.id] = "A new id is required.";
continue;
}
if (!validateStoreId(newId)) {
errors[conflict.id] =
"Use only letters, numbers, hyphens, underscores.";
continue;
}
// Ids that would already exist alongside this rename.
const taken = new Set<string>(existingIds);
for (const add of plan.additions) {
if ((additionActions[add.id] ?? "import") === "import") {
taken.add(add.id);
}
}
for (const other of plan.conflicts) {
if (other.id === conflict.id) continue;
const r = resolutions[other.id];
if (r?.action === "rename") taken.add(r.renameTo.trim());
}
if (taken.has(newId)) {
errors[conflict.id] = `"${newId}" is already in use.`;
}
}
return errors;
}, [plan, resolutions, additionActions, existingIds]);
const canImport = importCount > 0 && Object.keys(renameErrors).length === 0;
return {
phase,
error,
notice,
plan,
resolutions,
additionActions,
outcomes,
selectedType,
importCount,
renameErrors,
canImport,
setSelectedType,
pickSource,
pickFile,
setResolution,
setRenameTo,
setAdditionAction,
runImport,
back: () => setPhase("select"),
};
}

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,
};
}