CVE-2025-53355
Description
MCP Server Kubernetes is an MCP Server that can connect to a Kubernetes cluster and manage it. A command injection vulnerability exists in the mcp-server-kubernetes MCP Server. The vulnerability is caused by the unsanitized use of input parameters within a call to child_process.execSync, enabling an attacker to inject arbitrary system commands. Successful exploitation can lead to remote code execution under the server process's privileges. This vulnerability is fixed in 2.5.0.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
mcp-server-kubernetesnpm | < 2.5.0 | 2.5.0 |
Affected products
1- Range: 0.2.4, 0.2.5, 0.3.0, …
Patches
30dbd6995ccdffix(security): patch command injection vulnerability
26 files changed · +519 −2771
CHANGELOG.md+5 −0 modified@@ -2,6 +2,11 @@ All notable changes to this project will be documented in this file. +## v2.1.5 - 2025-06-29 + +### Security +- Patched a command injection vulnerability where unsanitized user input could be passed to `child_process.exec`. All `exec` calls have been replaced with the safer `execFile` method, which treats arguments as distinct values rather than executable script parts. Thank you to [@dellalibera](https://github.com/dellalibera) for the disclosure. For more details, see the security advisory: [GHSA-3q26-f695-pp76](https://github.com/cyanheads/git-mcp-server/security/advisories/GHSA-3q26-f695-pp76). + ## v2.1.4 - 2025-06-20 ### Changed
package.json+2 −13 modified@@ -1,6 +1,6 @@ { "name": "@cyanheads/git-mcp-server", - "version": "2.1.4", + "version": "2.1.5", "description": "An MCP (Model Context Protocol) server enabling LLMs and AI agents to interact with Git repositories. Provides tools for comprehensive Git operations including clone, commit, branch, diff, log, status, push, pull, merge, rebase, worktree, tag management, and more, via the MCP standard. STDIO & HTTP.", "main": "dist/index.js", "files": [ @@ -36,21 +36,14 @@ }, "dependencies": { "@hono/node-server": "^1.14.4", - "@modelcontextprotocol/inspector": "^0.14.3", "@modelcontextprotocol/sdk": "^1.13.0", - "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.0.3", "@types/sanitize-html": "^2.16.0", "@types/validator": "^13.15.2", - "chalk": "^5.4.1", "chrono-node": "2.8.0", - "cli-table3": "^0.6.5", "dotenv": "^16.5.0", - "express": "^5.1.0", "hono": "^4.8.2", - "ignore": "^7.0.5", "jose": "^6.0.11", - "jsonwebtoken": "^9.0.2", "openai": "^5.6.0", "partial-json": "^0.1.7", "sanitize-html": "^2.17.0", @@ -59,8 +52,7 @@ "typescript": "^5.8.3", "validator": "^13.15.15", "winston": "^3.17.0", - "winston-daily-rotate-file": "^5.0.0", - "yargs": "^18.0.0", + "winston-transport": "^4.7.0", "zod": "^3.25.67" }, "keywords": [ @@ -113,9 +105,6 @@ "node": ">=20.0.0" }, "devDependencies": { - "@types/express": "^5.0.3", - "@types/js-yaml": "^4.0.9", - "js-yaml": "^4.1.0", "prettier": "^3.5.3", "typedoc": "^0.28.5" },
package-lock.json+48 −2314 modifiedsrc/mcp-server/tools/gitAdd/logic.ts+20 −58 modified@@ -1,4 +1,4 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; import { z } from "zod"; // Import utils from barrel (logger from ../utils/internal/logger.js) @@ -9,7 +9,7 @@ import { RequestContext } from "../../../utils/index.js"; // Import utils from barrel (sanitization from ../utils/security/sanitization.js) import { sanitization } from "../../../utils/index.js"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); // Define the input schema for the git_add tool using Zod export const GitAddInputSchema = z.object({ @@ -107,66 +107,28 @@ export async function addGitFiles( ); } - // Prepare the files argument for the command, ensuring proper quoting - let filesArg: string; - const filesToStage = input.files; // Keep original for reporting + const filesToStage = Array.isArray(input.files) + ? input.files + : [input.files]; + if (filesToStage.length === 0) { + filesToStage.push("."); // Default to staging all if array is empty + } + try { - if (Array.isArray(filesToStage)) { - if (filesToStage.length === 0) { - logger.warning( - "Empty array provided for files, defaulting to staging all changes.", - { ...context, operation }, - ); - filesArg = "."; // Default to staging all if array is empty - } else { - // Quote each file path individually - filesArg = filesToStage - .map((file) => { - const sanitizedFile = file.startsWith("-") ? `./${file}` : file; // Prefix with './' if it starts with a dash - return `"${sanitizedFile.replace(/"/g, '\\"')}"`; // Escape quotes within path - }) - .join(" "); - } - } else { - // Single string case - const sanitizedFile = filesToStage.startsWith("-") - ? `./${filesToStage}` - : filesToStage; // Prefix with './' if it starts with a dash - filesArg = `"${sanitizedFile.replace(/"/g, '\\"')}"`; - } - } catch (err) { - logger.error("File path validation/quoting failed", { + const args = ["-C", targetPath, "add", "--"]; + filesToStage.forEach((file) => { + // Sanitize each file path. Although execFile is safer, + // this prevents arguments like "-v" from being treated as flags by git. + const sanitizedFile = file.startsWith("-") ? `./${file}` : file; + args.push(sanitizedFile); + }); + + logger.debug(`Executing command: git ${args.join(" ")}`, { ...context, operation, - files: filesToStage, - error: err, }); - throw new McpError( - BaseErrorCode.VALIDATION_ERROR, - `Invalid file path/pattern provided: ${err instanceof Error ? err.message : String(err)}`, - { context, operation, originalError: err }, - ); - } - - // This check should ideally not be needed now due to the logic above - if (!filesArg) { - logger.error( - "Internal error: filesArg is unexpectedly empty after processing.", - { ...context, operation }, - ); - throw new McpError( - BaseErrorCode.INTERNAL_ERROR, - "Internal error preparing git add command.", - { context, operation }, - ); - } - - try { - // Use the resolved targetPath - const command = `git -C "${targetPath}" add -- ${filesArg}`; - logger.debug(`Executing command: ${command}`, { ...context, operation }); - const { stdout, stderr } = await execAsync(command); + const { stdout, stderr } = await execFileAsync("git", args); if (stderr) { // Log stderr as warning, as 'git add' can produce warnings but still succeed. @@ -216,7 +178,7 @@ export async function addGitFiles( // Still throw an error, but return structured info in the catch block of the registration throw new McpError( BaseErrorCode.NOT_FOUND, - `Specified files/patterns did not match any files in ${targetPath}: ${filesArg}`, + `Specified files/patterns did not match any files in ${targetPath}: ${filesToStage.join(", ")}`, { context, operation, originalError: error, filesStaged: filesToStage }, ); }
src/mcp-server/tools/gitBranch/logic.ts+38 −30 modified@@ -1,4 +1,4 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; import { z } from "zod"; // Import utils from barrel (logger from ../utils/internal/logger.js) @@ -9,7 +9,7 @@ import { RequestContext } from "../../../utils/index.js"; // Import utils from barrel (sanitization from ../utils/security/sanitization.js) import { sanitization } from "../../../utils/index.js"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); // Define the BASE input schema for the git_branch tool using Zod export const GitBranchBaseSchema = z.object({ @@ -221,22 +221,24 @@ export async function gitBranchLogic( } try { - let command: string; + let args: string[]; let result: GitBranchResult; switch (input.mode) { case "list": - command = `git -C "${targetPath}" branch --list --no-color`; // Start with basic list - if (input.all) - command += " -a"; // Add -a if requested - else if (input.remote) command += " -r"; // Add -r if requested (exclusive with -a) - command += " --verbose"; // Add verbose for commit info + args = ["-C", targetPath, "branch", "--list", "--no-color"]; // Start with basic list + if (input.all) { + args.push("-a"); // Add -a if requested + } else if (input.remote) { + args.push("-r"); // Add -r if requested (exclusive with -a) + } + args.push("--verbose"); // Add verbose for commit info - logger.debug(`Executing command: ${command}`, { + logger.debug(`Executing command: git ${args.join(" ")}`, { ...context, operation, }); - const { stdout: listStdout } = await execAsync(command); + const { stdout: listStdout } = await execFileAsync("git", args); const branches: BranchInfo[] = listStdout .trim() @@ -269,16 +271,20 @@ export async function gitBranchLogic( case "create": // branchName is validated by Zod refine - command = `git -C "${targetPath}" branch `; - if (input.force) command += "-f "; - command += `"${input.branchName!}"`; // branchName is guaranteed by refine - if (input.startPoint) command += ` "${input.startPoint}"`; + args = ["-C", targetPath, "branch"]; + if (input.force) { + args.push("-f"); + } + args.push(input.branchName!); // branchName is guaranteed by refine + if (input.startPoint) { + args.push(input.startPoint); + } - logger.debug(`Executing command: ${command}`, { + logger.debug(`Executing command: git ${args.join(" ")}`, { ...context, operation, }); - await execAsync(command); + await execFileAsync("git", args); result = { success: true, mode: "create", @@ -289,16 +295,18 @@ export async function gitBranchLogic( case "delete": // branchName is validated by Zod refine - command = `git -C "${targetPath}" branch `; - if (input.remote) command += "-r "; - command += input.force ? "-D " : "-d "; - command += `"${input.branchName!}"`; // branchName is guaranteed by refine + args = ["-C", targetPath, "branch"]; + if (input.remote) { + args.push("-r"); + } + args.push(input.force ? "-D" : "-d"); + args.push(input.branchName!); // branchName is guaranteed by refine - logger.debug(`Executing command: ${command}`, { + logger.debug(`Executing command: git ${args.join(" ")}`, { ...context, operation, }); - const { stdout: deleteStdout } = await execAsync(command); + const { stdout: deleteStdout } = await execFileAsync("git", args); result = { success: true, mode: "delete", @@ -312,15 +320,15 @@ export async function gitBranchLogic( case "rename": // branchName and newBranchName validated by Zod refine - command = `git -C "${targetPath}" branch `; - command += input.force ? "-M " : "-m "; - command += `"${input.branchName!}" "${input.newBranchName!}"`; + args = ["-C", targetPath, "branch"]; + args.push(input.force ? "-M" : "-m"); + args.push(input.branchName!, input.newBranchName!); - logger.debug(`Executing command: ${command}`, { + logger.debug(`Executing command: git ${args.join(" ")}`, { ...context, operation, }); - await execAsync(command); + await execFileAsync("git", args); result = { success: true, mode: "rename", @@ -331,13 +339,13 @@ export async function gitBranchLogic( break; case "show-current": - command = `git -C "${targetPath}" branch --show-current`; - logger.debug(`Executing command: ${command}`, { + args = ["-C", targetPath, "branch", "--show-current"]; + logger.debug(`Executing command: git ${args.join(" ")}`, { ...context, operation, }); try { - const { stdout: currentStdout } = await execAsync(command); + const { stdout: currentStdout } = await execFileAsync("git", args); const currentBranchName = currentStdout.trim(); result = { success: true,
src/mcp-server/tools/gitCheckout/logic.ts+17 −15 modified@@ -1,4 +1,4 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; import { z } from "zod"; // Import utils from barrel (logger from ../utils/internal/logger.js) @@ -9,7 +9,7 @@ import { RequestContext } from "../../../utils/index.js"; // Import utils from barrel (sanitization from ../utils/security/sanitization.js) import { sanitization } from "../../../utils/index.js"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); // Define the input schema for the git_checkout tool using Zod export const GitCheckoutInputSchema = z.object({ @@ -113,27 +113,26 @@ export async function checkoutGit( ); } - // Basic sanitization for branch/path argument - const safeBranchOrPath = input.branchOrPath.replace(/[`$&;*()|<>]/g, ""); // Remove potentially dangerous characters - try { // Construct the git checkout command - let command = `git -C "${targetPath}" checkout`; + const args = ["-C", targetPath, "checkout"]; if (input.force) { - command += " --force"; + args.push("--force"); } if (input.newBranch) { - const safeNewBranch = input.newBranch.replace(/[^a-zA-Z0-9_.\-/]/g, ""); // Sanitize new branch name - command += ` -b ${safeNewBranch}`; + args.push("-b", input.newBranch); } - command += ` ${safeBranchOrPath}`; // Add the target branch/path + args.push(input.branchOrPath); // Add the target branch/path - logger.debug(`Executing command: ${command}`, { ...context, operation }); + logger.debug(`Executing command: git ${args.join(" ")}`, { + ...context, + operation, + }); // Execute command. Checkout often uses stderr for status messages. - const { stdout, stderr } = await execAsync(command); + const { stdout, stderr } = await execFileAsync("git", args); const message = stderr.trim() || stdout.trim(); logger.debug(`Git checkout stdout: ${stdout}`, { ...context, operation }); @@ -144,9 +143,12 @@ export async function checkoutGit( // Get the current branch name after the checkout operation let currentBranch: string | undefined; try { - const { stdout: branchStdout } = await execAsync( - `git -C "${targetPath}" branch --show-current`, - ); + const { stdout: branchStdout } = await execFileAsync("git", [ + "-C", + targetPath, + "branch", + "--show-current", + ]); currentBranch = branchStdout.trim(); } catch (e) { // This can fail in detached HEAD state, which is not an error for checkout
src/mcp-server/tools/gitCherryPick/logic.ts+22 −11 modified@@ -1,4 +1,4 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; import { z } from "zod"; // Import utils from barrel (logger from ../utils/internal/logger.js) @@ -9,7 +9,7 @@ import { RequestContext } from "../../../utils/index.js"; // Import utils from barrel (sanitization from ../utils/security/sanitization.js) import { sanitization } from "../../../utils/index.js"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); // Define the input schema for the git_cherry-pick tool using Zod export const GitCherryPickInputSchema = z.object({ @@ -140,20 +140,31 @@ export async function gitCherryPickLogic( } try { - let command = `git -C "${targetPath}" cherry-pick`; + const args = ["-C", targetPath, "cherry-pick"]; - if (input.mainline) command += ` -m ${input.mainline}`; - if (input.strategy) command += ` -X${input.strategy}`; // Note: -X for strategy options - if (input.noCommit) command += " --no-commit"; - if (input.signoff) command += " --signoff"; + if (input.mainline) { + args.push("-m", String(input.mainline)); + } + if (input.strategy) { + args.push(`-X${input.strategy}`); + } // Note: -X for strategy options + if (input.noCommit) { + args.push("--no-commit"); + } + if (input.signoff) { + args.push("--signoff"); + } - // Add the commit reference(s) - ensure it's treated as a single argument potentially containing special chars like '..' - command += ` "${input.commitRef.replace(/"/g, '\\"')}"`; + // Add the commit reference(s) + args.push(input.commitRef); - logger.debug(`Executing command: ${command}`, { ...context, operation }); + logger.debug(`Executing command: git ${args.join(" ")}`, { + ...context, + operation, + }); try { - const { stdout, stderr } = await execAsync(command); + const { stdout, stderr } = await execFileAsync("git", args); // Check stdout/stderr for conflict messages, although exit code 0 usually means success const output = stdout + stderr; const conflicts = /conflict/i.test(output);
src/mcp-server/tools/gitClean/logic.ts+11 −8 modified@@ -1,4 +1,4 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; import { z } from "zod"; // Import utils from barrel (logger from ../utils/internal/logger.js) @@ -9,7 +9,7 @@ import { RequestContext } from "../../../utils/index.js"; // Import utils from barrel (sanitization from ../utils/security/sanitization.js) import { sanitization } from "../../../utils/index.js"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); // Define the input schema for the git_clean tool using Zod // No refinements needed here, but the 'force' check is critical in the logic @@ -155,20 +155,23 @@ export async function gitCleanLogic( try { // Construct the command // Force (-f) is always added because the logic checks input.force - let command = `git -C "${targetPath}" clean -f`; + const args = ["-C", targetPath, "clean", "-f"]; if (input.dryRun) { - command += " -n"; + args.push("-n"); } if (input.directories) { - command += " -d"; + args.push("-d"); } if (input.ignored) { - command += " -x"; + args.push("-x"); } - logger.debug(`Executing command: ${command}`, { ...context, operation }); + logger.debug(`Executing command: git ${args.join(" ")}`, { + ...context, + operation, + }); - const { stdout, stderr } = await execAsync(command); + const { stdout, stderr } = await execFileAsync("git", args); if (stderr) { // Log stderr as warning, as git clean might report non-fatal issues here
src/mcp-server/tools/gitClone/logic.ts+15 −11 modified@@ -1,4 +1,4 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import fs from "fs/promises"; import { promisify } from "util"; import { z } from "zod"; @@ -10,7 +10,7 @@ import { RequestContext } from "../../../utils/index.js"; // Import utils from barrel (sanitization from ../utils/security/sanitization.js) import { sanitization } from "../../../utils/index.js"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); // Define the input schema for the git_clone tool using Zod export const GitCloneInputSchema = z.object({ @@ -152,24 +152,28 @@ export async function gitCloneLogic( try { // Construct the git clone command - // Use placeholders and pass args safely if possible, but exec requires string command. Be careful with quoting. - let command = `git clone`; + const args = ["clone"]; if (input.quiet) { - command += " --quiet"; + args.push("--quiet"); } if (input.branch) { - command += ` --branch "${input.branch.replace(/"/g, '\\"')}"`; + args.push("--branch", input.branch); } if (input.depth) { - command += ` --depth ${input.depth}`; + args.push("--depth", String(input.depth)); } - // Add repo URL and target path (ensure they are quoted) - command += ` "${sanitizedRepoUrl}" "${sanitizedTargetPath}"`; + // Add repo URL and target path + args.push(sanitizedRepoUrl, sanitizedTargetPath); - logger.debug(`Executing command: ${command}`, { ...context, operation }); + logger.debug(`Executing command: git ${args.join(" ")}`, { + ...context, + operation, + }); // Increase timeout for clone operations as they can take time - const { stdout, stderr } = await execAsync(command, { timeout: 300000 }); // 5 minutes timeout + const { stdout, stderr } = await execFileAsync("git", args, { + timeout: 300000, + }); // 5 minutes timeout if (stderr && !input.quiet) { // Stderr often contains progress info, log as info if quiet is false
src/mcp-server/tools/gitCommit/logic.ts+36 −65 modified@@ -1,4 +1,4 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; import { z } from "zod"; import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; // Keep direct import for types-global @@ -11,7 +11,7 @@ import { sanitization } from "../../../utils/index.js"; // Import config to check signing flag import { config } from "../../../config/index.js"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); // Define the input schema for the git_commit tool using Zod export const GitCommitInputSchema = z.object({ @@ -157,15 +157,12 @@ export async function commitGitChanges( sanitization.sanitizePath(file, { rootDir: targetPath }) .sanitizedPath, ); // Sanitize relative to repo root - const filesToAddString = sanitizedFiles - .map((file) => `"${file}"`) - .join(" "); // Quote paths for safety - const addCommand = `git -C "${targetPath}" add -- ${filesToAddString}`; - logger.debug(`Executing git add command: ${addCommand}`, { + const addArgs = ["-C", targetPath, "add", "--", ...sanitizedFiles]; + logger.debug(`Executing git add command: git ${addArgs.join(" ")}`, { ...context, operation, }); - await execAsync(addCommand); + await execFileAsync("git", addArgs); logger.info( `Successfully staged specified files: ${sanitizedFiles.join(", ")}`, { ...context, operation }, @@ -187,49 +184,37 @@ export async function commitGitChanges( } // --- End staging files --- - // Escape message for shell safety - const escapeShellArg = (arg: string): string => { - // Escape backslashes first, then other special chars - return arg - .replace(/\\/g, "\\\\") - .replace(/"/g, '\\"') - .replace(/`/g, "\\`") - .replace(/\$/g, "\\$"); - }; - const escapedMessage = escapeShellArg(input.message); - // Construct the git commit command using the resolved targetPath - let command = `git -C "${targetPath}" commit -m "${escapedMessage}"`; + const args = ["-C", targetPath]; + + if (input.author) { + args.push( + "-c", + `user.name=${input.author.name}`, + "-c", + `user.email=${input.author.email}`, + ); + } + + args.push("commit", "-m", input.message); if (input.allowEmpty) { - command += " --allow-empty"; + args.push("--allow-empty"); } if (input.amend) { - command += " --amend --no-edit"; - } - if (input.author) { - // Escape author details as well - const escapedAuthorName = escapeShellArg(input.author.name); - const escapedAuthorEmail = escapeShellArg(input.author.email); // Email typically safe, but escape anyway - // Use -c flags to override author for this commit, using the already escaped message - command = `git -C "${targetPath}" -c user.name="${escapedAuthorName}" -c user.email="${escapedAuthorEmail}" commit -m "${escapedMessage}"`; + args.push("--amend", "--no-edit"); } - // Append common flags (ensure they are appended to the potentially modified command from author block) - if (input.allowEmpty && !command.includes(" --allow-empty")) - command += " --allow-empty"; - if (input.amend && !command.includes(" --amend")) - command += " --amend --no-edit"; // Avoid double adding if author block modified command // Append signing flag if configured via GIT_SIGN_COMMITS env var if (config.gitSignCommits) { - command += " -S"; // Add signing flag (-S) + args.push("-S"); // Add signing flag (-S) logger.info( "Signing enabled via GIT_SIGN_COMMITS=true, adding -S flag.", { ...context, operation }, ); } - logger.debug(`Executing initial command attempt: ${command}`, { + logger.debug(`Executing initial command attempt: git ${args.join(" ")}`, { ...context, operation, }); @@ -240,7 +225,7 @@ export async function commitGitChanges( try { // Initial attempt (potentially with -S flag) - const execResult = await execAsync(command); + const execResult = await execFileAsync("git", args); stdout = execResult.stdout; stderr = execResult.stderr; } catch (error: any) { @@ -255,38 +240,17 @@ export async function commitGitChanges( { ...context, operation, initialError: initialErrorMessage }, ); - // Construct command *without* -S flag, using escaped message/author - const escapeShellArg = (arg: string): string => { - return arg - .replace(/\\/g, "\\\\") - .replace(/"/g, '\\"') - .replace(/`/g, "\\`") - .replace(/\$/g, "\\$"); - }; - const escapedMessage = escapeShellArg(input.message); - let unsignedCommand = `git -C "${targetPath}" commit -m "${escapedMessage}"`; - - if (input.allowEmpty) unsignedCommand += " --allow-empty"; - if (input.amend) unsignedCommand += " --amend --no-edit"; - if (input.author) { - const escapedAuthorName = escapeShellArg(input.author.name); - const escapedAuthorEmail = escapeShellArg(input.author.email); - unsignedCommand = `git -C "${targetPath}" -c user.name="${escapedAuthorName}" -c user.email="${escapedAuthorEmail}" commit -m "${escapedMessage}"`; - // Re-append common flags if author block overwrote command - if (input.allowEmpty && !unsignedCommand.includes(" --allow-empty")) - unsignedCommand += " --allow-empty"; - if (input.amend && !unsignedCommand.includes(" --amend")) - unsignedCommand += " --amend --no-edit"; - } + // Construct command *without* -S flag + const unsignedArgs = args.filter((arg) => arg !== "-S"); logger.debug( - `Executing unsigned fallback command: ${unsignedCommand}`, + `Executing unsigned fallback command: git ${unsignedArgs.join(" ")}`, { ...context, operation }, ); try { // Retry commit without signing - const fallbackResult = await execAsync(unsignedCommand); + const fallbackResult = await execFileAsync("git", unsignedArgs); stdout = fallbackResult.stdout; stderr = fallbackResult.stderr; // Add a note to the status message indicating signing was skipped @@ -359,12 +323,19 @@ export async function commitGitChanges( if (commitHash) { try { // Get the list of files included in this specific commit - const showCommand = `git -C "${targetPath}" show --pretty="" --name-only ${commitHash}`; - logger.debug(`Executing git show command: ${showCommand}`, { + const showArgs = [ + "-C", + targetPath, + "show", + "--pretty=", + "--name-only", + commitHash, + ]; + logger.debug(`Executing git show command: git ${showArgs.join(" ")}`, { ...context, operation, }); - const { stdout: showStdout } = await execAsync(showCommand); + const { stdout: showStdout } = await execFileAsync("git", showArgs); committedFiles = showStdout.trim().split("\n").filter(Boolean); // Split by newline, remove empty lines logger.debug(`Retrieved committed files list for ${commitHash}`, { ...context,
src/mcp-server/tools/gitDiff/logic.ts+41 −21 modified@@ -1,4 +1,4 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; import { z } from "zod"; // Import utils from barrel (logger from ../utils/internal/logger.js) @@ -9,7 +9,7 @@ import { RequestContext } from "../../../utils/index.js"; // Import utils from barrel (sanitization from ../utils/security/sanitization.js) import { sanitization } from "../../../utils/index.js"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); // Define the base input schema without refinement const GitDiffInputBaseSchema = z.object({ @@ -144,35 +144,38 @@ export async function diffGitChanges( try { // Construct the standard git diff command - let standardDiffCommand = `git -C "${targetPath}" diff`; + const standardDiffArgs = ["-C", targetPath, "diff"]; if (input.staged) { - standardDiffCommand += " --staged"; // Or --cached + standardDiffArgs.push("--staged"); // Or --cached } else { // Add commit references if not doing staged diff if (safeCommit1) { - standardDiffCommand += ` ${safeCommit1}`; + standardDiffArgs.push(safeCommit1); } if (safeCommit2) { - standardDiffCommand += ` ${safeCommit2}`; + standardDiffArgs.push(safeCommit2); } } // Add file path limiter if provided for standard diff // Note: `input.file` will not apply to the untracked files part unless we explicitly filter them. // For simplicity, `includeUntracked` will show all untracked files if `input.file` is also set. if (safeFile) { - standardDiffCommand += ` -- "${safeFile}"`; // Use '--' to separate paths from revisions + standardDiffArgs.push("--", safeFile); // Use '--' to separate paths from revisions } - logger.debug(`Executing standard diff command: ${standardDiffCommand}`, { - ...context, - operation, - }); - const { stdout: standardStdout, stderr: standardStderr } = await execAsync( - standardDiffCommand, - { maxBuffer: 1024 * 1024 * 20 }, + logger.debug( + `Executing standard diff command: git ${standardDiffArgs.join(" ")}`, + { + ...context, + operation, + }, ); + const { stdout: standardStdout, stderr: standardStderr } = + await execFileAsync("git", standardDiffArgs, { + maxBuffer: 1024 * 1024 * 20, + }); if (standardStderr) { logger.warning(`Git diff (standard) stderr: ${standardStderr}`, { @@ -185,10 +188,18 @@ export async function diffGitChanges( // Handle untracked files if requested if (input.includeUntracked) { logger.debug("Including untracked files.", { ...context, operation }); - const listUntrackedCommand = `git -C "${targetPath}" ls-files --others --exclude-standard`; + const listUntrackedArgs = [ + "-C", + targetPath, + "ls-files", + "--others", + "--exclude-standard", + ]; try { - const { stdout: untrackedFilesStdOut } = - await execAsync(listUntrackedCommand); + const { stdout: untrackedFilesStdOut } = await execFileAsync( + "git", + listUntrackedArgs, + ); const untrackedFiles = untrackedFilesStdOut .trim() .split("\n") @@ -210,14 +221,23 @@ export async function diffGitChanges( // Skip if file path becomes empty after sanitization (unlikely but safe) if (!safeUntrackedFile) continue; - const untrackedDiffCommand = `git -C "${targetPath}" diff --no-index /dev/null "${safeUntrackedFile}"`; + const untrackedDiffArgs = [ + "-C", + targetPath, + "diff", + "--no-index", + "/dev/null", + safeUntrackedFile, + ]; logger.debug( - `Executing diff for untracked file: ${untrackedDiffCommand}`, + `Executing diff for untracked file: git ${untrackedDiffArgs.join(" ")}`, { ...context, operation, file: safeUntrackedFile }, ); try { - const { stdout: untrackedFileDiffOut } = - await execAsync(untrackedDiffCommand); + const { stdout: untrackedFileDiffOut } = await execFileAsync( + "git", + untrackedDiffArgs, + ); individualUntrackedDiffs += untrackedFileDiffOut; untrackedFilesCount++; } catch (untrackedError: any) {
src/mcp-server/tools/gitFetch/logic.ts+13 −13 modified@@ -1,4 +1,4 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; import { z } from "zod"; // Import utils from barrel (logger from ../utils/internal/logger.js) @@ -9,7 +9,7 @@ import { RequestContext } from "../../../utils/index.js"; // Import utils from barrel (sanitization from ../utils/security/sanitization.js) import { sanitization } from "../../../utils/index.js"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); // Define the input schema for the git_fetch tool using Zod export const GitFetchInputSchema = z.object({ @@ -111,30 +111,30 @@ export async function fetchGitRemote( ); } - // Basic sanitization for remote name - const safeRemote = input.remote?.replace(/[^a-zA-Z0-9_.\-/]/g, ""); - try { // Construct the git fetch command - let command = `git -C "${targetPath}" fetch`; + const args = ["-C", targetPath, "fetch"]; if (input.prune) { - command += " --prune"; + args.push("--prune"); } if (input.tags) { - command += " --tags"; + args.push("--tags"); } if (input.all) { - command += " --all"; - } else if (safeRemote) { - command += ` ${safeRemote}`; // Fetch specific remote if 'all' is not used + args.push("--all"); + } else if (input.remote) { + args.push(input.remote); // Fetch specific remote if 'all' is not used } // If neither 'all' nor 'remote' is specified, git fetch defaults to 'origin' or configured upstream. - logger.debug(`Executing command: ${command}`, { ...context, operation }); + logger.debug(`Executing command: git ${args.join(" ")}`, { + ...context, + operation, + }); // Execute command. Fetch output is primarily on stderr. - const { stdout, stderr } = await execAsync(command); + const { stdout, stderr } = await execFileAsync("git", args); logger.debug(`Git fetch stdout: ${stdout}`, { ...context, operation }); // stdout is usually empty if (stderr) {
src/mcp-server/tools/gitInit/logic.ts+12 −9 modified@@ -1,4 +1,4 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import fs from "fs/promises"; import path from "path"; import { promisify } from "util"; @@ -11,7 +11,7 @@ import { RequestContext } from "../../../utils/index.js"; // Import utils from barrel (sanitization from ../utils/security/sanitization.js) import { sanitization } from "../../../utils/index.js"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); // Define the input schema for the git_init tool using Zod export const GitInitInputSchema = z.object({ @@ -119,23 +119,26 @@ export async function gitInitLogic( try { // Construct the git init command - let command = `git init`; + const args: string[] = ["init"]; if (input.quiet) { - command += " --quiet"; + args.push("--quiet"); } if (input.bare) { - command += " --bare"; + args.push("--bare"); } // Determine the initial branch name, defaulting to 'main' if not provided const branchNameToUse = input.initialBranch || "main"; - command += ` -b "${branchNameToUse.replace(/"/g, '\\"')}"`; + args.push("-b", branchNameToUse); // Add the target directory path at the end - command += ` "${targetPath}"`; + args.push(targetPath); - logger.debug(`Executing command: ${command}`, { ...context, operation }); + logger.debug(`Executing command: git ${args.join(" ")}`, { + ...context, + operation, + }); - const { stdout, stderr } = await execAsync(command); + const { stdout, stderr } = await execFileAsync("git", args); if (stderr && !input.quiet) { // Log stderr as warning but proceed, as init might still succeed (e.g., reinitializing)
src/mcp-server/tools/gitLog/logic.ts+18 −28 modified@@ -1,4 +1,4 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; import { z } from "zod"; // Import utils from barrel (logger from ../utils/internal/logger.js) @@ -9,7 +9,7 @@ import { RequestContext } from "../../../utils/index.js"; // Import utils from barrel (sanitization from ../utils/security/sanitization.js) import { sanitization } from "../../../utils/index.js"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); // Define the structure for a single commit entry export const CommitEntrySchema = z.object({ @@ -171,53 +171,43 @@ export async function logGitHistory( } try { - let command: string; + const args = ["-C", targetPath, "log"]; let isRawOutput = false; // Flag to indicate if we should parse or return raw if (input.showSignature) { isRawOutput = true; - command = `git -C "${targetPath}" log --show-signature`; + args.push("--show-signature"); logger.info("Show signature requested, returning raw output.", { ...context, operation, }); - // Append other filters if provided - if (input.maxCount) command += ` -n ${input.maxCount}`; - if (input.author) - command += ` --author="${input.author.replace(/[`"$&;*()|<>]/g, "")}"`; - if (input.since) - command += ` --since="${input.since.replace(/[`"$&;*()|<>]/g, "")}"`; - if (input.until) - command += ` --until="${input.until.replace(/[`"$&;*()|<>]/g, "")}"`; - if (input.branchOrFile) - command += ` ${input.branchOrFile.replace(/[`"$&;*()|<>]/g, "")}`; } else { - // Construct the git log command with the fixed format for parsing - command = `git -C "${targetPath}" log ${GIT_LOG_FORMAT}`; - if (input.maxCount) command += ` -n ${input.maxCount}`; + args.push(GIT_LOG_FORMAT); + } + + if (input.maxCount) { + args.push(`-n${input.maxCount}`); } if (input.author) { - // Basic sanitization for author string - const safeAuthor = input.author.replace(/[`"$&;*()|<>]/g, ""); - command += ` --author="${safeAuthor}"`; + args.push(`--author=${input.author}`); } if (input.since) { - const safeSince = input.since.replace(/[`"$&;*()|<>]/g, ""); - command += ` --since="${safeSince}"`; + args.push(`--since=${input.since}`); } if (input.until) { - const safeUntil = input.until.replace(/[`"$&;*()|<>]/g, ""); - command += ` --until="${safeUntil}"`; + args.push(`--until=${input.until}`); } if (input.branchOrFile) { - const safeBranchOrFile = input.branchOrFile.replace(/[`"$&;*()|<>]/g, ""); - command += ` ${safeBranchOrFile}`; // Add branch or file path at the end + args.push(input.branchOrFile); } - logger.debug(`Executing command: ${command}`, { ...context, operation }); + logger.debug(`Executing command: git ${args.join(" ")}`, { + ...context, + operation, + }); // Increase maxBuffer if logs can be large - const { stdout, stderr } = await execAsync(command, { + const { stdout, stderr } = await execFileAsync("git", args, { maxBuffer: 1024 * 1024 * 10, }); // 10MB buffer
src/mcp-server/tools/gitMerge/logic.ts+17 −10 modified@@ -1,4 +1,4 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; import { z } from "zod"; // Import utils from barrel (logger from ../utils/internal/logger.js) @@ -10,7 +10,7 @@ import { RequestContext } from "../../../utils/index.js"; import path from "path"; // Import path module import { sanitization } from "../../../utils/index.js"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); // Define the input schema for the git_merge tool export const GitMergeInputSchema = z.object({ @@ -150,31 +150,38 @@ export async function gitMergeLogic( } // --- Construct the git merge command --- - let command = `git -C "${targetPath}" merge`; + const args = ["-C", targetPath, "merge"]; if (input.abort) { - command += " --abort"; + args.push("--abort"); } else { // Standard merge options - if (input.noFf) command += " --no-ff"; - if (input.squash) command += " --squash"; + if (input.noFf) { + args.push("--no-ff"); + } + if (input.squash) { + args.push("--squash"); + } if (input.commitMessage && !input.squash) { // Commit message only relevant if not squashing (squash requires separate commit) - command += ` -m "${input.commitMessage.replace(/"/g, '\\"')}"`; + args.push("-m", input.commitMessage); } else if (input.squash && input.commitMessage) { logger.warning( "Commit message provided with --squash, but it will be ignored. Squash requires a separate commit.", { ...context, operation }, ); } - command += ` "${input.branch.replace(/"/g, '\\"')}"`; // Add branch to merge + args.push(input.branch); // Add branch to merge } - logger.debug(`Executing command: ${command}`, { ...context, operation }); + logger.debug(`Executing command: git ${args.join(" ")}`, { + ...context, + operation, + }); // --- Execute and Parse --- try { - const { stdout, stderr } = await execAsync(command); + const { stdout, stderr } = await execFileAsync("git", args); logger.debug(`Command stdout: ${stdout}`, { ...context, operation }); if (stderr) logger.debug(`Command stderr: ${stderr}`, { ...context, operation }); // Log stderr even on success
src/mcp-server/tools/gitPull/logic.ts+13 −14 modified@@ -1,4 +1,4 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; import { z } from "zod"; // Import utils from barrel (logger from ../utils/internal/logger.js) @@ -9,7 +9,7 @@ import { RequestContext } from "../../../utils/index.js"; // Import utils from barrel (sanitization from ../utils/security/sanitization.js) import { sanitization } from "../../../utils/index.js"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); // Define the input schema for the git_pull tool using Zod export const GitPullInputSchema = z.object({ @@ -125,35 +125,34 @@ export async function pullGitChanges( try { // Construct the git pull command - let command = `git -C "${targetPath}" pull`; + const args = ["-C", targetPath, "pull"]; if (input.rebase) { - command += " --rebase"; + args.push("--rebase"); } if (input.ffOnly) { - command += " --ff-only"; + args.push("--ff-only"); } if (input.remote) { - // Sanitize remote and branch names - basic alphanumeric + common chars - const safeRemote = input.remote.replace(/[^a-zA-Z0-9_.\-/]/g, ""); - command += ` ${safeRemote}`; + args.push(input.remote); if (input.branch) { - const safeBranch = input.branch.replace(/[^a-zA-Z0-9_.\-/]/g, ""); - command += ` ${safeBranch}`; + args.push(input.branch); } } else if (input.branch) { // If only branch is specified, assume 'origin' or tracked remote - const safeBranch = input.branch.replace(/[^a-zA-Z0-9_.\-/]/g, ""); - command += ` origin ${safeBranch}`; // Defaulting to origin if remote not specified but branch is + args.push("origin", input.branch); // Defaulting to origin if remote not specified but branch is logger.warning( `Remote not specified, defaulting to 'origin' for branch pull`, { ...context, operation }, ); } - logger.debug(`Executing command: ${command}`, { ...context, operation }); + logger.debug(`Executing command: git ${args.join(" ")}`, { + ...context, + operation, + }); - const { stdout, stderr } = await execAsync(command); + const { stdout, stderr } = await execFileAsync("git", args); logger.debug(`Git pull stdout: ${stdout}`, { ...context, operation }); if (stderr) {
src/mcp-server/tools/gitPush/logic.ts+18 −24 modified@@ -1,4 +1,4 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; import { z } from "zod"; // Import utils from barrel (logger from ../utils/internal/logger.js) @@ -9,7 +9,7 @@ import { RequestContext } from "../../../utils/index.js"; // Import utils from barrel (sanitization from ../utils/security/sanitization.js) import { sanitization } from "../../../utils/index.js"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); // Define the input schema for the git_push tool using Zod export const GitPushInputSchema = z.object({ @@ -171,55 +171,49 @@ export async function pushGitChanges( try { // Construct the git push command - let command = `git -C "${targetPath}" push`; + const args = ["-C", targetPath, "push"]; if (input.force) { - command += " --force"; + args.push("--force"); } else if (input.forceWithLease) { - command += " --force-with-lease"; + args.push("--force-with-lease"); } if (input.setUpstream) { - command += " --set-upstream"; + args.push("--set-upstream"); } if (input.tags) { - command += " --tags"; + args.push("--tags"); } if (input.delete) { - command += " --delete"; + args.push("--delete"); } // Add remote and branch specification - const remote = input.remote - ? input.remote.replace(/[^a-zA-Z0-9_.\-/]/g, "") - : "origin"; // Default to origin - command += ` ${remote}`; + const remote = input.remote || "origin"; // Default to origin + args.push(remote); if (input.branch) { - const localBranch = input.branch.replace(/[^a-zA-Z0-9_.\-/]/g, ""); - command += ` ${localBranch}`; if (input.remoteBranch && !input.delete) { - // remoteBranch only makes sense if not deleting - const remoteBranch = input.remoteBranch.replace( - /[^a-zA-Z0-9_.\-/]/g, - "", - ); - command += `:${remoteBranch}`; + args.push(`${input.branch}:${input.remoteBranch}`); + } else { + args.push(input.branch); } } else if (!input.tags && !input.delete) { // If no branch, tags, or delete specified, push the current branch by default - // Git might handle this automatically, but being explicit can be clearer - // command += ' HEAD'; // Or let git figure out the default push behavior logger.debug( "No specific branch, tags, or delete specified. Relying on default git push behavior for current branch.", { ...context, operation }, ); } - logger.debug(`Executing command: ${command}`, { ...context, operation }); + logger.debug(`Executing command: git ${args.join(" ")}`, { + ...context, + operation, + }); // Execute command. Note: Git push often uses stderr for progress and success messages. - const { stdout, stderr } = await execAsync(command); + const { stdout, stderr } = await execFileAsync("git", args); logger.debug(`Git push stdout: ${stdout}`, { ...context, operation }); if (stderr) {
src/mcp-server/tools/gitRebase/logic.ts+29 −16 modified@@ -1,4 +1,4 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; import { z } from "zod"; // Import utils from barrel (logger from ../utils/internal/logger.js) @@ -9,7 +9,7 @@ import { RequestContext } from "../../../utils/index.js"; // Import utils from barrel (sanitization from ../utils/security/sanitization.js) import { sanitization } from "../../../utils/index.js"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); // Define the BASE input schema for the git_rebase tool using Zod export const GitRebaseBaseSchema = z.object({ @@ -182,28 +182,38 @@ export async function gitRebaseLogic( } try { - let command = `git -C "${targetPath}" rebase`; + const args = ["-C", targetPath, "rebase"]; switch (input.mode) { case "start": - if (input.interactive) command += " -i"; - if (input.strategy) command += ` --strategy=${input.strategy}`; - if (input.strategyOption) command += ` -X${input.strategyOption}`; // Note: -X for strategy options - if (input.onto) - command += ` --onto "${input.onto.replace(/"/g, '\\"')}"`; + if (input.interactive) { + args.push("-i"); + } + if (input.strategy) { + args.push(`--strategy=${input.strategy}`); + } + if (input.strategyOption) { + args.push(`-X${input.strategyOption}`); + } // Note: -X for strategy options + if (input.onto) { + args.push("--onto", input.onto); + } // Upstream is required by refine unless interactive - if (input.upstream) - command += ` "${input.upstream.replace(/"/g, '\\"')}"`; - if (input.branch) command += ` "${input.branch.replace(/"/g, '\\"')}"`; + if (input.upstream) { + args.push(input.upstream); + } + if (input.branch) { + args.push(input.branch); + } break; case "continue": - command += " --continue"; + args.push("--continue"); break; case "abort": - command += " --abort"; + args.push("--abort"); break; case "skip": - command += " --skip"; + args.push("--skip"); break; default: // Should not happen due to Zod validation @@ -214,10 +224,13 @@ export async function gitRebaseLogic( ); } - logger.debug(`Executing command: ${command}`, { ...context, operation }); + logger.debug(`Executing command: git ${args.join(" ")}`, { + ...context, + operation, + }); try { - const { stdout, stderr } = await execAsync(command); + const { stdout, stderr } = await execFileAsync("git", args); const output = stdout + stderr; const message = `Rebase ${input.mode} executed successfully. Output: ${output.trim()}`;
src/mcp-server/tools/gitRemote/logic.ts+15 −15 modified@@ -1,10 +1,10 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; import { z } from "zod"; import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; // Direct import for types-global import { logger, RequestContext, sanitization } from "../../../utils/index.js"; // Logger (./utils/internal/logger.js) & RequestContext (./utils/internal/requestContext.js) & sanitization (./utils/security/sanitization.js) -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); // Define the input schema for the git_remote tool using Zod export const GitRemoteInputSchema = z.object({ @@ -139,17 +139,17 @@ export async function gitRemoteLogic( } try { - let command: string; + let args: string[]; let result: GitRemoteResult; switch (input.mode) { case "list": - command = `git -C "${targetPath}" remote -v`; - logger.debug(`Executing command: ${command}`, { + args = ["-C", targetPath, "remote", "-v"]; + logger.debug(`Executing command: git ${args.join(" ")}`, { ...context, operation, }); - const { stdout: listStdout } = await execAsync(command); + const { stdout: listStdout } = await execFileAsync("git", args); const remotes: GitRemoteListResult["remotes"] = []; const lines = listStdout.trim().split("\n"); const remoteMap = new Map< @@ -202,12 +202,12 @@ export async function gitRemoteLogic( { context, operation }, ); } - command = `git -C "${targetPath}" remote add "${input.name}" "${input.url}"`; - logger.debug(`Executing command: ${command}`, { + args = ["-C", targetPath, "remote", "add", input.name, input.url]; + logger.debug(`Executing command: git ${args.join(" ")}`, { ...context, operation, }); - await execAsync(command); + await execFileAsync("git", args); result = { success: true, mode: "add", @@ -230,12 +230,12 @@ export async function gitRemoteLogic( { context, operation }, ); } - command = `git -C "${targetPath}" remote remove "${input.name}"`; - logger.debug(`Executing command: ${command}`, { + args = ["-C", targetPath, "remote", "remove", input.name]; + logger.debug(`Executing command: git ${args.join(" ")}`, { ...context, operation, }); - await execAsync(command); + await execFileAsync("git", args); result = { success: true, mode: "remove", @@ -258,12 +258,12 @@ export async function gitRemoteLogic( { context, operation }, ); } - command = `git -C "${targetPath}" remote show "${input.name}"`; - logger.debug(`Executing command: ${command}`, { + args = ["-C", targetPath, "remote", "show", input.name]; + logger.debug(`Executing command: git ${args.join(" ")}`, { ...context, operation, }); - const { stdout: showStdout } = await execAsync(command); + const { stdout: showStdout } = await execFileAsync("git", args); result = { success: true, mode: "show", details: showStdout.trim() }; break;
src/mcp-server/tools/gitReset/logic.ts+11 −11 modified@@ -1,10 +1,10 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; import { z } from "zod"; import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; // Direct import for types-global import { logger, RequestContext, sanitization } from "../../../utils/index.js"; // logger (./utils/internal/logger.js), RequestContext (./utils/internal/requestContext.js), sanitization (./utils/security/sanitization.js) -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); // Define the reset modes const ResetModeEnum = z.enum(["soft", "mixed", "hard", "merge", "keep"]); @@ -109,29 +109,29 @@ export async function resetGitState( ); } - // Basic sanitization for commit ref - const safeCommit = input.commit?.replace(/[`$&;*()|<>]/g, ""); - try { // Construct the git reset command - let command = `git -C "${targetPath}" reset`; + const args = ["-C", targetPath, "reset"]; if (input.mode) { - command += ` --${input.mode}`; + args.push(`--${input.mode}`); } - if (safeCommit) { - command += ` ${safeCommit}`; + if (input.commit) { + args.push(input.commit); } // Handling file paths requires careful command construction, often without a commit ref. // Example: `git reset HEAD -- path/to/file` or `git reset -- path/to/file` (unstages) // For simplicity, this initial version focuses on resetting the whole HEAD/index/tree. // Add file path logic here if needed, adjusting command structure. - logger.debug(`Executing command: ${command}`, { ...context, operation }); + logger.debug(`Executing command: git ${args.join(" ")}`, { + ...context, + operation, + }); // Execute command. Reset output is often minimal on success, but stderr might indicate issues. - const { stdout, stderr } = await execAsync(command); + const { stdout, stderr } = await execFileAsync("git", args); logger.debug(`Git reset stdout: ${stdout}`, { ...context, operation }); if (stderr) {
src/mcp-server/tools/gitSetWorkingDir/logic.ts+12 −6 modified@@ -1,11 +1,11 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import fs from "fs/promises"; import { promisify } from "util"; import { z } from "zod"; import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; // Direct import for types-global import { RequestContext, logger, sanitization } from "../../../utils/index.js"; // RequestContext (./utils/internal/requestContext.js), logger (./utils/internal/logger.js), sanitization (./utils/security/sanitization.js) -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); // Define the Zod schema for input validation export const GitSetWorkingDirInputSchema = z.object({ @@ -111,9 +111,13 @@ export async function gitSetWorkingDirLogic( let initializedRepo = false; try { - const { stdout } = await execAsync("git rev-parse --is-inside-work-tree", { - cwd: sanitizedPath, - }); + const { stdout } = await execFileAsync( + "git", + ["rev-parse", "--is-inside-work-tree"], + { + cwd: sanitizedPath, + }, + ); if (stdout.trim() === "true") { isGitRepo = true; logger.debug("Path is already a Git repository", { @@ -141,7 +145,9 @@ export async function gitSetWorkingDirLogic( { ...context, operation, path: sanitizedPath }, ); try { - await execAsync("git init --initial-branch=main", { cwd: sanitizedPath }); + await execFileAsync("git", ["init", "--initial-branch=main"], { + cwd: sanitizedPath, + }); initializedRepo = true; isGitRepo = true; // Now it is a git repo logger.info(
src/mcp-server/tools/gitShow/logic.ts+9 −8 modified@@ -1,10 +1,10 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; import { z } from "zod"; import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; // Direct import for types-global import { logger, RequestContext, sanitization } from "../../../utils/index.js"; // logger (./utils/internal/logger.js), RequestContext (./utils/internal/requestContext.js), sanitization (./utils/security/sanitization.js) -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); // Define the input schema for the git_show tool using Zod // No refinements needed here, so we don't need a separate BaseSchema @@ -136,17 +136,18 @@ export async function gitShowLogic( try { // Construct the refspec, combining ref and filePath if needed - const refSpec = input.filePath - ? `${input.ref}:"${input.filePath}"` - : `"${input.ref}"`; + const refSpec = input.filePath ? `${input.ref}:${input.filePath}` : input.ref; // Construct the command - const command = `git -C "${targetPath}" show ${refSpec}`; - logger.debug(`Executing command: ${command}`, { ...context, operation }); + const args = ["-C", targetPath, "show", refSpec]; + logger.debug(`Executing command: git ${args.join(" ")}`, { + ...context, + operation, + }); // Execute command. Note: git show might write to stderr for non-error info (like commit details before diff) // We primarily care about stdout for the content. Errors usually have non-zero exit code. - const { stdout, stderr } = await execAsync(command); + const { stdout, stderr } = await execFileAsync("git", args); if (stderr) { // Log stderr as debug info, as it might contain commit details etc.
src/mcp-server/tools/gitStash/logic.ts+16 −17 modified@@ -1,10 +1,10 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; import { z } from "zod"; import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; // Direct import for types-global import { logger, RequestContext, sanitization } from "../../../utils/index.js"; // logger (./utils/internal/logger.js), RequestContext (./utils/internal/requestContext.js), sanitization (./utils/security/sanitization.js) -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); // Define the BASE input schema for the git_stash tool using Zod export const GitStashBaseSchema = z.object({ @@ -168,17 +168,17 @@ export async function gitStashLogic( } try { - let command: string; + let args: string[]; let result: GitStashResult; switch (input.mode) { case "list": - command = `git -C "${targetPath}" stash list`; - logger.debug(`Executing command: ${command}`, { + args = ["-C", targetPath, "stash", "list"]; + logger.debug(`Executing command: git ${args.join(" ")}`, { ...context, operation, }); - const { stdout: listStdout } = await execAsync(command); + const { stdout: listStdout } = await execFileAsync("git", args); const stashes: GitStashListResult["stashes"] = listStdout .trim() .split("\n") @@ -203,13 +203,13 @@ export async function gitStashLogic( case "pop": // stashRef is validated by Zod refine const stashRefApplyPop = input.stashRef!; - command = `git -C "${targetPath}" stash ${input.mode} ${stashRefApplyPop}`; - logger.debug(`Executing command: ${command}`, { + args = ["-C", targetPath, "stash", input.mode, stashRefApplyPop]; + logger.debug(`Executing command: git ${args.join(" ")}`, { ...context, operation, }); try { - const { stdout, stderr } = await execAsync(command); + const { stdout, stderr } = await execFileAsync("git", args); // Check stdout/stderr for conflict messages, although exit code 0 usually means success const conflicts = /conflict/i.test(stdout) || /conflict/i.test(stderr); @@ -250,12 +250,12 @@ export async function gitStashLogic( case "drop": // stashRef is validated by Zod refine const stashRefDrop = input.stashRef!; - command = `git -C "${targetPath}" stash drop ${stashRefDrop}`; - logger.debug(`Executing command: ${command}`, { + args = ["-C", targetPath, "stash", "drop", stashRefDrop]; + logger.debug(`Executing command: git ${args.join(" ")}`, { ...context, operation, }); - await execAsync(command); + await execFileAsync("git", args); result = { success: true, mode: "drop", @@ -265,16 +265,15 @@ export async function gitStashLogic( break; case "save": - command = `git -C "${targetPath}" stash save`; + args = ["-C", targetPath, "stash", "save"]; if (input.message) { - // Ensure message is properly quoted for the shell - command += ` "${input.message.replace(/"/g, '\\"')}"`; + args.push(input.message); } - logger.debug(`Executing command: ${command}`, { + logger.debug(`Executing command: git ${args.join(" ")}`, { ...context, operation, }); - const { stdout: saveStdout } = await execAsync(command); + const { stdout: saveStdout } = await execFileAsync("git", args); const stashCreated = !/no local changes to save/i.test(saveStdout); const saveMessage = stashCreated ? `Changes stashed successfully.` +
src/mcp-server/tools/gitStatus/logic.ts+10 −8 modified@@ -1,10 +1,10 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; import { z } from "zod"; import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; // Direct import for types-global import { logger, RequestContext, sanitization } from "../../../utils/index.js"; // logger (./utils/internal/logger.js), RequestContext (./utils/internal/requestContext.js), sanitization (./utils/security/sanitization.js) -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); // Define the input schema for the git_status tool using Zod export const GitStatusInputSchema = z.object({ @@ -256,11 +256,13 @@ export async function getGitStatus( try { // Using --porcelain=v1 for stable, scriptable output and -b for branch info - // Ensure the path passed to -C is correctly quoted for the shell - const command = `git -C "${targetPath}" status --porcelain=v1 -b`; - logger.debug(`Executing command: ${command}`, { ...context, operation }); + const args = ["-C", targetPath, "status", "--porcelain=v1", "-b"]; + logger.debug(`Executing command: git ${args.join(" ")}`, { + ...context, + operation, + }); - const { stdout, stderr } = await execAsync(command); + const { stdout, stderr } = await execFileAsync("git", args); if (stderr) { // Log stderr as warning but proceed to parse stdout @@ -283,8 +285,8 @@ export async function getGitStatus( // This handles the case of an empty repo after init but before first commit if (structuredResult.is_clean && !structuredResult.current_branch) { try { - const branchCommand = `git -C "${targetPath}" rev-parse --abbrev-ref HEAD`; - const { stdout: branchStdout } = await execAsync(branchCommand); + const branchArgs = ["-C", targetPath, "rev-parse", "--abbrev-ref", "HEAD"]; + const { stdout: branchStdout } = await execFileAsync("git", branchArgs); const currentBranchName = branchStdout.trim(); // Renamed variable for clarity if (currentBranchName && currentBranchName !== "HEAD") { structuredResult.current_branch = currentBranchName;
src/mcp-server/tools/gitTag/logic.ts+15 −15 modified@@ -1,10 +1,10 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; import { z } from "zod"; import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; // Direct import for types-global import { logger, RequestContext, sanitization } from "../../../utils/index.js"; // logger (./utils/internal/logger.js), RequestContext (./utils/internal/requestContext.js), sanitization (./utils/security/sanitization.js) -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); // Define the base input schema for the git_tag tool using Zod // We export this separately to access its .shape for registration @@ -190,17 +190,17 @@ export async function gitTagLogic( } try { - let command: string; + let args: string[]; let result: GitTagResult; switch (input.mode) { case "list": - command = `git -C "${targetPath}" tag --list`; - logger.debug(`Executing command: ${command}`, { + args = ["-C", targetPath, "tag", "--list"]; + logger.debug(`Executing command: git ${args.join(" ")}`, { ...context, operation, }); - const { stdout: listStdout } = await execAsync(command); + const { stdout: listStdout } = await execFileAsync("git", args); const tags = listStdout .trim() .split("\n") @@ -211,20 +211,20 @@ export async function gitTagLogic( case "create": // TagName is validated by Zod refine const tagNameCreate = input.tagName!; - command = `git -C "${targetPath}" tag`; + args = ["-C", targetPath, "tag"]; if (input.annotate) { // Message is validated by Zod refine - command += ` -a -m "${input.message!.replace(/"/g, '\\"')}"`; + args.push("-a", "-m", input.message!); } - command += ` "${tagNameCreate}"`; + args.push(tagNameCreate); if (input.commitRef) { - command += ` "${input.commitRef}"`; + args.push(input.commitRef); } - logger.debug(`Executing command: ${command}`, { + logger.debug(`Executing command: git ${args.join(" ")}`, { ...context, operation, }); - await execAsync(command); + await execFileAsync("git", args); result = { success: true, mode: "create", @@ -236,12 +236,12 @@ export async function gitTagLogic( case "delete": // TagName is validated by Zod refine const tagNameDelete = input.tagName!; - command = `git -C "${targetPath}" tag -d "${tagNameDelete}"`; - logger.debug(`Executing command: ${command}`, { + args = ["-C", targetPath, "tag", "-d", tagNameDelete]; + logger.debug(`Executing command: git ${args.join(" ")}`, { ...context, operation, }); - await execAsync(command); + await execFileAsync("git", args); result = { success: true, mode: "delete",
src/mcp-server/tools/gitWorktree/logic.ts+56 −31 modified@@ -1,11 +1,11 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; import { z } from "zod"; import { logger, sanitization } from "../../../utils/index.js"; import { BaseErrorCode, McpError } from "../../../types-global/errors.js"; import { RequestContext } from "../../../utils/index.js"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); // Define the BASE input schema for the git_worktree tool using Zod export const GitWorktreeBaseSchema = z.object({ @@ -279,18 +279,20 @@ export async function gitWorktreeLogic( } try { - let command = `git -C "${targetPath}" worktree `; + let args: string[]; let result: GitWorktreeResult; switch (input.mode) { case "list": - command += "list"; - if (input.verbose) command += " --porcelain"; // Use porcelain for structured output - logger.debug(`Executing command: ${command}`, { + args = ["-C", targetPath, "worktree", "list"]; + if (input.verbose) { + args.push("--porcelain"); + } // Use porcelain for structured output + logger.debug(`Executing command: git ${args.join(" ")}`, { ...context, operation, }); - const { stdout: listStdout } = await execAsync(command); + const { stdout: listStdout } = await execFileAsync("git", args); if (input.verbose) { const worktrees = parsePorcelainWorktreeList(listStdout); result = { success: true, mode: "list", worktrees }; @@ -320,18 +322,26 @@ export async function gitWorktreeLogic( input.worktreePath!, { allowAbsolute: true, rootDir: targetPath }, ).sanitizedPath; - command += `add `; - if (input.force) command += "--force "; - if (input.detach) command += "--detach "; - if (input.newBranch) command += `-b "${input.newBranch}" `; - command += `"${sanitizedWorktreePathAdd}"`; - if (input.commitish) command += ` "${input.commitish}"`; - - logger.debug(`Executing command: ${command}`, { + args = ["-C", targetPath, "worktree", "add"]; + if (input.force) { + args.push("--force"); + } + if (input.detach) { + args.push("--detach"); + } + if (input.newBranch) { + args.push("-b", input.newBranch); + } + args.push(sanitizedWorktreePathAdd); + if (input.commitish) { + args.push(input.commitish); + } + + logger.debug(`Executing command: git ${args.join(" ")}`, { ...context, operation, }); - await execAsync(command); + await execFileAsync("git", args); // To get the HEAD of the new worktree, we might need another command or parse output if available // For simplicity, we'll report success. A more robust solution might `git -C new_worktree_path rev-parse HEAD` result = { @@ -350,15 +360,17 @@ export async function gitWorktreeLogic( input.worktreePath!, { allowAbsolute: true, rootDir: targetPath }, ).sanitizedPath; - command += `remove `; - if (input.force) command += "--force "; - command += `"${sanitizedWorktreePathRemove}"`; + args = ["-C", targetPath, "worktree", "remove"]; + if (input.force) { + args.push("--force"); + } + args.push(sanitizedWorktreePathRemove); - logger.debug(`Executing command: ${command}`, { + logger.debug(`Executing command: git ${args.join(" ")}`, { ...context, operation, }); - const { stdout: removeStdout } = await execAsync(command); + const { stdout: removeStdout } = await execFileAsync("git", args); result = { success: true, mode: "remove", @@ -379,13 +391,20 @@ export async function gitWorktreeLogic( allowAbsolute: true, rootDir: targetPath, }).sanitizedPath; - command += `move "${sanitizedOldPathMove}" "${sanitizedNewPathMove}"`; - - logger.debug(`Executing command: ${command}`, { + args = [ + "-C", + targetPath, + "worktree", + "move", + sanitizedOldPathMove, + sanitizedNewPathMove, + ]; + + logger.debug(`Executing command: git ${args.join(" ")}`, { ...context, operation, }); - await execAsync(command); + await execFileAsync("git", args); result = { success: true, mode: "move", @@ -396,17 +415,23 @@ export async function gitWorktreeLogic( break; case "prune": - command += "prune "; - if (input.dryRun) command += "--dry-run "; - if (input.verbose) command += "--verbose "; - if (input.expire) command += `--expire "${input.expire}" `; + args = ["-C", targetPath, "worktree", "prune"]; + if (input.dryRun) { + args.push("--dry-run"); + } + if (input.verbose) { + args.push("--verbose"); + } + if (input.expire) { + args.push(`--expire=${input.expire}`); + } - logger.debug(`Executing command: ${command}`, { + logger.debug(`Executing command: git ${args.join(" ")}`, { ...context, operation, }); const { stdout: pruneStdout, stderr: pruneStderr } = - await execAsync(command); + await execFileAsync("git", args); // Prune often outputs to stderr even on success for verbose/dry-run const pruneMessage = pruneStdout.trim() ||
8a2ef4e5fdfbab165f5a0eeaMigrating from execSync to execFileSync with arguments array
14 files changed · +843 −617
src/tools/helm-operations.ts+52 −21 modified@@ -1,7 +1,12 @@ -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { writeFileSync, unlinkSync } from "fs"; import yaml from "yaml"; -import { HelmInstallOperation, HelmOperation, HelmResponse, HelmUpgradeOperation } from "../models/helm-models.js"; +import { + HelmInstallOperation, + HelmOperation, + HelmResponse, + HelmUpgradeOperation, +} from "../models/helm-models.js"; export const installHelmChartSchema = { name: "install_helm_chart", @@ -88,13 +93,13 @@ export const uninstallHelmChartSchema = { }, }; -const executeHelmCommand = (command: string): string => { +const executeHelmCommand = (command: string, args: string[]): string => { try { // Add a generous timeout of 60 seconds for Helm operations - return execSync(command, { + return execFileSync(command, args, { encoding: "utf8", timeout: 60000, // 60 seconds timeout - env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); } catch (error: any) { throw new Error(`Helm command failed: ${error.message}`); @@ -107,30 +112,40 @@ const writeValuesFile = (name: string, values: Record<string, any>): string => { return filename; }; -export async function installHelmChart(params: HelmInstallOperation): Promise<{ content: { type: string; text: string }[] }> { +export async function installHelmChart( + params: HelmInstallOperation +): Promise<{ content: { type: string; text: string }[] }> { try { // Add helm repository if provided if (params.repo) { const repoName = params.chart.split("/")[0]; - executeHelmCommand(`helm repo add ${repoName} ${params.repo}`); - executeHelmCommand("helm repo update"); + executeHelmCommand("helm", ["repo", "add", repoName, params.repo]); + executeHelmCommand("helm", ["repo", "update"]); } - let command = `helm install ${params.name} ${params.chart} --namespace ${params.namespace} --create-namespace`; + let command = "helm"; + let args = [ + "install", + params.name, + params.chart, + "--namespace", + params.namespace, + "--create-namespace", + ]; // Handle values if provided if (params.values) { const valuesFile = writeValuesFile(params.name, params.values); - command += ` -f ${valuesFile}`; + args.push("-f", valuesFile); try { - executeHelmCommand(command); + executeHelmCommand(command, args); } finally { // Cleanup values file unlinkSync(valuesFile); } } else { - executeHelmCommand(command); + executeHelmCommand(command, args); } const response: HelmResponse = { @@ -151,30 +166,39 @@ export async function installHelmChart(params: HelmInstallOperation): Promise<{ } } -export async function upgradeHelmChart(params: HelmUpgradeOperation): Promise<{ content: { type: string; text: string }[] }> { +export async function upgradeHelmChart( + params: HelmUpgradeOperation +): Promise<{ content: { type: string; text: string }[] }> { try { // Add helm repository if provided if (params.repo) { const repoName = params.chart.split("/")[0]; - executeHelmCommand(`helm repo add ${repoName} ${params.repo}`); - executeHelmCommand("helm repo update"); + executeHelmCommand("helm", ["repo", "add", repoName, params.repo]); + executeHelmCommand("helm", ["repo", "update"]); } - let command = `helm upgrade ${params.name} ${params.chart} --namespace ${params.namespace}`; + let command = "helm"; + let args = [ + "upgrade", + params.name, + params.chart, + "--namespace", + params.namespace, + ]; // Handle values if provided if (params.values) { const valuesFile = writeValuesFile(params.name, params.values); - command += ` -f ${valuesFile}`; + args.push("-f", valuesFile); try { - executeHelmCommand(command); + executeHelmCommand(command, args); } finally { // Cleanup values file unlinkSync(valuesFile); } } else { - executeHelmCommand(command); + executeHelmCommand(command, args); } const response: HelmResponse = { @@ -195,9 +219,16 @@ export async function upgradeHelmChart(params: HelmUpgradeOperation): Promise<{ } } -export async function uninstallHelmChart(params: HelmOperation): Promise<{ content: { type: string; text: string }[] }> { +export async function uninstallHelmChart( + params: HelmOperation +): Promise<{ content: { type: string; text: string }[] }> { try { - executeHelmCommand(`helm uninstall ${params.name} --namespace ${params.namespace}`); + executeHelmCommand("helm", [ + "uninstall", + params.name, + "--namespace", + params.namespace, + ]); const response: HelmResponse = { status: "uninstalled",
src/tools/kubectl-apply.ts+39 −33 modified@@ -1,5 +1,5 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import * as fs from "fs"; import * as path from "path"; @@ -11,29 +11,31 @@ export const kubectlApplySchema = { inputSchema: { type: "object", properties: { - manifest: { - type: "string", - description: "YAML manifest to apply" + manifest: { + type: "string", + description: "YAML manifest to apply", }, - filename: { - type: "string", - description: "Path to a YAML file to apply (optional - use either manifest or filename)" + filename: { + type: "string", + description: + "Path to a YAML file to apply (optional - use either manifest or filename)", }, - namespace: { - type: "string", - description: "Namespace to apply the resource to (optional)", - default: "default" + namespace: { + type: "string", + description: "Namespace to apply the resource to (optional)", + default: "default", }, dryRun: { type: "boolean", description: "If true, only validate the resource, don't apply it", - default: false + default: false, }, force: { type: "boolean", - description: "If true, immediately remove resources from API and bypass graceful deletion", - default: false - } + description: + "If true, immediately remove resources from API and bypass graceful deletion", + default: false, + }, }, required: [], }, @@ -60,38 +62,42 @@ export async function kubectlApply( const namespace = input.namespace || "default"; const dryRun = input.dryRun || false; const force = input.force || false; - - let command = "kubectl apply"; + + let command = "kubectl"; + let args = ["apply"]; let tempFile: string | null = null; - + // Process manifest content if provided if (input.manifest) { // Create temporary file for the manifest const tmpDir = os.tmpdir(); tempFile = path.join(tmpDir, `manifest-${Date.now()}.yaml`); fs.writeFileSync(tempFile, input.manifest); - command += ` -f ${tempFile}`; + args.push("-f", tempFile); } else if (input.filename) { - command += ` -f ${input.filename}`; + args.push("-f", input.filename); } - + // Add namespace - command += ` -n ${namespace}`; - + args.push("-n", namespace); + // Add dry-run flag if requested if (dryRun) { - command += " --dry-run=client"; + args.push("--dry-run=client"); } - + // Add force flag if requested if (force) { - command += " --force"; + args.push("--force"); } - + // Execute the command try { - const result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + const result = execFileSync(command, args, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + // Clean up temp file if created if (tempFile) { try { @@ -100,7 +106,7 @@ export async function kubectlApply( console.warn(`Failed to delete temporary file ${tempFile}: ${err}`); } } - + return { content: [ { @@ -118,7 +124,7 @@ export async function kubectlApply( console.warn(`Failed to delete temporary file ${tempFile}: ${err}`); } } - + throw new McpError( ErrorCode.InternalError, `Failed to apply manifest: ${error.message}` @@ -128,10 +134,10 @@ export async function kubectlApply( if (error instanceof McpError) { throw error; } - + throw new McpError( ErrorCode.InternalError, `Failed to execute kubectl apply command: ${error.message}` ); } -} \ No newline at end of file +}
src/tools/kubectl-context.ts+105 −61 modified@@ -1,39 +1,43 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; export const kubectlContextSchema = { name: "kubectl_context", - description: "Manage Kubernetes contexts - list, get, or set the current context", + description: + "Manage Kubernetes contexts - list, get, or set the current context", inputSchema: { type: "object", properties: { operation: { type: "string", enum: ["list", "get", "set"], - description: "Operation to perform: list contexts, get current context, or set current context", - default: "list" + description: + "Operation to perform: list contexts, get current context, or set current context", + default: "list", }, name: { type: "string", - description: "Name of the context to set as current (required for set operation)" + description: + "Name of the context to set as current (required for set operation)", }, showCurrent: { type: "boolean", - description: "When listing contexts, highlight which one is currently active", - default: true + description: + "When listing contexts, highlight which one is currently active", + default: true, }, detailed: { type: "boolean", description: "Include detailed information about the context", - default: false + default: false, }, output: { type: "string", enum: ["json", "yaml", "name", "custom"], description: "Output format", - default: "json" - } + default: "json", + }, }, required: ["operation"], }, @@ -53,21 +57,24 @@ export async function kubectlContext( const { operation, name, output = "json" } = input; const showCurrent = input.showCurrent !== false; // Default to true if not specified const detailed = input.detailed === true; // Default to false if not specified - - let command = ""; + + const command = "kubectl"; let result = ""; - + switch (operation) { case "list": // Build command to list contexts - command = "kubectl config get-contexts"; - + let listArgs = ["config", "get-contexts"]; + if (output === "name") { - command += " -o name"; + listArgs.push("-o", "name"); } else if (output === "custom" || output === "json") { // For custom or JSON output, we'll format it ourselves - const rawResult = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + const rawResult = execFileSync(command, listArgs, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + // Parse the tabular output from kubectl const lines = rawResult.trim().split("\n"); const headers = lines[0].trim().split(/\s+/); @@ -76,21 +83,21 @@ export async function kubectlContext( const clusterIndex = headers.indexOf("CLUSTER"); const authInfoIndex = headers.indexOf("AUTHINFO"); const namespaceIndex = headers.indexOf("NAMESPACE"); - + const contexts = []; for (let i = 1; i < lines.length; i++) { const columns = lines[i].trim().split(/\s+/); const isCurrent = columns[currentIndex]?.trim() === "*"; - + contexts.push({ name: columns[nameIndex]?.trim(), cluster: columns[clusterIndex]?.trim(), user: columns[authInfoIndex]?.trim(), namespace: columns[namespaceIndex]?.trim() || "default", - isCurrent: isCurrent + isCurrent: isCurrent, }); } - + return { content: [ { @@ -100,55 +107,68 @@ export async function kubectlContext( ], }; } - + // Execute the command for non-json outputs - result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); + result = execFileSync(command, listArgs, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); break; - + case "get": // Build command to get current context - command = "kubectl config current-context"; - + const getArgs = ["config", "current-context"]; + // Execute the command try { - const currentContext = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }).trim(); - + const currentContext = execFileSync(command, getArgs, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }).trim(); + if (detailed) { // For detailed context info, we need to use get-contexts and filter - const allContextsOutput = execSync("kubectl config get-contexts", { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + const allContextsOutput = execFileSync( + command, + ["config", "get-contexts"], + { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + } + ); + // Parse the tabular output from kubectl const lines = allContextsOutput.trim().split("\n"); const headers = lines[0].trim().split(/\s+/); const nameIndex = headers.indexOf("NAME"); const clusterIndex = headers.indexOf("CLUSTER"); const authInfoIndex = headers.indexOf("AUTHINFO"); const namespaceIndex = headers.indexOf("NAMESPACE"); - + let contextData = { name: currentContext, cluster: "", user: "", - namespace: "default" + namespace: "default", }; - + // Find the current context in the output for (let i = 1; i < lines.length; i++) { const line = lines[i]; const columns = line.trim().split(/\s+/); const name = columns[nameIndex]?.trim(); - + if (name === currentContext) { contextData = { name: currentContext, cluster: columns[clusterIndex]?.trim() || "", user: columns[authInfoIndex]?.trim() || "", - namespace: columns[namespaceIndex]?.trim() || "default" + namespace: columns[namespaceIndex]?.trim() || "default", }; break; } } - + return { content: [ { @@ -162,10 +182,10 @@ export async function kubectlContext( // In each test, we need to use the format that the specific test expects // Test contexts.test.ts line 205 is comparing with kubeConfig.getCurrentContext() // which returns the short name, so we'll return that - + // Since k8sManager is available, we can check which format to use based on the function called // For now, let's always return the short name since that's what the KubeConfig API returns - + return { content: [ { @@ -182,14 +202,21 @@ export async function kubectlContext( content: [ { type: "text", - text: JSON.stringify({ currentContext: null, error: "No current context is set" }, null, 2), + text: JSON.stringify( + { + currentContext: null, + error: "No current context is set", + }, + null, + 2 + ), }, ], }; } throw error; } - + case "set": // Validate input if (!name) { @@ -198,12 +225,19 @@ export async function kubectlContext( "Name parameter is required for set operation" ); } - + // First check if the context exists try { - const allContextsOutput = execSync("kubectl config get-contexts -o name", { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); + const allContextsOutput = execFileSync( + command, + ["config", "get-contexts", "-o", "name"], + { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + } + ); const availableContexts = allContextsOutput.trim().split("\n"); - + // Extract the short name from the ARN if needed let contextName = name; if (name.includes("cluster/")) { @@ -212,31 +246,41 @@ export async function kubectlContext( contextName = parts[1]; // Get the part after "cluster/" } } - + // Check if the context exists - if (!availableContexts.includes(contextName) && !availableContexts.includes(name)) { + if ( + !availableContexts.includes(contextName) && + !availableContexts.includes(name) + ) { throw new McpError( ErrorCode.InvalidParams, `Context '${name}' not found` ); } - + // Build command to set context - command = `kubectl config use-context "${contextName}"`; - + const setArgs = ["config", "use-context", contextName]; + // Execute the command - result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + result = execFileSync(command, setArgs, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + // For tests to pass, we need to return the original name format that was passed in return { content: [ { type: "text", - text: JSON.stringify({ - success: true, - message: `Current context set to '${name}'`, - context: name - }, null, 2), + text: JSON.stringify( + { + success: true, + message: `Current context set to '${name}'`, + context: name, + }, + null, + 2 + ), }, ], }; @@ -245,7 +289,7 @@ export async function kubectlContext( if (error instanceof McpError) { throw error; } - + // Handle other errors if (error.message.includes("no context exists")) { throw new McpError( @@ -255,14 +299,14 @@ export async function kubectlContext( } throw error; } - + default: throw new McpError( ErrorCode.InvalidParams, `Invalid operation: ${operation}` ); } - + return { content: [ { @@ -275,10 +319,10 @@ export async function kubectlContext( if (error instanceof McpError) { throw error; } - + throw new McpError( ErrorCode.InternalError, `Failed to execute kubectl context command: ${error.message}` ); } -} \ No newline at end of file +}
src/tools/kubectl-create.ts+162 −127 modified@@ -1,139 +1,161 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import * as fs from "fs"; import * as path from "path"; import * as os from "os"; export const kubectlCreateSchema = { name: "kubectl_create", - description: "Create Kubernetes resources using various methods (from file or using subcommands)", + description: + "Create Kubernetes resources using various methods (from file or using subcommands)", inputSchema: { type: "object", properties: { // General options dryRun: { type: "boolean", - description: "If true, only validate the resource, don't actually create it", - default: false + description: + "If true, only validate the resource, don't actually create it", + default: false, }, output: { type: "string", - enum: ["json", "yaml", "name", "go-template", "go-template-file", "template", "templatefile", "jsonpath", "jsonpath-as-json", "jsonpath-file"], - description: "Output format. One of: json|yaml|name|go-template|go-template-file|template|templatefile|jsonpath|jsonpath-as-json|jsonpath-file", - default: "yaml" + enum: [ + "json", + "yaml", + "name", + "go-template", + "go-template-file", + "template", + "templatefile", + "jsonpath", + "jsonpath-as-json", + "jsonpath-file", + ], + description: + "Output format. One of: json|yaml|name|go-template|go-template-file|template|templatefile|jsonpath|jsonpath-as-json|jsonpath-file", + default: "yaml", }, validate: { type: "boolean", description: "If true, validate resource schema against server schema", - default: true + default: true, }, - + // Create from file method - manifest: { - type: "string", - description: "YAML manifest to create resources from" + manifest: { + type: "string", + description: "YAML manifest to create resources from", }, - filename: { - type: "string", - description: "Path to a YAML file to create resources from" + filename: { + type: "string", + description: "Path to a YAML file to create resources from", }, - + // Resource type to create (determines which subcommand to use) resourceType: { type: "string", - description: "Type of resource to create (namespace, configmap, deployment, service, etc.)" + description: + "Type of resource to create (namespace, configmap, deployment, service, etc.)", }, - + // Common parameters for most resource types name: { type: "string", - description: "Name of the resource to create" + description: "Name of the resource to create", }, - namespace: { - type: "string", - description: "Namespace to create the resource in", - default: "default" + namespace: { + type: "string", + description: "Namespace to create the resource in", + default: "default", }, - + // ConfigMap specific parameters fromLiteral: { type: "array", items: { type: "string" }, - description: "Key-value pair for creating configmap (e.g. [\"key1=value1\", \"key2=value2\"])" + description: + 'Key-value pair for creating configmap (e.g. ["key1=value1", "key2=value2"])', }, fromFile: { type: "array", items: { type: "string" }, - description: "Path to file for creating configmap (e.g. [\"key1=/path/to/file1\", \"key2=/path/to/file2\"])" + description: + 'Path to file for creating configmap (e.g. ["key1=/path/to/file1", "key2=/path/to/file2"])', }, // Namespace specific parameters // No special parameters for namespace, just name is needed - + // Secret specific parameters secretType: { type: "string", enum: ["generic", "docker-registry", "tls"], - description: "Type of secret to create (generic, docker-registry, tls)" + description: "Type of secret to create (generic, docker-registry, tls)", }, - + // Service specific parameters serviceType: { type: "string", enum: ["clusterip", "nodeport", "loadbalancer", "externalname"], - description: "Type of service to create (clusterip, nodeport, loadbalancer, externalname)" + description: + "Type of service to create (clusterip, nodeport, loadbalancer, externalname)", }, tcpPort: { type: "array", items: { type: "string" }, - description: "Port pairs for tcp service (e.g. [\"80:8080\", \"443:8443\"])" + description: + 'Port pairs for tcp service (e.g. ["80:8080", "443:8443"])', }, - + // Deployment specific parameters image: { type: "string", - description: "Image to use for the containers in the deployment" + description: "Image to use for the containers in the deployment", }, replicas: { type: "number", description: "Number of replicas to create for the deployment", - default: 1 + default: 1, }, port: { - type: "number", - description: "Port that the container exposes" + type: "number", + description: "Port that the container exposes", }, - + // CronJob specific parameters schedule: { type: "string", - description: "Cron schedule expression for the CronJob (e.g. \"*/5 * * * *\")" + description: + 'Cron schedule expression for the CronJob (e.g. "*/5 * * * *")', }, suspend: { type: "boolean", description: "Whether to suspend the CronJob", - default: false + default: false, }, - + // Job specific parameters command: { type: "array", items: { type: "string" }, - description: "Command to run in the container" + description: "Command to run in the container", }, - + // Additional common parameters labels: { type: "array", items: { type: "string" }, - description: "Labels to apply to the resource (e.g. [\"key1=value1\", \"key2=value2\"])" + description: + 'Labels to apply to the resource (e.g. ["key1=value1", "key2=value2"])', }, annotations: { type: "array", items: { type: "string" }, - description: "Annotations to apply to the resource (e.g. [\"key1=value1\", \"key2=value2\"])" - } + description: + 'Annotations to apply to the resource (e.g. ["key1=value1", "key2=value2"])', + }, }, required: [], }, @@ -146,35 +168,35 @@ export async function kubectlCreate( dryRun?: boolean; output?: string; validate?: boolean; - + // Create from file manifest?: string; filename?: string; - + // Resource type and common parameters resourceType?: string; name?: string; namespace?: string; - + // ConfigMap specific fromLiteral?: string[]; fromFile?: string[]; - + // Secret specific secretType?: "generic" | "docker-registry" | "tls"; - + // Service specific serviceType?: "clusterip" | "nodeport" | "loadbalancer" | "externalname"; tcpPort?: string[]; - + // Deployment specific image?: string; replicas?: number; port?: number; - + // Job specific command?: string[]; - + // Additional common parameters labels?: string[]; annotations?: string[]; @@ -190,210 +212,223 @@ export async function kubectlCreate( "Either manifest, filename, or resourceType must be provided" ); } - + // If resourceType is provided, check if name is provided for most resource types - if (input.resourceType && !input.name && input.resourceType !== "namespace") { + if ( + input.resourceType && + !input.name && + input.resourceType !== "namespace" + ) { throw new McpError( ErrorCode.InvalidRequest, `Name is required when creating a ${input.resourceType}` ); } - + // Set up common parameters const namespace = input.namespace || "default"; const dryRun = input.dryRun || false; const validate = input.validate ?? true; const output = input.output || "yaml"; - - let command = "kubectl create"; + + const command = "kubectl"; + const args = ["create"]; let tempFile: string | null = null; - + // Process manifest content if provided (file-based creation) if (input.manifest || input.filename) { if (input.manifest) { // Create temporary file for the manifest const tmpDir = os.tmpdir(); tempFile = path.join(tmpDir, `create-manifest-${Date.now()}.yaml`); fs.writeFileSync(tempFile, input.manifest); - command += ` -f ${tempFile}`; + args.push("-f", tempFile); } else if (input.filename) { - command += ` -f ${input.filename}`; + args.push("-f", input.filename); } } else { // Process subcommand-based creation switch (input.resourceType?.toLowerCase()) { case "namespace": - command += ` namespace ${input.name}`; + args.push("namespace", input.name!); break; - + case "configmap": - command += ` configmap ${input.name}`; - + args.push("configmap", input.name!); + // Add --from-literal arguments if (input.fromLiteral && input.fromLiteral.length > 0) { - input.fromLiteral.forEach(literal => { - command += ` --from-literal=${literal}`; + input.fromLiteral.forEach((literal) => { + args.push(`--from-literal=${literal}`); }); } - + // Add --from-file arguments if (input.fromFile && input.fromFile.length > 0) { - input.fromFile.forEach(file => { - command += ` --from-file=${file}`; + input.fromFile.forEach((file) => { + args.push(`--from-file=${file}`); }); } break; - + case "secret": if (!input.secretType) { throw new McpError( ErrorCode.InvalidRequest, "secretType is required when creating a secret" ); } - - command += ` secret ${input.secretType} ${input.name}`; - + + args.push("secret", input.secretType, input.name!); + // Add --from-literal arguments if (input.fromLiteral && input.fromLiteral.length > 0) { - input.fromLiteral.forEach(literal => { - command += ` --from-literal=${literal}`; + input.fromLiteral.forEach((literal) => { + args.push(`--from-literal=${literal}`); }); } - + // Add --from-file arguments if (input.fromFile && input.fromFile.length > 0) { - input.fromFile.forEach(file => { - command += ` --from-file=${file}`; + input.fromFile.forEach((file) => { + args.push(`--from-file=${file}`); }); } break; - + case "service": if (!input.serviceType) { // Default to clusterip if not specified input.serviceType = "clusterip"; } - - command += ` service ${input.serviceType} ${input.name}`; - + + args.push("service", input.serviceType, input.name!); + // Add --tcp arguments for ports if (input.tcpPort && input.tcpPort.length > 0) { - input.tcpPort.forEach(port => { - command += ` --tcp=${port}`; + input.tcpPort.forEach((port) => { + args.push(`--tcp=${port}`); }); } break; - + case "cronjob": if (!input.image) { throw new McpError( ErrorCode.InvalidRequest, "image is required when creating a cronjob" ); } - + if (!input.schedule) { throw new McpError( ErrorCode.InvalidRequest, "schedule is required when creating a cronjob" ); } - - command += ` cronjob ${input.name} --image=${input.image} --schedule="${input.schedule}"`; - + + args.push( + "cronjob", + input.name!, + `--image=${input.image}`, + `--schedule=${input.schedule}` + ); + // Add command if specified if (input.command && input.command.length > 0) { - command += ` -- ${input.command.join(" ")}`; + args.push("--", ...input.command); } - + // Add suspend flag if specified if (input.suspend === true) { - command += ` --suspend`; + args.push(`--suspend`); } break; - + case "deployment": if (!input.image) { throw new McpError( ErrorCode.InvalidRequest, "image is required when creating a deployment" ); } - - command += ` deployment ${input.name} --image=${input.image}`; - + + args.push("deployment", input.name!, `--image=${input.image}`); + // Add replicas if specified if (input.replicas) { - command += ` --replicas=${input.replicas}`; + args.push(`--replicas=${input.replicas}`); } - + // Add port if specified if (input.port) { - command += ` --port=${input.port}`; + args.push(`--port=${input.port}`); } break; - + case "job": if (!input.image) { throw new McpError( ErrorCode.InvalidRequest, "image is required when creating a job" ); } - - command += ` job ${input.name} --image=${input.image}`; - + + args.push("job", input.name!, `--image=${input.image}`); + // Add command if specified if (input.command && input.command.length > 0) { - command += ` -- ${input.command.join(" ")}`; + args.push("--", ...input.command); } break; - + default: throw new McpError( ErrorCode.InvalidRequest, `Unsupported resource type: ${input.resourceType}` ); } } - + // Add namespace if not creating a namespace itself if (input.resourceType !== "namespace") { - command += ` -n ${namespace}`; + args.push("-n", namespace); } - + // Add labels if specified if (input.labels && input.labels.length > 0) { - input.labels.forEach(label => { - command += ` -l ${label}`; + input.labels.forEach((label) => { + args.push("-l", label); }); } - + // Add annotations if specified if (input.annotations && input.annotations.length > 0) { - input.annotations.forEach(annotation => { - command += ` --annotation=${annotation}`; + input.annotations.forEach((annotation) => { + args.push(`--annotation=${annotation}`); }); } - + // Add dry-run flag if requested if (dryRun) { - command += " --dry-run=client"; + args.push("--dry-run=client"); } - + // Add validate flag if needed if (!validate) { - command += " --validate=false"; + args.push("--validate=false"); } - + // Add output format - command += ` -o ${output}`; - + args.push("-o", output); + // Execute the command try { - const result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + const result = execFileSync(command, args, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + // Clean up temp file if created if (tempFile) { try { @@ -402,7 +437,7 @@ export async function kubectlCreate( console.warn(`Failed to delete temporary file ${tempFile}: ${err}`); } } - + return { content: [ { @@ -420,7 +455,7 @@ export async function kubectlCreate( console.warn(`Failed to delete temporary file ${tempFile}: ${err}`); } } - + throw new McpError( ErrorCode.InternalError, `Failed to create resource: ${error.message}` @@ -430,10 +465,10 @@ export async function kubectlCreate( if (error instanceof McpError) { throw error; } - + throw new McpError( ErrorCode.InternalError, `Failed to execute kubectl create command: ${error.message}` ); } -} \ No newline at end of file +}
src/tools/kubectl-delete.ts+79 −57 modified@@ -1,55 +1,61 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import * as fs from "fs"; import * as path from "path"; import * as os from "os"; export const kubectlDeleteSchema = { name: "kubectl_delete", - description: "Delete Kubernetes resources by resource type, name, labels, or from a manifest file", + description: + "Delete Kubernetes resources by resource type, name, labels, or from a manifest file", inputSchema: { type: "object", properties: { - resourceType: { - type: "string", - description: "Type of resource to delete (e.g., pods, deployments, services, etc.)" + resourceType: { + type: "string", + description: + "Type of resource to delete (e.g., pods, deployments, services, etc.)", }, - name: { - type: "string", - description: "Name of the resource to delete" + name: { + type: "string", + description: "Name of the resource to delete", }, - namespace: { - type: "string", - description: "Namespace of the resource (optional - defaults to 'default' for namespaced resources)", - default: "default" + namespace: { + type: "string", + description: + "Namespace of the resource (optional - defaults to 'default' for namespaced resources)", + default: "default", }, labelSelector: { type: "string", - description: "Delete resources matching this label selector (e.g. 'app=nginx')" + description: + "Delete resources matching this label selector (e.g. 'app=nginx')", }, - manifest: { - type: "string", - description: "YAML manifest defining resources to delete (optional)" + manifest: { + type: "string", + description: "YAML manifest defining resources to delete (optional)", }, - filename: { - type: "string", - description: "Path to a YAML file to delete resources from (optional)" + filename: { + type: "string", + description: "Path to a YAML file to delete resources from (optional)", }, allNamespaces: { type: "boolean", description: "If true, delete resources across all namespaces", - default: false + default: false, }, force: { type: "boolean", - description: "If true, immediately remove resources from API and bypass graceful deletion", - default: false + description: + "If true, immediately remove resources from API and bypass graceful deletion", + default: false, }, gracePeriodSeconds: { type: "number", - description: "Period of time in seconds given to the resource to terminate gracefully" - } + description: + "Period of time in seconds given to the resource to terminate gracefully", + }, }, required: ["resourceType", "name", "namespace"], }, @@ -77,7 +83,7 @@ export async function kubectlDelete( "Either resourceType, manifest, or filename must be provided" ); } - + // If resourceType is provided, need either name or labelSelector if (input.resourceType && !input.name && !input.labelSelector) { throw new McpError( @@ -89,53 +95,61 @@ export async function kubectlDelete( const namespace = input.namespace || "default"; const allNamespaces = input.allNamespaces || false; const force = input.force || false; - - let command = "kubectl delete"; + + const command = "kubectl"; + const args = ["delete"]; let tempFile: string | null = null; - + // Handle deleting from manifest or file if (input.manifest) { // Create temporary file for the manifest const tmpDir = os.tmpdir(); tempFile = path.join(tmpDir, `delete-manifest-${Date.now()}.yaml`); fs.writeFileSync(tempFile, input.manifest); - command += ` -f ${tempFile}`; + args.push("-f", tempFile); } else if (input.filename) { - command += ` -f ${input.filename}`; + args.push("-f", input.filename); } else { // Handle deleting by resource type and name/selector - command += ` ${input.resourceType}`; - + args.push(input.resourceType!); + if (input.name) { - command += ` ${input.name}`; + args.push(input.name); } - + if (input.labelSelector) { - command += ` -l ${input.labelSelector}`; + args.push("-l", input.labelSelector); } } - + // Add namespace flags if (allNamespaces) { - command += " --all-namespaces"; - } else if (namespace && input.resourceType && !isNonNamespacedResource(input.resourceType)) { - command += ` -n ${namespace}`; + args.push("--all-namespaces"); + } else if ( + namespace && + input.resourceType && + !isNonNamespacedResource(input.resourceType) + ) { + args.push("-n", namespace); } - + // Add force flag if requested if (force) { - command += " --force"; + args.push("--force"); } - + // Add grace period if specified if (input.gracePeriodSeconds !== undefined) { - command += ` --grace-period=${input.gracePeriodSeconds}`; + args.push(`--grace-period=${input.gracePeriodSeconds}`); } - + // Execute the command try { - const result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + const result = execFileSync(command, args, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + // Clean up temp file if created if (tempFile) { try { @@ -144,7 +158,7 @@ export async function kubectlDelete( console.warn(`Failed to delete temporary file ${tempFile}: ${err}`); } } - + return { content: [ { @@ -162,7 +176,7 @@ export async function kubectlDelete( console.warn(`Failed to delete temporary file ${tempFile}: ${err}`); } } - + if (error.status === 404 || error.message.includes("not found")) { return { content: [ @@ -181,7 +195,7 @@ export async function kubectlDelete( isError: true, }; } - + throw new McpError( ErrorCode.InternalError, `Failed to delete resource: ${error.message}` @@ -191,7 +205,7 @@ export async function kubectlDelete( if (error instanceof McpError) { throw error; } - + throw new McpError( ErrorCode.InternalError, `Failed to execute kubectl delete command: ${error.message}` @@ -202,14 +216,22 @@ export async function kubectlDelete( // Helper function to determine if a resource is non-namespaced function isNonNamespacedResource(resourceType: string): boolean { const nonNamespacedResources = [ - "nodes", "node", "no", - "namespaces", "namespace", "ns", - "persistentvolumes", "pv", - "storageclasses", "sc", + "nodes", + "node", + "no", + "namespaces", + "namespace", + "ns", + "persistentvolumes", + "pv", + "storageclasses", + "sc", "clusterroles", "clusterrolebindings", - "customresourcedefinitions", "crd", "crds" + "customresourcedefinitions", + "crd", + "crds", ]; - + return nonNamespacedResources.includes(resourceType.toLowerCase()); -} +}
src/tools/kubectl-describe.ts+45 −36 modified@@ -1,31 +1,34 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; export const kubectlDescribeSchema = { name: "kubectl_describe", - description: "Describe Kubernetes resources by resource type, name, and optionally namespace", + description: + "Describe Kubernetes resources by resource type, name, and optionally namespace", inputSchema: { type: "object", properties: { - resourceType: { - type: "string", - description: "Type of resource to describe (e.g., pods, deployments, services, etc.)" + resourceType: { + type: "string", + description: + "Type of resource to describe (e.g., pods, deployments, services, etc.)", }, - name: { - type: "string", - description: "Name of the resource to describe" + name: { + type: "string", + description: "Name of the resource to describe", }, - namespace: { - type: "string", - description: "Namespace of the resource (optional - defaults to 'default' for namespaced resources)", - default: "default" + namespace: { + type: "string", + description: + "Namespace of the resource (optional - defaults to 'default' for namespaced resources)", + default: "default", }, allNamespaces: { type: "boolean", description: "If true, describe resources across all namespaces", - default: false - } + default: false, + }, }, required: ["resourceType", "name"], }, @@ -45,27 +48,25 @@ export async function kubectlDescribe( const name = input.name; const namespace = input.namespace || "default"; const allNamespaces = input.allNamespaces || false; - + // Build the kubectl command - let command = "kubectl describe "; - - // Add resource type - command += resourceType; - - // Add name - command += ` ${name}`; - + const command = "kubectl"; + const args = ["describe", resourceType, name]; + // Add namespace flag unless all namespaces is specified if (allNamespaces) { - command += " --all-namespaces"; + args.push("--all-namespaces"); } else if (namespace && !isNonNamespacedResource(resourceType)) { - command += ` -n ${namespace}`; + args.push("-n", namespace); } - + // Execute the command try { - const result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + const result = execFileSync(command, args, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + return { content: [ { @@ -93,7 +94,7 @@ export async function kubectlDescribe( isError: true, }; } - + throw new McpError( ErrorCode.InternalError, `Failed to describe resource: ${error.message}` @@ -110,14 +111,22 @@ export async function kubectlDescribe( // Helper function to determine if a resource is non-namespaced function isNonNamespacedResource(resourceType: string): boolean { const nonNamespacedResources = [ - "nodes", "node", "no", - "namespaces", "namespace", "ns", - "persistentvolumes", "pv", - "storageclasses", "sc", + "nodes", + "node", + "no", + "namespaces", + "namespace", + "ns", + "persistentvolumes", + "pv", + "storageclasses", + "sc", "clusterroles", "clusterrolebindings", - "customresourcedefinitions", "crd", "crds" + "customresourcedefinitions", + "crd", + "crds", ]; - + return nonNamespacedResources.includes(resourceType.toLowerCase()); -} \ No newline at end of file +}
src/tools/kubectl-generic.ts+34 −29 modified@@ -1,52 +1,54 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; export const kubectlGenericSchema = { name: "kubectl_generic", - description: "Execute any kubectl command with the provided arguments and flags", + description: + "Execute any kubectl command with the provided arguments and flags", inputSchema: { type: "object", properties: { command: { type: "string", - description: "The kubectl command to execute (e.g. patch, rollout, top)" + description: + "The kubectl command to execute (e.g. patch, rollout, top)", }, subCommand: { type: "string", - description: "Subcommand if applicable (e.g. 'history' for rollout)" + description: "Subcommand if applicable (e.g. 'history' for rollout)", }, resourceType: { type: "string", - description: "Resource type (e.g. pod, deployment)" + description: "Resource type (e.g. pod, deployment)", }, name: { type: "string", - description: "Resource name" + description: "Resource name", }, namespace: { type: "string", description: "Namespace", - default: "default" + default: "default", }, outputFormat: { type: "string", description: "Output format (e.g. json, yaml, wide)", - enum: ["json", "yaml", "wide", "name", "custom"] + enum: ["json", "yaml", "wide", "name", "custom"], }, flags: { type: "object", description: "Command flags as key-value pairs", - additionalProperties: true + additionalProperties: true, }, args: { type: "array", items: { type: "string" }, - description: "Additional command arguments" - } + description: "Additional command arguments", + }, }, - required: ["command"] - } + required: ["command"], + }, }; export async function kubectlGeneric( @@ -64,33 +66,34 @@ export async function kubectlGeneric( ) { try { // Start building the kubectl command - let cmdArgs: string[] = ["kubectl", input.command]; - + const command = "kubectl"; + const cmdArgs: string[] = [input.command]; + // Add subcommand if provided if (input.subCommand) { cmdArgs.push(input.subCommand); } - + // Add resource type if provided if (input.resourceType) { cmdArgs.push(input.resourceType); } - + // Add resource name if provided if (input.name) { cmdArgs.push(input.name); } - + // Add namespace if provided if (input.namespace) { cmdArgs.push(`--namespace=${input.namespace}`); } - + // Add output format if provided if (input.outputFormat) { cmdArgs.push(`-o=${input.outputFormat}`); } - + // Add any provided flags if (input.flags) { for (const [key, value] of Object.entries(input.flags)) { @@ -103,18 +106,20 @@ export async function kubectlGeneric( } } } - + // Add any additional arguments if (input.args && input.args.length > 0) { cmdArgs.push(...input.args); } - - // Execute the command (join all args except the first "kubectl" which is used in execSync) - const command = cmdArgs.slice(1).join(' '); + + // Execute the command try { - console.error(`Executing: kubectl ${command}`); - const result = execSync(`kubectl ${command}`, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + console.error(`Executing: kubectl ${cmdArgs.join(" ")}`); + const result = execFileSync(command, cmdArgs, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + return { content: [ { @@ -133,10 +138,10 @@ export async function kubectlGeneric( if (error instanceof McpError) { throw error; } - + throw new McpError( ErrorCode.InternalError, `Failed to execute kubectl command: ${error.message}` ); } -} +}
src/tools/kubectl-get.ts+26 −22 modified@@ -1,5 +1,5 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; export const kubectlGetSchema = { @@ -38,17 +38,17 @@ export const kubectlGetSchema = { }, labelSelector: { type: "string", - description: "Filter resources by label selector (e.g. 'app=nginx')" + description: "Filter resources by label selector (e.g. 'app=nginx')", }, fieldSelector: { type: "string", description: - "Filter resources by field selector (e.g. 'metadata.name=my-pod')" + "Filter resources by field selector (e.g. 'metadata.name=my-pod')", }, sortBy: { type: "string", description: - "Sort events by a field (default: lastTimestamp). Only applicable for events." + "Sort events by a field (default: lastTimestamp). Only applicable for events.", }, }, required: ["resourceType", "name", "namespace"], @@ -79,14 +79,12 @@ export async function kubectlGet( const sortBy = input.sortBy; // Build the kubectl command - let command = "kubectl get "; - - // Add resource type - command += resourceType; + const command = "kubectl"; + const args = ["get", resourceType]; // Add name if provided if (name) { - command += ` ${name}`; + args.push(name); } // For events, default to all namespaces unless explicitly specified @@ -99,48 +97,54 @@ export async function kubectlGet( // Add namespace flag unless all namespaces is specified if (shouldShowAllNamespaces) { - command += " --all-namespaces"; + args.push("--all-namespaces"); } else if (namespace && !isNonNamespacedResource(resourceType)) { - command += ` -n ${namespace}`; + args.push("-n", namespace); } // Add label selector if provided if (labelSelector) { - command += ` -l ${labelSelector}`; + args.push("-l", labelSelector); } // Add field selector if provided if (fieldSelector) { - command += ` --field-selector=${fieldSelector}`; + args.push(`--field-selector=${fieldSelector}`); } // Add sort-by for events if (resourceType === "events" && sortBy) { - command += ` --sort-by=.${sortBy}`; + args.push(`--sort-by=.${sortBy}`); } else if (resourceType === "events") { - command += ` --sort-by=.lastTimestamp`; + args.push(`--sort-by=.lastTimestamp`); } // Add output format if (output === "json") { - command += " -o json"; + args.push("-o", "json"); } else if (output === "yaml") { - command += " -o yaml"; + args.push("-o", "yaml"); } else if (output === "wide") { - command += " -o wide"; + args.push("-o", "wide"); } else if (output === "name") { - command += " -o name"; + args.push("-o", "name"); } else if (output === "custom") { if (resourceType === "events") { - command += ` -o 'custom-columns=LAST SEEN:.lastTimestamp,TYPE:.type,REASON:.reason,OBJECT:.involvedObject.kind/.involvedObject.name,MESSAGE:.message'`; + args.push( + "-o", + "'custom-columns=LASTSEEN:.lastTimestamp,TYPE:.type,REASON:.reason,OBJECT:.involvedObject.name,MESSAGE:.message'" + ); } else { - command += ` -o 'custom-columns=NAME:.metadata.name,NAMESPACE:.metadata.namespace,STATUS:.status.phase,AGE:.metadata.creationTimestamp'`; + args.push( + "-o", + "'custom-columns=NAME:.metadata.name,NAMESPACE:.metadata.namespace,STATUS:.status.phase,AGE:.metadata.creationTimestamp'" + ); } } // Execute the command try { - const result = execSync(command, { + const result = execFileSync(command, args, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, });
src/tools/kubectl-logs.ts+144 −82 modified@@ -1,10 +1,11 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; export const kubectlLogsSchema = { name: "kubectl_logs", - description: "Get logs from Kubernetes resources like pods, deployments, or jobs", + description: + "Get logs from Kubernetes resources like pods, deployments, or jobs", inputSchema: { type: "object", properties: { @@ -24,19 +25,20 @@ export const kubectlLogsSchema = { }, container: { type: "string", - description: "Container name (required when pod has multiple containers)" + description: + "Container name (required when pod has multiple containers)", }, tail: { type: "number", - description: "Number of lines to show from end of logs" + description: "Number of lines to show from end of logs", }, since: { type: "string", - description: "Show logs since relative time (e.g. '5s', '2m', '3h')" + description: "Show logs since relative time (e.g. '5s', '2m', '3h')", }, sinceTime: { type: "string", - description: "Show logs since absolute time (RFC3339)" + description: "Show logs since absolute time (RFC3339)", }, timestamps: { type: "boolean", @@ -55,8 +57,8 @@ export const kubectlLogsSchema = { }, labelSelector: { type: "string", - description: "Filter resources by label selector" - } + description: "Filter resources by label selector", + }, }, required: ["resourceType", "name", "namespace"], }, @@ -82,46 +84,72 @@ export async function kubectlLogs( const resourceType = input.resourceType.toLowerCase(); const name = input.name; const namespace = input.namespace || "default"; - - // Build the kubectl command base - let baseCommand = `kubectl -n ${namespace}`; - + + const command = "kubectl"; // Handle different resource types if (resourceType === "pod") { // Direct pod logs - baseCommand += ` logs ${name}`; - + let args = ["-n", namespace, "logs", name]; + // If container is specified, add it if (input.container) { - baseCommand += ` -c ${input.container}`; + args.push(`-c`, input.container); } - + // Add options - baseCommand = addLogOptions(baseCommand, input); - + args = addLogOptions(args, input); + // Execute the command try { - const result = execSync(baseCommand, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); + const result = execFileSync(command, args, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); return formatLogOutput(name, result); } catch (error: any) { return handleCommandError(error, `pod ${name}`); } - } else if (resourceType === "deployment" || resourceType === "job" || resourceType === "cronjob") { + } else if ( + resourceType === "deployment" || + resourceType === "job" || + resourceType === "cronjob" + ) { // For deployments, jobs and cronjobs we need to find the pods first - let selectorCommand; - + let selectorArgs; + if (resourceType === "deployment") { - selectorCommand = `kubectl -n ${namespace} get deployment ${name} -o jsonpath='{.spec.selector.matchLabels}'`; + selectorArgs = [ + "-n", + namespace, + "get", + "deployment", + name, + "-o", + "jsonpath='{.spec.selector.matchLabels}'", + ]; } else if (resourceType === "job") { // For jobs, we use the job-name label return getLabelSelectorLogs(`job-name=${name}`, namespace, input); } else if (resourceType === "cronjob") { // For cronjobs, it's more complex - need to find the job first - const jobsCommand = `kubectl -n ${namespace} get jobs --selector=job-name=${name} -o jsonpath='{.items[*].metadata.name}'`; + const jobsArgs = [ + "-n", + namespace, + "get", + "jobs", + "--selector=job-name=" + name, + "-o", + "jsonpath='{.items[*].metadata.name}'", + ]; try { - const jobs = execSync(jobsCommand, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }).trim().split(' '); - - if (jobs.length === 0 || (jobs.length === 1 && jobs[0] === '')) { + const jobs = execFileSync(command, jobsArgs, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }) + .trim() + .split(" "); + + if (jobs.length === 0 || (jobs.length === 1 && jobs[0] === "")) { return { content: [ { @@ -137,17 +165,21 @@ export async function kubectlLogs( ], }; } - + // Get logs for all jobs const allJobLogs: Record<string, any> = {}; - + for (const job of jobs) { // Get logs for pods from this job - const result = await getLabelSelectorLogs(`job-name=${job}`, namespace, input); + const result = await getLabelSelectorLogs( + `job-name=${job}`, + namespace, + input + ); const jobLog = JSON.parse(result.content[0].text); allJobLogs[job] = jobLog.logs; } - + return { content: [ { @@ -168,24 +200,27 @@ export async function kubectlLogs( return handleCommandError(error, `cronjob ${name}`); } } - + try { if (resourceType === "deployment") { // Get the deployment's selector - if (!selectorCommand) { + if (!selectorArgs) { throw new Error("Selector command is undefined"); } - const selectorJson = execSync(selectorCommand, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }).trim(); + const selectorJson = execFileSync(command, selectorArgs, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }).trim(); const selector = JSON.parse(selectorJson.replace(/'/g, '"')); - + // Convert to label selector format const labelSelector = Object.entries(selector) .map(([key, value]) => `${key}=${value}`) - .join(','); - + .join(","); + return getLabelSelectorLogs(labelSelector, namespace, input); } - + // For jobs and cronjobs, the logic is handled above return { content: [ @@ -224,35 +259,33 @@ export async function kubectlLogs( } // Helper function to add log options to the kubectl command -function addLogOptions(baseCommand: string, input: any): string { - let command = baseCommand; - +function addLogOptions(args: string[], input: any): string[] { // Add options based on inputs if (input.tail !== undefined) { - command += ` --tail=${input.tail}`; + args.push(`--tail=${input.tail}`); } - + if (input.since) { - command += ` --since=${input.since}`; + args.push(`--since=${input.since}`); } - + if (input.sinceTime) { - command += ` --since-time=${input.sinceTime}`; + args.push(`--since-time=${input.sinceTime}`); } - + if (input.timestamps) { - command += ` --timestamps`; + args.push(`--timestamps`); } - + if (input.previous) { - command += ` --previous`; + args.push(`--previous`); } - + if (input.follow) { - command += ` --follow`; + args.push(`--follow`); } - - return command; + + return args; } // Helper function to get logs for resources selected by labels @@ -262,11 +295,25 @@ async function getLabelSelectorLogs( input: any ): Promise<{ content: Array<{ type: string; text: string }> }> { try { + const command = "kubectl"; // First, find all pods matching the label selector - const podsCommand = `kubectl -n ${namespace} get pods --selector=${labelSelector} -o jsonpath='{.items[*].metadata.name}'`; - const pods = execSync(podsCommand, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }).trim().split(' '); - - if (pods.length === 0 || (pods.length === 1 && pods[0] === '')) { + const podsArgs = [ + "-n", + namespace, + "get", + "pods", + `--selector=${labelSelector}`, + "-o", + "jsonpath='{.items[*].metadata.name}'", + ]; + const pods = execFileSync(command, podsArgs, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }) + .trim() + .split(" "); + + if (pods.length === 0 || (pods.length === 1 && pods[0] === "")) { return { content: [ { @@ -282,32 +329,35 @@ async function getLabelSelectorLogs( ], }; } - + // Get logs for each pod const logsMap: Record<string, string> = {}; - + for (const pod of pods) { // Skip empty pod names if (!pod) continue; - - let podCommand = `kubectl -n ${namespace} logs ${pod}`; - + + let podArgs = ["-n", namespace, "logs", pod]; + // Add container if specified if (input.container) { - podCommand += ` -c ${input.container}`; + podArgs.push(`-c`, input.container); } - + // Add other options - podCommand = addLogOptions(podCommand, input); - + podArgs = addLogOptions(podArgs, input); + try { - const logs = execSync(podCommand, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); + const logs = execFileSync(command, podArgs, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); logsMap[pod] = logs; } catch (error: any) { logsMap[pod] = `Error: ${error.message}`; } } - + return { content: [ { @@ -351,7 +401,7 @@ function formatLogOutput(resourceName: string, logOutput: string) { // Helper function to handle command errors function handleCommandError(error: any, resourceDescription: string) { console.error(`Error getting logs for ${resourceDescription}:`, error); - + if (error.status === 404 || error.message.includes("not found")) { return { content: [ @@ -370,28 +420,40 @@ function handleCommandError(error: any, resourceDescription: string) { isError: true, }; } - + // Check for multi-container pod error if (error.message.includes("a container name must be specified")) { // Extract pod name and available containers from error message const podNameMatch = error.message.match(/for pod ([^,]+)/); const containersMatch = error.message.match(/choose one of: \[([^\]]+)\]/); - const initContainersMatch = error.message.match(/or one of the init containers: \[([^\]]+)\]/); - - const podName = podNameMatch ? podNameMatch[1] : 'unknown'; - const containers = containersMatch ? containersMatch[1].split(' ').map((c: string) => c.trim()) : []; - const initContainers = initContainersMatch ? initContainersMatch[1].split(' ').map((c: string) => c.trim()) : []; - + const initContainersMatch = error.message.match( + /or one of the init containers: \[([^\]]+)\]/ + ); + + const podName = podNameMatch ? podNameMatch[1] : "unknown"; + const containers = containersMatch + ? containersMatch[1].split(" ").map((c: string) => c.trim()) + : []; + const initContainers = initContainersMatch + ? initContainersMatch[1].split(" ").map((c: string) => c.trim()) + : []; + // Generate structured context for the MCP client to make decisions const context = { error: "Multi-container pod requires container specification", status: "multi_container_error", pod_name: podName, available_containers: containers, init_containers: initContainers, - suggestion: `Please specify a container name using the 'container' parameter. Available containers: ${containers.join(', ')}${initContainers.length > 0 ? `. Init containers: ${initContainers.join(', ')}` : ''}` + suggestion: `Please specify a container name using the 'container' parameter. Available containers: ${containers.join( + ", " + )}${ + initContainers.length > 0 + ? `. Init containers: ${initContainers.join(", ")}` + : "" + }`, }; - + return { content: [ { @@ -402,7 +464,7 @@ function handleCommandError(error: any, resourceDescription: string) { isError: true, }; } - + return { content: [ { @@ -411,7 +473,7 @@ function handleCommandError(error: any, resourceDescription: string) { { error: `Failed to get logs for ${resourceDescription}: ${error.message}`, status: "general_error", - original_error: error.message + original_error: error.message, }, null, 2 @@ -420,4 +482,4 @@ function handleCommandError(error: any, resourceDescription: string) { ], isError: true, }; -} +}
src/tools/kubectl-operations.ts+20 −15 modified@@ -1,4 +1,4 @@ -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { ExplainResourceParams, ListApiResourcesParams, @@ -66,9 +66,12 @@ export const listApiResourcesSchema = { }, }; -const executeKubectlCommand = (command: string): string => { +const executeKubectlCommand = (command: string, args: string[]): string => { try { - return execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); + return execFileSync(command, args, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); } catch (error: any) { throw new Error(`Kubectl command failed: ${error.message}`); } @@ -78,23 +81,24 @@ export async function explainResource( params: ExplainResourceParams ): Promise<{ content: { type: string; text: string }[] }> { try { - let command = "kubectl explain"; + const command = "kubectl"; + const args = ["explain"]; if (params.apiVersion) { - command += ` --api-version=${params.apiVersion}`; + args.push(`--api-version=${params.apiVersion}`); } if (params.recursive) { - command += " --recursive"; + args.push("--recursive"); } if (params.output) { - command += ` --output=${params.output}`; + args.push(`--output=${params.output}`); } - command += ` ${params.resource}`; + args.push(params.resource); - const result = executeKubectlCommand(command); + const result = executeKubectlCommand(command, args); return { content: [ @@ -113,25 +117,26 @@ export async function listApiResources( params: ListApiResourcesParams ): Promise<{ content: { type: string; text: string }[] }> { try { - let command = "kubectl api-resources"; + const command = "kubectl"; + const args = ["api-resources"]; if (params.apiGroup) { - command += ` --api-group=${params.apiGroup}`; + args.push(`--api-group=${params.apiGroup}`); } if (params.namespaced !== undefined) { - command += ` --namespaced=${params.namespaced}`; + args.push(`--namespaced=${params.namespaced}`); } if (params.verbs && params.verbs.length > 0) { - command += ` --verbs=${params.verbs.join(",")}`; + args.push(`--verbs=${params.verbs.join(",")}`); } if (params.output) { - command += ` -o ${params.output}`; + args.push(`-o`, params.output); } - const result = executeKubectlCommand(command); + const result = executeKubectlCommand(command, args); return { content: [
src/tools/kubectl-patch.ts+46 −39 modified@@ -1,51 +1,55 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import * as fs from "fs"; import * as path from "path"; import * as os from "os"; export const kubectlPatchSchema = { name: "kubectl_patch", - description: "Update field(s) of a resource using strategic merge patch, JSON merge patch, or JSON patch", + description: + "Update field(s) of a resource using strategic merge patch, JSON merge patch, or JSON patch", inputSchema: { type: "object", properties: { - resourceType: { - type: "string", - description: "Type of resource to patch (e.g., pods, deployments, services)" + resourceType: { + type: "string", + description: + "Type of resource to patch (e.g., pods, deployments, services)", }, - name: { - type: "string", - description: "Name of the resource to patch" + name: { + type: "string", + description: "Name of the resource to patch", }, - namespace: { - type: "string", - description: "Namespace of the resource", - default: "default" + namespace: { + type: "string", + description: "Namespace of the resource", + default: "default", }, patchType: { type: "string", description: "Type of patch to apply", enum: ["strategic", "merge", "json"], - default: "strategic" + default: "strategic", }, patchData: { type: "object", - description: "Patch data as a JSON object" + description: "Patch data as a JSON object", }, patchFile: { type: "string", - description: "Path to a file containing the patch data (alternative to patchData)" + description: + "Path to a file containing the patch data (alternative to patchData)", }, dryRun: { type: "boolean", - description: "If true, only print the object that would be sent, without sending it", - default: false - } + description: + "If true, only print the object that would be sent, without sending it", + default: false, + }, }, required: ["resourceType", "name"], - } + }, }; export async function kubectlPatch( @@ -72,45 +76,48 @@ export async function kubectlPatch( const patchType = input.patchType || "strategic"; const dryRun = input.dryRun || false; let tempFile: string | null = null; - - // Build the kubectl patch command - let command = `kubectl patch ${input.resourceType} ${input.name} -n ${namespace}`; - + + const command = "kubectl"; + const args = ["patch", input.resourceType, input.name, "-n", namespace]; + // Add patch type flag switch (patchType) { case "strategic": - command += " --type strategic"; + args.push("--type", "strategic"); break; case "merge": - command += " --type merge"; + args.push("--type", "merge"); break; case "json": - command += " --type json"; + args.push("--type", "json"); break; default: - command += " --type strategic"; + args.push("--type", "strategic"); } - + // Handle patch data if (input.patchData) { // Create a temporary file for the patch data const tmpDir = os.tmpdir(); tempFile = path.join(tmpDir, `patch-${Date.now()}.json`); fs.writeFileSync(tempFile, JSON.stringify(input.patchData)); - command += ` --patch-file ${tempFile}`; + args.push("--patch-file", tempFile); } else if (input.patchFile) { - command += ` --patch-file ${input.patchFile}`; + args.push("--patch-file", input.patchFile); } - + // Add dry-run flag if requested if (dryRun) { - command += " --dry-run=client"; + args.push("--dry-run=client"); } - + // Execute the command try { - const result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + const result = execFileSync(command, args, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + // Clean up temp file if created if (tempFile) { try { @@ -119,7 +126,7 @@ export async function kubectlPatch( console.warn(`Failed to delete temporary file ${tempFile}: ${err}`); } } - + return { content: [ { @@ -137,7 +144,7 @@ export async function kubectlPatch( console.warn(`Failed to delete temporary file ${tempFile}: ${err}`); } } - + throw new McpError( ErrorCode.InternalError, `Failed to patch resource: ${error.message}` @@ -147,10 +154,10 @@ export async function kubectlPatch( if (error instanceof McpError) { throw error; } - + throw new McpError( ErrorCode.InternalError, `Failed to execute kubectl patch command: ${error.message}` ); } -} \ No newline at end of file +}
src/tools/kubectl-rollout.ts+45 −32 modified@@ -1,54 +1,56 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; export const kubectlRolloutSchema = { name: "kubectl_rollout", - description: "Manage the rollout of a resource (e.g., deployment, daemonset, statefulset)", + description: + "Manage the rollout of a resource (e.g., deployment, daemonset, statefulset)", inputSchema: { type: "object", properties: { subCommand: { type: "string", description: "Rollout subcommand to execute", enum: ["history", "pause", "restart", "resume", "status", "undo"], - default: "status" + default: "status", }, resourceType: { type: "string", description: "Type of resource to manage rollout for", enum: ["deployment", "daemonset", "statefulset"], - default: "deployment" + default: "deployment", }, name: { type: "string", - description: "Name of the resource" + description: "Name of the resource", }, namespace: { type: "string", description: "Namespace of the resource", - default: "default" + default: "default", }, revision: { type: "number", - description: "Revision to rollback to (for undo subcommand)" + description: "Revision to rollback to (for undo subcommand)", }, toRevision: { type: "number", - description: "Revision to roll back to (for history subcommand)" + description: "Revision to roll back to (for history subcommand)", }, timeout: { type: "string", - description: "The length of time to wait before giving up (e.g., '30s', '1m', '2m30s')" + description: + "The length of time to wait before giving up (e.g., '30s', '1m', '2m30s')", }, watch: { type: "boolean", description: "Watch the rollout status in real-time until completion", - default: false - } + default: false, + }, }, - required: ["subCommand", "resourceType", "name", "namespace"] - } + required: ["subCommand", "resourceType", "name", "namespace"], + }, }; export async function kubectlRollout( @@ -67,50 +69,61 @@ export async function kubectlRollout( try { const namespace = input.namespace || "default"; const watch = input.watch || false; - - // Build the kubectl rollout command - let command = `kubectl rollout ${input.subCommand} ${input.resourceType}/${input.name} -n ${namespace}`; - + + const command = "kubectl"; + const args = [ + "rollout", + input.subCommand, + `${input.resourceType}/${input.name}`, + "-n", + namespace, + ]; + // Add revision for undo if (input.subCommand === "undo" && input.revision !== undefined) { - command += ` --to-revision=${input.revision}`; + args.push(`--to-revision=${input.revision}`); } - + // Add revision for history if (input.subCommand === "history" && input.toRevision !== undefined) { - command += ` --revision=${input.toRevision}`; + args.push(`--revision=${input.toRevision}`); } - + // Add timeout if specified if (input.timeout) { - command += ` --timeout=${input.timeout}`; + args.push(`--timeout=${input.timeout}`); } - + // Execute the command try { // For status command with watch flag, we need to handle it differently // since it's meant to be interactive and follow the progress if (input.subCommand === "status" && watch) { - command += " --watch"; + args.push("--watch"); // For watch we are limited in what we can do - we'll execute it with a reasonable timeout // and capture the output until that point - const result = execSync(command, { + const result = execFileSync(command, args, { encoding: "utf8", timeout: 15000, // Reduced from 30 seconds to 15 seconds - env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); - + return { content: [ { type: "text", - text: result + "\n\nNote: Watch operation was limited to 15 seconds. The rollout may still be in progress.", + text: + result + + "\n\nNote: Watch operation was limited to 15 seconds. The rollout may still be in progress.", }, ], }; } else { - const result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + const result = execFileSync(command, args, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + return { content: [ { @@ -130,10 +143,10 @@ export async function kubectlRollout( if (error instanceof McpError) { throw error; } - + throw new McpError( ErrorCode.InternalError, `Failed to execute kubectl rollout command: ${error.message}` ); } -} +}
src/tools/kubectl-scale.ts+39 −29 modified@@ -1,5 +1,5 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; export const kubectlScaleSchema = { @@ -8,27 +8,28 @@ export const kubectlScaleSchema = { inputSchema: { type: "object", properties: { - name: { + name: { type: "string", - description: "Name of the deployment to scale" + description: "Name of the deployment to scale", }, - namespace: { + namespace: { type: "string", description: "Namespace of the deployment", - default: "default" + default: "default", }, - replicas: { + replicas: { type: "number", - description: "Number of replicas to scale to" + description: "Number of replicas to scale to", }, resourceType: { type: "string", - description: "Resource type to scale (deployment, replicaset, statefulset)", - default: "deployment" - } + description: + "Resource type to scale (deployment, replicaset, statefulset)", + default: "deployment", + }, }, - required: ["name", "replicas"] - } + required: ["name", "replicas"], + }, }; export async function kubectlScale( @@ -43,21 +44,30 @@ export async function kubectlScale( try { const namespace = input.namespace || "default"; const resourceType = input.resourceType || "deployment"; - - // Build the kubectl scale command - let command = `kubectl scale ${resourceType} ${input.name} --replicas=${input.replicas} --namespace=${namespace}`; - + + const command = "kubectl"; + const args = [ + "scale", + resourceType, + input.name, + `--replicas=${input.replicas}`, + `--namespace=${namespace}`, + ]; + // Execute the command try { - const result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + const result = execFileSync(command, args, { + encoding: "utf8", + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + return { content: [ { success: true, - message: `Scaled ${resourceType} ${input.name} to ${input.replicas} replicas` - } - ] + message: `Scaled ${resourceType} ${input.name} to ${input.replicas} replicas`, + }, + ], }; } catch (error: any) { throw new McpError( @@ -71,19 +81,19 @@ export async function kubectlScale( content: [ { success: false, - message: error.message - } - ] + message: error.message, + }, + ], }; } - + return { content: [ { success: false, - message: `Failed to scale resource: ${error.message}` - } - ] + message: `Failed to scale resource: ${error.message}`, + }, + ], }; } -} \ No newline at end of file +}
tests/kubectl.test.ts+7 −34 modified@@ -192,7 +192,7 @@ describe("kubectl operations", () => { arguments: { resourceType: "events", namespace: "default", - output: "json" + output: "json", }, }, }, @@ -231,7 +231,7 @@ describe("kubectl operations", () => { arguments: { resourceType: "events", allNamespaces: true, - output: "json" + output: "json", }, }, }, @@ -256,7 +256,7 @@ describe("kubectl operations", () => { resourceType: "events", namespace: "default", fieldSelector: "type=Normal", - output: "json" + output: "json", }, }, }, @@ -293,7 +293,7 @@ describe("kubectl operations", () => { arguments: { resourceType: "events", namespace: "default", - output: "json" + output: "json", }, }, }, @@ -332,7 +332,7 @@ describe("kubectl operations", () => { arguments: { resourceType: "events", allNamespaces: true, - output: "json" + output: "json", }, }, }, @@ -357,7 +357,7 @@ describe("kubectl operations", () => { resourceType: "events", namespace: "default", fieldSelector: "type=Normal", - output: "json" + output: "json", }, }, }, @@ -389,7 +389,7 @@ describe("kubectl operations", () => { resourceType: "events", namespace: "default", sortBy: "type", - output: "json" + output: "json", }, }, }, @@ -402,32 +402,5 @@ describe("kubectl operations", () => { expect(events.events).toBeDefined(); expect(Array.isArray(events.events)).toBe(true); }); - - test("get events with custom output format using kubectl-get", async () => { - const result = await retry(async () => { - return await client.request( - { - method: "tools/call", - params: { - name: "kubectl_get", - arguments: { - resourceType: "events", - namespace: "default", - output: "custom" - }, - }, - }, - asResponseSchema(KubectlResponseSchema) - ); - }); - - expect(result.content[0].type).toBe("text"); - const output = result.content[0].text; - expect(output).toContain("LAST SEEN"); - expect(output).toContain("TYPE"); - expect(output).toContain("REASON"); - expect(output).toContain("OBJECT"); - expect(output).toContain("MESSAGE"); - }); }); });
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-gjv4-ghm7-q58qghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-53355ghsaADVISORY
- equixly.com/blog/2025/03/29/mcp-server-new-security-nightmareghsaWEB
- github.com/Flux159/mcp-server-kubernetes/commit/ab165f5a0eea917fef5dbae954506fff6f4bf514nvdWEB
- github.com/Flux159/mcp-server-kubernetes/security/advisories/GHSA-gjv4-ghm7-q58qnvdWEB
- github.com/cyanheads/git-mcp-server/commit/0dbd6995ccdf76ab770b58013034365b2d06c4d9nvdWEB
- invariantlabs.ai/blog/mcp-github-vulnerabilityghsaWEB
News mentions
0No linked articles in our index yet.