diff --git a/actions/setup/js/convert_gateway_config_adapters.test.cjs b/actions/setup/js/convert_gateway_config_adapters.test.cjs new file mode 100644 index 0000000000..6193a4095f --- /dev/null +++ b/actions/setup/js/convert_gateway_config_adapters.test.cjs @@ -0,0 +1,81 @@ +// @ts-check +import { describe, it, expect } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +import { rewriteUrl, filterAndTransformServers, writeSecureOutput } from "./convert_gateway_config_shared.cjs"; +import { transformClaudeEntry } from "./convert_gateway_config_claude.cjs"; +import { transformCopilotEntry } from "./convert_gateway_config_copilot.cjs"; +import { transformGeminiEntry } from "./convert_gateway_config_gemini.cjs"; +import { toCodexTomlSection } from "./convert_gateway_config_codex.cjs"; + +describe("convert gateway config shared pipeline", () => { + it("rewrites gateway urls to the provided domain/port", () => { + const rewritten = rewriteUrl("http://old.example:81/mcp/github", "http://host.docker.internal:80"); + expect(rewritten).toBe("http://host.docker.internal:80/mcp/github"); + }); + + it("filters CLI-mounted servers before applying engine transforms", () => { + const servers = { + github: { url: "http://old/mcp/github" }, + playwright: { url: "http://old/mcp/playwright" }, + }; + const filtered = filterAndTransformServers(servers, new Set(["playwright"]), (_name, entry) => entry); + expect(filtered).toEqual({ + github: { url: "http://old/mcp/github" }, + }); + }); + + it("enforces output file permission mode to 0o600 even when file already exists", () => { + const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), "gateway-config-")); + const outputPath = path.join(outputDir, "mcp-servers.json"); + fs.writeFileSync(outputPath, "old"); + fs.chmodSync(outputPath, 0o644); + + writeSecureOutput(outputPath, "{}"); + + const mode = fs.statSync(outputPath).mode & 0o777; + expect(mode).toBe(0o600); + expect(fs.readFileSync(outputPath, "utf8")).toBe("{}"); + + fs.rmSync(outputDir, { recursive: true, force: true }); + }); +}); + +describe("convert gateway config adapters", () => { + const urlPrefix = "http://host.docker.internal:80"; + + it("claude adapter enforces type=http and rewrites url", () => { + const converted = transformClaudeEntry({ type: "ignored", url: "http://old/mcp/github", headers: { Authorization: "token" } }, urlPrefix); + expect(converted).toEqual({ + type: "http", + url: "http://host.docker.internal:80/mcp/github", + headers: { Authorization: "token" }, + }); + }); + + it("copilot adapter adds tools wildcard only when missing", () => { + const withoutTools = transformCopilotEntry({ url: "http://old/mcp/github" }, urlPrefix); + expect(withoutTools.tools).toEqual(["*"]); + expect(withoutTools.url).toBe("http://host.docker.internal:80/mcp/github"); + + const withTools = transformCopilotEntry({ tools: ["repo.read"], url: "http://old/mcp/github" }, urlPrefix); + expect(withTools.tools).toEqual(["repo.read"]); + }); + + it("gemini adapter removes type while keeping other fields", () => { + const converted = transformGeminiEntry({ type: "http", url: "http://old/mcp/github", headers: { Authorization: "token" } }, urlPrefix); + expect(converted).toEqual({ + url: "http://host.docker.internal:80/mcp/github", + headers: { Authorization: "token" }, + }); + }); + + it("codex adapter emits toml section with rewritten URL and auth header", () => { + const toml = toCodexTomlSection("github", { headers: { Authorization: "Bearer abc" } }, "http://172.30.0.1:80"); + expect(toml).toContain("[mcp_servers.github]"); + expect(toml).toContain('url = "http://172.30.0.1:80/mcp/github"'); + expect(toml).toContain('http_headers = { Authorization = "Bearer abc" }'); + }); +}); diff --git a/actions/setup/js/convert_gateway_config_claude.cjs b/actions/setup/js/convert_gateway_config_claude.cjs index fc50283acf..42dd2b3cd4 100644 --- a/actions/setup/js/convert_gateway_config_claude.cjs +++ b/actions/setup/js/convert_gateway_config_claude.cjs @@ -22,92 +22,43 @@ require("./shim.cjs"); * - GH_AW_MCP_CLI_SERVERS: JSON array of server names to exclude from agent config */ -const fs = require("fs"); const path = require("path"); +const { rewriteUrl, loadGatewayContext, logCLIFilters, filterAndTransformServers, logServerStats, writeSecureOutput } = require("./convert_gateway_config_shared.cjs"); const OUTPUT_PATH = path.join(process.env.RUNNER_TEMP || "/tmp", "gh-aw/mcp-config/mcp-servers.json"); /** - * Rewrite a gateway URL to use the configured domain and port. - * Replaces http:///mcp/ with http://:/mcp/. - * - * @param {string} url - Original URL from gateway output - * @param {string} urlPrefix - Target URL prefix (e.g., http://host.docker.internal:80) - * @returns {string} Rewritten URL + * @param {Record} entry + * @param {string} urlPrefix + * @returns {Record} */ -function rewriteUrl(url, urlPrefix) { - return url.replace(/^http:\/\/[^/]+\/mcp\//, `${urlPrefix}/mcp/`); +function transformClaudeEntry(entry, urlPrefix) { + const transformed = { ...entry }; + // Claude uses "type": "http" for HTTP-based MCP servers + transformed.type = "http"; + // Fix the URL to use the correct domain + if (typeof transformed.url === "string") { + transformed.url = rewriteUrl(transformed.url, urlPrefix); + } + return transformed; } function main() { - const gatewayOutput = process.env.MCP_GATEWAY_OUTPUT; - const domain = process.env.MCP_GATEWAY_DOMAIN; - const port = process.env.MCP_GATEWAY_PORT; - - if (!gatewayOutput) { - core.error("ERROR: MCP_GATEWAY_OUTPUT environment variable is required"); - process.exit(1); - } - if (!fs.existsSync(gatewayOutput)) { - core.error(`ERROR: Gateway output file not found: ${gatewayOutput}`); - process.exit(1); - } - if (!domain) { - core.error("ERROR: MCP_GATEWAY_DOMAIN environment variable is required"); - process.exit(1); - } - if (!port) { - core.error("ERROR: MCP_GATEWAY_PORT environment variable is required"); - process.exit(1); - } + const { gatewayOutput, domain, port, urlPrefix, cliServers, servers } = loadGatewayContext(); core.info("Converting gateway configuration to Claude format..."); core.info(`Input: ${gatewayOutput}`); core.info(`Target domain: ${domain}:${port}`); - - const urlPrefix = `http://${domain}:${port}`; - - /** @type {Set} */ - const cliServers = new Set(JSON.parse(process.env.GH_AW_MCP_CLI_SERVERS || "[]")); - if (cliServers.size > 0) { - core.info(`CLI-mounted servers to filter: ${[...cliServers].join(", ")}`); - } - - /** @type {Record} */ - const config = JSON.parse(fs.readFileSync(gatewayOutput, "utf8")); - const rawServers = config.mcpServers; - const servers = - /** @type {Record>} */ - rawServers && typeof rawServers === "object" && !Array.isArray(rawServers) ? rawServers : {}; - - /** @type {Record>} */ - const result = {}; - for (const [name, value] of Object.entries(servers)) { - if (cliServers.has(name)) continue; - const entry = { ...value }; - // Claude uses "type": "http" for HTTP-based MCP servers - entry.type = "http"; - // Fix the URL to use the correct domain - if (typeof entry.url === "string") { - entry.url = rewriteUrl(entry.url, urlPrefix); - } - result[name] = entry; - } + logCLIFilters(cliServers); + const result = filterAndTransformServers(servers, cliServers, (_name, entry) => transformClaudeEntry(entry, urlPrefix)); const output = JSON.stringify({ mcpServers: result }, null, 2); - - const serverCount = Object.keys(result).length; - const totalCount = Object.keys(servers).length; - const filteredCount = totalCount - serverCount; - core.info(`Servers: ${serverCount} included, ${filteredCount} filtered (CLI-mounted)`); - - // Ensure output directory exists - fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true }); + logServerStats(servers, Object.keys(result).length); // Write with owner-only permissions (0o600) to protect the gateway bearer token. // An attacker who reads mcp-servers.json could bypass --allowed-tools by issuing // raw JSON-RPC calls directly to the gateway. - fs.writeFileSync(OUTPUT_PATH, output, { mode: 0o600 }); + writeSecureOutput(OUTPUT_PATH, output); core.info(`Claude configuration written to ${OUTPUT_PATH}`); core.info(""); @@ -115,6 +66,8 @@ function main() { core.info(output); } -main(); +if (require.main === module) { + main(); +} -module.exports = { rewriteUrl }; +module.exports = { rewriteUrl, transformClaudeEntry, main }; diff --git a/actions/setup/js/convert_gateway_config_codex.cjs b/actions/setup/js/convert_gateway_config_codex.cjs index 438f6bafde..a72f70f424 100644 --- a/actions/setup/js/convert_gateway_config_codex.cjs +++ b/actions/setup/js/convert_gateway_config_codex.cjs @@ -22,32 +22,30 @@ require("./shim.cjs"); * - GH_AW_MCP_CLI_SERVERS: JSON array of server names to exclude from agent config */ -const fs = require("fs"); const path = require("path"); +const { loadGatewayContext, logCLIFilters, filterAndTransformServers, logServerStats, writeSecureOutput } = require("./convert_gateway_config_shared.cjs"); const OUTPUT_PATH = path.join(process.env.RUNNER_TEMP || "/tmp", "gh-aw/mcp-config/config.toml"); -function main() { - const gatewayOutput = process.env.MCP_GATEWAY_OUTPUT; - const domain = process.env.MCP_GATEWAY_DOMAIN; - const port = process.env.MCP_GATEWAY_PORT; +/** + * @param {string} name + * @param {Record} value + * @param {string} urlPrefix + * @returns {string} + */ +function toCodexTomlSection(name, value, urlPrefix) { + const url = `${urlPrefix}/mcp/${name}`; + const headers = /** @type {Record} */ value.headers || {}; + const authKey = headers.Authorization || ""; + let section = `[mcp_servers.${name}]\n`; + section += `url = "${url}"\n`; + section += `http_headers = { Authorization = "${authKey}" }\n`; + section += "\n"; + return section; +} - if (!gatewayOutput) { - core.error("ERROR: MCP_GATEWAY_OUTPUT environment variable is required"); - process.exit(1); - } - if (!fs.existsSync(gatewayOutput)) { - core.error(`ERROR: Gateway output file not found: ${gatewayOutput}`); - process.exit(1); - } - if (!domain) { - core.error("ERROR: MCP_GATEWAY_DOMAIN environment variable is required"); - process.exit(1); - } - if (!port) { - core.error("ERROR: MCP_GATEWAY_PORT environment variable is required"); - process.exit(1); - } +function main() { + const { gatewayOutput, domain, port, cliServers, servers } = loadGatewayContext(); core.info("Converting gateway configuration to Codex TOML format..."); core.info(`Input: ${gatewayOutput}`); @@ -63,45 +61,22 @@ function main() { } const urlPrefix = `http://${resolvedDomain}:${port}`; - - /** @type {Set} */ - const cliServers = new Set(JSON.parse(process.env.GH_AW_MCP_CLI_SERVERS || "[]")); - if (cliServers.size > 0) { - core.info(`CLI-mounted servers to filter: ${[...cliServers].join(", ")}`); - } - - /** @type {Record} */ - const config = JSON.parse(fs.readFileSync(gatewayOutput, "utf8")); - const rawServers = config.mcpServers; - const servers = - /** @type {Record>} */ - rawServers && typeof rawServers === "object" && !Array.isArray(rawServers) ? rawServers : {}; + logCLIFilters(cliServers); + const filteredServers = filterAndTransformServers(servers, cliServers, (_name, entry) => entry); // Build the TOML output let toml = '[history]\npersistence = "none"\n\n'; - for (const [name, value] of Object.entries(servers)) { - if (cliServers.has(name)) continue; - const url = `${urlPrefix}/mcp/${name}`; - const headers = /** @type {Record} */ value.headers || {}; - const authKey = headers.Authorization || ""; - toml += `[mcp_servers.${name}]\n`; - toml += `url = "${url}"\n`; - toml += `http_headers = { Authorization = "${authKey}" }\n`; - toml += "\n"; + for (const [name, value] of Object.entries(filteredServers)) { + toml += toCodexTomlSection(name, value, urlPrefix); } - const includedCount = Object.keys(servers).length - [...Object.keys(servers)].filter(k => cliServers.has(k)).length; - const filteredCount = Object.keys(servers).length - includedCount; - core.info(`Servers: ${includedCount} included, ${filteredCount} filtered (CLI-mounted)`); - - // Ensure output directory exists - fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true }); + logServerStats(servers, Object.keys(filteredServers).length); // Write with owner-only permissions (0o600) to protect the gateway bearer token. // An attacker who reads config.toml could issue raw JSON-RPC calls directly // to the gateway. - fs.writeFileSync(OUTPUT_PATH, toml, { mode: 0o600 }); + writeSecureOutput(OUTPUT_PATH, toml); core.info(`Codex configuration written to ${OUTPUT_PATH}`); core.info(""); @@ -109,6 +84,8 @@ function main() { core.info(toml); } -main(); +if (require.main === module) { + main(); +} -module.exports = {}; +module.exports = { toCodexTomlSection, main }; diff --git a/actions/setup/js/convert_gateway_config_copilot.cjs b/actions/setup/js/convert_gateway_config_copilot.cjs index db819344e1..c574b73fbe 100644 --- a/actions/setup/js/convert_gateway_config_copilot.cjs +++ b/actions/setup/js/convert_gateway_config_copilot.cjs @@ -21,94 +21,44 @@ require("./shim.cjs"); * - GH_AW_MCP_CLI_SERVERS: JSON array of server names to exclude from agent config */ -const fs = require("fs"); -const path = require("path"); +const { rewriteUrl, loadGatewayContext, logCLIFilters, filterAndTransformServers, logServerStats, writeSecureOutput } = require("./convert_gateway_config_shared.cjs"); const OUTPUT_PATH = "/home/runner/.copilot/mcp-config.json"; /** - * Rewrite a gateway URL to use the configured domain and port. - * Replaces http:///mcp/ with http://:/mcp/. - * - * @param {string} url - Original URL from gateway output - * @param {string} urlPrefix - Target URL prefix (e.g., http://host.docker.internal:80) - * @returns {string} Rewritten URL + * @param {Record} entry + * @param {string} urlPrefix + * @returns {Record} */ -function rewriteUrl(url, urlPrefix) { - return url.replace(/^http:\/\/[^/]+\/mcp\//, `${urlPrefix}/mcp/`); +function transformCopilotEntry(entry, urlPrefix) { + const transformed = { ...entry }; + // Add tools field if not present + if (!transformed.tools) { + transformed.tools = ["*"]; + } + // Fix the URL to use the correct domain + if (typeof transformed.url === "string") { + transformed.url = rewriteUrl(transformed.url, urlPrefix); + } + return transformed; } function main() { - const gatewayOutput = process.env.MCP_GATEWAY_OUTPUT; - const domain = process.env.MCP_GATEWAY_DOMAIN; - const port = process.env.MCP_GATEWAY_PORT; - - if (!gatewayOutput) { - core.error("ERROR: MCP_GATEWAY_OUTPUT environment variable is required"); - process.exit(1); - } - if (!fs.existsSync(gatewayOutput)) { - core.error(`ERROR: Gateway output file not found: ${gatewayOutput}`); - process.exit(1); - } - if (!domain) { - core.error("ERROR: MCP_GATEWAY_DOMAIN environment variable is required"); - process.exit(1); - } - if (!port) { - core.error("ERROR: MCP_GATEWAY_PORT environment variable is required"); - process.exit(1); - } + const { gatewayOutput, domain, port, urlPrefix, cliServers, servers } = loadGatewayContext(); core.info("Converting gateway configuration to Copilot format..."); core.info(`Input: ${gatewayOutput}`); core.info(`Target domain: ${domain}:${port}`); - - const urlPrefix = `http://${domain}:${port}`; - - /** @type {Set} */ - const cliServers = new Set(JSON.parse(process.env.GH_AW_MCP_CLI_SERVERS || "[]")); - if (cliServers.size > 0) { - core.info(`CLI-mounted servers to filter: ${[...cliServers].join(", ")}`); - } - - /** @type {Record} */ - const config = JSON.parse(fs.readFileSync(gatewayOutput, "utf8")); - const rawServers = config.mcpServers; - const servers = - /** @type {Record>} */ - rawServers && typeof rawServers === "object" && !Array.isArray(rawServers) ? rawServers : {}; - - /** @type {Record>} */ - const result = {}; - for (const [name, value] of Object.entries(servers)) { - if (cliServers.has(name)) continue; - const entry = { ...value }; - // Add tools field if not present - if (!entry.tools) { - entry.tools = ["*"]; - } - // Fix the URL to use the correct domain - if (typeof entry.url === "string") { - entry.url = rewriteUrl(entry.url, urlPrefix); - } - result[name] = entry; - } + logCLIFilters(cliServers); + const result = filterAndTransformServers(servers, cliServers, (_name, entry) => transformCopilotEntry(entry, urlPrefix)); const output = JSON.stringify({ mcpServers: result }, null, 2); - - const serverCount = Object.keys(result).length; - const totalCount = Object.keys(servers).length; - const filteredCount = totalCount - serverCount; - core.info(`Servers: ${serverCount} included, ${filteredCount} filtered (CLI-mounted)`); - - // Ensure output directory exists - fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true }); + logServerStats(servers, Object.keys(result).length); // Write with owner-only permissions (0o600) to protect the gateway bearer token. // An attacker who reads mcp-config.json could bypass --allowed-tools by issuing // raw JSON-RPC calls directly to the gateway. - fs.writeFileSync(OUTPUT_PATH, output, { mode: 0o600 }); + writeSecureOutput(OUTPUT_PATH, output); core.info(`Copilot configuration written to ${OUTPUT_PATH}`); core.info(""); @@ -116,6 +66,8 @@ function main() { core.info(output); } -main(); +if (require.main === module) { + main(); +} -module.exports = { rewriteUrl }; +module.exports = { rewriteUrl, transformCopilotEntry, main }; diff --git a/actions/setup/js/convert_gateway_config_gemini.cjs b/actions/setup/js/convert_gateway_config_gemini.cjs index 4b8d98c978..a3be709c61 100644 --- a/actions/setup/js/convert_gateway_config_gemini.cjs +++ b/actions/setup/js/convert_gateway_config_gemini.cjs @@ -29,80 +29,36 @@ require("./shim.cjs"); * - GH_AW_MCP_CLI_SERVERS: JSON array of server names to exclude from agent config */ -const fs = require("fs"); const path = require("path"); +const { rewriteUrl, loadGatewayContext, logCLIFilters, filterAndTransformServers, logServerStats, writeSecureOutput } = require("./convert_gateway_config_shared.cjs"); /** - * Rewrite a gateway URL to use the configured domain and port. - * Replaces http:///mcp/ with http://:/mcp/. - * - * @param {string} url - Original URL from gateway output - * @param {string} urlPrefix - Target URL prefix (e.g., http://host.docker.internal:80) - * @returns {string} Rewritten URL + * @param {Record} entry + * @param {string} urlPrefix + * @returns {Record} */ -function rewriteUrl(url, urlPrefix) { - return url.replace(/^http:\/\/[^/]+\/mcp\//, `${urlPrefix}/mcp/`); +function transformGeminiEntry(entry, urlPrefix) { + const transformed = { ...entry }; + // Remove "type" field — Gemini uses transport auto-detection from url/httpUrl + delete transformed.type; + // Fix the URL to use the correct domain + if (typeof transformed.url === "string") { + transformed.url = rewriteUrl(transformed.url, urlPrefix); + } + return transformed; } function main() { - const gatewayOutput = process.env.MCP_GATEWAY_OUTPUT; - const domain = process.env.MCP_GATEWAY_DOMAIN; - const port = process.env.MCP_GATEWAY_PORT; - const workspace = process.env.GITHUB_WORKSPACE; - - if (!gatewayOutput) { - core.error("ERROR: MCP_GATEWAY_OUTPUT environment variable is required"); - process.exit(1); - } - if (!fs.existsSync(gatewayOutput)) { - core.error(`ERROR: Gateway output file not found: ${gatewayOutput}`); - process.exit(1); - } - if (!domain) { - core.error("ERROR: MCP_GATEWAY_DOMAIN environment variable is required"); - process.exit(1); - } - if (!port) { - core.error("ERROR: MCP_GATEWAY_PORT environment variable is required"); - process.exit(1); - } - if (!workspace) { - core.error("ERROR: GITHUB_WORKSPACE environment variable is required"); - process.exit(1); - } + const { gatewayOutput, domain, port, urlPrefix, cliServers, servers, extraEnv } = loadGatewayContext({ + extraRequiredEnv: ["GITHUB_WORKSPACE"], + }); + const workspace = extraEnv.GITHUB_WORKSPACE; core.info("Converting gateway configuration to Gemini format..."); core.info(`Input: ${gatewayOutput}`); core.info(`Target domain: ${domain}:${port}`); - - const urlPrefix = `http://${domain}:${port}`; - - /** @type {Set} */ - const cliServers = new Set(JSON.parse(process.env.GH_AW_MCP_CLI_SERVERS || "[]")); - if (cliServers.size > 0) { - core.info(`CLI-mounted servers to filter: ${[...cliServers].join(", ")}`); - } - - /** @type {Record} */ - const config = JSON.parse(fs.readFileSync(gatewayOutput, "utf8")); - const rawServers = config.mcpServers; - const servers = - /** @type {Record>} */ - rawServers && typeof rawServers === "object" && !Array.isArray(rawServers) ? rawServers : {}; - - /** @type {Record>} */ - const result = {}; - for (const [name, value] of Object.entries(servers)) { - if (cliServers.has(name)) continue; - const entry = { ...value }; - // Remove "type" field — Gemini uses transport auto-detection from url/httpUrl - delete entry.type; - // Fix the URL to use the correct domain - if (typeof entry.url === "string") { - entry.url = rewriteUrl(entry.url, urlPrefix); - } - result[name] = entry; - } + logCLIFilters(cliServers); + const result = filterAndTransformServers(servers, cliServers, (_name, entry) => transformGeminiEntry(entry, urlPrefix)); // Build settings with mcpServers and context.includeDirectories // Allow Gemini CLI to read/write files from /tmp/ (e.g. MCP payload files, @@ -116,21 +72,16 @@ function main() { const output = JSON.stringify(settings, null, 2); - const serverCount = Object.keys(result).length; - const totalCount = Object.keys(servers).length; - const filteredCount = totalCount - serverCount; - core.info(`Servers: ${serverCount} included, ${filteredCount} filtered (CLI-mounted)`); + logServerStats(servers, Object.keys(result).length); // Create .gemini directory in the workspace (project-level settings) - const settingsDir = path.join(workspace, ".gemini"); - const settingsFile = path.join(settingsDir, "settings.json"); - fs.mkdirSync(settingsDir, { recursive: true }); + const settingsFile = path.join(workspace, ".gemini", "settings.json"); // Write with owner-only permissions (0o600) to protect the gateway bearer token. // settings.json contains the bearer token for the MCP gateway; an attacker // who reads it could bypass the --allowed-tools constraint by issuing raw // JSON-RPC calls directly to the gateway. - fs.writeFileSync(settingsFile, output, { mode: 0o600 }); + writeSecureOutput(settingsFile, output); core.info(`Gemini configuration written to ${settingsFile}`); core.info(""); @@ -138,6 +89,8 @@ function main() { core.info(output); } -main(); +if (require.main === module) { + main(); +} -module.exports = { rewriteUrl }; +module.exports = { rewriteUrl, transformGeminiEntry, main }; diff --git a/actions/setup/js/convert_gateway_config_shared.cjs b/actions/setup/js/convert_gateway_config_shared.cjs new file mode 100644 index 0000000000..1dcf9e5845 --- /dev/null +++ b/actions/setup/js/convert_gateway_config_shared.cjs @@ -0,0 +1,135 @@ +// @ts-check +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +/** + * Rewrite a gateway URL to use the configured domain and port. + * Replaces http:///mcp/ with http://:/mcp/. + * + * @param {string} url - Original URL from gateway output + * @param {string} urlPrefix - Target URL prefix (e.g., http://host.docker.internal:80) + * @returns {string} Rewritten URL + */ +function rewriteUrl(url, urlPrefix) { + return url.replace(/^http:\/\/[^/]+\/mcp\//, `${urlPrefix}/mcp/`); +} + +/** + * @param {string} name + * @returns {string} + */ +function requireEnvVar(name) { + const value = process.env[name]; + if (!value) { + core.error(`ERROR: ${name} environment variable is required`); + process.exit(1); + } + return value; +} + +/** + * @param {{ extraRequiredEnv?: string[] }} [options] + * @returns {{ + * gatewayOutput: string; + * domain: string; + * port: string; + * urlPrefix: string; + * cliServers: Set; + * servers: Record>; + * extraEnv: Record; + * }} + */ +function loadGatewayContext(options = {}) { + const extraRequiredEnv = options.extraRequiredEnv || []; + const gatewayOutput = requireEnvVar("MCP_GATEWAY_OUTPUT"); + if (!fs.existsSync(gatewayOutput)) { + core.error(`ERROR: Gateway output file not found: ${gatewayOutput}`); + process.exit(1); + } + + const domain = requireEnvVar("MCP_GATEWAY_DOMAIN"); + const port = requireEnvVar("MCP_GATEWAY_PORT"); + + /** @type {Record} */ + const extraEnv = {}; + for (const envVar of extraRequiredEnv) { + extraEnv[envVar] = requireEnvVar(envVar); + } + + /** @type {Set} */ + const cliServers = new Set(JSON.parse(process.env.GH_AW_MCP_CLI_SERVERS || "[]")); + + /** @type {Record} */ + const config = JSON.parse(fs.readFileSync(gatewayOutput, "utf8")); + const rawServers = config.mcpServers; + const servers = + /** @type {Record>} */ + rawServers && typeof rawServers === "object" && !Array.isArray(rawServers) ? rawServers : {}; + + return { + gatewayOutput, + domain, + port, + urlPrefix: `http://${domain}:${port}`, + cliServers, + servers, + extraEnv, + }; +} + +/** + * @param {Set} cliServers + */ +function logCLIFilters(cliServers) { + if (cliServers.size > 0) { + core.info(`CLI-mounted servers to filter: ${[...cliServers].join(", ")}`); + } +} + +/** + * @param {Record>} servers + * @param {Set} cliServers + * @param {(name: string, value: Record) => Record} transformServer + * @returns {Record>} + */ +function filterAndTransformServers(servers, cliServers, transformServer) { + /** @type {Record>} */ + const result = {}; + for (const [name, value] of Object.entries(servers)) { + if (cliServers.has(name)) continue; + const entry = { ...value }; + result[name] = transformServer(name, entry); + } + return result; +} + +/** + * @param {Record>} servers + * @param {number} includedCount + */ +function logServerStats(servers, includedCount) { + const totalCount = Object.keys(servers).length; + const filteredCount = totalCount - includedCount; + core.info(`Servers: ${includedCount} included, ${filteredCount} filtered (CLI-mounted)`); +} + +/** + * @param {string} outputPath + * @param {string} output + */ +function writeSecureOutput(outputPath, output) { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, output, { mode: 0o600 }); + fs.chmodSync(outputPath, 0o600); +} + +module.exports = { + rewriteUrl, + loadGatewayContext, + logCLIFilters, + filterAndTransformServers, + logServerStats, + writeSecureOutput, +};