agentic-server 0.5.0

Standalone axum server for agentic-api
Documentation
use std::collections::HashMap;
use std::io::Write;
use std::path::Path;

use agentic_core::McpServerEntry;
use agentic_core::config::CONFIG_FILE_NAME;
use agentic_core::error::Error;
use serde::{Deserialize, Serialize};

#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct WebSearchFileConfig {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub base_url: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub api_key_env: Option<String>,
}

impl WebSearchFileConfig {
    fn is_empty(&self) -> bool {
        self.base_url.is_none() && self.api_key_env.is_none()
    }
}

#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct McpFileConfig {
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub allowed_hosts: Vec<String>,
}

impl McpFileConfig {
    fn is_empty(&self) -> bool {
        self.allowed_hosts.is_empty()
    }
}

#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct MessagesGatewayFileConfig {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tool_aliases: Option<String>,
}

impl MessagesGatewayFileConfig {
    fn is_empty(&self) -> bool {
        self.tool_aliases.is_none()
    }
}

#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct FileConfig {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub llm_api_base: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub database_url: Option<String>,
    #[serde(skip_serializing_if = "WebSearchFileConfig::is_empty")]
    pub web_search: WebSearchFileConfig,
    #[serde(skip_serializing_if = "McpFileConfig::is_empty")]
    pub mcp: McpFileConfig,
    #[serde(skip_serializing_if = "MessagesGatewayFileConfig::is_empty")]
    pub messages_gateway: MessagesGatewayFileConfig,
    #[serde(skip_serializing_if = "HashMap::is_empty")]
    pub mcp_servers: HashMap<String, McpServerEntry>,
}

fn is_unwritable_home(error: &std::io::Error) -> bool {
    matches!(
        error.kind(),
        std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::ReadOnlyFilesystem
    ) || error.raw_os_error() == Some(30) // EROFS on platforms without the stable ErrorKind mapping
}

impl FileConfig {
    pub(crate) fn load(home: &Path) -> Result<Option<Self>, Error> {
        let path = home.join(CONFIG_FILE_NAME);
        let contents = match std::fs::read_to_string(&path) {
            Ok(contents) => contents,
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
            Err(error) => {
                return Err(Error::Config(format!(
                    "failed to read configuration file {}: {error}",
                    path.display()
                )));
            }
        };
        let config = toml::from_str::<Self>(&contents).map_err(|error| {
            Error::Config(format!(
                "failed to parse configuration file {}: {error}",
                path.display()
            ))
        })?;
        config.validate(&path)?;
        Ok(Some(config))
    }

