github-mcp 0.4.0

GitHub v3 REST API MCP server, generated by mcpify.
Documentation
// GitHub v3 REST API 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 std::time::Duration;

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

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

async fn prompt_base_url() -> anyhow::Result<String> {
    let url =
        tokio::task::spawn_blocking(|| inquire::Text::new("GitHub v3 REST API base URL:").prompt())
            .await??;

    let client = reqwest::Client::new();
    match client
        .head(&url)
        .timeout(Duration::from_secs(5))
        .send()
        .await
    {
        Ok(_) => println!("reachable"),
        Err(_) => println!(
            "could not reach that URL yet — continuing anyway (fix it later with `test-connection`)"
        ),
    }

    Ok(url)
}

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

    Ok(match selection {
        "pat" => AuthMethod::Pat,
        "apiKey" => AuthMethod::ApiKey,
        "basic" => AuthMethod::Basic,
        other => anyhow::bail!("unexpected auth method selection '{other}'"),
    })
}

// mcpify:versions:begin
async fn prompt_api_version() -> anyhow::Result<String> {
    let choices = vec![
        "gh-2026-03-10 (default/latest)",
        "ghec-2026-03-10",
        "ghes-3.21",
        "ghes-3.20",
        "ghes-3.19",
    ];
    let selection = tokio::task::spawn_blocking(move || {
        inquire::Select::new("API version to use:", choices).prompt()
    })
    .await??;

    Ok(match selection {
        "gh-2026-03-10 (default/latest)" => "gh-2026-03-10".to_string(),
        "ghec-2026-03-10" => "ghec-2026-03-10".to_string(),
        "ghes-3.21" => "ghes-3.21".to_string(),
        "ghes-3.20" => "ghes-3.20".to_string(),
        "ghes-3.19" => "ghes-3.19".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::Basic => {
                credentials.insert(
                    "username".to_string(),
                    inquire::Text::new("Username:").prompt()?,
                );
                credentials.insert(
                    "password".to_string(),
                    inquire::Password::new("Password:")
                        .without_confirmation()
                        .prompt()?,
                );
            }
            AuthMethod::Pat => {
                credentials.insert(
                    "token".to_string(),
                    inquire::Password::new("Personal access token:")
                        .without_confirmation()
                        .prompt()?,
                );
            }
            AuthMethod::ApiKey => {
                credentials.insert(
                    "api_key".to_string(),
                    inquire::Password::new("API key:")
                        .without_confirmation()
                        .prompt()?,
                );
            }
        }
        Ok(credentials)
    })
    .await?
}

/// Where `load_config` (`src/core/config_manager.rs`) actually looks for a
/// config file, in cascade order: local-cwd first, then the home-dir tier.
/// Kept in lockstep with that module's `LOCAL_CONFIG_FILE`/`CONFIG_DIR_NAME`
/// constants so the file this wizard writes is one `load_config` will
/// actually read back on the next run.
const LOCAL_CONFIG_FILE: &str = "github-mcp.config.yml";
const CONFIG_DIR_NAME: &str = ".github-mcp";

fn resolve_home_dir() -> std::path::PathBuf {
    std::env::var_os("HOME")
        .or_else(|| std::env::var_os("USERPROFILE"))
        .map(std::path::PathBuf::from)
        .unwrap_or_else(|| std::path::PathBuf::from("."))
}

