mcpway 0.2.0

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

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

use super::{infer_transport, json_string_array, json_string_map, load_json, 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(".nodecode").join("config.json");
    collect_from_path(
        &project_path,
        DiscoveryScope::Project,
        &mut servers,
        &mut issues,
    );

    if let Some(home) = home_dir {
        let global_path = home.join(".nodecode").join("config.json");
        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_json(path) {
        Ok(root) => root,
        Err(err) => {
            issues.push(source_issue(SourceKind::Nodecode, path, err));
            return;
        }
    };

    let servers_obj = root
        .get("mcp")
        .and_then(|v| v.as_object())
        .and_then(|mcp| mcp.get("servers"))
        .and_then(|v| v.as_object());

    let Some(servers_obj) = servers_obj else {
        issues.push(source_issue(
            SourceKind::Nodecode,
            path,
            "Missing or invalid 'mcp.servers' object",
        ));
        return;
    };

    for (name, server) in servers_obj {
        let Some(server_obj) = server.as_object() else {
            issues.push(source_issue(
                SourceKind::Nodecode,
                path,
                format!("mcp.servers.{name} must be an object"),
            ));
            continue;
        };

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

        let command = server_obj
            .get("command")
            .and_then(|v| v.as_str())
            .map(|v| v.to_string());
        let args = json_string_array(server_obj.get("args"));
        let env = json_string_map(server_obj.get("env"));
        let headers = json_string_map(server_obj.get("headers"));
        let url = server_obj
            .get("url")
            .and_then(|v| v.as_str())
            .map(|v| v.to_string());
        let explicit_type = server_obj.get("type").and_then(|v| v.as_str());

        let transport = match infer_transport(command.as_deref(), url.as_deref(), explicit_type) {
            Some(transport) => transport,
            None => {
                issues.push(source_issue(
                    SourceKind::Nodecode,
                    path,
                    format!("mcp.servers.{name} is missing transport fields (command/url/type)"),
                ));
                continue;
            }
        };

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

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn unique_temp_dir(name: &str) -> std::path::PathBuf {
        let suffix = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("clock drift")
            .as_nanos();
        std::env::temp_dir().join(format!("mcpway-nodecode-{name}-{suffix}"))
    }

    #[test]
    fn discovers_project_nodecode_servers() {
        let project = unique_temp_dir("project");
        let nodecode_dir = project.join(".nodecode");
        std::fs::create_dir_all(&nodecode_dir).expect("create project .nodecode");
        std::fs::write(
            nodecode_dir.join("config.json"),
            r#"{
  "mcp": {
    "servers": {
      "demo": {
        "command": "node",
        "args": ["server.js"],
        "env": {"API_KEY": "abc"},
        "headers": {"X-Test": "1"}
      }
    }
  }
}"#,
        )
        .expect("write config");

        let (servers, issues) = discover(&project, None);
        assert!(issues.is_empty());
        assert_eq!(servers.len(), 1);
        assert_eq!(servers[0].name, "demo");
        assert_eq!(servers[0].source, SourceKind::Nodecode);
        assert_eq!(servers[0].scope, DiscoveryScope::Project);
        assert_eq!(
            servers[0].transport,
            crate::discovery::DiscoveredTransport::Stdio
        );

        let _ = std::fs::remove_dir_all(project);
    }

    #[test]
    fn discovers_global_nodecode_servers() {
        let project = unique_temp_dir("project-empty");
        let home = unique_temp_dir("home");
        let nodecode_dir = home.join(".nodecode");
        std::fs::create_dir_all(&nodecode_dir).expect("create home .nodecode");
        std::fs::write(
            nodecode_dir.join("config.json"),
            r#"{
  "mcp": {
    "servers": {
      "remote": {
        "url": "https://example.com/mcp"
      }
    }
  }
}"#,
        )
        .expect("write config");

        let (servers, issues) = discover(&project, Some(&home));
        assert!(issues.is_empty());
        assert_eq!(servers.len(), 1);
        assert_eq!(servers[0].name, "remote");
        assert_eq!(servers[0].scope, DiscoveryScope::Global);
        assert_eq!(
            servers[0].transport,
            crate::discovery::DiscoveredTransport::StreamableHttp
        );

        let _ = std::fs::remove_dir_all(project);
        let _ = std::fs::remove_dir_all(home);
    }

    #[test]
    fn emits_issue_when_mcp_servers_missing() {
        let project = unique_temp_dir("missing");
        let nodecode_dir = project.join(".nodecode");
        std::fs::create_dir_all(&nodecode_dir).expect("create project .nodecode");
        std::fs::write(nodecode_dir.join("config.json"), r#"{"mcp": {}}"#).expect("write config");

        let (servers, issues) = discover(&project, None);
        assert!(servers.is_empty());
        assert_eq!(issues.len(), 1);
        assert!(issues[0].message.contains("mcp.servers"));

        let _ = std::fs::remove_dir_all(project);
    }
}