polyc-controller 2026.8.3

Conversation CRD + kube reconciler for the polychrome control plane.
//! The `ToolService` custom resource (`polychrome.dev/v1alpha1`).
//!
//! A `ToolService` is polychrome's **private MCP subregistry entry**: it
//! declares a connector (an MCP server) as data, modelled on the official MCP
//! registry's `server.json` shape (`name`, `version`, `description`,
//! `remotes[]`). Registering a connector is `kubectl apply` of one of these (or
//! `polychrome add`) — a new etcd object, no restart of anything. The next
//! conversation whose `tools_enabled` names it resolves the remote endpoint and
//! builds it into the harness's `CompositeRegistry`.
//!
//! The controller periodically health-checks each service (connect + MCP
//! `list_tools`) and reflects the result into the `status` subresource, exactly
//! as `claim_readiness` reflects pod readiness into a `Conversation`. The
//! reconcile loop is added by track T3 of the dynamic-tools plan.

use kube::CustomResource;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

/// Finalizer key the controller adds so a `ToolService` deletion blocks until
/// any controller-side state (e.g. a cached search index) is cleaned up.
pub const FINALIZER: &str = "polychrome.dev/toolservice-cleanup";

/// Default MCP transport when a [`Remote`] omits one.
fn default_transport() -> String {
    "streamable-http".to_owned()
}

/// Desired state of a registered connector (MCP server).
///
/// Mirrors the MCP registry `server.json`: the k8s `metadata.name` is the short
/// handle referenced by `Conversation.spec.tools_enabled`, while
/// [`Self::server_name`] carries the canonical reverse-DNS registry identity.
#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
#[kube(
    group = "polychrome.dev",
    version = "v1alpha1",
    kind = "ToolService",
    namespaced,
    status = "ToolServiceStatus",
    shortname = "ts",
    category = "polychrome",
    derive = "PartialEq",
    printcolumn = r#"{"name":"Healthy","type":"boolean","jsonPath":".status.healthy"}"#,
    printcolumn = r#"{"name":"Version","type":"string","jsonPath":".spec.version"}"#,
    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
)]
#[serde(rename_all = "camelCase")]
pub struct ToolServiceSpec {
    /// Canonical reverse-DNS server name from `server.json`
    /// (e.g. `"io.github.acme/search"`). Optional; the k8s resource name is the
    /// handle used elsewhere.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub server_name: Option<String>,
    /// Human-readable description (`server.json` `description`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// Immutable version of the registered server (`server.json` `version`).
    /// The controller keys cached state (status, search vectors) by this so it
    /// only recomputes on a version bump.
    pub version: String,
    /// Remote MCP endpoints (`server.json` `remotes[]`). The harness/controller
    /// dial the first reachable one.
    pub remotes: Vec<Remote>,
    /// Whether tools from this service require signed human approval before the
    /// harness executes them (wired into the existing HITL gate).
    #[serde(default)]
    pub needs_approval: bool,
    /// Tool names from this connector that require signed human approval even
    /// when `needs_approval` is false. This is the OPERATOR-side gate: the
    /// per-tool MCP `destructiveHint` a connector self-declares also routes a
    /// tool through approval, but only this list holds if a connector build
    /// stops advertising the hint. The harness ORs all three signals.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub approval_tools: Vec<String>,
    /// Make this connector available to conversations that don't name it
    /// explicitly in `tools_enabled` — i.e. enabled by default for new
    /// conversations in this namespace. This is the operator opt-in that lets
    /// `polychrome add` register a connector and have agents pick it up with no
    /// per-conversation configuration, while keeping egress governed: only a
    /// connector an operator explicitly marked default-enabled is broadcast.
    /// `tools_enabled` remains an additive per-conversation allowlist on top.
    #[serde(default)]
    pub default_enabled: bool,
    /// Optional bearer-token auth for dialing this connector's remotes. The
    /// control plane resolves the referenced Secret and presents the token in
    /// the `Authorization` header, so it never appears in this object, in
    /// `remotes[].url`, or in harness logs.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub auth: Option<Auth>,
    /// Reviewed retrieval-expansion text per tool name (synonyms, usage
    /// scenarios, example phrasings), consumed by the shortlist ranker and
    /// never generated at index time. Version-pinned exactly like
    /// [`ToolDescriptor::description`]: a change lands via spec review and a
    /// [`Self::version`] bump (invariant 8 of #582), so nothing unreviewed
    /// enters the ranking index.
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub tool_expansions: std::collections::BTreeMap<String, String>,
}

