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.

use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;

use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use keyring::Entry;
use sha2::{Digest, Sha256};

const SERVICE_NAME: &str = "github-mcp";

fn config_dir() -> PathBuf {
    let home = std::env::var_os("HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("."));
    home.join(".github-mcp")
}

fn fallback_file() -> PathBuf {
    config_dir().join("credentials.enc")
}

/// Derived from a machine/user-specific value so the encrypted fallback
/// file isn't portable to another machine by design, mirroring the OS
/// keychain's own implicit scoping — REQ-2.3.5. AES-256-GCM (authenticated
/// encryption) rather than `targets::typescript`'s AES-256-CBC: an
/// equally-simple choice available in this crate's toolchain that also
/// detects tampering, which CBC alone does not.
fn encryption_key() -> [u8; 32] {
    let home = std::env::var("HOME").unwrap_or_default();
    let mut hasher = Sha256::new();
    hasher.update(home.as_bytes());
    hasher.update(SERVICE_NAME.as_bytes());
    hasher.finalize().into()
}

fn to_hex(bytes: &[u8]) -> String {
    bytes.iter().map(|b| format!("{b:02x}")).collect()
}

fn from_hex(input: &str) -> anyhow::Result<Vec<u8>> {
    if !input.len().is_multiple_of(2) {
        anyhow::bail!("odd-length hex string");
    }
    (0..input.len())
        .step_by(2)
        .map(|i| u8::from_str_radix(&input[i..i + 2], 16).map_err(anyhow::Error::from))
        .collect()
}

fn read_store() -> HashMap<String, String> {
    fs::read_to_string(fallback_file())
        .ok()
        .and_then(|contents| serde_json::from_str(&contents).ok())
        .unwrap_or_default()
}

fn write_store(store: &HashMap<String, String>) -> anyhow::Result<()> {
    let dir = config_dir();
    fs::create_dir_all(&dir)?;
    let contents = serde_json::to_string(store)?;
    fs::write(fallback_file(), contents)?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
        fs::set_permissions(fallback_file(), fs::Permissions::from_mode(0o600))?;
    }

    Ok(())
}

fn save_to_file(account: &str, value: &str) -> anyhow::Result<()> {
    let key_bytes = encryption_key();
    let key: &Key<Aes256Gcm> = (&key_bytes).into();
    let cipher = Aes256Gcm::new(key);
    let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
    let ciphertext = cipher
        .encrypt(&nonce, value.as_bytes())
        .map_err(|_| anyhow::anyhow!("failed to encrypt credential"))?;

    let mut store = read_store();
    store.insert(
        account.to_string(),
        format!("{}:{}", to_hex(&nonce), to_hex(&ciphertext)),
    );
    write_store(&store)
}

fn load_from_file(account: &str) -> anyhow::Result<Option<String>> {
    let store = read_store();
    let Some(raw) = store.get(account) else {
        return Ok(None);
    };
    let (nonce_hex, ciphertext_hex) = raw
        .split_once(':')
        .ok_or_else(|| anyhow::anyhow!("corrupt credential entry for '{account}'"))?;

    let key_bytes = encryption_key();
    let key: &Key<Aes256Gcm> = (&key_bytes).into();
    let cipher = Aes256Gcm::new(key);
    let nonce_bytes = from_hex(nonce_hex)?;
    let nonce = Nonce::from_slice(&nonce_bytes);
    let ciphertext = from_hex(ciphertext_hex)?;
    let plaintext = cipher
        .decrypt(nonce, ciphertext.as_ref())
        .map_err(|_| anyhow::anyhow!("failed to decrypt credential for '{account}'"))?;

    Ok(Some(String::from_utf8(plaintext)?))
}

fn delete_from_file(account: &str) -> anyhow::Result<()> {
    let mut store = read_store();
    if store.remove(account).is_some() {
        write_store(&store)?;
    }
    Ok(())
}

/// Persists a credential to the OS-native keychain when available (macOS
/// Keychain, Windows Credential Manager, Linux Secret Service via the
/// `keyring` crate), falling back to an AES-256-GCM-encrypted local file
/// when the platform backend is unavailable (e.g. no D-Bus secret-service
/// daemon running, common in minimal containers) — REQ-2.3.5.
pub fn save_credential(account: &str, value: &str) -> anyhow::Result<()> {
    match Entry::new(SERVICE_NAME, account).and_then(|entry| entry.set_password(value)) {
        Ok(()) => Ok(()),
        Err(err) => {
            tracing::warn!(error = %err, "OS keychain unavailable; falling back to encrypted file storage");
            save_to_file(account, value)
        }
    }
}

pub fn load_credential(account: &str) -> anyhow::Result<Option<String>> {
    match Entry::new(SERVICE_NAME, account).and_then(|entry| entry.get_password()) {
        Ok(password) => Ok(Some(password)),
        Err(keyring::Error::NoEntry) => Ok(None),
        Err(_) => load_from_file(account),
    }
}

pub fn delete_credential(account: &str) -> anyhow::Result<()> {
    match Entry::new(SERVICE_NAME, account).and_then(|entry| entry.delete_credential()) {
        Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
        Err(_) => delete_from_file(account),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn hex_round_trips_arbitrary_bytes() {
        let bytes = [0u8, 1, 15, 16, 255, 42];
        assert_eq!(from_hex(&to_hex(&bytes)).unwrap(), bytes);
    }

    #[test]
    fn file_fallback_round_trips_a_credential() {
        // Exercises the encrypted-file path directly rather than through
        // `save_credential`/`load_credential`, since the OS keychain (the
        // primary path) may or may not be available in the test
        // environment and this test should be deterministic either way.
        let dir = tempfile::tempdir().unwrap();
        // SAFETY: test-only env mutation, single-threaded within this test.
        unsafe {
            std::env::set_var("HOME", dir.path());
        }

        save_to_file("test-account", "s3cr3t").unwrap();
        let loaded = load_from_file("test-account").unwrap();
        assert_eq!(loaded.as_deref(), Some("s3cr3t"));

        delete_from_file("test-account").unwrap();
        assert_eq!(load_from_file("test-account").unwrap(), None);
    }
}