jumperless-mcp 0.1.0

MCP server for the Jumperless V5 — persistent USB-serial bridge exposing the firmware API to LLMs
//! Stable wrapper around rmcp's tool descriptor type.
//!
//! [`ToolDescriptor`] insulates consumers of this substrate from rmcp version
//! churn. Subsystems return `Vec<ToolDescriptor>` from
//! [`Subsystem::tools`](crate::Subsystem::tools); the transport layer converts
//! to `rmcp::model::Tool` via `Into` when emitting to the wire.
//!
//! # Why this exists
//!
//! Without this wrapper, every subsystem MCP would couple directly to
//! `rmcp::model::Tool`. If rmcp's `Tool` struct shape changes between minor
//! versions (e.g., new fields, renamed accessors), every subsystem-specific
//! crate would need updating in lockstep. With this wrapper, the conversion
//! lives in ONE place (`impl From<ToolDescriptor> for rmcp::model::Tool`) and
//! every subsystem keeps compiling unchanged.
//!
//! # Conversion stub
//!
//! Phase 0b.4 implements the `From` conversion when the transport layer wires
//! up rmcp's stdio server. Until then the impl is `todo!()` — building +
//! type-checking work; runtime conversion panics if invoked.

use serde_json::Value;

/// Default timeout for tool `exec_code` calls (milliseconds).
///
/// Used when a tool doesn't declare its own budget. 5 seconds covers most
/// tools that talk to a connected device for a single operation.
///
/// Override with [`ToolDescriptor::with_timeout`] or the builder-style
/// [`.timeout()`](ToolDescriptor::timeout) for tools outside this range:
/// - Fast queries (`get_adc`, `get_gpio`): 1–2 s
/// - Medium ops (default): 5 s
/// - Slow ops (`probe_read_blocking`, animations): 30+ s
/// - Variable ops (`device.snapshot` iterating nets/paths): 10–15 s
pub const DEFAULT_TOOL_TIMEOUT_MS: u64 = 5_000;

/// Error type for invalid [`ToolDescriptor`] values.
#[derive(thiserror::Error, Debug)]
pub enum ToolDescriptorError {
    #[error("tool name cannot be empty")]
    EmptyName,
    #[error(
        "tool name must be snake_case (lowercase [a-z0-9_] only, no dots or spaces): got '{0}'"
    )]
    InvalidName(String),
    #[error("tool description cannot be empty")]
    EmptyDescription,
    #[error("input_schema must be a JSON object ({{...}}), got: {0}")]
    SchemaNotObject(String),
}

fn validate_snake_case(name: &str) -> bool {
    !name.is_empty()
        && name
            .chars()
            .all(|c| matches!(c, 'a'..='z' | '0'..='9' | '_'))
}

/// Descriptor for an MCP tool exposed by a [`Subsystem`](crate::Subsystem).
///
/// Stable interface independent of any specific MCP SDK version.
///
/// # Field conventions
/// - `name`: bare snake_case per D7 (e.g., `connect`, `dac_set`,
///   `ina_get_current`). The federation gateway prefixes with
///   `<subsystem.name>.` when aggregating; `WireFormat` translates to
///   wire-safe names per D15.
/// - `description`: human-readable; clients show this to end users.
/// - `input_schema`: JSON Schema describing tool argument shape.
/// - `timeout_ms`: expected `exec_code` budget in milliseconds. Defaults to
///   [`DEFAULT_TOOL_TIMEOUT_MS`] (5 s). Wave 2 tool dispatch reads this to set
///   the per-call read timeout rather than inheriting a single global value.
#[derive(Debug, Clone)]
pub struct ToolDescriptor {
    pub name: String,
    pub description: String,
    pub input_schema: Value,
    pub timeout_ms: u64,
}

impl ToolDescriptor {
    /// Fallible constructor. Returns an error if any field is invalid.
    ///
    /// Validation rules:
    /// - `name`: non-empty, snake_case (`[a-z0-9_]`, no dots or spaces)
    /// - `description`: non-empty
    /// - `input_schema`: must be a JSON object (`{...}`)
    pub fn try_new(
        name: impl Into<String>,
        description: impl Into<String>,
        input_schema: Value,
    ) -> Result<Self, ToolDescriptorError> {
        let name = name.into();
        let description = description.into();
        if name.is_empty() {
            return Err(ToolDescriptorError::EmptyName);
        }
        if !validate_snake_case(&name) {
            return Err(ToolDescriptorError::InvalidName(name));
        }
        if description.is_empty() {
            return Err(ToolDescriptorError::EmptyDescription);
        }
        if !matches!(input_schema, Value::Object(_)) {
            return Err(ToolDescriptorError::SchemaNotObject(
                input_schema.to_string(),
            ));
        }
        Ok(Self {
            name,
            description,
            input_schema,
            timeout_ms: DEFAULT_TOOL_TIMEOUT_MS,
        })
    }

    /// Panicking constructor. Panics if any field is invalid.
    ///
    /// Use [`try_new`](Self::try_new) when inputs are not compile-time-known.
    ///
    /// ```rust,ignore
    /// use serde_json::json;
    /// use ygg_mcp_base::ToolDescriptor;
    ///
    /// let tool = ToolDescriptor::new(
    ///     "connect",
    ///     "Connect two breadboard nodes",
    ///     json!({
    ///         "type": "object",
    ///         "properties": {
    ///             "node1": { "type": "integer" },
    ///             "node2": { "type": "integer" },
    ///         },
    ///         "required": ["node1", "node2"]
    ///     }),
    /// );
    /// ```
    pub fn new(
        name: impl Into<String>,
        description: impl Into<String>,
        input_schema: Value,
    ) -> Self {
        Self::try_new(name, description, input_schema)
            .expect("invalid ToolDescriptor — see ToolDescriptorError variants")
    }

