acp-runtime 0.1.2

Rust SDK for the Agent Communication Protocol (ACP)
Documentation
// Copyright 2026 ACP Project
// Licensed under the Apache License, Version 2.0
// See LICENSE file for details.

use serde_json::{Map, Value};
use url::Url;

use crate::constants::{ACP_VERSION, DEFAULT_IDENTITY_DOCUMENT_PATH};
use crate::errors::{AcpError, AcpResult};
use crate::identity::parse_agent_id;

pub const WELL_KNOWN_PATH: &str = "/.well-known/acp";
pub const SUPPORTED_WELL_KNOWN_VERSION: &str = ACP_VERSION;
pub const SUPPORTED_SECURITY_PROFILES: &[&str] = &["http", "https", "mtls", "https+mtls"];

pub fn well_known_url_from_base(base_url: &str) -> AcpResult<String> {
    let normalized = base_url.trim();
    if normalized.is_empty() {
        return Err(AcpError::Validation("base_url is required".to_string()));
    }
    if normalized.ends_with(WELL_KNOWN_PATH) {
        return Ok(normalized.to_string());
    }
    Ok(format!(
        "{}{}",
        normalized.trim_end_matches('/'),
        WELL_KNOWN_PATH
    ))
}

pub fn identity_document_url_from_base(base_url: &str) -> AcpResult<String> {
    let normalized = base_url.trim();
    if normalized.is_empty() {
        return Err(AcpError::Validation("base_url is required".to_string()));
    }
    Ok(format!(
        "{}{}",
        normalized.trim_end_matches('/'),
        DEFAULT_IDENTITY_DOCUMENT_PATH
    ))
}

pub fn build_well_known_document(
    identity_document: &Map<String, Value>,
    base_url: &str,
    identity_document_url: Option<&str>,
    version: Option<&str>,
) -> AcpResult<Map<String, Value>> {
    let Some(agent_id) = identity_document
        .get("agent_id")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|v| !v.is_empty())
    else {
        return Err(AcpError::Validation(
            "identity_document.agent_id is required".to_string(),
        ));
    };

    let service = identity_document
        .get("service")
        .and_then(Value::as_object)
        .cloned()
        .unwrap_or_default();
    let capabilities = identity_document
        .get("capabilities")
        .and_then(Value::as_object)
        .cloned()
        .unwrap_or_default();

    let mut transports = Map::new();
    if let Some(direct_endpoint) = service
        .get("direct_endpoint")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|v| !v.is_empty())
    {
        let mut http = Map::new();
        http.insert(
            "endpoint".to_string(),
            Value::String(direct_endpoint.to_string()),
        );
        if let Some(profile) = service
            .get("http")
            .and_then(Value::as_object)
            .and_then(|http| http.get("security_profile"))
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|v| !v.is_empty())
        {
            http.insert(
                "security_profile".to_string(),
                Value::String(profile.to_string()),
            );
        }
        transports.insert("http".to_string(), Value::Object(http));
    }
    if let Some(relay_hints) = service.get("relay_hints").and_then(Value::as_array) {
        let endpoints = relay_hints
            .iter()
            .filter_map(Value::as_str)
            .map(str::trim)
            .filter(|v| !v.is_empty())
            .map(str::to_string)
            .collect::<Vec<_>>();
        if !endpoints.is_empty() {
            let mut relay = Map::new();
            relay.insert("endpoint".to_string(), Value::String(endpoints[0].clone()));
            if let Some(profile) = service
                .get("relay")
                .and_then(Value::as_object)
                .and_then(|relay| relay.get("security_profile"))
                .and_then(Value::as_str)
                .map(str::trim)
                .filter(|v| !v.is_empty())
            {
                relay.insert(
                    "security_profile".to_string(),
                    Value::String(profile.to_string()),
                );
            }
            if endpoints.len() > 1 {
                relay.insert(
                    "hints".to_string(),
                    Value::Array(endpoints.into_iter().map(Value::String).collect()),
                );
            }
            transports.insert("relay".to_string(), Value::Object(relay));
        }
    }
    if let Some(amqp) = service.get("amqp").and_then(Value::as_object) {
        transports.insert("amqp".to_string(), Value::Object(amqp.clone()));
    }
    if let Some(mqtt) = service.get("mqtt").and_then(Value::as_object) {
        transports.insert("mqtt".to_string(), Value::Object(mqtt.clone()));
    }

    let version = version.unwrap_or(SUPPORTED_WELL_KNOWN_VERSION);
    if version != SUPPORTED_WELL_KNOWN_VERSION {
        return Err(AcpError::Validation(format!(
            "Unsupported well-known version {version}; expected {SUPPORTED_WELL_KNOWN_VERSION}"
        )));
    }
    let identity_ref = identity_document_url
        .map(str::to_string)
        .unwrap_or(identity_document_url_from_base(base_url)?);
    validate_identity_document_reference(&identity_ref)?;

    let mut doc = Map::new();
    doc.insert("agent_id".to_string(), Value::String(agent_id.to_string()));
    doc.insert("identity_document".to_string(), Value::String(identity_ref));
    doc.insert("transports".to_string(), Value::Object(transports.clone()));
    doc.insert("version".to_string(), Value::String(version.to_string()));
    doc.insert(
        "security_profile".to_string(),
        Value::String(infer_security_profile(&transports)),
    );

    if let Some(supports) = capabilities.get("supports").and_then(Value::as_object) {
        let mut capability_names = supports
            .iter()
            .filter(|(_, enabled)| enabled.as_bool().unwrap_or(false))
            .map(|(key, _)| key.to_string())
            .collect::<Vec<_>>();
        capability_names.sort();
        doc.insert(
            "capabilities".to_string(),
            Value::Array(capability_names.into_iter().map(Value::String).collect()),
        );
    }
    Ok(doc)
}

