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
2 changes: 1 addition & 1 deletion .changeset/fix-enclave-only-github-guard-policy.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

107 changes: 105 additions & 2 deletions actions/setup/js/mcp_cli_bridge.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const DEFAULT_HTTP_TIMEOUT_MS = 15000;

/** Timeout (ms) for tool invocation calls (may be long-running) */
const TOOL_CALL_TIMEOUT_MS = 120000;
const TOOLS_LIST_REQUEST_ID = 3;
/** Default run count for logs MCP calls when count is not provided (mirrors server default) */
const LOGS_TOOL_DEFAULT_COUNT = 100;
/** Number of runs per timeout minute for logs auto-scaling (mirrors server: ceil(count/40)) */
Expand All @@ -58,6 +59,7 @@ const LOGS_TOOL_MIN_TIMEOUT_MINUTES_NO_FILTER = 5;
const LOGS_TOOL_MAX_EXPLICIT_TIMEOUT_MINUTES = 60;
/** Extra time (ms) to allow response marshalling/transport after tool execution */
const TOOL_CALL_TIMEOUT_BUFFER_MS = 15000;
const ENCLAVE_BIT_BUDGET_HINT = "Hint: the enclave response schema exceeded the finite-disclosure bit budget. Retry only with a lower-cardinality response schema.";

/** Timeout (ms) for the notifications/initialized handshake step */
const NOTIFY_TIMEOUT_MS = 10000;
Expand All @@ -74,6 +76,12 @@ const TOOL_HELP_MAX_LINES = 30;
const TOOL_DESC_MAX_LEN = 90;
const COMPACT_NAME_LINE_TARGET_WIDTH = 110;
const SAFEOUTPUTS_SERVER_NAME = "safeoutputs";
const AWF_ENCLAVE_SERVER_NAME = "awf-enclave";
const DEFERRED_SERVERS_ENV = "GH_AW_MCP_DEFERRED_SERVERS";
const DEFERRED_TOOLS_LIST_MAX_ATTEMPTS = 5;
const DEFERRED_TOOLS_LIST_RETRY_DELAY_MS = 1000;
/** @type {Set<string>} */
const deferredToolsRefreshExhaustedServers = new Set();

// ---------------------------------------------------------------------------
// Audit logging
Expand Down Expand Up @@ -1110,6 +1118,89 @@ function ensureSafeOutputsTools(tools, serverName, toolsFile) {
throw new Error(`[${serverName}] tool schema is empty (${toolsFile}). ` + "Failing fast to prevent runs where safe-output tools cannot be discovered.");
}

/**
* @param {string} name
* @param {string} list
* @returns {boolean}
*/
function serverInCommaList(name, list) {
return list
.split(",")
.map(item => item.trim())
.filter(Boolean)
.includes(name);
}

function isEnclaveBitBudgetExhausted(serverName, message) {
return serverName === AWF_ENCLAVE_SERVER_NAME && /bit-budget-exhausted/i.test(message);
}

/**
* Fetch the live tools/list result for a server and persist it over an empty
* cache. The awf-enclave server is always treated as deferred, and any server in
* GH_AW_MCP_DEFERRED_SERVERS is also treated as deferred. These servers may
* register after the wrapper is mounted, so their startup-time cache can
* legitimately be empty. The refresh retries bounded attempts before falling
* back to the cached tools. If retries are exhausted, a process-local marker
* avoids repeating the same bounded retry loop again in this invocation.
*
* @param {Array<{name: string, description?: string, inputSchema?: {properties?: Record<string, {description?: string, type?: string}>, required?: string[]}}>} tools
* @param {string} serverName
* @param {string} serverUrl
* @param {string} apiKey
* @param {string} toolsFile
* @param {number} [maxAttempts]
* @param {number} [retryDelayMs]
* @returns {Promise<Array<{name: string, description?: string, inputSchema?: {properties?: Record<string, {description?: string, type?: string}>, required?: string[]}}>>}
*/
async function refreshDeferredToolsIfNeeded(tools, serverName, serverUrl, apiKey, toolsFile, maxAttempts = DEFERRED_TOOLS_LIST_MAX_ATTEMPTS, retryDelayMs = DEFERRED_TOOLS_LIST_RETRY_DELAY_MS) {
const isDeferredServer = serverName === AWF_ENCLAVE_SERVER_NAME || serverInCommaList(serverName, process.env[DEFERRED_SERVERS_ENV] || "");
if (tools.length > 0 || !isDeferredServer) {
return tools;
}
if (deferredToolsRefreshExhaustedServers.has(serverName)) {
global.core.warning(`[${serverName}] deferred tools/list refresh already exhausted in this process; using cached schema`);
return tools;
}
const core = global.core;
core.warning(`[${serverName}] cached tool schema is empty for deferred server; refreshing from live gateway`);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const sessionId = await mcpInitialize(serverUrl, apiKey, serverName);
await mcpNotifyInitialized(serverUrl, apiKey, sessionId, serverName);
/** @type {Record<string, string>} */
const headers = { Authorization: apiKey };
if (sessionId) {
headers["Mcp-Session-Id"] = sessionId;
}
const resp = await httpPostJSON(serverUrl, headers, { jsonrpc: "2.0", id: TOOLS_LIST_REQUEST_ID, method: "tools/list" }, DEFAULT_HTTP_TIMEOUT_MS);
const messages = extractJSONRPCMessages(resp.body);
const resultMessage = messages.find(isResultMessage);
const result = resultMessage && typeof resultMessage === "object" && "result" in resultMessage && resultMessage.result && typeof resultMessage.result === "object" ? resultMessage.result : null;
const refreshed = result && "tools" in result && Array.isArray(result.tools) ? result.tools : [];
if (refreshed.length > 0) {
try {
fs.writeFileSync(toolsFile, JSON.stringify(refreshed, null, 2), { mode: 0o644 });
} catch (err) {
core.warning(`[${serverName}] failed to update refreshed tools cache ${toolsFile}: ${getErrorMessage(err)}`);
}
deferredToolsRefreshExhaustedServers.delete(serverName);
core.info(`[${serverName}] refreshed deferred tools cache with ${refreshed.length} tool(s)`);
return refreshed;
}
core.warning(`[${serverName}] live tools/list attempt ${attempt}/${maxAttempts} returned 0 tools for deferred server`);
} catch (err) {
core.warning(`[${serverName}] deferred tools/list attempt ${attempt}/${maxAttempts} failed: ${getErrorMessage(err)}`);
}
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
}
}
deferredToolsRefreshExhaustedServers.add(serverName);
core.warning(`[${serverName}] deferred tools/list refresh exhausted retries; using cached empty schema`);
return tools;
}

