Skip to content
Merged
184 changes: 184 additions & 0 deletions actions/setup/js/mcp_dependencies_manager.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// @ts-check

const { execFileSync } = require("child_process");
const fs = require("fs");
const path = require("path");

const installedDependencyPromises = new Map();
const perToolInstallPromises = new Map();
let execFileSyncRunner = execFileSync;

/**
* Emit dependency-install logs via the provided MCP logger.
* @param {Object} logger
* @param {string} level
* @param {string} message
*/
function logWithCore(logger, level, message) {
if (logger) {
const logMethod = typeof logger[level] === "function" ? logger[level] : logger.debug;
if (typeof logMethod === "function") {
logMethod(message);
}
}
}

function inferDependencyManager(handlerPath) {
const ext = path.extname(handlerPath).toLowerCase();
if (ext === ".py") return "pip";
if (ext === ".go") return "go";
if (ext === ".sh") return "shell";
return "npm";
}

function resolveShellPackageManager() {
const managers = [
{ command: "apt-get", args: ["install", "-y"] },
{ command: "yum", args: ["install", "-y"] },
{ command: "dnf", args: ["install", "-y"] },
];

for (const manager of managers) {
try {
execFileSyncRunner("which", [manager.command], { stdio: "pipe" });
return manager;
} catch {
// Try next manager
}
}

return null;
}

function isTransientInstallFailure(message) {
return /(timed out|timeout|temporary|network|econnreset|econnrefused|eai_again|etimedout|429|502|503|504)/i.test(message);
}

function isDeterministicInstallFailure(message) {
return /(not found|no matching distribution|unable to locate package|invalid requirement|permission denied|forbidden|unauthorized|unknown revision|invalid version)/i.test(message);
}

function executeInstallWithRetry(logger, toolName, dependency, command, args, cwd) {
const maxRetries = 2;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
logWithCore(logger, "debug", ` [${toolName}] Installing dependency '${dependency}' with: ${command} ${args.join(" ")}`);
execFileSyncRunner(command, args, {
cwd,
stdio: "pipe",
env: process.env,
});
logWithCore(logger, "info", ` [${toolName}] Installed dependency '${dependency}'`);
return;
} catch (error) {
const stderr = error && error.stderr ? String(error.stderr) : "";
const stdout = error && error.stdout ? String(error.stdout) : "";
const details = [stderr.trim(), stdout.trim(), error && error.message ? String(error.message) : ""].filter(Boolean).join("\n");

if (isDeterministicInstallFailure(details)) {
logWithCore(logger, "error", ` [${toolName}] Deterministic dependency install failure for '${dependency}'`);
throw new Error(`Dependency installation failed for '${dependency}': ${details || "deterministic failure"}`);
}

if (!isTransientInstallFailure(details) || attempt === maxRetries) {
logWithCore(logger, "error", ` [${toolName}] Dependency install failed after ${attempt + 1} attempt(s) for '${dependency}'`);
throw new Error(`Dependency installation failed for '${dependency}' after ${attempt + 1} attempt(s): ${details || "unknown error"}`);
}

logWithCore(logger, "warning", ` [${toolName}] Transient dependency install failure for '${dependency}', retrying (${attempt + 1}/${maxRetries})`);
}
}
}

function installDependency(logger, toolName, dependency, manager, basePath) {
let command = "";
let args = [];
let cwd = basePath;

if (manager === "npm") {
command = "npm";
args = ["install", "--ignore-scripts", "--no-save", "--", dependency];
} else if (manager === "pip") {
command = "python3";
args = ["-m", "pip", "install", "--disable-pip-version-check", dependency];
} else if (manager === "go") {
const goModPath = path.join(basePath, "go.mod");
if (!fs.existsSync(goModPath)) {
try {
execFileSyncRunner("go", ["mod", "init", "example.com/mcp-scripts"], { cwd: basePath, stdio: "pipe", env: process.env });
} catch {
// go.mod may have been created concurrently
}
}
command = "go";
args = ["get", dependency];
} else if (manager === "shell") {
const shellPM = resolveShellPackageManager();
if (!shellPM) {
throw new Error(`Dependency installation failed for '${dependency}': no supported system package manager found (expected apt-get, yum, or dnf)`);
}
command = shellPM.command;
args = [...shellPM.args, dependency];
cwd = process.cwd();
} else {
Comment on lines +115 to +123
return;
}

executeInstallWithRetry(logger, toolName, dependency, command, args, cwd);
}

