mcp-stdio-proxy 0.2.9

MCP (Model Context Protocol) proxy server and CLI tool for protocol conversion and remote service access
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use mcp_proxy_args::{LoadedFallbackJson, RawFallbackImportSpec, try_load_fallback};

use crate::client::protocol::McpProtocol;
use crate::client::support::ConvertArgs;

#[derive(Debug)]
pub(super) enum DiscoveryMode {
    Normal,
    Import(LoadedFallbackJson),
    Export(ExportTargets),
}

#[derive(Debug, Clone)]
pub(super) struct ExportTargets {
    pub initialize: Option<PathBuf>,
    pub tools: Option<PathBuf>,
}

pub(super) fn prepare(
    args: &ConvertArgs,
    config_protocol: Option<&McpProtocol>,
) -> Result<DiscoveryMode> {
    let import_spec = RawFallbackImportSpec {
        initialize_inline: args.import_initialize.clone(),
        initialize_file: args.import_initialize_file.clone(),
        tools_inline: args.import_tools.clone(),
        tools_file: args.import_tools_file.clone(),
    };
    let import_requested = import_spec.has_any();
    let export_requested = args.export_initialize.is_some() || args.export_tools.is_some();
    let protocol_known = args.protocol.is_some()
        || matches!(
            config_protocol,
            Some(McpProtocol::Sse | McpProtocol::Stream)
        );

    validate_mode(
        import_requested,
        export_requested,
        protocol_known,
        args.export_initialize.as_deref(),
        args.export_tools.as_deref(),
    )?;

    if import_requested {
        let loaded = try_load_fallback(&import_spec)?.ok_or_else(|| {
            anyhow::anyhow!("fallback import was requested but no input was loaded")
        })?;
        return Ok(DiscoveryMode::Import(loaded));
    }
    if export_requested {
        return Ok(DiscoveryMode::Export(ExportTargets {
            initialize: args.export_initialize.clone(),
            tools: args.export_tools.clone(),
        }));
    }
    Ok(DiscoveryMode::Normal)
}

fn validate_mode(
    import_requested: bool,
    export_requested: bool,
    protocol_known: bool,
    export_initialize: Option<&Path>,
    export_tools: Option<&Path>,
) -> Result<()> {
    if import_requested && export_requested {
        bail!("fallback import and export options cannot be used together");
    }
    if (import_requested || export_requested) && !protocol_known {
        bail!(
            "fallback import/export requires an explicit SSE or Stream protocol from --protocol or remote service config"
        );
    }
    if export_initialize == Some(Path::new("-")) && export_tools == Some(Path::new("-")) {
        bail!("--export-initialize and --export-tools cannot both write to stdout");
    }
    let initialize_target = export_initialize.map(export_target_identity).transpose()?;
    let tools_target = export_tools.map(export_target_identity).transpose()?;
    if let (Some(initialize), Some(tools)) = (&initialize_target, &tools_target)
        && initialize == tools
    {
        bail!("--export-initialize and --export-tools must use different file targets");
    }
    Ok(())
}

fn export_target_identity(target: &Path) -> Result<std::path::PathBuf> {
    if target == Path::new("-") {
        return Ok(target.to_path_buf());
    }
    let file_name = target
        .file_name()
        .with_context(|| format!("invalid export target {}", target.display()))?;
    let parent = target
        .parent()
        .filter(|path| !path.as_os_str().is_empty())
        .unwrap_or(Path::new("."));
    let canonical_parent = parent.canonicalize().with_context(|| {
        format!(
            "failed to resolve export target directory {}",
            parent.display()
        )
    })?;
    Ok(canonical_parent.join(file_name))
}

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

    fn args(values: &[&str]) -> ConvertArgs {
        ConvertArgs::parse_from(values)
    }

    #[test]
    fn resolves_discovery_modes_once() {
        assert!(matches!(
            prepare(&args(&["convert"]), None).expect("normal mode"),
            DiscoveryMode::Normal
        ));
        assert!(matches!(
            prepare(
                &args(&[
                    "convert",
                    "--protocol",
                    "sse",
                    "--import-initialize",
                    "{}",
                    "--import-tools",
                    "{}",
                ]),
                None,
            )
            .expect("import mode"),
            DiscoveryMode::Import(_)
        ));
        assert!(matches!(
            prepare(
                &args(&["convert", "--protocol", "stream", "--export-tools", "-",]),
                None,
            )
            .expect("export mode"),
            DiscoveryMode::Export(_)
        ));
    }

    #[test]
    fn import_without_explicit_protocol_fails_before_loading() {
        let error = prepare(
            &args(&[
                "convert",
                "--import-initialize",
                "{invalid",
                "--import-tools",
                "{}",
            ]),
            None,
        )
        .expect_err("protocol validation must happen before JSON loading");

        assert!(error.to_string().contains("explicit SSE or Stream"));
    }

    #[test]
    fn rejects_import_without_known_protocol() {
        let error =
            validate_mode(true, false, false, None, None).expect_err("unknown protocol must fail");
        assert!(
            error
                .to_string()
                .contains("explicit SSE or Stream protocol")
        );
    }

    #[test]
    fn rejects_import_export_conflict_before_loading() {
        let error = validate_mode(true, true, true, None, Some(Path::new("tools.json")))
            .expect_err("import/export conflict must fail");
        assert!(error.to_string().contains("cannot be used together"));
    }

    #[test]
    fn rejects_ambiguous_export_targets() {
        validate_mode(
            false,
            true,
            true,
            Some(Path::new("-")),
            Some(Path::new("-")),
        )
        .expect_err("dual stdout must fail");
        validate_mode(
            false,
            true,
            true,
            Some(Path::new("snapshot.json")),
            Some(Path::new("snapshot.json")),
        )
        .expect_err("same file target must fail");
    }

    #[test]
    fn rejects_equivalent_relative_export_targets() {
        validate_mode(
            false,
            true,
            true,
            Some(Path::new("snapshot.json")),
            Some(Path::new("./snapshot.json")),
        )
        .expect_err("equivalent file targets must fail");
    }
}