jumperless-mcp 0.1.0

MCP server for the Jumperless V5 — persistent USB-serial bridge exposing the firmware API to LLMs
//! The [`Subsystem`] trait — the central abstraction in this substrate.
//!
//! Every MCP server implements this trait. The design is fractal: a federation
//! gateway is itself a `Subsystem` that aggregates other `Subsystem`s. The same
//! shape repeats at every level of the topology.

use crate::base::cli::CommonCli;
use crate::base::errors::{DiscoveryError, McpError};
use crate::base::health::{HealthStatus, SubsystemDescriptor};
use crate::base::tool::ToolDescriptor;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;

/// A Carabiner ring identifier. Kebab-case string, e.g., `"personal-bench"`.
///
/// When a `Subsystem` returns `Some(ring_id)` from [`Subsystem::carabiner_ring`],
/// federation gateways with Carabiner-awareness scope access by ring membership.
///
/// Validated at construction: must be non-empty, lowercase, whitespace-free,
/// and kebab-case (underscores are rejected in favour of hyphens per D9).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RingId(String);

/// Error type for invalid [`RingId`] values.
#[derive(thiserror::Error, Debug)]
pub enum RingIdError {
    #[error("ring id cannot be empty")]
    Empty,
    #[error("ring id cannot contain whitespace")]
    ContainsWhitespace,
    #[error("ring id must be lowercase (got: {0})")]
    NotLowercase(String),
    #[error("ring id must use kebab-case, not underscores (got: {0})")]
    UnderscoreNotKebab(String),
}

impl RingId {
    /// Fallible constructor. Returns an error if `s` violates the kebab-case rules.
    pub fn try_new(s: impl Into<String>) -> Result<Self, RingIdError> {
        let s = s.into();
        if s.is_empty() {
            return Err(RingIdError::Empty);
        }
        if s.chars().any(|c| c.is_whitespace()) {
            return Err(RingIdError::ContainsWhitespace);
        }
        if s != s.to_lowercase() {
            return Err(RingIdError::NotLowercase(s));
        }
        if s.contains('_') {
            return Err(RingIdError::UnderscoreNotKebab(s));
        }
        Ok(Self(s))
    }

    /// Panicking constructor. Panics on invalid input.
    ///
    /// Use [`try_new`](Self::try_new) when the input is not compile-time-known.
    pub fn new(s: impl Into<String>) -> Self {
        Self::try_new(s).expect("invalid RingId — see RingIdError variants")
    }
}

impl std::fmt::Display for RingId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        self.0.fmt(f)
    }
}

impl AsRef<str> for RingId {
    fn as_ref(&self) -> &str {
        &self.0
    }
}

/// Arguments for [`Subsystem::connect`], derived from [`CommonCli`].
///
/// Passed to every subsystem on startup to provide the resolved port override
/// (if any) and other pre-parsed flags.
#[derive(Debug, Clone)]
pub struct ConnectArgs {
    /// Explicit port override from `--port`. `None` = auto-discover.
    pub port_override: Option<String>,
}

impl ConnectArgs {
    /// Build from the standard CLI. Phase 0b.2 may extend this with additional
    /// fields as the CLI grows.
    ///
    /// Returns `Err(McpError::Discovery(DiscoveryError::InvalidPort))` if
    /// `--port ""` (empty string) is passed — omit the flag entirely to
    /// enable auto-discovery.
    pub fn from_cli(cli: &CommonCli) -> Result<Self, McpError> {
        let port_override = match cli.port.as_deref() {
            Some("") => return Err(McpError::Discovery(DiscoveryError::InvalidPort)),
            other => other.map(String::from),
        };
        Ok(ConnectArgs { port_override })
    }
}

