Skip to content

ray-mcp — Grilling Decision Log

Date started: 2026-06-13 Companion to: 2026-06-13-ray-mcp-design.md Purpose: Capture every question raised during the design grilling and the decision taken, so they can be reviewed in one place and folded back into the design spec.

Status legend: ✅ decided · 🔄 in progress · ⏳ not yet reached


Research grounding (done before Q1)

Ran a deep-research pass (17 primary sources, 3-vote adversarial verification, 23/25 claims confirmed) on: does a KubeRay-aware MCP server already exist, and why not?

Findings: - No official Ray-project or Anyscale MCP server for KubeRay CRD lifecycle exists; no dominant community one either. - Anyscale deliberately chose a non-MCP path for agent control — first-party tooling is "Agent Skills" (SKILL.md folders, GA ~Apr 2026). In Anyscale docs, MCP appears only as a workload you host on Ray Serve, never as a Ray control plane. The most-resourced potential incumbent walked the other way. - Existing Ray MCP servers hit the wrong layer: rayatouille (Go, read-only, 11 dashboard tools), ray-mcp-server / killerapp (Python, write-capable but via Ray core API ray.init()/JobSubmissionClient, not CRDs), ray-agent (manifest gen). All individual, near-zero stars, some archived/deleted. - Generic K8s MCP servers already CRUD Ray CRDs via apiVersion+kind escape hatches — containers/kubernetes-mcp-server (Go, ~1k★) and Flux159/mcp-server-kubernetes (TS, ~1.4k★) — but with zero Ray-aware semantics. This is the main thing a new project must beat. - apiserversdk (KubeRay v1.4.0+, alpha, beta targeted v1.6) is an importable Go reverse-proxy library exposing CRUD over all three CRDs. Correction to the research framing: it is a server-side proxy, not a client library, so it does not replace a typed CRD client.

Why it doesn't exist yet: MCP is new; Anyscale's commercial incentives point at the managed platform, not an OSS control plane; the community took the easy dashboard-API path; generic K8s MCP servers suppress the urgency. → The gap is real and largely uncontested. Decision: build it.


Q1 — Purpose & wedge ✅

Question: What is this for, and what is the wedge, given a ~1k★ generic Go K8s MCP server already drives Ray CRDs?

Options: (a) serious "become the default OSS KubeRay MCP server"; (b) personal/internal tool; (c) portfolio/learning exercise.

Recommendation: (a), with the wedge written in as a first-class section: Ray-aware semantics a generic apiVersion+kind tool can't give an agent — typed worker-group autoscaling, RayService zero-downtime rollout, job submit→poll-to-terminal orchestration, live status/logs from the dashboard API, and safety tiers tuned to Ray destructive ops.

DECISION: ✅ (a) — become the default OSS KubeRay MCP server. Highest ambition; raises the bar on adoption, not just code quality. Wedge as above.


Q2 — Survival posture & maintenance commitment ✅

Question: How does "default" survive a first-party (ray-project) entrant, and how aligned with ray-project should the project be?

Options: (A) independent land-grab (speed + registries + momentum); (B) upstream-alignment / donation play (clean-room Apache-2.0, KubeRay-native, court ray-project, keep donation door open); blend.

Recommendation: (B) with (A)'s low-cost tactics layered on; be honest about the ongoing maintenance "default" demands.

DECISION: ✅ (B) + (A)'s low-cost tactics — upstream-alignment / donation play as the primary posture, with the cheap land-grab tactics (MCP registry listings, discoverability) layered on as a hedge. User commits to long-term maintenance; expects contributors to join over time. Implies: Apache-2.0, KubeRay-native types/concepts/cadence, build so it reads like something ray-project could adopt; also ship registry/discoverability tactics since they cost little and hedge if alignment stalls.

