github-mcp 0.5.0

GitHub v3 REST API MCP server, generated by mcpify.
Documentation
// GitHub v3 REST API MCP server — generated by mcpify. Do not hand-edit.

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

use super::auth_strategy::{AuthConfig, AuthStrategy, Credentials};
use super::errors::AuthError;
use super::request_credentials::RequestCredentials;
use super::strategies::api_key::ApiKeyStrategy;
use super::strategies::basic::BasicAuthStrategy;
use super::strategies::pat::PatAuthStrategy;
use super::strategies::stub::StubAuthStrategy;

const CREDENTIAL_ACCOUNT: &str = "active-credentials";
const ENV_PREFIX: &str = "GITHUB_MCP";

/// Builds an `AuthConfig` straight from the `<PREFIX>_TOKEN`/`_API_KEY`/
/// `_USERNAME`/`_PASSWORD` env vars documented in `.env.example`, if the
/// vars this `auth_method` needs are actually set — closes the gap where
/// those vars were documented but never wired into the credentials lookup,
/// leaving a prior `setup` run (keychain/file) as the only way to
/// authenticate. Returns `None` when the required var(s) for this
/// deployment's `auth_method` aren't present, so callers fall back to the
/// stored-credential lookup unchanged.
fn credentials_from_env(auth_method: AuthMethod) -> Option<AuthConfig> {
    let mut config = AuthConfig::new();
    match auth_method {
        AuthMethod::Pat => {
            let token = std::env::var(format!("{ENV_PREFIX}_TOKEN"))
                .or_else(|_| std::env::var(format!("{ENV_PREFIX}_API_KEY")))
                .ok()?;
            config.insert("token".to_string(), token);
        }
        AuthMethod::ApiKey => {
            let api_key = std::env::var(format!("{ENV_PREFIX}_API_KEY"))
                .or_else(|_| std::env::var(format!("{ENV_PREFIX}_TOKEN")))
                .ok()?;
            config.insert("api_key".to_string(), api_key);
        }
        AuthMethod::Basic => {
            let username = std::env::var(format!("{ENV_PREFIX}_USERNAME")).ok()?;
            let password = std::env::var(format!("{ENV_PREFIX}_PASSWORD")).ok()?;
            config.insert("username".to_string(), username);
            config.insert("password".to_string(), password);
        }
        #[allow(unreachable_patterns)]
        _ => return None,
    }
    Some(config)
}

fn strategy_for(auth_method: AuthMethod) -> Box<dyn AuthStrategy> {
    match auth_method {
        AuthMethod::Basic => Box::new(BasicAuthStrategy),
        AuthMethod::ApiKey => Box::new(ApiKeyStrategy),
        AuthMethod::Pat => Box::new(PatAuthStrategy),
        #[allow(unreachable_patterns)]
        _ => Box::new(StubAuthStrategy),
    }
}

/// Where `auth_method`'s credential value travels on the wire — used only
/// by the HTTP-transport auth-gate/extractor (`http::auth_extractor`) to
/// know which incoming header to read; stdio never calls this.
pub fn header_location_for(auth_method: AuthMethod) -> (&'static str, &'static str) {
    match auth_method {
        AuthMethod::Pat => ("header", "Authorization"),
        AuthMethod::ApiKey => ("header", "Authorization"),
        AuthMethod::Basic => ("header", "Authorization"),
    }
}

/// Selects exactly one active auth strategy per deployment, chosen via the
/// `auth_method` config value (REQ-1.2.3) — there is no runtime engine for
/// resolving multiple simultaneously-required schemes.
pub struct AuthManager {
    auth_method: AuthMethod,
    strategy: Box<dyn AuthStrategy>,
    cached_credentials: Option<Credentials>,
}

impl AuthManager {
    pub fn new(auth_method: AuthMethod) -> Self {
        Self {
            strategy: strategy_for(auth_method),
            auth_method,
            cached_credentials: None,
        }
    }

