mcpway 0.1.1

Run MCP stdio servers over SSE, WebSocket, and Streamable HTTP transports.
pub mod registry;
pub mod sources;

use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SourceKind {
    Cursor,
    Claude,
    Codex,
    Windsurf,
    OpenCode,
    Nodecode,
    VsCode,
}

impl SourceKind {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Cursor => "cursor",
            Self::Claude => "claude",
            Self::Codex => "codex",
            Self::Windsurf => "windsurf",
            Self::OpenCode => "opencode",
            Self::Nodecode => "nodecode",
            Self::VsCode => "vscode",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DiscoveryScope {
    Project,
    Global,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DiscoveredTransport {
    Stdio,
    Sse,
    Ws,
    StreamableHttp,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveredServer {
    pub name: String,
    pub source: SourceKind,
    pub scope: DiscoveryScope,
    pub origin_path: String,
    pub transport: DiscoveredTransport,
    pub command: Option<String>,
    pub args: Vec<String>,
    pub url: Option<String>,
    pub headers: BTreeMap<String, String>,
    pub env: BTreeMap<String, String>,
    pub enabled: bool,
    pub raw_format: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DiscoveryIssueLevel {
    Warning,
    Error,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveryIssue {
    pub level: DiscoveryIssueLevel,
    pub source: SourceKind,
    pub origin_path: String,
    pub message: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveryConflict {
    pub name: String,
    pub kept_source: SourceKind,
    pub kept_scope: DiscoveryScope,
    pub kept_origin_path: String,
    pub dropped_source: SourceKind,
    pub dropped_scope: DiscoveryScope,
    pub dropped_origin_path: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveryReport {
    pub project_root: String,
    pub servers: Vec<DiscoveredServer>,
    pub conflicts: Vec<DiscoveryConflict>,
    pub issues: Vec<DiscoveryIssue>,
}

#[derive(Debug, Clone)]
pub struct DiscoverOptions {
    pub from: Option<SourceKind>,
    pub project_root: Option<PathBuf>,
}

pub const SOURCE_PRIORITY: [SourceKind; 7] = [
    SourceKind::Cursor,
    SourceKind::Claude,
    SourceKind::Codex,
    SourceKind::Windsurf,
    SourceKind::OpenCode,
    SourceKind::Nodecode,
    SourceKind::VsCode,
];

pub fn discover(options: &DiscoverOptions) -> Result<DiscoveryReport, String> {
    let project_root = resolve_project_root(options.project_root.as_deref())?;
    let home_dir = user_home_dir();
    let selected_sources = if let Some(source) = options.from {
        vec![source]
    } else {
        SOURCE_PRIORITY.to_vec()
    };

    let mut issues = Vec::new();
    let mut by_source: HashMap<SourceKind, Vec<DiscoveredServer>> = HashMap::new();

    for source in &selected_sources {
        let (servers, source_issues) = match source {
            SourceKind::Cursor => sources::cursor::discover(&project_root, home_dir.as_deref()),
            SourceKind::Claude => sources::claude::discover(&project_root, home_dir.as_deref()),
            SourceKind::Codex => sources::codex::discover(&project_root, home_dir.as_deref()),
            SourceKind::Windsurf => sources::windsurf::discover(&project_root, home_dir.as_deref()),
            SourceKind::OpenCode => sources::opencode::discover(&project_root, home_dir.as_deref()),
            SourceKind::Nodecode => sources::nodecode::discover(&project_root, home_dir.as_deref()),
            SourceKind::VsCode => sources::vscode::discover(&project_root, home_dir.as_deref()),
        };

        issues.extend(source_issues);
        by_source.insert(*source, collapse_source_scope_precedence(servers));
    }

    let mut conflicts = Vec::new();
    let mut by_name: BTreeMap<String, DiscoveredServer> = BTreeMap::new();

    for source in SOURCE_PRIORITY
        .iter()
        .copied()
        .filter(|source| selected_sources.contains(source))
    {
        if let Some(mut source_servers) = by_source.remove(&source) {
            source_servers.sort_by(|a, b| a.name.cmp(&b.name));
            for server in source_servers {
                if let Some(existing) = by_name.get(&server.name) {
                    conflicts.push(DiscoveryConflict {
                        name: server.name.clone(),
                        kept_source: existing.source,
                        kept_scope: existing.scope,
                        kept_origin_path: existing.origin_path.clone(),
                        dropped_source: server.source,
                        dropped_scope: server.scope,
                        dropped_origin_path: server.origin_path.clone(),
                    });
                    continue;
                }
                by_name.insert(server.name.clone(), server);
            }
        }
    }

    let servers = by_name.into_values().collect::<Vec<_>>();

    Ok(DiscoveryReport {
        project_root: project_root.to_string_lossy().to_string(),
        servers,
        conflicts,
        issues,
    })
}

fn collapse_source_scope_precedence(servers: Vec<DiscoveredServer>) -> Vec<DiscoveredServer> {
    let mut sorted = servers;
    sorted.sort_by(|a, b| {
        let primary = a.name.cmp(&b.name);
        if primary != std::cmp::Ordering::Equal {
            return primary;
        }
        scope_priority(a.scope).cmp(&scope_priority(b.scope))
    });

    let mut out = BTreeMap::new();
    for server in sorted {
        out.entry(server.name.clone()).or_insert(server);
    }

    out.into_values().collect()
}

fn scope_priority(scope: DiscoveryScope) -> u8 {
    match scope {
        DiscoveryScope::Project => 0,
        DiscoveryScope::Global => 1,
    }
}

pub fn resolve_project_root(input: Option<&Path>) -> Result<PathBuf, String> {
    let start = if let Some(path) = input {
        if path.is_absolute() {
            path.to_path_buf()
        } else {
            std::env::current_dir()
                .map_err(|err| format!("Failed to read cwd: {err}"))?
                .join(path)
        }
    } else {
        std::env::current_dir().map_err(|err| format!("Failed to read cwd: {err}"))?
    };

    Ok(find_nearest_git_root(&start).unwrap_or(start))
}

fn find_nearest_git_root(start: &Path) -> Option<PathBuf> {
    let mut current = if start.is_file() {
        start.parent().map(Path::to_path_buf)
    } else {
        Some(start.to_path_buf())
    }?;

    loop {
        let git_path = current.join(".git");
        if git_path.exists() {
            return Some(current);
        }

        if !current.pop() {
            break;
        }
    }

    None
}

pub fn user_home_dir() -> Option<PathBuf> {
    std::env::var_os("HOME")
        .map(PathBuf::from)
        .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
}

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

    #[test]
    fn scope_precedence_prefers_project() {
        let global = DiscoveredServer {
            name: "shared".to_string(),
            source: SourceKind::Cursor,
            scope: DiscoveryScope::Global,
            origin_path: "/tmp/global".to_string(),
            transport: DiscoveredTransport::StreamableHttp,
            command: None,
            args: Vec::new(),
            url: Some("https://example.com/mcp".to_string()),
            headers: BTreeMap::new(),
            env: BTreeMap::new(),
            enabled: true,
            raw_format: "test".to_string(),
        };

        let mut project = global.clone();
        project.scope = DiscoveryScope::Project;
        project.origin_path = "/tmp/project".to_string();

        let collapsed = collapse_source_scope_precedence(vec![global, project]);
        assert_eq!(collapsed.len(), 1);
        assert_eq!(collapsed[0].scope, DiscoveryScope::Project);
        assert_eq!(collapsed[0].origin_path, "/tmp/project");
    }
}