mcpway 0.2.0

Run MCP stdio servers over SSE, WebSocket, Streamable HTTP, and gRPC transports.
Documentation
use std::collections::BTreeMap;
use std::path::Path;

use crate::discovery::{DiscoveredServer, DiscoveryIssue, DiscoveryScope, SourceKind};

use super::{infer_transport, load_toml, source_issue};

pub fn discover(
    project_root: &Path,
    home_dir: Option<&Path>,
) -> (Vec<DiscoveredServer>, Vec<DiscoveryIssue>) {
    let mut servers = Vec::new();
    let mut issues = Vec::new();

    let project_path = project_root.join(".codex").join("config.toml");
    collect_from_path(
        &project_path,
        DiscoveryScope::Project,
        &mut servers,
        &mut issues,
    );

    if let Some(home) = home_dir {
        let global_path = home.join(".codex").join("config.toml");
        collect_from_path(
            &global_path,
            DiscoveryScope::Global,
            &mut servers,
            &mut issues,
        );
    }

    (servers, issues)
}

fn collect_from_path(
    path: &Path,
    scope: DiscoveryScope,
    out: &mut Vec<DiscoveredServer>,
    issues: &mut Vec<DiscoveryIssue>,
) {
    if !path.exists() {
        return;
    }

    let root = match load_toml(path) {
        Ok(root) => root,
        Err(err) => {
            issues.push(source_issue(SourceKind::Codex, path, err));
            return;
        }
    };

    let Some(mcp_servers) = root.get("mcp_servers").and_then(|v| v.as_table()) else {
        return;
    };

    for (name, raw_server) in mcp_servers {
        let Some(server) = raw_server.as_table() else {
            issues.push(source_issue(
                SourceKind::Codex,
                path,
                format!("mcp_servers.{name} must be a table"),
            ));
            continue;
        };

        if server
            .get("enabled")
            .and_then(|v| v.as_bool())
            .is_some_and(|enabled| !enabled)
        {
            continue;
        }

        let command = server
            .get("command")
            .and_then(|v| v.as_str())
            .map(|v| v.to_string());
        let args = server
            .get("args")
            .and_then(|v| v.as_array())
            .map(|arr| {
                arr.iter()
                    .filter_map(|v| v.as_str())
                    .map(ToString::to_string)
                    .collect::<Vec<_>>()
            })
            .unwrap_or_default();
        let env = toml_string_map(server.get("env"));

        let mut headers = toml_string_map(server.get("http_headers"));

        if let Some(env_headers) = server.get("env_http_headers").and_then(|v| v.as_table()) {
            for (header, env_var) in env_headers {
                if let Some(key) = env_var.as_str() {
                    headers.insert(header.to_string(), format!("${{{key}}}"));
                }
            }
        }

        if let Some(token_var) = server.get("bearer_token_env_var").and_then(|v| v.as_str()) {
            headers
                .entry("Authorization".to_string())
                .or_insert_with(|| format!("Bearer ${{{token_var}}}"));
        }

        let url = server
            .get("url")
            .and_then(|v| v.as_str())
            .map(|v| v.to_string());

        let transport = match infer_transport(command.as_deref(), url.as_deref(), None) {
            Some(transport) => transport,
            None => {
                issues.push(source_issue(
                    SourceKind::Codex,
                    path,
                    format!("mcp_servers.{name} is missing 'command' or 'url'"),
                ));
                continue;
            }
        };

        out.push(DiscoveredServer {
            name: name.to_string(),
            source: SourceKind::Codex,
            scope,
            origin_path: path.to_string_lossy().to_string(),
            transport,
            command,
            args,
            url,
            headers,
            env,
            enabled: true,
            raw_format: "codex-config-toml".to_string(),
        });
    }
}

fn toml_string_map(value: Option<&toml::Value>) -> BTreeMap<String, String> {
    let Some(table) = value.and_then(|v| v.as_table()) else {
        return BTreeMap::new();
    };

    let mut out = BTreeMap::new();
    for (key, value) in table {
        if let Some(v) = value.as_str() {
            out.insert(key.to_string(), v.to_string());
        }
    }
    out
}