car-connectors 0.24.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).
//! AgentDNS-style service naming — the `agentdns://organization/category/name`
//! scheme ([arXiv:2505.22368], design in
//! `docs/proposals/agentdns-discovery-layer.md`).
//!
//! A [`ServiceIdentifier`] is the DNS-analogous name a discovery resolver maps
//! a natural-language need onto: an `organization` (the registering entity —
//! `local` for CAR's own agents), a nested `category` (functional domain, e.g.
//! `agent`, or `academic/nlp/summarization`), and a `name` (the unique service
//! within that org+category). The resolver returns these alongside protocol and
//! reachability metadata, so a caller can resolve → select → invoke across
//! vendors under one namespace.
//!
//! This module holds the pure naming primitive ([`ServiceIdentifier`]) and the
//! client for a **remote AgentDNS root server** ([`RemoteRoot`]) — the
//! cross-vendor resolver. The local resolver (over CAR's own registered
//! services) lives in `car-server-core`; the remote root is a separate service
//! (Parslee) that both resolvers feed into one ranked result. The contract the
//! root implements is documented in `docs/agentdns-root-contract.md`.

use serde::{Deserialize, Serialize};
use std::fmt;

/// The `agentdns://` URI scheme prefix.
pub const SCHEME: &str = "agentdns://";

/// The organization segment used for CAR's own in-daemon services.
pub const LOCAL_ORG: &str = "local";

/// A parsed AgentDNS service identifier.
///
/// `agentdns://org/cat1/cat2/name` parses to `organization = "org"`,
/// `category = ["cat1", "cat2"]`, `name = "name"`. The category may be empty
/// (`agentdns://org/name`). Round-trips with [`fmt::Display`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServiceIdentifier {
    pub organization: String,
    pub category: Vec<String>,
    pub name: String,
}

/// A path segment is valid when it is non-empty and made only of ASCII
/// alphanumerics, `-`, `_`, or `.`. This is a superset of the registry's
/// filename-safe agent-id charset (`car_registry` `declarative::is_filename_safe`
/// = alnum + `-` + `_` + `.`), so every registered declarative agent is
/// representable as an identifier — discovery never silently drops a routable
/// agent. `/` stays excluded so it remains an unambiguous segment delimiter and
/// the `agentdns://` scheme stays parseable.
fn validate_segment(seg: &str, what: &str) -> Result<(), String> {
    if seg.is_empty() {
        return Err(format!("{what} segment is empty"));
    }
    if let Some(bad) = seg
        .chars()
        .find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_' || *c == '.'))
    {
        return Err(format!("{what} segment '{seg}' has invalid character '{bad}'"));
    }
    Ok(())
}

impl ServiceIdentifier {
    /// Build and validate an identifier from its parts.
    pub fn new(
        organization: impl Into<String>,
        category: impl IntoIterator<Item = String>,
        name: impl Into<String>,
    ) -> Result<Self, String> {
        let organization = organization.into();
        let category: Vec<String> = category.into_iter().collect();
        let name = name.into();
        validate_segment(&organization, "organization")?;
        for c in &category {
            validate_segment(c, "category")?;
        }
        validate_segment(&name, "name")?;
        Ok(Self { organization, category, name })
    }

    /// A CAR-local service identifier (`organization = "local"`).
    pub fn local(category: impl Into<String>, name: impl Into<String>) -> Result<Self, String> {
        Self::new(LOCAL_ORG, std::iter::once(category.into()), name)
    }

    /// Parse an `agentdns://org/[category…/]name` string.
    pub fn parse(s: &str) -> Result<Self, String> {
        let rest = s
            .strip_prefix(SCHEME)
            .ok_or_else(|| format!("missing '{SCHEME}' scheme in '{s}'"))?;
        let segments: Vec<&str> = rest.split('/').collect();
        // Need at least an organization and a name.
        if segments.len() < 2 {
            return Err(format!(
                "'{s}' needs at least organization and name (agentdns://org/name)"
            ));
        }
        let organization = segments[0];
        let name = segments[segments.len() - 1];
        let category: Vec<String> = segments[1..segments.len() - 1]
            .iter()
            .map(|s| s.to_string())
            .collect();
        Self::new(organization, category, name)
    }
}