    pub async fn login(&mut self, config: &AuthConfig) -> anyhow::Result<Credentials> {
        let credentials = self.strategy.authenticate(config).await?;
        self.cached_credentials = Some(credentials.clone());
        save_credential(CREDENTIAL_ACCOUNT, &serde_json::to_string(&credentials)?)?;
        Ok(credentials)
    }

    /// Seeds in-memory credentials directly, bypassing OS-keychain/file
    /// storage entirely — for tests (which must never touch the real OS
    /// keychain as a side effect of running) and for callers that already
    /// hold validated credentials from elsewhere.
    pub fn set_credentials(&mut self, credentials: Credentials) {
        self.cached_credentials = Some(credentials);
    }

    pub async fn credentials(&mut self) -> anyhow::Result<Credentials> {
        if let Some(cached) = &self.cached_credentials
            && self.strategy.validate_credentials(cached)
        {
            return self.normalize_credentials(cached).await;
        }

        if let Some(env_config) = credentials_from_env(self.auth_method)
            && let Ok(from_env) = self.strategy.authenticate(&env_config).await
            && self.strategy.validate_credentials(&from_env)
        {
            self.cached_credentials = Some(from_env.clone());
            return Ok(from_env);
        }

        if let Some(stored) = load_credential(CREDENTIAL_ACCOUNT)? {
            let parsed: Credentials = serde_json::from_str(&stored)?;
            if self.strategy.validate_credentials(&parsed) {
                let normalized = self.normalize_credentials(&parsed).await?;
                self.cached_credentials = Some(normalized.clone());
                save_credential(CREDENTIAL_ACCOUNT, &serde_json::to_string(&normalized)?)?;
                return Ok(normalized);
            }
            // A blob with a `refresh_token` has already completed the
            // initial code exchange at some point (it just expired) — use
            // the cheap refresh path. A blob with neither `access_token`
            // nor `refresh_token` is straight out of the setup wizard
            // (raw `authorization_code`, never exchanged yet), so fall
            // through to the full exchange instead.
            let exchanged = if parsed.contains_key("refresh_token") {
                self.strategy.refresh_token(&parsed).await
            } else {
                self.strategy.authenticate(&parsed).await
            };
            if let Ok(exchanged) = exchanged {
                let normalized = self.normalize_credentials(&exchanged).await?;
                self.cached_credentials = Some(normalized.clone());
                save_credential(CREDENTIAL_ACCOUNT, &serde_json::to_string(&normalized)?)?;
                return Ok(normalized);
            }
        }

        Err(AuthError::NoActiveCredentials(format!("{:?}", self.auth_method)).into())
    }

    async fn normalize_credentials(
        &self,
        credentials: &Credentials,
    ) -> anyhow::Result<Credentials> {
        if credentials.contains_key("authorization_header")
            || credentials.contains_key("api_key")
            || credentials.contains_key("request_header_name")
        {
            return Ok(credentials.clone());
        }

        if let Some(access_token) = credentials.get("access_token") {
            let mut normalized = credentials.clone();
            normalized.insert(
                "authorization_header".to_string(),
                format!("Bearer {access_token}"),
            );
            return Ok(normalized);
        }

        self.strategy.authenticate(credentials).await
    }

