trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
//! MCP tool catalog for the trusty-mpm orchestration + session-manager server.
//!
//! Why: `tools/list` must advertise a JSON Schema for every tool so Claude Code
//! knows how to call it. Keeping the catalog separate from the dispatch logic
//! makes the tool surface easy to audit and version. Since #1221 the catalog
//! spans several concepts — the original orchestration/bug-reporting tools
//! ([`core`]), the session-lifecycle tools ([`session`]), the console tools
//! ([`console`]), the project-registry + NL-resolver tools ([`project`],
//! #1519 / #1517), the session-manager proxy tools ([`proxy`], #2550), the
//! Disk-dashboard survey ([`disk`], #6927), and the secrets reference tool
//! ([`secrets`], #7522) — so this is a thin facade that re-exports all and
//! concatenates their descriptors, keeping each leaf file well under the
//! 500-SLOC production cap.
//! What: [`tool_catalog`] builds the thirty-six MCP tool descriptors (nine core +
//! twelve session + five console + four project + four proxy + one disk + one
//! secrets — #1222 / #1220 / #1508 / #1519 / #1517 WI-5 / #2012 / #2550 / #6927 /
//! #7522 / session-context tools); [`TOOL_CATALOG`]
//! lists their names for tests and the startup log; `tool` is the shared
//! descriptor builder used by every submodule.
//! Test: the `tests` module below asserts the catalog has the expected count,
//! well-formed entries, and names matching [`TOOL_CATALOG`].

use serde_json::{Value, json};

pub mod console;
pub mod core;
// #6927: the Disk dashboard's worktree survey.
pub mod disk;
pub mod project;
pub mod proxy;
// #7522: `secrets_get_ref`, a client of the trusty-secrets socket.
pub mod secrets;
pub mod session;

/// Canonical names of every tool the server exposes, in catalog order.
///
/// Why: tests, the daemon's startup log, and the loopback-`/rpc` audit all want
/// the authoritative list without re-parsing the JSON schema. Keeping it exact
/// resolves the #1221 review nit about ambiguous existing-vs-new counts.
/// What: a static slice of the thirty-six tool names — the nine core/bug tools,
/// the twelve session-lifecycle tools, the five console-facing tools, the four
/// project-registry + NL-resolver tools (#1519 WI-2, #1517 WI-5), the four
/// session-manager proxy tools (#2550), the Disk survey (#6927), and
/// `secrets_get_ref` (#7522).
/// Test: `catalog_names_match_constant`.
pub const TOOL_CATALOG: [&str; 36] = [
    // ── 9 pre-existing tools (core.rs) ───────────────────────────────────────
    "session_list",
    "session_status",
    "agent_delegate",
    "memory_protect",
    "circuit_breaker_status",
    "hook_event",
    "list_recent_errors",
    "preview_bug_report",
    "report_bug",
    // ── 12 session-lifecycle tools (#1221 + #1508 + #2012 + #6431 + session-context, session.rs) ──
    "session_new",
    "session_stop",
    "session_resume",
    "session_decommission",
    // #2012: hard-delete the record (distinct from decommission).
    "session_delete",
    // #6431: record-only bulk delete over an explicit id list.
    "session_delete_records",
    "session_activity",
    "session_send",
    // #1508 bulk teardown + by-state prune.
    "session_decommission_ephemeral",
    "session_prune",
    // PM pause/resume context tools (distinct from managed-session lifecycle above).
    "session_context_catchup",
    "session_context_pause",
    // ── 5 console-facing tools (#1222 + #1220, console.rs) ───────────────────
    "console_metrics",
    "supervisor_status",
    "auto_resume_set",
    // #1220 config-convention tools: read/write ~/.trusty-tools/trusty-mpm/config.yaml
    "config_read",
    "config_write",
    // ── 4 project-registry + NL-resolver tools (#1519 WI-2, #1517 WI-5) ─────
    "project_list",
    "project_register",
    "project_get",
    "project_resolve",
    // ── 4 session-manager proxy tools (#2550, #1440 follow-up) ───────────────
    "session_proxy_focus",
    "session_proxy_unfocus",
    "session_proxy_message",
    "session_proxy_summary",
    // ── 1 Disk-dashboard tool (#6927, DOC-73 §16.6 item 2) ──────────────────
    "disk_survey",
    // ── 1 secrets tool (#7522, DOC-74 §10.2) ────────────────────────────────
    "secrets_get_ref",
];

