* 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>
145 lines
4.8 KiB
TypeScript
145 lines
4.8 KiB
TypeScript
/**
|
|
* Shared storage path resolution, validation, and atomic file I/O.
|
|
* Used by the file storage adapter and the remote server's /api/storage routes.
|
|
*/
|
|
|
|
import * as path from "node:path";
|
|
import * as fs from "node:fs/promises";
|
|
import { readFile, writeFile } from "atomically";
|
|
|
|
// Re-export the pure id validator so existing `store-io` importers are
|
|
// unaffected; the implementation lives in the Node-free `store-id` module so
|
|
// isomorphic code can reuse it without pulling Node deps into the browser.
|
|
export { validateStoreId } from "./store-id.js";
|
|
|
|
/**
|
|
* Default storage directory (~/.mcp-inspector/storage or %USERPROFILE%\.mcp-inspector\storage on Windows).
|
|
*/
|
|
export function getDefaultStorageDir(): string {
|
|
const homeDir = process.env.HOME || process.env.USERPROFILE || ".";
|
|
return path.join(homeDir, ".mcp-inspector", "storage");
|
|
}
|
|
|
|
/**
|
|
* Default path for the user's server list file
|
|
* (~/.mcp-inspector/mcp.json or %USERPROFILE%\.mcp-inspector\mcp.json on Windows).
|
|
*/
|
|
export function getDefaultMcpConfigPath(): string {
|
|
const homeDir = process.env.HOME || process.env.USERPROFILE || ".";
|
|
return path.join(homeDir, ".mcp-inspector", "mcp.json");
|
|
}
|
|
|
|
/**
|
|
* Path for a store ID under the given storage directory.
|
|
* Callers must pass a validated storeId.
|
|
*/
|
|
export function getStoreFilePath(storageDir: string, storeId: string): string {
|
|
return path.join(storageDir, `${storeId}.json`);
|
|
}
|
|
|
|
/**
|
|
* Read store file atomically. Returns null if the file does not exist (ENOENT).
|
|
* @throws on other read errors or parse errors (caller may use parseStore on the string).
|
|
*/
|
|
export async function readStoreFile(filePath: string): Promise<string | null> {
|
|
try {
|
|
const data = await readFile(filePath, { encoding: "utf-8" });
|
|
return data;
|
|
} catch (error) {
|
|
const err = error as NodeJS.ErrnoException;
|
|
if (err.code === "ENOENT") {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* In-flight writeStoreFile() promises, keyed by resolved path. Lets callers
|
|
* await persistence completion via flushStoreFileWrites() instead of polling
|
|
* the file — Zustand's persist middleware invokes writeStoreFile() fire-and-
|
|
* forget, so the in-memory store updates synchronously while the file write
|
|
* lags. Entries are removed once their write settles.
|
|
*
|
|
* Load-bearing: pendingWrites.set() below runs synchronously before the first
|
|
* await in writeStoreFile(), so a flushStoreFileWrites() called right after a
|
|
* persist sees the in-flight entry. Callers (e.g. the storage adapter's
|
|
* setItem) must not introduce an await before writeStoreFile() — doing so would
|
|
* let a flush run before registration and return early.
|
|
*/
|
|
const pendingWrites = new Map<string, Promise<void>>();
|
|
|
|
/**
|
|
* Write store file atomically (temp file + rename). Ensures parent directory exists.
|
|
* Uses mode 0o600 for the file.
|
|
*
|
|
* The returned promise is also tracked per path so flushStoreFileWrites() can
|
|
* await it. Writes to the same path are chained so a flush awaits every queued
|
|
* write (and a later write's completion implies all earlier ones settled).
|
|
*/
|
|
export async function writeStoreFile(
|
|
filePath: string,
|
|
data: string,
|
|
): Promise<void> {
|
|
const key = path.resolve(filePath);
|
|
const run = async (): Promise<void> => {
|
|
const dir = path.dirname(filePath);
|
|
await fs.mkdir(dir, { recursive: true });
|
|
await writeFile(filePath, data, {
|
|
encoding: "utf-8",
|
|
mode: 0o600,
|
|
});
|
|
};
|
|
const prior = pendingWrites.get(key);
|
|
const tracked = (prior ?? Promise.resolve()).catch(() => {}).then(run);
|
|
pendingWrites.set(key, tracked);
|
|
try {
|
|
await tracked;
|
|
} finally {
|
|
if (pendingWrites.get(key) === tracked) {
|
|
pendingWrites.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Await pending writeStoreFile() writes — those for `filePath` if given, else
|
|
* all of them. Use in tests after triggering persistence (Zustand persist
|
|
* writes fire-and-forget) instead of polling the file, and for graceful
|
|
* shutdown. Resolves immediately when nothing is in flight.
|
|
*/
|
|
export async function flushStoreFileWrites(filePath?: string): Promise<void> {
|
|
if (filePath !== undefined) {
|
|
await pendingWrites.get(path.resolve(filePath));
|
|
return;
|
|
}
|
|
await Promise.all(pendingWrites.values());
|
|
}
|
|
|
|
/**
|
|
* Delete store file. Ignores ENOENT (already deleted).
|
|
*/
|
|
export async function deleteStoreFile(filePath: string): Promise<void> {
|
|
try {
|
|
await fs.unlink(filePath);
|
|
} catch (error) {
|
|
const err = error as NodeJS.ErrnoException;
|
|
if (err.code !== "ENOENT") {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Serialize store data to JSON string (consistent format for server writes).
|
|
*/
|
|
export function serializeStore(data: unknown): string {
|
|
return JSON.stringify(data, null, 2);
|
|
}
|
|
|
|
/**
|
|
* Parse store JSON string. Use after readStoreFile when returning parsed object.
|
|
*/
|
|
export function parseStore(raw: string): unknown {
|
|
return JSON.parse(raw);
|
|
}
|