    /// Decorates outgoing request headers with credentials for `transport`:
    /// on `Transport::Http`, `request_override` (extracted per-request from
    /// the caller's own headers — see `http::auth_extractor`) is the *only*
    /// source ever consulted, since HTTP deployments must never leak the
    /// operator's local config/keychain secrets to a caller who didn't
    /// supply their own; a missing override is a hard error, not a
    /// fallback. On `Transport::Stdio`, `request_override` is ignored and
    /// behavior is unchanged from before HTTP-transport support existed:
    /// `self.credentials()` (config cascade + keychain).
    ///
    /// API-key placement (header vs. query, and the header/param name)
    /// defaults to an `X-Api-Key` header here; a scheme declaring a
    /// different location is one of Story R6's api-client responsibilities
    /// to refine, not this manager's — `request_override`'s
    /// `request_header_name`/`request_header_value` pair is the one
    /// exception, since the HTTP path already knows the scheme's declared
    /// header name (`RsAuthSchemeView::header_name`) at generation time.
    pub async fn apply_auth_headers(
        &mut self,
        mut headers: std::collections::HashMap<String, String>,
        method: &str,
        url: &str,
        transport: Transport,
        request_override: Option<&RequestCredentials>,
    ) -> anyhow::Result<std::collections::HashMap<String, String>> {
        let credentials = match (transport, request_override) {
            (Transport::Http, Some(override_credentials)) => {
                override_credentials.clone().into_credentials_map()
            }
            (Transport::Http, None) => {
                anyhow::bail!(
                    "no credentials were supplied on this HTTP request; local config/env/keychain \
                     is never used as a fallback over HTTP transport"
                );
            }
            (Transport::Stdio, _) => self.credentials().await?,
        };
        // `method`/`url` are part of this method's fixed public signature
        // (every active strategy's header-decoration needs them in
        // principle), but only OAuth1's per-request signing actually
        // consumes them — unused here since OAuth1 wasn't discovered for
        // this deployment.
        let _ = method;
        let _ = url;

        if let (Some(name), Some(value)) = (
            credentials.get("request_header_name"),
            credentials.get("request_header_value"),
        ) {
            // HTTP-transport relay case: both name and value came from the
            // caller's own incoming request (`RequestCredentials`).
            headers.insert(name.clone(), value.clone());
        } else if let Some(header) = credentials.get("authorization_header") {
            headers.insert("Authorization".to_string(), header.clone());
        } else if let Some(api_key) = credentials.get("api_key") {
            let header_name = credentials
                .get("request_header_name")
                .cloned()
                .unwrap_or_else(|| "X-Api-Key".to_string());
            headers.insert(header_name, api_key.clone());
        }
        Ok(headers)
    }
}

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

    #[tokio::test]
    async fn seeded_credentials_win_over_stored_ones() {
        let mut manager = AuthManager::new(AuthMethod::Pat);
        let mut credentials = Credentials::new();
        // Seeded with whatever field(s) `auth_methods.0`'s own
        // `validate_credentials` actually checks for — each strategy
        // checks a different key (see auth/strategies/*.rs), so this has
        // to match the specific first-discovered method, not just
        // "oauth1 or not".
        credentials.insert("token".to_string(), "abc".to_string());
        manager.set_credentials(credentials.clone());

        let resolved = manager.credentials().await.unwrap();
        assert_eq!(
            resolved.get("authorization_header").map(String::as_str),
            Some("Bearer abc")
        );
    }
    #[tokio::test]
    async fn http_transport_uses_the_request_override_not_the_config_cascade() {
        let mut manager = AuthManager::new(AuthMethod::Pat);
        // Deliberately seed a *different* credential than the override below,
        // so the assertion fails if HTTP transport ever falls back to it.
        let mut stale = Credentials::new();
        stale.insert(
            "request_header_name".to_string(),
            "X-Should-Not-Be-Used".to_string(),
        );
        manager.set_credentials(stale);

        let override_credentials = RequestCredentials {
            header_name: "X-Api-Key".to_string(),
            value: "from-the-request".to_string(),
        };
        let headers = manager
            .apply_auth_headers(
                std::collections::HashMap::new(),
                "GET",
                "https://example.com",
                Transport::Http,
                Some(&override_credentials),
            )
            .await
            .unwrap();

        assert_eq!(
            headers.get("X-Api-Key").map(String::as_str),
            Some("from-the-request")
        );
    }

    #[tokio::test]
    async fn http_transport_without_an_override_errors_instead_of_falling_back() {
        let mut manager = AuthManager::new(AuthMethod::Pat);
        let mut stale = Credentials::new();
        stale.insert(
            "request_header_name".to_string(),
            "X-Should-Not-Be-Used".to_string(),
        );
        manager.set_credentials(stale);

        let result = manager
            .apply_auth_headers(
                std::collections::HashMap::new(),
                "GET",
                "https://example.com",
                Transport::Http,
                None,
            )
            .await;

        assert!(result.is_err());
    }
}