1use 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
46fn 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 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}