trusty-mpm 1.5.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
Documentation
//! 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), and the session-manager proxy tools ([`proxy`], #2550) — 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-three MCP tool descriptors (nine core +
//! eleven session + five console + four project + four proxy — #1222 / #1220 /
//! #1508 / #1519 / #1517 WI-5 / #2012 / #2550 / 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;
pub mod project;
pub mod proxy;
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-three tool names — the nine core/bug tools,
/// the eleven session-lifecycle tools, the five console-facing tools, the four
/// project-registry + NL-resolver tools (#1519 WI-2, #1517 WI-5), and the four
/// session-manager proxy tools (#2550).
/// Test: `catalog_names_match_constant`.
pub const TOOL_CATALOG: [&str; 33] = [
    // ── 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",
    // ── 11 session-lifecycle tools (#1221 + #1508 + #2012 + session-context, session.rs) ──
    "session_new",
    "session_stop",
    "session_resume",
    "session_decommission",
    // #2012: hard-delete the record (distinct from decommission).
    "session_delete",
    "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",
];

/// 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`] (eleven descriptors), [`console::console_tools`]
/// (five descriptors), [`project::project_tools`] (four descriptors, WI-5 adds
/// `project_resolve`), and [`proxy::proxy_tools`] (four descriptors, #2550) in
/// catalog order, returning thirty-three `{ 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
}

/// 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 + 11 session-lifecycle + 5 console + 4 project + 4 proxy = 33.
        // (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)
        assert_eq!(tool_catalog().len(), 33);
        assert_eq!(TOOL_CATALOG.len(), 33);
    }

    #[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_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 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}"
        );
    }
}