function createDependencyInstallGate(logger, toolName, handlerPath, dependencies, basePath) {
const depList = Array.isArray(dependencies) ? dependencies.filter(dep => typeof dep === "string" && dep.trim() !== "") : [];
if (depList.length === 0) {
return async () => {};
}

const manager = inferDependencyManager(handlerPath);
const toolKey = `${toolName}:${handlerPath}`;

return async () => {
if (perToolInstallPromises.has(toolKey)) {
logWithCore(logger, "debug", ` [${toolName}] Reusing dependency install gate for ${toolKey}`);
return perToolInstallPromises.get(toolKey);
}

const installPromise = (async () => {
logWithCore(logger, "debug", ` [${toolName}] Starting dependency install gate (${depList.length} dependency item(s))`);
for (const dependency of depList) {
const key = `${manager}:${dependency}`;
if (!installedDependencyPromises.has(key)) {
logWithCore(logger, "debug", ` [${toolName}] No existing install promise for '${dependency}', creating one`);
installedDependencyPromises.set(
key,
Promise.resolve().then(() => installDependency(logger, toolName, dependency, manager, basePath))
);
} else {
logWithCore(logger, "debug", ` [${toolName}] Reusing existing install promise for '${dependency}'`);
}
await installedDependencyPromises.get(key);
}
logWithCore(logger, "debug", ` [${toolName}] Dependency install gate completed`);
})();

perToolInstallPromises.set(toolKey, installPromise);
return installPromise;
};
}

function resetDependencyInstallStateForTests() {
installedDependencyPromises.clear();
perToolInstallPromises.clear();
}

function setExecFileSyncRunnerForTests(runner) {
execFileSyncRunner = runner || execFileSync;
}

module.exports = {
createDependencyInstallGate,
inferDependencyManager,
isTransientInstallFailure,
isDeterministicInstallFailure,
resetDependencyInstallStateForTests,
setExecFileSyncRunnerForTests,
};
70 changes: 70 additions & 0 deletions actions/setup/js/mcp_dependencies_manager.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

