* 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
|
|
@ -1,11 +1,13 @@
|
|||
/**
|
||||
* Hono-based remote server for MCP transports.
|
||||
* Hosts /api/config, /api/mcp/connect, send, events, disconnect, /api/fetch, /api/log,
|
||||
* /api/storage/:storeId, /api/servers (+ /api/servers/:id).
|
||||
* /api/storage/:storeId, /api/servers (+ /api/servers/:id), /api/import-source.
|
||||
*/
|
||||
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { stat as fsStat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import type pino from "pino";
|
||||
import {
|
||||
getDefaultStorageDir,
|
||||
|
|
@ -25,7 +27,10 @@ import { streamSSE } from "hono/streaming";
|
|||
import { watch as chokidarWatch, type FSWatcher } from "chokidar";
|
||||
import { createTransportNode } from "../../node/transport.js";
|
||||
import type { RemoteConnectRequest, RemoteSendRequest } from "../types.js";
|
||||
import { DEFAULT_MAX_FETCH_REQUESTS, DEFAULT_TASK_TTL_MS } from "../../types.js";
|
||||
import {
|
||||
DEFAULT_MAX_FETCH_REQUESTS,
|
||||
DEFAULT_TASK_TTL_MS,
|
||||
} from "../../types.js";
|
||||
import type {
|
||||
InspectorServerSettings,
|
||||
MCPConfig,
|
||||
|
|
@ -43,6 +48,7 @@ import {
|
|||
storedFieldsToInspectorSettings,
|
||||
stripInspectorFields,
|
||||
} from "../../serverList.js";
|
||||
import { resolveImportSource } from "../../import/resolveSource.js";
|
||||
import { RemoteSession } from "./remote-session.js";
|
||||
import { createTokenAuthProvider } from "./tokenAuthProvider.js";
|
||||
import { API_SERVER_ENV_VARS } from "../constants.js";
|
||||
|
|
@ -423,10 +429,7 @@ export function createRemoteApp(
|
|||
if (event !== "unlink") {
|
||||
try {
|
||||
const s = await fsStat(mcpConfigPath);
|
||||
if (
|
||||
lastWrittenMtimeMs !== null &&
|
||||
s.mtimeMs === lastWrittenMtimeMs
|
||||
) {
|
||||
if (lastWrittenMtimeMs !== null && s.mtimeMs === lastWrittenMtimeMs) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -915,9 +918,7 @@ export function createRemoteApp(
|
|||
// A roots array: each entry must have a string `uri` and, when present, a
|
||||
// string `name` (SDK `Root`). Other keys (e.g. `_meta`) pass through — we
|
||||
// only gate the two fields the Inspector reads/writes.
|
||||
const isRootArray = (
|
||||
v: unknown,
|
||||
): v is { uri: string; name?: string }[] => {
|
||||
const isRootArray = (v: unknown): v is { uri: string; name?: string }[] => {
|
||||
if (!Array.isArray(v)) return false;
|
||||
return v.every((e) => {
|
||||
if (e === null || typeof e !== "object") return false;
|
||||
|
|
@ -1328,7 +1329,9 @@ export function createRemoteApp(
|
|||
fields: string[],
|
||||
): Promise<Record<string, string>> => {
|
||||
const values = await Promise.all(
|
||||
fields.map(async (field) => [field, await secretStore.get(id, field)] as const),
|
||||
fields.map(
|
||||
async (field) => [field, await secretStore.get(id, field)] as const,
|
||||
),
|
||||
);
|
||||
const out: Record<string, string> = {};
|
||||
for (const [field, v] of values) {
|
||||
|
|
@ -1468,10 +1471,7 @@ export function createRemoteApp(
|
|||
// 404). Returns a Response to short-circuit, or undefined to proceed.
|
||||
const requireWritable = (c: Context): Response | undefined => {
|
||||
if (writable) return undefined;
|
||||
return c.json(
|
||||
{ error: "Server list is read-only for this session." },
|
||||
403,
|
||||
);
|
||||
return c.json({ error: "Server list is read-only for this session." }, 403);
|
||||
};
|
||||
|
||||
app.get("/api/servers", async (c) => {
|
||||
|
|
@ -1529,9 +1529,9 @@ export function createRemoteApp(
|
|||
await writeMcpAndTrackMtime(serializeStore(DEFAULT_SEED_CONFIG));
|
||||
return DEFAULT_SEED_CONFIG;
|
||||
}
|
||||
const parsedInside = parseStore(rawInside) as
|
||||
| { mcpServers?: unknown }
|
||||
| null;
|
||||
const parsedInside = parseStore(rawInside) as {
|
||||
mcpServers?: unknown;
|
||||
} | null;
|
||||
const inside: MCPConfig = {
|
||||
mcpServers: normalizeMcpServers(parsedInside?.mcpServers),
|
||||
};
|
||||
|
|
@ -1554,6 +1554,31 @@ export function createRemoteApp(
|
|||
}
|
||||
});
|
||||
|
||||
// Read another MCP client's well-known config file on this host and return
|
||||
// its servers in canonical form, for the "Import config" source picker
|
||||
// (#1348). Read-only: it never writes, so it's allowed even in a read-only
|
||||
// session (the subsequent POST /api/servers per chosen entry is what's gated).
|
||||
// `?type=` selects the strategy (claude-desktop | cursor | cline | vscode);
|
||||
// an unknown type is a 400. When none of the strategy's well-known paths exist
|
||||
// the response is `{ found: false, searched }` so the UI can fall back to the
|
||||
// file picker; when a path exists but won't parse, `{ found: true, error }`.
|
||||
app.get("/api/import-source", (c) => {
|
||||
const type = c.req.query("type") ?? "";
|
||||
const result = resolveImportSource(
|
||||
type,
|
||||
process.platform,
|
||||
homedir(),
|
||||
(path) => (existsSync(path) ? readFileSync(path, "utf-8") : null),
|
||||
);
|
||||
if (!result) {
|
||||
return c.json(
|
||||
{ error: `Unknown import source: ${type || "(none)"}` },
|
||||
400,
|
||||
);
|
||||
}
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
app.post("/api/servers", async (c) => {
|
||||
const ro = requireWritable(c);
|
||||
if (ro) return ro;
|
||||
|
|
@ -1758,10 +1783,7 @@ export function createRemoteApp(
|
|||
// The `in` check above guarantees this branch is unreachable;
|
||||
// narrowing without the non-null assertion keeps TS happy and
|
||||
// makes the contract explicit for future refactors.
|
||||
return c.json(
|
||||
{ error: `Server '${originalId}' not found` },
|
||||
404,
|
||||
);
|
||||
return c.json({ error: `Server '${originalId}' not found` }, 404);
|
||||
}
|
||||
// Split the existing entry into its SDK-only config (no Inspector-
|
||||
// extension fields) and its lifted settings, then apply patch
|
||||
|
|
|
|||
Loading…
Reference in a new issue