1use anyhow::{Context, Result, bail};
9use semver::Version;
10use serde::Deserialize;
11use std::collections::BTreeSet;
12use std::fs;
13use std::io::Write;
14use std::path::{Path, PathBuf};
15use walkdir::WalkDir;
16
17mod conformance;
18mod diagnostics;
19mod impact;
20mod issue_body;
21mod options;
22mod schema;
23
24use conformance::{ConformanceMonitorInput, build_conformance_report};
25use diagnostics::json_parse_context;
26use issue_body::render_issue_body;
27use options::Options;
28use schema::{SchemaMonitorInput, build_schema_report};
29
30const MARKER: &str = "<!-- rmcp-release-monitor -->";
31const DEFAULT_MAX_BODY_BYTES: usize = 60_000;
32
33#[derive(Debug)]
34struct MonitorReport {
35 drift: bool,
36 rmcp_drift: bool,
37 mcp_schema_drift: bool,
38 conformance_drift: bool,
39 current_version: String,
40 latest_version: String,
41 issue_title: String,
42 issue_body: String,
43}
44
45#[derive(Debug, Deserialize)]
46struct CratesIoResponse {
47 #[serde(rename = "crate")]
48 crate_info: CrateInfo,
49 versions: Vec<CrateVersion>,
50}
51
52#[derive(Debug, Deserialize)]
53struct CrateInfo {
54 max_version: String,
55 #[serde(default)]
56 repository: Option<String>,
57 #[serde(default)]
58 homepage: Option<String>,
59 #[serde(default)]
60 documentation: Option<String>,
61}
62
63#[derive(Debug, Deserialize)]
64struct CrateVersion {
65 num: String,
66 created_at: String,
67 yanked: bool,
68}
69
70#[derive(Debug, Deserialize)]
71struct GithubRelease {
72 tag_name: String,
73 name: Option<String>,
74 html_url: Option<String>,
75 published_at: Option<String>,
76 body: Option<String>,
77}
78
79pub(crate) fn run(args: &[String]) -> Result<()> {
80 let options = Options::parse(args)?;
81 let current_version = match &options.current_version {
82 Some(version) => version.clone(),
83 None => detect_current_rmcp_version(Path::new("."))?,
84 };
85 let crate_json = fs::read_to_string(&options.crate_json)
86 .with_context(|| format!("failed to read {}", options.crate_json.display()))?;
87 let releases_json = fs::read_to_string(&options.releases_json)
88 .with_context(|| format!("failed to read {}", options.releases_json.display()))?;
89 let schema = options.schema_input()?;
90 let conformance = options.conformance_input()?;
91 let report = build_monitor_report(
92 ¤t_version,
93 &crate_json,
94 &releases_json,
95 schema.as_ref(),
96 conformance.as_ref(),
97 options.max_body_bytes,
98 )?;
99
100 if let Some(parent) = options.issue_body.parent()
101 && !parent.as_os_str().is_empty()
102 {
103 fs::create_dir_all(parent)
104 .with_context(|| format!("failed to create {}", parent.display()))?;
105 }
106 fs::write(&options.issue_body, &report.issue_body)
107 .with_context(|| format!("failed to write {}", options.issue_body.display()))?;
108
109 println!("drift={}", report.drift);
110 println!("rmcp_drift={}", report.rmcp_drift);
111 println!("mcp_schema_drift={}", report.mcp_schema_drift);
112 println!("conformance_drift={}", report.conformance_drift);
113 println!("current_version={}", report.current_version);
114 println!("latest_version={}", report.latest_version);
115 println!("issue_title={}", report.issue_title);
116 write_github_output("drift", if report.drift { "true" } else { "false" })?;
117 write_github_output(
118 "rmcp_drift",
119 if report.rmcp_drift { "true" } else { "false" },
120 )?;
121 write_github_output(
122 "mcp_schema_drift",
123 if report.mcp_schema_drift {
124 "true"
125 } else {
126 "false"
127 },
128 )?;
129 write_github_output(
130 "conformance_drift",
131 if report.conformance_drift {
132 "true"
133 } else {
134 "false"
135 },
136 )?;
137 write_github_output("current_version", &report.current_version)?;
138 write_github_output("latest_version", &report.latest_version)?;
139 write_github_output("issue_title", &report.issue_title)?;
140 Ok(())
141}
142
143fn build_monitor_report(
144 current_version: &str,
145 crate_json: &str,
146 releases_json: &str,
147 schema: Option<&SchemaMonitorInput>,
148 conformance: Option<&ConformanceMonitorInput>,
149 max_body_bytes: usize,
150) -> Result<MonitorReport> {
151 let metadata: CratesIoResponse = serde_json::from_str(crate_json).with_context(|| {
152 json_parse_context("crates.io rmcp metadata (--crate-json)", crate_json)
153 })?;
154 let releases: Vec<GithubRelease> = serde_json::from_str(releases_json).with_context(|| {
155 json_parse_context("GitHub release metadata (--releases-json)", releases_json)
156 })?;
157 let current = Version::parse(exact_version(current_version))
158 .with_context(|| format!("invalid current rmcp version {current_version:?}"))?;
159 let latest = latest_non_yanked_version(&metadata)?;
160 let rmcp_drift = latest > current;
161 let schema_report = schema.map(build_schema_report).transpose()?;
162 let mcp_schema_drift = schema_report.as_ref().is_some_and(|report| report.drift);
163 let conformance_report = conformance.map(build_conformance_report).transpose()?;
164 let conformance_drift = conformance_report
165 .as_ref()
166 .is_some_and(|report| report.drift);
167 let drift = rmcp_drift || mcp_schema_drift || conformance_drift;
168 let latest_version = latest.to_string();
169 let issue_title = match (rmcp_drift, mcp_schema_drift, conformance_drift) {
170 (true, false, false) => {
171 format!("rmcp {latest_version} released (Soma pins {current_version})")
172 }
173 (false, true, false) => "MCP schema changed upstream".to_owned(),
174 (false, false, true) => "MCP conformance changed upstream".to_owned(),
175 (false, false, false) => {
176 format!("rmcp, MCP schema, and conformance are current at {current_version}")
177 }
178 _ => "MCP upstream changes need Soma review".to_owned(),
179 };
180 let issue_body = if drift {
181 render_issue_body(
182 &metadata,
183 &releases,
184 ¤t,
185 &latest,
186 schema_report.as_ref(),
187 conformance_report.as_ref(),
188 max_body_bytes,
189 )?
190 } else {
191 format!(
192 "{MARKER}\n<!-- rmcp-current-version: {current_version} -->\n<!-- rmcp-latest-version: {latest_version} -->\n\nThe Soma rmcp pin, MCP schema baseline, and conformance baseline are current.\n"
193 )
194 };
195 Ok(MonitorReport {
196 drift,
197 rmcp_drift,
198 mcp_schema_drift,
199 conformance_drift,
200 current_version: current_version.to_owned(),
201 latest_version,
202 issue_title,
203 issue_body,
204 })
205}
206
207fn exact_version(requirement: &str) -> &str {
215 requirement
216 .trim()
217 .trim_start_matches(['=', '^', '~', 'v'])
218 .trim()
219}
220
221fn detect_current_rmcp_version(root: &Path) -> Result<String> {
222 let manifest_versions = discover_rmcp_manifest_versions(root)?;
223 let versions: BTreeSet<_> = manifest_versions
224 .iter()
225 .map(|(_, version)| version.clone())
226 .collect();
227 match versions.len() {
228 0 => bail!("no rmcp dependency version found in workspace manifests"),
229 1 => Ok(versions.into_iter().next().expect("one version")),
230 _ => bail!(
231 "conflicting rmcp versions across workspace manifests: {}",
232 manifest_versions
233 .iter()
234 .map(|(path, version)| format!("{}={version}", path.display()))
235 .collect::<Vec<_>>()
236 .join(", ")
237 ),
238 }
239}
240
241fn discover_rmcp_manifest_versions(root: &Path) -> Result<Vec<(PathBuf, String)>> {
242 let mut manifest_versions = Vec::new();
243 for entry in WalkDir::new(root)
244 .into_iter()
245 .filter_entry(|entry| !is_ignored_manifest_dir(entry.path()))
246 {
247 let entry = entry.with_context(|| format!("failed to walk {}", root.display()))?;
248 if !entry.file_type().is_file() || entry.file_name() != "Cargo.toml" {
249 continue;
250 }
251 let path = entry.path();
252 let text = fs::read_to_string(path)
253 .with_context(|| format!("failed to read {}", path.display()))?;
254 if let Some(version) = rmcp_version_from_manifest(&text) {
255 let relative = path.strip_prefix(root).unwrap_or(path).to_path_buf();
256 manifest_versions.push((relative, version));
257 }
258 }
259 manifest_versions.sort_by(|left, right| left.0.cmp(&right.0));
260 Ok(manifest_versions)
261}
262
263fn is_ignored_manifest_dir(path: &Path) -> bool {
264 if path.ends_with("crates/synapse/import") {
265 return true;
266 }
267 path.file_name()
268 .and_then(|name| name.to_str())
269 .is_some_and(|name| matches!(name, ".git" | ".worktrees" | "target" | "node_modules"))
270}
271
272fn rmcp_version_from_manifest(text: &str) -> Option<String> {
273 text.lines().find_map(|raw_line| {
274 let line = raw_line.trim();
275 if line.starts_with('#') || !line.starts_with("rmcp") {
276 return None;
277 }
278 let (name, rhs) = line.split_once('=')?;
279 if name.trim() != "rmcp" {
280 return None;
281 }
282 quoted_version(rhs)
283 })
284}
285
286fn quoted_version(value: &str) -> Option<String> {
287 if let Some(rest) = value.trim().strip_prefix('"') {
288 return rest.split_once('"').map(|(version, _)| version.to_owned());
289 }
290 let (_, after_version) = value.split_once("version")?;
291 let (_, after_equals) = after_version.split_once('=')?;
292 let rest = after_equals.trim().strip_prefix('"')?;
293 rest.split_once('"').map(|(version, _)| version.to_owned())
294}
295
296fn latest_non_yanked_version(metadata: &CratesIoResponse) -> Result<Version> {
297 let mut latest = Version::parse(&metadata.crate_info.max_version).with_context(|| {
298 format!(
299 "invalid max rmcp version {:?}",
300 metadata.crate_info.max_version
301 )
302 })?;
303 if metadata
304 .versions
305 .iter()
306 .any(|version| !version.yanked && version.num == latest.to_string())
307 {
308 return Ok(latest);
309 }
310 latest = metadata
311 .versions
312 .iter()
313 .filter(|version| !version.yanked)
314 .filter_map(|version| Version::parse(&version.num).ok())
315 .max()
316 .context("crates.io metadata did not contain any non-yanked rmcp versions")?;
317 Ok(latest)
318}
319
320fn short_sha(sha: &str) -> &str {
323 sha.get(..12).unwrap_or(sha)
324}
325
326fn write_github_output(key: &str, value: &str) -> Result<()> {
327 let Some(path) = std::env::var_os("GITHUB_OUTPUT").map(PathBuf::from) else {
328 return Ok(());
329 };
330 let mut file = fs::OpenOptions::new()
331 .create(true)
332 .append(true)
333 .open(&path)
334 .with_context(|| format!("failed to open {}", path.display()))?;
335 writeln!(file, "{key}={value}")?;
336 Ok(())
337}
338
339#[cfg(test)]
343#[path = "rmcp_release_monitor_tests.rs"]
344mod sibling_tests;
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use std::fs;
350 use tempfile::TempDir;
351
352 const CRATE_JSON: &str = r#"{
353 "crate": {
354 "name": "rmcp",
355 "max_version": "1.8.0",
356 "repository": "https://github.com/modelcontextprotocol/rust-sdk/",
357 "homepage": "https://github.com/modelcontextprotocol/rust-sdk",
358 "documentation": "https://docs.rs/rmcp"
359 },
360 "versions": [
361 {"num": "1.8.0", "created_at": "2026-06-23T12:28:57.399938Z", "yanked": false},
362 {"num": "1.7.0", "created_at": "2026-05-13T13:44:43.260847Z", "yanked": false}
363 ]
364 }"#;
365
366 const RELEASES_JSON: &str = r#"[
367 {
368 "tag_name": "rmcp-v1.8.0",
369 "name": "rmcp-v1.8.0",
370 "html_url": "https://github.com/modelcontextprotocol/rust-sdk/releases/tag/rmcp-v1.8.0",
371 "published_at": "2026-06-23T12:29:09Z",
372 "body": "> [!WARNING]\n> Breaking Changes\n\nPeer::peer_info() return type changed.\n\n### Fixed\n- strip and validate tool outputSchema and inputSchema"
373 },
374 {
375 "tag_name": "rmcp-v1.7.0",
376 "name": "rmcp-v1.7.0",
377 "html_url": "https://github.com/modelcontextprotocol/rust-sdk/releases/tag/rmcp-v1.7.0",
378 "published_at": "2026-05-13T13:44:49Z",
379 "body": "already pinned"
380 }
381 ]"#;
382
383 const COMMITS_JSON: &str = r#"[
384 {
385 "sha": "357adac47ab2654b64799f994e6db8d3df4ee19d",
386 "html_url": "https://github.com/modelcontextprotocol/modelcontextprotocol/commit/357adac47ab2654b64799f994e6db8d3df4ee19d",
387 "commit": {
388 "message": "schema: allow null for Task.ttl in generated JSON schema\n\nbody",
389 "author": {"date": "2026-03-15T17:36:29Z"}
390 }
391 }
392 ]"#;
393
394 const CONFORMANCE_HEAD_JSON: &str = r#"{
395 "sha": "32523cc21a344373408c622c772ba09866e58158",
396 "html_url": "https://github.com/modelcontextprotocol/conformance/commit/32523cc21a344373408c622c772ba09866e58158",
397 "commit": {
398 "message": "feat: CIMD support check for authorization-server metadata\n\nbody",
399 "author": {"date": "2026-06-24T15:53:00Z"}
400 }
401 }"#;
402
403 const CONFORMANCE_COMPARE_JSON: &str = r#"{
404 "commits": [
405 {
406 "sha": "32523cc21a344373408c622c772ba09866e58158",
407 "html_url": "https://github.com/modelcontextprotocol/conformance/commit/32523cc21a344373408c622c772ba09866e58158",
408 "commit": {
409 "message": "feat: CIMD support check for authorization-server metadata\n\nbody",
410 "author": {"date": "2026-06-24T15:53:00Z"}
411 }
412 }
413 ],
414 "files": [
415 {
416 "filename": "src/scenarios/authorization-server/authorization-server-metadata.ts",
417 "status": "modified",
418 "additions": 39,
419 "deletions": 3,
420 "changes": 42,
421 "blob_url": "https://github.com/modelcontextprotocol/conformance/blob/32523cc/src/scenarios/authorization-server/authorization-server-metadata.ts",
422 "patch": "+ id: 'authorization-server-metadata-cimd'\n+ client_id_metadata_document_supported: true\n"
423 }
424 ]
425 }"#;
426
427 #[test]
428 fn report_detects_new_rmcp_release_and_includes_release_notes() {
429 let report = build_monitor_report("1.7.0", CRATE_JSON, RELEASES_JSON, None, None, 60_000)
430 .expect("monitor report");
431
432 assert!(report.drift);
433 assert!(report.rmcp_drift);
434 assert!(!report.mcp_schema_drift);
435 assert!(!report.conformance_drift);
436 assert_eq!(report.current_version, "1.7.0");
437 assert_eq!(report.latest_version, "1.8.0");
438 assert!(report.issue_title.contains("rmcp 1.8.0 released"));
439 assert!(report.issue_body.contains("<!-- rmcp-release-monitor -->"));
440 assert!(
441 report
442 .issue_body
443 .contains("<!-- rmcp-latest-version: 1.8.0 -->")
444 );
445 assert!(
446 report
447 .issue_body
448 .contains("Peer::peer_info() return type changed")
449 );
450 assert!(
451 report
452 .issue_body
453 .contains("strip and validate tool outputSchema")
454 );
455 assert!(report.issue_body.contains(
456 "https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.7.0...rmcp-v1.8.0"
457 ));
458 }
459
460 #[test]
461 fn report_includes_mcp_schema_drift_when_schema_hash_changes() {
462 let temp = TempDir::new().unwrap();
463 fs::create_dir_all(temp.path().join("crates/soma/mcp/src")).unwrap();
464 fs::write(
465 temp.path().join("crates/soma/mcp/src/rmcp_server.rs"),
466 "fn inspect_schema() { let _schema_type = \"NewThing\"; }\n",
467 )
468 .unwrap();
469 let schema = SchemaMonitorInput {
470 baseline: "export const LATEST_PROTOCOL_VERSION = \"2025-11-25\";\n".to_owned(),
471 upstream: "export const LATEST_PROTOCOL_VERSION = \"2025-11-25\";\nexport interface NewThing {}\n".to_owned(),
472 commits_json: Some(COMMITS_JSON.to_owned()),
473 url: "https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-11-25/schema.ts".to_owned(),
474 repo_root: temp.path().to_path_buf(),
475 };
476
477 let report = build_monitor_report(
478 "1.8.0",
479 CRATE_JSON,
480 RELEASES_JSON,
481 Some(&schema),
482 None,
483 60_000,
484 )
485 .expect("monitor report");
486
487 assert!(report.drift);
488 assert!(!report.rmcp_drift);
489 assert!(report.mcp_schema_drift);
490 assert!(!report.conformance_drift);
491 assert_eq!(report.issue_title, "MCP schema changed upstream");
492 assert!(report.issue_body.contains("## MCP Schema Watch"));
493 assert!(report.issue_body.contains("mcp-schema-baseline-sha256"));
494 assert!(report.issue_body.contains("mcp-schema-upstream-sha256"));
495 assert!(
496 report
497 .issue_body
498 .contains("schema: allow null for Task.ttl")
499 );
500 assert!(
501 report
502 .issue_body
503 .contains("Potential schema impact in this repo")
504 );
505 assert!(
506 report
507 .issue_body
508 .contains("crates/soma/mcp/src/rmcp_server.rs")
509 );
510 assert!(report.issue_body.contains("`NewThing`"));
511 assert!(report.issue_body.contains("+export interface NewThing {}"));
512 }
513
514 #[test]
515 fn matching_mcp_schema_hash_does_not_create_drift_by_itself() {
516 let temp = TempDir::new().unwrap();
517 let schema = SchemaMonitorInput {
518 baseline: "same schema\n".to_owned(),
519 upstream: "same schema\n".to_owned(),
520 commits_json: None,
521 url: "https://example.test/schema.ts".to_owned(),
522 repo_root: temp.path().to_path_buf(),
523 };
524
525 let report = build_monitor_report(
526 "1.8.0",
527 CRATE_JSON,
528 RELEASES_JSON,
529 Some(&schema),
530 None,
531 60_000,
532 )
533 .expect("monitor report");
534
535 assert!(!report.drift);
536 assert!(!report.rmcp_drift);
537 assert!(!report.mcp_schema_drift);
538 assert!(!report.conformance_drift);
539 }
540
541 #[test]
542 fn report_includes_conformance_drift_and_repo_impact_candidates() {
543 let temp = TempDir::new().unwrap();
544 fs::create_dir_all(temp.path().join("crates/soma/runtime/src")).unwrap();
545 fs::write(
546 temp.path().join("crates/soma/runtime/src/server.rs"),
547 "const AUTH_METADATA_FIELD: &str = \"client_id_metadata_document_supported\";\n",
548 )
549 .unwrap();
550 let conformance = ConformanceMonitorInput {
551 baseline_sha: "565eaffc902017060cb8bc38517af7de0f2e2adb\n".to_owned(),
552 head_json: CONFORMANCE_HEAD_JSON.to_owned(),
553 compare_json: Some(CONFORMANCE_COMPARE_JSON.to_owned()),
554 url: "https://github.com/modelcontextprotocol/conformance".to_owned(),
555 repo_root: temp.path().to_path_buf(),
556 };
557
558 let report = build_monitor_report(
559 "1.8.0",
560 CRATE_JSON,
561 RELEASES_JSON,
562 None,
563 Some(&conformance),
564 60_000,
565 )
566 .expect("monitor report");
567
568 assert!(report.drift);
569 assert!(!report.rmcp_drift);
570 assert!(!report.mcp_schema_drift);
571 assert!(report.conformance_drift);
572 assert_eq!(report.issue_title, "MCP conformance changed upstream");
573 assert!(report.issue_body.contains("## MCP Conformance Watch"));
574 assert!(report.issue_body.contains("mcp-conformance-baseline-sha"));
575 assert!(report.issue_body.contains("feat: CIMD support check"));
576 assert!(
577 report
578 .issue_body
579 .contains("authorization-server-metadata.ts")
580 );
581 assert!(
582 report
583 .issue_body
584 .contains("Potential conformance impact in this repo")
585 );
586 assert!(
587 report
588 .issue_body
589 .contains("crates/soma/runtime/src/server.rs")
590 );
591 assert!(
592 report
593 .issue_body
594 .contains("`client_id_metadata_document_supported`")
595 );
596 }
597
598 #[test]
599 fn current_version_discovery_requires_consistent_rmcp_pins() {
600 let temp = TempDir::new().unwrap();
601 let root = temp.path();
602 for crate_path in [
603 "apps/soma",
604 "crates/shared/auth",
605 "crates/soma/mcp",
606 "crates/shared/traces",
607 ] {
608 fs::create_dir_all(root.join(crate_path)).unwrap();
609 fs::write(
610 root.join(format!("{crate_path}/Cargo.toml")),
611 "rmcp = { version = \"1.7.0\", default-features = false }\n",
612 )
613 .unwrap();
614 }
615 fs::create_dir_all(root.join("crates/no-rmcp")).unwrap();
616 fs::write(
617 root.join("crates/no-rmcp/Cargo.toml"),
618 "[package]\nname = \"no-rmcp\"\n",
619 )
620 .unwrap();
621 fs::create_dir_all(root.join(".worktrees/stale/crates/stale")).unwrap();
622 fs::write(
623 root.join(".worktrees/stale/crates/stale/Cargo.toml"),
624 "rmcp = { version = \"9.9.9\", default-features = false }\n",
625 )
626 .unwrap();
627 fs::create_dir_all(root.join("crates/synapse/import")).unwrap();
628 fs::write(
629 root.join("crates/synapse/import/Cargo.toml"),
630 "rmcp = { version = \"3.1.0\", default-features = false }\n",
631 )
632 .unwrap();
633
634 assert_eq!(detect_current_rmcp_version(root).unwrap(), "1.7.0");
635
636 fs::write(
637 root.join("crates/shared/traces/Cargo.toml"),
638 "rmcp = { version = \"1.8.0\", default-features = false }\n",
639 )
640 .unwrap();
641 let error = detect_current_rmcp_version(root).expect_err("mixed pins should fail");
642 let message = error.to_string();
643 let normalized_message = message.replace('\\', "/");
644 assert!(message.contains("conflicting rmcp versions"));
645 assert!(normalized_message.contains("crates/shared/traces/Cargo.toml=1.8.0"));
646 assert!(!message.contains("9.9.9"));
647 }
648
649 #[test]
650 fn workflow_uses_hidden_marker_and_stable_issue_update_path() {
651 let workflow = include_str!("../../.github/workflows/rmcp-release-monitor.yml");
652
653 assert!(workflow.contains("rmcp-release-monitor in:body"));
654 assert!(workflow.contains("gh issue edit"));
655 assert!(workflow.contains("gh issue create"));
656 assert!(workflow.contains("cargo xtask rmcp-release-monitor"));
657 assert!(workflow.contains("--schema-baseline"));
658 assert!(workflow.contains("--schema-upstream"));
659 assert!(workflow.contains("schema/2025-11-25/schema.ts"));
660 assert!(workflow.contains("--conformance-baseline"));
661 assert!(workflow.contains("--conformance-head-json"));
662 assert!(workflow.contains("modelcontextprotocol/conformance"));
663 assert!(workflow.contains("issues: write"));
664 }
665
666 #[test]
667 fn issue_body_truncation_preserves_utf8_boundary() {
668 let body = format!("{}{}", "a".repeat(200), "⚠️".repeat(10));
669 let truncated = super::issue_body::clamp_issue_body(body, 230);
670
671 assert!(truncated.contains("rmcp-release-monitor-truncated"));
672 assert!(std::str::from_utf8(truncated.as_bytes()).is_ok());
673 }
674}