/// Build the MCP tool descriptor list returned by `tools/list`.
///
/// Why: Claude Code reads `inputSchema` to validate calls; a single builder
/// keeps the schemas and the dispatch argument-parsing in lockstep across all
/// tool groups.
/// What: concatenates `core::core_tools` (nine descriptors),
/// `session::session_tools` (twelve descriptors), `console::console_tools`
/// (five descriptors), `project::project_tools` (four descriptors, WI-5 adds
/// `project_resolve`), `proxy::proxy_tools` (four descriptors, #2550) and
/// `disk::disk_tools` (one descriptor, #6927) and `secrets::secrets_tools` (one
/// descriptor, #7522) in catalog order, returning thirty-six
/// `{ name, description, inputSchema }` objects.
/// Test: `catalog_has_expected_tool_count` and `every_tool_has_input_schema`.
pub fn tool_catalog() -> Vec<Value> {
    let mut tools = core::core_tools();
    tools.extend(session::session_tools());
    tools.extend(console::console_tools());
    tools.extend(project::project_tools());
    tools.extend(proxy::proxy_tools());
    tools.extend(disk::disk_tools());
    tools.extend(secrets::secrets_tools());
    tools
}

/// Assemble one MCP tool descriptor object.
///
/// Why: every descriptor has the same `{ name, description, inputSchema }` shape;
/// a shared builder keeps the two tool-group modules terse and consistent.
/// What: returns a JSON object wrapping the three fields.
/// Test: exercised by every entry in `tool_catalog` via the tests below.
pub(crate) fn tool(name: &str, description: &str, input_schema: Value) -> Value {
    json!({
        "name": name,
        "description": description,
        "inputSchema": input_schema,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn catalog_has_expected_tool_count() {
        // 9 core + 12 session-lifecycle + 5 console + 4 project + 4 proxy = 34.
        // (WI-5 adds project_resolve to the 3 WI-2 tools → 4 total project tools;
        // #2012 adds session_delete → 9 session-lifecycle tools;
        // #2550 adds the 4 session-manager proxy tools;
        // session-context tools add session_context_catchup + session_context_pause → 11;
        // #6431 adds session_delete_records → 12)
        // #6927 adds disk_survey → 35.
        // #7522 adds secrets_get_ref → 36.
        assert_eq!(tool_catalog().len(), 36);
        assert_eq!(TOOL_CATALOG.len(), 36);
    }

    #[test]
    fn catalog_names_match_constant() {
        let names: Vec<String> = tool_catalog()
            .iter()
            .map(|t| t["name"].as_str().unwrap().to_string())
            .collect();
        assert_eq!(names, TOOL_CATALOG);
    }

    #[test]
    fn every_tool_has_input_schema() {
        for t in tool_catalog() {
            assert!(t["name"].is_string());
            assert!(t["description"].is_string());
            assert_eq!(t["inputSchema"]["type"], "object");
        }
    }

    #[test]
    fn bug_reporting_tools_present() {
        let catalog = tool_catalog();
        let names: Vec<&str> = catalog.iter().filter_map(|t| t["name"].as_str()).collect();
        assert!(names.contains(&"list_recent_errors"), "{names:?}");
        assert!(names.contains(&"preview_bug_report"), "{names:?}");
        assert!(names.contains(&"report_bug"), "{names:?}");
    }

    #[test]
    fn session_tools_present() {
        let catalog = tool_catalog();
        let names: Vec<&str> = catalog.iter().filter_map(|t| t["name"].as_str()).collect();
        for expected in [
            "session_new",
            "session_stop",
            "session_resume",
            "session_decommission",
            "session_delete",
            "session_delete_records",
            "session_activity",
            "session_send",
            "session_context_catchup",
            "session_context_pause",
        ] {
            assert!(names.contains(&expected), "missing {expected}: {names:?}");
        }
    }

    /// #4091 REGRESSION: `session_context_pause` must expose NO knob that could
    /// authorise discarding uncommitted work.
    ///
    /// Why: the pause tool prunes worktrees by DEFAULT, so it is the single
    /// most likely place for a destructive override to be added "for
    /// convenience" and then triggered by an ordinary `/tm-session-pause`. The
    /// discard opt-in belongs on the deliberate operator surfaces only (the
    /// `prune-worktrees` HTTP route / CLI). This test fails if anyone adds a
    /// force/discard/dirty property to the pause schema.
    #[test]
    fn session_context_pause_schema_has_no_dirty_override() {
        let catalog = tool_catalog();
        let pause = catalog
            .iter()
            .find(|t| t["name"] == "session_context_pause")
            .expect("session_context_pause must be in the catalog");
        let props = pause["inputSchema"]["properties"]
            .as_object()
            .expect("pause schema must declare properties");
        for key in props.keys() {
            let lowered = key.to_ascii_lowercase();
            assert!(
                !lowered.contains("force")
                    && !lowered.contains("discard")
                    && !lowered.contains("dirty"),
                "#4091: `{key}` would let a default /tm-session-pause discard unsaved work"
            );
        }
        assert_eq!(
            pause["inputSchema"]["additionalProperties"], false,
            "the pause schema must stay closed so no override can be smuggled in"
        );
    }

    #[test]
    fn console_tools_present() {
        let catalog = tool_catalog();
        let names: Vec<&str> = catalog.iter().filter_map(|t| t["name"].as_str()).collect();
        for expected in [
            "console_metrics",
            "supervisor_status",
            "auto_resume_set",
            "config_read",
            "config_write",
        ] {
            assert!(names.contains(&expected), "missing {expected}: {names:?}");
        }
    }

    #[test]
    fn project_tools_present() {
        let catalog = tool_catalog();
        let names: Vec<&str> = catalog.iter().filter_map(|t| t["name"].as_str()).collect();
        for expected in [
            "project_list",
            "project_register",
            "project_get",
            "project_resolve",
        ] {
            assert!(names.contains(&expected), "missing {expected}: {names:?}");
        }
    }

    #[test]
    fn proxy_tools_present() {
        let catalog = tool_catalog();
        let names: Vec<&str> = catalog.iter().filter_map(|t| t["name"].as_str()).collect();
        for expected in [
            "session_proxy_focus",
            "session_proxy_unfocus",
            "session_proxy_message",
            "session_proxy_summary",
        ] {
            assert!(names.contains(&expected), "missing {expected}: {names:?}");
        }
    }

    #[test]
    fn disk_tools_present() {
        let catalog = tool_catalog();
        let names: Vec<&str> = catalog.iter().filter_map(|t| t["name"].as_str()).collect();
        assert!(names.contains(&"disk_survey"), "{names:?}");
    }

    /// #6927: the tool's schema must survive a JSON round-trip unchanged — the
    /// console reads `inputSchema` to build the call, so a schema that only
    /// serialises one way would be a broken contract nothing else catches.
    #[test]
    fn disk_survey_schema_round_trips() {
        let catalog = tool_catalog();
        let disk = catalog
            .iter()
            .find(|t| t["name"] == "disk_survey")
            .expect("disk_survey must be in the catalog");
        let text = serde_json::to_string(disk).expect("serialize");
        let back: Value = serde_json::from_str(&text).expect("deserialize");
        assert_eq!(&back, disk, "the descriptor must round-trip unchanged");

        let schema = &disk["inputSchema"];
        assert_eq!(schema["type"], "object");
        assert_eq!(
            schema["additionalProperties"], false,
            "the schema stays closed so no unrecognised argument is silently accepted"
        );
        assert_eq!(schema["properties"]["project"]["type"], "string");
        assert_eq!(schema["properties"]["budget_seconds"]["type"], "integer");
        assert!(
            schema.get("required").is_none(),
            "every argument is optional: {schema}"
        );
        let desc = disk["description"].as_str().expect("a description");
        assert!(
            desc.contains("READ-ONLY"),
            "the description must state the tool removes nothing: {desc}"
        );
    }

    /// #7522: the schema stays closed, so no argument can ask for a value, and
    /// the description documents a response that carries none.
    #[test]
    fn secrets_get_ref_schema_is_closed_and_documents_no_value() {
        let catalog = tool_catalog();
        let tool = catalog
            .iter()
            .find(|t| t["name"] == "secrets_get_ref")
            .expect("secrets_get_ref must be in the catalog");
        let schema = &tool["inputSchema"];
        assert_eq!(schema["additionalProperties"], false, "{schema}");
        assert_eq!(schema["required"], json!(["project", "key"]), "{schema}");
        let props = schema["properties"].as_object().expect("properties");
        let mut names: Vec<&str> = props.keys().map(String::as_str).collect();
        names.sort_unstable();
        assert_eq!(names, ["key", "project"], "{schema}");
        let desc = tool["description"].as_str().expect("a description");
        assert!(desc.contains("NEVER returns a value"), "{desc}");
        for field in [
            "reference",
            "key",
            "present",
            "scope",
            "vault",
            "backend",
            "imported_at",
        ] {
            assert!(
                desc.contains(field),
                "the description omits `{field}`: {desc}"
            );
        }
    }

    #[test]
    fn agent_delegate_description_clarifies_it_does_not_execute() {
        // Why (#1942): the old wording ("...before spawning") wrongly implied
        // agent_delegate executes the agent. It only gates + tracks; the real
        // execution path is the native Agent/Task tool reading ~/.claude/agents/.
        // Guard the corrected wording so a future edit can't silently regress it.
        let catalog = tool_catalog();
        let desc = catalog
            .iter()
            .find(|t| t["name"] == "agent_delegate")
            .and_then(|t| t["description"].as_str())
            .expect("agent_delegate tool present with a description");
        assert!(
            desc.contains("does not spawn"),
            "agent_delegate description must state it does not spawn: {desc}"
        );
        assert!(
            desc.contains("Agent/Task tool"),
            "agent_delegate description must point to the native Agent/Task tool: {desc}"
        );
        assert!(
            !desc.contains("before spawning"),
            "agent_delegate description must not claim it spawns: {desc}"
        );
    }
}