Coupled correction: apiserversdk is a server-side reverse proxy, not a client lib → the typed-CRD-client decision (#8) stands regardless. Its relevance is strategic (the vector a first-party MCP server would likely use; a possible deployment/alignment surface).


Q3 — Client foundation (what "KubeRay-native" compiles against) ✅

Question: Decision #8 says "KubeRay typed client + controller-runtime." Pin the three hidden sub-choices: which part of controller-runtime, which CRD type source, and the create/update/dryRun mechanism.

Recommendation & DECISION: ✅ 1. controller-runtime client package (pkg/client.New()), NOT the manager/framework — an uncached, direct-to-API-server typed client. We are a client, not a controller; no cache/reconcile/leader-election/webhooks. 2. Depend on the KubeRay Go module (github.com/ray-project/kuberay/ ray-operator/apis/ray/v1) for typed CRDs + scheme. This module pin IS the supported-version baseline (feeds #12): typed fields = ceiling of curated params; rawSpec covers anything newer. This dependency is the literal embodiment of the wedge — the generic K8s MCP server can't do this. 3. Server-Side Apply (client.Apply) + client.DryRunAll for create/update/dryRun — clean fit for #11's server-side dry-run; field-manager + conflict handling for free. 4. apiserversdk stays OUT of the client path (it's a server-side proxy). Noted only as a future HTTP-deployment/alignment seam (alpha; beta ~v1.6). 5. Action: reword spec decision #8 to "controller-runtime client package" so nobody wires up a manager.

Considered & rejected: KubeRay's generated clientset (client-go style) — leaner/more vanilla, but worse SSA + unstructured rawSpec merge ergonomics.


Q4 — Version baseline, compatibility contract, runtime detection ✅

Context / footgun: With structural-schema CRDs, the K8s API server silently prunes fields the installed CRD schema doesn't know — no error. So "compatibility" is governed by the installed CRD schema, not a version string; a newer field set against an older CRD vanishes and the agent thinks it worked.

Sub-decisions & DECISIONS: 1. API group: ✅ ray.io/v1 only. No v1alpha1 (legacy; supporting it dilutes the typed wedge for ~no adoption). 2. Compile target: ✅ Latest stable GA KubeRay at first commit (v1.5.x as of 2026-06), bump deliberately per KubeRay release as part of the Q2 maintenance cadence. Not auto-latest (unstable for users), not ancient (defeats wedge). Module pin = ceiling of curated params; rawSpec covers anything newer. 3. Runtime detection: ✅ Read the RayCluster CRD (user accepted more privilege; it strengthens the wedge — pre-apply pruning prediction is Ray-aware safety the generic K8s MCP server can't do). Diverges from the original "exclude CRD read" lean. Accepted with 3 refinements: - (3a) Tightly scoped privilege: small ClusterRole, get customresourcedefinitions with resourceNames: [rayclusters.ray.io, rayjobs.ray.io, rayservices.ray.io] — not blanket CRD read. resourceNames works for get-by-name. README least-privilege story survives ("+ read 3 named Ray CRDs cluster-scoped"). - (3b) Honest reporting: CRD read gives schema truth (valid fields → reliable pruning prediction — the real prize) but only best-effort version (report app.kubernetes.io/version label if installer set it, else served API version). ray_capabilities never fabricates a precise version it couldn't verify. - (3c) Use-if-present, not required: Helm ships the ClusterRole by default, but degrade gracefully if SA lacks it → crdVersion: "unknown (insufficient RBAC)", fall back to read-back diff.

Resulting layers: pre-apply pruning prediction (CRD schema) + post-apply ground-truth read-back diff (Q3) — complementary, both cheap, neither mandatory.

Architecture impact: shipped RBAC = namespace Role + small ClusterRole/Binding; Helm chart ships both. README declares a CI-tested range (e.g., "tested against KubeRay v1.3–v1.5"), not a runtime-derived promise.


Q5 — rawSpec merge semantics + escape-hatch security boundary ✅

Landmine: "deep-merge" (#10) is under-specified, and the naive "build typed → merge → unmarshal to typed → apply" pipeline silently drops every field newer than the compiled baseline, destroying Q4's "rawSpec covers anything newer" promise. Merge mechanics and the escape-hatch security boundary are the same decision.

(a) Unified apply pipeline — DECISION ✅ (reused by every create/update/deploy tool; unifies Q3 SSA + Q4 pruning + #10 merge + #11 diff): 1. Curated params → typed RayCluster → marshal to JSON (base). 2. rawSpec (YAML|JSON) → JSON. 3. RFC 7396 JSON Merge Patch, rawSpec over base (rawSpec wins). Arrays replace wholesale, documented loudly ("set workerGroups in rawSpec → you own the whole list"). Predictability > merge-by-key cleverness. 4. Identity guard: merged result changing name/namespace away from tool-arg identity → error, not silent ignore. 5. Keep result unstructured (preserves newer-than-baseline fields — the wedge). Validation is server-side, not a client typed round-trip. 6. Always DryRunAll the unstructured object → API server validates vs the installed CRD schema → reveals pruning (Q4) → diff intent-vs-result. 7. If not dryRun: SSA-apply unstructured → read back → diff (#11). A typed unmarshal MAY run for diagnostic warnings only; must NOT drop applied fields.

(b) Security boundary — DECISION ✅ bounded by RBAC, no in-server field policy. rawSpec reaches full PodTemplateSpec (privileged, hostPath, hostNetwork, serviceAccountName, arbitrary images). Decision: do NOT reinvent PodSecurity in the server. Self-hosted, per-user, bounded by the user's own RBAC (#7 "RBAC is the floor"); pod-spec policy is the job of PodSecurity admission / Kyverno / Gatekeeper. - Ship a --allow-raw-spec flag (default true); false drops the rawSpec arg from the schema entirely (curated-params-only hard mode for autonomous agents). - Document the boundary: "rawSpec is a full escape hatch bounded only by RBAC + admission control; for autonomous agents use restrictive RBAC and/or PodSecurity admission, or --allow-raw-spec=false."

Considered & rejected for v1: built-in denylist of scary fields (hostPath/privileged/hostNetwork/serviceAccountName). RBAC + admission is the real floor; a built-in denylist would be a worse copy of PodSecurity admission.


Q6 — Ray dashboard reachability + read-only boundary ✅

Bug found: #5 says "auto port-forward" as a constant, but reachability is deployment-dependent: stdio/out-of-cluster needs SPDY port-forward; in-cluster HTTP reaches head-svc directly via cluster DNS (port-forward is pointless overhead + extra pods/portforward RBAC). Data-flow B was written for the stdio case only.

Unstated reality: Ray dashboard / Job API has no built-in auth (basis of "ShadowRay" RCE attacks — open Job API = unauthenticated RCE). The tunnel terminates at an RCE-capable endpoint.

DECISIONS ✅: 1. Reachability = strategy, auto-detected. RayReachability port w/ two adapters: DirectDial (in-cluster) + PortForward (out-of-cluster). --ray-access=auto|direct|port-forward, auto detects in-cluster config. Rewrite data-flow B to branch. In-cluster needs no pods/portforward RBAC. 2. Dashboard/Job API is strictly READ-ONLY — live status + ray_job_logs only. Enforced by construction: RayAPIPort has no write methods. Every mutation goes through the guarded CRD path. The unauthenticated dashboard is never a write vector through this tool. 3. Tunnel lifecycle: one tunnel per (namespace, cluster) + idle-timeout reaper (not per-call ref-count/deferred-close). Re-dials on next use if dropped. #9 graceful degradation stays: reachability failure → CRD-derived status annotated "live Ray detail unavailable." 4. Dashboard-auth seam, off by default — optional token/header passthrough for orgs that front :8265 with an auth proxy.

Tool-surface change: ✅ ray_job_stop DROPPED from v1 (deferred). It was the only Ray-side write candidate; CRD spec.suspend semantics (heavier — can tear down cluster) deferred rather than implemented now. v1 RayJob tools: ray_job_list/get/logs (read) + ray_job_submit (write) + ray_job_delete (destructive). Confirms zero Ray-side write surface in v1. (ray_job_submit is still a write tool but creates a RayJob CRD → on the guarded CRD path, consistent with the invariant.)


Q7 — Transport scope: is in-cluster HTTP a v1 thing? ✅

Separation: transport plumbing is cheap (go-sdk gives stdio + streamable HTTP free); the in-cluster HTTP deployment is expensive (bearer auth, insecure footgun, TLS, Helm, NetworkPolicy, bigger threat model). stdio is the wedge-delivery vehicle — every local MCP client speaks it; drop-in binary + kubeconfig = adoption path to "default." HTTP = smaller "shared team instance" scenario (non-goals already rule out hosted multi-tenant).

DECISION ✅: Both transports in v1. stdio = primary, must-be-flawless (README quickstart leads with it; registry listings point at it; most test coverage). HTTP = secondary but its security is designed IN v1, not bolted on (a half-designed authenticated endpoint is worse than none). Docs honest about "start here" (stdio) vs "shared team instance" (HTTP).

Considered & rejected: defer HTTP+Helm to v1.1. Guts the Helm chart's reason to exist, contradicts goal #5, and the cheap part is the plumbing — the costly part (auth/hardening) we're designing anyway, so deferral buys little.


Q8 — HTTP auth model ✅

Problems with #7 as written: (1) 127.0.0.1 default contradicts HTTP's only purpose (in-cluster shared instance — loopback is unreachable by other pods); (2) --insecure (no-token) is a loaded gun on a network-reachable cluster-driver + autonomous agent; (3) a static bearer token has no identity/rotation/audit. Deeper point: the token only gates talking to the server; the server then acts with its own SA's RBAC, so every caller gets the union of their restraint and the SA's power → RBAC on the server SA is the real privilege boundary (consistent with #7); the token must be strong + non-optional on any non-loopback bind.

DECISIONS ✅: - Kill --insecure no-token mode. Invariant: non-loopback bind ⇒ token mandatory, no bypass flag. Process refuses to boot otherwise, with an explanatory error. Only tokenless case = loopback bind (OS gates it). - Default bind stays 127.0.0.1; binding non-loopback without a token (or TokenReview) = refuse to start. - Two token modes, both built in v1: (default) static bearer via --auth-token/env — "shared secret, rotation is your job, actions attributable to the server SA"; (opt-in) Kubernetes TokenReview — callers present their own SA token, validated via TokenReview API → real caller identity, rotatable, idiomatic (the realized form of the spec's "OAuth 2.1 future-work" seam; cheaper than OAuth for in-cluster). - v1 default = static bearer; TokenReview = built but opt-in. Rationale: stdio (primary adoption path) has no HTTP auth at all, so v1 shouldn't gate on the complex mode — but TokenReview ships so serious in-cluster operators have the idiomatic option and the future-work seam is real, not vapor. - TLS: push termination to ingress/mesh, document it; don't build TLS into the binary for v1. - Audit-log every mutating call: caller identity (token fingerprint / SA username), tool, args summary, dryRun flag, outcome. Not polish — it's how you answer "what did the agent do."


Q9 — Mutation interaction model (dryRun default + confirm + MCP annotations) ✅

Gap: spec never states dryRun's default — but that default is the safety posture. Also spec ignores MCP tool annotations (readOnlyHint/ destructiveHint/idempotentHint) which compliant clients use to auto-prompt the human — free protocol-layer "safe by default."

Models weighed: (A) dryRun=false kubectl-like (weakest in-moment guard); (B) dryRun=true preview-first ("safe by default" but silent-non-action failure — agent sees "would create," believes done, never commits); (C) confirm-token (diff + server token, 2nd call commits — robust, complex, server holds pending state).

DECISION ✅ — layered model, not one knob: 1. Tag ALL tools with MCP annotations (regardless of other choices): readOnlyHint:true on read tools; destructiveHint:true on delete/destructive-scale; idempotentHint:true on SSA update/scale. Free client-side confirmation. 2. dryRun defaults false for writes — meaningful because of the layers around it. B's silent-non-action is genuinely dangerous (misleads autonomous agent); no K8s tooling defaults to preview (adoption intuition). Agent can pass dryRun=true to preview; Q5 pipeline runs server-side dry-run + diff internally on every apply anyway. 3. Confirm-token (C) for the destructive tier ONLY — ray_cluster_delete, ray_service_delete, destructive scale-downs return diff + token; commit requires echoing token. Cost justified only for irreversible/traffic-affecting ops. Plain writes (create/update/submit) don't need it.

Considered & rejected: dryRun=true everywhere — feels safer but silent-non-action actively misleads an autonomous agent; worse than confirm-token on the ops that actually matter.


Q10 — protected annotation: real guard or theater? ✅

Logic hole: as written (ray-mcp/protected=true refuses delete "regardless of flags") it's theater for the named threat — (1) the agent can remove the annotation via ray_cluster_update/rawSpec then delete (two plausible steps = real confused-agent failure mode); (2) it's enforced in our server, not the cluster, so kubectl / generic K8s MCP / a 2nd instance ignore it.

DECISION ✅ — keep it, close the loop, be honest: - (a) Gate its own removal: removing/changing ray-mcp/protected via any patch this tool issues is refused unless the destructive confirm-token (Q9) is presented. Turns the 2-step bypass into a deliberate confirmed act — without this, the guard is decorative. - (b) Document honestly: a guardrail against fat-finger / confused-agent deletion through this tool, NOT a security control. "Defense against mistakes, not adversaries. Real protection is RBAC." Matches #7. - Noted, not done in v1: metadata.finalizers would block deletion at the API-server level (works vs kubectl too) but risks wedging resources into stuck states — too easy a footgun for v1.

Considered & rejected: drop it entirely (rely on tier+confirm-token+RBAC). Cheap once confirm-token machinery exists; "don't accidentally delete prod RayService" is genuinely useful for an agent-driven tool, provided it's not oversold as security.


Q11 — Long-running ops (submit job / deploy service) ✅

Tension: MCP calls are synchronous req/response; Ray jobs run minutes–hours. Trap: block-and-poll server-side → hits MCP client request timeout (~tens of s) on real workloads → call dies, job keeps running, agent thinks it failed (worst outcome — looks like orchestration, breaks on the long jobs that matter).

Options: (A) fire-and-return non-blocking; (B) block-and-poll (breaks on real jobs); (C) MCP progress notifications (still timeout-bound, uneven client support); (D) hybrid — non-blocking + bounded wait helper.

DECISION ✅ — (D): 1. All long-running mutations non-blocking by default. ray_job_submit → {name, jobId-when-available, initialStatus} immediately; ray_service_deploy → immediate rollout-pending status. Never block to completion. 2. Bounded wait, capped well under client timeouts (~120s). ray_job_wait(name, waitSeconds≤120, until=running|terminal) → current status + "reached" bool. Default until=running ("did it start or is it stuck Pending?"); waiting for terminal on a real job is pointless. 3. Wedge = status distillation, NOT server-side polling. ray_job_get / ray_service_get return distilled, agent-actionable status: phase, progressing-vs-wedged (e.g. "Pending: unschedulable, no GPU nodes" + pod event), terminal exit + logs pointer; RayService rollout phase + old/new serve health + cutover state. Generic K8s MCP hands raw .status YAML; we hand "stuck because no GPU nodes." That distillation is the differentiation. 4. Document the agent loop in README (submit → wait(running) → poll get → logs).

Considered & rejected: (B) block-to-completion — intuitive but a trap; times out on real jobs, #1 way the tool would feel broken.

Tool-surface change: ✅ adds ray_job_wait (read-ish, bounded).

Correction (Review Round 2): the wait cap logged here as ~120s was inconsistent with this same question's "tens of seconds" client-timeout premise (A1). Superseded → ≤30s (well under a typical ~60s client timeout). The spec reflects 30s.


Q12 — Namespace model vs. least-privilege RBAC ✅

Tension: #6 "multi-namespace" vs. #7/Q4 "namespace-scoped Role." A Role+RoleBinding = one namespace; multi-ns = either N RoleBindings (explicit, least-priv, manual) or a ClusterRole (broad, kills the least-priv story). --allow-all-namespaces implies cluster-wide list → requires ClusterRole, so the flag silently demands broad RBAC and disagrees with the shipped namespaced Role. Also: in-cluster default ns should be the pod's own namespace, not the literal "default".

DECISION ✅ — explicit two-mode, RBAC-honest: 1. Namespaced mode (default, the least-priv story): operate in a configured namespace set; Helm ships Role+RoleBinding per namespace (templated list, default = release namespace). ray_capabilities reports served namespaces; tool call outside the set → clear "not configured for namespace X" before hitting the API. 2. Cluster mode (opt-in): --allow-all-namespaces → ClusterRole+ ClusterRoleBinding (shipped but OFF by default). Only this enables cluster-wide list / arbitrary-ns access. 3. Flag and RBAC must agree, fail loud: startup SelfSubjectAccessReview determines actual scope; if --allow-all-namespaces set but SA lacks cluster-wide list → refuse to start (or loud-warn downgrade), not confusing per-call Forbiddens. Self-reported ray_capabilities matches reality (same honesty principle as Q4/Q8). 4. Default namespace = pod's own namespace in-cluster (SA-token ns file), fall back to "default" only for stdio/kubeconfig. --default-namespace overrides both.

Considered & rejected: always ship a ClusterRole and treat --allow-all-namespaces as a pure app-level gate atop always-broad RBAC — simpler Helm, but makes the least-privilege claim false by default (bad for posture-B credibility). Chose real two-mode RBAC despite more templating.


Q13 — Token economy of tool results (MCP-specific constraint) ✅

Gap: spec describes what tools return but ignores that every result is consumed by an LLM against a context window — an MCP constraint absent from CLI/REST. Failure modes in the current surface: list dumping full status for 40 clusters; get returning raw verbose CRD .status; "bounded tail" logs bounded by lines not bytes (10K-line stack trace ≈ unbounded); huge PodTemplateSpec diffs. Connects to Q11 — distillation is both intelligent and compact.

DECISION ✅ — token economy as a cross-cutting rule; aggressive defaults + explicit escape hatches (agent can ask for more; can't un-spend forced tokens): 1. Two-tier verbosity: list → tiny row (name, phase, ready replicas, age, 1-line health), never full status; get → distilled view (Q11), not raw .status; verbose/raw arg on get (default off) for the full object. 2. Every list paginates + caps: hard default cap (~50) + limit/continue (reuse k8s continue tokens). "Showing 50 of 213, continue token X" — never silently truncate (CLAUDE.md "surface confusion"). 3. ray_job_logs byte/token-bounded, not just lines: tailLines + hard byte ceiling (~10–20KB) + "truncated, N bytes omitted" marker; default to tail. 4. MCP structured + text dual output (go-sdk): lead with compact structuredContent; short human text; don't pretty-print giant YAML. 5. Diffs summarized by default ("3 fields changed: replicas 2→5, image X→Y, +annotation Z"); full structural diff behind verbose.

No counter-position taken — only knob was default aggressiveness; chose aggressive.


Q14 — Confirm mechanism: stateful token vs. stateless fingerprint ✅

Self-correction: Q9/Q10's confirm-token introduced server-side pending state with TTL into an architecture that assumes a stateless server (added by the grilling, not the original spec). Problems: in-memory map breaks across HTTP replicas / rolling upgrades; needs TTL + single-use reaper; extra round-trip some agents fumble.

Insight: a content-derived fingerprint gives the same guarantee statelessly — destructive/protected-removal call returns diff + fingerprint = hash(resourceUID + resourceVersion + operation); agent commits with confirm=<fingerprint>; server recomputes from the live object and compares — no stored state. Because resourceVersion is included, a resource changed between preview and commit → fingerprint mismatch → stale confirm rejected (free TOCTOU / optimistic-concurrency protection; equivalent to a resourceVersion delete precondition).

DECISION ✅ — stateless content-derived fingerprint for destructive ops (Q9) + protected-annotation removal (Q10). Server stays stateless → HTTP deployment trivially horizontally scalable; gains stale-write rejection. Add §4 invariant: "server holds no cross-call state; confirmations are content-derived."

Considered & rejected: server-issued random token — only advantage is guaranteeing the preview happened (fingerprint is predictable, could be computed without viewing the diff). Rejected: "force the LLM to look" is unenforceable anyway; statelessness + TOCTOU are concrete wins over a soft guarantee.


Q15 — MCP SDK: official go-sdk vs. mark3labs/mcp-go ✅

Tradeoff: official modelcontextprotocol/go-sdk (canonical, tracks protocol features first, but younger / possible pre-1.0 churn) vs. mark3labs/mcp-go (community incumbent, more battle-tested, but could lag/diverge from canonical). Posture-B framing: a donate-upstream project on the official SDK is far more blessable — same "native/canonical foundation" logic as Q3's KubeRay types.

DECISION ✅ — keep official go-sdk (#9 stands), justified by upstream alignment, with two accepted consequences: 1. Pin exact version + budget for churn; SDK fully quarantined behind the internal/mcp/ edge (hexagonal arch already does this) → an SDK swap/upgrade touches only that package. 2. Feature-existence dependency (OPEN, resolve at implementation start): confirm the SDK today supports Q9 tool annotations (destructiveHint/readOnlyHint/idempotentHint), Q13 structuredContent dual output, and the §10 in-memory transport. If any are missing it changes either the SDK choice or those decisions. Reasoned from a Jan-2026 cutoff — not yet verified. Low-risk to defer due to edge isolation.

Considered & rejected: mark3labs/mcp-go — more mature now, but community foundation weakens the upstream-donation story; maturity gap is quarantined to one package anyway.


Q16 — ray_job_submit ephemeral vs. existing cluster 🔄 SCHEMA RATIFIED, 2 SUB-QS OPEN

(Grilling stopped here originally. Review Round 2 ratified the submit schema; two behavioral sub-questions remain open — see the updated status summary and spec §14 Q16a/Q16b.)

The fork: KubeRay RayJob runs in two modes — ephemeral (spec.rayClusterSpec: job creates its own RayCluster and, if shutdownAfterJobFinishes=true, tears it down) vs. existing-cluster (spec.clusterSelector: submits to a running RayCluster, never touches its lifecycle). The spec's "cluster selector/spec" hides this.

Why it matters: (1) ray_job_delete blast radius is mode-dependent — deleting an ephemeral job can cascade-delete a whole RayCluster (+ every other job/actor on it); deleting an existing-cluster job just removes the record. Q9/Q14 currently treat ray_job_delete as one uniform thing. (2) shutdownAfterJobFinishes is an agent footgun: default-off → orphaned GPU-burning clusters (cost surprise); default-on → cluster torn down before post-mortem (lost debugging). (3) Mode interacts with namespace/cluster resolution.

Recommendation (pending user decision): - (a) Explicit mode / one-of existingCluster:<name> XOR clusterSpec:{curated + rawSpec}; both-or-neither → error. Ephemeral reuses the Q5 pipeline (a job's rayClusterSpec is a cluster spec). ray_job_delete becomes mode-aware: ephemeral-with-cascade → destructive tier (confirm-fingerprint); existing-cluster → plain write. (Ray-aware safety the generic K8s MCP can't do; extends Q9/Q14.) - (b) shutdownAfterJobFinishes default — lean true (prevent silent GPU-cost leak; surface "pass false to keep for debugging" in result). Flip to false if primary users are interactive debuggers.


Status summary (updated Review Round 2, 2026-06-13)

Q1–Q15 deltas are now FOLDED INTO the design spec. The main spec (2026-06-13-ray-mcp-design.md) was rewritten to incorporate every decided question, then re-reviewed. This log is the decision history; the spec is the current source of truth. Where they differ, the spec wins (the Q11 wait-cap correction above is an example).

Decided + folded into spec (✅): Q1 purpose/wedge · Q2 posture+maintenance · Q3 client foundation · Q4 version baseline/detection · Q5 rawSpec merge + security boundary · Q6 Ray reachability + read-only dashboard · Q7 transport scope · Q8 HTTP auth · Q9 mutation interaction · Q10 protected annotation · Q11 long-running ops (wait cap corrected 120s→30s) · Q12 namespace model · Q13 token economy · Q14 stateless confirm fingerprint · Q15 MCP SDK.

Q15 open item — CLOSED ✅ (verified 2026-06-13). Official go-sdk confirmed GA v1.6.1 (2026-05-22); ToolAnnotations{ReadOnlyHint, DestructiveHint, IdempotentHint}, CallToolResult.StructuredContent, StdioTransport, NewStreamableHTTPHandler, typed AddTool[In,Out], NewInMemoryTransports() all present by exact name. No SDK risk. (Repo is "official, in collaboration with Google" — not Anthropic-maintained; don't mislabel.)

Q16 — schema RATIFIED 🔄, two sub-questions OPEN (need sign-off before impl): - Q16a ray_job_delete mode-aware tiering (ephemeral-cascade → destructive + confirm-fingerprint vs. existing-cluster → plain write). Couples to B3. - Q16b shutdownAfterJobFinishes default (KubeRay default false; lean true).

New findings from Review Round 2 (verification + structural pass): - Verification (primary sources): §13 facts hold. Codegen-critical: RayJob jobId is the only submission-id field (no submissionId) and also exists as spec.jobId — folded into spec §13. Autoscaler field path spec.workerGroupSpecs[].scaleStrategy.workersToDelete confirmed. - Fixed in spec this round: A1 wait-cap 120→30s; A2 Q16 resolved/​open contradiction (schema ratified in §6, sub-qs scoped in §14); A3 dangling "R1/R8" refs removed; B4 stdout/stderr stdio-logging invariant added (§5); C1 two-phase wait (CRD→dashboard) made explicit in §7.A + mermaid; C2 head-endpoint resolved from status not DNS-templating (§7.B). - OPEN decision branches surfaced this round (not yet ratified — recommend a short pass): - B1 confirm-fingerprint vs. autoscaler churn — resourceVersion in the hash will livelock confirm on busy autoscaling clusters; lean: delete uses hash(UID+op) (identity), reserve resourceVersion for scale/update. - B2 Q5's unconditional DryRunAll partly obsoletes Q4's CRD-schema-read + its ClusterRole — reconsider whether that extra privilege earns its keep or demotes to optional capability-reporting. - B3 ✅ RESOLVED 2026-06-19 — "destructive" was overloaded for registration tier vs. runtime confirm. Decision: scale-to-zero requires BOTH --allow-destructive (tier) AND the confirm-fingerprint, not confirm alone: the confirm-fingerprint is a stateless handshake an autonomous agent can satisfy itself, so only the boot-time tier flag is an agent-proof guard, and a scale-to-zero (deleting all head+worker pods) is a teardown that belongs behind the same wall as delete. A non-zero scale stays a plain write. Couples to Q16a. - C3 curated params thin for GPU Ray (no rayStartParams/tolerations/ nodeSelector) → --allow-raw-spec=false hard mode unusable in the core GPU case; grow curated params or document the limit. - C4 ✅ ADDRESSED 2026-06-20 — status-distillation (the wedge crown jewel) was under-specified; now has its own design note (inputs per kind, RBAC, concrete examples, and the cross-kind seam): ray-mcp-distillation-design.md. Resolves the architecture review's Candidate 1 (name the distillation seam before RayJob lands). Implementation lands with the RayJob task.

Not yet grilled (candidate future questions): - RayService zero-downtime update semantics — what the tool exposes, how rollout progress is distilled (pairs with Q11). - ray_cluster_scale vs. ray_cluster_update overlap (fold or keep both?). - Project's own versioning/release strategy (semver, KubeRay-compat matrix, breaking-change policy) — distinct from Q4's KubeRay baseline. - Observability specifics (metrics, structured-log schema, audit-log correlation). - Error-taxonomy completeness; how SSA field-manager Conflict surfaces. - ray_service_delete "serving traffic" detection + missing force arg (D).