describe("mcp_dependencies_manager", () => {
beforeEach(() => {
vi.resetModules();
});

it("infers manager from handler extension", async () => {
const { inferDependencyManager } = await import("./mcp_dependencies_manager.cjs");
expect(inferDependencyManager("/tmp/tool.py")).toBe("pip");
expect(inferDependencyManager("/tmp/tool.go")).toBe("go");
expect(inferDependencyManager("/tmp/tool.sh")).toBe("shell");
expect(inferDependencyManager("/tmp/tool.cjs")).toBe("npm");
});

it("installs python dependencies before first invocation only", async () => {
const execRunner = vi.fn().mockReturnValue(Buffer.from(""));
const { createDependencyInstallGate, resetDependencyInstallStateForTests, setExecFileSyncRunnerForTests } = await import("./mcp_dependencies_manager.cjs");
resetDependencyInstallStateForTests();
setExecFileSyncRunnerForTests(execRunner);

const logger = { debug: vi.fn(), debugError: vi.fn() };
const gate = createDependencyInstallGate(logger, "fetch-url", "/tmp/fetch.py", ["requests"], "/tmp");
await gate();
await gate();

const installCalls = execRunner.mock.calls.filter(call => call[0] === "python3" && call[1][0] === "-m");
expect(installCalls).toHaveLength(1);
expect(installCalls[0][1]).toEqual(["-m", "pip", "install", "--disable-pip-version-check", "requests"]);
});

it("retries transient install failures", async () => {
const execRunner = vi
.fn()
.mockImplementationOnce(() => {
const error = new Error("timeout");
error.stderr = Buffer.from("network timeout");
throw error;
})
.mockReturnValueOnce(Buffer.from(""));
const { createDependencyInstallGate, resetDependencyInstallStateForTests, setExecFileSyncRunnerForTests } = await import("./mcp_dependencies_manager.cjs");
resetDependencyInstallStateForTests();
setExecFileSyncRunnerForTests(execRunner);

const logger = { debug: vi.fn(), debugError: vi.fn() };
const gate = createDependencyInstallGate(logger, "fetch-url", "/tmp/fetch.py", ["requests"], "/tmp");
await gate();

const installCalls = execRunner.mock.calls.filter(call => call[0] === "python3" && call[1][0] === "-m");
expect(installCalls).toHaveLength(2);
});

it("fails fast on deterministic install failures", async () => {
const execRunner = vi.fn().mockImplementation(() => {
const error = new Error("bad package");
error.stderr = Buffer.from("No matching distribution found for bad package");
throw error;
});
const { createDependencyInstallGate, resetDependencyInstallStateForTests, setExecFileSyncRunnerForTests } = await import("./mcp_dependencies_manager.cjs");
resetDependencyInstallStateForTests();
setExecFileSyncRunnerForTests(execRunner);

const logger = { debug: vi.fn(), debugError: vi.fn() };
const gate = createDependencyInstallGate(logger, "fetch-url", "/tmp/fetch.py", ["bad package"], "/tmp");

await expect(gate()).rejects.toThrow("Dependency installation failed");
const installCalls = execRunner.mock.calls.filter(call => call[0] === "python3" && call[1][0] === "-m");
expect(installCalls).toHaveLength(1);
});
});
1 change: 1 addition & 0 deletions actions/setup/js/mcp_scripts_config_loader.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const { ERR_SYSTEM, ERR_VALIDATION } = require("./error_codes.cjs");
* @property {Object} inputSchema - JSON Schema for tool inputs
* @property {string} [handler] - Path to handler file (.cjs, .sh, or .py)
* @property {number} [timeout] - Timeout in seconds for tool execution (default: 60)
* @property {string[]} [dependencies] - Runtime dependencies installed before first invocation
*/

/**
Expand Down
1 change: 1 addition & 0 deletions actions/setup/js/mcp_scripts_mcp_server.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const { getErrorMessage } = require("./error_helpers.cjs");
* @property {string} description - Tool description
* @property {Object} inputSchema - JSON Schema for tool inputs
* @property {string} [handler] - Path to handler file (.cjs, .sh, or .py)
* @property {string[]} [dependencies] - Runtime dependencies installed before first invocation
*/

/**
Expand Down
1 change: 1 addition & 0 deletions actions/setup/js/mcp_scripts_tool_factory.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
* @property {string} description - Tool description
* @property {Object} inputSchema - JSON Schema for tool inputs
* @property {string} handler - Path to handler file (.cjs, .sh, or .py)
* @property {string[]} [dependencies] - Runtime dependencies installed before first invocation
*/

/**
Expand Down
30 changes: 26 additions & 4 deletions actions/setup/js/mcp_server_core.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const { ReadBuffer } = require("./read_buffer.cjs");
const { validateRequiredFields, validateStringInputLengths } = require("./mcp_scripts_validation.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { generateEnhancedErrorMessage } = require("./mcp_enhanced_errors.cjs");
const { createDependencyInstallGate } = require("./mcp_dependencies_manager.cjs");

const encoder = new TextEncoder();
const PARAMETER_SIMILARITY_DISTANCE_BONUS = 2;
Expand All @@ -53,6 +54,7 @@ const UNKNOWN_PARAMETER_LIST_PREVIEW_MAX = 10;
* @property {Function} [handler] - Tool handler function
* @property {string} [handlerPath] - Optional file path to handler module (original path from config)
* @property {number} [timeout] - Timeout in seconds for tool execution (default: 60)
* @property {string[]} [dependencies] - Runtime dependencies to install before first invocation
*/