/**
* Show top-level help: list all available commands for a server.
*
Expand Down Expand Up @@ -1387,10 +1478,13 @@ async function formatResponse(responseBody, serverName, toolName = "") {
const message = "message" in errRecord ? String(errRecord.message || "Unknown error") : "Unknown error";
const code = "code" in errRecord && errRecord.code != null ? String(errRecord.code) : "";
const isSafeOutputsEmptyArgs = serverName === SAFEOUTPUTS_SERVER_NAME && code === "-32602" && /Empty arguments are not allowed/i.test(message);
const isEnclaveBitBudget = isEnclaveBitBudgetExhausted(serverName, message);
const hint =
isSafeOutputsEmptyArgs && toolName
? `Hint: do not retry '${serverName} ${toolName}' with empty arguments. Run '${serverName} ${toolName} --help' to inspect the required options, or call 'noop' with a message if no action is needed.`
: "";
: isEnclaveBitBudget
? ENCLAVE_BIT_BUDGET_HINT
: "";
const errText = code ? `Error [${code}]: ${message}` : `Error: ${message}`;
process.stderr.write(errText + "\n");
auditLog(serverName, { event: "tool_error", error: errText });
Expand Down Expand Up @@ -1418,6 +1512,9 @@ async function formatResponse(responseBody, serverName, toolName = "") {
const output = outputParts.join("\n");
if (isErrorResult) {
process.stderr.write(output + "\n");
if (isEnclaveBitBudgetExhausted(serverName, output)) {
process.stderr.write(ENCLAVE_BIT_BUDGET_HINT + "\n");
}
auditLog(serverName, { event: "tool_error", error: output });
core.setFailed(`[${serverName}] Tool returned isError=true: ${output.length} chars`);
return;
Expand All @@ -1431,6 +1528,9 @@ async function formatResponse(responseBody, serverName, toolName = "") {
const resultStr = typeof result === "string" ? result : JSON.stringify(result);
if (isErrorResult) {
process.stderr.write(resultStr + "\n");
if (isEnclaveBitBudgetExhausted(serverName, resultStr)) {
process.stderr.write(ENCLAVE_BIT_BUDGET_HINT + "\n");
}
auditLog(serverName, { event: "tool_error", error: resultStr });
core.setFailed(`[${serverName}] Tool returned isError=true`);
return;
Expand Down Expand Up @@ -1467,7 +1567,8 @@ async function main() {
});

// Load cached tools for help display
const tools = ensureSafeOutputsTools(loadTools(toolsFile), serverName, toolsFile);
let tools = await refreshDeferredToolsIfNeeded(loadTools(toolsFile), serverName, serverUrl, apiKey, toolsFile);
tools = ensureSafeOutputsTools(tools, serverName, toolsFile);

// Route: --help or no args → show top-level help
if (userArgs.length === 0 || userArgs[0] === "--help" || userArgs[0] === "-h") {
Expand Down Expand Up @@ -1598,6 +1699,8 @@ module.exports = {
hasStdinJsonPayload,
readStdinSync,
ensureSafeOutputsTools,
refreshDeferredToolsIfNeeded,
serverInCommaList,
getToolCallTimeoutMs,
auditLog,
ensureAuditDir,
Expand Down
Loading
Loading