1#![allow(clippy::unused_async)]
4
5use std::sync::Arc;
6
7use rmcp::{
8 handler::server::wrapper::Parameters,
9 model::{CallToolResult, ContentBlock, ServerCapabilities, ServerConfig},
10 tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler,
11};
12
13use crate::client::CodecovClient;
14use crate::tool_args::{FileReportArgs, MissFilesArgs, RepoArgs};
15
16pub const DEFAULT_HTTP_LISTEN: &str = "127.0.0.1:8690";
18
19#[derive(Clone, Default)]
21pub struct CodecovMcp;
22
23pub(crate) fn text_ok(text: impl Into<String>) -> CallToolResult {
24 CallToolResult::success(vec![ContentBlock::text(text.into())])
25}
26
27pub(crate) fn mcp_err(msg: impl Into<String>) -> McpError {
28 McpError::invalid_params(msg.into(), None)
29}
30
31pub(crate) fn client_from_env() -> Result<CodecovClient, McpError> {
32 CodecovClient::from_env().map_err(|err| mcp_err(err.to_string()))
33}
34
35pub(crate) fn to_json_text<T: serde::Serialize>(value: &T) -> Result<String, McpError> {
36 serde_json::to_string_pretty(value).map_err(|err| mcp_err(err.to_string()))
37}
38
39pub(crate) async fn totals_with_client(
41 client: &CodecovClient,
42 args: RepoArgs,
43) -> Result<CallToolResult, McpError> {
44 let report = client
45 .totals(
46 &args.owner,
47 &args.repo,
48 args.branch.as_deref(),
49 args.sha.as_deref(),
50 )
51 .await
52 .map_err(|err| mcp_err(err.to_string()))?;
53 Ok(text_ok(to_json_text(&report.totals)?))
54}
55
56pub(crate) async fn miss_files_with_client(
58 client: &CodecovClient,
59 args: MissFilesArgs,
60) -> Result<CallToolResult, McpError> {
61 let limit = usize::try_from(args.limit.unwrap_or(30)).unwrap_or(30);
62 let result = client
63 .miss_files(
64 &args.owner,
65 &args.repo,
66 args.branch.as_deref(),
67 args.sha.as_deref(),
68 limit,
69 )
70 .await
71 .map_err(|err| mcp_err(err.to_string()))?;
72 Ok(text_ok(to_json_text(&result)?))
73}
74
75pub(crate) async fn file_report_with_client(
77 client: &CodecovClient,
78 args: FileReportArgs,
79) -> Result<CallToolResult, McpError> {
80 let report = client
81 .file_report(
82 &args.owner,
83 &args.repo,
84 &args.path,
85 args.branch.as_deref(),
86 args.sha.as_deref(),
87 )
88 .await
89 .map_err(|err| mcp_err(err.to_string()))?;
90 Ok(text_ok(to_json_text(&report)?))
91}
92
93#[tool_router]
94impl CodecovMcp {
95 #[tool(
97 description = "Codecov commit coverage totals for a GitHub owner/repo (optional branch or sha)"
98 )]
99 async fn codecov_totals(
100 &self,
101 Parameters(args): Parameters<RepoArgs>,
102 ) -> Result<CallToolResult, McpError> {
103 let client = client_from_env()?;
104 totals_with_client(&client, args).await
105 }
106
107 #[tool(
109 description = "Codecov miss files for a GitHub owner/repo: totals + files sorted by misses desc + returned count (limit default 30; 0 = uncapped)"
110 )]
111 async fn codecov_miss_files(
112 &self,
113 Parameters(args): Parameters<MissFilesArgs>,
114 ) -> Result<CallToolResult, McpError> {
115 let client = client_from_env()?;
116 miss_files_with_client(&client, args).await
117 }
118
119 #[tool(description = "Codecov line coverage report for one file path in a GitHub owner/repo")]
121 async fn codecov_file_report(
122 &self,
123 Parameters(args): Parameters<FileReportArgs>,
124 ) -> Result<CallToolResult, McpError> {
125 let client = client_from_env()?;
126 file_report_with_client(&client, args).await
127 }
128}
129
130#[cfg(test)]
131impl CodecovMcp {
132 async fn call_totals(&self, args: RepoArgs) -> Result<CallToolResult, McpError> {
133 self.codecov_totals(Parameters(args)).await
134 }
135
136 async fn call_miss_files(&self, args: MissFilesArgs) -> Result<CallToolResult, McpError> {
137 self.codecov_miss_files(Parameters(args)).await
138 }
139
140 async fn call_file_report(&self, args: FileReportArgs) -> Result<CallToolResult, McpError> {
141 self.codecov_file_report(Parameters(args)).await
142 }
143}
144
145#[tool_handler]
146#[allow(clippy::unused_async_trait_impl)]
147impl ServerHandler for CodecovMcp {
148 fn get_info(&self) -> ServerConfig {
149 ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
150 .with_server_info(rmcp::model::Implementation::new(
151 "codecov",
152 env!("CARGO_PKG_VERSION"),
153 ))
154 .with_instructions(
155 "Codecov coverage tools: codecov_totals, codecov_miss_files, codecov_file_report. Requires CODECOV_TOKEN; optional CODECOV_API_URL.",
156 )
157 }
158}
159
160fn http_router() -> axum::Router {
161 let config =
162 rmcp::transport::streamable_http_server::tower::StreamableHttpServerConfig::default();
163 let service = rmcp::transport::streamable_http_server::tower::StreamableHttpService::new(
164 || Ok(CodecovMcp),
165 Arc::new(
166 rmcp::transport::streamable_http_server::session::local::LocalSessionManager::default(),
167 ),
168 config,
169 );
170 let method_router = axum::routing::any_service(service);
171 axum::Router::new()
172 .route("/mcp", method_router.clone())
173 .route("/mcp/", method_router)
174}
175
176async fn serve_listener(
177 listener: tokio::net::TcpListener,
178 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
179) -> std::io::Result<()> {
180 let addr = listener.local_addr()?;
181 tracing::info!(%addr, "codecov-rust-mcp HTTP listening");
182 axum::serve(listener, http_router())
183 .with_graceful_shutdown(shutdown)
184 .await?;
185 Ok(())
186}
187
188pub async fn run_http(addr: &str) -> std::io::Result<()> {
194 let listener = tokio::net::TcpListener::bind(addr).await?;
195 serve_listener(listener, std::future::pending()).await
196}
197
198#[cfg(test)]
199#[allow(clippy::await_holding_lock)] mod tests {
201 use super::*;
202 use crate::test_env::{env_lock, restore_env};
203 use serde_json::json;
204 use wiremock::matchers::{method, path_regex};
205 use wiremock::{Mock, MockServer, ResponseTemplate};
206
207 fn sample_totals_json() -> serde_json::Value {
208 json!({
209 "totals": {
210 "files": 1,
211 "lines": 10,
212 "hits": 8,
213 "misses": 2,
214 "partials": 0,
215 "coverage": 80.0,
216 "branches": 0,
217 "methods": 0
218 },
219 "files": [{
220 "name": "a.rs",
221 "totals": {
222 "files": 0,
223 "lines": 10,
224 "hits": 8,
225 "misses": 2,
226 "partials": 0,
227 "coverage": 80.0,
228 "branches": 0,
229 "methods": 0
230 },
231 "line_coverage": []
232 }]
233 })
234 }
235
236 fn sample_file_report_json() -> serde_json::Value {
237 json!({
238 "name": "src/lib.rs",
239 "totals": {
240 "files": 0,
241 "lines": 4,
242 "hits": 3,
243 "misses": 1,
244 "partials": 0,
245 "coverage": 75.0,
246 "branches": 0,
247 "methods": 0
248 },
249 "line_coverage": [[1, 1]]
250 })
251 }
252
253 #[test]
254 fn mcp_server_version_matches_crate() {
255 let info = CodecovMcp.get_info();
256 assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
257 assert_eq!(info.server_info.name.as_str(), "codecov");
258 }
259
260 #[test]
261 fn text_ok_and_mcp_err_helpers() {
262 let ok = text_ok("hello");
263 assert!(ok.is_error.is_none() || ok.is_error == Some(false));
264 let err = mcp_err("bad");
265 assert!(err.to_string().contains("bad"));
266 }
267
268 struct Boom;
269
270 impl serde::Serialize for Boom {
271 fn serialize<S: serde::Serializer>(&self, _serializer: S) -> Result<S::Ok, S::Error> {
272 Err(serde::ser::Error::custom("boom"))
273 }
274 }
275
276 #[test]
277 fn to_json_text_ok_and_err() {
278 let text = to_json_text(&json!({"a": 1})).expect("json");
279 assert!(text.contains('1'));
280 let err = to_json_text(&Boom);
281 assert!(err.is_err());
282 }
283
284 #[test]
285 fn client_from_env_missing_token() {
286 let _guard = env_lock();
287 let prev_token = std::env::var("CODECOV_TOKEN").ok();
288 let prev_url = std::env::var("CODECOV_API_URL").ok();
289 std::env::set_var("CODECOV_TOKEN", "");
290 std::env::remove_var("CODECOV_API_URL");
291 assert!(client_from_env().is_err());
292 restore_env("CODECOV_TOKEN", prev_token);
293 restore_env("CODECOV_API_URL", prev_url);
294 }
295
296 #[tokio::test]
297 async fn client_from_env_success_with_wiremock() {
298 let _guard = env_lock();
299 let server = MockServer::start().await;
300 let prev_token = std::env::var("CODECOV_TOKEN").ok();
301 let prev_url = std::env::var("CODECOV_API_URL").ok();
302 std::env::set_var("CODECOV_TOKEN", "server-tok");
303 std::env::set_var("CODECOV_API_URL", server.uri());
304 let client = client_from_env().expect("client");
305 assert_eq!(client.api_base(), server.uri().trim_end_matches('/'));
306 restore_env("CODECOV_TOKEN", prev_token);
307 restore_env("CODECOV_API_URL", prev_url);
308 }
309
310 #[tokio::test]
311 async fn helpers_with_client_cover_paths() {
312 let server = MockServer::start().await;
313 Mock::given(method("GET"))
314 .and(path_regex(r".*/totals/.*"))
315 .respond_with(ResponseTemplate::new(200).set_body_json(sample_totals_json()))
316 .mount(&server)
317 .await;
318 Mock::given(method("GET"))
319 .and(path_regex(r".*/file_report/.*"))
320 .respond_with(ResponseTemplate::new(200).set_body_json(sample_file_report_json()))
321 .mount(&server)
322 .await;
323
324 let client = CodecovClient::new("tok", server.uri());
325 let totals = totals_with_client(
326 &client,
327 RepoArgs {
328 owner: "o".into(),
329 repo: "r".into(),
330 branch: Some("dev".into()),
331 sha: None,
332 },
333 )
334 .await
335 .expect("totals");
336 assert!(totals.is_error.is_none() || totals.is_error == Some(false));
337
338 let misses = miss_files_with_client(
339 &client,
340 MissFilesArgs {
341 owner: "o".into(),
342 repo: "r".into(),
343 branch: None,
344 sha: None,
345 limit: Some(5),
346 },
347 )
348 .await
349 .expect("misses");
350 assert!(misses.is_error.is_none() || misses.is_error == Some(false));
351
352 let file = file_report_with_client(
353 &client,
354 FileReportArgs {
355 owner: "o".into(),
356 repo: "r".into(),
357 path: "src/lib.rs".into(),
358 branch: Some("dev".into()),
359 sha: None,
360 },
361 )
362 .await
363 .expect("file");
364 assert!(file.is_error.is_none() || file.is_error == Some(false));
365 }
366
367 #[tokio::test]
368 async fn helpers_map_api_errors() {
369 let server = MockServer::start().await;
370 Mock::given(method("GET"))
371 .respond_with(ResponseTemplate::new(503).set_body_string("down"))
372 .mount(&server)
373 .await;
374 let client = CodecovClient::new("tok", server.uri());
375 let args = RepoArgs {
376 owner: "o".into(),
377 repo: "r".into(),
378 branch: None,
379 sha: None,
380 };
381 assert!(totals_with_client(&client, args).await.is_err());
382 assert!(miss_files_with_client(
383 &client,
384 MissFilesArgs {
385 owner: "o".into(),
386 repo: "r".into(),
387 branch: None,
388 sha: None,
389 limit: Some(1),
390 },
391 )
392 .await
393 .is_err());
394 assert!(file_report_with_client(
395 &client,
396 FileReportArgs {
397 owner: "o".into(),
398 repo: "r".into(),
399 path: "a.rs".into(),
400 branch: None,
401 sha: None,
402 },
403 )
404 .await
405 .is_err());
406 }
407
408 #[tokio::test]
409 async fn tools_fail_without_token() {
410 let _guard = env_lock();
411 let prev_token = std::env::var("CODECOV_TOKEN").ok();
412 let prev_url = std::env::var("CODECOV_API_URL").ok();
413 std::env::set_var("CODECOV_TOKEN", "");
414 std::env::remove_var("CODECOV_API_URL");
415
416 let mcp = CodecovMcp;
417 assert!(mcp
418 .call_totals(RepoArgs {
419 owner: "o".into(),
420 repo: "r".into(),
421 branch: None,
422 sha: None,
423 })
424 .await
425 .is_err());
426 assert!(mcp
427 .call_miss_files(MissFilesArgs {
428 owner: "o".into(),
429 repo: "r".into(),
430 branch: None,
431 sha: None,
432 limit: None,
433 })
434 .await
435 .is_err());
436 assert!(mcp
437 .call_file_report(FileReportArgs {
438 owner: "o".into(),
439 repo: "r".into(),
440 path: "a.rs".into(),
441 branch: None,
442 sha: None,
443 })
444 .await
445 .is_err());
446
447 restore_env("CODECOV_TOKEN", prev_token);
448 restore_env("CODECOV_API_URL", prev_url);
449 }
450
451 #[tokio::test]
452 async fn miss_files_limit_none_defaults() {
453 let server = MockServer::start().await;
454 Mock::given(method("GET"))
455 .and(path_regex(r".*/totals/.*"))
456 .respond_with(ResponseTemplate::new(200).set_body_json(sample_totals_json()))
457 .mount(&server)
458 .await;
459 let client = CodecovClient::new("tok", server.uri());
460 let ok = miss_files_with_client(
461 &client,
462 MissFilesArgs {
463 owner: "o".into(),
464 repo: "r".into(),
465 branch: None,
466 sha: None,
467 limit: None,
468 },
469 )
470 .await
471 .expect("misses");
472 assert!(ok.is_error.is_none() || ok.is_error == Some(false));
473 }
474
475 #[tokio::test]
476 async fn tools_invoke_via_env_client() {
477 let _guard = env_lock();
478 let server = MockServer::start().await;
479 Mock::given(method("GET"))
480 .and(path_regex(r".*/totals/.*"))
481 .respond_with(ResponseTemplate::new(200).set_body_json(sample_totals_json()))
482 .mount(&server)
483 .await;
484 Mock::given(method("GET"))
485 .and(path_regex(r".*/file_report/.*"))
486 .respond_with(ResponseTemplate::new(200).set_body_json(sample_file_report_json()))
487 .mount(&server)
488 .await;
489
490 let prev_token = std::env::var("CODECOV_TOKEN").ok();
491 let prev_url = std::env::var("CODECOV_API_URL").ok();
492 std::env::set_var("CODECOV_TOKEN", "tool-tok");
493 std::env::set_var("CODECOV_API_URL", server.uri());
494
495 let mcp = CodecovMcp;
496 let totals = mcp
497 .call_totals(RepoArgs {
498 owner: "o".into(),
499 repo: "r".into(),
500 branch: None,
501 sha: None,
502 })
503 .await
504 .expect("totals tool");
505 assert!(totals.is_error.is_none() || totals.is_error == Some(false));
506
507 let misses = mcp
508 .call_miss_files(MissFilesArgs {
509 owner: "o".into(),
510 repo: "r".into(),
511 branch: None,
512 sha: None,
513 limit: None,
514 })
515 .await
516 .expect("misses tool");
517 assert!(misses.is_error.is_none() || misses.is_error == Some(false));
518
519 let file = mcp
520 .call_file_report(FileReportArgs {
521 owner: "o".into(),
522 repo: "r".into(),
523 path: "src/lib.rs".into(),
524 branch: None,
525 sha: None,
526 })
527 .await
528 .expect("file tool");
529 assert!(file.is_error.is_none() || file.is_error == Some(false));
530
531 restore_env("CODECOV_TOKEN", prev_token);
532 restore_env("CODECOV_API_URL", prev_url);
533 }
534
535 #[tokio::test]
536 async fn run_http_bind_failure_when_port_in_use() {
537 let held = tokio::net::TcpListener::bind("127.0.0.1:0")
538 .await
539 .expect("hold port");
540 let addr = held.local_addr().expect("addr").to_string();
541 let err = run_http(&addr).await;
542 assert!(err.is_err(), "expected bind failure on busy port");
543 }
544
545 #[tokio::test]
546 async fn run_http_pending_path_accepts_then_aborts() {
547 let probe = tokio::net::TcpListener::bind("127.0.0.1:0")
548 .await
549 .expect("probe");
550 let addr = probe.local_addr().expect("addr");
551 drop(probe);
552
553 let handle = tokio::spawn(async move { run_http(&addr.to_string()).await });
554
555 for _ in 0..50 {
556 match tokio::net::TcpStream::connect(addr).await {
557 Ok(_) => break,
558 Err(_) => {
559 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
560 }
561 }
562 }
563
564 let slash = reqwest::Client::new()
565 .post(format!("http://{addr}/mcp/"))
566 .header("content-type", "application/json")
567 .header("accept", "application/json, text/event-stream")
568 .body("{}")
569 .send()
570 .await
571 .expect("slash post");
572 assert_ne!(slash.status().as_u16(), 404);
573
574 handle.abort();
575 let _ = handle.await;
576 }
577
578 #[tokio::test]
579 async fn run_http_serves_mcp_and_shuts_down() {
580 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
581 .await
582 .expect("bind");
583 let addr = listener.local_addr().expect("addr");
584 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
585
586 let server = tokio::spawn(async move {
587 tokio::time::sleep(std::time::Duration::from_millis(40)).await;
589 serve_listener(listener, async {
590 let _ = shutdown_rx.await;
591 })
592 .await
593 });
594
595 for _ in 0..50 {
596 match tokio::net::TcpStream::connect(addr).await {
597 Ok(_) => break,
598 Err(_) => {
599 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
600 }
601 }
602 }
603
604 let init = json!({
605 "jsonrpc": "2.0",
606 "id": 1,
607 "method": "initialize",
608 "params": {
609 "protocolVersion": "2024-11-05",
610 "capabilities": {},
611 "clientInfo": { "name": "coverage-test", "version": "0.0.1" }
612 }
613 });
614 let response = reqwest::Client::new()
615 .post(format!("http://{addr}/mcp"))
616 .header("content-type", "application/json")
617 .header("accept", "application/json, text/event-stream")
618 .json(&init)
619 .send()
620 .await
621 .expect("mcp post");
622 let status = response.status();
623 assert!(status.is_success() || status.as_u16() == 406);
624
625 let _ = shutdown_tx.send(());
626 server
627 .await
628 .expect("join")
629 .expect("serve_listener should shut down cleanly");
630 }
631}