github-mcp 0.1.0

GitHub v3 REST API MCP server, generated by mcpify.
Documentation
// GitHub v3 REST API MCP server — generated by mcpify. Do not hand-edit.
//
// Generic operationId -> HTTP request dispatcher (rather than one method
// per operation), so generation stays robust against specs with hundreds
// of operations and this file's size never grows with the spec.

use std::collections::HashMap;
use std::time::Duration;

use serde_json::{Map, Value};

use crate::auth::auth_manager::AuthManager;
use crate::auth::request_credentials::RequestCredentials;
use crate::core::api_url_builder::build_api_url;
use crate::core::circuit_breaker::{CircuitBreaker, CircuitBreakerError};
use crate::core::config_schema::Config;
use crate::core::rate_limiter::RateLimiter;
use crate::data::store::EndpointRecord;

fn parameters_of(endpoint: &EndpointRecord) -> Vec<Value> {
    endpoint
        .input_schema
        .get("parameters")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default()
}

fn names_with_location(endpoint: &EndpointRecord, location: &str) -> Vec<String> {
    parameters_of(endpoint)
        .iter()
        .filter(|param| param.get("in").and_then(Value::as_str) == Some(location))
        .filter_map(|param| param.get("name").and_then(Value::as_str).map(String::from))
        .collect()
}

fn value_to_plain_string(value: &Value) -> String {
    match value {
        Value::String(s) => s.clone(),
        other => other.to_string(),
    }
}

/// Substitutes every `{name}` path template segment with the matching
/// argument, percent-encoded — mirrors `targets::typescript`'s
/// regex-based `applyPathParams`, done here with a manual scan rather
/// than a `regex`-crate dependency this codebase otherwise has no use for.
fn apply_path_params(endpoint: &EndpointRecord, args: &Map<String, Value>) -> String {
    let mut result = String::new();
    let mut chars = endpoint.path.chars().peekable();
    while let Some(c) = chars.next() {
        if c != '{' {
            result.push(c);
            continue;
        }
        let mut name = String::new();
        for next in chars.by_ref() {
            if next == '}' {
                break;
            }
            name.push(next);
        }
        let value = args
            .get(&name)
            .map(value_to_plain_string)
            .unwrap_or_default();
        result.push_str(
            &percent_encoding::utf8_percent_encode(&value, percent_encoding::NON_ALPHANUMERIC)
                .to_string(),
        );
    }
    result
}

fn pick_by_location(
    endpoint: &EndpointRecord,
    args: &Map<String, Value>,
    location: &str,
) -> Vec<(String, String)> {
    names_with_location(endpoint, location)
        .into_iter()
        .filter_map(|name| {
            args.get(&name)
                .map(|value| (name.clone(), value_to_plain_string(value)))
        })
        .collect()
}

pub struct ApiClient {
    config: Config,
    client: reqwest::Client,
    circuit_breaker: CircuitBreaker,
    rate_limiter: RateLimiter,
}

impl ApiClient {
    pub fn new(config: Config) -> Self {
        let rate_limiter = RateLimiter::new(config.rate_limit as usize, Duration::from_secs(1));
        Self {
            client: reqwest::Client::new(),
            circuit_breaker: CircuitBreaker::default(),
            rate_limiter,
            config,
        }
    }

    /// Executes `endpoint` against the configured target API, retrying
    /// transient failures up to `config.retry_attempts` times, all inside
    /// the circuit breaker and rate limiter (REQ-2.3.3). `args` may
    /// contain a `body` key holding the JSON request body, alongside any
    /// path/query/header parameter values.
    pub async fn execute(
        &self,
        endpoint: &EndpointRecord,
        args: &Value,
        auth_manager: &mut AuthManager,
        request_override: Option<&RequestCredentials>,
    ) -> anyhow::Result<Value> {
        self.rate_limiter.acquire()?;

        let empty = Map::new();
        let args_map = args.as_object().unwrap_or(&empty);

        let query = pick_by_location(endpoint, args_map, "query");
        let path = apply_path_params(endpoint, args_map);
        let url = build_api_url(&self.config.url, &path, &query)?;

        let mut headers: HashMap<String, String> = HashMap::new();
        headers.insert("Content-Type".to_string(), "application/json".to_string());
        for (name, value) in pick_by_location(endpoint, args_map, "header") {
            headers.insert(name, value);
        }
        let headers = auth_manager
            .apply_auth_headers(
                headers,
                &endpoint.method,
                &url,
                self.config.transport,
                request_override,
            )
            .await?;

        let body = args_map.get("body").cloned();

        match self
            .circuit_breaker
            .execute(|| self.dispatch(&endpoint.method, &url, body.as_ref(), &headers))
            .await
        {
            Ok(value) => Ok(value),
            Err(CircuitBreakerError::Open) => anyhow::bail!("circuit breaker is open"),
            Err(CircuitBreakerError::Inner(err)) => Err(err),
        }
    }

    async fn dispatch(
        &self,
        method: &str,
        url: &str,
        body: Option<&Value>,
        headers: &HashMap<String, String>,
    ) -> anyhow::Result<Value> {
        let parsed_method = reqwest::Method::from_bytes(method.as_bytes())?;

        let mut attempt = 0u32;
        loop {
            let mut request = self
                .client
                .request(parsed_method.clone(), url)
                .timeout(Duration::from_millis(self.config.timeout_ms));
            for (key, value) in headers {
                request = request.header(key, value);
            }
            if let Some(body) = body {
                request = request.json(body);
            }

            match request.send().await {
                Ok(response) => {
                    let value = response
                        .error_for_status()?
                        .json::<Value>()
                        .await
                        .unwrap_or(Value::Null);
                    return Ok(value);
                }
                Err(err) => {
                    attempt += 1;
                    if attempt > self.config.retry_attempts {
                        return Err(err.into());
                    }
                }
            }
        }
    }
}

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

    fn endpoint(path: &str, input_schema: Value) -> EndpointRecord {
        EndpointRecord {
            operation_id: "op".to_string(),
            path: path.to_string(),
            method: "GET".to_string(),
            summary: None,
            description: None,
            input_schema,
            output_schema: Value::Null,
            auth_scheme_ref: None,
        }
    }

    #[test]
    fn substitutes_path_parameters() {
        let endpoint = endpoint("/widgets/{id}", Value::Null);
        let args = Map::from_iter([("id".to_string(), Value::String("abc 123".to_string()))]);
        assert_eq!(apply_path_params(&endpoint, &args), "/widgets/abc%20123");
    }

    #[test]
    fn leaves_a_path_without_placeholders_untouched() {
        let endpoint = endpoint("/widgets", Value::Null);
        assert_eq!(apply_path_params(&endpoint, &Map::new()), "/widgets");
    }

    #[test]
    fn picks_query_parameters_by_declared_location() {
        let endpoint = endpoint(
            "/widgets",
            serde_json::json!({
                "parameters": [
                    { "in": "query", "name": "limit" },
                    { "in": "header", "name": "X-Trace-Id" },
                ]
            }),
        );
        let args = Map::from_iter([
            ("limit".to_string(), serde_json::json!(10)),
            ("X-Trace-Id".to_string(), Value::String("abc".to_string())),
        ]);

        let query = pick_by_location(&endpoint, &args, "query");
        assert_eq!(query, vec![("limit".to_string(), "10".to_string())]);

        let headers = pick_by_location(&endpoint, &args, "header");
        assert_eq!(headers, vec![("X-Trace-Id".to_string(), "abc".to_string())]);
    }
}