jumperless-mcp 0.1.0

MCP server for the Jumperless V5 — persistent USB-serial bridge exposing the firmware API to LLMs
//! Core control ToolDefs: `jumperless.core.*`
//!
//! Two tools for pausing and resuming Core2 on the Jumperless V5.
//! Core2 handles LED rendering, overlay animations, logic analyzer,
//! and waveform generation.
//!
//! ## Gotchas baked in (from contract sheet):
//! - Do NOT call pause before slot_save — nodes_save() already pauses Core2
//!   internally. Double-pause causes deadlock/crash.
//! - Pausing Core2 for >100ms disrupts waveform generation (I2S/I2C stops)
//!   and may drop logic analyzer samples (USB polling stops).
//! - No maximum pause duration is enforced by firmware — caller responsibility.
//! - Core2 still handles sendAllPathsCore2 == 3 (bypass mode) even while paused.

use crate::base::{McpError, ToolDescriptor};
use serde_json::{json, Value};
use std::io::{Read, Write};

use crate::library::exec_with_cleanup;

/// Empty input schema for zero-arg tools.
fn no_args() -> Value {
    json!({"type": "object", "properties": {}, "additionalProperties": false})
}

// ── ToolDescriptors ───────────────────────────────────────────────────────────

/// Pause Core2 (LED render + service scheduler).
pub fn core_pause_descriptor() -> ToolDescriptor {
    ToolDescriptor::with_timeout(
        "core_pause",
        "Pause Core2 on the Jumperless V5, stopping LED rendering, overlay animations, \
         and logo swirls. Use before bulk LED/overlay operations to prevent flicker. \
         CRITICAL: do NOT call this before jumperless.slot.save — slot_save already \
         pauses Core2 internally; double-pause will deadlock or crash. \
         WARNING: pausing for >100ms disrupts waveform generation (wavegen stops) \
         and may drop logic analyzer samples. Always follow with core_resume promptly.",
        no_args(),
        1_000,
    )
}

/// Resume Core2.
pub fn core_resume_descriptor() -> ToolDescriptor {
    ToolDescriptor::with_timeout(
        "core_resume",
        "Resume Core2 on the Jumperless V5 after a pause_core2 call. \
         LED rendering, overlay animations, and service scheduling resume immediately. \
         Extended pauses (>100ms) may cause LED flicker artifacts as Core2 re-renders \
         from scratch on resume.",
        no_args(),
        1_000,
    )
}

/// Return both core ToolDescriptors.
pub fn descriptors() -> Vec<ToolDescriptor> {
    vec![core_pause_descriptor(), core_resume_descriptor()]
}

// ── Handlers ─────────────────────────────────────────────────────────────────

/// Execute `pause_core2(True)` and return `{"paused": true, "note": ...}`.
///
/// The firmware `pause_core2` is a stateless volatile bool toggle — there is no
/// read-back mechanism. The returned `paused: true` is a syntactic restatement of
/// the request, NOT state confirmation. The `note` field makes this explicit so
/// callers aren't surprised if another code path resumes Core2 between the pause
/// call and the next operation.
pub fn handle_core_pause<P: Read + Write + ?Sized>(port: &mut P) -> Result<Value, McpError> {
    let code = "pause_core2(True)";
    exec_with_cleanup(port, code, "core_pause")?;
    tracing::info!("core_pause: Core2 paused — resume promptly to avoid waveform disruption");
    Ok(json!({
        "paused": true,
        "note": "pause_core2 is a stateless volatile bool — no read-back. If another path calls pause_core2(False), this pause is voided without notice."
    }))
}

