sqlserver-mcp 0.5.0

SQL Server 2025/2022/2019/2017 - master/msdb/sandbox combined catalog MCP server, generated by mcpify.
Documentation
// SQL Server 2025 - master/msdb/sandbox combined catalog MCP server — generated by mcpify. Do not hand-edit.
//
// Interactive setup wizard (REQ-1.6): collects the API URL and the
// credentials the discovered auth scheme(s) need, then persists them per
// the operator's choice: a .env file, a config.json file, or a
// ready-to-run CLI invocation printed to stdout (nothing written to disk).
// `inquire`'s prompts are blocking — run through `spawn_blocking`, the
// same pattern mcpify's own `auth_profile::prompt` documents for calling
// it from an async runtime.

use std::collections::HashMap;

use sqlserver_mcp_catalog::core::config_schema::{AuthMethod, Transport};
use sqlserver_mcp_catalog::core::credential_storage::save_credential;

fn to_env_key(key: &str) -> String {
    key.to_uppercase()
}

async fn prompt_base_url() -> anyhow::Result<String> {
    tokio::task::spawn_blocking(|| {
        inquire::Text::new("SQL Server host (or host:port -- defaults to port 1433 if omitted):")
            .prompt()
    })
    .await?
    .map_err(anyhow::Error::from)
}

async fn prompt_auth_method() -> anyhow::Result<AuthMethod> {
    let choices = vec!["sql_server", "windows", "azure_ad"];
    let selection = tokio::task::spawn_blocking(move || {
        inquire::Select::new("Authentication method:", choices).prompt()
    })
    .await??;

    Ok(match selection {
        "sql_server" => AuthMethod::SqlServer,
        "windows" => AuthMethod::Windows,
        "azure_ad" => AuthMethod::AzureAd,
        other => anyhow::bail!("unexpected auth method selection '{other}'"),
    })
}

// mcpify:versions:begin
async fn prompt_api_version() -> anyhow::Result<String> {
    let choices = vec!["2025 (default/latest)", "2022", "2019", "2017"];
    let selection = tokio::task::spawn_blocking(move || {
        inquire::Select::new("API version to use:", choices).prompt()
    })
    .await??;

    Ok(match selection {
        "2025 (default/latest)" => "2025".to_string(),
        "2022" => "2022".to_string(),
        "2019" => "2019".to_string(),
        "2017" => "2017".to_string(),
        other => other.to_string(),
    })
}
// mcpify:versions:end

async fn prompt_transport() -> anyhow::Result<Transport> {
    let choices = vec![
        "stdio (spawned as a subprocess by the MCP client)",
        "http (a standalone server the MCP client connects to over the network)",
    ];
    let selection = tokio::task::spawn_blocking(move || {
        inquire::Select::new("Which transport will this deployment use?", choices).prompt()
    })
    .await??;

    Ok(if selection.starts_with("stdio") {
        Transport::Stdio
    } else {
        Transport::Http
    })
}

async fn prompt_credentials(auth_method: AuthMethod) -> anyhow::Result<HashMap<String, String>> {
    tokio::task::spawn_blocking(move || -> anyhow::Result<HashMap<String, String>> {
        let mut credentials = HashMap::new();
        match auth_method {
            AuthMethod::SqlServer => {
                credentials.insert(
                    "username".to_string(),
                    inquire::Text::new("SQL login username:").prompt()?,
                );
                credentials.insert(
                    "password".to_string(),
                    inquire::Password::new("Password:")
                        .without_confirmation()
                        .prompt()?,
                );
            }
            AuthMethod::Windows => {
                credentials.insert(
                    "username".to_string(),
                    inquire::Text::new("Windows username (optionally DOMAIN\\user):").prompt()?,
                );
                credentials.insert(
                    "password".to_string(),
                    inquire::Password::new("Password:")
                        .without_confirmation()
                        .prompt()?,
                );
            }
            AuthMethod::AzureAd => {
                credentials.insert(
                    "client_id".to_string(),
                    inquire::Text::new("Azure AD application (client) ID:").prompt()?,
                );
                credentials.insert(
                    "client_secret".to_string(),
                    inquire::Password::new("Azure AD client secret:")
                        .without_confirmation()
                        .prompt()?,
                );
                let tenant_id = inquire::Text::new("Azure AD tenant ID:").prompt()?;
                if !tenant_id.is_empty() {
                    credentials.insert("tenant_id".to_string(), tenant_id);
                }
            }
        }
        Ok(credentials)
    })
    .await?
}

/// Persists the non-secret config fields (`url`/`auth_method`/`api_version`/
/// `transport`) as YAML matching exactly what `config_manager::load_config`
/// reads back — never the credential fields, which stay in the OS
/// keychain/encrypted-file fallback via `save_credential` (called by the
/// caller before this runs). Writing anywhere else (a stray `.env`-only
/// var name, `config.json`, etc.) would leave the persisted config silently
/// ignored on every subsequent run, since `load_config`'s cascade only ever
/// reads `./sqlserver-mcp.config.yml` (local) or
/// `~/.sqlserver-mcp/config.yml` (global).
async fn write_config_yaml(global: bool, config_fields: &serde_json::Value) -> anyhow::Result<()> {
    let yaml = serde_yaml::to_string(config_fields)?;
    let path = if global {
        let dir = sqlserver_mcp_catalog::core::credential_storage::resolve_home_dir()
            .join(".sqlserver-mcp");
        std::fs::create_dir_all(&dir)?;
        dir.join("config.yml")
    } else {
        std::path::PathBuf::from("sqlserver-mcp.config.yml")
    };
    std::fs::write(&path, yaml)?;
    println!("Wrote {}", path.display());
    println!(
        "Credentials are never written to this file — they're stored via the OS \
         keychain/encrypted-file fallback from the credential prompt above."
    );
    Ok(())
}

