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).
//! `~/.car/connectors.json` — the persisted connector manifest.
//!
//! Holds only non-secret configuration (slug, display name, endpoint
//! URL, which auth header names exist, and which tools are enabled).
//! Secret header *values* live in the OS keychain via
//! [`crate::secrets`], never in this file.

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::error::ConnectorError;

/// File name under `~/.car/`.
pub const CONNECTORS_FILE: &str = "connectors.json";

/// One configured remote MCP connector.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectorConfig {
    /// Stable routing slug (sanitized from `name`).
    pub slug: String,
    /// Human-readable display name.
    pub name: String,
    /// MCP HTTP-streamable endpoint URL. Empty for stdio connectors.
    #[serde(default)]
    pub url: String,
    /// Stdio (local subprocess) transport. When set, the connector
    /// launches a local MCP server instead of dialing `url`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub stdio: Option<StdioConfig>,
    /// Names of auth headers whose values live in the keychain under
    /// `connector:<slug>:<header>`. Phase 1 static-token auth.
    #[serde(default)]
    pub secret_headers: Vec<String>,
    /// OAuth 2.1 configuration (Phase 2). When set, the connector
    /// authenticates with a bearer token resolved (and refreshed) from
    /// the keychain rather than static headers.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub oauth: Option<OAuthConfig>,
    /// Server-side tool names the user has explicitly enabled. Only
    /// enabled tools receive routes and registry entries.
    #[serde(default)]
    pub enabled_tools: Vec<String>,
}

/// Stdio (local subprocess) transport for a connector. Launches a
/// local MCP server over stdin/stdout — the same transport
/// `car_engine::McpServer` drives.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StdioConfig {
    /// Executable to launch.
    pub command: String,
    /// Arguments passed to the command.
    #[serde(default)]
    pub args: Vec<String>,
    /// Extra environment variables for the subprocess.
    #[serde(default)]
    pub env: std::collections::BTreeMap<String, String>,
}

/// OAuth 2.1 configuration for a connector. Non-secret only — token
/// material and any issued `client_secret` live in the keychain.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthConfig {
    /// Token endpoint, used for refresh.
    pub token_endpoint: String,
    /// Registered (dynamic) client id.
    pub client_id: String,
    /// True when the server issued a `client_secret` (stored in the
    /// keychain under `connector:<slug>:client_secret`). Public clients
    /// leave this false.
    #[serde(default)]
    pub has_client_secret: bool,
    /// RFC 8707 resource indicator the tokens are bound to.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resource: Option<String>,
    /// Scopes requested at authorization time.
    #[serde(default)]
    pub scopes: Vec<String>,
}

/// The whole `connectors.json` document.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ConnectorsFile {
    #[serde(default)]
    pub connectors: Vec<ConnectorConfig>,
}

/// Default path: `~/.car/connectors.json` (or `%USERPROFILE%` on
/// Windows).
pub fn connectors_path() -> Result<PathBuf, ConnectorError> {
    let home = std::env::var_os("HOME")
        .or_else(|| std::env::var_os("USERPROFILE"))
        .ok_or_else(|| ConnectorError::Io("cannot determine home directory".into()))?;
    Ok(PathBuf::from(home).join(".car").join(CONNECTORS_FILE))
}

/// Read and parse the manifest, treating a missing file as empty.
pub fn load_from(path: &Path) -> Result<ConnectorsFile, ConnectorError> {
    match std::fs::read_to_string(path) {
        Ok(s) => serde_json::from_str(&s)
            .map_err(|e| ConnectorError::Io(format!("parse {}: {e}", path.display()))),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ConnectorsFile::default()),
        Err(e) => Err(ConnectorError::Io(format!("read {}: {e}", path.display()))),
    }
}

/// Serialize and write the manifest, creating parent directories.
pub fn save_to(path: &Path, file: &ConnectorsFile) -> Result<(), ConnectorError> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)
            .map_err(|e| ConnectorError::Io(format!("create {}: {e}", parent.display())))?;
    }
    let s = serde_json::to_string_pretty(file).map_err(|e| ConnectorError::Io(e.to_string()))?;
    std::fs::write(path, s).map_err(|e| ConnectorError::Io(format!("write {}: {e}", path.display())))
}

// ---- Team-shareable project config (Phase 4) ------------------------

/// One entry in a `.car/connectors.toml`. Secret-free by construction:
/// a server URL (or a local command), never credentials. Each team
/// member authorizes interactively; tokens stay in their own keychain.
#[derive(Debug, Clone, Deserialize)]
struct TeamConnector {
    name: String,
    #[serde(default)]
    url: Option<String>,
    #[serde(default)]
    command: Option<String>,
    #[serde(default)]
    args: Vec<String>,
}

#[derive(Debug, Clone, Default, Deserialize)]
struct TeamConnectorsFile {
    #[serde(default)]
    connector: Vec<TeamConnector>,
}

