#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; import { isDeepStrictEqual } from "node:util"; function usage() { console.error( "Usage: node install/merge-managed-mcp-config.mjs --target --template --server-key [--overrides ] [--set NAME=value]", ); } function parseArgs(argv) { const options = { replacements: {}, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--target") { options.target = argv[index + 1]; index += 1; continue; } if (arg === "--template") { options.template = argv[index + 1]; index += 1; continue; } if (arg === "--server-key") { options.serverKey = argv[index + 1]; index += 1; continue; } if (arg === "--overrides") { options.overrides = argv[index + 1]; index += 1; continue; } if (arg === "--set") { const assignment = argv[index + 1] ?? ""; const equalsIndex = assignment.indexOf("="); if (equalsIndex <= 0) { throw new Error(`Invalid --set assignment: ${assignment}`); } const key = assignment.slice(0, equalsIndex); const value = assignment.slice(equalsIndex + 1); options.replacements[key] = value; index += 1; continue; } throw new Error(`Unknown argument: ${arg}`); } if (!options.target || !options.template || !options.serverKey) { usage(); throw new Error( "--target, --template, and --server-key are all required.", ); } return options; } function stripJsonComments(input) { let output = ""; let inString = false; let escaping = false; let inLineComment = false; let inBlockComment = false; for (let index = 0; index < input.length; index += 1) { const char = input[index]; const next = input[index + 1]; if (inLineComment) { if (char === "\n") { inLineComment = false; output += char; } continue; } if (inBlockComment) { if (char === "*" && next === "/") { inBlockComment = false; index += 1; } else if (char === "\n" || char === "\r") { output += char; } continue; } if (inString) { output += char; if (escaping) { escaping = false; } else if (char === "\\") { escaping = true; } else if (char === '"') { inString = false; } continue; } if (char === '"') { inString = true; output += char; continue; } if (char === "/" && next === "/") { inLineComment = true; index += 1; continue; } if (char === "/" && next === "*") { inBlockComment = true; index += 1; continue; } output += char; } return output; } function stripTrailingCommas(input) { let output = ""; let inString = false; let escaping = false; for (let index = 0; index < input.length; index += 1) { const char = input[index]; if (inString) { output += char; if (escaping) { escaping = false; } else if (char === "\\") { escaping = true; } else if (char === '"') { inString = false; } continue; } if (char === '"') { inString = true; output += char; continue; } if (char === ",") { let lookahead = index + 1; while (lookahead < input.length && /\s/.test(input[lookahead])) { lookahead += 1; } if (input[lookahead] === "}" || input[lookahead] === "]") { continue; } } output += char; } return output; } function renderTemplate(input, replacements) { return input.replace(/\{\{([A-Z0-9_]+)\}\}/g, (match, key) => { if (!(key in replacements)) { return match; } return replacements[key].replace(/\\/g, "\\\\").replace(/"/g, '\\"'); }); } function parseJsonc(input, label) { const sanitized = stripTrailingCommas(stripJsonComments(input)).trim(); if (!sanitized) { return {}; } let parsed; try { parsed = JSON.parse(sanitized); } catch (error) { throw new Error(`Failed to parse ${label}: ${error.message}`); } if (!isPlainObject(parsed)) { throw new Error(`${label} must contain a JSON object at the root.`); } return parsed; } function parseJsoncAst(input, label) { if (!input.trim()) { return { type: "object", start: 0, end: 0, properties: [], value: {}, }; } let index = 0; function fail(message) { throw new Error(`Failed to parse ${label}: ${message}`); } function skipTrivia() { while (index < input.length) { const char = input[index]; const next = input[index + 1]; if (/\s/.test(char)) { index += 1; continue; } if (char === "/" && next === "/") { index += 2; while (index < input.length && input[index] !== "\n") { index += 1; } continue; } if (char === "/" && next === "*") { index += 2; while ( index < input.length && !(input[index] === "*" && input[index + 1] === "/") ) { index += 1; } if (index >= input.length) { fail("unterminated block comment"); } index += 2; continue; } break; } } function parseStringNode() { const start = index; index += 1; while (index < input.length) { const char = input[index]; if (char === "\\") { index += 2; continue; } if (char === '"') { index += 1; const raw = input.slice(start, index); return { type: "string", start, end: index, value: JSON.parse(raw), }; } index += 1; } fail("unterminated string literal"); } function parseLiteralNode(expectedText, value) { const start = index; if (!input.startsWith(expectedText, index)) { fail(`expected ${expectedText}`); } index += expectedText.length; return { type: typeof value, start, end: index, value, }; } function parseNumberNode() { const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec( input.slice(index), ); if (!match) { fail(`invalid number at offset ${index}`); } const start = index; index += match[0].length; return { type: "number", start, end: index, value: Number(match[0]), }; } function parseArrayNode() { const start = index; index += 1; const elements = []; skipTrivia(); while (index < input.length && input[index] !== "]") { const valueNode = parseValueNode(); elements.push(valueNode); skipTrivia(); if (input[index] === ",") { index += 1; skipTrivia(); continue; } if (input[index] !== "]") { fail(`expected ',' or ']' at offset ${index}`); } } if (input[index] !== "]") { fail("unterminated array"); } index += 1; return { type: "array", start, end: index, elements, value: elements.map((element) => element.value), }; } function parseObjectNode() { const start = index; index += 1; const properties = []; const value = {}; skipTrivia(); while (index < input.length && input[index] !== "}") { if (input[index] !== '"') { fail(`expected string property name at offset ${index}`); } const keyNode = parseStringNode(); const key = keyNode.value; skipTrivia(); if (input[index] !== ":") { fail(`expected ':' after property name at offset ${index}`); } index += 1; skipTrivia(); const valueNode = parseValueNode(); const property = { key, keyNode, value: valueNode, hasTrailingComma: false, }; properties.push(property); value[key] = valueNode.value; skipTrivia(); if (input[index] === ",") { property.hasTrailingComma = true; index += 1; skipTrivia(); continue; } if (input[index] !== "}") { fail(`expected ',' or '}' at offset ${index}`); } } if (input[index] !== "}") { fail("unterminated object"); } index += 1; return { type: "object", start, end: index, properties, value, }; } function parseValueNode() { skipTrivia(); const char = input[index]; if (char === "{") { return parseObjectNode(); } if (char === "[") { return parseArrayNode(); } if (char === '"') { return parseStringNode(); } if (char === "t") { return parseLiteralNode("true", true); } if (char === "f") { return parseLiteralNode("false", false); } if (char === "n") { return parseLiteralNode("null", null); } if (char === "-" || /\d/.test(char ?? "")) { return parseNumberNode(); } fail(`unexpected token at offset ${index}`); } skipTrivia(); const root = parseValueNode(); skipTrivia(); if (index !== input.length) { fail(`unexpected trailing content at offset ${index}`); } if (root.type !== "object") { throw new Error(`${label} must contain a JSON object at the root.`); } return root; } function isPlainObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } function detectIndentUnit(input) { const matches = input.match(/^( +|\t+)\S/m); if (!matches) { return " "; } return matches[1]; } function detectEol(input) { return input.includes("\r\n") ? "\r\n" : "\n"; } function lineStartIndex(input, index) { const newlineIndex = input.lastIndexOf("\n", index - 1); return newlineIndex === -1 ? 0 : newlineIndex + 1; } function lineIndentAt(input, index) { const start = lineStartIndex(input, index); let end = start; while (end < input.length && (input[end] === " " || input[end] === "\t")) { end += 1; } return input.slice(start, end); } function renderValue(value, propertyIndent, indentUnit, eol) { const raw = JSON.stringify(value, null, indentUnit); if (!raw.includes("\n")) { return raw; } return raw .split("\n") .map((line, index) => (index === 0 ? line : `${propertyIndent}${line}`)) .join(eol); } function renderProperty(key, value, propertyIndent, indentUnit, eol) { return `${JSON.stringify(key)}: ${renderValue(value, propertyIndent, indentUnit, eol)}`; } function getObjectChildIndent(input, objectNode, indentUnit) { if (objectNode.properties.length > 0) { return lineIndentAt(input, objectNode.properties[0].keyNode.start); } return `${lineIndentAt(input, objectNode.start)}${indentUnit}`; } function buildMergeEdits( input, objectNode, managedSettings, indentUnit, eol, edits, ) { const missingEntries = []; for (const [key, managedValue] of Object.entries(managedSettings)) { const property = objectNode.properties.find( (candidate) => candidate.key === key, ); if (!property) { missingEntries.push([key, managedValue]); continue; } if (isPlainObject(managedValue) && property.value.type === "object") { buildMergeEdits( input, property.value, managedValue, indentUnit, eol, edits, ); continue; } if (!isDeepStrictEqual(property.value.value, managedValue)) { const propertyIndent = lineIndentAt(input, property.keyNode.start); edits.push({ start: property.value.start, end: property.value.end, text: renderValue(managedValue, propertyIndent, indentUnit, eol), }); } } if (missingEntries.length === 0) { return; } const propertyIndent = getObjectChildIndent(input, objectNode, indentUnit); const closingIndent = lineIndentAt(input, objectNode.end); const closingBraceIndex = objectNode.end - 1; const renderedProperties = missingEntries .map( ([key, value]) => `${propertyIndent}${renderProperty(key, value, propertyIndent, indentUnit, eol)}`, ) .join(`,${eol}`); if (objectNode.properties.length === 0) { edits.push({ start: objectNode.start + 1, end: closingBraceIndex, text: `${eol}${renderedProperties}${eol}${closingIndent}`, }); return; } const lastProperty = objectNode.properties[objectNode.properties.length - 1]; if (!lastProperty.hasTrailingComma) { edits.push({ start: lastProperty.value.end, end: lastProperty.value.end, text: ",", }); } const closingLineStart = lineStartIndex(input, objectNode.end); const insertBeforeClosingLine = closingLineStart > lastProperty.value.end; edits.push({ start: insertBeforeClosingLine ? closingLineStart : objectNode.end, end: insertBeforeClosingLine ? closingLineStart : objectNode.end, text: insertBeforeClosingLine ? `${renderedProperties}${eol}` : `${eol}${renderedProperties}${eol}${closingIndent}`, }); } function buildRemovalEdits(input, objectNode, keysToRemove, edits) { const removeSet = new Set(keysToRemove); if (removeSet.size === 0) { return; } const properties = objectNode.properties; const closingBraceIndex = objectNode.end - 1; for (let index = 0; index < properties.length; index += 1) { if (!removeSet.has(properties[index].key)) { continue; } let endIndex = index; while ( endIndex + 1 < properties.length && removeSet.has(properties[endIndex + 1].key) ) { endIndex += 1; } const firstProperty = properties[index]; const lastProperty = properties[endIndex]; const previousProperty = index > 0 ? properties[index - 1] : null; const nextProperty = endIndex + 1 < properties.length ? properties[endIndex + 1] : null; if (!previousProperty && !nextProperty) { edits.push({ start: objectNode.start + 1, end: closingBraceIndex, text: closingBraceIndex > lastProperty.value.end ? input.slice(lastProperty.value.end, closingBraceIndex) : "", }); continue; } if (nextProperty) { edits.push({ start: firstProperty.keyNode.start, end: nextProperty.keyNode.start, text: "", }); index = endIndex; continue; } edits.push({ start: previousProperty.value.end, end: closingBraceIndex, text: closingBraceIndex > lastProperty.value.end ? input.slice(lastProperty.value.end, closingBraceIndex) : "", }); index = endIndex; } } function applyEdits(input, edits) { return edits .sort((left, right) => right.start - left.start || right.end - left.end) .reduce( (text, edit) => `${text.slice(0, edit.start)}${edit.text}${text.slice(edit.end)}`, input, ); } function readLocalOverrides(filePath) { if (!filePath || !fs.existsSync(filePath)) { return {}; } return parseJsonc(fs.readFileSync(filePath, "utf8"), filePath); } function getServerOverride(overrides, name) { const serverMap = isPlainObject(overrides.servers) ? overrides.servers : {}; const value = serverMap[name]; return isPlainObject(value) ? value : {}; } function normalizeString(value) { return typeof value === "string" ? value.trim() : ""; } function buildTemplateReplacements(replacements, overrides) { const giteaOverrides = getServerOverride(overrides, "gitea"); return { ...replacements, GITEA_SERVER_URL: normalizeString(giteaOverrides.serverUrl), GITEA_TOKEN: normalizeString(giteaOverrides.token), }; } function isServerEnabled(name, overrides) { const serverOverride = getServerOverride(overrides, name); if (serverOverride.enabled === false) { return false; } if (name === "gitea") { return ( serverOverride.enabled === true && normalizeString(serverOverride.serverUrl) !== "" && normalizeString(serverOverride.token) !== "" ); } return true; } function selectManagedConfig(managedConfig, serverKey, overrides) { const managedServers = managedConfig[serverKey]; if (!isPlainObject(managedServers)) { throw new Error( `${serverKey} in ${serverKey} template must be an object at the root.`, ); } const selectedServers = {}; for (const [name, serverConfig] of Object.entries(managedServers)) { if (isServerEnabled(name, overrides)) { selectedServers[name] = serverConfig; } } return { ...managedConfig, [serverKey]: selectedServers, }; } function removeStaleManagedServers( input, rootNode, serverKey, managedServerNames, desiredServerNames, ) { const serverProperty = rootNode.properties.find( (candidate) => candidate.key === serverKey, ); if (!serverProperty || serverProperty.value.type !== "object") { return input; } const removableKeys = serverProperty.value.properties .map((property) => property.key) .filter( (key) => managedServerNames.has(key) && !desiredServerNames.has(key), ); if (removableKeys.length === 0) { return input; } const edits = []; buildRemovalEdits(input, serverProperty.value, removableKeys, edits); return applyEdits(input, edits); } function main() { const options = parseArgs(process.argv.slice(2)); const localOverrides = readLocalOverrides(options.overrides); const replacements = buildTemplateReplacements( options.replacements, localOverrides, ); const templateText = fs.readFileSync(options.template, "utf8"); const renderedTemplate = renderTemplate(templateText, replacements); const managedConfig = parseJsonc(renderedTemplate, options.template); const desiredConfig = selectManagedConfig( managedConfig, options.serverKey, localOverrides, ); const managedServers = desiredConfig[options.serverKey]; const allManagedServerNames = new Set(Object.keys(managedConfig[options.serverKey])); const desiredServerNames = new Set(Object.keys(managedServers)); const existingTargetText = fs.existsSync(options.target) ? fs.readFileSync(options.target, "utf8") : ""; const targetText = existingTargetText.trim() ? existingTargetText : "{}\n"; const targetAst = parseJsoncAst(targetText, options.target); const cleanedTargetText = removeStaleManagedServers( targetText, targetAst, options.serverKey, allManagedServerNames, desiredServerNames, ); const cleanedTargetAst = parseJsoncAst(cleanedTargetText, options.target); const indentUnit = detectIndentUnit(targetText); const eol = detectEol(targetText); const edits = []; buildMergeEdits( cleanedTargetText, cleanedTargetAst, desiredConfig, indentUnit, eol, edits, ); const output = edits.length === 0 ? cleanedTargetText : applyEdits(cleanedTargetText, edits); parseJsonc(output, options.target); fs.mkdirSync(path.dirname(options.target), { recursive: true }); if (output === targetText) { console.log(`Managed MCP config already up to date: ${options.target}`); return; } fs.writeFileSync(options.target, output, "utf8"); console.log(`Merged managed MCP config into: ${options.target}`); } main();