devboy-cli 0.28.0

Command-line interface for devboy-tools — `devboy` binary. Primary distribution is npm (@devboy-tools/cli); `cargo install devboy-cli` is the secondary channel.
Documentation
use crate::doctor::{CheckResult, CheckStatus, DiagnosticCheck, DiagnosticContext};
use async_trait::async_trait;
use devboy_mcp::KNOWN_BUILTIN_TOOLS;
use serde_json::json;

pub struct McpToolsCheck;

#[async_trait]
impl DiagnosticCheck for McpToolsCheck {
    fn id(&self) -> &'static str {
        "mcp.tools"
    }

    fn name(&self) -> &'static str {
        "Built-in MCP tools configuration"
    }

    fn category(&self) -> &'static str {
        "MCP Server"
    }

    async fn run(&self, ctx: &DiagnosticContext) -> CheckResult {
        let Some(config) = &ctx.config else {
            return CheckResult {
                id: self.id().to_string(),
                category: self.category().to_string(),
                name: self.name().to_string(),
                status: CheckStatus::Skipped,
                message: "Skipped because config could not be loaded".to_string(),
                details: None,
                fix_command: None,
                fix_url: None,
            };
        };

        let builtin = &config.builtin_tools;
        if let Err(error) = builtin.validate() {
            return CheckResult {
                id: self.id().to_string(),
                category: self.category().to_string(),
                name: self.name().to_string(),
                status: CheckStatus::Error,
                message: format!("Built-in MCP tools configuration is invalid: {error}"),
                details: ctx.verbose.then(|| {
                    json!({
                        "disabled": builtin.disabled,
                        "enabled": builtin.enabled,
                    })
                }),
                fix_command: None,
                fix_url: None,
            };
        }

        let unknown_tools: Vec<String> = builtin
            .disabled
            .iter()
            .chain(builtin.enabled.iter())
            .filter(|name| {
                !KNOWN_BUILTIN_TOOLS
                    .iter()
                    .any(|known| known == &name.as_str())
            })
            .cloned()
            .collect();

        let mode = if !builtin.enabled.is_empty() {
            "whitelist"
        } else if !builtin.disabled.is_empty() {
            "blacklist"
        } else {
            "default"
        };

        let status = if unknown_tools.is_empty() {
            CheckStatus::Pass
        } else {
            CheckStatus::Warning
        };

        let message = if !unknown_tools.is_empty() {
            format!(
                "Built-in MCP tools configuration contains {} unknown tool name(s)",
                unknown_tools.len()
            )
        } else if mode == "default" {
            "Built-in MCP tools configuration uses the default tool set".to_string()
        } else if mode == "whitelist" {
            format!(
                "Built-in MCP tools whitelist is valid ({} enabled)",
                builtin.enabled.len()
            )
        } else {
            format!(
                "Built-in MCP tools blacklist is valid ({} disabled)",
                builtin.disabled.len()
            )
        };

        CheckResult {
            id: self.id().to_string(),
            category: self.category().to_string(),
            name: self.name().to_string(),
            status,
            message,
            details: ctx.verbose.then(|| {
                json!({
                    "mode": mode,
                    "known_tools_count": KNOWN_BUILTIN_TOOLS.len(),
                    "disabled": builtin.disabled,
                    "enabled": builtin.enabled,
                    "unknown_tools": unknown_tools,
                })
            }),
            fix_command: None,
            fix_url: None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::doctor::DiagnosticContext;
    use devboy_core::{BuiltinToolsConfig, Config};
    use devboy_storage::MemoryStore;
    use std::path::PathBuf;
    use std::sync::Arc;

    fn test_context(config: Config, verbose: bool) -> DiagnosticContext {
        DiagnosticContext {
            config: Some(config),
            config_path: Some(PathBuf::from("config.toml")),
            config_exists: true,
            config_source: "test",
            config_path_error: None,
            config_load_error: None,
            credential_store: Arc::new(MemoryStore::new()),
            verbose,
        }
    }

    #[tokio::test]
    async fn mcp_tools_check_passes_for_default_configuration() {
        let ctx = test_context(Config::default(), true);
        let result = McpToolsCheck.run(&ctx).await;
        assert_eq!(result.status, CheckStatus::Pass);
        assert!(result.message.contains("default tool set"));
    }

    #[tokio::test]
    async fn mcp_tools_check_warns_for_unknown_tools() {
        let ctx = test_context(
            Config {
                builtin_tools: BuiltinToolsConfig {
                    disabled: vec!["get_issues".to_string(), "missing_tool".to_string()],
                    enabled: vec![],
                },
                ..Default::default()
            },
            true,
        );

        let result = McpToolsCheck.run(&ctx).await;
        assert_eq!(result.status, CheckStatus::Warning);
        let details = result.details.unwrap();
        assert_eq!(details["unknown_tools"][0], "missing_tool");
    }

    #[tokio::test]
    async fn mcp_tools_check_skips_without_config() {
        let ctx = DiagnosticContext {
            config: None,
            config_path: Some(PathBuf::from("config.toml")),
            config_exists: true,
            config_source: "test",
            config_path_error: None,
            config_load_error: None,
            credential_store: Arc::new(MemoryStore::new()),
            verbose: true,
        };

        let result = McpToolsCheck.run(&ctx).await;

        assert_eq!(result.status, CheckStatus::Skipped);
        assert!(result.message.contains("config could not be loaded"));
    }

    #[tokio::test]
    async fn mcp_tools_check_errors_for_invalid_configuration() {
        let ctx = test_context(
            Config {
                builtin_tools: BuiltinToolsConfig {
                    disabled: vec!["get_issues".to_string()],
                    enabled: vec!["get_issues".to_string()],
                },
                ..Default::default()
            },
            true,
        );

        let result = McpToolsCheck.run(&ctx).await;

        assert_eq!(result.status, CheckStatus::Error);
        assert!(result.message.contains("invalid"));
    }

    #[tokio::test]
    async fn mcp_tools_check_reports_valid_whitelist_and_blacklist() {
        let whitelist = test_context(
            Config {
                builtin_tools: BuiltinToolsConfig {
                    disabled: vec![],
                    enabled: vec!["get_issues".to_string()],
                },
                ..Default::default()
            },
            true,
        );
        let whitelist_result = McpToolsCheck.run(&whitelist).await;
        assert_eq!(whitelist_result.status, CheckStatus::Pass);
        assert!(whitelist_result.message.contains("whitelist"));

        let blacklist = test_context(
            Config {
                builtin_tools: BuiltinToolsConfig {
                    disabled: vec!["get_issues".to_string()],
                    enabled: vec![],
                },
                ..Default::default()
            },
            true,
        );
        let blacklist_result = McpToolsCheck.run(&blacklist).await;
        assert_eq!(blacklist_result.status, CheckStatus::Pass);
        assert!(blacklist_result.message.contains("blacklist"));
    }
}