/// The core trait every MCP server in this substrate implements.
///
/// Required methods: `name`, `tools`, lifecycle quartet, `invoke`.
/// Default methods: federation hooks (`carabiner_ring`, `describe_self`).
#[async_trait]
pub trait Subsystem: Send + Sync + 'static {
    // ── Identity ─────────────────────────────────────────────────────────────

    /// Stable identifier. Used as the federation tool-name prefix.
    ///
    /// Format: bare lowercase, kebab-case for multi-word (D9).
    /// Examples: `"jumperless"`, `"esp32-p4"`, `"huskylens"`, `"rp2350"`
    fn name(&self) -> &str;

    /// Tool catalog this subsystem exposes.
    ///
    /// Tool names are bare snake_case (D7); federation gateways prefix with
    /// `<name>.` when aggregating (D8/D13). Return `vec![]` until the tool
    /// surface is implemented (Phase 0b.5).
    ///
    /// Returns [`ToolDescriptor`] (stable wrapper) rather than
    /// `rmcp::model::Tool` directly — insulates subsystem authors from rmcp
    /// version churn. The transport layer converts via `Into` when emitting
    /// to clients.
    fn tools(&self) -> Vec<ToolDescriptor>;

    // ── Lifecycle quartet (D5) ────────────────────────────────────────────────

    /// One-time setup: USB discovery, protocol handshake, port open, etc.
    ///
    /// Called by [`super::run`] before the MCP server loop starts.
    async fn connect(&mut self, args: &ConnectArgs) -> Result<(), McpError>;

    /// Graceful teardown: close port, drop connection state, etc.
    ///
    /// Called when the MCP server is shutting down or when the federation
    /// gateway is temporarily detaching this subsystem (e.g., to re-route
    /// during recovery). Must be idempotent — safe to call multiple times.
    async fn disconnect(&mut self) -> Result<(), McpError>;

    /// Periodic liveness check.
    ///
    /// Federation gateways call this on a cadence to update routing decisions.
    /// Should be cheap — no full-protocol roundtrip required. A local state
    /// check or a lightweight ping is sufficient.
    async fn health_check(&self) -> Result<HealthStatus, McpError>;

    /// Terminal cleanup. Called once at process shutdown.
    ///
    /// Differs from [`disconnect`](Subsystem::disconnect): `shutdown` means
    /// "this Subsystem will never be used again," while `disconnect` means
    /// "we're temporarily releasing it." Implementations typically call
    /// `disconnect()` internally then free persistent resources.
    async fn shutdown(&mut self) -> Result<(), McpError>;

    // ── Tool dispatch ─────────────────────────────────────────────────────────

    /// Dispatch a tool invocation.
    ///
    /// `tool_name` is the local (unprefixed) name; the federation router strips
    /// the subsystem prefix before calling this. `args` is the raw JSON
    /// argument object from the MCP request.
    ///
    /// Note: `&self` (not `&mut self`) is intentional — async dispatch over
    /// mutable state requires interior mutability (`Mutex`, `RwLock`,
    /// `AtomicXxx`). This avoids `Pin` issues with `&mut self` across `.await`
    /// points and makes the trait usable through `&dyn Subsystem`.
    async fn invoke(&self, tool_name: &str, args: Value) -> Result<Value, McpError>;

    // ── Federation hooks (default-impl, override if needed) ──────────────────

    /// Crate version of this subsystem MCP.
    ///
    /// Subsystems SHOULD override with `env!("CARGO_PKG_VERSION")` to report
    /// their own crate version. The default `"unknown"` is a safe fallback but
    /// provides no version signal in topology dumps or health reports.
    ///
    /// ```rust,ignore
    /// fn version(&self) -> &str { env!("CARGO_PKG_VERSION") }
    /// ```
    fn version(&self) -> &str {
        "unknown"
    }

    /// Carabiner ring this subsystem belongs to.
    ///
    /// Default `None` = ungated. When `Some(ring_id)`, federation gateways with
    /// Carabiner-awareness scope access by ring membership.
    fn carabiner_ring(&self) -> Option<RingId> {
        None
    }

    /// Structured self-description for gateway introspection.
    ///
    /// Used by gateways to build the aggregated topology view. Override to add
    /// subsystem-specific fields (port, baud, firmware version, etc.) to the
    /// `subsystem_specific` blob of the returned [`HealthStatus`].
    fn describe_self(&self) -> SubsystemDescriptor {
        // TODO Phase 0b.4: McpTransport is hardcoded to Stdio here. When TCP/Ws
        // variants are wired up in transport.rs, this default needs to thread
        // the actual configured transport through. Either as an instance field
        // on Subsystem implementors or a parameter to describe_self.
        SubsystemDescriptor {
            name: self.name().to_string(),
            tool_count: self.tools().len(),
            ring: self.carabiner_ring(),
            transport: crate::base::cli::McpTransport::Stdio,
            version: self.version().to_string(),
        }
    }
}