Skip to content
Closed
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
108 changes: 108 additions & 0 deletions actions/setup/js/start_mcp_gateway.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>} */ 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<string, unknown>} */ 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);
Comment on lines +300 to +304
}
}
}
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<string, unknown>} inputConfig
* @param {Record<string, unknown>} 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<string, unknown>} */ outputServers : {};
const optional = new Set(optionalServerNames);

return Object.entries(/** @type {Record<string, unknown>} */ inputServers)
.filter(([name, serverConfig]) => {
if (optional.has(name)) {
return false;
}
if (serverConfig && typeof serverConfig === "object" && !Array.isArray(serverConfig)) {
const server = /** @type {Record<string, unknown>} */ 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1100,9 +1205,12 @@ module.exports = {
applyOTLPIgnoreIfMissing,
detectEngineType,
extractOptionalServerNames,
findOmittedRequiredMCPServers,
formatOmittedRequiredMCPServersMessage,
getOTLPIfMissingMode,
hasNonEmptyOTLPHeaders,
isOTLPIfMissingIgnore,
getMissingEnvVarNamesForServer,
getJSONParseErrorContext,
injectCustomGatewayEnvArgs,
normalizeSinkVisibilityEncoding,
Expand Down
97 changes: 97 additions & 0 deletions actions/setup/js/start_mcp_gateway.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import {
applyOTLPIgnoreIfMissing,
detectEngineType,
extractOptionalServerNames,
findOmittedRequiredMCPServers,
formatOmittedRequiredMCPServersMessage,
getJSONParseErrorContext,
getMissingEnvVarNamesForServer,
getOTLPIfMissingMode,
hasNonEmptyOTLPHeaders,
injectCustomGatewayEnvArgs,
Expand Down Expand Up @@ -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"]);
});
});
Loading