pub fn parse_well_known_document(value: &Value) -> AcpResult<Map<String, Value>> {
    let map = value.as_object().ok_or_else(|| {
        AcpError::Validation("Well-known response must be a JSON object".to_string())
    })?;
    let agent_id = map
        .get("agent_id")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|v| !v.is_empty())
        .ok_or_else(|| AcpError::Validation("Well-known response missing agent_id".to_string()))?;
    parse_agent_id(agent_id)?;

    let transports = map
        .get("transports")
        .and_then(Value::as_object)
        .ok_or_else(|| {
            AcpError::Validation("Well-known response missing transports".to_string())
        })?;
    let version = map
        .get("version")
        .and_then(Value::as_str)
        .unwrap_or_default();
    if version != SUPPORTED_WELL_KNOWN_VERSION {
        return Err(AcpError::Validation(format!(
            "Well-known response version must be {SUPPORTED_WELL_KNOWN_VERSION}"
        )));
    }
    let identity_ref = map
        .get("identity_document")
        .and_then(Value::as_str)
        .ok_or_else(|| {
            AcpError::Validation(
                "Well-known response identity_document must be a URL string".to_string(),
            )
        })?;
    validate_identity_document_reference(identity_ref)?;
    validate_transports(transports)?;
    if let Some(profile) = map.get("security_profile").and_then(Value::as_str)
        && !SUPPORTED_SECURITY_PROFILES.contains(&profile)
    {
        return Err(AcpError::Validation(
            "Well-known response security_profile is invalid".to_string(),
        ));
    }
    Ok(map.clone())
}

pub fn resolve_identity_document_reference(
    well_known: &Map<String, Value>,
    source_url: &str,
) -> AcpResult<String> {
    let reference = well_known
        .get("identity_document")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|v| !v.is_empty())
        .ok_or_else(|| {
            AcpError::Validation(
                "Well-known response identity_document reference is invalid".to_string(),
            )
        })?;
    validate_identity_document_reference(reference)?;
    if Url::parse(reference).is_ok() {
        return Ok(reference.to_string());
    }
    let source = Url::parse(source_url)?;
    source
        .join(reference)
        .map(|url| url.to_string())
        .map_err(AcpError::from)
}

fn validate_identity_document_reference(reference: &str) -> AcpResult<()> {
    if let Ok(parsed) = Url::parse(reference) {
        if !matches!(parsed.scheme(), "http" | "https") {
            return Err(AcpError::Validation(
                "identity_document URL must use http or https".to_string(),
            ));
        }
        if parsed.host_str().unwrap_or_default().trim().is_empty() {
            return Err(AcpError::Validation(
                "identity_document URL is missing host".to_string(),
            ));
        }
        return Ok(());
    }
    if !reference.starts_with('/') {
        return Err(AcpError::Validation(
            "identity_document URL must be absolute http(s) or root-relative path".to_string(),
        ));
    }
    Ok(())
}

fn validate_transports(transports: &Map<String, Value>) -> AcpResult<()> {
    for (transport_name, hint_value) in transports {
        let hint = hint_value.as_object().ok_or_else(|| {
            AcpError::Validation(format!(
                "Well-known transport hint {transport_name} must be an object"
            ))
        })?;
        if let Some(endpoint_value) = hint.get("endpoint") {
            let endpoint = endpoint_value.as_str().ok_or_else(|| {
                AcpError::Validation(format!(
                    "Well-known transport hint {transport_name}.endpoint must be a string"
                ))
            })?;
            let parsed = Url::parse(endpoint).map_err(|_| {
                AcpError::Validation(format!(
                    "Well-known transport hint {transport_name}.endpoint must be an absolute http(s) URL"
                ))
            })?;
            if !matches!(parsed.scheme(), "http" | "https")
                || parsed.host_str().unwrap_or_default().trim().is_empty()
            {
                return Err(AcpError::Validation(format!(
                    "Well-known transport hint {transport_name}.endpoint must be an absolute http(s) URL"
                )));
            }
        }
        if let Some(profile) = hint.get("security_profile").and_then(Value::as_str)
            && !SUPPORTED_SECURITY_PROFILES.contains(&profile)
        {
            return Err(AcpError::Validation(format!(
                "Well-known transport hint {transport_name}.security_profile is invalid"
            )));
        }
    }
    Ok(())
}

fn infer_security_profile(transports: &Map<String, Value>) -> String {
    for transport in ["http", "relay"] {
        if let Some(profile) = transports
            .get(transport)
            .and_then(Value::as_object)
            .and_then(|hint| hint.get("security_profile"))
            .and_then(Value::as_str)
            .map(str::trim)
            .filter(|v| !v.is_empty())
        {
            return profile.to_string();
        }
    }
    if let Some(endpoint) = transports
        .get("http")
        .and_then(Value::as_object)
        .and_then(|hint| hint.get("endpoint"))
        .and_then(Value::as_str)
    {
        if endpoint.starts_with("https://") {
            return "https".to_string();
        }
        if endpoint.starts_with("http://") {
            return "http".to_string();
        }
    }
    "https".to_string()
}