/// Execute `pause_core2(False)` and return `{"resumed": true, "note": ...}`.
///
/// Same stateless-volatile note applies to resume.
pub fn handle_core_resume<P: Read + Write + ?Sized>(port: &mut P) -> Result<Value, McpError> {
    let code = "pause_core2(False)";
    exec_with_cleanup(port, code, "core_resume")?;
    tracing::info!("core_resume: Core2 resumed");
    Ok(json!({
        "resumed": true,
        "note": "pause_core2 is a stateless volatile bool — no read-back. Core2 is assumed running but state cannot be confirmed."
    }))
}

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

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::VecDeque;
    use std::io::{self, Read, Write};

    // ── MockPort ──────────────────────────────────────────────────────────────

    struct MockPort {
        read_data: VecDeque<u8>,
        pub write_data: Vec<u8>,
    }

    impl MockPort {
        fn with_responses(responses: &[&[u8]]) -> Self {
            let mut buf = Vec::new();
            for r in responses {
                buf.extend_from_slice(r);
            }
            MockPort {
                read_data: VecDeque::from(buf),
                write_data: Vec::new(),
            }
        }

        fn ok_frame() -> Vec<u8> {
            b"OK\x04\x04>".to_vec()
        }

        fn error_frame(msg: &str) -> Vec<u8> {
            let mut v = b"OK\x04".to_vec();
            v.extend_from_slice(msg.as_bytes());
            v.push(b'\n');
            v.push(b'\x04');
            v.push(b'>');
            v
        }
    }

    impl Read for MockPort {
        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
            let n = buf.len().min(self.read_data.len());
            if n == 0 {
                return Err(io::Error::new(
                    io::ErrorKind::UnexpectedEof,
                    "MockPort: no more scripted bytes",
                ));
            }
            for (dst, src) in buf[..n].iter_mut().zip(self.read_data.drain(..n)) {
                *dst = src;
            }
            Ok(n)
        }
    }

    impl Write for MockPort {
        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
            self.write_data.extend_from_slice(buf);
            Ok(buf.len())
        }
        fn flush(&mut self) -> io::Result<()> {
            Ok(())
        }
    }

    // ── Descriptor tests ──────────────────────────────────────────────────────

    #[test]
    fn all_descriptors_have_correct_names() {
        let descs = descriptors();
        let names: Vec<&str> = descs.iter().map(|d| d.name.as_str()).collect();
        assert!(names.contains(&"core_pause"));
        assert!(names.contains(&"core_resume"));
        assert_eq!(descs.len(), 2);
    }

    #[test]
    fn all_descriptors_have_object_schema_with_additional_properties_false() {
        for d in descriptors() {
            assert!(
                matches!(d.input_schema, Value::Object(_)),
                "descriptor '{}' must have object input_schema",
                d.name
            );
            assert_eq!(
                d.input_schema.get("additionalProperties"),
                Some(&Value::Bool(false)),
                "descriptor '{}' must have additionalProperties=false",
                d.name
            );
        }
    }

    #[test]
    fn core_pause_description_warns_against_double_pause() {
        let d = core_pause_descriptor();
        assert!(
            d.description.contains("slot.save") || d.description.contains("double-pause"),
            "core_pause must warn against double-pause with slot.save"
        );
    }

    #[test]
    fn core_pause_description_warns_wavegen() {
        let d = core_pause_descriptor();
        assert!(
            d.description.contains("wavegen") || d.description.contains("waveform"),
            "core_pause must warn about wavegen disruption"
        );
    }

    // ── Handler: core_pause ───────────────────────────────────────────────────

    #[test]
    fn core_pause_happy_path_returns_paused_true_with_note() {
        let frame = MockPort::ok_frame();
        let mut port = MockPort::with_responses(&[&frame]);
        let result = handle_core_pause(&mut port).unwrap();
        assert_eq!(result["paused"], true);
        // R5: must include stateless-volatile advisory note.
        assert!(
            result.get("note").and_then(|v| v.as_str()).is_some(),
            "core_pause response must include 'note' about stateless volatile bool"
        );
        assert!(
            result["note"].as_str().unwrap().contains("stateless"),
            "note must mention stateless nature"
        );
    }

    #[test]
    fn core_pause_device_error_sends_ctrl_c() {
        let err = MockPort::error_frame("NameError: pause_core2");
        let mut port = MockPort::with_responses(&[&err]);
        let result = handle_core_pause(&mut port);
        assert!(result.is_err());
        assert!(port.write_data.contains(&0x03));
    }

    // ── Handler: core_resume ──────────────────────────────────────────────────

    #[test]
    fn core_resume_happy_path_returns_resumed_true_with_note() {
        let frame = MockPort::ok_frame();
        let mut port = MockPort::with_responses(&[&frame]);
        let result = handle_core_resume(&mut port).unwrap();
        assert_eq!(result["resumed"], true);
        // R5: must include stateless-volatile advisory note.
        assert!(
            result.get("note").and_then(|v| v.as_str()).is_some(),
            "core_resume response must include 'note' about stateless volatile bool"
        );
        assert!(
            result["note"].as_str().unwrap().contains("stateless"),
            "note must mention stateless nature"
        );
    }

    #[test]
    fn core_resume_device_error_sends_ctrl_c() {
        let err = MockPort::error_frame("NameError: pause_core2");
        let mut port = MockPort::with_responses(&[&err]);
        let result = handle_core_resume(&mut port);
        assert!(result.is_err());
        assert!(port.write_data.contains(&0x03));
    }
}