* chore(deps): migrate to eslint 10 to clear the brace-expansion DoS advisory (#1838) eslint 9 pulled `minimatch@3` → `brace-expansion@1.1.16`, which carries GHSA-mh99-v99m-4gvg (high, DoS via unbounded expansion length). It appeared in all five lockfiles — the root plus each of the four clients, none of which is an npm workspace, so all five had to move together. Deferred from the pre-2.0.0 sweep because it is dev-scope, not attacker- controlled, and a major bump across five packages was the wrong thing to attempt immediately before an irreversible publish. This is that work. ## What moved - `eslint` `^9.39.4/5` → `^10.8.0` and `@eslint/js` → `^10.0.1`, in all five manifests. - `eslint-plugin-react-hooks` `^7.0.1` → `^7.1.1` in web and tui. Required, not cosmetic: 7.0.1's peer range stops at `^9.0.0`, so installing it alongside eslint 10 fails `ERESOLVE`. - Everything else already declared eslint 10 support and stayed put: `typescript-eslint@^8.56.1` (`^8.57 || ^9 || ^10`), `eslint-plugin-react-refresh@^0.5.2` (`^9 || ^10`), `eslint-plugin-storybook@^10.2.19` (`>=8`). ## Config migration None was needed. All five configs were already flat (`eslint.config.js` with `defineConfig`/`globalIgnores` from `eslint/config`), which is the format v10 keeps and the only one it still supports. The root config's `core/` and shared- surface blocks — including the `_`-prefix `argsIgnorePattern` / `varsIgnorePattern` / `caughtErrorsIgnorePattern` — are unchanged and still gate `lint:core` and `lint:shared`. No rule was disabled, downgraded, or scoped away to make the new eslint pass. v10 surfaced 21 real violations across three rules; all 21 are fixed in the code. ### `preserve-caught-error` (new in `eslint:recommended`) — 15 sites A `throw new Error(msg)` inside a `catch` silently drops the original error. Each now passes `{ cause }`, so the underlying failure survives into the chain. Messages are unchanged, so nothing that asserts on them moves. 12 in `core/`, 2 in `clients/cli`, 1 in `test-servers/src`. ### `no-useless-assignment` (new in `eslint:recommended`) — 2 sites `clients/cli/src/cli.ts`'s `optionArgs` and `test-servers/src/test-server-oauth` 's `valid` were initialized and then unconditionally overwritten on every path. Both are now declared without the dead initializer; TypeScript's definite- assignment analysis covers the remaining paths. ### `react-hooks/set-state-in-effect` — 8 sites (web) These were false negatives the plugin fixed in 7.1.0, not a rule we newly opted into: `set-state-in-effect` has been `error` in `flat/recommended` since 7.0.1, and the web config has always extended it. Every one was the same anti-pattern — resetting or re-syncing local state from a prop inside a `useEffect`, which paints the stale value for one frame and then renders again to correct it. All eight now use a new `useValueChange(value, onChange)` hook (`clients/web/src/hooks/useValueChange.ts`), which is React's documented "adjusting state during render": compare against the previous render's value with `Object.is` and call back *during* render, so React discards the in-progress output before it reaches the DOM. - `MrtrConversation`, `ProtocolEntry`, `TaskCard` — mirror `isListExpanded`. - `NetworkEntry` — same mirror, plus the "Reveal in Network" force-open. The reveal's rAF scroll stays an effect (real external-system work); only the `setIsExpanded(true)` moved. `isExpanded` is now seeded from `isListExpanded || revealed`, since a render-time sync fires on change and so cannot cover an entry that mounts already revealed. - `PromptArgumentsForm`, `ResourceTemplatePanel` — reset on template/prompt switch. - `ServerConfigModal` — reset on open. Keyed on `opened ? initial : undefined`, which collapses "opened flipped" and "initial changed while open" into one value; `initial` is already memoized, so it cannot thrash. `useValueChange` has its own test file, and `ServerConfigModal` gains an open→edit→close→reopen test (the close leg was previously untested, which is what left the new guard uncovered). ## Advisory delta `npm audit`, per package, before → after: | Package | before | after | | ------------------- | -------------------------- | -------------------------- | | root | 9 (1 low, 6 mod, 2 high) | 8 (1 low, 6 mod, 1 high) | | `clients/web` | 19 (3 low, 6 mod, 6 high, 4 crit) | 17 (3 low, 6 mod, 4 high, 4 crit) | | `clients/cli` | 6 (2 low, 4 high) | 4 (2 low, 2 high) | | `clients/tui` | 4 (1 low, 3 high) | 2 (1 low, 1 high) | | `clients/launcher` | 3 (3 high) | 1 (1 high) | `brace-expansion` is gone from all five — `eslint@10` resolves `minimatch@10` → `brace-expansion@5.0.9`. Dropping `@eslint/eslintrc` (removed in v10) also took `js-yaml` with it, clearing GHSA-52cp-r559-cp3m (high) from all four clients as a bonus. The remainder are unrelated and tracked separately. `npm run ci` passes end to end — validate, the per-file ≥90 coverage gate, the build gate, all five smokes (including `smoke:tui`, run locally on a real TTY), and 462 Storybook tests. Also documents the effect rule in AGENTS.md and mirrors it into `.github/copilot-instructions.md`, since it is now something a reviewer cites. Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: state useValueChange's referential-stability requirement (#1838) Copilot review on #1900: the hook compares with `Object.is`, so an object or array literal rebuilt in the component body compares unequal every render. Because the callback is what updates state, that is not merely a wasted render — it is an infinite loop. Every current caller passes a primitive (`boolean`, `string`) or an already- memoized value, so nothing changes behaviorally. But the hazard was implicit, and the next caller is the one it would bite. Documented on the hook itself, and one clause added to the AGENTS.md rule with its mirror in `.github/copilot-instructions.md`. No runtime guard: the requirement is the same one a `useEffect` dependency array carries, and here the failure is loud and immediate rather than silent. `npm run ci` passes. Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(cli): stop asserting the --tool-args-json catch binding is an Error (#1838) Copilot review round 2 on #1900: the `--tool-args-json` parse handler formatted its message with `(e as Error).message`. A `catch` binding is `unknown`, so that cast is an assertion rather than a check — a thrown non-Error would render `--tool-args-json is not valid JSON: undefined`, which points the user at nothing. `JSON.parse` does only throw `SyntaxError` today, so this is not a live bug. But the cast is the kind AGENTS.md asks us not to leave unjustified, the same handler was already being touched here to add `{ cause: e }`, and every other site in this PR uses the guarded form. Now it does too. `npm run ci` passes. Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: require useValueChange's onChange to be render-pure (#1838) Copilot review round 3 on #1900: `onChange` is called during render, so it is bound by render-phase purity — and the web client mounts under `StrictMode`, which deliberately double-renders in development. A callback doing anything external would run an unpredictable number of times, and concurrent React can abandon an in-progress render entirely. Every current caller already passes a callback that only calls `setState`, so nothing changes. But "runs during render" is the whole point of the hook and the constraint that follows from it was left implicit. Stated on the hook, with the same clause in the AGENTS.md rule and its `.github/copilot-instructions.md` mirror. All three now point at `NetworkEntry` as the worked example of the split: the reveal's force-open is a state update and lives in `useValueChange`, while its `requestAnimationFrame` scroll stays a `useEffect`. `npm run ci` passes. Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
426 lines
15 KiB
TypeScript
426 lines
15 KiB
TypeScript
/**
|
|
* Parser for the official MCP Registry `server.json` format (the file
|
|
* `mcp-publisher init` generates and publishes). Distinct from the client-config
|
|
* parsers: a `server.json` describes a *single* server via one or more runnable
|
|
* `packages` and/or `remotes`, and carries publish-time metadata
|
|
* (`environmentVariables`, `packageArguments`, URL `variables`) that has to be
|
|
* mapped onto a runnable Inspector `MCPServerConfig`.
|
|
*
|
|
* Pure + isomorphic (no Node deps). The web wiring layer (`useServerJsonImport`)
|
|
* turns the parsed options into the props the dumb `ImportServerJsonPanel`
|
|
* renders, then calls `buildServerConfig` once the user has filled in env vars.
|
|
*
|
|
* Schema reference: https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json
|
|
* The published `server.json` file uses camelCase keys (`registryType`,
|
|
* `runtimeHint`, `environmentVariables`, …); the registry REST API has
|
|
* historically returned the same fields in snake_case. We read both so a blob
|
|
* copied from either source imports cleanly.
|
|
*/
|
|
import type { MCPServerConfig, StdioServerConfig } from "../types.js";
|
|
import { validateStoreId } from "../../storage/store-id.js";
|
|
|
|
/** A registry package's environment-variable declaration. */
|
|
export interface ServerJsonEnvVar {
|
|
name: string;
|
|
description?: string;
|
|
required: boolean;
|
|
/** Declared default/value, pre-filled into the form when present. */
|
|
default?: string;
|
|
isSecret?: boolean;
|
|
}
|
|
|
|
/**
|
|
* One runnable way to launch the server — either a package (npm/pypi/oci/nuget)
|
|
* resolved to a stdio command, or a remote (streamable-http/sse) URL. The dumb
|
|
* panel renders these as the package-selection radios; `buildServerConfig`
|
|
* turns the chosen one + env overrides into the final config.
|
|
*/
|
|
export interface ServerJsonOption {
|
|
/** Registry/transport label source: "npm" | "pypi" | "oci" | "nuget" | "streamable-http" | "sse". */
|
|
registryType: string;
|
|
/** Package identifier or remote URL — shown in the radio label. */
|
|
identifier: string;
|
|
/** The runtime/transport used to launch it (e.g. "npx", "uvx", "docker", "streamable-http"). */
|
|
runtimeHint: string;
|
|
/**
|
|
* The runnable config with all statically-known values resolved (runtime
|
|
* command, args, declared env defaults, URL variable defaults). Env vars the
|
|
* user must supply are surfaced via `envVars` and merged in by
|
|
* `buildServerConfig`.
|
|
*/
|
|
baseConfig: MCPServerConfig;
|
|
/** Env vars declared for this option (empty for remotes). */
|
|
envVars: ServerJsonEnvVar[];
|
|
}
|
|
|
|
export interface ParsedServerJson {
|
|
/** Sanitized default server id, derived from the last segment of `name`. */
|
|
serverName: string;
|
|
/** The full registry name (e.g. "io.github.foo/weather"). */
|
|
fullName: string;
|
|
/** Runnable launch options, packages first then remotes. */
|
|
options: ServerJsonOption[];
|
|
}
|
|
|
|
/** Read a field by its camelCase or snake_case key. */
|
|
function pick(
|
|
obj: Record<string, unknown>,
|
|
camel: string,
|
|
snake: string,
|
|
): unknown {
|
|
return obj[camel] !== undefined ? obj[camel] : obj[snake];
|
|
}
|
|
|
|
function asString(value: unknown): string | undefined {
|
|
return typeof value === "string" ? value : undefined;
|
|
}
|
|
|
|
function asObject(value: unknown): Record<string, unknown> | undefined {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: undefined;
|
|
}
|
|
|
|
function asArray(value: unknown): unknown[] {
|
|
return Array.isArray(value) ? value : [];
|
|
}
|
|
|
|
/**
|
|
* Derive a safe Inspector server id from a registry name. The registry name is
|
|
* a reverse-DNS-ish string ("io.github.user/weather"); we take the segment
|
|
* after the last "/" and strip anything outside `[A-Za-z0-9_-]`. Falls back to
|
|
* "server" when nothing usable remains.
|
|
*/
|
|
export function deriveServerId(fullName: string): string {
|
|
const lastSegment = fullName.split("/").pop() ?? fullName;
|
|
const cleaned = lastSegment
|
|
.replace(/[^A-Za-z0-9_-]/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
return cleaned || "server";
|
|
}
|
|
|
|
/** Parse an `environmentVariables` array into our env-var descriptors. */
|
|
function parseEnvVars(rawList: unknown[]): ServerJsonEnvVar[] {
|
|
const out: ServerJsonEnvVar[] = [];
|
|
for (const entry of rawList) {
|
|
const obj = asObject(entry);
|
|
if (!obj) continue;
|
|
const name = asString(obj.name);
|
|
if (!name) continue;
|
|
const required = Boolean(pick(obj, "isRequired", "is_required"));
|
|
const isSecret = Boolean(pick(obj, "isSecret", "is_secret"));
|
|
// The schema's `value` is a *fixed* value and `default` a suggestion; we
|
|
// prefill both as the (editable) input value. We don't currently lock a
|
|
// fixed `value` against editing — acceptable for an import preview the user
|
|
// reviews before saving.
|
|
const def = asString(obj.default) ?? asString(obj.value);
|
|
out.push({
|
|
name,
|
|
description: asString(obj.description),
|
|
required,
|
|
...(def !== undefined ? { default: def } : {}),
|
|
...(isSecret ? { isSecret: true } : {}),
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Resolve a registry `Argument` list (positional/named) into argv strings.
|
|
* Best-effort: positional args contribute their `value`/`default`; named args
|
|
* contribute their flag `name` plus any `value`/`default`. Arguments with
|
|
* neither a value nor a default are skipped (the user can add them via the edit
|
|
* form afterward); advanced features (`isRepeated`, `variables`) are ignored.
|
|
*/
|
|
function resolveArguments(rawList: unknown[]): string[] {
|
|
const out: string[] = [];
|
|
for (const entry of rawList) {
|
|
const obj = asObject(entry);
|
|
if (!obj) continue;
|
|
const type = asString(obj.type) ?? "positional";
|
|
const value = asString(obj.value) ?? asString(obj.default);
|
|
if (type === "named") {
|
|
const name = asString(obj.name);
|
|
if (!name) continue;
|
|
out.push(name);
|
|
if (value !== undefined) out.push(value);
|
|
} else {
|
|
if (value !== undefined) out.push(value);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Build the env map for a package: declared defaults only (overrides applied later). */
|
|
function defaultEnv(envVars: ServerJsonEnvVar[]): Record<string, string> {
|
|
const env: Record<string, string> = {};
|
|
for (const v of envVars) {
|
|
if (v.default !== undefined) env[v.name] = v.default;
|
|
}
|
|
return env;
|
|
}
|
|
|
|
/** runtime command + base args for a given registry package type, or null if unsupported. */
|
|
function runtimeForPackage(
|
|
registryType: string,
|
|
identifier: string,
|
|
version: string | undefined,
|
|
): { command: string; runtimeHint: string; ref: string } | null {
|
|
const versioned = (sep: string) =>
|
|
version ? `${identifier}${sep}${version}` : identifier;
|
|
switch (registryType) {
|
|
case "npm":
|
|
return { command: "npx", runtimeHint: "npx", ref: versioned("@") };
|
|
case "pypi":
|
|
return { command: "uvx", runtimeHint: "uvx", ref: identifier };
|
|
case "oci":
|
|
return { command: "docker", runtimeHint: "docker", ref: identifier };
|
|
case "nuget":
|
|
return { command: "dnx", runtimeHint: "dnx", ref: versioned("@") };
|
|
// mcpb artifacts are downloadable bundles, not a runnable command — skip.
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Map a single `packages[]` entry to a launch option, or null if unsupported. */
|
|
function parsePackage(raw: Record<string, unknown>): ServerJsonOption | null {
|
|
const registryType =
|
|
asString(pick(raw, "registryType", "registry_type")) ?? "";
|
|
const identifier = asString(raw.identifier) ?? "";
|
|
if (!registryType || !identifier) return null;
|
|
const version = asString(raw.version);
|
|
const runtime = runtimeForPackage(registryType, identifier, version);
|
|
if (!runtime) return null;
|
|
|
|
const envVars = parseEnvVars(
|
|
asArray(pick(raw, "environmentVariables", "environment_variables")),
|
|
);
|
|
const runtimeArgs = resolveArguments(
|
|
asArray(pick(raw, "runtimeArguments", "runtime_arguments")),
|
|
);
|
|
const packageArgs = resolveArguments(
|
|
asArray(pick(raw, "packageArguments", "package_arguments")),
|
|
);
|
|
|
|
const env = defaultEnv(envVars);
|
|
|
|
// docker needs `run -i --rm` before the image; package args go to the
|
|
// container after the image. Declared env vars must be forwarded into the
|
|
// container with `-e KEY` flags — otherwise they'd only set the `docker` CLI
|
|
// process's environment (via config.env) and never reach the server. `-e KEY`
|
|
// (no value) tells docker to pass KEY through from that process env, which is
|
|
// where buildServerConfig merges the user-supplied values. Other runtimes
|
|
// inherit config.env directly, so they need no extra flags.
|
|
const dockerEnvArgs = envVars.flatMap((v) => ["-e", v.name]);
|
|
const args =
|
|
runtime.command === "docker"
|
|
? [
|
|
"run",
|
|
"-i",
|
|
"--rm",
|
|
...runtimeArgs,
|
|
...dockerEnvArgs,
|
|
runtime.ref,
|
|
...packageArgs,
|
|
]
|
|
: registryType === "npm"
|
|
? [...runtimeArgs, "-y", runtime.ref, ...packageArgs]
|
|
: [...runtimeArgs, runtime.ref, ...packageArgs];
|
|
|
|
// No `cwd`: the registry `server.json` package schema (2025-12-11) carries no
|
|
// working-directory concept — its package fields are registryType,
|
|
// registryBaseUrl, identifier, version, fileSha256, runtimeHint,
|
|
// runtimeArguments, packageArguments, environmentVariables, transport. So
|
|
// there is nothing to map onto `config.cwd` here; the user can set one
|
|
// afterward via the Add/Edit or Server Settings modal.
|
|
const baseConfig: StdioServerConfig = {
|
|
type: "stdio",
|
|
command: runtime.command,
|
|
...(args.length > 0 ? { args } : {}),
|
|
...(Object.keys(env).length > 0 ? { env } : {}),
|
|
};
|
|
return {
|
|
registryType,
|
|
identifier,
|
|
runtimeHint: runtime.runtimeHint,
|
|
baseConfig,
|
|
envVars,
|
|
};
|
|
}
|
|
|
|
/** Substitute `{var}` template tokens in a remote URL with their declared defaults. */
|
|
function resolveRemoteUrl(
|
|
url: string,
|
|
variables: Record<string, unknown> | undefined,
|
|
): string {
|
|
if (!variables) return url;
|
|
return url.replace(/\{([^}]+)\}/g, (match, name: string) => {
|
|
const v = asObject(variables[name]);
|
|
const def = v ? asString(v.default) : undefined;
|
|
return def ?? match;
|
|
});
|
|
}
|
|
|
|
/** Map a single `remotes[]` entry to a launch option, or null if unsupported. */
|
|
function parseRemote(raw: Record<string, unknown>): ServerJsonOption | null {
|
|
const type = asString(raw.type);
|
|
const url = asString(raw.url);
|
|
if (!url) return null;
|
|
const transport = type === "sse" ? "sse" : "streamable-http";
|
|
const resolvedUrl = resolveRemoteUrl(url, asObject(raw.variables));
|
|
const baseConfig: MCPServerConfig =
|
|
transport === "sse"
|
|
? { type: "sse", url: resolvedUrl }
|
|
: { type: "streamable-http", url: resolvedUrl };
|
|
// Label remotes as "remote" (the install method); the transport variant
|
|
// (streamable-http / sse) is carried in runtimeHint so the picker shows
|
|
// e.g. "remote: https://… (streamable-http)".
|
|
return {
|
|
registryType: "remote",
|
|
identifier: resolvedUrl,
|
|
runtimeHint: transport,
|
|
baseConfig,
|
|
envVars: [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Parse a registry `server.json` blob into its runnable options. Accepts either
|
|
* a bare server object or a `{ server: {...} }` wrapper (some registry API
|
|
* responses nest it). Throws when the blob is not a server object or exposes no
|
|
* runnable package/remote.
|
|
*/
|
|
export function parseServerJson(raw: string): ParsedServerJson {
|
|
let data: unknown;
|
|
try {
|
|
data = JSON.parse(raw);
|
|
} catch (err) {
|
|
const detail = err instanceof Error ? err.message : String(err);
|
|
throw new Error(`Invalid JSON: ${detail}`, { cause: err });
|
|
}
|
|
const top = asObject(data);
|
|
if (!top) throw new Error("Expected a JSON object at the top level");
|
|
const server = asObject(top.server) ?? top;
|
|
|
|
const fullName = asString(server.name);
|
|
if (!fullName) {
|
|
throw new Error("server.json is missing a 'name' field");
|
|
}
|
|
|
|
const options: ServerJsonOption[] = [];
|
|
for (const pkg of asArray(server.packages)) {
|
|
const obj = asObject(pkg);
|
|
if (!obj) continue;
|
|
const option = parsePackage(obj);
|
|
if (option) options.push(option);
|
|
}
|
|
for (const remote of asArray(server.remotes)) {
|
|
const obj = asObject(remote);
|
|
if (!obj) continue;
|
|
const option = parseRemote(obj);
|
|
if (option) options.push(option);
|
|
}
|
|
|
|
if (options.length === 0) {
|
|
throw new Error(
|
|
"No runnable package or remote found in server.json (mcpb-only or unsupported registry types are not importable)",
|
|
);
|
|
}
|
|
|
|
return {
|
|
serverName: deriveServerId(fullName),
|
|
fullName,
|
|
options,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build the final runnable config for a chosen option, merging user-supplied
|
|
* env overrides over the declared defaults. Empty-string values are dropped so
|
|
* an untouched required var doesn't persist as `KEY=""`. Remotes ignore env
|
|
* overrides (their config carries no env).
|
|
*/
|
|
export function buildServerConfig(
|
|
option: ServerJsonOption,
|
|
envOverrides: Record<string, string>,
|
|
): MCPServerConfig {
|
|
if (option.baseConfig.type !== "stdio") {
|
|
return { ...option.baseConfig };
|
|
}
|
|
const merged: Record<string, string> = { ...(option.baseConfig.env ?? {}) };
|
|
for (const [key, value] of Object.entries(envOverrides)) {
|
|
if (value === "") delete merged[key];
|
|
else merged[key] = value;
|
|
}
|
|
// Drop the original `env` from the base (via destructure) so it isn't spread
|
|
// back in; re-add it only when the merged map is non-empty. This keeps an
|
|
// entry whose overrides emptied everything out from persisting a stale env.
|
|
const { env: _baseEnv, ...base } = option.baseConfig;
|
|
return {
|
|
...base,
|
|
...(Object.keys(merged).length > 0 ? { env: merged } : {}),
|
|
};
|
|
}
|
|
|
|
/** The id a parsed server.json will be saved under: the override (if any) wins. */
|
|
export function resolveServerId(
|
|
parsed: ParsedServerJson,
|
|
idOverride?: string,
|
|
): string {
|
|
const trimmed = (idOverride ?? "").trim();
|
|
return trimmed || parsed.serverName;
|
|
}
|
|
|
|
/** The chosen launch option plus the resulting id and its validity. */
|
|
export interface ServerJsonSelection {
|
|
selectedIndex: number;
|
|
selectedOption: ServerJsonOption;
|
|
serverId: string;
|
|
idIsValid: boolean;
|
|
idIsDuplicate: boolean;
|
|
}
|
|
|
|
/**
|
|
* Resolve which option a parsed server.json will import as, the id it will use,
|
|
* and whether that id is valid + free. Pure, so both the web wiring and a future
|
|
* CLI/TUI import can share the selection logic. `selectedIndex` is clamped to the
|
|
* available options.
|
|
*/
|
|
export function selectServerJsonOption(
|
|
parsed: ParsedServerJson,
|
|
opts: {
|
|
selectedIndex?: number;
|
|
idOverride?: string;
|
|
existingIds?: readonly string[];
|
|
} = {},
|
|
): ServerJsonSelection {
|
|
const selectedIndex = Math.min(
|
|
opts.selectedIndex ?? 0,
|
|
Math.max(parsed.options.length - 1, 0),
|
|
);
|
|
const serverId = resolveServerId(parsed, opts.idOverride);
|
|
return {
|
|
selectedIndex,
|
|
selectedOption: parsed.options[selectedIndex],
|
|
serverId,
|
|
idIsValid: validateStoreId(serverId),
|
|
idIsDuplicate: (opts.existingIds ?? []).includes(serverId),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Merge the user's env overrides for the *selected* option only (env vars the
|
|
* option doesn't declare are ignored) and build the runnable config.
|
|
*/
|
|
export function buildServerConfigForSelection(
|
|
option: ServerJsonOption,
|
|
envOverrides: Record<string, string>,
|
|
): MCPServerConfig {
|
|
const overrides: Record<string, string> = {};
|
|
for (const v of option.envVars) {
|
|
const value = envOverrides[v.name];
|
|
if (value !== undefined) overrides[v.name] = value;
|
|
}
|
|
return buildServerConfig(option, overrides);
|
|
}
|