    /// Fallible constructor with an explicit timeout.
    ///
    /// Validates the same fields as [`try_new`](Self::try_new) and sets
    /// `timeout_ms` to `timeout_ms` instead of [`DEFAULT_TOOL_TIMEOUT_MS`].
    pub fn try_with_timeout(
        name: impl Into<String>,
        description: impl Into<String>,
        input_schema: Value,
        timeout_ms: u64,
    ) -> Result<Self, ToolDescriptorError> {
        let mut d = Self::try_new(name, description, input_schema)?;
        d.timeout_ms = timeout_ms;
        Ok(d)
    }

    /// Panicking constructor with an explicit timeout.
    ///
    /// Use when inputs are compile-time-known. Panics on invalid field values.
    ///
    /// ```rust,ignore
    /// use serde_json::json;
    /// use ygg_mcp_base::ToolDescriptor;
    ///
    /// let tool = ToolDescriptor::with_timeout(
    ///     "probe_read_blocking",
    ///     "Blocking probe read (may take 30+ s)",
    ///     json!({"type": "object", "properties": {}}),
    ///     30_000,
    /// );
    /// ```
    pub fn with_timeout(
        name: impl Into<String>,
        description: impl Into<String>,
        input_schema: Value,
        timeout_ms: u64,
    ) -> Self {
        Self::try_with_timeout(name, description, input_schema, timeout_ms)
            .expect("invalid ToolDescriptor — see ToolDescriptorError variants")
    }

    /// Builder-style timeout setter.
    ///
    /// Useful when constructing via [`new`](Self::new) but needing a
    /// non-default timeout:
    ///
    /// ```rust,ignore
    /// let tool = ToolDescriptor::new("snapshot", "...", schema).timeout(15_000);
    /// ```
    pub fn timeout(mut self, ms: u64) -> Self {
        self.timeout_ms = ms;
        self
    }
}

/// Convert to rmcp's wire-format Tool. Used by the transport layer when
/// emitting tool catalogs to MCP clients.
///
/// The `name` field is carried over verbatim — callers are responsible for
/// applying [`crate::WireFormat`] translation BEFORE constructing the
/// `rmcp::model::Tool` if dot→underscore conversion is required.
///
/// `input_schema` is converted from `serde_json::Value::Object` to
/// `Arc<serde_json::Map<String, Value>>`. If the value is not an Object
/// (validation should have caught this earlier), an empty schema is used
/// as a safe fallback rather than panicking at the wire boundary.
impl From<ToolDescriptor> for rmcp::model::Tool {
    fn from(td: ToolDescriptor) -> rmcp::model::Tool {
        let input_schema: std::sync::Arc<serde_json::Map<String, Value>> = match td.input_schema {
            Value::Object(map) => std::sync::Arc::new(map),
            // Defensive fallback: ToolDescriptor validation rejects non-objects,
            // but we prefer a safe empty schema over a runtime panic at the
            // wire boundary.
            _ => {
                tracing::error!(
                    tool_name = %td.name,
                    "ToolDescriptor has non-Object input_schema; emitting empty schema to client (subsystem bug)"
                );
                std::sync::Arc::new(serde_json::Map::new())
            }
        };
        // Use Tool::new() to respect #[non_exhaustive].
        rmcp::model::Tool::new(td.name, td.description, input_schema)
    }
}

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

    #[test]
    fn descriptor_default_timeout_is_5000ms() {
        let d = ToolDescriptor::new("test_tool", "test description", json!({"type": "object"}));
        assert_eq!(d.timeout_ms, DEFAULT_TOOL_TIMEOUT_MS);
        assert_eq!(d.timeout_ms, 5_000);
    }

    #[test]
    fn descriptor_with_timeout_overrides_default() {
        let d = ToolDescriptor::with_timeout(
            "slow_tool",
            "a blocking probe read",
            json!({"type": "object"}),
            30_000,
        );
        assert_eq!(d.timeout_ms, 30_000);
    }

    #[test]
    fn descriptor_builder_timeout_setter_works() {
        let d = ToolDescriptor::new("snapshot", "device snapshot", json!({"type": "object"}))
            .timeout(15_000);
        assert_eq!(d.timeout_ms, 15_000);
    }

    #[test]
    fn try_new_sets_default_timeout() {
        let d = ToolDescriptor::try_new("get_adc", "read ADC", json!({"type": "object"})).unwrap();
        assert_eq!(d.timeout_ms, DEFAULT_TOOL_TIMEOUT_MS);
    }

    #[test]
    fn try_with_timeout_sets_explicit_timeout() {
        let d = ToolDescriptor::try_with_timeout(
            "get_gpio",
            "read GPIO",
            json!({"type": "object"}),
            1_500,
        )
        .unwrap();
        assert_eq!(d.timeout_ms, 1_500);
    }

    #[test]
    fn try_with_timeout_propagates_validation_errors() {
        let err = ToolDescriptor::try_with_timeout(
            "",
            "empty name should fail",
            json!({"type": "object"}),
            5_000,
        )
        .unwrap_err();
        assert!(matches!(err, ToolDescriptorError::EmptyName));
    }
}