/// Find the nearest `.car/connectors.toml`, walking up from the current
/// directory (the same discovery shape as the `.car/` project dir).
pub fn team_connectors_path() -> Option<PathBuf> {
    let mut dir = std::env::current_dir().ok()?;
    loop {
        let candidate = dir.join(".car").join("connectors.toml");
        if candidate.is_file() {
            return Some(candidate);
        }
        if !dir.pop() {
            return None;
        }
    }
}

/// Load team-shareable connector definitions from the nearest
/// `.car/connectors.toml`. Returns an empty vec if none is found or it
/// can't be parsed (project config is best-effort — a malformed team
/// file must not break a user's own connectors). An entry with a
/// `command` is a stdio connector; otherwise it must carry a `url`.
pub fn load_team_connectors() -> Vec<ConnectorConfig> {
    let Some(path) = team_connectors_path() else {
        return Vec::new();
    };
    let Ok(text) = std::fs::read_to_string(&path) else {
        return Vec::new();
    };
    match parse_team_connectors(&text) {
        Ok(configs) => configs,
        Err(e) => {
            tracing::warn!("ignoring malformed {}: {e}", path.display());
            Vec::new()
        }
    }
}

/// Parse `.car/connectors.toml` text into connector configs (pure;
/// testable without touching the filesystem or cwd).
fn parse_team_connectors(text: &str) -> Result<Vec<ConnectorConfig>, toml::de::Error> {
    let parsed: TeamConnectorsFile = toml::from_str(text)?;
    Ok(parsed
        .connector
        .into_iter()
        .filter_map(|c| {
            let slug = crate::schema::slugify(&c.name);
            if let Some(command) = c.command {
                Some(ConnectorConfig {
                    slug,
                    name: c.name,
                    url: String::new(),
                    stdio: Some(StdioConfig {
                        command,
                        args: c.args,
                        env: std::collections::BTreeMap::new(),
                    }),
                    secret_headers: Vec::new(),
                    oauth: None,
                    enabled_tools: Vec::new(),
                })
            } else {
                c.url.map(|url| ConnectorConfig {
                    slug,
                    name: c.name,
                    url,
                    stdio: None,
                    secret_headers: Vec::new(),
                    oauth: None,
                    enabled_tools: Vec::new(),
                })
            }
        })
        .collect())
}

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

    #[test]
    fn missing_file_loads_empty() {
        let path = std::env::temp_dir().join("car-connectors-does-not-exist-xyz.json");
        let _ = std::fs::remove_file(&path);
        let file = load_from(&path).unwrap();
        assert!(file.connectors.is_empty());
    }

    #[test]
    fn parses_team_connectors_http_and_stdio() {
        let toml_text = r#"
            [[connector]]
            name = "Acme Docs"
            url = "https://mcp.acme.dev/docs"

            [[connector]]
            name = "Local FS"
            command = "npx"
            args = ["-y", "@mcp/server-filesystem", "/srv"]

            [[connector]]
            name = "Bad — no transport"
        "#;
        let configs = parse_team_connectors(toml_text).unwrap();
        // The transport-less entry is dropped.
        assert_eq!(configs.len(), 2);

        let acme = &configs[0];
        assert_eq!(acme.slug, "acme_docs");
        assert_eq!(acme.url, "https://mcp.acme.dev/docs");
        assert!(acme.stdio.is_none());

        let fs = &configs[1];
        assert_eq!(fs.slug, "local_fs");
        assert!(fs.url.is_empty());
        let stdio = fs.stdio.as_ref().unwrap();
        assert_eq!(stdio.command, "npx");
        assert_eq!(stdio.args, ["-y", "@mcp/server-filesystem", "/srv"]);
    }

    #[test]
    fn roundtrip_preserves_config() {
        let dir = std::env::temp_dir().join("car-connectors-test-roundtrip");
        let path = dir.join(CONNECTORS_FILE);
        let _ = std::fs::remove_dir_all(&dir);
        let file = ConnectorsFile {
            connectors: vec![ConnectorConfig {
                slug: "github".into(),
                name: "GitHub".into(),
                url: "https://mcp.example/github".into(),
                stdio: None,
                secret_headers: vec!["Authorization".into()],
                oauth: None,
                enabled_tools: vec!["create_issue".into()],
            }],
        };
        save_to(&path, &file).unwrap();
        let loaded = load_from(&path).unwrap();
        assert_eq!(loaded.connectors.len(), 1);
        assert_eq!(loaded.connectors[0].slug, "github");
        assert_eq!(loaded.connectors[0].secret_headers, vec!["Authorization"]);
        assert_eq!(loaded.connectors[0].enabled_tools, vec!["create_issue"]);
        let _ = std::fs::remove_dir_all(&dir);
    }
}