/// Persists the settings the operator just chose. `env` (env-var-prefixed
/// keys, credentials included) backs the `.env` file and the printed CLI
/// invocation. `config_fields` (flat, unprefixed keys matching `Config`'s
/// own field names — no credentials, which always go through
/// `save_credential`'s keychain/file path instead) backs the YAML config
/// file option, written to whichever path `load_config`'s cascade actually
/// reads: the local-cwd file if the operator picks "local", or
/// `~/.github-mcp/config.yml` if they pick "global".
async fn prompt_persistence(
    env: &HashMap<String, String>,
    config_fields: &HashMap<String, String>,
) -> anyhow::Result<()> {
    let choices = vec![
        "Write a .env file",
        "Write a config.yml file",
        "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");
        }
        "Write a config.yml file" => {
            let tier_choices = vec![
                "Local (this directory only — ./github-mcp.config.yml)",
                "Global (all runs for this user — ~/.github-mcp/config.yml)",
            ];
            let tier_selection = tokio::task::spawn_blocking(move || {
                inquire::Select::new(
                    "Save this config for just this project, or globally?",
                    tier_choices,
                )
                .prompt()
            })
            .await??;

            let yaml = serde_yaml::to_string(config_fields)?;
            if tier_selection.starts_with("Local") {
                std::fs::write(LOCAL_CONFIG_FILE, &yaml)?;
                println!("Wrote {LOCAL_CONFIG_FILE}");
            } else {
                let dir = resolve_home_dir().join(CONFIG_DIR_NAME);
                std::fs::create_dir_all(&dir)?;
                let path = dir.join("config.yml");
                std::fs::write(&path, &yaml)?;
                println!("Wrote {}", path.display());
            }
        }
        _ => {
            let flags = env
                .iter()
                .map(|(key, value)| {
                    format!("--{} {:?}", key.to_lowercase().replace('_', "-"), value)
                })
                .collect::<Vec<_>>()
                .join(" ");
            println!("github-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 shows credentials as headers only
/// (never env/CLI args, matching `AuthManager::apply_auth_headers`'s
/// HTTP-only-uses-per-request-credentials behavior); 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,
    auth_method: AuthMethod,
    env: &HashMap<String, String>,
) {
    match transport {
        Transport::Stdio => {
            let config = serde_json::json!({
                "mcpServers": {
                    "github-mcp": {
                        "command": "github-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: `github-mcp start {cli_args}`)");
        }
        Transport::Http => {
            let (_, header_name) = github_mcp::auth::auth_manager::header_location_for(auth_method);
            let mut headers = serde_json::Map::new();
            headers.insert(
                header_name.to_string(),
                serde_json::Value::String("<credential value here>".to_string()),
            );
            let config = serde_json::json!({
                "mcpServers": {
                    "github-mcp": {
                        "url": "http://127.0.0.1:3000/mcp",
                        "headers": headers,
                    }
                }
            });
            println!(
                "\nMCP client config (http) — adjust the host/port to match how you run \
                 `github-mcp http`:"
            );
            println!("{}", serde_json::to_string_pretty(&config).unwrap());
            println!(
                "Credentials are never read from local config/env over HTTP transport — \
                 every request must carry its own \"{header_name}\" header."
            );
        }
    }
}

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("GITHUB_MCP_URL".to_string(), url);
    env.insert(
        "GITHUB_MCP_AUTH_METHOD".to_string(),
        serde_json::to_value(auth_method)?
            .as_str()
            .unwrap_or_default()
            .to_string(),
    );
    env.insert("GITHUB_MCP_API_VERSION".to_string(), api_version);
    env.insert(
        "GITHUB_MCP_TRANSPORT".to_string(),
        serde_json::to_value(transport)?
            .as_str()
            .unwrap_or_default()
            .to_string(),
    );
    for (key, value) in &credentials {
        env.insert(format!("GITHUB_MCP_{}", to_env_key(key)), value.clone());
    }

    // Flat, unprefixed keys matching `Config`'s own field names — the shape
    // `load_config`'s YAML layers expect. Deliberately excludes credentials
    // (those only ever go through `save_credential`'s keychain/file path).
    let mut config_fields: HashMap<String, String> = HashMap::new();
    config_fields.insert("url".to_string(), env["GITHUB_MCP_URL"].clone());
    config_fields.insert(
        "auth_method".to_string(),
        env["GITHUB_MCP_AUTH_METHOD"].clone(),
    );
    config_fields.insert(
        "api_version".to_string(),
        env["GITHUB_MCP_API_VERSION"].clone(),
    );
    config_fields.insert("transport".to_string(), env["GITHUB_MCP_TRANSPORT"].clone());

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

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