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}'"),
})
}
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(),
})
}
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>> {
match auth_method {
AuthMethod::Basic => {
tokio::task::spawn_blocking(move || -> anyhow::Result<HashMap<String, String>> {
let mut credentials = HashMap::new();
credentials.insert(
"username".to_string(),
inquire::Text::new("Username:").prompt()?,
);
credentials.insert(
"password".to_string(),
inquire::Password::new("Password:")
.without_confirmation()
.prompt()?,
);
Ok(credentials)
})
.await?
}
AuthMethod::Pat => {
tokio::task::spawn_blocking(move || -> anyhow::Result<HashMap<String, String>> {
let mut credentials = HashMap::new();
credentials.insert(
"token".to_string(),
inquire::Password::new("Personal access token:")
.without_confirmation()
.prompt()?,
);
Ok(credentials)
})
.await?
}
AuthMethod::ApiKey => {
tokio::task::spawn_blocking(move || -> anyhow::Result<HashMap<String, String>> {
let mut credentials = HashMap::new();
credentials.insert(
"api_key".to_string(),
inquire::Password::new("API key:")
.without_confirmation()
.prompt()?,
);
Ok(credentials)
})
.await?
}
}
}
async fn write_config_yaml(
path: &std::path::Path,
config_fields: &serde_json::Value,
) -> anyhow::Result<()> {
let yaml = serde_yaml::to_string(config_fields)?;
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
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 — ~/.github-mcp/config.yml, read on every invocation on this machine)",
"Write a config.yml file (local — ./github-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??;
persist_selection(selection, env, config_fields).await
}
async fn persist_selection(
selection: &str,
env: &HashMap<String, String>,
config_fields: &serde_json::Value,
) -> anyhow::Result<()> {
let local_dir = std::env::current_dir()?;
let global_config =
github_mcp::core::credential_storage::resolve_home_dir().join(".github-mcp/config.yml");
persist_selection_at(selection, env, config_fields, &local_dir, &global_config).await
}
async fn persist_selection_at(
selection: &str,
env: &HashMap<String, String>,
config_fields: &serde_json::Value,
local_dir: &std::path::Path,
global_config: &std::path::Path,
) -> anyhow::Result<()> {
match selection {
"Write a .env file" => {
let contents = env
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(local_dir.join(".env"), format!("{contents}\n"))?;
println!("Wrote .env");
}
s if s.starts_with("Write a config.yml file (global") => {
write_config_yaml(global_config, config_fields).await?;
}
s if s.starts_with("Write a config.yml file (local") => {
write_config_yaml(&local_dir.join("github-mcp.config.yml"), config_fields).await?;
}
_ => {
let flags = env
.iter()
.map(|(key, value)| {
format!("--{} {:?}", key.to_lowercase().replace('_', "-"), value)
})
.collect::<Vec<_>>()
.join(" ");
println!("github-mcp start {flags}");
}
}
Ok(())
}
fn build_runtime_settings(
url: String,
auth_method: AuthMethod,
api_version: String,
transport: Transport,
credentials: &HashMap<String, String>,
) -> anyhow::Result<(HashMap<String, String>, serde_json::Value)> {
let mut env = 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());
}
let config_fields = serde_json::json!({
"url": env.get("GITHUB_MCP_URL"),
"auth_method": env.get("GITHUB_MCP_AUTH_METHOD"),
"api_version": env.get("GITHUB_MCP_API_VERSION"),
"transport": env.get("GITHUB_MCP_TRANSPORT"),
});
Ok((env, config_fields))
}
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 (env, config_fields) =
build_runtime_settings(url, auth_method, api_version, transport, &credentials)?;
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(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn runtime_settings_keep_config_fields_and_filter_ephemeral_oauth_values() {
let credentials = HashMap::from([
("client_id".to_string(), "client".to_string()),
("authorization_code".to_string(), "spent-code".to_string()),
]);
let (env, config) = build_runtime_settings(
"https://api.example/v1".to_string(),
AuthMethod::Pat,
"gh-2026-03-10".to_string(),
Transport::Stdio,
&credentials,
)
.unwrap();
assert_eq!(config["url"], "https://api.example/v1");
assert_eq!(config["transport"], "stdio");
assert_eq!(env["GITHUB_MCP_CLIENT_ID"], "client");
assert_eq!(env["GITHUB_MCP_AUTHORIZATION_CODE"], "spent-code");
}
#[tokio::test]
async fn noninteractive_output_paths_cover_both_transports() {
let credentials = HashMap::new();
let (env, config) = build_runtime_settings(
"https://api.example/v1".to_string(),
prompt_auth_method().await.unwrap(),
prompt_api_version().await.unwrap(),
Transport::Http,
&credentials,
)
.unwrap();
persist_selection("Print a ready-to-run CLI invocation", &env, &config)
.await
.unwrap();
print_mcp_client_config(Transport::Stdio, AuthMethod::Pat, &env);
print_mcp_client_config(Transport::Http, AuthMethod::Pat, &env);
}
#[tokio::test]
async fn file_persistence_writes_env_local_yaml_and_global_yaml() {
let dir = tempfile::tempdir().unwrap();
let local_dir = dir.path().join("local");
let global_config = dir.path().join("global/config.yml");
std::fs::create_dir_all(&local_dir).unwrap();
let credentials = HashMap::new();
let (env, config) = build_runtime_settings(
"https://api.example/v1".to_string(),
AuthMethod::Pat,
"gh-2026-03-10".to_string(),
Transport::Stdio,
&credentials,
)
.unwrap();
persist_selection_at(
"Write a .env file",
&env,
&config,
&local_dir,
&global_config,
)
.await
.unwrap();
persist_selection_at(
"Write a config.yml file (local — ./github-mcp.config.yml, read only from this directory)",
&env,
&config,
&local_dir,
&global_config,
)
.await
.unwrap();
persist_selection_at(
"Write a config.yml file (global — ~/.github-mcp/config.yml, read on every invocation on this machine)",
&env,
&config,
&local_dir,
&global_config,
)
.await
.unwrap();
assert!(local_dir.join(".env").is_file());
assert!(local_dir.join("github-mcp.config.yml").is_file());
assert!(global_config.is_file());
}
}