jumperless-mcp 0.1.0

MCP server for the Jumperless V5 — persistent USB-serial bridge exposing the firmware API to LLMs
//! Wire-format translation for federated tool names (D15).
//!
//! Internal naming uses dots as namespace separators:
//!   `jumperless.connect`, `bench.jumperless.dac_set`
//!
//! The MCP wire format is pluggable. Default is [`WireFormat::UnderscoreSeparated`]
//! (safe across all known clients). Power-users running private deployments can
//! switch to [`WireFormat::DotSeparated`] once the MCP spec stabilises across all
//! clients. Custom translators are supported via [`WireFormat::Custom`].
//!
//! # Subsystem-name constraint (D9)
//! Multi-word subsystem names MUST use kebab-case (`esp32-p4`, `huskylens`,
//! `rp2350`) — NOT underscores. This prevents ambiguity when
//! `UnderscoreSeparated` reverse-translates `esp32_p4_some_tool`: it cannot
//! distinguish `subsystem=esp32_p4` / `tool=some_tool` from
//! `subsystem=esp32` / `tool=p4_some_tool`. Hyphens never appear in the
//! dot→underscore translation, so they are unambiguous as separators.

/// Translation policy for federated tool names on the MCP wire.
///
/// Describes the OUTBOUND direction only (internal → wire). Inbound translation
/// (wire → internal) is handled by [`crate::base::federation::FederationAggregator`]'s
/// mapping table, which is built at connect time from the registered tool catalogs.
/// This eliminates the need for a heuristic reverse-lookup entirely.
#[derive(Default)]
pub enum WireFormat {
    /// **Default.** Translate dot→underscore when emitting to clients.
    /// Safe across all known MCP clients (Claude Desktop, Windsurf,
    /// GitHub Copilot CLI, Anthropic API). Internal logic retains dots.
    #[default]
    UnderscoreSeparated,

    /// Power-user/future-facing. Emit tool names as-is (dots included).
    /// May break against Claude Desktop < 2025-Q4, Windsurf, GitHub Copilot CLI.
    /// Use only when you control all clients (private deployments).
    DotSeparated,

    /// Niche / custom translation. Implement [`WireNameTranslator`] for
    /// non-standard conventions.
    Custom(Box<dyn WireNameTranslator + Send + Sync>),
}

impl WireFormat {
    /// Translate an internal dotted name to the wire name for emission to clients.
    ///
    /// Example (UnderscoreSeparated): `"jumperless.connect"` → `"jumperless_connect"`
    pub fn outbound(&self, internal: &str) -> String {
        match self {
            WireFormat::UnderscoreSeparated => DotToUnderscoreTranslator.outbound(internal),
            WireFormat::DotSeparated => IdentityTranslator.outbound(internal),
            WireFormat::Custom(t) => t.outbound(internal),
        }
    }
}

/// Strategy for translating tool names from internal dotted form to wire form.
///
/// Only the OUTBOUND direction is part of the trait contract. The inbound
/// direction (wire → internal) is owned by [`crate::base::federation::FederationAggregator`]
/// via a pre-built mapping table. This makes the trait simpler and eliminates
/// the empty-tool-segment edge case that existed in the old heuristic-based inbound.
///
/// Implement this for custom outbound conventions. Box it into [`WireFormat::Custom`].
pub trait WireNameTranslator: Send + Sync {
    /// Internal dotted name → wire name.
    ///
    /// Example: `"jumperless.connect"` → `"jumperless_connect"` (underscore variant)
    fn outbound(&self, internal: &str) -> String;
}

/// Built-in translator for [`WireFormat::UnderscoreSeparated`].
///
/// Replaces all `.` with `_` on outbound.
pub struct DotToUnderscoreTranslator;

impl WireNameTranslator for DotToUnderscoreTranslator {
    fn outbound(&self, internal: &str) -> String {
        internal.replace('.', "_")
    }
}

/// Built-in translator for [`WireFormat::DotSeparated`] — identity (no change).
struct IdentityTranslator;

impl WireNameTranslator for IdentityTranslator {
    fn outbound(&self, internal: &str) -> String {
        internal.to_string()
    }
}

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

    #[test]
    fn underscore_outbound_replaces_dots() {
        let fmt = WireFormat::UnderscoreSeparated;
        assert_eq!(fmt.outbound("jumperless.connect"), "jumperless_connect");
        assert_eq!(
            fmt.outbound("bench.jumperless.dac_set"),
            "bench_jumperless_dac_set"
        );
    }

    #[test]
    fn dot_separated_is_identity() {
        let fmt = WireFormat::DotSeparated;
        assert_eq!(fmt.outbound("jumperless.connect"), "jumperless.connect");
    }

    /// F13: overlapping subsystem name prefixes produce distinct wire names
    /// because D9 mandates kebab-case for multi-word subsystem names.
    ///
    /// `esp32` (subsystem) + `p4_dac_set` (tool)  →  wire `esp32_p4_dac_set`
    /// `esp32-p4` (subsystem) + `dac_set` (tool)  →  wire `esp32-p4_dac_set`
    ///
    /// These are different wire names — no ambiguity — because the kebab hyphen
    /// never appears in the dot→underscore translation.
    #[test]
    fn overlapping_prefix_kebab_produces_distinct_wire_names() {
        let fmt = WireFormat::UnderscoreSeparated;

        // esp32 subsystem, tool p4_dac_set
        let wire_esp32 = fmt.outbound("esp32.p4_dac_set");
        // esp32-p4 subsystem, tool dac_set
        let wire_esp32_p4 = fmt.outbound("esp32-p4.dac_set");

        assert_eq!(wire_esp32, "esp32_p4_dac_set");
        assert_eq!(wire_esp32_p4, "esp32-p4_dac_set");
        // The wire names are different — kebab hyphen disambiguates.
        assert_ne!(wire_esp32, wire_esp32_p4);
    }
}