impl fmt::Display for ServiceIdentifier {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{SCHEME}{}", self.organization)?;
        for c in &self.category {
            write!(f, "/{c}")?;
        }
        write!(f, "/{}", self.name)
    }
}

// --- Remote AgentDNS root client -------------------------------------------
//
// CAR is the *client* of a remote root server (Parslee) that holds a
// cross-vendor service registry and does the semantic search. CAR POSTs a need,
// the root returns candidate service records, and CAR folds them into its own
// ranking. The full contract — endpoints, schemas, auth, errors, versioning —
// is `docs/agentdns-root-contract.md`. This is the executable client side of
// that contract; the server side lives in the Parslee backend.

/// One service record returned by the root's resolve endpoint. CAR embeds
/// `description` and re-ranks it locally alongside its own services, so remote
/// and local candidates compete on one uniform score (the root's own ordering
/// is advisory). `endpoint`/`pricing` are carried through for the caller.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RemoteServiceRecord {
    /// `agentdns://organization/category/name` — validated by the caller.
    pub identifier: String,
    pub name: String,
    /// Vendor-defined service kind (e.g. `agent`, `tool`, `a2a`).
    pub kind: String,
    /// Reachability protocol (e.g. `a2a`, `mcp`, `http`).
    pub protocol: String,
    /// Capability description CAR embeds for ranking.
    #[serde(default)]
    pub description: String,
    /// Advisory price string; the root meters and bills.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pricing: Option<String>,
    /// Concrete endpoint to invoke (the root's proxy address), when the root
    /// exposes one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub endpoint: Option<String>,
}

/// Response body of `POST /agentdns/resolve`.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct RemoteResolveResponse {
    #[serde(default)]
    pub services: Vec<RemoteServiceRecord>,
}

/// Client for a remote AgentDNS root server. Construct with the root base URL
/// and an optional bearer token (the Parslee access token when signed in).
pub struct RemoteRoot {
    base_url: String,
    auth_token: Option<String>,
    http: reqwest::Client,
}

impl RemoteRoot {
    pub fn new(base_url: impl Into<String>, auth_token: Option<String>) -> Self {
        // Own the security-relevant client policy rather than inherit defaults:
        // a hard request/connect timeout so a slow root reclaims its socket
        // (not just abandoned by the caller's outer timeout), and NO redirect
        // following so a bearer can never ride a redirect to another origin.
        let http = reqwest::Client::builder()
            .timeout(std::time::Duration::from_secs(4))
            .connect_timeout(std::time::Duration::from_secs(2))
            .redirect(reqwest::redirect::Policy::none())
            .build()
            .unwrap_or_else(|_| reqwest::Client::new());
        Self {
            base_url: base_url.into().trim_end_matches('/').to_string(),
            auth_token,
            http,
        }
    }

    /// The resolve endpoint URL.
    pub fn resolve_endpoint(&self) -> String {
        format!("{}/agentdns/resolve", self.base_url)
    }

    /// Resolve a need against the root's cross-vendor registry. Returns the raw
    /// service records; the caller validates identifiers and ranks them.
    pub async fn resolve(&self, need: &str, limit: usize) -> Result<Vec<RemoteServiceRecord>, String> {
        let mut req = self
            .http
            .post(self.resolve_endpoint())
            .json(&resolve_request_body(need, limit));
        if let Some(token) = &self.auth_token {
            req = req.bearer_auth(token);
        }
        let resp = req
            .send()
            .await
            .map_err(|e| format!("agentdns root request failed: {e}"))?;
        let status = resp.status();
        if !status.is_success() {
            return Err(format!("agentdns root returned HTTP {status}"));
        }
        let parsed: RemoteResolveResponse = resp
            .json()
            .await
            .map_err(|e| format!("agentdns root response: {e}"))?;
        Ok(parsed.services)
    }
}