    pub(crate) fn create_or_load(self, home: &Path) -> Result<Self, Error> {
        let path = home.join(CONFIG_FILE_NAME);
        let body = toml::to_string_pretty(&self)
            .map_err(|error| Error::Config(format!("failed to serialize default configuration: {error}")))?;
        let contents = format!(
            "# Generated by agentic-server from the first invocation with a resolved LLM base URL.\n\
             # CLI arguments and process environment variables take precedence.\n\
             # Secret values are read from referenced environment variables and are not written here.\n\n{body}"
        );

        let mut file = match tempfile::Builder::new().prefix(".agentic-config-").tempfile_in(home) {
            Ok(file) => file,
            Err(error) if is_unwritable_home(&error) => {
                // A read-only root filesystem (the Kubernetes base) or an unwritable home must not
                // prevent startup: the generated file is a convenience, not a requirement.
                tracing::warn!(
                    home = %home.display(),
                    error = %error,
                    "Agentic API home is not writable; continuing with the generated configuration in memory"
                );
                return Ok(self);
            }
            Err(error) => {
                return Err(Error::Config(format!(
                    "failed to create temporary configuration file: {error}"
                )));
            }
        };
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            // Group-readable (not group-writable) so a container restart under a
            // different arbitrary UID sharing the image's root group can still
            // read a config generated by an earlier UID.
            file.as_file()
                .set_permissions(std::fs::Permissions::from_mode(0o640))
                .map_err(|error| Error::Config(format!("failed to set configuration file permissions: {error}")))?;
        }
        if let Err(error) = file.write_all(contents.as_bytes()) {
            return Err(Error::Config(format!(
                "failed to write temporary configuration file: {error}"
            )));
        }
        file.as_file()
            .sync_all()
            .map_err(|error| Error::Config(format!("failed to sync temporary configuration file: {error}")))?;
        if let Err(error) = file.persist_noclobber(&path) {
            if error.error.kind() == std::io::ErrorKind::AlreadyExists {
                return Self::load(home)?.ok_or_else(|| {
                    Error::Config(format!(
                        "configuration file {} disappeared while it was being loaded",
                        path.display()
                    ))
                });
            }
            return Err(Error::Config(format!(
                "failed to install configuration file {}: {}",
                path.display(),
                error.error
            )));
        }
        #[cfg(unix)]
        std::fs::File::open(home)
            .and_then(|directory| directory.sync_all())
            .map_err(|error| Error::Config(format!("failed to sync configuration directory: {error}")))?;
        Ok(self)
    }

    fn validate(&self, path: &Path) -> Result<(), Error> {
        if self
            .web_search
            .api_key_env
            .as_deref()
            .is_some_and(|name| name.trim().is_empty())
        {
            return Err(Error::Config(format!(
                "configuration file {} contains an empty web_search.api_key_env",
                path.display()
            )));
        }
        if let Some(host) = self.mcp.allowed_hosts.iter().find(|host| host.trim().is_empty()) {
            return Err(Error::Config(format!(
                "configuration file {} contains an empty MCP allowed host: {host:?}",
                path.display()
            )));
        }
        if let Some(label) = self.mcp_servers.keys().find(|label| label.trim().is_empty()) {
            return Err(Error::Config(format!(
                "configuration file {} contains an empty MCP server label: {label:?}",
                path.display()
            )));
        }
        for (label, server) in &self.mcp_servers {
            if let Some(allowed_tools) = server.allowed_tools() {
                if allowed_tools.is_empty() {
                    return Err(Error::Config(format!(
                        "configuration file {} contains an empty allowed_tools list for MCP server {label:?}",
                        path.display()
                    )));
                }
                if let Some(tool) = allowed_tools.iter().find(|tool| tool.trim().is_empty()) {
                    return Err(Error::Config(format!(
                        "configuration file {} contains an empty allowed tool for MCP server {label:?}: {tool:?}",
                        path.display()
                    )));
                }
            }
            if server.require_approval().is_some_and(|policy| policy != "never") {
                return Err(Error::Config(format!(
                    "configuration file {} sets unsupported require_approval for MCP server {label:?}; only 'never' is supported",
                    path.display()
                )));
            }
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use std::fs;

    use agentic_core::McpServerEntry;
    use tempfile::tempdir;

    use super::{FileConfig, McpFileConfig, WebSearchFileConfig};

    #[test]
    fn missing_config_file_uses_defaults() {
        let home = tempdir().expect("temp home");
        let config = FileConfig::load(home.path()).expect("missing config is optional");

        assert!(config.is_none());
    }

    #[test]
    fn creates_secret_free_default_config_from_runtime_settings() {
        let home = tempdir().expect("temp home");
        let defaults = FileConfig {
            llm_api_base: Some("http://127.0.0.1:5050".to_owned()),
            web_search: WebSearchFileConfig {
                base_url: Some("https://api.ydc-index.io".to_owned()),
                api_key_env: Some("YOU_API_KEY".to_owned()),
            },
            mcp: McpFileConfig {
                allowed_hosts: vec!["mcp.example.com".to_owned()],
            },
            ..FileConfig::default()
        };

        let config = defaults.create_or_load(home.path()).expect("create config");
        let contents = fs::read_to_string(home.path().join("config.toml")).expect("read generated config");

        assert_eq!(config.llm_api_base.as_deref(), Some("http://127.0.0.1:5050"));
        assert!(contents.contains("llm_api_base = \"http://127.0.0.1:5050\""));
        assert!(contents.contains("[web_search]"));
        assert!(contents.contains("api_key_env = \"YOU_API_KEY\""));
        assert!(contents.contains("allowed_hosts = [\"mcp.example.com\"]"));
        assert!(!contents.contains("YOU_API_KEY ="));
        assert!(!contents.contains("[mcp_servers]"));

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mode = fs::metadata(home.path().join("config.toml"))
                .expect("config metadata")
                .permissions()
                .mode();
            assert_eq!(mode & 0o777, 0o640);
        }
    }

    #[test]
    fn loads_typed_settings_and_mcp_servers() {
        let home = tempdir().expect("temp home");
        let fixture = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/typed-config.toml");
        fs::copy(fixture, home.path().join("config.toml")).expect("copy typed config fixture");

        let config = FileConfig::load(home.path())
            .expect("load config")
            .expect("existing config");

        assert_eq!(config.llm_api_base.as_deref(), Some("http://127.0.0.1:8000/v1"));
        assert_eq!(config.database_url.as_deref(), Some("sqlite:///tmp/agentic.db"));
        assert_eq!(config.web_search.api_key_env.as_deref(), Some("YOU_API_KEY"));
        assert_eq!(config.mcp.allowed_hosts, vec!["mcp.example.com"]);
        assert!(matches!(config.mcp_servers["remote"], McpServerEntry::Http { .. }));
        assert_eq!(
            config.mcp_servers["remote"].allowed_tools(),
            Some(["say_hello".to_owned(), "sum".to_owned()].as_slice())
        );
        assert_eq!(config.mcp_servers["remote"].require_approval(), Some("never"));
    }

    #[test]
    fn rejects_generic_environment_table() {
        let home = tempdir().expect("temp home");
        fs::write(home.path().join("config.toml"), "[env]\nYOU_API_KEY = \"secret\"\n").expect("write config");

        let error = FileConfig::load(home.path()).expect_err("generic env table must fail");
        assert!(error.to_string().contains("unknown field"));
    }

    #[test]
    fn rejects_unknown_top_level_fields() {
        let home = tempdir().expect("temp home");
        fs::write(home.path().join("config.toml"), "unknown = true\n").expect("write config");

        let error = FileConfig::load(home.path()).expect_err("unknown field must fail");
        assert!(error.to_string().contains("unknown field"));
    }

    #[test]
    fn rejects_empty_api_key_environment_name() {
        let home = tempdir().expect("temp home");
        fs::write(home.path().join("config.toml"), "[web_search]\napi_key_env = \"  \"\n").expect("write config");

        let error = FileConfig::load(home.path()).expect_err("empty environment name must fail");
        assert!(error.to_string().contains("empty web_search.api_key_env"));
    }

    #[test]
    fn rejects_mcp_entries_with_multiple_transports() {
        let home = tempdir().expect("temp home");
        fs::write(
            home.path().join("config.toml"),
            "[mcp_servers.invalid]\ncommand = \"one\"\nurl = \"http://localhost:8000/mcp\"\n",
        )
        .expect("write config");

        let error = FileConfig::load(home.path()).expect_err("ambiguous transport must fail");
        assert!(error.to_string().contains("did not match any variant"));
    }

    #[test]
    fn rejects_unsupported_configured_mcp_approval_policy() {
        let home = tempdir().expect("temp home");
        fs::write(
            home.path().join("config.toml"),
            "[mcp_servers.remote]\nurl = \"https://mcp.example.com/mcp\"\nrequire_approval = \"always\"\n",
        )
        .expect("write config");

        let error = FileConfig::load(home.path()).expect_err("unsupported approval policy must fail");
        assert!(error.to_string().contains("only 'never' is supported"));
    }

    #[test]
    fn existing_config_is_never_overwritten() {
        let home = tempdir().expect("temp home");
        fs::write(
            home.path().join("config.toml"),
            "llm_api_base = \"http://existing:8000\"\n",
        )
        .expect("write config");
        let defaults = FileConfig {
            llm_api_base: Some("http://replacement:9000".to_owned()),
            ..FileConfig::default()
        };

        let config = defaults.create_or_load(home.path()).expect("load existing config");
        let contents = fs::read_to_string(home.path().join("config.toml")).expect("read config");

        assert_eq!(config.llm_api_base.as_deref(), Some("http://existing:8000"));
        assert!(!contents.contains("replacement"));
    }

    #[cfg(unix)]
    #[test]
    fn create_or_load_continues_when_home_is_not_writable() {
        use std::os::unix::fs::PermissionsExt;

        let home = tempfile::tempdir().expect("tempdir");
        fs::set_permissions(home.path(), fs::Permissions::from_mode(0o555)).expect("make home read-only");
        if fs::File::create(home.path().join("probe")).is_ok() {
            // Running as root: the permission bits are not enforced, so the scenario cannot be reproduced.
            return;
        }
        let defaults = FileConfig {
            llm_api_base: Some("http://127.0.0.1:5050".to_owned()),
            ..FileConfig::default()
        };

        let config = defaults
            .create_or_load(home.path())
            .expect("an unwritable home must not fail startup");

        assert_eq!(config.llm_api_base.as_deref(), Some("http://127.0.0.1:5050"));
        assert!(!home.path().join("config.toml").exists());
        fs::set_permissions(home.path(), fs::Permissions::from_mode(0o755)).expect("restore permissions");
    }
}