diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index ee06f17..3edc6d3 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -74,11 +74,10 @@ jobs: # Make sure we're on main branch git checkout main - # Update the version in package.json and manifest.json and commit the change - jq --arg v "${{ steps.version.outputs.current-version }}" '.version = $v' package.json > temp.json && mv temp.json package.json - jq --arg v "${{ steps.version.outputs.current-version }}" '.version = $v' manifest.json > temp.json && mv temp.json manifest.json + # Update the version in all files using the npm script + npm run version:update ${{ steps.version.outputs.current-version }} - git add package.json manifest.json + git add package.json manifest.json CITATION.cff README.md git commit -m "Bump version to ${{ steps.version.outputs.current-version }}" # Create and push the tag diff --git a/.gitignore b/.gitignore index 4d7eb0f..a7f50df 100644 --- a/.gitignore +++ b/.gitignore @@ -126,7 +126,6 @@ dist # Stores VSCode versions used for testing VSCode extensions .vscode-test -.vscode/ # yarn v2 .yarn/cache @@ -136,3 +135,5 @@ dist .pnp.* mcp-server-kubernetes.dxt + +.vscode/ diff --git a/ADVANCED_README.md b/ADVANCED_README.md index 08003ee..de37a5c 100644 --- a/ADVANCED_README.md +++ b/ADVANCED_README.md @@ -1,5 +1,23 @@ # Advanced README for mcp-server-kubernetes +## Large clusters + +If you have large clusters or see a `spawnSync ENOBFUS` error, you may need to specify the environment argument `SPAWN_MAX_BUFFER` (in bytes) when running the server. See [this issue](https://github.com/Flux159/mcp-server-kubernetes/issues/172) for more information. + +```json +{ + "mcpServers": { + "kubernetes-readonly": { + "command": "npx", + "args": ["mcp-server-kubernetes"], + "env": { + "SPAWN_MAX_BUFFER": "5242880" // 5MB = 1024*1024*5. Default is 1MB in Node.js + } + } + } +} +``` + ## Authentication Options The server supports multiple authentication methods with the following priority order: @@ -115,9 +133,75 @@ For Claude Desktop with environment variables: } ``` +### Tool Filtering Modes + +The server offers several modes to control which tools are available, configured via environment variables. The modes are prioritized as follows: + +1. `ALLOWED_TOOLS` +2. `ALLOW_ONLY_READONLY_TOOLS` +3. `ALLOW_ONLY_NON_DESTRUCTIVE_TOOLS` + +#### Allowed Tools List + +You can specify a comma-separated list of tool names to enable only those specific tools. This provides fine-grained control over the server's capabilities. + +```shell +ALLOWED_TOOLS="kubectl_get,kubectl_describe" npx mcp-server-kubernetes +``` + +In your Claude Desktop configuration: + +```json +{ + "mcpServers": { + "kubernetes-custom": { + "command": "npx", + "args": ["mcp-server-kubernetes"], + "env": { + "ALLOWED_TOOLS": "kubectl_get,kubectl_describe,kubectl_logs" + } + } + } +} +``` + +#### Read-Only Mode + +For the strictest level of safety, you can enable read-only mode. This mode only permits tools that cannot alter the cluster state. + +```shell +ALLOW_ONLY_READONLY_TOOLS=true npx mcp-server-kubernetes +``` + +The following tools are available in read-only mode: + +- `kubectl_get` +- `kubectl_describe` +- `kubectl_logs` +- `kubectl_context` +- `explain_resource` +- `list_api_resources` +- `ping` + +In your Claude Desktop configuration: + +```json +{ + "mcpServers": { + "kubernetes-readonly-strict": { + "command": "npx", + "args": ["mcp-server-kubernetes"], + "env": { + "ALLOW_ONLY_READONLY_TOOLS": "true" + } + } + } +} +``` + ### Non-Destructive Mode -You can run the server in a non-destructive mode that disables all destructive operations (delete pods, delete deployments, delete namespaces, etc.) by setting the `ALLOW_ONLY_NON_DESTRUCTIVE_TOOLS` environment variable to `true`: +If neither of the above modes are active, you can run the server in a non-destructive mode that disables all destructive operations (delete pods, delete deployments, delete namespaces, etc.) by setting the `ALLOW_ONLY_NON_DESTRUCTIVE_TOOLS` environment variable to `true`: ```shell ALLOW_ONLY_NON_DESTRUCTIVE_TOOLS=true npx mcp-server-kubernetes @@ -157,7 +241,53 @@ For Non destructive mode in Claude Desktop, you can specify the env var like thi } ``` -### SSE Transport +### Secrets Masking + +By default, the server automatically masks sensitive data in Kubernetes secrets to prevent accidental exposure of confidential information. You can disable this behavior if needed: + +```shell +MASK_SECRETS=false npx mcp-server-kubernetes +``` + +For Claude Desktop configuration to disable secrets masking: + +```json +{ + "mcpServers": { + "kubernetes": { + "command": "npx", + "args": ["mcp-server-kubernetes"], + "env": { + "MASK_SECRETS": "false" + } + } + } +} +``` + +When enabled (default), `kubectl get secrets` and `kubectl get secret` commands will automatically mask all values in the `data` section with `***` while preserving the structure and metadata. Note that this only applies to the `kubectl get secrets` command output and does not mask secrets that may appear in logs or other operations. + +### Streamable HTTP Transport + +To enable [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) for mcp-server-kubernetes, use the ENABLE_UNSAFE_STREAMABLE_HTTP_TRANSPORT environment variable. + +```shell +ENABLE_UNSAFE_STREAMABLE_HTTP_TRANSPORT=1 npx flux159/mcp-server-kubernetes +``` + +This will start an http server with the `/mcp` endpoint for streamable http events (POST, GET, and DELETE). Use the `PORT` env var to configure the server port. Use the `HOST` env var to configure listening on interfaces other than localhost. + +```shell +ENABLE_UNSAFE_STREAMABLE_HTTP_TRANSPORT=1 PORT=3001 HOST=0.0.0.0 npx flux159/mcp-server-kubernetes +``` + +To enable DNS Rebinding protection if running locally, you should use `DNS_REBINDING_PROTECTION` and optionally `DNS_REBINDING_ALLOWED_HOST` (defaults to 127.0.0.1): + +``` +DNS_REBINDING_ALLOWED_HOST=true ENABLE_UNSAFE_STREAMABLE_HTTP_TRANSPORT=1 PORT=3001 HOST=0.0.0.0 npx flux159/mcp-server-kubernetes +``` + +### SSE Transport (Deprecated in favor of Streamable HTTP) To enable [SSE transport](https://modelcontextprotocol.io/docs/concepts/transports#server-sent-events-sse) for mcp-server-kubernetes, use the ENABLE_UNSAFE_SSE_TRANSPORT environment variable. @@ -203,6 +333,26 @@ Assuming your image name is flux159/mcp-server-kubernetes and you need to map po docker run --rm -it -p 3001:3001 -e ENABLE_UNSAFE_SSE_TRANSPORT=1 -e PORT=3001 -v ~/.kube/config:/home/appuser/.kube/config flux159/mcp-server-kubernetes:latest ``` +āš ļø Key safety considerations +When deploying SSE mode using Docker, due to the insecure SSE transport protocol and sensitive configuration file mounting, you should consider using a proxy to handle authentication & authorization to the MCP server. + +mcp config + +```shell +{ + "mcpServers": { + "mcp-server-kubernetes": { + "url": "http://localhost:3001/sse", + "args": [] + } + } +} +``` + +### Why is SSE Transport Unsafe? + +SSE transport exposes an http endpoint that can be accessed by anyone with the URL. This can be a security risk if the server is not properly secured. It is recommended to use a secure proxy server to proxy to the SSE endpoint. In addition, anyone with access to the URL will be able to utilize the authentication of your kubeconfig to make requests to your Kubernetes cluster. You should add logging to your proxy in order to monitor user requests to the SSE endpoint. + ## Advance Docker Usage ### Connect to AWS EKS Cluster @@ -278,23 +428,3 @@ docker run --rm -it -p 3001:3001 -e ENABLE_UNSAFE_SSE_TRANSPORT=1 -e PORT=3001 } } ``` - -āš ļø Key safety considerations -When deploying SSE mode using Docker, due to the insecure SSE transport protocol and sensitive configuration file mounting, strict security constraints must be implemented in the production environment - -mcp config - -```shell -{ - "mcpServers": { - "mcp-server-kubernetes": { - "url": "http://localhost:3001/sse", - "args": [] - } - } -} -``` - -### Why is SSE Transport Unsafe? - -SSE transport exposes an http endpoint that can be accessed by anyone with the URL. This can be a security risk if the server is not properly secured. It is recommended to use a secure proxy server to proxy to the SSE endpoint. In addition, anyone with access to the URL will be able to utilize the authentication of your kubeconfig to make requests to your Kubernetes cluster. You should add logging to your proxy in order to monitor user requests to the SSE endpoint. diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..cb50ace --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,14 @@ +cff-version: 1.2.0 +message: "If you use kubernetes mcp server, please cite it as below." +abstract: "MCP Server for interacting with Kubernetes clusters via kubectl, enabling management and troubleshooting of Kubernetes resources." +authors: + - family-names: "Patel" + given-names: "Paras" + orcid: "https://orcid.org/0009-0000-4058-8214" + - family-names: "Sonwalkar" + given-names: "Suyog" + orcid: "https://orcid.org/0009-0002-7352-5978" +title: "MCP Server Kubernetes" +version: 2.8.0 +date-released: 2024-07-30 +url: "https://github.com/Flux159/mcp-server-kubernetes" diff --git a/README.md b/README.md index 44dbc8c..691c597 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![Issues](https://img.shields.io/github/issues/Flux159/mcp-server-kubernetes)](https://github.com/Flux159/mcp-server-kubernetes/issues) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/Flux159/mcp-server-kubernetes/pulls) [![Last Commit](https://img.shields.io/github/last-commit/Flux159/mcp-server-kubernetes)](https://github.com/Flux159/mcp-server-kubernetes/commits/main) -[![smithery badge](https://smithery.ai/badge/mcp-server-kubernetes)](https://smithery.ai/protocol/mcp-server-kubernetes) +[![Trust Score](https://archestra.ai/mcp-catalog/api/badge/quality/Flux159/mcp-server-kubernetes)](https://archestra.ai/mcp-catalog/flux159__mcp-server-kubernetes) MCP Server that can connect to a Kubernetes cluster and manage it. Supports loading kubeconfig from multiple sources in priority order. @@ -93,6 +93,7 @@ npx mcp-chat --config "%APPDATA%\Claude\claude_desktop_config.json" - [x] Troubleshooting Prompt (`k8s-diagnose`) - Guides through a systematic Kubernetes troubleshooting flow for pods based on a keyword and optional namespace. - [x] Non-destructive mode for read and create/update-only access to clusters +- [x] Secrets masking for security (masks sensitive data in `kubectl get secrets` commands, does not affect logs) ## Prompts @@ -278,3 +279,22 @@ This will create a new tag which will trigger a new release build via the cd.yml ## Not planned Adding clusters to kubectx. + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=Flux159/mcp-server-kubernetes&type=Date)](https://www.star-history.com/#Flux159/mcp-server-kubernetes&Date) + +## šŸ–Šļø Cite + +If you find this repo useful, please cite: + +``` +@software{Patel_MCP_Server_Kubernetes_2024, +author = {Patel, Paras and Sonwalkar, Suyog}, +month = jul, +title = {{MCP Server Kubernetes}}, +url = {https://github.com/Flux159/mcp-server-kubernetes}, +version = {2.5.0}, +year = {2024} +} +``` diff --git a/bun.lockb b/bun.lockb index e0efe07..c32e858 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/manifest.json b/manifest.json index d116e43..cca4fb9 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "dxt_version": "0.1", "name": "mcp-server-kubernetes", - "version": "2.4.7", + "version": "2.8.0", "description": "MCP server for interacting with Kubernetes clusters via kubectl", "long_description": "MCP Server that can connect to a Kubernetes cluster and manage it.\n\nBy default, the server loads kubeconfig from `~/.kube/config`.\n\nThe server will automatically connect to your current kubectl context. Make sure you have:\n\n1. kubectl installed and in your PATH\n2. A valid kubeconfig file with contexts configured\n3. Access to a Kubernetes cluster configured for kubectl (e.g. minikube, Rancher Desktop, GKE, etc.)\n4. Optional: Helm v3 installed and in your PATH.\n\nYou can verify your connection by asking Claude to list your pods or create a test deployment.\n\nIf you have errors open up a standard terminal and run `kubectl get pods` to see if you can connect to your cluster without credentials issues.\n\n## Features\n\n- [x] Connect to a Kubernetes cluster\n- [x] Unified kubectl API for managing resources\n- Get or list resources with `kubectl_get`\n- Describe resources with `kubectl_describe`\n- List resources with `kubectl_get`\n- Create resources with `kubectl_create`\n- Apply YAML manifests with `kubectl_apply`\n- Delete resources with `kubectl_delete`\n- Get logs with `kubectl_logs`\n- and more.", "author": { @@ -15,19 +15,95 @@ "command": "node", "args": [ "${__dirname}/dist/index.js" - ], - "env": { - "KUBECONFIG": "${user_config.kubeconfig_path}" - } + ] } }, "tools": [ + { + "name": "ping", + "description": "Verify that the counterpart is still responsive and the connection is alive." + }, + { + "name": "cleanup", + "description": "Cleanup all managed resources" + }, { "name": "kubectl_get", - "description": "Use kubectl get to retrieve information from cluster" + "description": "Get or list Kubernetes resources by resource type, name, and optionally namespace" + }, + { + "name": "kubectl_describe", + "description": "Describe Kubernetes resources by resource type, name, and optionally namespace" + }, + { + "name": "kubectl_apply", + "description": "Apply a Kubernetes YAML manifest from a string or file" + }, + { + "name": "kubectl_delete", + "description": "Delete Kubernetes resources by resource type, name, labels, or from a manifest file" + }, + { + "name": "kubectl_create", + "description": "Create Kubernetes resources using various methods (from file or using subcommands)" + }, + { + "name": "kubectl_logs", + "description": "Get logs from Kubernetes resources like pods, deployments, or jobs" + }, + { + "name": "kubectl_patch", + "description": "Update field(s) of a resource using strategic merge patch, JSON merge patch, or JSON patch" + }, + { + "name": "kubectl_rollout", + "description": "Manage the rollout of a resource (e.g., deployment, daemonset, statefulset)" + }, + { + "name": "kubectl_scale", + "description": "Scale a Kubernetes deployment" + }, + { + "name": "kubectl_context", + "description": "Manage Kubernetes contexts - list, get, or set the current context" + }, + { + "name": "kubectl_generic", + "description": "Execute any kubectl command with the provided arguments and flags" + }, + { + "name": "install_helm_chart", + "description": "Install a Helm chart" + }, + { + "name": "upgrade_helm_chart", + "description": "Upgrade a Helm release" + }, + { + "name": "uninstall_helm_chart", + "description": "Uninstall a Helm release" + }, + { + "name": "explain_resource", + "description": "Get documentation for a Kubernetes resource or field" + }, + { + "name": "list_api_resources", + "description": "List the API resources available in the cluster" + }, + { + "name": "exec_in_pod", + "description": "Execute a command in a Kubernetes pod or container and return the output" + }, + { + "name": "port_forward", + "description": "Forward a local port to a port on a Kubernetes resource" + }, + { + "name": "stop_port_forward", + "description": "Stop a port-forward process" } ], - "tools_generated": true, "keywords": [ "kubernetes", "docker", diff --git a/package.json b/package.json index ef34d51..9d9e103 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-server-kubernetes", - "version": "2.4.7", + "version": "2.8.0", "description": "MCP server for interacting with Kubernetes clusters via kubectl", "license": "MIT", "type": "module", @@ -23,7 +23,8 @@ "test": "vitest run", "prepublishOnly": "npm run build", "dockerbuild": "docker buildx build -t flux159/mcp-server-kubernetes --platform linux/amd64,linux/arm64 --push .", - "chat": "npx mcp-chat --server \"./dist/index.js\"" + "chat": "npx mcp-chat --server \"./dist/index.js\"", + "version:update": "node scripts/update-version.js" }, "keywords": [ "mcp", @@ -37,7 +38,7 @@ }, "dependencies": { "@kubernetes/client-node": "1.3.0", - "@modelcontextprotocol/sdk": "1.7.0", + "@modelcontextprotocol/sdk": "1.17.0", "express": "4.21.2", "js-yaml": "4.1.0", "yaml": "2.7.0", diff --git a/scripts/update-version.js b/scripts/update-version.js new file mode 100755 index 0000000..cee3dd0 --- /dev/null +++ b/scripts/update-version.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node + +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { join } from 'path'; + +const version = process.argv[2]; +if (!version) { + console.error('Usage: node scripts/update-version.js '); + console.error('Example: node scripts/update-version.js 2.6.0'); + process.exit(1); +} + +// Validate version format (basic semver check) +if (!/^\d+\.\d+\.\d+(-[\w\.-]+)?$/.test(version)) { + console.error(`Invalid version format: ${version}`); + console.error('Expected format: major.minor.patch (e.g., 2.6.0 or 2.6.0-beta.1)'); + process.exit(1); +} + +const files = [ + { + path: 'package.json', + update: (content) => { + const pkg = JSON.parse(content); + pkg.version = version; + return JSON.stringify(pkg, null, 2) + '\n'; + } + }, + { + path: 'manifest.json', + update: (content) => { + const manifest = JSON.parse(content); + manifest.version = version; + return JSON.stringify(manifest, null, 2) + '\n'; + } + }, + { + path: 'CITATION.cff', + update: (content) => content.replace(/^version: .*/m, `version: ${version}`) + }, + { + path: 'README.md', + update: (content) => content.replace(/version = \{\{\{VERSION\}\}\}/g, `version = {${version}}`) + } +]; + +let hasErrors = false; + +files.forEach(({ path, update }) => { + try { + if (!existsSync(path)) { + console.error(`āœ— File not found: ${path}`); + hasErrors = true; + return; + } + + const content = readFileSync(path, 'utf8'); + const updatedContent = update(content); + writeFileSync(path, updatedContent); + console.log(`āœ“ Updated ${path}`); + } catch (error) { + console.error(`āœ— Failed to update ${path}:`, error.message); + hasErrors = true; + } +}); + +if (hasErrors) { + console.error(`\nāŒ Some files failed to update. Please check the errors above.`); + process.exit(1); +} else { + console.log(`\nšŸŽ‰ All files updated to version ${version}`); +} \ No newline at end of file diff --git a/src/config/max-buffer.ts b/src/config/max-buffer.ts new file mode 100644 index 0000000..7cef93d --- /dev/null +++ b/src/config/max-buffer.ts @@ -0,0 +1,3 @@ +export function getSpawnMaxBuffer(): number { + return parseInt(process.env.SPAWN_MAX_BUFFER || "1048577", 10); +} diff --git a/src/index.ts b/src/index.ts index 946d1c2..27b8e99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -60,11 +60,25 @@ import { } from "./tools/kubectl-rollout.js"; import { registerPromptHandlers } from "./prompts/index.js"; import { ping, pingSchema } from "./tools/ping.js"; +import { startStreamableHTTPServer } from "./utils/streamable-http.js"; -// Check if non-destructive tools only mode is enabled +// Check environment variables for tool filtering +const allowOnlyReadonlyTools = process.env.ALLOW_ONLY_READONLY_TOOLS === "true"; +const allowedToolsEnv = process.env.ALLOWED_TOOLS; const nonDestructiveTools = process.env.ALLOW_ONLY_NON_DESTRUCTIVE_TOOLS === "true"; +// Define readonly tools +const readonlyTools = [ + kubectlGetSchema, + kubectlDescribeSchema, + kubectlLogsSchema, + kubectlContextSchema, + explainResourceSchema, + listApiResourcesSchema, + pingSchema, +]; + // Define destructive tools (delete and uninstall operations) const destructiveTools = [ kubectlDeleteSchema, // This replaces all individual delete operations @@ -105,7 +119,6 @@ const allTools = [ StopPortForwardSchema, execInPodSchema, - // API resource operations listApiResourcesSchema, // Generic kubectl command @@ -147,12 +160,21 @@ registerPromptHandlers(server, k8sManager); // Tools handlers server.setRequestHandler(ListToolsRequestSchema, async () => { - // Filter out destructive tools if ALLOW_ONLY_NON_DESTRUCTIVE_TOOLS is set to 'true' - const tools = nonDestructiveTools - ? allTools.filter( - (tool) => !destructiveTools.some((dt) => dt.name === tool.name) - ) - : allTools; + let tools; + + if (allowedToolsEnv) { + const allowedToolNames = allowedToolsEnv.split(",").map((t) => t.trim()); + tools = allTools.filter((tool) => allowedToolNames.includes(tool.name)); + } else if (allowOnlyReadonlyTools) { + tools = readonlyTools; + } else if (nonDestructiveTools) { + tools = allTools.filter( + (tool) => !destructiveTools.some((dt) => dt.name === tool.name) + ); + } else { + tools = allTools; + } + return { tools }; }); @@ -175,6 +197,7 @@ server.setRequestHandler( showCurrent?: boolean; detailed?: boolean; output?: string; + context?: string; } ); } @@ -191,6 +214,7 @@ server.setRequestHandler( labelSelector?: string; fieldSelector?: string; sortBy?: string; + context?: string; } ); } @@ -203,6 +227,7 @@ server.setRequestHandler( name: string; namespace?: string; allNamespaces?: boolean; + context?: string; } ); } @@ -216,6 +241,7 @@ server.setRequestHandler( namespace?: string; dryRun?: boolean; force?: boolean; + context?: string; } ); } @@ -233,6 +259,7 @@ server.setRequestHandler( allNamespaces?: boolean; force?: boolean; gracePeriodSeconds?: number; + context?: string; } ); } @@ -246,6 +273,7 @@ server.setRequestHandler( namespace?: string; dryRun?: boolean; validate?: boolean; + context?: string; } ); } @@ -265,6 +293,7 @@ server.setRequestHandler( previous?: boolean; follow?: boolean; labelSelector?: string; + context?: string; } ); } @@ -280,6 +309,7 @@ server.setRequestHandler( patchData?: object; patchFile?: string; dryRun?: boolean; + context?: string; } ); } @@ -302,6 +332,7 @@ server.setRequestHandler( toRevision?: number; timeout?: string; watch?: boolean; + context?: string; } ); } @@ -318,6 +349,7 @@ server.setRequestHandler( outputFormat?: string; flags?: Record; args?: string[]; + context?: string; } ); } @@ -330,6 +362,7 @@ server.setRequestHandler( labelSelector: (input as { labelSelector?: string }).labelSelector, sortBy: (input as { sortBy?: string }).sortBy, output: (input as { output?: string }).output, + context: (input as { context?: string }).context, }); } @@ -356,6 +389,7 @@ server.setRequestHandler( case "explain_resource": { return await explainResource( input as { + context?: string; resource: string; apiVersion?: string; recursive?: boolean; @@ -372,6 +406,7 @@ server.setRequestHandler( repo: string; namespace: string; values?: Record; + context?: string; } ); } @@ -381,6 +416,7 @@ server.setRequestHandler( input as { name: string; namespace: string; + context?: string; } ); } @@ -393,6 +429,7 @@ server.setRequestHandler( repo: string; namespace: string; values?: Record; + context?: string; } ); } @@ -404,6 +441,7 @@ server.setRequestHandler( namespaced?: boolean; verbs?: string[]; output?: "wide" | "name" | "no-headers"; + context?: string; } ); } @@ -416,6 +454,7 @@ server.setRequestHandler( resourceName: string; localPort: number; targetPort: number; + context?: string; } ); } @@ -437,6 +476,7 @@ server.setRequestHandler( namespace?: string; replicas: number; resourceType?: string; + context?: string; } ); } @@ -453,6 +493,7 @@ server.setRequestHandler( namespace?: string; command: string | string[]; container?: string; + context?: string; } ); } @@ -474,6 +515,9 @@ server.setRequestHandler( if (process.env.ENABLE_UNSAFE_SSE_TRANSPORT) { startSSEServer(server); console.log(`SSE server started`); +} else if (process.env.ENABLE_UNSAFE_STREAMABLE_HTTP_TRANSPORT) { + startStreamableHTTPServer(server); + console.log(`Streamable HTTP server started`); } else { const transport = new StdioServerTransport(); diff --git a/src/models/common-parameters.ts b/src/models/common-parameters.ts new file mode 100644 index 0000000..fddb048 --- /dev/null +++ b/src/models/common-parameters.ts @@ -0,0 +1,17 @@ +export const contextParameter = { + type: "string" as const, + description: "Kubeconfig Context to use for the command (optional - defaults to null)", + default: "", +}; + +export const namespaceParameter = { + type: "string" as const, + description: "Kubernetes namespace", + default: "default", +}; + +export const dryRunParameter = { + type: "boolean" as const, + description: "If true, only validate the resource, don't actually execute the operation", + default: false, +}; \ No newline at end of file diff --git a/src/models/kubectl-models.ts b/src/models/kubectl-models.ts index 77dc99b..321684c 100644 --- a/src/models/kubectl-models.ts +++ b/src/models/kubectl-models.ts @@ -14,6 +14,7 @@ export interface ExplainResourceParams { apiVersion?: string; recursive?: boolean; output?: "plaintext" | "plaintext-openapiv2"; + context?: string; } export interface ListApiResourcesParams { @@ -21,4 +22,5 @@ export interface ListApiResourcesParams { namespaced?: boolean; verbs?: string[]; output?: "wide" | "name" | "no-headers"; + context?: string; } diff --git a/src/tools/exec_in_pod.ts b/src/tools/exec_in_pod.ts index 6a9a754..344a410 100644 --- a/src/tools/exec_in_pod.ts +++ b/src/tools/exec_in_pod.ts @@ -9,6 +9,7 @@ import * as k8s from "@kubernetes/client-node"; import { KubernetesManager } from "../types.js"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { Writable } from "stream"; +import { contextParameter, namespaceParameter } from "../models/common-parameters.js"; /** * Schema for exec_in_pod tool. @@ -27,11 +28,7 @@ export const execInPodSchema = { type: "string", description: "Name of the pod to execute the command in", }, - namespace: { - type: "string", - description: "Kubernetes namespace where the pod is located", - default: "default", - }, + namespace: namespaceParameter, command: { anyOf: [ { type: "string" }, @@ -42,18 +39,16 @@ export const execInPodSchema = { container: { type: "string", description: "Container name (required when pod has multiple containers)", - optional: true, }, shell: { type: "string", description: "Shell to use for command execution (e.g. '/bin/sh', '/bin/bash'). If not provided, will use command as-is.", - optional: true, }, timeout: { type: "number", description: "Timeout for command - 60000 milliseconds if not specified", - optional: true, }, + context: contextParameter, }, required: ["name", "command"], }, @@ -73,6 +68,7 @@ export async function execInPod( container?: string; shell?: string; timeout?: number; + context?: string; } ): Promise<{ content: { type: string; text: string }[] }> { const namespace = input.namespace || "default"; @@ -112,6 +108,11 @@ export async function execInPod( }); try { + // Set context if provided + if (input.context) { + k8sManager.setCurrentContext(input.context); + } + // Use the Kubernetes client-node Exec API for native exec const kc = k8sManager.getKubeConfig(); const exec = new k8s.Exec(kc); diff --git a/src/tools/helm-operations.ts b/src/tools/helm-operations.ts index 044c7f0..d6add2b 100644 --- a/src/tools/helm-operations.ts +++ b/src/tools/helm-operations.ts @@ -1,7 +1,14 @@ -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"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; +import { contextParameter, namespaceParameter } from "../models/common-parameters.js"; export const installHelmChartSchema = { name: "install_helm_chart", @@ -21,10 +28,8 @@ export const installHelmChartSchema = { type: "string", description: "Chart repository URL", }, - namespace: { - type: "string", - description: "Kubernetes namespace", - }, + namespace: namespaceParameter, + context: contextParameter, values: { type: "object", description: "Chart values", @@ -54,10 +59,8 @@ export const upgradeHelmChartSchema = { type: "string", description: "Chart repository URL", }, - namespace: { - type: "string", - description: "Kubernetes namespace", - }, + namespace: namespaceParameter, + context: contextParameter, values: { type: "object", description: "Chart values", @@ -79,22 +82,21 @@ export const uninstallHelmChartSchema = { type: "string", description: "Release name", }, - namespace: { - type: "string", - description: "Kubernetes namespace", - }, + namespace: namespaceParameter, + context: contextParameter, }, required: ["name", "namespace"], }, }; -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 } + maxBuffer: getSpawnMaxBuffer(), + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); } catch (error: any) { throw new Error(`Helm command failed: ${error.message}`); @@ -107,30 +109,40 @@ const writeValuesFile = (name: string, values: Record): 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 +163,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 +216,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", diff --git a/src/tools/kubectl-apply.ts b/src/tools/kubectl-apply.ts index b5bf9f1..796a5d5 100644 --- a/src/tools/kubectl-apply.ts +++ b/src/tools/kubectl-apply.ts @@ -1,9 +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"; import * as fs from "fs"; import * as path from "path"; import * as os from "os"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; +import { contextParameter, namespaceParameter, dryRunParameter } from "../models/common-parameters.js"; export const kubectlApplySchema = { name: "kubectl_apply", @@ -11,29 +13,24 @@ 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)" - }, - 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 + filename: { + type: "string", + description: + "Path to a YAML file to apply (optional - use either manifest or filename)", }, + namespace: namespaceParameter, + dryRun: dryRunParameter, 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, + }, + context: contextParameter, }, required: [], }, @@ -47,6 +44,7 @@ export async function kubectlApply( namespace?: string; dryRun?: boolean; force?: boolean; + context?: string; } ) { try { @@ -60,38 +58,49 @@ export async function kubectlApply( const namespace = input.namespace || "default"; const dryRun = input.dryRun || false; const force = input.force || false; - - let command = "kubectl apply"; + const context = input.context || ""; + + 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"); + } + + // Add context if provided + if (context) { + args.push("--context", context); } - + // Execute the command try { - const result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + const result = execFileSync(command, args, { + encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + // Clean up temp file if created if (tempFile) { try { @@ -100,7 +109,7 @@ export async function kubectlApply( console.warn(`Failed to delete temporary file ${tempFile}: ${err}`); } } - + return { content: [ { @@ -118,7 +127,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 +137,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 +} diff --git a/src/tools/kubectl-context.ts b/src/tools/kubectl-context.ts index 98a9e24..5dfd2a9 100644 --- a/src/tools/kubectl-context.ts +++ b/src/tools/kubectl-context.ts @@ -1,39 +1,44 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; +import { getSpawnMaxBuffer } from "../config/max-buffer.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 +58,25 @@ 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", + maxBuffer: getSpawnMaxBuffer(), + 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 +85,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,23 +109,39 @@ 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", + maxBuffer: getSpawnMaxBuffer(), + 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", + maxBuffer: getSpawnMaxBuffer(), + 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", + maxBuffer: getSpawnMaxBuffer(), + 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+/); @@ -124,31 +149,31 @@ export async function kubectlContext( 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 +187,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 +207,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 +230,20 @@ 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", + maxBuffer: getSpawnMaxBuffer(), + 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 +252,42 @@ 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", + maxBuffer: getSpawnMaxBuffer(), + 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 +296,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 +306,14 @@ export async function kubectlContext( } throw error; } - + default: throw new McpError( ErrorCode.InvalidParams, `Invalid operation: ${operation}` ); } - + return { content: [ { @@ -275,10 +326,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 +} diff --git a/src/tools/kubectl-create.ts b/src/tools/kubectl-create.ts index 3e14b54..fd350f2 100644 --- a/src/tools/kubectl-create.ts +++ b/src/tools/kubectl-create.ts @@ -1,139 +1,155 @@ 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"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; +import { contextParameter, namespaceParameter, dryRunParameter } from "../models/common-parameters.js"; 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 - }, + dryRun: dryRunParameter, 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" - }, - namespace: { - type: "string", - description: "Namespace to create the resource in", - default: "default" + description: "Name of the resource to create", }, - + namespace: namespaceParameter, + // 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"])', + }, + context: contextParameter, }, required: [], }, @@ -146,40 +162,41 @@ 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[]; schedule?: string; suspend?: boolean; + context?: string; } ) { try { @@ -190,24 +207,30 @@ 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 context = input.context || ""; + + 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) { @@ -215,35 +238,35 @@ export async function kubectlCreate( 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( @@ -251,40 +274,40 @@ export async function kubectlCreate( "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( @@ -292,27 +315,32 @@ export async function kubectlCreate( "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( @@ -320,20 +348,20 @@ export async function kubectlCreate( "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( @@ -341,15 +369,15 @@ export async function kubectlCreate( "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, @@ -357,43 +385,52 @@ export async function kubectlCreate( ); } } - + // 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); + + // Add context if provided + if (context) { + args.push("--context", context); + } + // Execute the command try { - const result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + const result = execFileSync(command, args, { + encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + // Clean up temp file if created if (tempFile) { try { @@ -402,7 +439,7 @@ export async function kubectlCreate( console.warn(`Failed to delete temporary file ${tempFile}: ${err}`); } } - + return { content: [ { @@ -420,7 +457,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 +467,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 +} diff --git a/src/tools/kubectl-delete.ts b/src/tools/kubectl-delete.ts index ab61fbb..e6bc32e 100644 --- a/src/tools/kubectl-delete.ts +++ b/src/tools/kubectl-delete.ts @@ -1,55 +1,59 @@ 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"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; +import { contextParameter, namespaceParameter } from "../models/common-parameters.js"; 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.)" - }, - name: { - type: "string", - description: "Name of the resource to delete" + resourceType: { + type: "string", + description: + "Type of resource to delete (e.g., pods, deployments, services, etc.)", }, - namespace: { - type: "string", - description: "Namespace of the resource (optional - defaults to 'default' for namespaced resources)", - default: "default" + name: { + type: "string", + description: "Name of the resource to delete", }, + namespace: namespaceParameter, 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", + }, + context: contextParameter, }, required: ["resourceType", "name", "namespace"], }, @@ -67,6 +71,7 @@ export async function kubectlDelete( allNamespaces?: boolean; force?: boolean; gracePeriodSeconds?: number; + context?: string; } ) { try { @@ -77,7 +82,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 +94,68 @@ export async function kubectlDelete( const namespace = input.namespace || "default"; const allNamespaces = input.allNamespaces || false; const force = input.force || false; - - let command = "kubectl delete"; + const context = input.context || ""; + + 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}`); + } + + // Add context if provided + if (context) { + args.push("--context", context); } - + // Execute the command try { - const result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + const result = execFileSync(command, args, { + encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + // Clean up temp file if created if (tempFile) { try { @@ -144,7 +164,7 @@ export async function kubectlDelete( console.warn(`Failed to delete temporary file ${tempFile}: ${err}`); } } - + return { content: [ { @@ -162,7 +182,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 +201,7 @@ export async function kubectlDelete( isError: true, }; } - + throw new McpError( ErrorCode.InternalError, `Failed to delete resource: ${error.message}` @@ -191,7 +211,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 +222,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()); -} +} diff --git a/src/tools/kubectl-describe.ts b/src/tools/kubectl-describe.ts index ea1c9a3..bd05303 100644 --- a/src/tools/kubectl-describe.ts +++ b/src/tools/kubectl-describe.ts @@ -1,31 +1,32 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; +import { namespaceParameter, contextParameter } from "../models/common-parameters.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" - }, - namespace: { - type: "string", - description: "Namespace of the resource (optional - defaults to 'default' for namespaced resources)", - default: "default" + name: { + type: "string", + description: "Name of the resource to describe", }, + namespace: namespaceParameter, + context: contextParameter, allNamespaces: { type: "boolean", description: "If true, describe resources across all namespaces", - default: false - } + default: false, + }, }, required: ["resourceType", "name"], }, @@ -38,6 +39,7 @@ export async function kubectlDescribe( name: string; namespace?: string; allNamespaces?: boolean; + context?: string; } ) { try { @@ -45,27 +47,31 @@ export async function kubectlDescribe( const name = input.name; const namespace = input.namespace || "default"; const allNamespaces = input.allNamespaces || false; - + const context = input.context || ""; + // 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); + } + + if (context) { + args.push("--context", context); } - + // Execute the command try { - const result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + const result = execFileSync(command, args, { + encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + return { content: [ { @@ -93,7 +99,7 @@ export async function kubectlDescribe( isError: true, }; } - + throw new McpError( ErrorCode.InternalError, `Failed to describe resource: ${error.message}` @@ -110,14 +116,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 +} diff --git a/src/tools/kubectl-generic.ts b/src/tools/kubectl-generic.ts index 9efb09c..8d2af6f 100644 --- a/src/tools/kubectl-generic.ts +++ b/src/tools/kubectl-generic.ts @@ -1,52 +1,53 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; +import { contextParameter, namespaceParameter } from "../models/common-parameters.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" - }, - namespace: { - type: "string", - description: "Namespace", - default: "default" + description: "Resource name", }, + namespace: namespaceParameter, 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", + }, + context: contextParameter, }, - required: ["command"] - } + required: ["command"], + }, }; export async function kubectlGeneric( @@ -60,37 +61,39 @@ export async function kubectlGeneric( outputFormat?: string; flags?: Record; args?: string[]; + context?: string; } ) { 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,26 @@ 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(' '); + + // Add context if provided + if (input.context) { + cmdArgs.push("--context", input.context); + } + + // 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", + maxBuffer: getSpawnMaxBuffer(), + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + return { content: [ { @@ -133,10 +144,10 @@ export async function kubectlGeneric( if (error instanceof McpError) { throw error; } - + throw new McpError( ErrorCode.InternalError, `Failed to execute kubectl command: ${error.message}` ); } -} +} diff --git a/src/tools/kubectl-get.ts b/src/tools/kubectl-get.ts index aa023a7..13c7002 100644 --- a/src/tools/kubectl-get.ts +++ b/src/tools/kubectl-get.ts @@ -1,6 +1,9 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; +import * as yaml from "js-yaml"; +import { contextParameter, namespaceParameter } from "../models/common-parameters.js"; export const kubectlGetSchema = { name: "kubectl_get", @@ -19,12 +22,7 @@ export const kubectlGetSchema = { description: "Name of the resource (optional - if not provided, lists all resources of the specified type)", }, - namespace: { - type: "string", - description: - "Namespace of the resource (optional - defaults to 'default' for namespaced resources)", - default: "default", - }, + namespace: namespaceParameter, output: { type: "string", enum: ["json", "yaml", "wide", "name", "custom"], @@ -38,18 +36,19 @@ 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.", }, + context: contextParameter }, required: ["resourceType", "name", "namespace"], }, @@ -66,6 +65,7 @@ export async function kubectlGet( labelSelector?: string; fieldSelector?: string; sortBy?: string; + context?: string; } ) { try { @@ -77,16 +77,15 @@ export async function kubectlGet( const labelSelector = input.labelSelector || ""; const fieldSelector = input.fieldSelector || ""; const sortBy = input.sortBy; + const context = input.context || ""; // 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,58 +98,78 @@ 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); + } + + if (context) { + args.push("--context", context); } // 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", + maxBuffer: getSpawnMaxBuffer(), env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, }); + // Apply secrets masking if enabled and dealing with secrets + const shouldMaskSecrets = process.env.MASK_SECRETS !== "false" && + (resourceType === "secrets" || resourceType === "secret"); + + let processedResult = result; + if (shouldMaskSecrets) { + processedResult = maskSecretsData(result, output); + } + // Format the results for better readability const isListOperation = !name; if (isListOperation && output === "json") { try { // Parse JSON and extract key information - const parsed = JSON.parse(result); + const parsed = JSON.parse(processedResult); if (parsed.kind && parsed.kind.endsWith("List") && parsed.items) { if (resourceType === "events") { @@ -205,7 +224,7 @@ export async function kubectlGet( content: [ { type: "text", - text: result, + text: processedResult, }, ], }; @@ -310,3 +329,95 @@ function isNonNamespacedResource(resourceType: string): boolean { return nonNamespacedResources.includes(resourceType.toLowerCase()); } + +/** + * Recursively traverses an object and masks values in 'data' sections of Kubernetes secrets. + * + * @param {any} obj - The object to traverse. Can be an array, object, or primitive value. + * @returns {any} A new object with masked values in 'data' sections. + */ +function maskDataValues(obj: any): any { + + if (obj == null) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(item => maskDataValues(item)); + } + + if (typeof obj === "object") { + const result: any = {}; + for (const key in obj) { + if (key === "data" && typeof obj[key] === "object" && obj[key] !== null) { + // This is a data section - mask all leaf values within it + result[key] = maskAllLeafValues(obj[key]); + } else { + result[key] = maskDataValues(obj[key]); + } + } + return result; + } + + return obj; +} + +/** + * Recursively masks all leaf values (non-object, non-array values) in an object structure. + * + * @param {any} obj - The input object or value to process. + * @returns {any} A new object or value with all leaf values replaced by a mask. + */ +function maskAllLeafValues(obj: any): any { + const maskValue = "***"; + + if (obj == null) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(item => maskAllLeafValues(item)); + } + + if (typeof obj === "object") { + const result: any = {}; + for (const key in obj) { + result[key] = maskAllLeafValues(obj[key]); + } + return result; + } + + // This is a leaf value (string, number, boolean) - mask it + return maskValue; +} + +/** + * Masks sensitive data in Kubernetes secrets by parsing the raw output and replacing + * all leaf values in the "data" section with a placeholder value ("***"). + * + * @param {string} output - The raw output from a `kubectl` command, containing secrets data. + * @param {string} format - The format of the output, either "json" or "yaml". + * @returns {string} - The masked output in the same format as the input. + */ +function maskSecretsData(output: string, format: string): string { + try { + if (format === "json") { + const parsed = JSON.parse(output); + const masked = maskDataValues(parsed); + return JSON.stringify(masked, null, 2); + } else if (format === "yaml") { + // Parse YAML to JSON, mask, then convert back to YAML + const parsed = yaml.load(output); + const masked = maskDataValues(parsed); + return yaml.dump(masked, { + indent: 2, + lineWidth: -1, // Don't wrap lines + noRefs: true // Don't use references + }); + } + } catch (error) { + console.warn("Failed to parse secrets output for masking:", error); + } + + return output; +} diff --git a/src/tools/kubectl-logs.ts b/src/tools/kubectl-logs.ts index 385c9b6..8abd307 100644 --- a/src/tools/kubectl-logs.ts +++ b/src/tools/kubectl-logs.ts @@ -1,10 +1,13 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; +import { contextParameter, namespaceParameter } from "../models/common-parameters.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: { @@ -17,26 +20,23 @@ export const kubectlLogsSchema = { type: "string", description: "Name of the resource", }, - namespace: { - type: "string", - description: "Namespace of the resource", - default: "default", - }, + namespace: namespaceParameter, 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 +55,9 @@ export const kubectlLogsSchema = { }, labelSelector: { type: "string", - description: "Filter resources by label selector" - } + description: "Filter resources by label selector", + }, + context: contextParameter, }, required: ["resourceType", "name", "namespace"], }, @@ -76,52 +77,87 @@ export async function kubectlLogs( previous?: boolean; follow?: boolean; labelSelector?: string; + context?: string; } ) { try { 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 context = input.context || ""; + + 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); + + // Add context if provided + if (context) { + args.push("--context", context); + } + // Execute the command try { - const result = execSync(baseCommand, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); + const result = execFileSync(command, args, { + encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), + 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", + maxBuffer: getSpawnMaxBuffer(), + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }) + .trim() + .split(" "); + + if (jobs.length === 0 || (jobs.length === 1 && jobs[0] === "")) { return { content: [ { @@ -137,17 +173,21 @@ export async function kubectlLogs( ], }; } - + // Get logs for all jobs const allJobLogs: Record = {}; - + 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 +208,28 @@ 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", + maxBuffer: getSpawnMaxBuffer(), + 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 +268,37 @@ 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`); + } + + if (input.context) { + args.push("--context", input.context); } - - return command; + + return args; } // Helper function to get logs for resources selected by labels @@ -262,11 +308,26 @@ 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", + maxBuffer: getSpawnMaxBuffer(), + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }) + .trim() + .split(" "); + + if (pods.length === 0 || (pods.length === 1 && pods[0] === "")) { return { content: [ { @@ -282,32 +343,36 @@ async function getLabelSelectorLogs( ], }; } - + // Get logs for each pod const logsMap: Record = {}; - + 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", + maxBuffer: getSpawnMaxBuffer(), + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); logsMap[pod] = logs; } catch (error: any) { logsMap[pod] = `Error: ${error.message}`; } } - + return { content: [ { @@ -351,7 +416,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,18 +435,24 @@ 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", @@ -389,9 +460,15 @@ function handleCommandError(error: any, resourceDescription: string) { 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 +479,7 @@ function handleCommandError(error: any, resourceDescription: string) { isError: true, }; } - + return { content: [ { @@ -411,7 +488,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 +497,4 @@ function handleCommandError(error: any, resourceDescription: string) { ], isError: true, }; -} +} diff --git a/src/tools/kubectl-operations.ts b/src/tools/kubectl-operations.ts index 1b93714..7f4fcfd 100644 --- a/src/tools/kubectl-operations.ts +++ b/src/tools/kubectl-operations.ts @@ -1,8 +1,10 @@ -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { ExplainResourceParams, ListApiResourcesParams, } from "../models/kubectl-models.js"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; +import { contextParameter } from "../models/common-parameters.js"; export const explainResourceSchema = { name: "explain_resource", @@ -24,6 +26,7 @@ export const explainResourceSchema = { description: "Print the fields of fields recursively", default: false, }, + context: contextParameter, output: { type: "string", description: "Output format (plaintext or plaintext-openapiv2)", @@ -49,6 +52,12 @@ export const listApiResourcesSchema = { type: "boolean", description: "If true, only show namespaced resources", }, + context: { + type: "string", + description: + "Kubeconfig Context to use for the command (optional - defaults to null)", + default: "", + }, verbs: { type: "array", items: { @@ -66,35 +75,44 @@ 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", + maxBuffer: getSpawnMaxBuffer(), + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); } catch (error: any) { throw new Error(`Kubectl command failed: ${error.message}`); } }; export async function explainResource( - params: ExplainResourceParams + 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.context) { + args.push("--context", params.context); } 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: [ @@ -110,28 +128,33 @@ export async function explainResource( } export async function listApiResources( - params: ListApiResourcesParams + 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); + } + + if (params.context) { + args.push("--context", params.context); } - const result = executeKubectlCommand(command); + const result = executeKubectlCommand(command, args); return { content: [ diff --git a/src/tools/kubectl-patch.ts b/src/tools/kubectl-patch.ts index 53c8409..a765605 100644 --- a/src/tools/kubectl-patch.ts +++ b/src/tools/kubectl-patch.ts @@ -1,51 +1,49 @@ 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"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; +import { contextParameter, dryRunParameter, namespaceParameter } from "../models/common-parameters.js"; 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)" - }, - name: { - type: "string", - description: "Name of the resource to patch" + resourceType: { + type: "string", + description: + "Type of resource to patch (e.g., pods, deployments, services)", }, - namespace: { - type: "string", - description: "Namespace of the resource", - default: "default" + name: { + type: "string", + description: "Name of the resource to patch", }, + namespace: namespaceParameter, 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 - } + dryRun: dryRunParameter, + context: contextParameter, }, required: ["resourceType", "name"], - } + }, }; export async function kubectlPatch( @@ -58,6 +56,7 @@ export async function kubectlPatch( patchData?: object; patchFile?: string; dryRun?: boolean; + context?: string; } ) { try { @@ -71,46 +70,56 @@ export async function kubectlPatch( const namespace = input.namespace || "default"; const patchType = input.patchType || "strategic"; const dryRun = input.dryRun || false; + const context = input.context || ""; 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"); + } + + // Add context if provided + if (context) { + args.push("--context", context); } - + // Execute the command try { - const result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + const result = execFileSync(command, args, { + encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + // Clean up temp file if created if (tempFile) { try { @@ -119,7 +128,7 @@ export async function kubectlPatch( console.warn(`Failed to delete temporary file ${tempFile}: ${err}`); } } - + return { content: [ { @@ -137,7 +146,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 +156,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 +} diff --git a/src/tools/kubectl-rollout.ts b/src/tools/kubectl-rollout.ts index f3eed7f..f593ab9 100644 --- a/src/tools/kubectl-rollout.ts +++ b/src/tools/kubectl-rollout.ts @@ -1,10 +1,13 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; +import { contextParameter, namespaceParameter } from "../models/common-parameters.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: { @@ -12,43 +15,41 @@ export const kubectlRolloutSchema = { 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" - }, - namespace: { - type: "string", - description: "Namespace of the resource", - default: "default" + description: "Name of the resource", }, + namespace: namespaceParameter, 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, + }, + context: contextParameter, }, - required: ["subCommand", "resourceType", "name", "namespace"] - } + required: ["subCommand", "resourceType", "name", "namespace"], + }, }; export async function kubectlRollout( @@ -62,55 +63,75 @@ export async function kubectlRollout( toRevision?: number; timeout?: string; watch?: boolean; + context?: string; } ) { 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 context = input.context || ""; + + 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}`); } - + + // Add context if provided + if (context) { + args.push("--context", context); + } + // 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", + maxBuffer: getSpawnMaxBuffer(), 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", + maxBuffer: getSpawnMaxBuffer(), + env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG }, + }); + return { content: [ { @@ -130,10 +151,10 @@ export async function kubectlRollout( if (error instanceof McpError) { throw error; } - + throw new McpError( ErrorCode.InternalError, `Failed to execute kubectl rollout command: ${error.message}` ); } -} +} diff --git a/src/tools/kubectl-scale.ts b/src/tools/kubectl-scale.ts index 09d9938..9833349 100644 --- a/src/tools/kubectl-scale.ts +++ b/src/tools/kubectl-scale.ts @@ -1,6 +1,8 @@ import { KubernetesManager } from "../types.js"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; +import { getSpawnMaxBuffer } from "../config/max-buffer.js"; +import { contextParameter, namespaceParameter } from "../models/common-parameters.js"; export const kubectlScaleSchema = { name: "kubectl_scale", @@ -8,27 +10,25 @@ 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: { - type: "string", - description: "Namespace of the deployment", - default: "default" - }, - replicas: { + namespace: namespaceParameter, + 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", + }, + context: contextParameter, }, - required: ["name", "replicas"] - } + required: ["name", "replicas"], + }, }; export async function kubectlScale( @@ -38,26 +38,43 @@ export async function kubectlScale( namespace?: string; replicas: number; resourceType?: string; + context?: string; } ) { 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 context = input.context || ""; + + const command = "kubectl"; + const args = [ + "scale", + resourceType, + input.name, + `--replicas=${input.replicas}`, + `--namespace=${namespace}`, + ]; + + // Add context if provided + if (context) { + args.push("--context", context); + } + // Execute the command try { - const result = execSync(command, { encoding: "utf8", env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG } }); - + const result = execFileSync(command, args, { + encoding: "utf8", + maxBuffer: getSpawnMaxBuffer(), + 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 +88,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 +} diff --git a/src/tools/ping.ts b/src/tools/ping.ts index 2d914d9..06dd99b 100644 --- a/src/tools/ping.ts +++ b/src/tools/ping.ts @@ -1,6 +1,7 @@ export const pingSchema = { name: "ping", - description: "Verify that the counterpart is still responsive and the connection is alive.", + description: + "Verify that the counterpart is still responsive and the connection is alive.", inputSchema: { type: "object", properties: {}, @@ -10,4 +11,4 @@ export const pingSchema = { export async function ping(): Promise> { return {}; -} \ No newline at end of file +} diff --git a/src/utils/streamable-http.ts b/src/utils/streamable-http.ts new file mode 100644 index 0000000..d86faba --- /dev/null +++ b/src/utils/streamable-http.ts @@ -0,0 +1,97 @@ +import express from "express"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import http from "http"; + +export function startStreamableHTTPServer(server: Server): http.Server { + const app = express(); + app.use(express.json()); + + app.post("/mcp", async (req: express.Request, res: express.Response) => { + // In stateless mode, create a new instance of transport and server for each request + // to ensure complete isolation. A single instance would cause request ID collisions + // when multiple clients connect concurrently. + + try { + // DNS rebinding protection is disabled by default for backwards compatibility. If you are running this server + // locally, make sure to set DNS_REBINDING_PROTECTION=true + const enableDnsRebindingProtection = + process.env.DNS_REBINDING_PROTECTION === "true"; + const allowedHosts = process.env.DNS_REBINDING_ALLOWED_HOST + ? [process.env.DNS_REBINDING_ALLOWED_HOST] + : ["127.0.0.1"]; + + const transport: StreamableHTTPServerTransport = + new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableDnsRebindingProtection, + allowedHosts, + }); + res.on("close", () => { + transport.close(); + server.close(); + }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error("Error handling MCP request:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { + code: -32603, + message: "Internal server error", + }, + id: null, + }); + } + } + }); + + // SSE notifications not supported in stateless mode + app.get("/mcp", async (req: express.Request, res: express.Response) => { + console.log("Received GET MCP request"); + res.writeHead(405).end( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Method not allowed.", + }, + id: null, + }) + ); + }); + + // Session termination not needed in stateless mode + app.delete("/mcp", async (req: express.Request, res: express.Response) => { + console.log("Received DELETE MCP request"); + res.writeHead(405).end( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Method not allowed.", + }, + id: null, + }) + ); + }); + + let port = 3000; + try { + port = parseInt(process.env.PORT || "3000", 10); + } catch (e) { + console.error( + "Invalid PORT environment variable, using default port 3000." + ); + } + + const host = process.env.HOST || "localhost"; + const httpServer = app.listen(port, host, () => { + console.log( + `mcp-kubernetes-server is listening on port ${port}\nUse the following url to connect to the server:\nhttp://${host}:${port}/mcp` + ); + }); + return httpServer; +} diff --git a/tests/exec_in_pod.test.ts b/tests/exec_in_pod.test.ts index 2bc1ec1..5928776 100644 --- a/tests/exec_in_pod.test.ts +++ b/tests/exec_in_pod.test.ts @@ -7,25 +7,23 @@ describe("exec_in_pod tool", () => { expect(execInPodSchema).toBeDefined(); expect(execInPodSchema.name).toBe("exec_in_pod"); expect(execInPodSchema.description).toContain("Execute a command in a Kubernetes pod"); - + // Check input schema expect(execInPodSchema.inputSchema).toBeDefined(); expect(execInPodSchema.inputSchema.properties).toBeDefined(); - + // Check required properties expect(execInPodSchema.inputSchema.required).toContain("name"); expect(execInPodSchema.inputSchema.required).toContain("command"); - + // Check for our newly added properties expect(execInPodSchema.inputSchema.properties.shell).toBeDefined(); expect(execInPodSchema.inputSchema.properties.shell.description).toContain("Shell to use"); - expect(execInPodSchema.inputSchema.properties.shell.optional).toBe(true); - + expect(execInPodSchema.inputSchema.properties.timeout).toBeDefined(); expect(execInPodSchema.inputSchema.properties.timeout.description).toContain("Timeout for command"); expect(execInPodSchema.inputSchema.properties.timeout.type).toBe("number"); - expect(execInPodSchema.inputSchema.properties.timeout.optional).toBe(true); - + // Check command can be string or array expect(execInPodSchema.inputSchema.properties.command.anyOf).toHaveLength(2); expect(execInPodSchema.inputSchema.properties.command.anyOf[0].type).toBe("string"); @@ -37,37 +35,37 @@ describe("exec_in_pod tool", () => { // Simple test to verify command string/array handling test("command parameter can be string or array", () => { // Test string command - should wrap in shell (kubectl exec pod-name -- echo hello) - let commandArr = Array.isArray("echo hello") - ? "echo hello" + let commandArr = Array.isArray("echo hello") + ? "echo hello" : ["/bin/sh", "-c", "echo hello"]; expect(commandArr).toEqual(["/bin/sh", "-c", "echo hello"]); - + // Test array command - should pass through as-is (kubectl exec pod-name -- echo hello) - commandArr = Array.isArray(["echo", "hello"]) - ? ["echo", "hello"] + commandArr = Array.isArray(["echo", "hello"]) + ? ["echo", "hello"] : ["/bin/sh", "-c", ["echo", "hello"].join(" ")]; expect(commandArr).toEqual(["echo", "hello"]); }); - + // Test complex commands test("handles complex command strings", () => { // Test command with quotes (kubectl exec pod-name -- sh -c 'echo "hello world"') let command = 'echo "hello world"'; let commandArr = ["/bin/sh", "-c", command]; expect(commandArr).toEqual(["/bin/sh", "-c", 'echo "hello world"']); - + // Test command with pipe (kubectl exec pod-name -- sh -c 'ls | grep file') command = "ls | grep file"; commandArr = ["/bin/sh", "-c", command]; expect(commandArr).toEqual(["/bin/sh", "-c", "ls | grep file"]); - + // Test command with multiple statements (kubectl exec pod-name -- sh -c 'cd /tmp && ls') command = "cd /tmp && ls"; commandArr = ["/bin/sh", "-c", command]; expect(commandArr).toEqual(["/bin/sh", "-c", "cd /tmp && ls"]); }); }); - + // Test shell parameter handling describe("shell parameter", () => { test("shell parameter changes default shell", () => { @@ -75,23 +73,23 @@ describe("exec_in_pod tool", () => { let shell: string | undefined = undefined; let commandArr = [shell || "/bin/sh", "-c", "echo hello"]; expect(commandArr).toEqual(["/bin/sh", "-c", "echo hello"]); - + // Test with bash shell (kubectl exec pod-name -- bash -c 'command') shell = "/bin/bash"; commandArr = [shell || "/bin/sh", "-c", "echo hello"]; expect(commandArr).toEqual(["/bin/bash", "-c", "echo hello"]); - + // Test with zsh shell (kubectl exec pod-name -- zsh -c 'command') shell = "/bin/zsh"; commandArr = [shell || "/bin/sh", "-c", "echo hello"]; expect(commandArr).toEqual(["/bin/zsh", "-c", "echo hello"]); }); - + test("shell parameter not used with array commands", () => { // Array commands should pass through regardless of shell const command = ["echo", "hello"]; const shell = "/bin/bash"; - + // With array commands, the shell should be ignored if (Array.isArray(command)) { expect(command).toEqual(["echo", "hello"]); @@ -101,7 +99,7 @@ describe("exec_in_pod tool", () => { } }); }); - + // Test timeout parameter describe("timeout parameter", () => { test("timeout parameter changes default timeout", () => { @@ -109,42 +107,42 @@ describe("exec_in_pod tool", () => { function getTimeoutValue(inputTimeout: number | undefined): number { return inputTimeout !== undefined ? inputTimeout : 60000; } - + // Test with default timeout (kubectl exec has no built-in timeout) - let timeout: number | undefined = undefined; + let timeout: number | undefined = undefined; let timeoutMs = getTimeoutValue(timeout); expect(timeoutMs).toBe(60000); - + // Test with custom timeout timeout = 30000; timeoutMs = getTimeoutValue(timeout); expect(timeoutMs).toBe(30000); - + // Test with zero timeout (should be honored, not use default) timeout = 0; timeoutMs = getTimeoutValue(timeout); expect(timeoutMs).toBe(0); }); - + test("timeout value represents milliseconds", () => { // Convert common timeouts to human-readable form function formatTimeout(ms: number): string { if (ms < 1000) return `${ms}ms`; - if (ms < 60000) return `${ms/1000} seconds`; - return `${ms/60000} minutes`; + if (ms < 60000) return `${ms / 1000} seconds`; + return `${ms / 60000} minutes`; } - + // Default timeout is 1 minute expect(formatTimeout(60000)).toBe("1 minutes"); - + // 30 second timeout expect(formatTimeout(30000)).toBe("30 seconds"); - + // 5 minute timeout expect(formatTimeout(300000)).toBe("5 minutes"); }); }); - + // Test container parameter describe("container parameter", () => { test("container parameter sets target container", () => { @@ -159,17 +157,17 @@ describe("exec_in_pod tool", () => { } return cmd; } - + // Test without container (kubectl exec pod-name -- command) let execCmd = buildExecCommand("test-pod", undefined, ["echo", "hello"]); expect(execCmd).toBe("kubectl exec test-pod -- echo hello"); - + // Test with container (kubectl exec -c container-name pod-name -- command) execCmd = buildExecCommand("test-pod", "main-container", ["echo", "hello"]); expect(execCmd).toBe("kubectl exec test-pod -c main-container -- echo hello"); }); }); - + // Test namespace parameter describe("namespace parameter", () => { test("namespace parameter sets target namespace", () => { @@ -184,56 +182,56 @@ describe("exec_in_pod tool", () => { } return cmd + " -- command"; } - + // Test with default namespace (kubectl exec pod-name -- command) let execCmd = buildExecCommand("test-pod"); expect(execCmd).toBe("kubectl exec test-pod -- command"); - + // Test with custom namespace (kubectl exec -n custom-ns pod-name -- command) execCmd = buildExecCommand("test-pod", "custom-ns"); expect(execCmd).toBe("kubectl exec test-pod -n custom-ns -- command"); - + // Test with namespace and container execCmd = buildExecCommand("test-pod", "custom-ns", "main-container"); expect(execCmd).toBe("kubectl exec test-pod -n custom-ns -c main-container -- command"); }); }); - + // Test error handling describe("error handling", () => { test("handles stderr output", () => { // Simulate stderr output in execInPod function processExecOutput(stdout: string, stderr: string): { success: boolean, message?: string, output?: string } { if (stderr) { - return { - success: false, - message: `Failed to execute command in pod: ${stderr}` + return { + success: false, + message: `Failed to execute command in pod: ${stderr}` }; } - + if (!stdout && !stderr) { - return { - success: false, - message: "Failed to execute command in pod: No output" + return { + success: false, + message: "Failed to execute command in pod: No output" }; } - - return { - success: true, - output: stdout + + return { + success: true, + output: stdout }; } - + // Test successful execution let result = processExecOutput("command output", ""); expect(result.success).toBe(true); expect(result.output).toBe("command output"); - + // Test stderr error result = processExecOutput("", "command not found"); expect(result.success).toBe(false); expect(result.message).toContain("command not found"); - + // Test no output result = processExecOutput("", ""); expect(result.success).toBe(false); diff --git a/tests/kubectl-get-secrets.test.ts b/tests/kubectl-get-secrets.test.ts new file mode 100644 index 0000000..330320e --- /dev/null +++ b/tests/kubectl-get-secrets.test.ts @@ -0,0 +1,533 @@ +import { expect, test, describe, beforeEach, afterEach, vi } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { KubectlResponseSchema } from "../src/models/kubectl-models.js"; +import { asResponseSchema } from "./context-helper"; + +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Helper function to retry operations that might be flaky +async function retry( + operation: () => Promise, + maxRetries: number = 3, + delayMs: number = 2000 +): Promise { + let lastError: Error | unknown; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = error; + console.warn( + `Attempt ${attempt}/${maxRetries} failed. Retrying in ${delayMs}ms...` + ); + await sleep(delayMs); + } + } + + throw lastError; +} + +describe("kubectl get secrets masking functionality", () => { + // Helper function to create client with specific environment + async function createClientWithEnv(maskSecrets?: string): Promise<{transport: StdioClientTransport, client: Client}> { + const env = { ...process.env }; + if (maskSecrets !== undefined) { + env.MASK_SECRETS = maskSecrets; + } else { + delete env.MASK_SECRETS; + } + + const transport = new StdioClientTransport({ + command: "bun", + args: ["src/index.ts"], + stderr: "pipe", + env: env + }); + + const client = new Client( + { + name: "test-client", + version: "1.0.0", + }, + { + capabilities: {}, + } + ); + + await client.connect(transport); + await sleep(2000); + + return { transport, client }; + } + + // Helper function to cleanup client + async function cleanupClient(transport: StdioClientTransport) { + try { + await transport.close(); + await sleep(2000); + } catch (e) { + console.error("Error during cleanup:", e); + } + } + + describe("integration tests with MASK_SECRETS environment variable", () => { + test("should mask secrets when MASK_SECRETS=true", async () => { + const { transport, client } = await createClientWithEnv("true"); + + try { + // Create a test secret first + const createResult = await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_apply", + arguments: { + manifest: ` +apiVersion: v1 +kind: Secret +metadata: + name: test-masking-secret + namespace: default +type: Opaque +data: + username: dGVzdC11c2VybmFtZQ== + password: dGVzdC1wYXNzd29yZA== + config: ewogICJkYjpuYW1lIjogInRlc3QtZGF0YWJhc2UiLAogICJob3N0IjogInRlc3QtaG9zdCIKfQ== +`, + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + expect(createResult.content[0].type).toBe("text"); + + // Now get the secret and verify it's masked + const result = await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_get", + arguments: { + resourceType: "secrets", + name: "test-masking-secret", + namespace: "default", + output: "json", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + expect(result.content[0].type).toBe("text"); + const secretData = JSON.parse(result.content[0].text); + + // Verify that data fields are masked + expect(secretData.data).toBeDefined(); + expect(secretData.data.username).toBe("***"); + expect(secretData.data.password).toBe("***"); + expect(secretData.data.config).toBe("***"); + + // Verify that non-data fields are not masked + expect(secretData.metadata.name).toBe("test-masking-secret"); + expect(secretData.metadata.namespace).toBe("default"); + expect(secretData.kind).toBe("Secret"); + + // Clean up + await client.request( + { + method: "tools/call", + params: { + name: "kubectl_delete", + arguments: { + resourceType: "secret", + name: "test-masking-secret", + namespace: "default", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + } finally { + await cleanupClient(transport); + } + }); + + test("should not mask secrets when MASK_SECRETS=false", async () => { + const { transport, client } = await createClientWithEnv("false"); + + try { + // Create a test secret first + const createResult = await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_apply", + arguments: { + manifest: ` +apiVersion: v1 +kind: Secret +metadata: + name: test-unmasked-secret + namespace: default +type: Opaque +data: + username: dGVzdC11c2VybmFtZQ== + password: dGVzdC1wYXNzd29yZA== +`, + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + expect(createResult.content[0].type).toBe("text"); + + // Now get the secret and verify it's not masked + const result = await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_get", + arguments: { + resourceType: "secrets", + name: "test-unmasked-secret", + namespace: "default", + output: "json", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + expect(result.content[0].type).toBe("text"); + const secretData = JSON.parse(result.content[0].text); + + // Verify that data fields are NOT masked + expect(secretData.data).toBeDefined(); + expect(secretData.data.username).toBe("dGVzdC11c2VybmFtZQ=="); + expect(secretData.data.password).toBe("dGVzdC1wYXNzd29yZA=="); + + // Clean up + await client.request( + { + method: "tools/call", + params: { + name: "kubectl_delete", + arguments: { + resourceType: "secret", + name: "test-unmasked-secret", + namespace: "default", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + } finally { + await cleanupClient(transport); + } + }); + + test("should mask secrets when MASK_SECRETS is unset (default behavior)", async () => { + const { transport, client } = await createClientWithEnv(); + + try { + // Create a test secret first + const createResult = await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_apply", + arguments: { + manifest: ` +apiVersion: v1 +kind: Secret +metadata: + name: test-default-secret + namespace: default +type: Opaque +data: + username: dGVzdC11c2VybmFtZQ== +`, + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + expect(createResult.content[0].type).toBe("text"); + + // Now get the secret and verify it's not masked (default behavior) + const result = await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_get", + arguments: { + resourceType: "secrets", + name: "test-default-secret", + namespace: "default", + output: "json", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + expect(result.content[0].type).toBe("text"); + const secretData = JSON.parse(result.content[0].text); + + // Verify that data fields are masked (default behavior) + expect(secretData.data).toBeDefined(); + expect(secretData.data.username).toBe("***"); + + // Clean up + await client.request( + { + method: "tools/call", + params: { + name: "kubectl_delete", + arguments: { + resourceType: "secret", + name: "test-default-secret", + namespace: "default", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + } finally { + await cleanupClient(transport); + } + }); + }); + + describe("output format tests", () => { + test("should mask secrets in JSON output", async () => { + const { transport, client } = await createClientWithEnv("true"); + + try { + const createResult = await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_apply", + arguments: { + manifest: ` +apiVersion: v1 +kind: Secret +metadata: + name: test-json-secret + namespace: default +type: Opaque +data: + key1: dGVzdC12YWx1ZS0x + key2: dGVzdC12YWx1ZS0y +`, + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + const result = await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_get", + arguments: { + resourceType: "secrets", + name: "test-json-secret", + namespace: "default", + output: "json", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + expect(result.content[0].type).toBe("text"); + const secretData = JSON.parse(result.content[0].text); + expect(secretData.data.key1).toBe("***"); + expect(secretData.data.key2).toBe("***"); + + // Clean up + await client.request( + { + method: "tools/call", + params: { + name: "kubectl_delete", + arguments: { + resourceType: "secret", + name: "test-json-secret", + namespace: "default", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + } finally { + await cleanupClient(transport); + } + }); + + test("should mask secrets in YAML output", async () => { + const { transport, client } = await createClientWithEnv("true"); + + try { + const createResult = await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_apply", + arguments: { + manifest: ` +apiVersion: v1 +kind: Secret +metadata: + name: test-yaml-secret + namespace: default +type: Opaque +data: + yamlkey: dGVzdC15YW1sLXZhbHVl +`, + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + const result = await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_get", + arguments: { + resourceType: "secrets", + name: "test-yaml-secret", + namespace: "default", + output: "yaml", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + expect(result.content[0].type).toBe("text"); + const yamlOutput = result.content[0].text; + + // Verify that the output contains masked values + expect(yamlOutput).toContain("yamlkey: '***'"); + // Verify that metadata is not masked + expect(yamlOutput).toContain("name: test-yaml-secret"); + + // Clean up + await client.request( + { + method: "tools/call", + params: { + name: "kubectl_delete", + arguments: { + resourceType: "secret", + name: "test-yaml-secret", + namespace: "default", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + } finally { + await cleanupClient(transport); + } + }); + }); + + describe("edge cases and error handling", () => { + test("should only mask secrets, not other resource types", async () => { + const { transport, client } = await createClientWithEnv("true"); + + try { + // Test with configmaps to ensure they're not masked + const result = await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_get", + arguments: { + resourceType: "configmaps", + namespace: "default", + output: "json", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + expect(result.content[0].type).toBe("text"); + // Should succeed without masking since it's not secrets + const configData = JSON.parse(result.content[0].text); + expect(configData).toBeDefined(); + } finally { + await cleanupClient(transport); + } + }); + + test("should handle malformed secrets gracefully", async () => { + const { transport, client } = await createClientWithEnv("true"); + + try { + // Test with non-existent secret + const result = await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_get", + arguments: { + resourceType: "secrets", + name: "non-existent-test-secret", + namespace: "default", + output: "json", + }, + }, + }, + asResponseSchema(KubectlResponseSchema) + ); + }); + + expect(result.content[0].type).toBe("text"); + const errorData = JSON.parse(result.content[0].text); + expect(errorData.error).toContain("not found"); + expect(errorData.status).toBe("not_found"); + } finally { + await cleanupClient(transport); + } + }); + }); +}); \ No newline at end of file diff --git a/tests/kubectl.test.ts b/tests/kubectl.test.ts index ca351ce..70a2dfd 100644 --- a/tests/kubectl.test.ts +++ b/tests/kubectl.test.ts @@ -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", }, }, }, @@ -403,31 +403,30 @@ describe("kubectl operations", () => { 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" + test("get events with custom invalid context should fail", async () => { + try { + await retry(async () => { + return await client.request( + { + method: "tools/call", + params: { + name: "kubectl_get", + arguments: { + resourceType: "events", + namespace: "default", + output: "json", + context: "non-existent-cluster" + }, }, }, - }, - 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"); + asResponseSchema(KubectlResponseSchema) + ); + }); + + expect(true).toBe(false); // This should not execute + } catch (error: any) { + expect(error.message).toContain('error: context "non-existent-cluster" does not exist'); + } }); }); }); diff --git a/tests/port-helper.ts b/tests/port-helper.ts new file mode 100644 index 0000000..d6c7ae1 --- /dev/null +++ b/tests/port-helper.ts @@ -0,0 +1,28 @@ +import net from "net"; + +// Helper function to find an available port +export async function findAvailablePort( + startPort: number, + maxRetries: number = 100 +): Promise { + if (maxRetries <= 0) { + return Promise.reject( + new Error("No available ports found within the retry limit.") + ); + } + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on("error", () => { + findAvailablePort(startPort + 1, maxRetries - 1) + .then(resolve) + .catch(reject); + }); + server.listen(startPort, () => { + const port = (server.address() as net.AddressInfo)?.port; + server.close(() => { + resolve(port); + }); + }); + }); +} diff --git a/tests/sse.test.ts b/tests/sse.test.ts index 4cc98a6..0c63b15 100644 --- a/tests/sse.test.ts +++ b/tests/sse.test.ts @@ -9,24 +9,7 @@ import { KubernetesManager } from "../src/utils/kubernetes-manager.js"; import { kubectlGetSchema, kubectlGet } from "../src/tools/kubectl-get.js"; import express from "express"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; -import net from "net"; - -// Helper function to find an available port -async function findAvailablePort(startPort: number): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.listen(startPort, () => { - const port = (server.address() as net.AddressInfo)?.port; - server.close(() => { - resolve(port); - }); - }); - server.on('error', () => { - // Try next port - findAvailablePort(startPort + 1).then(resolve).catch(reject); - }); - }); -} +import { findAvailablePort } from "./port-helper.js"; // Modified version of startSSEServer that returns the Express app function startSSEServerWithReturn(server: Server): Promise { @@ -61,8 +44,8 @@ function startSSEServerWithReturn(server: Server): Promise { ); resolve(serverInstance); }); - - serverInstance.on('error', reject); + + serverInstance.on("error", reject); }); } @@ -100,16 +83,19 @@ describe("SSE transport", () => { switch (name) { case "kubectl_get": - return await kubectlGet(k8sManager, input as { - resourceType: string; - name?: string; - namespace?: string; - output?: string; - allNamespaces?: boolean; - labelSelector?: string; - fieldSelector?: string; - sortBy?: string; - }); + return await kubectlGet( + k8sManager, + input as { + resourceType: string; + name?: string; + namespace?: string; + output?: string; + allNamespaces?: boolean; + labelSelector?: string; + fieldSelector?: string; + sortBy?: string; + } + ); default: throw new Error(`Unknown tool: ${name}`); } @@ -118,13 +104,13 @@ describe("SSE transport", () => { // Find an available port instead of using a fixed one actualPort = await findAvailablePort(3001); process.env.PORT = actualPort.toString(); - + // Start the SSE server and get the Express app reference expressApp = await startSSEServerWithReturn(server); serverUrl = `http://localhost:${actualPort}`; - + // Wait a bit for server to fully start - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); }); afterAll(async () => { @@ -186,8 +172,8 @@ describe("SSE transport", () => { arguments: { resourceType: "pods", namespace: "default", - output: "json" - } + output: "json", + }, }, }), } @@ -222,7 +208,7 @@ describe("SSE transport", () => { if (toolCallResult.result) { expect(toolCallResult.result.content[0].type).toBe("text"); const responseText = toolCallResult.result.content[0].text; - + // If it's JSON, parse it and check structure try { const parsedResponse = JSON.parse(responseText); diff --git a/tests/streaming_http.test.ts b/tests/streaming_http.test.ts new file mode 100644 index 0000000..3f90487 --- /dev/null +++ b/tests/streaming_http.test.ts @@ -0,0 +1,87 @@ +import { expect, test, describe, beforeAll, afterAll } from "vitest"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { startStreamableHTTPServer } from "../src/utils/streamable-http.js"; +import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import http from "http"; +import { pingSchema } from "../src/tools/ping.js"; +import { findAvailablePort } from "./port-helper.js"; + +// Simple type guard for ListToolsResponse +function isListToolsResponse(data: any): boolean { + return ( + data && + data.jsonrpc === "2.0" && + data.result && + Array.isArray(data.result.tools) + ); +} + +describe("Streamable HTTP Server", () => { + let server: Server; + let httpServer: http.Server; + let port: number; + let url: string; + + beforeAll(async () => { + port = await findAvailablePort(3001); + url = `http://localhost:${port}/mcp`; + + // Create a server and register a handler for list_tools + server = new Server( + { name: "test-stream-server", version: "1.0.0" }, + { capabilities: { tools: {} } } // Enable the tools capability + ); + server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: [pingSchema], // Return a simple tool schema + }; + }); + + process.env.PORT = port.toString(); + httpServer = startStreamableHTTPServer(server); + }); + + afterAll(async () => { + await new Promise((resolve, reject) => { + httpServer.close((err) => { + if (err) return reject(err); + resolve(); + }); + }); + }); + + test("should handle a full MCP session lifecycle", async () => { + try { + // Send a POST request and verify the response on the same channel + const listToolsRequest = { + jsonrpc: "2.0" as const, + method: "tools/list" as const, + params: {}, + id: 2, + }; + const postResponse = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify(listToolsRequest), + }); + expect(postResponse.status).toBe(200); + + // The response is expected directly on the POST request for this transport implementation + const postResponseText = await postResponse.text(); + const messageLine = postResponseText + .split("\n") + .find((line) => line.startsWith("data:")); + + expect(messageLine).toBeDefined(); + const postResponseJson = JSON.parse(messageLine!.replace(/^data: /, "")); + expect(isListToolsResponse(postResponseJson)).toBe(true); + expect(postResponseJson.result.tools[0].name).toBe("ping"); + } catch (error) { + console.error("Error during POST request:", error); + throw error; + } + }); +});