diff --git a/actions/setup/js/start_mcp_gateway.cjs b/actions/setup/js/start_mcp_gateway.cjs index 17a79ad45e8..697c4d1c247 100644 --- a/actions/setup/js/start_mcp_gateway.cjs +++ b/actions/setup/js/start_mcp_gateway.cjs @@ -269,6 +269,100 @@ function extractOptionalServerNames(configObj) { return optional; } +/** + * @param {unknown} serverConfig + * @param {NodeJS.ProcessEnv} env + * @returns {string[]} + */ +function getMissingEnvVarNamesForServer(serverConfig, env = process.env) { + if (!serverConfig || typeof serverConfig !== "object" || Array.isArray(serverConfig)) { + return []; + } + const server = /** @type {Record} */ serverConfig; + const serverEnv = server["env"]; + if (!serverEnv || typeof serverEnv !== "object" || Array.isArray(serverEnv)) { + return []; + } + + const missing = new Set(); + const varPattern = /\$\{([A-Z_][A-Z0-9_]*)\}/g; + for (const [envName, rawValue] of Object.entries(/** @type {Record} */ serverEnv)) { + if (typeof rawValue !== "string") { + continue; + } + const matches = [...rawValue.matchAll(varPattern)]; + if (matches.length === 0) { + if (rawValue.trim() === "") { + missing.add(envName); + } + continue; + } + for (const match of matches) { + const varName = match[1]; + const envValue = env[varName]; + if (envValue == null || envValue.trim() === "") { + missing.add(varName); + } + } + } + return Array.from(missing).sort(); +} + +/** + * Find required MCP servers that were present in the input configuration but are + * absent from the gateway output. Missing required servers usually indicate that + * startup-time configuration such as secrets or environment variables was empty. + * + * @param {Record} inputConfig + * @param {Record} gatewayOutput + * @param {string[]} [optionalServerNames] + * @param {NodeJS.ProcessEnv} [env] + * @returns {Array<{name: string, missingEnvVars: string[]}>} + */ +function findOmittedRequiredMCPServers(inputConfig, gatewayOutput, optionalServerNames = [], env = process.env) { + const inputServers = inputConfig && inputConfig.mcpServers; + if (!inputServers || typeof inputServers !== "object" || Array.isArray(inputServers)) { + return []; + } + const outputServers = gatewayOutput && gatewayOutput.mcpServers; + const outputServerMap = outputServers && typeof outputServers === "object" && !Array.isArray(outputServers) ? /** @type {Record} */ outputServers : {}; + const optional = new Set(optionalServerNames); + + return Object.entries(/** @type {Record} */ inputServers) + .filter(([name, serverConfig]) => { + if (optional.has(name)) { + return false; + } + if (serverConfig && typeof serverConfig === "object" && !Array.isArray(serverConfig)) { + const server = /** @type {Record} */ serverConfig; + if (server.required === false) { + return false; + } + } + return !Object.prototype.hasOwnProperty.call(outputServerMap, name); + }) + .map(([name, serverConfig]) => ({ + name, + missingEnvVars: getMissingEnvVarNamesForServer(serverConfig, env), + })); +} + +/** + * @param {Array<{name: string, missingEnvVars: string[]}>} omittedServers + * @returns {string} + */ +function formatOmittedRequiredMCPServersMessage(omittedServers) { + const details = omittedServers + .map(server => { + if (server.missingEnvVars.length === 0) { + return server.name; + } + return `${server.name} (missing/empty env: ${server.missingEnvVars.join(", ")})`; + }) + .join("; "); + return `Required MCP server(s) were omitted from the gateway output: ${details}. ` + `Configure the missing secrets/environment variables, or mark intentionally best-effort servers with required: false.`; +} + /** * Check whether a process is alive. * @param {number} pid @@ -878,6 +972,17 @@ async function main() { core.setFailed("ERROR: Gateway returned an error payload instead of configuration"); return; } + const omittedRequiredServers = findOmittedRequiredMCPServers(configObj, gatewayOutput, optionalServerNames); + if (omittedRequiredServers.length > 0) { + const message = formatOmittedRequiredMCPServersMessage(omittedRequiredServers); + try { + process.kill(gatewayPid); + } catch { + // ignore + } + core.setFailed(`ERROR: ${message}`); + return; + } // ----------------------------------------------------------------------- // Convert gateway output to agent-specific format @@ -1100,9 +1205,12 @@ module.exports = { applyOTLPIgnoreIfMissing, detectEngineType, extractOptionalServerNames, + findOmittedRequiredMCPServers, + formatOmittedRequiredMCPServersMessage, getOTLPIfMissingMode, hasNonEmptyOTLPHeaders, isOTLPIfMissingIgnore, + getMissingEnvVarNamesForServer, getJSONParseErrorContext, injectCustomGatewayEnvArgs, normalizeSinkVisibilityEncoding, diff --git a/actions/setup/js/start_mcp_gateway.test.cjs b/actions/setup/js/start_mcp_gateway.test.cjs index a863a243960..dcec2e0432f 100644 --- a/actions/setup/js/start_mcp_gateway.test.cjs +++ b/actions/setup/js/start_mcp_gateway.test.cjs @@ -4,7 +4,10 @@ import { applyOTLPIgnoreIfMissing, detectEngineType, extractOptionalServerNames, + findOmittedRequiredMCPServers, + formatOmittedRequiredMCPServersMessage, getJSONParseErrorContext, + getMissingEnvVarNamesForServer, getOTLPIfMissingMode, hasNonEmptyOTLPHeaders, injectCustomGatewayEnvArgs, @@ -352,3 +355,97 @@ describe("start_mcp_gateway extractOptionalServerNames", () => { expect(extractOptionalServerNames({ mcpServers: null })).toEqual([]); }); }); + +describe("start_mcp_gateway omitted required MCP server detection", () => { + it("reports configured required servers missing from gateway output with empty env names", () => { + const inputConfig = { + mcpServers: { + sentry: { + type: "stdio", + command: "npx", + env: { + SENTRY_ACCESS_TOKEN: "${SENTRY_ACCESS_TOKEN}", + SENTRY_HOST: "sentry.io", + OPENAI_API_KEY: "${SENTRY_OPENAI_API_KEY}", + }, + }, + grafana: { + type: "stdio", + container: "grafana/mcp-grafana:1.0.0-alpine", + env: { + GRAFANA_URL: "${GRAFANA_URL}", + GRAFANA_SERVICE_ACCOUNT_TOKEN: "${GRAFANA_SERVICE_ACCOUNT_TOKEN}", + }, + }, + github: { + type: "http", + url: "http://localhost:8080/mcp/github", + }, + }, + }; + const gatewayOutput = { + mcpServers: { + github: { + type: "http", + url: "http://localhost:8080/mcp/github", + }, + }, + }; + + expect( + findOmittedRequiredMCPServers(inputConfig, gatewayOutput, [], { + SENTRY_ACCESS_TOKEN: "", + SENTRY_OPENAI_API_KEY: "", + GRAFANA_URL: "", + GRAFANA_SERVICE_ACCOUNT_TOKEN: "", + }) + ).toEqual([ + { name: "sentry", missingEnvVars: ["SENTRY_ACCESS_TOKEN", "SENTRY_OPENAI_API_KEY"] }, + { name: "grafana", missingEnvVars: ["GRAFANA_SERVICE_ACCOUNT_TOKEN", "GRAFANA_URL"] }, + ]); + }); + + it("does not report servers marked optional in their config", () => { + const inputConfig = { + mcpServers: { + datadog: { type: "http", url: "https://example.com/mcp", required: false }, + }, + }; + const gatewayOutput = { mcpServers: {} }; + + expect(findOmittedRequiredMCPServers(inputConfig, gatewayOutput)).toEqual([]); + }); + + it("does not report servers forwarded as optional after required flags are stripped", () => { + const inputConfig = { + mcpServers: { + slack: { type: "http", url: "https://example.com/slack" }, + }, + }; + const gatewayOutput = { mcpServers: {} }; + + expect(findOmittedRequiredMCPServers(inputConfig, gatewayOutput, ["slack"])).toEqual([]); + }); + + it("formats an actionable startup failure message", () => { + expect( + formatOmittedRequiredMCPServersMessage([ + { name: "sentry", missingEnvVars: ["SENTRY_ACCESS_TOKEN"] }, + { name: "grafana", missingEnvVars: ["GRAFANA_SERVICE_ACCOUNT_TOKEN", "GRAFANA_URL"] }, + ]) + ).toBe( + "Required MCP server(s) were omitted from the gateway output: sentry (missing/empty env: SENTRY_ACCESS_TOKEN); grafana (missing/empty env: GRAFANA_SERVICE_ACCOUNT_TOKEN, GRAFANA_URL). Configure the missing secrets/environment variables, or mark intentionally best-effort servers with required: false." + ); + }); + + it("detects direct empty env values when no variable placeholder is present", () => { + expect( + getMissingEnvVarNamesForServer({ + env: { + API_TOKEN: "", + HOST: "https://example.com", + }, + }) + ).toEqual(["API_TOKEN"]); + }); +});