/// The `POST /agentdns/resolve` request body. Factored out so it's testable
/// without a live root.
fn resolve_request_body(need: &str, limit: usize) -> serde_json::Value {
    serde_json::json!({ "need": need, "limit": limit })
}

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

    #[test]
    fn roundtrips_with_nested_category() {
        let s = "agentdns://acme/academic/nlp/summarize";
        let id = ServiceIdentifier::parse(s).unwrap();
        assert_eq!(id.organization, "acme");
        assert_eq!(id.category, vec!["academic", "nlp"]);
        assert_eq!(id.name, "summarize");
        assert_eq!(id.to_string(), s);
    }

    #[test]
    fn roundtrips_without_category() {
        let s = "agentdns://local/greeter";
        let id = ServiceIdentifier::parse(s).unwrap();
        assert!(id.category.is_empty());
        assert_eq!(id.name, "greeter");
        assert_eq!(id.to_string(), s);
    }

    #[test]
    fn local_helper_builds_local_org() {
        let id = ServiceIdentifier::local("agent", "greeter-bot").unwrap();
        assert_eq!(id.to_string(), "agentdns://local/agent/greeter-bot");
    }

    #[test]
    fn rejects_missing_scheme() {
        assert!(ServiceIdentifier::parse("acme/agent/x").is_err());
        assert!(ServiceIdentifier::parse("http://acme/agent/x").is_err());
    }

    #[test]
    fn rejects_too_few_segments() {
        assert!(ServiceIdentifier::parse("agentdns://onlyorg").is_err());
    }

    #[test]
    fn rejects_empty_and_invalid_segments() {
        assert!(ServiceIdentifier::parse("agentdns://acme//name").is_err()); // empty category
        assert!(ServiceIdentifier::parse("agentdns://acme/na me").is_err()); // space
        assert!(ServiceIdentifier::parse("agentdns://acme/na/me!").is_err()); // bang
        assert!(ServiceIdentifier::new("", std::iter::empty(), "x").is_err());
    }

    #[test]
    fn remote_root_endpoint_trims_base_slash() {
        let root = RemoteRoot::new("https://api.parslee.ai/", None);
        assert_eq!(root.resolve_endpoint(), "https://api.parslee.ai/agentdns/resolve");
    }

    #[test]
    fn resolve_request_body_shape() {
        let body = resolve_request_body("summarize a pdf", 5);
        assert_eq!(body["need"], "summarize a pdf");
        assert_eq!(body["limit"], 5);
    }

    #[test]
    fn remote_response_deserializes_with_optional_fields() {
        let json = r#"{
            "services": [
                { "identifier": "agentdns://acme/tool/summarize", "name": "Summarizer",
                  "kind": "tool", "protocol": "mcp", "description": "Summarizes text",
                  "pricing": "$0.01/call", "endpoint": "https://acme.example/mcp" },
                { "identifier": "agentdns://acme/agent/writer", "name": "Writer",
                  "kind": "agent", "protocol": "a2a" }
            ]
        }"#;
        let parsed: RemoteResolveResponse = serde_json::from_str(json).unwrap();
        assert_eq!(parsed.services.len(), 2);
        assert_eq!(parsed.services[0].pricing.as_deref(), Some("$0.01/call"));
        // Missing optional fields default cleanly.
        assert_eq!(parsed.services[1].description, "");
        assert_eq!(parsed.services[1].endpoint, None);
    }

    #[test]
    fn empty_remote_response_is_default() {
        let parsed: RemoteResolveResponse = serde_json::from_str("{}").unwrap();
        assert!(parsed.services.is_empty());
    }

    #[test]
    fn accepts_dotted_name_matching_registry_charset() {
        // Declarative agent ids are filename-safe (may contain `.`); they must
        // round-trip as identifiers so discovery never drops a routable agent.
        let s = "agentdns://local/agent/my.bot.v2";
        let id = ServiceIdentifier::parse(s).unwrap();
        assert_eq!(id.name, "my.bot.v2");
        assert_eq!(id.to_string(), s);
        assert!(ServiceIdentifier::local("agent", "my.bot.v2").is_ok());
    }
}