starweaver-cli 0.13.0

Command-line interface for Starweaver
Documentation
use std::{collections::BTreeMap, sync::Arc};

use serde_json::json;
use starweaver_agent::{
    DynToolset, McpServerSpec, McpToolSpec, McpToolset, McpToolsetConfig, McpTransport,
    ToolProxyToolset,
};

use crate::config::{CliConfig, McpExposureMode, mcp_servers};

pub(super) fn configured_mcp_server_specs(config: &CliConfig) -> Vec<McpServerSpec> {
    mcp_servers(config)
        .into_iter()
        .filter_map(|(name, value)| {
            let transport = parse_mcp_transport(&value)?;
            let metadata = value.as_object().cloned().unwrap_or_default();
            Some(McpServerSpec {
                name,
                transport: transport.kind().to_string(),
                metadata,
            })
        })
        .collect()
}

pub(super) fn configured_mcp_toolsets(config: &CliConfig) -> Vec<DynToolset> {
    let mut descriptions = BTreeMap::new();
    let toolsets = configured_mcp_toolsets_with_descriptions(config, &mut descriptions);
    match config.mcp_mode() {
        McpExposureMode::Direct => toolsets,
        McpExposureMode::Proxy if toolsets.is_empty() => Vec::new(),
        McpExposureMode::Proxy => {
            let Some(proxy) = ToolProxyToolset::new(toolsets)
                .try_with_name_prefix("mcp")
                .ok()
                .map(|proxy| proxy.with_namespace_descriptions(descriptions))
            else {
                return Vec::new();
            };
            vec![Arc::new(proxy)]
        }
    }
}

fn configured_mcp_toolsets_with_descriptions(
    config: &CliConfig,
    descriptions: &mut BTreeMap<String, String>,
) -> Vec<DynToolset> {
    mcp_servers(config)
        .into_iter()
        .filter_map(|(name, value)| {
            let transport = parse_mcp_transport(&value)?;
            let toolset_id = format!("mcp_{name}");
            let mut toolset_config = McpToolsetConfig::new(toolset_id.clone(), transport);
            if config.mcp_mode() == McpExposureMode::Direct
                && let Some(prefix) = direct_mcp_tool_prefix(&name, &value)
            {
                toolset_config = toolset_config.with_tool_prefix(prefix);
            }
            if value
                .get("include_instructions")
                .and_then(serde_json::Value::as_bool)
                .unwrap_or(false)
            {
                toolset_config = toolset_config.with_include_instructions(true);
            }
            if let Some(instructions) = value
                .get("instructions")
                .and_then(serde_json::Value::as_str)
            {
                toolset_config = toolset_config.with_instructions(instructions);
            }
            let description = mcp_namespace_description(&value);
            for tool in parse_mcp_tools(&value) {
                toolset_config = toolset_config.with_tool(tool);
            }
            if let Some(description) = description {
                descriptions.insert(toolset_id, description);
            }
            Some(Arc::new(McpToolset::new(toolset_config)) as DynToolset)
        })
        .collect()
}

fn direct_mcp_tool_prefix<'a>(name: &'a str, value: &'a serde_json::Value) -> Option<&'a str> {
    match value.get("prefix") {
        Some(serde_json::Value::String(prefix)) => (!prefix.is_empty()).then_some(prefix.as_str()),
        // Explicit null has the same behavior as omitting the new field: use the server name.
        Some(_) => Some(name),
        // Preserve the pre-existing Starweaver spelling for existing configurations.
        None => match value.get("tool_prefix") {
            Some(serde_json::Value::String(prefix)) => {
                (!prefix.is_empty()).then_some(prefix.as_str())
            }
            _ => Some(name),
        },
    }
}

fn mcp_namespace_description(value: &serde_json::Value) -> Option<String> {
    value
        .get("description")
        .and_then(serde_json::Value::as_str)
        .or_else(|| {
            value
                .get("instructions")
                .and_then(serde_json::Value::as_str)
        })
        .map(str::trim)
        .filter(|description| !description.is_empty())
        .map(str::to_string)
}

pub(super) fn mcp_transport_error(value: &serde_json::Value) -> Option<String> {
    let transport = value
        .get("transport")
        .and_then(serde_json::Value::as_str)
        .unwrap_or("stdio");
    match transport {
        "stdio" => value
            .get("command")
            .and_then(serde_json::Value::as_str)
            .map(|command| command.trim().is_empty())
            .map_or_else(
                || Some("stdio transport requires command".to_string()),
                |empty| empty.then(|| "stdio transport requires command".to_string()),
            ),
        "streamable_http" | "http" | "sse" => value
            .get("url")
            .and_then(serde_json::Value::as_str)
            .map(|url| url.trim().is_empty())
            .map_or_else(
                || Some(format!("{transport} transport requires url")),
                |empty| empty.then(|| format!("{transport} transport requires url")),
            ),
        other => Some(format!("unknown MCP transport {other}")),
    }
}

fn parse_mcp_transport(value: &serde_json::Value) -> Option<McpTransport> {
    let transport = value
        .get("transport")
        .and_then(serde_json::Value::as_str)
        .unwrap_or("stdio");
    match transport {
        "stdio" => {
            let command = value.get("command").and_then(serde_json::Value::as_str)?;
            let args = value
                .get("args")
                .and_then(serde_json::Value::as_array)
                .into_iter()
                .flatten()
                .filter_map(serde_json::Value::as_str)
                .map(str::to_string)
                .collect::<Vec<_>>();
            let mut parsed = McpTransport::stdio(command).with_args(args);
            if let Some(cwd) = value.get("cwd").and_then(serde_json::Value::as_str) {
                parsed = parsed.with_cwd(cwd);
            }
            if let Some(env) = value.get("env").and_then(serde_json::Value::as_object) {
                parsed = parsed.with_env(env.clone());
            }
            Some(parsed)
        }
        "streamable_http" | "http" => {
            let url = value.get("url").and_then(serde_json::Value::as_str)?;
            let mut parsed = McpTransport::streamable_http(url);
            if let Some(headers) = value.get("headers").and_then(serde_json::Value::as_object) {
                parsed = parsed.with_headers(headers.clone());
            }
            Some(parsed)
        }
        "sse" => {
            let url = value.get("url").and_then(serde_json::Value::as_str)?;
            let mut parsed = McpTransport::sse(url);
            if let Some(headers) = value.get("headers").and_then(serde_json::Value::as_object) {
                parsed = parsed.with_headers(headers.clone());
            }
            Some(parsed)
        }
        _ => None,
    }
}

fn parse_mcp_tools(value: &serde_json::Value) -> Vec<McpToolSpec> {
    value
        .get("tools")
        .and_then(serde_json::Value::as_array)
        .into_iter()
        .flatten()
        .filter_map(|tool| {
            let name = tool.get("name").and_then(serde_json::Value::as_str)?;
            let parameters = tool
                .get("parameters")
                .cloned()
                .unwrap_or_else(|| json!({"type": "object", "properties": {}}));
            let mut spec = McpToolSpec::new(name, parameters);
            if let Some(description) = tool.get("description").and_then(serde_json::Value::as_str) {
                spec = spec.with_description(description);
            }
            if let Some(task) = tool.get("task").and_then(serde_json::Value::as_bool) {
                spec = spec.with_task(task);
            }
            if let Some(metadata) = tool.get("metadata").and_then(serde_json::Value::as_object) {
                spec = spec.with_metadata(metadata.clone());
            }
            Some(spec)
        })
        .collect()
}