Skip to main content

github_mcp/services/
api_client.rs

1// GitHub v3 REST API MCP server — generated by mcpify. Do not hand-edit.
2//
3// Generic operationId -> HTTP request dispatcher (rather than one method
4// per operation), so generation stays robust against specs with hundreds
5// of operations and this file's size never grows with the spec.
6
7use std::collections::HashMap;
8use std::time::Duration;
9
10use serde_json::{Map, Value};
11
12use crate::auth::auth_manager::AuthManager;
13use crate::auth::request_credentials::RequestCredentials;
14use crate::core::api_url_builder::build_api_url;
15use crate::core::circuit_breaker::{CircuitBreaker, CircuitBreakerError};
16use crate::core::config_schema::Config;
17use crate::core::rate_limiter::RateLimiter;
18use crate::data::store::EndpointRecord;
19
20const DEFAULT_USER_AGENT: &str = concat!("github-mcp/", env!("CARGO_PKG_VERSION"));
21
22fn parameters_of(endpoint: &EndpointRecord) -> Vec<Value> {
23    endpoint
24        .input_schema
25        .get("parameters")
26        .and_then(Value::as_array)
27        .cloned()
28        .unwrap_or_default()
29}
30
31fn names_with_location(endpoint: &EndpointRecord, location: &str) -> Vec<String> {
32    parameters_of(endpoint)
33        .iter()
34        .filter(|param| param.get("in").and_then(Value::as_str) == Some(location))
35        .filter_map(|param| param.get("name").and_then(Value::as_str).map(String::from))
36        .collect()
37}
38
39fn value_to_plain_string(value: &Value) -> String {
40    match value {
41        Value::String(s) => s.clone(),
42        other => other.to_string(),
43    }
44}
45
46/// Substitutes every `{name}` path template segment with the matching
47/// argument, percent-encoded — mirrors `targets::typescript`'s
48/// regex-based `applyPathParams`, done here with a manual scan rather
49/// than a `regex`-crate dependency this codebase otherwise has no use for.
50fn apply_path_params(endpoint: &EndpointRecord, args: &Map<String, Value>) -> String {
51    let mut result = String::new();
52    let mut chars = endpoint.path.chars().peekable();
53    while let Some(c) = chars.next() {
54        if c != '{' {
55            result.push(c);
56            continue;
57        }
58        let mut name = String::new();
59        for next in chars.by_ref() {
60            if next == '}' {
61                break;
62            }
63            name.push(next);
64        }
65        let value = args
66            .get(&name)
67            .map(value_to_plain_string)
68            .unwrap_or_default();
69        result.push_str(
70            &percent_encoding::utf8_percent_encode(&value, percent_encoding::NON_ALPHANUMERIC)
71                .to_string(),
72        );
73    }
74    result
75}
76
77fn pick_by_location(
78    endpoint: &EndpointRecord,
79    args: &Map<String, Value>,
80    location: &str,
81) -> Vec<(String, String)> {
82    names_with_location(endpoint, location)
83        .into_iter()
84        .filter_map(|name| {
85            args.get(&name)
86                .map(|value| (name.clone(), value_to_plain_string(value)))
87        })
88        .collect()
89}
90
91pub struct ApiClient {
92    config: Config,
93    client: reqwest::Client,
94    circuit_breaker: CircuitBreaker,
95    rate_limiter: RateLimiter,
96}
97
98impl ApiClient {
99    pub fn new(config: Config) -> Self {
100        let rate_limiter = RateLimiter::new(config.rate_limit as usize, Duration::from_secs(1));
101        Self {
102            client: reqwest::Client::new(),
103            circuit_breaker: CircuitBreaker::default(),
104            rate_limiter,
105            config,
106        }
107    }
108
109    /// Executes `endpoint` against the configured target API, retrying
110    /// transient failures up to `config.retry_attempts` times, all inside
111    /// the circuit breaker and rate limiter (REQ-2.3.3). `args` may
112    /// contain a `body` key holding the JSON request body, alongside any
113    /// path/query/header parameter values.
114    pub async fn execute(
115        &self,
116        endpoint: &EndpointRecord,
117        args: &Value,
118        auth_manager: &mut AuthManager,
119        request_override: Option<&RequestCredentials>,
120    ) -> anyhow::Result<Value> {
121        self.rate_limiter.acquire()?;
122
123        let empty = Map::new();
124        let args_map = args.as_object().unwrap_or(&empty);
125
126        let query = pick_by_location(endpoint, args_map, "query");
127        let path = apply_path_params(endpoint, args_map);
128        let url = build_api_url(&self.config.url, &path, &query)?;
129
130        let mut headers: HashMap<String, String> = HashMap::new();
131        headers.insert("Content-Type".to_string(), "application/json".to_string());
132        headers.insert(
133            "Accept".to_string(),
134            "application/vnd.github+json".to_string(),
135        );
136        headers.insert("User-Agent".to_string(), DEFAULT_USER_AGENT.to_string());
137        for (name, value) in pick_by_location(endpoint, args_map, "header") {
138            headers.insert(name, value);
139        }
140        let headers = auth_manager
141            .apply_auth_headers(
142                headers,
143                &endpoint.method,
144                &url,
145                self.config.transport,
146                request_override,
147            )
148            .await?;
149
150        let body = args_map.get("body").cloned();
151
152        match self
153            .circuit_breaker
154            .execute(|| self.dispatch(&endpoint.method, &url, body.as_ref(), &headers))
155            .await
156        {
157            Ok(value) => Ok(value),
158            Err(CircuitBreakerError::Open) => anyhow::bail!("circuit breaker is open"),
159            Err(CircuitBreakerError::Inner(err)) => Err(err),
160        }
161    }
162
163    async fn dispatch(
164        &self,
165        method: &str,
166        url: &str,
167        body: Option<&Value>,
168        headers: &HashMap<String, String>,
169    ) -> anyhow::Result<Value> {
170        let parsed_method = reqwest::Method::from_bytes(method.as_bytes())?;
171
172        let mut attempt = 0u32;
173        loop {
174            let mut request = self
175                .client
176                .request(parsed_method.clone(), url)
177                .timeout(Duration::from_millis(self.config.timeout_ms));
178            for (key, value) in headers {
179                request = request.header(key, value);
180            }
181            if let Some(body) = body {
182                request = request.json(body);
183            }
184
185            match request.send().await {
186                Ok(response) => {
187                    let value = response
188                        .error_for_status()?
189                        .json::<Value>()
190                        .await
191                        .unwrap_or(Value::Null);
192                    return Ok(value);
193                }
194                Err(err) => {
195                    attempt += 1;
196                    if attempt > self.config.retry_attempts {
197                        return Err(err.into());
198                    }
199                }
200            }
201        }
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn endpoint(path: &str, input_schema: Value) -> EndpointRecord {
210        EndpointRecord {
211            operation_id: "op".to_string(),
212            path: path.to_string(),
213            method: "GET".to_string(),
214            summary: None,
215            description: None,
216            input_schema,
217            output_schema: Value::Null,
218            auth_scheme_ref: None,
219        }
220    }
221
222    #[test]
223    fn substitutes_path_parameters() {
224        let endpoint = endpoint("/widgets/{id}", Value::Null);
225        let args = Map::from_iter([("id".to_string(), Value::String("abc 123".to_string()))]);
226        assert_eq!(apply_path_params(&endpoint, &args), "/widgets/abc%20123");
227    }
228
229    #[test]
230    fn leaves_a_path_without_placeholders_untouched() {
231        let endpoint = endpoint("/widgets", Value::Null);
232        assert_eq!(apply_path_params(&endpoint, &Map::new()), "/widgets");
233    }
234
235    #[test]
236    fn picks_query_parameters_by_declared_location() {
237        let endpoint = endpoint(
238            "/widgets",
239            serde_json::json!({
240                "parameters": [
241                    { "in": "query", "name": "limit" },
242                    { "in": "header", "name": "X-Trace-Id" },
243                ]
244            }),
245        );
246        let args = Map::from_iter([
247            ("limit".to_string(), serde_json::json!(10)),
248            ("X-Trace-Id".to_string(), Value::String("abc".to_string())),
249        ]);
250
251        let query = pick_by_location(&endpoint, &args, "query");
252        assert_eq!(query, vec![("limit".to_string(), "10".to_string())]);
253
254        let headers = pick_by_location(&endpoint, &args, "header");
255        assert_eq!(headers, vec![("X-Trace-Id".to_string(), "abc".to_string())]);
256    }
257
258    #[tokio::test]
259    async fn adds_headers_required_by_github() {
260        let endpoint = endpoint("/user", Value::Null);
261        let config = Config {
262            url: "https://api.github.com".to_string(),
263            auth_method: crate::core::config_schema::AuthMethod::Pat,
264            api_version: "gh-2026-03-10".to_string(),
265            log_level: "info".to_string(),
266            rate_limit: 100,
267            timeout_ms: 30_000,
268            cache_size: 500,
269            retry_attempts: 0,
270            transport: crate::core::config_schema::Transport::Stdio,
271            host: "127.0.0.1".to_string(),
272            port: 3000,
273            cors_allow: None,
274        };
275        let mut auth_manager = AuthManager::new(crate::core::config_schema::AuthMethod::Pat);
276        let mut credentials = crate::auth::auth_strategy::Credentials::new();
277        credentials.insert("token".to_string(), "abc".to_string());
278        auth_manager.set_credentials(credentials);
279
280        let mut headers = HashMap::new();
281        headers.insert("Content-Type".to_string(), "application/json".to_string());
282        headers.insert(
283            "Accept".to_string(),
284            "application/vnd.github+json".to_string(),
285        );
286        headers.insert("User-Agent".to_string(), DEFAULT_USER_AGENT.to_string());
287        let headers = auth_manager
288            .apply_auth_headers(
289                headers,
290                &endpoint.method,
291                &config.url,
292                config.transport,
293                None,
294            )
295            .await
296            .unwrap();
297
298        assert_eq!(
299            headers.get("User-Agent").map(String::as_str),
300            Some(DEFAULT_USER_AGENT)
301        );
302        assert_eq!(
303            headers.get("Accept").map(String::as_str),
304            Some("application/vnd.github+json")
305        );
306        assert_eq!(
307            headers.get("Authorization").map(String::as_str),
308            Some("Bearer abc")
309        );
310    }
311}