/// Bearer-token auth for a connector, resolved from a k8s Secret.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct Auth {
    /// Reference to a `Secret` (same namespace as the `ToolService`) holding the
    /// bearer token. The control plane reads it and presents the value as
    /// `Authorization: Bearer <token>` when dialing — the token stays in the
    /// Secret, never the spec or the URL.
    pub bearer_secret_ref: BearerSecretRef,
}

/// Points at one key inside a namespace-local k8s `Secret`.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct BearerSecretRef {
    /// `Secret` name, in the `ToolService`'s namespace.
    pub name: String,
    /// Key within the Secret's `data` whose value is the raw bearer token.
    pub key: String,
}

/// A remote MCP endpoint (`server.json` `remotes[]` entry).
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct Remote {
    /// Endpoint URL the harness dials via the streamable-HTTP transport.
    pub url: String,
    /// Transport type (`server.json` `type`), e.g. `"streamable-http"`.
    #[serde(default = "default_transport")]
    pub transport: String,
}

/// Observed state, written back to the `status` subresource by the controller.
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ToolServiceStatus {
    /// `true` when the last health check connected and listed tools.
    #[serde(default)]
    pub healthy: bool,
    /// RFC3339 timestamp of the last successful `list_tools`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_checked_at: Option<String>,
    /// The connector's tool catalog, reflected at the last check for the
    /// [`Self::observed_version`] it was read under.
    ///
    /// This is the version-keyed catalog: entries are (re)listed only when
    /// `spec.version` changes, never on a same-version periodic re-check, so
    /// nothing unreviewed or unversioned enters the reflected catalog. Health
    /// (`healthy`, `last_checked_at`, `message`) still tracks the live
    /// connector; the catalog stays frozen to the reviewed version.
    ///
    /// # Status size
    ///
    /// Full per-tool specs (schema + annotations) live inline here. A
    /// `ToolService` object shares etcd's ~1.5 MiB per-object ceiling across
    /// its spec, status, and metadata, so the documented spillover trigger is a
    /// serialized `availableTools` exceeding **256 KiB** — comfortably below the
    /// ceiling with headroom for the rest of the object. Past that a connector's
    /// catalog spills to a sibling `ConfigMap` keyed by `observedVersion`, with
    /// status holding only the reference. The spillover is deliberately NOT
    /// built yet: today's connectors sit far under the trigger, and this note
    /// records the threshold so the decision is made before the field lands.
    #[serde(default)]
    pub available_tools: Vec<ToolDescriptor>,
    /// Human-readable status detail (e.g. an error reason when unhealthy).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
    /// The [`ToolServiceSpec::version`] this status reflects.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub observed_version: Option<String>,
}

/// One tool's full reflected spec.
///
/// Carries the name, description, argument schema, and the MCP behavioral
/// annotations (`readOnlyHint`, `destructiveHint`, `openWorldHint`) the approval
/// and untrusted-content gates key on.
///
/// Reflected into status from `list_tools` and forwarded to the harness in the
/// turn input, so the harness advertises the connector's tools without a
/// per-turn `list_tools` and its gates decide from these annotations rather than
/// re-fetching at call time.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ToolDescriptor {
    /// Tool name as advertised by the MCP server.
    pub name: String,
    /// Tool description, if the server provided one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// The tool's argument JSON Schema, serialized as a JSON string.
    ///
    /// Stored as text (not a structured object) so the CRD keeps a plain
    /// structural schema — a connector's schema is arbitrary JSON that would
    /// otherwise force `x-kubernetes-preserve-unknown-fields`. The harness
    /// parses it back to a schema object when it composes the tool spec.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub input_schema: String,
    /// MCP `title`: a friendly display label for the tool, if the server
    /// provided one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,
    /// MCP `readOnlyHint`: the tool does not modify its environment.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub read_only: bool,
    /// MCP `destructiveHint`: the tool may perform irreversible or
    /// side-effecting changes. Routes the tool through the approval gate.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub destructive: bool,
    /// MCP `openWorldHint`: the tool's result may carry content of uncontrolled
    /// provenance — the inbound leg of the untrusted-content gate.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub open_world: bool,
}