jumperless-mcp 0.1.0

MCP server for the Jumperless V5 — persistent USB-serial bridge exposing the firmware API to LLMs
//! Library CRUD ToolDefs. Manual user/MCP control over the resident
//! library at `/jumperless_mcp/`; the connect ceremony auto-installs,
//! but these tools expose explicit control via the MCP protocol.
//!
//! Four tools:
//!   - `library_install`      — idempotent install (no-op if up-to-date)
//!   - `library_uninstall`    — remove /jumperless_mcp/ files
//!   - `library_check`        — return version + files-present status as JSON
//!   - `library_reinstall`    — force clean + install (uninstall + install)
//!
//! Names are snake_case per D7. The federation gateway will prefix with
//! `jumperless.` when aggregating.
//!
//! ## Note on MCP advertisement (R8)
//!
//! These descriptors are intentionally NOT included in `Jumperless::tools()`.
//! The library operations require multi-step state management that is only
//! implemented in the CLI subcommand path. Advertising them via MCP would
//! make them discoverable but not callable (invoke() has no dispatch for them).
//! Retained here for CLI documentation and potential future MCP dispatch.
#![allow(dead_code)]

use crate::base::ToolDescriptor;
use serde_json::json;

/// Empty input schema for tools that take no arguments.
///
/// `"additionalProperties": false` makes the schema strict-MCP-validator-compatible
/// (validators that enforce JSON Schema Draft 7 reject objects with undeclared
/// properties if additionalProperties is not explicitly set to false).
fn no_args() -> serde_json::Value {
    json!({"type": "object", "properties": {}, "additionalProperties": false})
}

/// Idempotent install: write library files to `/jumperless_mcp/` if missing
/// or outdated. No-op if current version is already installed.
///
/// Expected exec budget: ~3s typical (3 fs_write calls + check overhead).
pub fn library_install_descriptor() -> ToolDescriptor {
    ToolDescriptor::with_timeout(
        "library_install",
        "Install the Jumperless MCP resident library to /jumperless_mcp/ on the device. \
         No-op if the current version is already installed.",
        no_args(),
        3_000,
    )
}

/// Remove all library files from `/jumperless_mcp/`.
///
/// Expected exec budget: ~1s typical (best-effort fs_remove + fs_rmdir calls).
pub fn library_uninstall_descriptor() -> ToolDescriptor {
    ToolDescriptor::with_timeout(
        "library_uninstall",
        "Remove the Jumperless MCP resident library from /jumperless_mcp/ on the device.",
        no_args(),
        1_000,
    )
}

/// Return current installation status as a JSON object with fields:
/// `installed`, `installed_version`, `current_version`, `up_to_date`,
/// `files_present`, `files_missing`.
///
/// Expected exec budget: <1s (4 exec_code calls: 1 read + 3 fs_exists).
pub fn library_check_descriptor() -> ToolDescriptor {
    ToolDescriptor::with_timeout(
        "library_check",
        "Check whether the Jumperless MCP resident library is installed and up-to-date. \
         Returns version and file presence status.",
        no_args(),
        1_000,
    )
}

/// Forced reinstall: remove existing library files then install fresh.
/// Equivalent to `library_uninstall` followed by `library_install`.
///
/// Expected exec budget: ~3s typical (uninstall + 3 fs_write calls).
pub fn library_reinstall_descriptor() -> ToolDescriptor {
    ToolDescriptor::with_timeout(
        "library_reinstall",
        "Force a clean reinstall of the Jumperless MCP resident library. \
         Removes existing files then writes fresh copies from the embedded bundle.",
        no_args(),
        3_000,
    )
}

/// Return all four library ToolDescriptors. Called from `Jumperless::tools()`.
pub fn descriptors() -> Vec<ToolDescriptor> {
    vec![
        library_install_descriptor(),
        library_uninstall_descriptor(),
        library_check_descriptor(),
        library_reinstall_descriptor(),
    ]
}

// ── Tests ──────────────────────────────────────────────────────────────────────

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

    #[test]
    fn library_install_descriptor_has_correct_name_and_timeout() {
        let d = library_install_descriptor();
        assert_eq!(d.name, "library_install");
        assert!(!d.description.is_empty());
        assert_eq!(d.timeout_ms, 3_000);
    }

    #[test]
    fn library_uninstall_descriptor_has_correct_name_and_timeout() {
        let d = library_uninstall_descriptor();
        assert_eq!(d.name, "library_uninstall");
        assert!(!d.description.is_empty());
        assert_eq!(d.timeout_ms, 1_000);
    }

    #[test]
    fn library_check_descriptor_has_correct_name_and_timeout() {
        let d = library_check_descriptor();
        assert_eq!(d.name, "library_check");
        assert!(!d.description.is_empty());
        assert_eq!(d.timeout_ms, 1_000);
    }

    #[test]
    fn library_reinstall_descriptor_has_correct_name_and_timeout() {
        let d = library_reinstall_descriptor();
        assert_eq!(d.name, "library_reinstall");
        assert!(!d.description.is_empty());
        assert_eq!(d.timeout_ms, 3_000);
    }

    #[test]
    fn all_descriptors_have_object_schema() {
        for d in descriptors() {
            assert!(
                matches!(d.input_schema, serde_json::Value::Object(_)),
                "descriptor '{}' must have object input_schema",
                d.name
            );
        }
    }

    #[test]
    fn all_descriptors_have_additional_properties_false() {
        // Strict-MCP-validator-compatible: no_args() must set additionalProperties=false.
        for d in descriptors() {
            let schema = &d.input_schema;
            assert_eq!(
                schema.get("additionalProperties"),
                Some(&serde_json::Value::Bool(false)),
                "descriptor '{}' input_schema must have additionalProperties=false for strict MCP validator compat",
                d.name
            );
        }
    }

    #[test]
    fn descriptors_returns_four_tools() {
        assert_eq!(descriptors().len(), 4);
    }

    #[test]
    fn all_tool_names_are_unique() {
        let descs = descriptors();
        let mut names: Vec<&str> = descs.iter().map(|d| d.name.as_str()).collect();
        let original_len = names.len();
        names.dedup();
        assert_eq!(names.len(), original_len, "tool names must be unique");
    }

    #[test]
    fn install_timeout_is_at_least_check_timeout() {
        // install does more work than check; its timeout should be >= check's
        let install = library_install_descriptor();
        let check = library_check_descriptor();
        assert!(install.timeout_ms >= check.timeout_ms);
    }

    #[test]
    fn reinstall_timeout_is_at_least_install_timeout() {
        let reinstall = library_reinstall_descriptor();
        let install = library_install_descriptor();
        assert!(reinstall.timeout_ms >= install.timeout_ms);
    }
}