github-mcp 0.1.3

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",
        "ghes-2.22",
    ];
    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(),
        "ghes-2.22" => "ghes-2.22".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?
}

async fn prompt_persistence(env: &HashMap<String, String>) -> anyhow::Result<()> {
    let choices = vec![
        "Write a .env file",
        "Write a config.json 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.json file" => {
            std::fs::write("config.json", serde_json::to_string_pretty(env)?)?;
            println!("Wrote config.json");
        }
        _ => {
            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());
    }

    prompt_persistence(&env).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(())
}