async fn prompt_persistence(
    env: &HashMap<String, String>,
    config_fields: &serde_json::Value,
) -> anyhow::Result<()> {
    let choices = vec![
        "Write a .env file",
        "Write a config.yml file (global — ~/.sqlserver-mcp/config.yml, read on every invocation on this machine)",
        "Write a config.yml file (local — ./sqlserver-mcp.config.yml, read only from this directory)",
        "Print a ready-to-run CLI invocation (nothing written to disk)",
    ];
    let selection = tokio::task::spawn_blocking(move || {
        inquire::Select::new("How should these settings be saved?", choices).prompt()
    })
    .await??;

    match selection {
        "Write a .env file" => {
            let contents = env
                .iter()
                .map(|(key, value)| format!("{key}={value}"))
                .collect::<Vec<_>>()
                .join("\n");
            std::fs::write(".env", format!("{contents}\n"))?;
            println!("Wrote .env");
        }
        s if s.starts_with("Write a config.yml file (global") => {
            write_config_yaml(true, config_fields).await?;
        }
        s if s.starts_with("Write a config.yml file (local") => {
            write_config_yaml(false, config_fields).await?;
        }
        _ => {
            let flags = env
                .iter()
                .map(|(key, value)| {
                    format!("--{} {:?}", key.to_lowercase().replace('_', "-"), value)
                })
                .collect::<Vec<_>>()
                .join(" ");
            println!("sqlserver-mcp start {flags}");
        }
    }
    Ok(())
}

/// Prints a ready-to-use MCP client config (the `mcpServers` block a host
/// like Claude Code/Desktop expects) for whichever transport was selected
/// — the stdio and http shapes are mutually exclusive, never both, since
/// they're two different ways of running this same server and a client
/// only ever picks one. HTTP transport carries no per-request credential
/// (SQL Server auth is always this server's own configured identity, not
/// something an MCP client supplies per call — see
/// `AuthManager::resolve_tds_auth`); stdio shows both the `env` block and
/// the equivalent all-CLI-args invocation, since the config cascade
/// accepts either for a spawned subprocess.
fn print_mcp_client_config(transport: Transport, env: &HashMap<String, String>) {
    match transport {
        Transport::Stdio => {
            let config = serde_json::json!({
                "mcpServers": {
                    "sqlserver-mcp": {
                        "command": "sqlserver-mcp",
                        "args": ["start"],
                        "env": env,
                    }
                }
            });
            println!("\nMCP client config (stdio):");
            println!("{}", serde_json::to_string_pretty(&config).unwrap());

            let cli_args = env
                .iter()
                .map(|(key, value)| format!("--{} {value:?}", key.to_lowercase().replace('_', "-")))
                .collect::<Vec<_>>()
                .join(" ");
            println!("(equivalently: `sqlserver-mcp start {cli_args}`)");
        }
        Transport::Http => {
            let config = serde_json::json!({
                "mcpServers": {
                    "sqlserver-mcp": {
                        "url": "http://127.0.0.1:3000/mcp",
                    }
                }
            });
            println!(
                "\nMCP client config (http) — adjust the host/port to match how you run \
                 `sqlserver-mcp http`:"
            );
            println!("{}", serde_json::to_string_pretty(&config).unwrap());
            println!(
                "This server connects to SQL Server with its own configured credentials \
                 (set via env/config/keychain on the machine running it) — every caller who \
                 reaches this HTTP endpoint gets the same DB access. Put it behind your own \
                 network-level access control (reverse proxy, VPN, firewall rule) if it's \
                 reachable beyond localhost."
            );
        }
    }
}

pub async fn run_setup_wizard() -> anyhow::Result<()> {
    let url = prompt_base_url().await?;
    let auth_method = prompt_auth_method().await?;
    let credentials = prompt_credentials(auth_method).await?;
    let api_version = prompt_api_version().await?;
    let transport = prompt_transport().await?;

    save_credential("active-credentials", &serde_json::to_string(&credentials)?)?;

    let mut env: HashMap<String, String> = HashMap::new();
    env.insert("SQLSERVER_URL".to_string(), url);
    env.insert(
        "SQLSERVER_AUTH_METHOD".to_string(),
        serde_json::to_value(auth_method)?
            .as_str()
            .unwrap_or_default()
            .to_string(),
    );
    env.insert("SQLSERVER_API_VERSION".to_string(), api_version);
    env.insert(
        "SQLSERVER_TRANSPORT".to_string(),
        serde_json::to_value(transport)?
            .as_str()
            .unwrap_or_default()
            .to_string(),
    );
    for (key, value) in &credentials {
        env.insert(format!("SQLSERVER_{}", to_env_key(key)), value.clone());
    }

    // Non-secret fields only, keyed exactly as `config_manager::load_config`
    // reads them back from YAML — credentials never go in this file.
    let config_fields = serde_json::json!({
        "url": env.get("SQLSERVER_URL"),
        "auth_method": env.get("SQLSERVER_AUTH_METHOD"),
        "api_version": env.get("SQLSERVER_API_VERSION"),
        "transport": env.get("SQLSERVER_TRANSPORT"),
    });

    prompt_persistence(&env, &config_fields).await?;
    print_mcp_client_config(transport, &env);

    let run_command = match transport {
        Transport::Stdio => "start",
        Transport::Http => "http",
    };
    println!("Setup complete! Run: sqlserver-mcp {run_command}");
    Ok(())
}