car-connectors 0.25.0

Remote MCP connectors for the Common Agent Runtime — connect to remote MCP servers over HTTP, register their tools, and route calls through CAR's governance layer (validator, policy, eventlog).
//! Connector naming + MCP-tool → CAR-tool translation.
//!
//! Uses the engine's existing MCP naming convention
//! (`mcp_{server}_{tool}`) so connector tools route through the same
//! [`car_engine::McpToolExecutor`] dispatch path as stdio MCP tools.

use car_engine::{McpToolInfo, ToolEntry, ToolPermission, ToolSource};
use car_ir::ToolSchema;
use serde_json::json;

/// Canonical, model-visible name for a connector tool. The engine's
/// dispatch recovers the bare server-side name by stripping the
/// `mcp_{slug}_` prefix, so this must agree with that scheme.
pub fn canonical_tool_name(slug: &str, tool: &str) -> String {
    format!("mcp_{slug}_{tool}")
}

/// Sanitize a connector display name into a routing slug: lowercase
/// ASCII alphanumerics, every other run collapsed to a single `_`, with
/// leading/trailing underscores trimmed. Empty input yields
/// `"connector"`.
pub fn slugify(name: &str) -> String {
    let mut s = String::new();
    let mut last_us = false;
    for ch in name.chars() {
        if ch.is_ascii_alphanumeric() {
            s.push(ch.to_ascii_lowercase());
            last_us = false;
        } else if !last_us {
            s.push('_');
            last_us = true;
        }
    }
    let trimmed = s.trim_matches('_');
    if trimmed.is_empty() {
        "connector".to_string()
    } else {
        trimmed.to_string()
    }
}

/// Build a registry [`ToolEntry`] for an **enabled** connector tool.
/// Permission is `Allow` — a tool only reaches registration once the
/// user has enabled it; until then it is neither routed nor registered.
/// `source` records the connector slug so the eventlog attributes every
/// call to its origin connector.
pub fn tool_entry(slug: &str, tool: &McpToolInfo) -> ToolEntry {
    let schema = ToolSchema {
        name: canonical_tool_name(slug, &tool.name),
        description: tool.description.clone().unwrap_or_default(),
        parameters: tool
            .input_schema
            .clone()
            .unwrap_or_else(|| json!({"type": "object"})),
        returns: None,
        idempotent: false,
        cache_ttl_secs: None,
        rate_limit: None,
    };
    ToolEntry::new(schema)
        .with_source(ToolSource::Mcp {
            server_name: slug.to_string(),
        })
        .with_permission(ToolPermission::Allow)
}

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

    #[test]
    fn slugify_collapses_and_lowercases() {
        assert_eq!(slugify("GitHub Issues!"), "github_issues");
        assert_eq!(slugify("  spaced  out  "), "spaced_out");
        assert_eq!(slugify("Acme/Linear"), "acme_linear");
        assert_eq!(slugify("***"), "connector");
        assert_eq!(slugify("Notion"), "notion");
    }

    #[test]
    fn canonical_name_matches_engine_scheme() {
        assert_eq!(
            canonical_tool_name("github", "create_issue"),
            "mcp_github_create_issue"
        );
    }

    #[test]
    fn tool_entry_carries_schema_and_source() {
        let info = McpToolInfo {
            name: "create_issue".into(),
            description: Some("Open an issue".into()),
            input_schema: Some(json!({"type": "object", "properties": {"title": {"type": "string"}}})),
        };
        let entry = tool_entry("github", &info);
        assert_eq!(entry.schema.name, "mcp_github_create_issue");
        assert_eq!(entry.schema.description, "Open an issue");
        assert_eq!(entry.schema.parameters["properties"]["title"]["type"], "string");
        assert_eq!(
            entry.source,
            ToolSource::Mcp {
                server_name: "github".into()
            }
        );
    }
}