/**
Expand Down Expand Up @@ -391,7 +393,12 @@ function loadToolHandlers(server, tools, basePath) {
// Lazy-load shell handler module
const { createShellHandler } = require("./mcp_handler_shell.cjs");
const timeout = tool.timeout || 60; // Default to 60 seconds if not specified
tool.handler = createShellHandler(server, toolName, resolvedPath, timeout);
const baseHandler = createShellHandler(server, toolName, resolvedPath, timeout);
const ensureDependenciesInstalled = createDependencyInstallGate(server, toolName, resolvedPath, tool.dependencies, basePath || process.cwd());
tool.handler = async args => {
await ensureDependenciesInstalled();
return baseHandler(args);
};

loadedCount++;
server.debug(` [${toolName}] Shell handler created successfully with timeout: ${timeout}s`);
Expand All @@ -417,7 +424,12 @@ function loadToolHandlers(server, tools, basePath) {
// Lazy-load Python handler module
const { createPythonHandler } = require("./mcp_handler_python.cjs");
const timeout = tool.timeout || 60; // Default to 60 seconds if not specified
tool.handler = createPythonHandler(server, toolName, resolvedPath, timeout);
const baseHandler = createPythonHandler(server, toolName, resolvedPath, timeout);
const ensureDependenciesInstalled = createDependencyInstallGate(server, toolName, resolvedPath, tool.dependencies, basePath || process.cwd());
tool.handler = async args => {
await ensureDependenciesInstalled();
return baseHandler(args);
};

loadedCount++;
server.debug(` [${toolName}] Python handler created successfully with timeout: ${timeout}s`);
Expand All @@ -428,7 +440,12 @@ function loadToolHandlers(server, tools, basePath) {
// Lazy-load Go handler module
const { createGoHandler } = require("./mcp_handler_go.cjs");
const timeout = tool.timeout || 60; // Default to 60 seconds if not specified
tool.handler = createGoHandler(server, toolName, resolvedPath, timeout);
const baseHandler = createGoHandler(server, toolName, resolvedPath, timeout);
const ensureDependenciesInstalled = createDependencyInstallGate(server, toolName, resolvedPath, tool.dependencies, basePath || process.cwd());
tool.handler = async args => {
await ensureDependenciesInstalled();
return baseHandler(args);
};

loadedCount++;
server.debug(` [${toolName}] Go handler created successfully with timeout: ${timeout}s`);
Expand All @@ -439,7 +456,12 @@ function loadToolHandlers(server, tools, basePath) {
// Lazy-load JavaScript handler module
const { createJavaScriptHandler } = require("./mcp_handler_javascript.cjs");
const timeout = tool.timeout || 60; // Default to 60 seconds if not specified
tool.handler = createJavaScriptHandler(server, toolName, resolvedPath, timeout);
const baseHandler = createJavaScriptHandler(server, toolName, resolvedPath, timeout);
const ensureDependenciesInstalled = createDependencyInstallGate(server, toolName, resolvedPath, tool.dependencies, basePath || process.cwd());
tool.handler = async args => {
await ensureDependenciesInstalled();
return baseHandler(args);
};

loadedCount++;
server.debug(` [${toolName}] JavaScript handler created successfully with timeout: ${timeout}s`);
Expand Down
2 changes: 2 additions & 0 deletions actions/setup/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ MCP_SCRIPTS_FILES=(
"mcp_scripts_tool_factory.cjs"
"mcp_scripts_validation.cjs"
"mcp_server_core.cjs"
"mcp_dependencies_manager.cjs"
"mcp_logger.cjs"
"mcp_http_transport.cjs"
"mcp_http_server_runner.cjs"
Expand Down Expand Up @@ -273,6 +274,7 @@ SAFE_OUTPUTS_FILES=(
"allowed_extensions_helpers.cjs"
"safe_outputs_append.cjs"
"mcp_server_core.cjs"
"mcp_dependencies_manager.cjs"
"mcp_logger.cjs"
"mcp_http_transport.cjs"
"mcp_http_server_runner.cjs"
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/mcp-scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ mcp-scripts:
print(json.dumps(result))
```

Python 3.10+ is available with standard library modules. Install additional packages inline using pip if needed.
Python 3.10+ is available with standard library modules. For third-party packages, use the `dependencies:` field with exact version pins (for example, `requests==2.32.3`) so gh-aw installs them before first tool invocation.

## Go Tools (`go:`)

Expand Down
Loading
Loading