jumperless-mcp 0.1.0

MCP server for the Jumperless V5 — persistent USB-serial bridge exposing the firmware API to LLMs
//! Deferred-loading tool discovery.
//!
//! Three list modes + capability negotiation following MCP spec conventions
//! for vendor extensions under `experimental`; keyword search v1 with a hook
//! for semantic search v2.

/// Controls which tools are returned by `tools/list`.
///
/// Mirrors Python toolrack's three modes. Default selection is driven by
/// [`determine_default_mode`] based on the client's declared capabilities.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ListMode {
    /// All tools with full JSON schemas. Default for non-deferred clients.
    #[default]
    Full,
    /// Only discovery meta-tools (`ygg.tools.search`, `ygg.tools.describe_many`).
    /// Default for clients that declare `x-deferred-tools` in MCP initialize.
    Discovery,
    /// All tool names + short descriptions, no schemas. Intermediate budget option.
    Names,
}

/// A ranked tool search result.
#[derive(Debug, Clone)]
pub struct ToolHit {
    /// Fully-qualified internal tool name (e.g., `"jumperless.connect"`).
    pub name: String,
    /// Short description from the tool schema.
    pub description: String,
    /// Relevance score in `[0.0, 1.0]`. Higher = more relevant.
    pub score: f32,
}

/// Scope for federation-aware tool search.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
    /// Search only the local subsystem's tools.
    Local,
    /// Search all subsystems in the federation (recurse).
    Federation,
}

/// Env var name for forcing deferred mode regardless of client capabilities.
///
/// Transitional bridge for clients that don't yet support `x-deferred-tools`
/// (e.g. stock Claude Code, OpenCode Go). Allowlist: `"1"`, `"true"`, `"yes"`,
/// `"on"` (case-insensitive). Once those clients adopt the capability, this env
/// var becomes redundant.
///
/// Canonical definition lives here so both [`determine_default_mode`] and the
/// transport module's `detect_list_mode` share a single source of truth.
pub(crate) const FORCE_DEFERRED_ENV_VAR: &str = "YGG_MCP_FORCE_DEFERRED";

/// Returns `true` if [`FORCE_DEFERRED_ENV_VAR`] is set to a truthy value.
pub(crate) fn force_deferred_via_env() -> bool {
    std::env::var(FORCE_DEFERRED_ENV_VAR)
        .map(|v| {
            matches!(
                v.trim().to_lowercase().as_str(),
                "1" | "true" | "yes" | "on"
            )
        })
        .unwrap_or(false)
}

/// Determine default list mode from client capabilities.
///
/// Returns [`ListMode::Discovery`] when the client has declared the
/// `x-deferred-tools` capability in its MCP initialize handshake under
/// `client_capabilities.experimental`, per the MCP spec convention for
/// non-standard vendor extensions. Returns [`ListMode::Full`] otherwise.
///
/// The [`FORCE_DEFERRED_ENV_VAR`] override is also applied here — if the env
/// var is set to a truthy value, the result is [`ListMode::Discovery`] even
/// when the client has not declared `x-deferred-tools`.
///
/// The `transport` module's `detect_list_mode` uses the same env-var check
/// (via [`force_deferred_via_env`]) to keep both paths consistent.
pub fn determine_default_mode(client_capabilities: &rmcp::model::ClientCapabilities) -> ListMode {
    let client_opted_in = client_capabilities
        .experimental
        .as_ref()
        .map(|exp| exp.contains_key("x-deferred-tools"))
        .unwrap_or(false);
    let forced = force_deferred_via_env();
    let deferred = client_opted_in || forced;
    if deferred {
        ListMode::Discovery
    } else {
        ListMode::Full
    }
}

/// Strategy for tool search. V1: keyword matching. V2: embedding-based semantic search.
///
/// Swap out the default keyword implementation with a semantic one once
/// local embedding inference is available on bench-brain.
pub trait SearchEngine: Send + Sync {
    /// Rank `tools` by relevance to `query`. Returns hits in descending score order.
    fn search(&self, query: &str, tools: &[ToolHit]) -> Vec<ToolHit>;
}

/// V1 keyword search engine. Case-insensitive substring match on name + description.
pub struct KeywordSearchEngine;

impl SearchEngine for KeywordSearchEngine {
    fn search(&self, query: &str, tools: &[ToolHit]) -> Vec<ToolHit> {
        let q = query.to_lowercase();
        let mut hits: Vec<ToolHit> = tools
            .iter()
            .filter(|t| {
                t.name.to_lowercase().contains(&q) || t.description.to_lowercase().contains(&q)
            })
            .cloned()
            .collect();
        // Prefer name matches over description matches.
        hits.sort_by(|a, b| {
            let a_name = a.name.to_lowercase().contains(&q);
            let b_name = b.name.to_lowercase().contains(&q);
            b_name.cmp(&a_name).then(a.name.cmp(&b.name))
        });
        hits
    }
}