Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions actions/setup/js/convert_gateway_config_adapters.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// @ts-check
import { describe, it, expect } from "vitest";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test file uses ESM import syntax but has a .cjs extension. CommonJS files typically use require(). Confirm vitest is configured to handle ESM syntax in .cjs files (e.g., via transformMode or an explicit ESM config), otherwise this may fail at runtime.

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" }');
});
});
91 changes: 22 additions & 69 deletions actions/setup/js/convert_gateway_config_claude.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,99 +22,52 @@ 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://<anything>/mcp/ with http://<domain>:<port>/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<string, unknown>} entry
* @param {string} urlPrefix
* @returns {Record<string, unknown>}
*/
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<string>} */
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<string, unknown>} */
const config = JSON.parse(fs.readFileSync(gatewayOutput, "utf8"));
const rawServers = config.mcpServers;
const servers =
/** @type {Record<string, Record<string, unknown>>} */
rawServers && typeof rawServers === "object" && !Array.isArray(rawServers) ? rawServers : {};

/** @type {Record<string, Record<string, unknown>>} */
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("");
core.info("Converted configuration:");
core.info(output);
}

main();
if (require.main === module) {
main();
}

module.exports = { rewriteUrl };
module.exports = { rewriteUrl, transformClaudeEntry, main };
81 changes: 29 additions & 52 deletions actions/setup/js/convert_gateway_config_codex.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>} value
* @param {string} urlPrefix
* @returns {string}
*/
function toCodexTomlSection(name, value, urlPrefix) {
const url = `${urlPrefix}/mcp/${name}`;
const headers = /** @type {Record<string, string>} */ 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}`);
Expand All @@ -63,52 +61,31 @@ function main() {
}

const urlPrefix = `http://${resolvedDomain}:${port}`;

/** @type {Set<string>} */
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<string, unknown>} */
const config = JSON.parse(fs.readFileSync(gatewayOutput, "utf8"));
const rawServers = config.mcpServers;
const servers =
/** @type {Record<string, Record<string, unknown>>} */
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<string, string>} */ 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("");
core.info("Converted configuration:");
core.info(toml);
}

main();
if (require.main === module) {
main();
}

module.exports = {};
module.exports = { toCodexTomlSection, main };
Loading
Loading