neurogrim-mcp 3.2.2

MCP client + server integration for NeuroGrim
Documentation
//! Bundled methodology primer (v3.2 Phase B; relocated from
//! neurogrim-cli to neurogrim-mcp in v3.2.1 so both the CLI and the
//! MCP server can expose `explain` from a single source of truth).
//!
//! Eight self-contained topic files ship inside the binary via
//! `include_str!`. Source: `neurogrim-mcp/data/explain/<topic>.md`.
//!
//! Each topic carries a version-stamped HTML comment header
//! (`<!-- topic: NAME — bundled in neurogrim-cli vX.Y -->`) so a
//! drift sensor can audit bundle-vs-canonical alignment over time.

const TOPIC_METHODOLOGY: &str = include_str!("../data/explain/methodology.md");
const TOPIC_DOMAIN: &str = include_str!("../data/explain/domain.md");
const TOPIC_SENSOR: &str = include_str!("../data/explain/sensor.md");
const TOPIC_HAT: &str = include_str!("../data/explain/hat.md");
const TOPIC_SCORING: &str = include_str!("../data/explain/scoring.md");
const TOPIC_FEDERATION: &str = include_str!("../data/explain/federation.md");
const TOPIC_CLI: &str = include_str!("../data/explain/cli.md");
const TOPIC_CULTURE: &str = include_str!("../data/explain/culture.md");

/// Spec/methodology version this bundle was compiled against. Matches
/// the version header in each `data/explain/*.md`. Bumped manually
/// when methodology evolves enough to invalidate prior agent
/// guidance. The `--version` surface in CLI + MCP both read this.
pub const BUNDLED_VERSION: &str = "v3.2";

/// Canonical-source path relative to the workspace root, surfaced via
/// `neurogrim explain --version` and the MCP `explain --topic
/// __version__` path. Helps human auditors locate the source of truth.
pub const CANONICAL_SOURCE: &str =
    "neurogrim/crates/neurogrim-mcp/data/explain/<topic>.md";

/// All bundled topics, in display order. Each entry is
/// `(name, summary, body)`.
pub fn topics() -> &'static [(&'static str, &'static str, &'static str)] {
    &[
        (
            "methodology",
            "What is LSP Brains; the overlay framing; the 5-piece model",
            TOPIC_METHODOLOGY,
        ),
        (
            "domain",
            "Anatomy of a domain; weight tiers; when to add one",
            TOPIC_DOMAIN,
        ),
        (
            "sensor",
            "Sensor authoring contract; CMDB envelope; score formula patterns",
            TOPIC_SENSOR,
        ),
        (
            "hat",
            "The 8 declared hats and when to wear each",
            TOPIC_HAT,
        ),
        (
            "scoring",
            "Unified score, confidence, trajectory, floor gates",
            TOPIC_SCORING,
        ),
        (
            "federation",
            "A2A peers, fractal composition, read-only siblings",
            TOPIC_FEDERATION,
        ),
        (
            "cli",
            "All commands grouped by purpose (introspection / authoring / execution / bookkeeping)",
            TOPIC_CLI,
        ),
        (
            "culture",
            "culture.yaml — five values as floor-only invariants",
            TOPIC_CULTURE,
        ),
    ]
}

/// Look up a topic body by name. Returns `Some(body)` when the topic
/// is bundled; `None` otherwise. Caller renders the error.
pub fn lookup(name: &str) -> Option<&'static str> {
    topics().iter().find(|(k, _, _)| *k == name).map(|t| t.2)
}

/// Comma-separated list of available topic names. Used in error
/// messages from both CLI and MCP layers.
pub fn topic_names() -> Vec<&'static str> {
    topics().iter().map(|(k, _, _)| *k).collect()
}

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

    #[test]
    fn topic_count_is_8() {
        assert_eq!(topics().len(), 8);
    }

    #[test]
    fn topic_names_are_unique() {
        let mut names = topic_names();
        names.sort();
        let n = names.len();
        names.dedup();
        assert_eq!(n, names.len(), "duplicate topic name");
    }

    #[test]
    fn all_topics_have_non_empty_bodies() {
        for (name, _summary, body) in topics() {
            assert!(
                body.len() > 200,
                "topic '{name}' bundled body is too short ({} bytes)",
                body.len()
            );
        }
    }

    #[test]
    fn all_topics_carry_version_header() {
        for (name, _summary, body) in topics() {
            let first = body.lines().next().unwrap_or("");
            assert!(
                first.starts_with("<!-- topic:") && first.contains(BUNDLED_VERSION),
                "topic '{name}' is missing the bundled-version header marker; first line: {first:?}"
            );
        }
    }

    #[test]
    fn methodology_topic_mentions_overlay() {
        assert!(TOPIC_METHODOLOGY.contains("overlay"));
        assert!(TOPIC_METHODOLOGY.contains("nervous system"));
    }

    #[test]
    fn domain_topic_describes_weight_tiers() {
        assert!(TOPIC_DOMAIN.contains("Weighted"));
        assert!(TOPIC_DOMAIN.contains("Advisory"));
        assert!(TOPIC_DOMAIN.contains("Stub"));
    }

    #[test]
    fn cli_topic_lists_command_families() {
        assert!(TOPIC_CLI.contains("Introspection"));
        assert!(TOPIC_CLI.contains("Authoring"));
        assert!(TOPIC_CLI.contains("Execution"));
        assert!(TOPIC_CLI.contains("Bookkeeping"));
    }

    #[test]
    fn lookup_finds_known_topic() {
        assert!(lookup("methodology").is_some());
        assert!(lookup("nonexistent").is_none());
    }
}