mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 15:26:25 +00:00
* feat(setup): register LM Studio during setup Register rea in mcp.json in the LM Studio home through a new lm_studio client. The home follows LM Studio's own lookup, read-only: the path in ~/.lmstudio-home-pointer, else an existing ~/.cache/lm-studio, else ~/.lmstudio. An unreadable or relative pointer reports a path error instead of a guessed home. LM Studio parses mcp.json with strict JSON.parse, so comments, trailing commas and a BOM are rejected before any backup or write. The entry carries command, args and env with no type or timeout. Local models have small context windows, so the registration selects REA_MCP_INPUT_SCHEMA_PROFILE=compact unless a profile is set explicitly, and doctor reports an entry without a profile as stale. One predicate now owns that default for Kimi Code and LM Studio. * fix(doctor): resolve provider budgets for LM Studio as setup does
1866 lines
61 KiB
TypeScript
1866 lines
61 KiB
TypeScript
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
import { isDeepStrictEqual } from "node:util";
|
|
|
|
import {
|
|
parseDshCordisDocument,
|
|
serializeDshDocument,
|
|
editDshDocument,
|
|
dshClientRegistrationEntry,
|
|
} from "./DshCordisPatch.js";
|
|
import {
|
|
editHermesConfigurationYaml,
|
|
parseHermesConfigurationYaml,
|
|
stringifyHermesConfigurationYaml,
|
|
} from "./HermesConfigurationYaml.js";
|
|
import {
|
|
applyEdits,
|
|
createScanner,
|
|
format as formatJson,
|
|
getNodeValue,
|
|
parseTree,
|
|
printParseErrorCode,
|
|
type Edit,
|
|
type Node,
|
|
type ParseError,
|
|
type SyntaxKind,
|
|
} from "jsonc-parser";
|
|
import { z } from "zod";
|
|
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import { PRODUCT_IDENTITY } from "../identity.js";
|
|
import type { SetupClient } from "./SupportedClients.js";
|
|
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
|
|
|
export type ClientConfigurationFormat = NonNullable<SetupClient["format"]>;
|
|
type ClientServersKey = "mcp_servers" | "mcpServers" | "mcp" | "servers";
|
|
|
|
/**
|
|
* Registration entry dialect. OpenCode V2 reads V1 `mcp.<name>` entries, but a
|
|
* document already using the native `mcp.servers` table takes native entries.
|
|
*/
|
|
export type ClientRegistrationDialect =
|
|
| ClientConfigurationFormat
|
|
| "opencode_v2";
|
|
|
|
/** Validated client document and its server table, preserving unrelated settings. */
|
|
export interface ClientConfigurationDocument {
|
|
readonly document: Record<string, unknown>;
|
|
/** Server table that registrations are written to. */
|
|
readonly servers: Record<string, unknown>;
|
|
/** Key path of `servers` in `document`. */
|
|
readonly serversPath: readonly string[];
|
|
readonly dialect: ClientRegistrationDialect;
|
|
/** Member holding explicit environment overrides in a server registration. */
|
|
readonly environmentKey: "env" | "environment";
|
|
/**
|
|
* OpenCode V1 server members beside a native V2 `mcp.servers` table. OpenCode
|
|
* still loads them, and a native entry with the same name takes precedence.
|
|
*/
|
|
readonly legacyServers: Record<string, unknown>;
|
|
}
|
|
|
|
/** Admit source mappings without Zod's record clone dropping caller keys. */
|
|
export const clientConfigurationRecordSchema = z
|
|
.custom<Record<string, unknown>>((value) => {
|
|
if (typeof value !== "object" || value === null) return false;
|
|
const prototype: unknown = Object.getPrototypeOf(value);
|
|
return prototype === null || prototype === Object.prototype;
|
|
})
|
|
.transform((value) => Object.fromEntries(Object.entries(value)));
|
|
|
|
/** Compare parsed configuration values without depending on parser prototypes. */
|
|
export const clientConfigurationValuesEqual = (
|
|
left: unknown,
|
|
right: unknown,
|
|
): boolean =>
|
|
isDeepStrictEqual(
|
|
normalizeConfigurationValue(left),
|
|
normalizeConfigurationValue(right),
|
|
);
|
|
|
|
const normalizeConfigurationValue = (value: unknown): unknown => {
|
|
if (Array.isArray(value)) return value.map(normalizeConfigurationValue);
|
|
if (typeof value !== "object" || value === null || value instanceof Date)
|
|
return value;
|
|
return Object.fromEntries(
|
|
Object.entries(value)
|
|
.sort(([left], [right]) => compareUnicodeCodePoints(left, right))
|
|
.map(([key, nested]) => [key, normalizeConfigurationValue(nested)]),
|
|
);
|
|
};
|
|
|
|
/** Return the top-level object used for MCP server registrations by a client. */
|
|
export const clientConfigurationServersKey = (
|
|
format: ClientConfigurationFormat,
|
|
): ClientServersKey => {
|
|
switch (format) {
|
|
case "toml":
|
|
case "grok":
|
|
case "hermes":
|
|
return "mcp_servers";
|
|
case "opencode":
|
|
case "zcode":
|
|
return "mcp";
|
|
case "vscode":
|
|
return "servers";
|
|
default:
|
|
return "mcpServers";
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Whether raw configuration text holds no settings to preserve. Some clients
|
|
* create a zero-byte config file before any server is added.
|
|
*/
|
|
const isEmptyClientConfigurationText = (text: string): boolean =>
|
|
text.replace(/^\uFEFF/, "").trim() === "";
|
|
|
|
const INT64_MIN = -(2n ** 63n);
|
|
const INT64_MAX = 2n ** 63n - 1n;
|
|
|
|
/**
|
|
* Parse client TOML with TOML 1.0's signed 64-bit integer contract. Integers
|
|
* beyond JavaScript's safe range stay exact as bigint; values outside Int64,
|
|
* which Codex rejects, are malformed.
|
|
*/
|
|
const parseClientToml = (text: string): Record<string, unknown> => {
|
|
const parsed = parseToml(text, { integersAsBigInt: "asNeeded" });
|
|
const pending: unknown[] = [parsed];
|
|
for (let value = pending.pop(); value !== undefined; value = pending.pop()) {
|
|
if (typeof value === "bigint") {
|
|
if (value < INT64_MIN || value > INT64_MAX)
|
|
throw new SyntaxError(
|
|
`Invalid TOML document: integer ${String(value)} is outside the signed 64-bit range`,
|
|
);
|
|
} else if (
|
|
typeof value === "object" &&
|
|
value !== null &&
|
|
!(value instanceof Date)
|
|
)
|
|
// Large documents can exceed argument limits, so do not spread.
|
|
for (const nested of Object.values(value)) pending.push(nested);
|
|
}
|
|
return parsed;
|
|
};
|
|
|
|
const parseDocument = (
|
|
text: string,
|
|
format: ClientConfigurationFormat,
|
|
): Record<string, unknown> => {
|
|
if (format === "toml" || format === "grok")
|
|
return requireObject(parseClientToml(text), "The configuration root");
|
|
// Pi loads `mcp.json` with a strict `JSON.parse` and no BOM tolerance, so
|
|
// comments, trailing commas, a BOM, or an empty existing file must be
|
|
// rejected instead of registering into a document the client cannot load.
|
|
// Kimi and LM Studio also read mcp.json with JSON.parse; an empty file holds
|
|
// nothing to preserve.
|
|
if (format === "pi" || format === "kimi" || format === "lm_studio") {
|
|
if (format !== "pi" && text.trim().length === 0) return {};
|
|
return requireObject(JSON.parse(text), "The configuration root");
|
|
}
|
|
// An empty file, which some clients create before any server is added,
|
|
// holds no settings to preserve.
|
|
if (isEmptyClientConfigurationText(text)) return {};
|
|
// Hermes stores MCP servers in `mcp_servers` inside a YAML document.
|
|
if (format === "hermes")
|
|
return requireObject(
|
|
parseHermesConfigurationYaml(text),
|
|
"The configuration root",
|
|
);
|
|
// ZCode loads nonempty native files with strict JSON.parse.
|
|
if (format === "zcode")
|
|
return requireObject(JSON.parse(text), "The configuration root");
|
|
// Claude Code loads `.claude.json` with strict JSON.parse after a UTF-8 BOM,
|
|
// so comments and trailing commas it rejects must not be admitted as JSONC.
|
|
if (format === "claude_code")
|
|
return requireObject(
|
|
JSON.parse(text.replace(/^\uFEFF/, "")),
|
|
"The configuration root",
|
|
);
|
|
const errors: ParseError[] = [];
|
|
// Accept a UTF-8 BOM without shifting diagnostics or editing the original text.
|
|
const jsonText = text.startsWith("\uFEFF") ? ` ${text.slice(1)}` : text;
|
|
const root = parseTree(jsonText, errors, { allowTrailingComma: true });
|
|
const firstError = errors[0];
|
|
if (firstError !== undefined)
|
|
throw new SyntaxError(
|
|
`Invalid JSON/JSONC at offset ${firstError.offset}: ${printParseErrorCode(firstError.error)}`,
|
|
);
|
|
return requireObject(
|
|
root === undefined ? undefined : getNodeValue(root),
|
|
"The configuration root",
|
|
);
|
|
};
|
|
|
|
/** A parsed configuration whose structure, not its syntax, cannot hold MCP servers. */
|
|
export class ClientConfigurationShapeError extends TypeError {}
|
|
|
|
/** Validate source mappings and copy their own properties without rewriting values. */
|
|
const requireObject = (
|
|
value: unknown,
|
|
description: string,
|
|
): Record<string, unknown> => {
|
|
const parsed = clientConfigurationRecordSchema.safeParse(value);
|
|
if (parsed.success) return parsed.data;
|
|
throw new ClientConfigurationShapeError(`${description} must be an object`, {
|
|
cause: parsed.error,
|
|
});
|
|
};
|
|
|
|
/** ZCode reads the same-scope fallback as strict JSON with a mcpServers table. */
|
|
export const parseZcodeFallbackConfiguration = (
|
|
text: string,
|
|
): ClientConfigurationDocument => {
|
|
const document = requireObject(JSON.parse(text), "ZCode fallback root");
|
|
return {
|
|
document,
|
|
servers:
|
|
document.mcpServers === undefined
|
|
? {}
|
|
: requireObject(document.mcpServers, "ZCode fallback mcpServers"),
|
|
serversPath: ["mcpServers"],
|
|
dialect: "zcode_fallback",
|
|
environmentKey: "env",
|
|
legacyServers: {},
|
|
};
|
|
};
|
|
|
|
/** Members of OpenCode V2's `mcp` object that are not server names. */
|
|
const OPENCODE_V2_MCP_SETTINGS = new Set(["servers", "timeout"]);
|
|
|
|
/** Parse a client document and reject malformed roots or MCP server tables. */
|
|
export const parseClientConfiguration = (
|
|
text: string,
|
|
format: ClientConfigurationFormat | undefined,
|
|
): ClientConfigurationDocument => {
|
|
if (format === undefined || format === "unsupported")
|
|
throw new TypeError("client does not have a supported MCP config format");
|
|
// DeepSeek Harness stores a Cordis patch list, not a server table.
|
|
if (format === "zcode_fallback") return parseZcodeFallbackConfiguration(text);
|
|
if (format === "dsh") return parseDshCordisDocument(text);
|
|
const document = parseDocument(text, format);
|
|
const environmentKey = format === "opencode" ? "environment" : "env";
|
|
const serversKey = clientConfigurationServersKey(format);
|
|
const value = document[serversKey];
|
|
const servers =
|
|
value === undefined ? {} : requireObject(value, `"${serversKey}"`);
|
|
// ZCode reads stdio servers from the nested `mcp.servers` table of
|
|
// `~/.zcode/cli/config.json`; entries carry `command`, `args`, and `env`.
|
|
if (format === "zcode") {
|
|
const nested = servers.servers;
|
|
return {
|
|
document,
|
|
servers:
|
|
nested === undefined
|
|
? {}
|
|
: requireObject(nested, `"${serversKey}.servers"`),
|
|
serversPath: [serversKey, "servers"],
|
|
dialect: format,
|
|
environmentKey,
|
|
legacyServers: {},
|
|
};
|
|
}
|
|
const native = servers.servers;
|
|
// A V1 server named `servers` has a string `type` discriminator; the V2
|
|
// table may instead hold a server object named `type`.
|
|
if (
|
|
format !== "opencode" ||
|
|
native === undefined ||
|
|
(typeof native === "object" &&
|
|
native !== null &&
|
|
"type" in native &&
|
|
typeof native.type === "string")
|
|
)
|
|
return {
|
|
document,
|
|
servers,
|
|
serversPath: [serversKey],
|
|
dialect: format,
|
|
environmentKey,
|
|
legacyServers: {},
|
|
};
|
|
return {
|
|
document,
|
|
servers: requireObject(native, `"${serversKey}.servers"`),
|
|
serversPath: [serversKey, "servers"],
|
|
dialect: "opencode_v2",
|
|
environmentKey,
|
|
legacyServers: Object.fromEntries(
|
|
Object.entries(servers).filter(
|
|
([name]) => !OPENCODE_V2_MCP_SETTINGS.has(name),
|
|
),
|
|
),
|
|
};
|
|
};
|
|
|
|
/** The registration a client loads for `name`, including OpenCode V1 entries. */
|
|
export const effectiveClientServer = (
|
|
parsed: ClientConfigurationDocument,
|
|
name: string,
|
|
): unknown => {
|
|
const native = Object.hasOwn(parsed.servers, name);
|
|
const value = native
|
|
? parsed.servers[name]
|
|
: Object.hasOwn(parsed.legacyServers, name)
|
|
? parsed.legacyServers[name]
|
|
: undefined;
|
|
if (
|
|
(parsed.dialect !== "opencode" && parsed.dialect !== "opencode_v2") ||
|
|
typeof value !== "object" ||
|
|
value === null ||
|
|
Array.isArray(value)
|
|
)
|
|
return value;
|
|
const { enabled, ...registration } = value as Record<string, unknown>;
|
|
// OpenCode migrates legacy activation before merging layers. An enabled V1
|
|
// fragment must override inherited disabled:true; native V2 ignores enabled.
|
|
return (parsed.dialect === "opencode" || !native) &&
|
|
typeof enabled === "boolean"
|
|
? { ...registration, disabled: !enabled }
|
|
: registration;
|
|
};
|
|
|
|
/**
|
|
* Return `parsed.document` with `servers` at its server table. A legacy OpenCode
|
|
* V1 member named in `removeLegacy` is removed beside a native V2 table.
|
|
*/
|
|
export const withClientServers = (
|
|
parsed: ClientConfigurationDocument,
|
|
servers: Record<string, unknown>,
|
|
removeLegacy?: string,
|
|
): Record<string, unknown> => {
|
|
// The Harness patch list upserts or removes by entry id, not by table.
|
|
if (parsed.dialect === "dsh")
|
|
return Object.hasOwn(servers, PRODUCT_IDENTITY.mcpServerKey)
|
|
? editDshDocument(
|
|
parsed,
|
|
clientConfigurationRecordSchema.parse(
|
|
servers[PRODUCT_IDENTITY.mcpServerKey],
|
|
),
|
|
)
|
|
: editDshDocument(parsed);
|
|
const [key = "", nested] = parsed.serversPath;
|
|
if (nested === undefined) return { ...parsed.document, [key]: servers };
|
|
// A fresh document may not hold the outer table yet (for example a new
|
|
// ZCode `config.json` without an `mcp` object), so treat absence as empty.
|
|
const outerValue = parsed.document[key];
|
|
const outer =
|
|
outerValue === undefined
|
|
? {}
|
|
: clientConfigurationRecordSchema.parse(outerValue);
|
|
if (
|
|
parsed.dialect === "opencode_v2" &&
|
|
removeLegacy !== undefined &&
|
|
!OPENCODE_V2_MCP_SETTINGS.has(removeLegacy)
|
|
)
|
|
delete outer[removeLegacy];
|
|
return { ...parsed.document, [key]: { ...outer, [nested]: servers } };
|
|
};
|
|
|
|
/** Read the value at a key path, or `undefined` when any member is absent. */
|
|
const valueAt = (
|
|
document: Record<string, unknown>,
|
|
path: readonly string[],
|
|
): unknown => {
|
|
let current: unknown = document;
|
|
for (const key of path) {
|
|
const object = clientConfigurationRecordSchema.safeParse(current);
|
|
if (!object.success || !Object.hasOwn(object.data, key)) return undefined;
|
|
current = object.data[key];
|
|
}
|
|
return current;
|
|
};
|
|
|
|
type TomlStringMode = "none" | "ml-basic" | "ml-literal";
|
|
|
|
const TOML_BASIC_ESCAPES: Readonly<Record<string, string>> = {
|
|
b: "\b",
|
|
t: "\t",
|
|
n: "\n",
|
|
f: "\f",
|
|
r: "\r",
|
|
e: "\u001b",
|
|
'"': '"',
|
|
"\\": "\\",
|
|
};
|
|
|
|
/** A `[table]` or `[[array]]` header outside strings, with decoded key segments. */
|
|
interface TomlTableHeader {
|
|
readonly path: readonly string[];
|
|
}
|
|
|
|
const skipHorizontalSpace = (line: string, index: number): number => {
|
|
let next = index;
|
|
while (line[next] === " " || line[next] === "\t") next += 1;
|
|
return next;
|
|
};
|
|
|
|
const decodeBasicEscape = (
|
|
line: string,
|
|
slash: number,
|
|
): { readonly value: string; readonly next: number } | undefined => {
|
|
const escape = line[slash + 1];
|
|
if (escape === undefined) return undefined;
|
|
const simple = TOML_BASIC_ESCAPES[escape];
|
|
if (simple !== undefined) return { value: simple, next: slash + 2 };
|
|
// smol-toml accepts `\xHH` even though TOML 1.0 does not.
|
|
if (escape === "x") {
|
|
const hex = line.slice(slash + 2, slash + 4);
|
|
if (!/^[0-9A-Fa-f]{2}$/u.test(hex)) return undefined;
|
|
return {
|
|
value: String.fromCodePoint(Number.parseInt(hex, 16)),
|
|
next: slash + 4,
|
|
};
|
|
}
|
|
if (escape !== "u" && escape !== "U") return undefined;
|
|
const length = escape === "u" ? 4 : 8;
|
|
const hex = line.slice(slash + 2, slash + 2 + length);
|
|
if (!new RegExp(`^[0-9A-Fa-f]{${String(length)}}$`, "u").test(hex))
|
|
return undefined;
|
|
const code = Number.parseInt(hex, 16);
|
|
if (code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return undefined;
|
|
return { value: String.fromCodePoint(code), next: slash + 2 + length };
|
|
};
|
|
|
|
/** Read one single-line quoted key. Multiline quotes are not a header key. */
|
|
const parseQuotedKey = (
|
|
line: string,
|
|
start: number,
|
|
): { readonly value: string; readonly next: number } | undefined => {
|
|
const quote = line[start];
|
|
if (
|
|
(quote !== '"' && quote !== "'") ||
|
|
line.startsWith(quote === '"' ? '"""' : "'''", start)
|
|
)
|
|
return undefined;
|
|
if (quote === "'") {
|
|
const end = line.indexOf("'", start + 1);
|
|
if (end < 0 || line.slice(start + 1, end).includes("\n")) return undefined;
|
|
return { value: line.slice(start + 1, end), next: end + 1 };
|
|
}
|
|
let value = "";
|
|
let index = start + 1;
|
|
while (index < line.length) {
|
|
const char = line[index];
|
|
if (char === "\n" || char === "\r") return undefined;
|
|
if (char === '"') return { value, next: index + 1 };
|
|
if (char !== "\\") {
|
|
value += char ?? "";
|
|
index += 1;
|
|
continue;
|
|
}
|
|
const decoded = decodeBasicEscape(line, index);
|
|
if (decoded === undefined) return undefined;
|
|
value += decoded.value;
|
|
index = decoded.next;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const parseBareKey = (
|
|
line: string,
|
|
start: number,
|
|
): { readonly value: string; readonly next: number } | undefined => {
|
|
let index = start;
|
|
while (/[A-Za-z0-9_-]/u.test(line[index] ?? "")) index += 1;
|
|
if (index === start) return undefined;
|
|
return { value: line.slice(start, index), next: index };
|
|
};
|
|
|
|
/**
|
|
* Parse a table header. Quoted segments stay one key, so `mcp_servers."rea.env"`
|
|
* is not the dotted path `mcp_servers.rea.env`.
|
|
*/
|
|
const parseTomlTableHeader = (line: string): TomlTableHeader | undefined => {
|
|
let index = skipHorizontalSpace(line, 0);
|
|
const array = line.startsWith("[[", index);
|
|
if (!array && line[index] !== "[") return undefined;
|
|
index += array ? 2 : 1;
|
|
const path: string[] = [];
|
|
for (;;) {
|
|
index = skipHorizontalSpace(line, index);
|
|
const key =
|
|
line[index] === '"' || line[index] === "'"
|
|
? parseQuotedKey(line, index)
|
|
: parseBareKey(line, index);
|
|
if (key === undefined) return undefined;
|
|
path.push(key.value);
|
|
index = skipHorizontalSpace(line, key.next);
|
|
if (line[index] !== ".") break;
|
|
index += 1;
|
|
}
|
|
const close = array ? "]]" : "]";
|
|
if (!line.startsWith(close, index)) return undefined;
|
|
index = skipHorizontalSpace(line, index + close.length);
|
|
if (index < line.length && line[index] !== "#") return undefined;
|
|
return { path };
|
|
};
|
|
|
|
/**
|
|
* String state after one physical line, starting at `start`. A multiline
|
|
* closer does not end the scan: `""", """` closes one string and opens another.
|
|
*/
|
|
const tomlStringModeAfter = (
|
|
line: string,
|
|
mode: TomlStringMode,
|
|
start = 0,
|
|
): TomlStringMode => {
|
|
if (mode === "ml-basic" || mode === "ml-literal") {
|
|
const delimiter = mode === "ml-basic" ? '"""' : "'''";
|
|
const closed = closeMultiline(line, start, delimiter, mode === "ml-basic");
|
|
if (closed === undefined) return mode;
|
|
return tomlStringModeAfter(line, "none", closed);
|
|
}
|
|
let index = start;
|
|
while (index < line.length) {
|
|
if (line[index] === "#") return "none";
|
|
if (line.startsWith('"""', index)) {
|
|
const closed = closeMultiline(line, index + 3, '"""', true);
|
|
if (closed === undefined) return "ml-basic";
|
|
index = closed;
|
|
continue;
|
|
}
|
|
if (line.startsWith("'''", index)) {
|
|
const closed = closeMultiline(line, index + 3, "'''", false);
|
|
if (closed === undefined) return "ml-literal";
|
|
index = closed;
|
|
continue;
|
|
}
|
|
const quote = line[index];
|
|
if (quote === '"' || quote === "'") {
|
|
const quoted = parseQuotedKey(line, index);
|
|
if (quoted === undefined) return "none";
|
|
index = quoted.next;
|
|
continue;
|
|
}
|
|
index += 1;
|
|
}
|
|
return "none";
|
|
};
|
|
|
|
/** Index after a multiline delimiter, or undefined when this line does not close it. */
|
|
const closeMultiline = (
|
|
line: string,
|
|
start: number,
|
|
delimiter: '"""' | "'''",
|
|
escapes: boolean,
|
|
): number | undefined => {
|
|
let index = start;
|
|
while (index < line.length) {
|
|
if (escapes && line[index] === "\\") {
|
|
if (line[index + 1] === undefined) return undefined;
|
|
index += 2;
|
|
continue;
|
|
}
|
|
if (line.startsWith(delimiter, index)) {
|
|
// One or two quotes before the closer belong to the string: """keep""""
|
|
let end = index + delimiter.length;
|
|
const quote = delimiter[0];
|
|
let extra = 0;
|
|
while (extra < 2 && line[end] === quote) {
|
|
extra += 1;
|
|
end += 1;
|
|
}
|
|
return end;
|
|
}
|
|
index += 1;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const isBlankOrComment = (line: string): boolean => {
|
|
const trimmed = line.trimStart();
|
|
return trimmed === "" || trimmed.startsWith("#");
|
|
};
|
|
|
|
const looksLikeTomlHeader = (line: string): boolean =>
|
|
line.trimStart().startsWith("[");
|
|
|
|
/** `mcp_servers.<server>` and tables nested under it, compared by key segment. */
|
|
const isTomlServerTable = (
|
|
path: readonly string[],
|
|
serverKey: string,
|
|
): boolean => path[0] === "mcp_servers" && path[1] === serverKey;
|
|
|
|
const parseKeyPath = (
|
|
text: string,
|
|
start: number,
|
|
):
|
|
| { readonly segments: readonly string[]; readonly next: number }
|
|
| undefined => {
|
|
const segments: string[] = [];
|
|
let index = start;
|
|
for (;;) {
|
|
index = skipHorizontalSpace(text, index);
|
|
const current = text[index];
|
|
if (current === undefined || current === "\n" || current === "\r")
|
|
return undefined;
|
|
const key =
|
|
current === '"' || current === "'"
|
|
? parseQuotedKey(text, index)
|
|
: parseBareKey(text, index);
|
|
if (key === undefined) return undefined;
|
|
segments.push(key.value);
|
|
index = skipHorizontalSpace(text, key.next);
|
|
if (text[index] !== ".") return { segments, next: index };
|
|
index += 1;
|
|
}
|
|
};
|
|
|
|
const parseAssignment = (
|
|
line: string,
|
|
):
|
|
| { readonly segments: readonly string[]; readonly equals: number }
|
|
| undefined => {
|
|
const key = parseKeyPath(line, 0);
|
|
if (key === undefined) return undefined;
|
|
const equals = skipHorizontalSpace(line, key.next);
|
|
if (line[equals] !== "=") return undefined;
|
|
return { segments: key.segments, equals };
|
|
};
|
|
|
|
const skipWsAndComments = (text: string, start: number): number => {
|
|
let index = start;
|
|
while (index < text.length) {
|
|
const char = text[index];
|
|
if (char === " " || char === "\t" || char === "\n" || char === "\r") {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (char === "#") {
|
|
while (index < text.length && text[index] !== "\n") index += 1;
|
|
continue;
|
|
}
|
|
return index;
|
|
}
|
|
return index;
|
|
};
|
|
|
|
/** Index after one TOML value. Containers and multiline strings may cross lines. */
|
|
const endOfTomlValue = (text: string, start: number): number => {
|
|
const index = skipWsAndComments(text, start);
|
|
if (index >= text.length) return index;
|
|
if (text.startsWith('"""', index)) {
|
|
const closed = closeMultiline(text, index + 3, '"""', true);
|
|
return closed ?? text.length;
|
|
}
|
|
if (text.startsWith("'''", index)) {
|
|
const closed = closeMultiline(text, index + 3, "'''", false);
|
|
return closed ?? text.length;
|
|
}
|
|
const quote = text[index];
|
|
if (quote === '"' || quote === "'") {
|
|
const quoted = parseQuotedKey(text, index);
|
|
return quoted === undefined ? text.length : quoted.next;
|
|
}
|
|
if (quote === "{" || quote === "[") return endOfContainer(text, index);
|
|
let cursor = index;
|
|
while (cursor < text.length) {
|
|
const char = text[cursor];
|
|
if (
|
|
char === "\n" ||
|
|
char === "#" ||
|
|
char === "," ||
|
|
char === "}" ||
|
|
char === "]"
|
|
)
|
|
break;
|
|
cursor += 1;
|
|
}
|
|
return cursor;
|
|
};
|
|
|
|
const endOfContainer = (text: string, open: number): number => {
|
|
const closers: string[] = [text[open] === "{" ? "}" : "]"];
|
|
let index = open + 1;
|
|
while (index < text.length && closers.length > 0) {
|
|
if (text.startsWith('"""', index)) {
|
|
const closed = closeMultiline(text, index + 3, '"""', true);
|
|
if (closed === undefined) return text.length;
|
|
index = closed;
|
|
continue;
|
|
}
|
|
if (text.startsWith("'''", index)) {
|
|
const closed = closeMultiline(text, index + 3, "'''", false);
|
|
if (closed === undefined) return text.length;
|
|
index = closed;
|
|
continue;
|
|
}
|
|
const quote = text[index];
|
|
if (quote === '"' || quote === "'") {
|
|
const quoted = parseQuotedKey(text, index);
|
|
if (quoted === undefined) return text.length;
|
|
index = quoted.next;
|
|
continue;
|
|
}
|
|
if (quote === "#") {
|
|
while (index < text.length && text[index] !== "\n") index += 1;
|
|
continue;
|
|
}
|
|
if (quote === "{" || quote === "[") {
|
|
closers.push(quote === "{" ? "}" : "]");
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (quote === closers[closers.length - 1]) {
|
|
closers.pop();
|
|
index += 1;
|
|
continue;
|
|
}
|
|
index += 1;
|
|
}
|
|
return index;
|
|
};
|
|
|
|
/** TOML basic string. JSON leaves U+007F raw, which TOML rejects. */
|
|
const tomlBasicString = (value: string): string =>
|
|
JSON.stringify(value).replaceAll("\u007f", "\\u007f");
|
|
|
|
const tomlInlineKey = (key: string): string =>
|
|
/^[A-Za-z0-9_-]+$/u.test(key) ? key : tomlBasicString(key);
|
|
|
|
const tomlInlineValue = (value: unknown): string => {
|
|
if (typeof value === "string") return tomlBasicString(value);
|
|
if (typeof value === "boolean") return value ? "true" : "false";
|
|
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
if (typeof value === "bigint") return String(value);
|
|
if (Array.isArray(value))
|
|
return `[${value.map((item) => tomlInlineValue(item)).join(", ")}]`;
|
|
if (typeof value === "object" && value !== null) {
|
|
const body = Object.entries(value)
|
|
.map(
|
|
([key, nested]) => `${tomlInlineKey(key)} = ${tomlInlineValue(nested)}`,
|
|
)
|
|
.join(", ");
|
|
return `{ ${body} }`;
|
|
}
|
|
return '""';
|
|
};
|
|
|
|
interface InlinePair {
|
|
readonly leading: string;
|
|
readonly text: string;
|
|
readonly drop: boolean;
|
|
}
|
|
|
|
const readTrivia = (
|
|
text: string,
|
|
start: number,
|
|
limit: number,
|
|
): { readonly text: string; readonly next: number } => {
|
|
let index = start;
|
|
while (index < limit) {
|
|
const char = text[index];
|
|
if (
|
|
char === " " ||
|
|
char === "\t" ||
|
|
char === "\n" ||
|
|
char === "\r" ||
|
|
char === ","
|
|
) {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (char === "#") {
|
|
while (index < limit && text[index] !== "\n") index += 1;
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
return { text: text.slice(start, index), next: index };
|
|
};
|
|
|
|
const commasOutsideComments = (trivia: string): string =>
|
|
trivia
|
|
.split("\n")
|
|
.map((part) => {
|
|
const comment = part.indexOf("#");
|
|
if (comment < 0) return part.replaceAll(",", "");
|
|
return `${part.slice(0, comment).replaceAll(",", "")}${part.slice(comment)}`;
|
|
})
|
|
.join("\n");
|
|
|
|
const hasSeparatorComma = (trivia: string): boolean =>
|
|
trivia.split("\n").some((part) => {
|
|
const comment = part.indexOf("#");
|
|
const code = comment < 0 ? part : part.slice(0, comment);
|
|
return code.includes(",");
|
|
});
|
|
|
|
/**
|
|
* Replace or remove `serverKey` inside one `{ ... }` table. A closed inline
|
|
* table cannot later grow a `[mcp_servers.rea]` header.
|
|
*/
|
|
const editInlineServerTable = (
|
|
table: string,
|
|
serverKey: string,
|
|
entry: unknown,
|
|
):
|
|
| { readonly text: string; readonly comments: readonly string[] }
|
|
| undefined => {
|
|
if (!table.startsWith("{")) return undefined;
|
|
const end = endOfContainer(table, 0);
|
|
if (table[end - 1] !== "}") return undefined;
|
|
const pairs: InlinePair[] = [];
|
|
const comments: string[] = [];
|
|
let index = 1;
|
|
let trailing = "";
|
|
while (index < end - 1) {
|
|
const trivia = readTrivia(table, index, end - 1);
|
|
if (trivia.next >= end - 1) {
|
|
trailing = trivia.text;
|
|
break;
|
|
}
|
|
const key = parseKeyPath(table, trivia.next);
|
|
if (key === undefined) return undefined;
|
|
const equals = skipWsAndComments(table, key.next);
|
|
if (table[equals] !== "=") return undefined;
|
|
const valueEnd = endOfTomlValue(table, equals + 1);
|
|
if (valueEnd > end - 1) return undefined;
|
|
const pairText = table.slice(trivia.next, valueEnd);
|
|
const drop = key.segments[0] === serverKey;
|
|
if (drop)
|
|
for (const comment of tomlCommentLines(pairText)) comments.push(comment);
|
|
pairs.push({
|
|
leading: trivia.text,
|
|
text: pairText,
|
|
drop,
|
|
});
|
|
index = valueEnd;
|
|
}
|
|
const insertion =
|
|
entry === undefined
|
|
? undefined
|
|
: `${tomlInlineKey(serverKey)} = ${tomlInlineValue(entry)}`;
|
|
if (insertion === undefined && pairs.every((pair) => !pair.drop))
|
|
return { text: table, comments };
|
|
return { text: rebuildInlineTable(pairs, trailing, insertion), comments };
|
|
};
|
|
|
|
const rebuildInlineTable = (
|
|
pairs: readonly InlinePair[],
|
|
trailing: string,
|
|
insertion: string | undefined,
|
|
): string => rebuildDelimitedEntries(pairs, trailing, insertion, "{", "}");
|
|
|
|
/**
|
|
* Rebuild `{ ... }` or `[ ... ]` after dropping entries. An empty container
|
|
* keeps comment trivia; a table with no comments stays `{}`.
|
|
*/
|
|
const rebuildDelimitedEntries = (
|
|
pairs: readonly InlinePair[],
|
|
trailing: string,
|
|
insertion: string | undefined,
|
|
open: "{" | "[",
|
|
close: "}" | "]",
|
|
): string => {
|
|
const parts: string[] = [open];
|
|
let emitted = false;
|
|
// A comma already emitted in a dropped pair's comment trivia.
|
|
let separatorPending = false;
|
|
for (const pair of pairs) {
|
|
if (pair.drop) {
|
|
if (!pair.leading.includes("#")) continue;
|
|
// A separator already emitted must not be repeated for the next field.
|
|
const dropped: string =
|
|
!emitted || separatorPending
|
|
? commasOutsideComments(pair.leading)
|
|
: pair.leading;
|
|
parts.push(dropped);
|
|
separatorPending = separatorPending || hasSeparatorComma(dropped);
|
|
continue;
|
|
}
|
|
const kept = emitted ? pair.leading : commasOutsideComments(pair.leading);
|
|
parts.push(separatorPending ? commasOutsideComments(kept) : kept);
|
|
if (emitted && !separatorPending && !pair.leading.includes(","))
|
|
parts.push(", ");
|
|
separatorPending = false;
|
|
parts.push(pair.text);
|
|
emitted = true;
|
|
}
|
|
if (insertion !== undefined) {
|
|
if (!(emitted && separatorPending)) parts.push(emitted ? ", " : " ");
|
|
parts.push(insertion);
|
|
emitted = true;
|
|
}
|
|
if (!emitted) {
|
|
const retained = `${parts.slice(1).join("")}${commasOutsideComments(trailing)}`;
|
|
return retained.includes("#")
|
|
? `${open}${retained}${close}`
|
|
: `${open}${close}`;
|
|
}
|
|
const tail = commasOutsideComments(trailing);
|
|
parts.push(tail);
|
|
if (tail === "" && open === "{") parts.push(" ");
|
|
parts.push(close);
|
|
return parts.join("");
|
|
};
|
|
|
|
/**
|
|
* Decoded TOML string, or undefined when the token is not a string.
|
|
* The document parser accepts multiline basic and literal strings.
|
|
*/
|
|
const tomlStringValue = (text: string): string | undefined => {
|
|
try {
|
|
const value = parseClientToml(`value = ${text}`)["value"];
|
|
return typeof value === "string" ? value : undefined;
|
|
} catch (cause: unknown) {
|
|
void cause;
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Array text with `serverKey` removed. `""` means the assignment itself can
|
|
* be deleted. Undefined when the array cannot be edited safely.
|
|
*/
|
|
const tomlArrayWithoutString = (
|
|
array: string,
|
|
serverKey: string,
|
|
): string | undefined => {
|
|
if (!array.startsWith("[")) return undefined;
|
|
const end = endOfContainer(array, 0);
|
|
if (end !== array.length || array[end - 1] !== "]") return undefined;
|
|
const pairs: InlinePair[] = [];
|
|
let index = 1;
|
|
let trailing = "";
|
|
while (index < end - 1) {
|
|
const trivia = readTrivia(array, index, end - 1);
|
|
if (trivia.next >= end - 1) {
|
|
trailing = trivia.text;
|
|
break;
|
|
}
|
|
const valueEnd = endOfTomlValue(array, trivia.next);
|
|
if (valueEnd > end - 1) return undefined;
|
|
const text = array.slice(trivia.next, valueEnd);
|
|
pairs.push({
|
|
leading: trivia.text,
|
|
text,
|
|
drop: tomlStringValue(text) === serverKey,
|
|
});
|
|
index = valueEnd;
|
|
}
|
|
if (pairs.every((pair) => !pair.drop)) return array;
|
|
const rebuilt = rebuildDelimitedEntries(pairs, trailing, undefined, "[", "]");
|
|
return rebuilt === "[]" ? "" : rebuilt;
|
|
};
|
|
|
|
type DisabledServerListEdit =
|
|
| { readonly kind: "ignore" }
|
|
| { readonly kind: "keep" }
|
|
| { readonly kind: "delete"; readonly comment: string | undefined }
|
|
| { readonly kind: "replace"; readonly text: string };
|
|
|
|
interface DisabledServerListInput {
|
|
readonly text: string;
|
|
readonly lineStart: number;
|
|
readonly valueOffset: number;
|
|
readonly valueEnd: number;
|
|
readonly statementEnd: number;
|
|
readonly tablePath: readonly string[];
|
|
readonly segments: readonly string[];
|
|
readonly serverKey: string;
|
|
readonly entry: unknown;
|
|
}
|
|
|
|
/** Drop `serverKey` from a root `disabled_mcp_servers` array while installing. */
|
|
const disabledServerListEdit = (
|
|
input: DisabledServerListInput,
|
|
): DisabledServerListEdit => {
|
|
if (
|
|
input.entry === undefined ||
|
|
input.tablePath.length !== 0 ||
|
|
input.segments.length !== 1 ||
|
|
input.segments[0] !== "disabled_mcp_servers"
|
|
)
|
|
return { kind: "ignore" };
|
|
const open = skipWsAndComments(input.text, input.valueOffset);
|
|
if (input.text[open] !== "[") return { kind: "ignore" };
|
|
const arrayText = input.text.slice(open, input.valueEnd);
|
|
const edited = tomlArrayWithoutString(arrayText, input.serverKey);
|
|
if (edited === undefined || edited === arrayText) return { kind: "keep" };
|
|
if (edited === "") {
|
|
const suffix = input.text.slice(input.valueEnd, input.statementEnd);
|
|
const hash = suffix.indexOf("#");
|
|
return {
|
|
kind: "delete",
|
|
comment: hash < 0 ? undefined : suffix.slice(hash),
|
|
};
|
|
}
|
|
return {
|
|
kind: "replace",
|
|
text: `${input.text.slice(input.lineStart, open)}${edited}${input.text.slice(input.valueEnd, input.statementEnd)}`,
|
|
};
|
|
};
|
|
|
|
/** Lines that replace a disabled-server assignment, or undefined to leave it. */
|
|
const disabledServerListLines = (
|
|
edit: DisabledServerListEdit,
|
|
lines: readonly string[],
|
|
lineIndex: number,
|
|
endLine: number,
|
|
): readonly string[] | undefined => {
|
|
switch (edit.kind) {
|
|
case "ignore":
|
|
return undefined;
|
|
case "keep":
|
|
return lines.slice(lineIndex, endLine + 1);
|
|
case "replace":
|
|
return edit.text.split("\n");
|
|
case "delete":
|
|
return edit.comment === undefined ? [] : [edit.comment];
|
|
default: {
|
|
const unreachable: never = edit;
|
|
return unreachable;
|
|
}
|
|
}
|
|
};
|
|
|
|
/** Strip terminal LF without rescanning internal blank-line runs. */
|
|
const withoutTrailingLineFeeds = (text: string): string => {
|
|
let end = text.length;
|
|
while (end > 0 && text.charCodeAt(end - 1) === 10) end -= 1;
|
|
return text.slice(0, end);
|
|
};
|
|
|
|
/** Retain comment trivia from removed code without promoting string contents. */
|
|
const tomlCommentLines = (text: string): readonly string[] => {
|
|
const comments: string[] = [];
|
|
let index = 0;
|
|
while (index < text.length) {
|
|
if (text.startsWith('"""', index) || text.startsWith("'''", index)) {
|
|
const basic = text[index] === '"';
|
|
index =
|
|
closeMultiline(text, index + 3, basic ? '"""' : "'''", basic) ??
|
|
text.length;
|
|
} else if (text[index] === '"' || text[index] === "'") {
|
|
index = parseQuotedKey(text, index)?.next ?? text.length;
|
|
} else if (text[index] === "#") {
|
|
const end = text.indexOf("\n", index);
|
|
comments.push(text.slice(index, end < 0 ? text.length : end));
|
|
index = end < 0 ? text.length : end + 1;
|
|
} else index += 1;
|
|
}
|
|
return comments;
|
|
};
|
|
|
|
/**
|
|
* Replace REA's TOML server tables and assignments. Every comment line stays
|
|
* where it was, including comments before, inside, and after REA's own table;
|
|
* inline comments on replaced code become standalone comment lines.
|
|
* Only blank lines that separated a removed table go with it. Text inside
|
|
* strings is not treated as a header. Only Grok Build reads the root
|
|
* `disabled_mcp_servers` list that registration also edits.
|
|
*/
|
|
const upsertTomlServerSection = (
|
|
originalText: string,
|
|
serverKey: string,
|
|
entry: unknown,
|
|
format: "toml" | "grok",
|
|
): string => {
|
|
// smol-toml accepts a leading BOM. Leave it in place or the first header is missed.
|
|
const bom = originalText.startsWith("\uFEFF");
|
|
const source = bom ? originalText.slice(1) : originalText;
|
|
// Keep a preceding \r on each line. Rewriting \r\n in the whole file would
|
|
// change string values. A file whose every break is CRLF gets a CRLF section.
|
|
const crlf =
|
|
source.includes("\r\n") && !source.replaceAll("\r\n", "").includes("\n");
|
|
const text = source;
|
|
const lines = text.split("\n");
|
|
const lineStarts: number[] = [];
|
|
let cursor = 0;
|
|
for (const line of lines) {
|
|
lineStarts.push(cursor);
|
|
cursor += line.length + 1;
|
|
}
|
|
const kept: string[] = [];
|
|
// Blank lines held until the next statement shows whether they belong to a
|
|
// removed REA table. Comments are user text and are never held.
|
|
const pending: string[] = [];
|
|
let skipping = false;
|
|
let inlineServerTable = false;
|
|
let mode: TomlStringMode = "none";
|
|
let tablePath: readonly string[] = [];
|
|
const discardPending = (): void => {
|
|
pending.length = 0;
|
|
};
|
|
// Document line counts must not become engine-limited argument counts.
|
|
const appendLines = (lines: readonly string[]): void => {
|
|
for (const line of lines) kept.push(line);
|
|
};
|
|
const flushPending = (): void => {
|
|
appendLines(pending);
|
|
pending.length = 0;
|
|
};
|
|
let lineIndex = 0;
|
|
while (lineIndex < lines.length) {
|
|
const physical = lines[lineIndex] ?? "";
|
|
const line = physical.endsWith("\r") ? physical.slice(0, -1) : physical;
|
|
if (mode !== "none") {
|
|
mode = tomlStringModeAfter(line, mode);
|
|
if (skipping) discardPending();
|
|
else kept.push(physical);
|
|
lineIndex += 1;
|
|
continue;
|
|
}
|
|
const header = parseTomlTableHeader(line);
|
|
if (header !== undefined) {
|
|
tablePath = header.path;
|
|
if (isTomlServerTable(header.path, serverKey)) {
|
|
discardPending();
|
|
appendLines(tomlCommentLines(physical));
|
|
skipping = true;
|
|
lineIndex += 1;
|
|
continue;
|
|
}
|
|
skipping = false;
|
|
flushPending();
|
|
kept.push(physical);
|
|
lineIndex += 1;
|
|
continue;
|
|
}
|
|
const assignment = parseAssignment(line);
|
|
if (assignment !== undefined) {
|
|
const lineStart = lineStarts[lineIndex] ?? 0;
|
|
const valueOffset = lineStart + assignment.equals + 1;
|
|
const valueEnd = endOfTomlValue(text, valueOffset);
|
|
let statementEnd = valueEnd;
|
|
while (statementEnd < text.length && text[statementEnd] !== "\n")
|
|
statementEnd += 1;
|
|
const lastOffset = Math.max(statementEnd - 1, lineStart);
|
|
let endLine = lineIndex;
|
|
// Statements advance through the source; only inspect their own lines.
|
|
while (
|
|
endLine + 1 < lineStarts.length &&
|
|
(lineStarts[endLine + 1] ?? 0) <= lastOffset
|
|
)
|
|
endLine += 1;
|
|
if (skipping) {
|
|
discardPending();
|
|
appendLines(tomlCommentLines(text.slice(lineStart, statementEnd)));
|
|
lineIndex = endLine + 1;
|
|
mode = "none";
|
|
continue;
|
|
}
|
|
const disabledLines =
|
|
format === "grok"
|
|
? disabledServerListLines(
|
|
disabledServerListEdit({
|
|
text,
|
|
lineStart,
|
|
valueOffset,
|
|
valueEnd,
|
|
statementEnd,
|
|
tablePath,
|
|
segments: assignment.segments,
|
|
serverKey,
|
|
entry,
|
|
}),
|
|
lines,
|
|
lineIndex,
|
|
endLine,
|
|
)
|
|
: undefined;
|
|
if (disabledLines !== undefined) {
|
|
flushPending();
|
|
appendLines(disabledLines);
|
|
lineIndex = endLine + 1;
|
|
mode = "none";
|
|
continue;
|
|
}
|
|
const absolute = [...tablePath, ...assignment.segments];
|
|
const open = skipWsAndComments(text, valueOffset);
|
|
const closesServerTable =
|
|
absolute.length === 1 &&
|
|
absolute[0] === "mcp_servers" &&
|
|
text[open] === "{";
|
|
if (closesServerTable || isTomlServerTable(absolute, serverKey)) {
|
|
if (closesServerTable) {
|
|
flushPending();
|
|
const edited = editInlineServerTable(
|
|
text.slice(open, valueEnd),
|
|
serverKey,
|
|
entry,
|
|
);
|
|
const prefix = text.slice(lineStart, open);
|
|
const suffix = text.slice(valueEnd, statementEnd);
|
|
const replacement =
|
|
edited === undefined
|
|
? text.slice(lineStart, statementEnd)
|
|
: `${prefix}${edited.text}${suffix}`;
|
|
appendLines(replacement.split("\n"));
|
|
// TOML 1.0 inline tables cannot gain standalone comments between
|
|
// their fields. Retain removed value comments after the assignment.
|
|
if (edited !== undefined) appendLines(edited.comments);
|
|
inlineServerTable = true;
|
|
} else {
|
|
discardPending();
|
|
appendLines(tomlCommentLines(text.slice(lineStart, statementEnd)));
|
|
}
|
|
} else {
|
|
flushPending();
|
|
for (let index = lineIndex; index <= endLine; index += 1)
|
|
kept.push(lines[index] ?? "");
|
|
}
|
|
lineIndex = endLine + 1;
|
|
mode = "none";
|
|
continue;
|
|
}
|
|
if (isBlankOrComment(line)) {
|
|
if (line.trimStart().startsWith("#")) {
|
|
flushPending();
|
|
kept.push(physical);
|
|
} else pending.push(physical);
|
|
lineIndex += 1;
|
|
continue;
|
|
}
|
|
mode = tomlStringModeAfter(line, "none");
|
|
if (skipping) {
|
|
if (looksLikeTomlHeader(line)) {
|
|
flushPending();
|
|
skipping = false;
|
|
tablePath = ["\0"];
|
|
kept.push(physical);
|
|
} else discardPending();
|
|
} else {
|
|
flushPending();
|
|
kept.push(physical);
|
|
}
|
|
lineIndex += 1;
|
|
}
|
|
if (!skipping) flushPending();
|
|
let body = kept.join("\n");
|
|
// The last kept CRLF line has no following LF from the join.
|
|
if (body.endsWith("\r")) body += "\n";
|
|
if (entry !== undefined && !inlineServerTable) {
|
|
let fragment = withoutTrailingLineFeeds(
|
|
stringifyToml({ mcp_servers: { [serverKey]: entry } }),
|
|
);
|
|
if (crlf) fragment = fragment.replaceAll("\n", "\r\n");
|
|
const nl = crlf ? "\r\n" : "\n";
|
|
const endedWithNewline = body.endsWith("\n");
|
|
const base = withoutTrailingLineFeeds(body);
|
|
const trimmed =
|
|
endedWithNewline && base.endsWith("\r") ? base.slice(0, -1) : base;
|
|
body =
|
|
trimmed.length === 0
|
|
? `${fragment}${nl}`
|
|
: `${trimmed}${nl}${nl}${fragment}${nl}`;
|
|
}
|
|
// Removing a final table also drops the file's last line break.
|
|
if (originalText.endsWith("\n") && !body.endsWith("\n"))
|
|
body += originalText.endsWith("\r\n") ? "\r\n" : "\n";
|
|
return bom ? `\uFEFF${body}` : body;
|
|
};
|
|
|
|
const tomlServerEntry = (
|
|
document: Record<string, unknown>,
|
|
serverKey: string,
|
|
): unknown => {
|
|
const servers = clientConfigurationRecordSchema.safeParse(
|
|
document.mcp_servers,
|
|
);
|
|
if (!servers.success || !Object.hasOwn(servers.data, serverKey))
|
|
return undefined;
|
|
return servers.data[serverKey];
|
|
};
|
|
|
|
/** Whether Grok Build's root disable list names `serverKey`. */
|
|
const grokServerListedDisabled = (
|
|
document: Record<string, unknown>,
|
|
serverKey: string,
|
|
): boolean =>
|
|
Array.isArray(document.disabled_mcp_servers) &&
|
|
document.disabled_mcp_servers.includes(serverKey);
|
|
|
|
/**
|
|
* OMP's user `disabledServers` denylist hides a server by name regardless of
|
|
* its entry, so a listed REA registration is not loaded.
|
|
*/
|
|
export const OMP_DISABLED_SERVERS_KEY = "disabledServers";
|
|
|
|
/** Whether OMP's root `disabledServers` denylist names `serverKey`. */
|
|
const ompServerListedDisabled = (
|
|
document: Record<string, unknown>,
|
|
serverKey: string,
|
|
): boolean => {
|
|
const listed = document[OMP_DISABLED_SERVERS_KEY];
|
|
return Array.isArray(listed) && listed.includes(serverKey);
|
|
};
|
|
|
|
/** OMP's user `enabledServers` allowlist overrides an entry's `enabled: false`. */
|
|
const OMP_ENABLED_SERVERS_KEY = "enabledServers";
|
|
|
|
/**
|
|
* Whether OMP's allowlist forces `serverKey` on despite its entry's
|
|
* `enabled: false`. The `disabledServers` denylist still wins over it.
|
|
*/
|
|
export const clientServerForcedEnabled = (
|
|
parsed: ClientConfigurationDocument,
|
|
serverKey: string,
|
|
): boolean => {
|
|
if (parsed.dialect !== "omp") return false;
|
|
const listed = parsed.document[OMP_ENABLED_SERVERS_KEY];
|
|
return Array.isArray(listed) && listed.includes(serverKey);
|
|
};
|
|
|
|
/** Whether a client's own disable list suppresses `serverKey`. */
|
|
export const clientServerListedDisabled = (
|
|
parsed: ClientConfigurationDocument,
|
|
serverKey: string,
|
|
): boolean =>
|
|
(parsed.dialect === "grok" &&
|
|
grokServerListedDisabled(parsed.document, serverKey)) ||
|
|
(parsed.dialect === "omp" &&
|
|
ompServerListedDisabled(parsed.document, serverKey));
|
|
|
|
/** Keep an existing Grok document intact aside from the REA server tables. */
|
|
const serializeGrokConfiguration = (
|
|
document: Record<string, unknown>,
|
|
originalText: string | undefined,
|
|
): string => {
|
|
const entry = tomlServerEntry(document, PRODUCT_IDENTITY.mcpServerKey);
|
|
if (originalText === undefined) return stringifyToml(document);
|
|
return upsertTomlServerSection(
|
|
originalText,
|
|
PRODUCT_IDENTITY.mcpServerKey,
|
|
entry,
|
|
"grok",
|
|
);
|
|
};
|
|
|
|
/** Removing the last server leaves an empty table, which equals an absent one. */
|
|
const withoutEmptyServerTable = (
|
|
document: Record<string, unknown>,
|
|
): Record<string, unknown> => {
|
|
const servers = clientConfigurationRecordSchema.safeParse(
|
|
document.mcp_servers,
|
|
);
|
|
if (!servers.success || Object.keys(servers.data).length > 0) return document;
|
|
const { mcp_servers: empty, ...rest } = document;
|
|
void empty;
|
|
return rest;
|
|
};
|
|
|
|
/**
|
|
* Keep an existing Codex document intact aside from the REA server tables.
|
|
* An edit that would change any other parsed value falls back to a full
|
|
* rewrite, which keeps every value but not comments or spelling.
|
|
*/
|
|
const serializeCodexConfiguration = (
|
|
document: Record<string, unknown>,
|
|
originalText: string | undefined,
|
|
): string => {
|
|
if (
|
|
originalText === undefined ||
|
|
isEmptyClientConfigurationText(originalText)
|
|
)
|
|
return stringifyToml(document);
|
|
const edited = upsertTomlServerSection(
|
|
originalText,
|
|
PRODUCT_IDENTITY.mcpServerKey,
|
|
tomlServerEntry(document, PRODUCT_IDENTITY.mcpServerKey),
|
|
"toml",
|
|
);
|
|
return tomlTextHolds(edited, document) ? edited : stringifyToml(document);
|
|
};
|
|
|
|
const tomlTextHolds = (
|
|
text: string,
|
|
document: Record<string, unknown>,
|
|
): boolean => {
|
|
try {
|
|
return clientConfigurationValuesEqual(
|
|
withoutEmptyServerTable(
|
|
clientConfigurationRecordSchema.parse(parseClientToml(text)),
|
|
),
|
|
withoutEmptyServerTable(document),
|
|
);
|
|
} catch (cause: unknown) {
|
|
// An unparsable edit is rejected in favor of the full rewrite.
|
|
void cause;
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// jsonc-parser declares SyntaxKind as an ambient const enum, so keep its token
|
|
// code local and type-checked as in the JSON scanner used by IDA diagnostics.
|
|
const JSON_COMMA = 5 satisfies SyntaxKind;
|
|
|
|
/** Find the next non-trivia JSONC token at or after `offset`. */
|
|
const nextJsoncToken = (
|
|
text: string,
|
|
offset: number,
|
|
): { readonly kind: SyntaxKind; readonly offset: number } => {
|
|
const scanner = createScanner(text, true);
|
|
scanner.setPosition(offset);
|
|
const kind = scanner.scan();
|
|
return { kind, offset: scanner.getTokenOffset() };
|
|
};
|
|
|
|
const isJsonMember = (property: Node, key: string): boolean =>
|
|
property.children?.length === 2 && property.children[0]?.value === key;
|
|
|
|
/**
|
|
* The property a client reads for `key`. `JSON.parse` and JSONC `parse` keep
|
|
* the last duplicate member, while jsonc-parser's `findNodeAtLocation` and
|
|
* `modify` select the first, so JSON text edits locate members only here.
|
|
*/
|
|
const effectiveJsonProperty = (object: Node, key: string): Node | undefined =>
|
|
object.children?.findLast((property) => isJsonMember(property, key));
|
|
|
|
/** The object a client reads at `path`, following the effective members. */
|
|
const effectiveJsonObject = (
|
|
root: Node | undefined,
|
|
path: readonly string[],
|
|
): Node | undefined => {
|
|
let node = root;
|
|
for (const key of path) {
|
|
if (node?.type !== "object") return undefined;
|
|
node = effectiveJsonProperty(node, key)?.children?.[1];
|
|
}
|
|
return node?.type === "object" ? node : undefined;
|
|
};
|
|
|
|
/**
|
|
* Earlier members of one server name repeated in the tables a client reads,
|
|
* in document order. Parsing keeps only the last member, as clients do.
|
|
*/
|
|
export interface ShadowedClientServers {
|
|
readonly servers: readonly unknown[];
|
|
/** Repeats among OpenCode V1 members beside a native V2 table. */
|
|
readonly legacyServers: readonly unknown[];
|
|
}
|
|
|
|
const NO_SHADOWED_SERVERS: ShadowedClientServers = Object.freeze({
|
|
servers: [],
|
|
legacyServers: [],
|
|
});
|
|
|
|
// Token codes follow the pinned jsonc-parser scanner, as JSON_COMMA does.
|
|
const JSON_STRING = 10 satisfies SyntaxKind;
|
|
const JSON_EOF = 17 satisfies SyntaxKind;
|
|
|
|
/** A conservative duplicate check using the parser's own string decoder. */
|
|
const hasRepeatedJsonString = (text: string, name: string): boolean => {
|
|
const scanner = createScanner(text, true);
|
|
let matches = 0;
|
|
for (let token = scanner.scan(); token !== JSON_EOF; token = scanner.scan()) {
|
|
if (token === JSON_STRING && scanner.getTokenValue() === name) {
|
|
matches += 1;
|
|
if (matches === 2) return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
/** Values of the earlier members named `name` in `object`. */
|
|
const shadowedJsonMembers = (
|
|
object: Node | undefined,
|
|
name: string,
|
|
): readonly unknown[] =>
|
|
(object?.children ?? [])
|
|
.filter((property) => isJsonMember(property, name))
|
|
.slice(0, -1)
|
|
.map((property): unknown => {
|
|
const value = property.children?.[1];
|
|
return value === undefined ? undefined : getNodeValue(value);
|
|
});
|
|
|
|
/**
|
|
* Earlier members of `name` that removing its effective entry would make
|
|
* effective, so a removal decision must cover them too. Locating them needs a
|
|
* syntax tree of the whole document, which is built only when the name's
|
|
* string token occurs more than once.
|
|
*/
|
|
export const shadowedClientServers = (
|
|
text: string,
|
|
parsed: ClientConfigurationDocument,
|
|
name: string,
|
|
): ShadowedClientServers => {
|
|
// TOML rejects repeated keys, Hermes parses YAML with unique keys, and the
|
|
// Harness owner edits REA's entry by id rather than through a server table.
|
|
if (
|
|
parsed.dialect === "toml" ||
|
|
parsed.dialect === "grok" ||
|
|
parsed.dialect === "hermes" ||
|
|
parsed.dialect === "dsh" ||
|
|
!hasRepeatedJsonString(text, name)
|
|
)
|
|
return NO_SHADOWED_SERVERS;
|
|
const root = parseTree(text, [], { allowTrailingComma: true });
|
|
return {
|
|
servers: shadowedJsonMembers(
|
|
effectiveJsonObject(root, parsed.serversPath),
|
|
name,
|
|
),
|
|
legacyServers:
|
|
parsed.dialect === "opencode_v2" && !OPENCODE_V2_MCP_SETTINGS.has(name)
|
|
? shadowedJsonMembers(
|
|
effectiveJsonObject(root, parsed.serversPath.slice(0, 1)),
|
|
name,
|
|
)
|
|
: [],
|
|
};
|
|
};
|
|
|
|
/** Apply one edit and format the lines it touched, as `modify` does. */
|
|
const applyFormattedJsonEdit = (text: string, edit: Edit): string => {
|
|
const edited = applyEdits(text, [edit]);
|
|
let begin = edit.offset;
|
|
let end = edit.offset + edit.content.length;
|
|
if (edit.length === 0) {
|
|
while (begin > 0 && !"\r\n".includes(edited.charAt(begin - 1))) begin -= 1;
|
|
while (end < edited.length && !"\r\n".includes(edited.charAt(end)))
|
|
end += 1;
|
|
}
|
|
return applyEdits(
|
|
edited,
|
|
formatJson(
|
|
edited,
|
|
{ offset: begin, length: end - begin },
|
|
{ insertSpaces: true, tabSize: 2, eol: "\n", keepLines: false },
|
|
),
|
|
);
|
|
};
|
|
|
|
/** Remove selected member spans and only the separators needed by kept members. */
|
|
const deleteJsonMembers = (
|
|
text: string,
|
|
object: Node,
|
|
properties: readonly Node[],
|
|
): string => {
|
|
const siblings = object.children ?? [];
|
|
const removed = new Set(properties);
|
|
const anyKept = siblings.some((member) => !removed.has(member));
|
|
const edits: Edit[] = [];
|
|
let earlierKept = false;
|
|
for (const [index, member] of siblings.entries()) {
|
|
if (removed.has(member))
|
|
edits.push({ offset: member.offset, length: member.length, content: "" });
|
|
else earlierKept = true;
|
|
const separator = nextJsoncToken(text, member.offset + member.length);
|
|
const next = siblings[index + 1];
|
|
if (separator.kind !== JSON_COMMA && next !== undefined)
|
|
throw new TypeError("JSON property separator was not found");
|
|
// Keep the separator before the next retained member, matching the
|
|
// existing single-member deletion rule. JSONC may retain a trailing comma.
|
|
const keepSeparator =
|
|
next === undefined ? anyKept : earlierKept && !removed.has(next);
|
|
if (separator.kind === JSON_COMMA && !keepSeparator)
|
|
edits.push({ offset: separator.offset, length: 1, content: "" });
|
|
}
|
|
return applyEdits(text, edits);
|
|
};
|
|
|
|
/** A JSON edit refused because it would activate a shadowed duplicate member. */
|
|
export interface ClientConfigurationShadowedMember {
|
|
readonly kind: "shadowed_member";
|
|
/** Key path of the effective member whose removal was refused. */
|
|
readonly path: readonly string[];
|
|
}
|
|
|
|
/**
|
|
* Set or delete the member a client reads at `path`, keeping every byte
|
|
* outside that member. Shadowed duplicates stay in place, and deleting a
|
|
* member that shadows one is refused so the earlier member never becomes
|
|
* active, unless the caller has established removal authority for that path.
|
|
*/
|
|
const editJsonMember = (
|
|
text: string,
|
|
path: readonly string[],
|
|
value: unknown,
|
|
removeShadowedMembers: boolean,
|
|
): Result<string, ClientConfigurationShadowedMember> => {
|
|
let object = parseTree(text, [], { allowTrailingComma: true });
|
|
for (const [index, key] of path.entries()) {
|
|
if (object?.type !== "object")
|
|
throw new TypeError("JSON edit parent is not an object");
|
|
const property = effectiveJsonProperty(object, key);
|
|
const member = property?.children?.[1];
|
|
if (property === undefined || member === undefined) {
|
|
if (value === undefined) return ok(text);
|
|
const content = `${JSON.stringify(key)}: ${JSON.stringify(
|
|
path
|
|
.slice(index + 1)
|
|
.reduceRight<unknown>((nested, name) => ({ [name]: nested }), value),
|
|
)}`;
|
|
const previous = object.children?.at(-1);
|
|
return ok(
|
|
applyFormattedJsonEdit(
|
|
text,
|
|
previous === undefined
|
|
? { offset: object.offset + 1, length: 0, content }
|
|
: {
|
|
offset: previous.offset + previous.length,
|
|
length: 0,
|
|
content: `,${content}`,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
if (index < path.length - 1) {
|
|
object = member;
|
|
continue;
|
|
}
|
|
if (value !== undefined)
|
|
return ok(
|
|
applyFormattedJsonEdit(text, {
|
|
offset: member.offset,
|
|
length: member.length,
|
|
content: JSON.stringify(value),
|
|
}),
|
|
);
|
|
if (
|
|
object.children?.some(
|
|
(sibling) => sibling !== property && isJsonMember(sibling, key),
|
|
)
|
|
)
|
|
return removeShadowedMembers
|
|
? ok(
|
|
deleteJsonMembers(
|
|
text,
|
|
object,
|
|
(object.children ?? []).filter((sibling) =>
|
|
isJsonMember(sibling, key),
|
|
),
|
|
),
|
|
)
|
|
: err({ kind: "shadowed_member", path });
|
|
return ok(deleteJsonMembers(text, object, [property]));
|
|
}
|
|
throw new TypeError("JSON edit path is empty");
|
|
};
|
|
|
|
/** Edit authority beyond the members a client reads. */
|
|
export interface ClientConfigurationEditOptions {
|
|
/**
|
|
* Exact deleted paths whose repeated members the caller may remove.
|
|
* Uninstall must establish ownership of every shadowed member; setup may
|
|
* grant replacement authority for an explicitly superseded registration.
|
|
*/
|
|
readonly removeShadowedMembersAt?: readonly (readonly string[])[];
|
|
}
|
|
|
|
/**
|
|
* Serialize a validated client document, retaining JSONC and TOML comments.
|
|
* JSON edits fail only when they would activate a shadowed duplicate member.
|
|
*/
|
|
export const serializeClientConfiguration = (
|
|
document: Record<string, unknown>,
|
|
format: ClientConfigurationFormat | undefined,
|
|
originalText?: string,
|
|
editedPaths?: readonly (readonly string[])[],
|
|
options: ClientConfigurationEditOptions = {},
|
|
): Result<string, ClientConfigurationShadowedMember> => {
|
|
if (format === undefined || format === "unsupported")
|
|
throw new TypeError("client does not have a supported MCP config format");
|
|
if (format === "toml")
|
|
return ok(serializeCodexConfiguration(document, originalText));
|
|
if (format === "grok")
|
|
return ok(serializeGrokConfiguration(document, originalText));
|
|
if (format === "hermes")
|
|
return ok(serializeHermesConfiguration(document, originalText));
|
|
if (format === "dsh") return ok(serializeDshDocument(document));
|
|
// An empty original holds no comments or settings to preserve. Writing a
|
|
// fresh document also keeps a lone BOM from shifting past the closing brace.
|
|
if (
|
|
originalText === undefined ||
|
|
isEmptyClientConfigurationText(originalText) ||
|
|
editedPaths === undefined
|
|
)
|
|
return ok(`${JSON.stringify(document, null, 2)}\n`);
|
|
let text = originalText;
|
|
for (const path of editedPaths) {
|
|
const edited = editJsonMember(
|
|
text,
|
|
path,
|
|
valueAt(document, path),
|
|
options.removeShadowedMembersAt?.some(
|
|
(allowed) =>
|
|
allowed.length === path.length &&
|
|
allowed.every((key, index) => key === path[index]),
|
|
) ?? false,
|
|
);
|
|
if (!edited.ok) return edited;
|
|
text = edited.value;
|
|
}
|
|
return ok(text);
|
|
};
|
|
|
|
/** Key path of a named registration in the server table. */
|
|
export const clientServerPath = (
|
|
parsed: ClientConfigurationDocument,
|
|
name: string,
|
|
): string[] => [...parsed.serversPath, name];
|
|
|
|
/** Key path of a present OpenCode V1 entry beside a native V2 table. */
|
|
export const legacyClientServerPath = (
|
|
parsed: ClientConfigurationDocument,
|
|
name: string,
|
|
): string[] | undefined =>
|
|
Object.hasOwn(parsed.legacyServers, name)
|
|
? [parsed.serversPath[0] ?? "mcp", name]
|
|
: undefined;
|
|
|
|
/**
|
|
* Keep an existing Hermes `config.yaml` intact aside from the REA server entry.
|
|
* Hermes config files carry user comments and unrelated settings, so the rea
|
|
* entry is upserted into the parsed YAML document (which retains formatting and
|
|
* comments) rather than re-serializing the whole object.
|
|
*/
|
|
const serializeHermesConfiguration = (
|
|
document: Record<string, unknown>,
|
|
originalText: string | undefined,
|
|
): string => {
|
|
if (
|
|
originalText === undefined ||
|
|
isEmptyClientConfigurationText(originalText)
|
|
)
|
|
return stringifyHermesConfigurationYaml(document);
|
|
const serversKey = clientConfigurationServersKey("hermes");
|
|
const servers = document[serversKey];
|
|
const entry =
|
|
typeof servers === "object" && servers !== null
|
|
? (servers as Record<string, unknown>)[PRODUCT_IDENTITY.mcpServerKey]
|
|
: undefined;
|
|
return editHermesConfigurationYaml(
|
|
originalText,
|
|
[serversKey, PRODUCT_IDENTITY.mcpServerKey],
|
|
entry,
|
|
);
|
|
};
|
|
|
|
/** Build the stdio entry shape expected by one client's configuration dialect. */
|
|
export const clientRegistrationEntry = (
|
|
format: ClientRegistrationDialect,
|
|
command: readonly string[],
|
|
environment: Readonly<Record<string, string>>,
|
|
): Record<string, unknown> => {
|
|
const [executable = "rea", ...args] = command;
|
|
switch (format) {
|
|
case "opencode":
|
|
return {
|
|
type: "local",
|
|
command: [...command],
|
|
enabled: true,
|
|
...(Object.keys(environment).length === 0
|
|
? {}
|
|
: { environment: { ...environment } }),
|
|
};
|
|
case "kimi":
|
|
case "lm_studio":
|
|
// Both infer stdio from command; Kimi uses `transport`, not `type`.
|
|
return {
|
|
command: executable,
|
|
args,
|
|
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
|
|
};
|
|
case "opencode_v2":
|
|
// Override lower-priority disabled flags when refreshing a V2 registration.
|
|
return {
|
|
type: "local",
|
|
command: [...command],
|
|
disabled: false,
|
|
...(Object.keys(environment).length === 0
|
|
? {}
|
|
: { environment: { ...environment } }),
|
|
};
|
|
case "vscode":
|
|
case "omp":
|
|
case "pi":
|
|
return {
|
|
type: "stdio",
|
|
command: executable,
|
|
args,
|
|
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
|
|
};
|
|
case "copilot_cli":
|
|
return {
|
|
type: "stdio",
|
|
command: executable,
|
|
args,
|
|
tools: ["*"],
|
|
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
|
|
};
|
|
case "commandcode":
|
|
return {
|
|
transport: "stdio",
|
|
enabled: true,
|
|
command: executable,
|
|
args,
|
|
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
|
|
};
|
|
case "dsh":
|
|
return dshClientRegistrationEntry(command, environment);
|
|
case "hermes":
|
|
// Hermes reads stdio servers from `mcp_servers.<name>` and connects them
|
|
// unless `enabled` is false, so the entry states it explicitly.
|
|
return {
|
|
command: executable,
|
|
args,
|
|
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
|
|
enabled: true,
|
|
};
|
|
default:
|
|
return {
|
|
command: executable,
|
|
args,
|
|
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
|
|
};
|
|
}
|
|
};
|
|
|
|
/** Parse one Gemini policy at its source boundary before consolidating scopes. */
|
|
export const parseGeminiMcpPolicy = (parsed: ClientConfigurationDocument) =>
|
|
z
|
|
.object({
|
|
allowed: z.array(z.string()).optional(),
|
|
excluded: z.array(z.string()).optional(),
|
|
})
|
|
.passthrough()
|
|
.parse(parsed.document.mcp ?? {});
|
|
|
|
/** One server-name comparison for the exclusion, intersection and membership rules below. */
|
|
const normalizeGeminiServerName = (name: string): string =>
|
|
name.toLowerCase().trim();
|
|
|
|
/** Gemini consolidates excludes by union and allowlists by normalized intersection. */
|
|
export const geminiServerPolicyBlock = (
|
|
policies: readonly ReturnType<typeof parseGeminiMcpPolicy>[],
|
|
serverKey: string,
|
|
): string | undefined => {
|
|
// Hand-authored settings cannot be assumed to spell the product key exactly,
|
|
// so every rule compares normalized names. Testing the consolidated list
|
|
// against the raw key instead would make the outcome depend on which policy
|
|
// supplied the spelling that survived the intersection.
|
|
const key = normalizeGeminiServerName(serverKey);
|
|
if (
|
|
policies.some(({ excluded }) =>
|
|
excluded?.some((name) => normalizeGeminiServerName(name) === key),
|
|
)
|
|
)
|
|
return `Gemini mcp.excluded blocks ${serverKey}. Review the selected settings policies before rerunning setup.`;
|
|
const allowlists = policies.flatMap(({ allowed }) =>
|
|
allowed === undefined ? [] : [allowed],
|
|
);
|
|
const allowed = allowlists.reduce<string[] | undefined>(
|
|
(previous, current) => {
|
|
if (previous === undefined) return current;
|
|
const normalized = new Set(current.map(normalizeGeminiServerName));
|
|
return previous.filter((name) =>
|
|
normalized.has(normalizeGeminiServerName(name)),
|
|
);
|
|
},
|
|
undefined,
|
|
);
|
|
// Gemini's manager applies the consolidated allowlist only when nonempty.
|
|
if (
|
|
allowed !== undefined &&
|
|
allowed.length > 0 &&
|
|
!allowed.some((name) => normalizeGeminiServerName(name) === key)
|
|
)
|
|
return `Gemini mcp.allowed does not include ${serverKey}. Review the selected settings policies before rerunning setup.`;
|
|
return undefined;
|
|
};
|