{
  "markdown": "# mcp-k8s-ephemeral-job\n\n**English** | [Русский](README.ru.md)\n\n[![Version](https://img.shields.io/github/v/tag/inhuman/mcp-k8s-ephemeral-job?sort=semver&style=flat-square&label=version)](https://github.com/inhuman/mcp-k8s-ephemeral-job/tags)\n[![MCP Registry](https://img.shields.io/badge/MCP_Registry-io.github.inhuman%2Fmcp--k8s--ephemeral--job-blue?style=flat-square)](https://registry.modelcontextprotocol.io)\n[![Docker Pulls](https://img.shields.io/docker/pulls/idconstruct/mcp-k8s-ephemeral-job?style=flat-square&logo=docker)](https://hub.docker.com/r/idconstruct/mcp-k8s-ephemeral-job)\n[![Docker Image Version](https://img.shields.io/docker/v/idconstruct/mcp-k8s-ephemeral-job?sort=semver&style=flat-square&logo=docker&label=image)](https://hub.docker.com/r/idconstruct/mcp-k8s-ephemeral-job/tags)\n[![Build](https://img.shields.io/github/actions/workflow/status/inhuman/mcp-k8s-ephemeral-job/docker-publish.yml?style=flat-square&logo=github)](https://github.com/inhuman/mcp-k8s-ephemeral-job/actions/workflows/docker-publish.yml)\n[![Go Version](https://img.shields.io/github/go-mod/go-version/inhuman/mcp-k8s-ephemeral-job?style=flat-square&logo=go)](https://go.dev/)\n[![Go Report Card](https://goreportcard.com/badge/github.com/inhuman/mcp-k8s-ephemeral-job?style=flat-square)](https://goreportcard.com/report/github.com/inhuman/mcp-k8s-ephemeral-job)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow?style=flat-square)](LICENSE)\n[![Issues](https://img.shields.io/github/issues/inhuman/mcp-k8s-ephemeral-job?style=flat-square)](https://github.com/inhuman/mcp-k8s-ephemeral-job/issues)\n[![Last Commit](https://img.shields.io/github/last-commit/inhuman/mcp-k8s-ephemeral-job?style=flat-square)](https://github.com/inhuman/mcp-k8s-ephemeral-job/commits/main)\n\nPublic OSS MCP server (Go, MIT) that spawns an **ephemeral Kubernetes Job/pod** from a\ncaller-chosen image, runs a command inside it, returns `exit_code` / `stdout` / **artifacts**, and\n**deletes the pod**. The server **builds the pod manifest in code** — the caller passes parameters\nonly, never raw YAML.\n\nIt is the \"operating room\" counterpart to [`mcp-exec`](https://github.com/inhuman/mcp-exec) (the\n\"scalpel\"): where `mcp-exec` runs a single Python file in a locked-down, network-less sandbox in\nmilliseconds, this one spins up a **full pod** with the toolchain/image you choose, **controlled\nnetwork** (clone repos, pull deps), long tasks and file **artifacts** out. They complement each\nother.\n\nWorks over three transports — **stdio / HTTP / SSE** — with an identical tool set everywhere\n(official [`modelcontextprotocol/go-sdk`](https://github.com/modelcontextprotocol/go-sdk)).\n\n## Tools\n\n### `run_job` — run and wait\n\n**Input**: `{ image (required), command (required), files?, env?, limits?, timeout_s?, workdir?, clone? }`\n**Output**: `{ exit_code, stdout, stderr, duration_ms, status, artifacts, truncated }`\n\n- `status` is one of `succeeded` / `failed` / `timeout` / `error`. A non-zero `exit_code` or a\n  `timeout` is a **normal result**, not a tool error. Only invalid input (empty image/command,\n  image not in the allowlist, bad file path) is a tool-call error.\n- `stdout` carries the container's **combined** stdout+stderr — Kubernetes merges the two streams in\n  pod logs. `stderr` is reserved and always empty, so adding stream separation later stays\n  backward-compatible.\n- **Artifacts** (files from the working directory) come back **inline** (base64) under a size cap.\n  Exceeding a cap never loses data silently: the matching `truncated` flag is set.\n- The manifest is built deterministically by the server — **no raw YAML from the caller**.\n\n### `submit_job` / `fetch_job` — run in the background\n\n`submit_job` takes the same arguments as `run_job` but returns a `job_token` **immediately**;\n`fetch_job` collects the result later. This is what lets an agent start a long job (a full test\nbattery, a build) and keep working instead of idling inside one synchronous call for its whole\nwall-clock time.\n\n- `fetch_job` returns `status=running` while the job is in flight; pass `wait_s` (≤120) to\n  long-poll instead of hammering. It answers as soon as the job is done rather than sitting out the\n  full wait.\n- Results are retained for 60 minutes and can be fetched more than once.\n- Arguments are validated at **submit** time, so a bad image fails the submit while the caller can\n  still fix it.\n- Handles live in memory (the server is single-replica by design): a restart drops pending tokens\n  and the caller simply resubmits.\n\n### Cloning a repository\n\nWith `clone: { repo_url, ref, subdir? }` an init container checks the repo out into the working\ndirectory before the command runs. **The caller never handles credentials**: the server holds a\nsecret with one token per git host, mounts it **only** on the cloner, and the token is masked in\n`.git/config` afterwards — the main container never sees it. The `clone` field is accepted only when\nthe operator has configured `MCP_K8S_CLONE_IMAGE` + `MCP_K8S_CLONE_SECRET`.\n\n## Security model (invariants)\n\nPer run: a **fresh ephemeral pod**, deleted afterwards (success / failure / timeout). The server's\nRBAC is **namespace-scoped** (`Role`/`RoleBinding`, never `ClusterRole`) — `create/delete jobs,pods`\nplus `pods/log`, `pods/exec` in **one** namespace. Spawned pods run with `cap-drop=ALL`,\n`no-privilege-escalation`, seccomp `RuntimeDefault`. The blast radius is that one namespace:\n`LimitRange` (per-pod default+max) + `ResourceQuota` (namespace ceiling) + wall-clock timeout\n(→ kill) + TTL/owner-reference GC + a concurrency cap. Images must pass a **strict allowlist**\n(`MCP_K8S_ALLOWED_IMAGES`; empty = nothing runs). Caller data (`command` / `files` / output /\nartifacts) is never persisted and never logged in full — only metadata.\n\n> `run_job` is the most powerful surface there is (it creates pods). When embedding it in an agent,\n> gate it behind that agent's tool-policy (trusted roles only).\n\n**Network note:** the pod's network is **not** disabled (it's needed to clone repos / pull deps).\nEgress is controlled by a namespace `NetworkPolicy` (allowlist) as a deployment concern, not a\nright baked into the code. The invariant is ephemerality + deletion, not the absence of network.\n\n**Resources:** the server's `MCP_K8S_DEFAULT_CPU`/`MEMORY` are pod **requests** (the scheduler's\nreservation). Limits are set only when the caller passes `limits`; otherwise the ceiling comes from\nthe namespace `LimitRange`. Passing `limits.memory` also raises the memory request to match, since\nmemory is incompressible and the pod must land on a node that actually has it.\n\n## Run\n\nPublished in the [MCP Registry](https://registry.modelcontextprotocol.io) as\n`io.github.inhuman/mcp-k8s-ephemeral-job`; the image is on Docker Hub as\n[`idconstruct/mcp-k8s-ephemeral-job`](https://hub.docker.com/r/idconstruct/mcp-k8s-ephemeral-job).\n\n```bash\ndocker run --rm -i \\\n  -v \"$HOME/.kube:/kube:ro\" \\\n  -e MCP_K8S_KUBECONFIG=/kube/config \\\n  -e MCP_K8S_NAMESPACE=ephemeral-dev \\\n  -e MCP_K8S_ALLOWED_IMAGES=busybox:1.36,python:3.12-slim \\\n  idconstruct/mcp-k8s-ephemeral-job:latest\n```\n\nFrom source, against a dev cluster, over stdio:\n\n```bash\ngo build -o mcp-k8s-ephemeral-job ./cmd/mcp-k8s-ephemeral-job\nexport MCP_K8S_KUBECONFIG=$HOME/.kube/config\nexport MCP_K8S_NAMESPACE=ephemeral-dev\nexport MCP_K8S_ALLOWED_IMAGES=busybox:1.36,python:3.12-slim\n./mcp-k8s-ephemeral-job\n```\n\nIn production the server runs **in-cluster** as a Deployment with its own ServiceAccount +\n`Role`/`RoleBinding` on the ephemeral namespace + `ResourceQuota`/`LimitRange` (+ optional egress\n`NetworkPolicy`), usually on the `http` transport.\n\n### Example call (`run_job`)\n\n```json\n{\n  \"image\": \"python:3.12-slim\",\n  \"command\": [\"python\", \"gen.py\"],\n  \"files\": [{ \"path\": \"gen.py\", \"content_b64\": \"<base64 of a script writing out.png>\" }],\n  \"limits\": { \"cpu\": \"500m\", \"memory\": \"256Mi\" },\n  \"timeout_s\": 30\n}\n```\n\nReturns `exit_code`, captured output, and `out.png` inline in `artifacts`. Afterwards the pod is\ngone (`kubectl get jobs,pods -n $NS` is empty).\n\n### Optional auth (HTTP/SSE)\n\nSet `MCP_K8S_AUTH_TOKEN` to require every HTTP/SSE request to carry a matching `X-MCP-AUTH` header\n(constant-time compare; `401` otherwise). Empty token disables it. Not applicable to stdio.\n\n## Configuration\n\n| Env var | Purpose | Default |\n|---|---|---|\n| `MCP_K8S_TRANSPORT` | `stdio` \\| `http` \\| `sse` | `stdio` |\n| `MCP_K8S_ADDR` | listen address for http/sse | `:8080` |\n| `MCP_K8S_NAMESPACE` | namespace where ephemeral pods are spawned | `mcp-ephemeral` |\n| `MCP_K8S_DEFAULT_TIMEOUT_S` | default wall-clock timeout (s) | `60` |\n| `MCP_K8S_MAX_TIMEOUT_S` | timeout ceiling (s) | `600` |\n| `MCP_K8S_MAX_OUTPUT_BYTES` | combined stdout+stderr cap | `1048576` |\n| `MCP_K8S_MAX_ARTIFACT_BYTES` | total artifacts size cap | `10485760` |\n| `MCP_K8S_DEFAULT_CPU` | pod CPU request (scheduling reservation; limits come from the caller's `limits` or the namespace LimitRange) | `1` |\n| `MCP_K8S_DEFAULT_MEMORY` | pod memory request (see above) | `512Mi` |\n| `MCP_K8S_MAX_CONCURRENT` | max concurrent ephemeral pods (over → queue/error) | `10` |\n| `MCP_K8S_ALLOWED_IMAGES` | strict image allowlist (CSV); **empty = deny everything** | `` |\n| `MCP_K8S_SIDECAR_IMAGE` | helper sidecar image for artifact collection (pinned) | `busybox:1.36` |\n| `MCP_K8S_CLONE_IMAGE` | image with git for the clone init container; empty = `clone` unavailable | `` |\n| `MCP_K8S_CLONE_SECRET` | secret holding one token per git host (key = host); mounted only on the cloner | `` |\n| `MCP_K8S_CACHE_PVC` | existing PVC mounted into every job pod as a shared cache; empty = no cache | `` |\n| `MCP_K8S_CACHE_MOUNT_PATH` | where the cache PVC is mounted, e.g. `/go/pkg/mod` | `` |\n| `MCP_K8S_JOB_EXTRA_ENV` | JSON object `{\"KEY\":\"value\"}` added to every job pod; caller keys win | `` |\n| `MCP_K8S_KUBECONFIG` | path to kubeconfig; empty = in-cluster | `` |\n| `MCP_K8S_AUTH_TOKEN` | if set, http/sse require `X-MCP-AUTH` header (constant-time); empty = off | `` |\n\nBoth cache variables must be set together for the cache to mount. The PVC itself is provisioned\nout-of-band (helm/manifest); the server only references it by name and fails fast at startup if it\nis missing.\n\n## Not implemented\n\nPVC / object-storage delivery for artifacts too large to inline, multi-cluster support, and proxying\nother MCP servers into the pod.\n\n## License\n\nMIT.\n",
  "bytes": 10430,
  "sha": "808ed157fbc58505be0064aa2b22808dcad54e5983c14538110b1376e58bb65e",
  "repo_slug": "inhuman/mcp-k8s-ephemeral-job",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_inhuman_mcp_k8s_ephemeral_job_a33a7a14/readme"
}