polyc-controller 2026.9.0

Conversation CRD + kube reconciler for the polychrome control plane.
#![allow(clippy::unwrap_used)] // test/example/bench: panics are acceptable
//! Seam-3 (ToolService CRD boundary): a fixture MCP server's schema and
//! behavioral annotations land in the reflected status catalog.
//!
//! Drives the controller's [`health_check`] against an in-process MCP server
//! advertising a read-only closed-world `echo` and a destructive open-world
//! `delete_file`, then asserts the resulting [`ToolServiceReadiness`] carries
//! each tool's argument schema plus its `destructiveHint` / `openWorldHint` /
//! `readOnlyHint` — the full spec, not just name + summary.

#![allow(clippy::pedantic, clippy::nursery, missing_docs)]

use std::{borrow::Cow, net::SocketAddr, sync::Arc, time::Duration};

use polyc_controller::health_check;
use rmcp::{
    ErrorData as McpError, ServerHandler,
    handler::server::{
        router::tool::ToolRouter,
        tool::{ToolCallContext, ToolRoute},
    },
    model::{
        CallToolRequestParams, CallToolResponse, CallToolResult, Implementation, InitializeResult,
        ListToolsResult, PaginatedRequestParams, ServerCapabilities, Tool,
    },
    service::{RequestContext, RoleServer},
    transport::streamable_http_server::{
        StreamableHttpServerConfig, StreamableHttpService, session::never::NeverSessionManager,
    },
};
use serde_json::json;
use tokio_util::sync::CancellationToken;

#[derive(Clone)]
struct CatalogServer {
    router: Arc<ToolRouter<Self>>,
}

impl CatalogServer {
    fn new() -> Self {
        let mut router: ToolRouter<Self> = ToolRouter::new();

        let echo_schema = json!({
            "type": "object",
            "properties": { "text": { "type": "string" } },
            "required": ["text"],
        });
        let mut echo = Tool::new(
            Cow::Borrowed("echo"),
            Cow::Borrowed("Echo the input text back."),
            echo_schema.as_object().cloned().unwrap_or_default(),
        );
        // Read-only, explicitly closed-world.
        echo.annotations = Some(
            echo.annotations
                .unwrap_or_default()
                .read_only(true)
                .open_world(false),
        );
        router.add_route(ToolRoute::new_dyn(echo, |_ctx: ToolCallContext<Self>| {
            Box::pin(async move { Ok(CallToolResult::structured(json!({ "ok": true })).into()) })
        }));

        let del_schema = json!({
            "type": "object",
            "properties": { "path": { "type": "string" } },
            "required": ["path"],
        });
        let mut del = Tool::new(
            Cow::Borrowed("delete_file"),
            Cow::Borrowed("Delete a file."),
            del_schema.as_object().cloned().unwrap_or_default(),
        );
        del.title = Some("Delete a file".to_owned());
        del.annotations = Some(
            del.annotations
                .unwrap_or_default()
                .read_only(false)
                .destructive(true)
                .open_world(true),
        );
        router.add_route(ToolRoute::new_dyn(del, |_ctx: ToolCallContext<Self>| {
            Box::pin(async move { Ok(CallToolResult::structured(json!({ "ok": true })).into()) })
        }));

        Self {
            router: Arc::new(router),
        }
    }
}

impl std::fmt::Debug for CatalogServer {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("CatalogServer").finish_non_exhaustive()
    }
}

impl ServerHandler for CatalogServer {
    fn get_info(&self) -> rmcp::model::ServerInfo {
        InitializeResult::new(ServerCapabilities::builder().enable_tools().build())
            .with_server_info(Implementation::new(
                "catalog-fixture",
                env!("CARGO_PKG_VERSION"),
            ))
    }

    fn list_tools(
        &self,
        _request: Option<PaginatedRequestParams>,
        _context: RequestContext<RoleServer>,
    ) -> impl Future<Output = Result<ListToolsResult, McpError>> + Send + '_ {
        let tools = self.router.list_all();
        async move { Ok(ListToolsResult::with_all_items(tools)) }
    }

    fn call_tool(
        &self,
        request: CallToolRequestParams,
        context: RequestContext<RoleServer>,
    ) -> impl Future<Output = Result<CallToolResponse, McpError>> + Send + '_ {
        let router = self.router.clone();
        async move {
            let ctx = ToolCallContext::new(self, request, context);
            router.call(ctx).await
        }
    }
}

async fn spawn_server() -> (SocketAddr, CancellationToken, tokio::task::JoinHandle<()>) {
    let mut config = StreamableHttpServerConfig::default();
    config.legacy_session_mode = false;
    config.sse_keep_alive = None;
    let config = config.disable_allowed_hosts().disable_allowed_origins();
    let service = StreamableHttpService::new(
        || Ok(CatalogServer::new()),
        Arc::new(NeverSessionManager::default()),
        config,
    );
    let router = axum::Router::new().nest_service("/mcp", service);
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    let ct = CancellationToken::new();
    let server_ct = ct.clone();
    let handle = tokio::spawn(async move {
        let _ = axum::serve(listener, router)
            .with_graceful_shutdown(async move { server_ct.cancelled_owned().await })
            .await;
    });
    tokio::time::sleep(Duration::from_millis(50)).await;
    (addr, ct, handle)
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn health_check_reflects_full_specs_and_annotations() {
    let (addr, ct, handle) = spawn_server().await;
    let uri = format!("http://{addr}/mcp");

    let readiness = health_check(&uri, None).await;
    assert!(readiness.healthy, "fixture connector should list tools");
    assert_eq!(readiness.available_tools.len(), 2);

    let echo = readiness
        .available_tools
        .iter()
        .find(|d| d.name == "echo")
        .expect("echo reflected");
    assert!(echo.read_only, "readOnlyHint rides the catalog");
    assert!(!echo.destructive);
    assert!(
        !echo.open_world,
        "explicit openWorldHint:false rides the catalog"
    );
    let echo_schema: serde_json::Value = serde_json::from_str(&echo.input_schema).unwrap();
    assert_eq!(echo_schema["properties"]["text"]["type"], "string");

    let del = readiness
        .available_tools
        .iter()
        .find(|d| d.name == "delete_file")
        .expect("delete_file reflected");
    assert!(del.destructive, "destructiveHint rides the catalog");
    assert!(del.open_world, "openWorldHint rides the catalog");
    assert_eq!(del.title.as_deref(), Some("Delete a file"));
    let del_schema: serde_json::Value = serde_json::from_str(&del.input_schema).unwrap();
    assert_eq!(del_schema["required"][0], "path");

    ct.cancel();
    let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;
}