Skip to main content

codecov_rust_mcp/
server.rs

1//! MCP server (`rmcp`) for Codecov (stdio or Streamable HTTP).
2
3#![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
16/// Default Streamable HTTP bind address.
17pub const DEFAULT_HTTP_LISTEN: &str = "127.0.0.1:8690";
18
19/// MCP server handle exposing Codecov coverage tools.
20#[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
39/// Fetches totals via an existing client and returns pretty JSON text.
40pub(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
56/// Fetches miss files via an existing client and returns pretty JSON text.
57pub(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
75/// Fetches a file report via an existing client and returns pretty JSON text.
76pub(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    /// Returns commit coverage totals for a GitHub repository.
96    #[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    /// Returns commit totals plus files with missed lines, sorted by misses descending.
108    #[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    /// Returns line coverage for one repository-relative path.
120    #[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
188/// Serves MCP over Streamable HTTP until the process is stopped.
189///
190/// # Errors
191///
192/// Returns I/O errors from binding or serving.
193pub 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)] // env mutex must span HTTP mocks
200mod 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            // Delay so the readiness loop exercises the retry sleep branch.
588            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}