1use anyhow::{bail, Context, Result};
2use semver::Version;
3use serde::Deserialize;
4use sha2::{Digest, Sha256};
5use std::collections::BTreeSet;
6use std::fs;
7use std::io::Write;
8use std::path::{Path, PathBuf};
9use walkdir::WalkDir;
10
11const MARKER: &str = "<!-- rmcp-release-monitor -->";
12const DEFAULT_MAX_BODY_BYTES: usize = 60_000;
13
14#[derive(Debug)]
15struct MonitorReport {
16 drift: bool,
17 rmcp_drift: bool,
18 mcp_schema_drift: bool,
19 conformance_drift: bool,
20 current_version: String,
21 latest_version: String,
22 issue_title: String,
23 issue_body: String,
24}
25
26#[derive(Debug, Deserialize)]
27struct CratesIoResponse {
28 #[serde(rename = "crate")]
29 crate_info: CrateInfo,
30 versions: Vec<CrateVersion>,
31}
32
33#[derive(Debug, Deserialize)]
34struct CrateInfo {
35 max_version: String,
36 #[serde(default)]
37 repository: Option<String>,
38 #[serde(default)]
39 homepage: Option<String>,
40 #[serde(default)]
41 documentation: Option<String>,
42}
43
44#[derive(Debug, Deserialize)]
45struct CrateVersion {
46 num: String,
47 created_at: String,
48 yanked: bool,
49}
50
51#[derive(Debug, Deserialize)]
52struct GithubRelease {
53 tag_name: String,
54 name: Option<String>,
55 html_url: Option<String>,
56 published_at: Option<String>,
57 body: Option<String>,
58}
59
60#[derive(Debug)]
61struct Options {
62 crate_json: PathBuf,
63 releases_json: PathBuf,
64 issue_body: PathBuf,
65 schema_baseline: Option<PathBuf>,
66 schema_upstream: Option<PathBuf>,
67 schema_commits_json: Option<PathBuf>,
68 schema_url: String,
69 conformance_baseline: Option<PathBuf>,
70 conformance_head_json: Option<PathBuf>,
71 conformance_compare_json: Option<PathBuf>,
72 conformance_url: String,
73 current_version: Option<String>,
74 max_body_bytes: usize,
75}
76
77#[derive(Debug)]
78struct SchemaMonitorInput {
79 baseline: String,
80 upstream: String,
81 commits_json: Option<String>,
82 url: String,
83 repo_root: PathBuf,
84}
85
86#[derive(Debug)]
87struct SchemaReport {
88 drift: bool,
89 baseline_hash: String,
90 upstream_hash: String,
91 url: String,
92 diff: String,
93 commits: Vec<SchemaCommit>,
94 impacts: Vec<RepoImpact>,
95}
96
97#[derive(Debug)]
98struct ConformanceMonitorInput {
99 baseline_sha: String,
100 head_json: String,
101 compare_json: Option<String>,
102 url: String,
103 repo_root: PathBuf,
104}
105
106#[derive(Debug)]
107struct ConformanceReport {
108 drift: bool,
109 baseline_sha: String,
110 head_sha: String,
111 url: String,
112 head_date: String,
113 head_message: String,
114 head_html_url: String,
115 commits: Vec<ConformanceCommit>,
116 files: Vec<ConformanceFile>,
117 impacts: Vec<RepoImpact>,
118}
119
120#[derive(Debug)]
121struct RepoImpact {
122 path: String,
123 identifiers: Vec<String>,
124}
125
126#[derive(Debug, Deserialize)]
127struct SchemaCommit {
128 sha: String,
129 html_url: String,
130 commit: SchemaCommitDetails,
131}
132
133#[derive(Debug, Deserialize)]
134struct SchemaCommitDetails {
135 message: String,
136 author: SchemaCommitAuthor,
137}
138
139#[derive(Debug, Deserialize)]
140struct SchemaCommitAuthor {
141 date: String,
142}
143
144#[derive(Debug, Deserialize)]
145struct ConformanceHead {
146 sha: String,
147 html_url: String,
148 commit: ConformanceCommitDetails,
149}
150
151#[derive(Debug, Deserialize)]
152struct ConformanceCompare {
153 #[serde(default)]
154 commits: Vec<ConformanceCommit>,
155 #[serde(default)]
156 files: Vec<ConformanceFile>,
157}
158
159#[derive(Debug, Clone, Deserialize)]
160struct ConformanceCommit {
161 sha: String,
162 html_url: String,
163 commit: ConformanceCommitDetails,
164}
165
166#[derive(Debug, Clone, Deserialize)]
167struct ConformanceCommitDetails {
168 message: String,
169 author: ConformanceCommitAuthor,
170}
171
172#[derive(Debug, Clone, Deserialize)]
173struct ConformanceCommitAuthor {
174 date: String,
175}
176
177#[derive(Debug, Deserialize)]
178struct ConformanceFile {
179 filename: String,
180 status: String,
181 additions: u64,
182 deletions: u64,
183 changes: u64,
184 #[serde(default)]
185 blob_url: Option<String>,
186 #[serde(default)]
187 patch: Option<String>,
188}
189
190pub(crate) fn run(args: &[String]) -> Result<()> {
191 let options = Options::parse(args)?;
192 let current_version = match &options.current_version {
193 Some(version) => version.clone(),
194 None => detect_current_rmcp_version(Path::new("."))?,
195 };
196 let crate_json = fs::read_to_string(&options.crate_json)
197 .with_context(|| format!("failed to read {}", options.crate_json.display()))?;
198 let releases_json = fs::read_to_string(&options.releases_json)
199 .with_context(|| format!("failed to read {}", options.releases_json.display()))?;
200 let schema = options.schema_input()?;
201 let conformance = options.conformance_input()?;
202 let report = build_monitor_report(
203 ¤t_version,
204 &crate_json,
205 &releases_json,
206 schema.as_ref(),
207 conformance.as_ref(),
208 options.max_body_bytes,
209 )?;
210
211 if let Some(parent) = options.issue_body.parent() {
212 if !parent.as_os_str().is_empty() {
213 fs::create_dir_all(parent)
214 .with_context(|| format!("failed to create {}", parent.display()))?;
215 }
216 }
217 fs::write(&options.issue_body, &report.issue_body)
218 .with_context(|| format!("failed to write {}", options.issue_body.display()))?;
219
220 println!("drift={}", report.drift);
221 println!("rmcp_drift={}", report.rmcp_drift);
222 println!("mcp_schema_drift={}", report.mcp_schema_drift);
223 println!("conformance_drift={}", report.conformance_drift);
224 println!("current_version={}", report.current_version);
225 println!("latest_version={}", report.latest_version);
226 println!("issue_title={}", report.issue_title);
227 write_github_output("drift", if report.drift { "true" } else { "false" })?;
228 write_github_output(
229 "rmcp_drift",
230 if report.rmcp_drift { "true" } else { "false" },
231 )?;
232 write_github_output(
233 "mcp_schema_drift",
234 if report.mcp_schema_drift {
235 "true"
236 } else {
237 "false"
238 },
239 )?;
240 write_github_output(
241 "conformance_drift",
242 if report.conformance_drift {
243 "true"
244 } else {
245 "false"
246 },
247 )?;
248 write_github_output("current_version", &report.current_version)?;
249 write_github_output("latest_version", &report.latest_version)?;
250 write_github_output("issue_title", &report.issue_title)?;
251 Ok(())
252}
253
254fn build_monitor_report(
255 current_version: &str,
256 crate_json: &str,
257 releases_json: &str,
258 schema: Option<&SchemaMonitorInput>,
259 conformance: Option<&ConformanceMonitorInput>,
260 max_body_bytes: usize,
261) -> Result<MonitorReport> {
262 let metadata: CratesIoResponse =
263 serde_json::from_str(crate_json).context("failed to parse crates.io rmcp metadata")?;
264 let releases: Vec<GithubRelease> =
265 serde_json::from_str(releases_json).context("failed to parse GitHub release metadata")?;
266 let current = Version::parse(current_version)
267 .with_context(|| format!("invalid current rmcp version {current_version:?}"))?;
268 let latest = latest_non_yanked_version(&metadata)?;
269 let rmcp_drift = latest > current;
270 let schema_report = schema.map(build_schema_report).transpose()?;
271 let mcp_schema_drift = schema_report.as_ref().is_some_and(|report| report.drift);
272 let conformance_report = conformance.map(build_conformance_report).transpose()?;
273 let conformance_drift = conformance_report
274 .as_ref()
275 .is_some_and(|report| report.drift);
276 let drift = rmcp_drift || mcp_schema_drift || conformance_drift;
277 let latest_version = latest.to_string();
278 let issue_title = match (rmcp_drift, mcp_schema_drift, conformance_drift) {
279 (true, false, false) => {
280 format!("rmcp {latest_version} released (Soma pins {current_version})")
281 }
282 (false, true, false) => "MCP schema changed upstream".to_owned(),
283 (false, false, true) => "MCP conformance changed upstream".to_owned(),
284 (false, false, false) => {
285 format!("rmcp, MCP schema, and conformance are current at {current_version}")
286 }
287 _ => "MCP upstream changes need Soma review".to_owned(),
288 };
289 let issue_body = if drift {
290 render_issue_body(
291 &metadata,
292 &releases,
293 ¤t,
294 &latest,
295 schema_report.as_ref(),
296 conformance_report.as_ref(),
297 max_body_bytes,
298 )?
299 } else {
300 format!(
301 "{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"
302 )
303 };
304 Ok(MonitorReport {
305 drift,
306 rmcp_drift,
307 mcp_schema_drift,
308 conformance_drift,
309 current_version: current_version.to_owned(),
310 latest_version,
311 issue_title,
312 issue_body,
313 })
314}
315
316fn detect_current_rmcp_version(root: &Path) -> Result<String> {
317 let manifest_versions = discover_rmcp_manifest_versions(root)?;
318 let versions: BTreeSet<_> = manifest_versions
319 .iter()
320 .map(|(_, version)| version.clone())
321 .collect();
322 match versions.len() {
323 0 => bail!("no rmcp dependency version found in workspace manifests"),
324 1 => Ok(versions.into_iter().next().expect("one version")),
325 _ => bail!(
326 "conflicting rmcp versions across workspace manifests: {}",
327 manifest_versions
328 .iter()
329 .map(|(path, version)| format!("{}={version}", path.display()))
330 .collect::<Vec<_>>()
331 .join(", ")
332 ),
333 }
334}
335
336fn discover_rmcp_manifest_versions(root: &Path) -> Result<Vec<(PathBuf, String)>> {
337 let mut manifest_versions = Vec::new();
338 for entry in WalkDir::new(root)
339 .into_iter()
340 .filter_entry(|entry| !is_ignored_manifest_dir(entry.path()))
341 {
342 let entry = entry.with_context(|| format!("failed to walk {}", root.display()))?;
343 if !entry.file_type().is_file() || entry.file_name() != "Cargo.toml" {
344 continue;
345 }
346 let path = entry.path();
347 let text = fs::read_to_string(path)
348 .with_context(|| format!("failed to read {}", path.display()))?;
349 if let Some(version) = rmcp_version_from_manifest(&text) {
350 let relative = path.strip_prefix(root).unwrap_or(path).to_path_buf();
351 manifest_versions.push((relative, version));
352 }
353 }
354 manifest_versions.sort_by(|left, right| left.0.cmp(&right.0));
355 Ok(manifest_versions)
356}
357
358fn is_ignored_manifest_dir(path: &Path) -> bool {
359 path.file_name()
360 .and_then(|name| name.to_str())
361 .is_some_and(|name| matches!(name, ".git" | ".worktrees" | "target" | "node_modules"))
362}
363
364fn rmcp_version_from_manifest(text: &str) -> Option<String> {
365 text.lines().find_map(|raw_line| {
366 let line = raw_line.trim();
367 if line.starts_with('#') || !line.starts_with("rmcp") {
368 return None;
369 }
370 let (name, rhs) = line.split_once('=')?;
371 if name.trim() != "rmcp" {
372 return None;
373 }
374 quoted_version(rhs)
375 })
376}
377
378fn quoted_version(value: &str) -> Option<String> {
379 if let Some(rest) = value.trim().strip_prefix('"') {
380 return rest.split_once('"').map(|(version, _)| version.to_owned());
381 }
382 let (_, after_version) = value.split_once("version")?;
383 let (_, after_equals) = after_version.split_once('=')?;
384 let rest = after_equals.trim().strip_prefix('"')?;
385 rest.split_once('"').map(|(version, _)| version.to_owned())
386}
387
388fn latest_non_yanked_version(metadata: &CratesIoResponse) -> Result<Version> {
389 let mut latest = Version::parse(&metadata.crate_info.max_version).with_context(|| {
390 format!(
391 "invalid max rmcp version {:?}",
392 metadata.crate_info.max_version
393 )
394 })?;
395 if metadata
396 .versions
397 .iter()
398 .any(|version| !version.yanked && version.num == latest.to_string())
399 {
400 return Ok(latest);
401 }
402 latest = metadata
403 .versions
404 .iter()
405 .filter(|version| !version.yanked)
406 .filter_map(|version| Version::parse(&version.num).ok())
407 .max()
408 .context("crates.io metadata did not contain any non-yanked rmcp versions")?;
409 Ok(latest)
410}
411
412fn render_issue_body(
413 metadata: &CratesIoResponse,
414 releases: &[GithubRelease],
415 current: &Version,
416 latest: &Version,
417 schema_report: Option<&SchemaReport>,
418 conformance_report: Option<&ConformanceReport>,
419 max_body_bytes: usize,
420) -> Result<String> {
421 let released_versions = released_versions_between(metadata, current, latest);
422 let repository = metadata
423 .crate_info
424 .repository
425 .as_deref()
426 .or(metadata.crate_info.homepage.as_deref());
427 let compare_url = repository.and_then(|repo| github_compare_url(repo, current, latest));
428
429 let mut body = String::new();
430 body.push_str(MARKER);
431 body.push('\n');
432 body.push_str(&format!("<!-- rmcp-current-version: {current} -->\n"));
433 body.push_str(&format!("<!-- rmcp-latest-version: {latest} -->\n\n"));
434 if let Some(report) = schema_report {
435 body.push_str(&format!(
436 "<!-- mcp-schema-baseline-sha256: {} -->\n",
437 report.baseline_hash
438 ));
439 body.push_str(&format!(
440 "<!-- mcp-schema-upstream-sha256: {} -->\n",
441 report.upstream_hash
442 ));
443 }
444 if let Some(report) = conformance_report {
445 body.push_str(&format!(
446 "<!-- mcp-conformance-baseline-sha: {} -->\n",
447 report.baseline_sha
448 ));
449 body.push_str(&format!(
450 "<!-- mcp-conformance-head-sha: {} -->\n",
451 report.head_sha
452 ));
453 }
454 body.push('\n');
455 if latest > current {
456 body.push_str(&format!(
457 "`rmcp` has a newer published crate release. Soma currently pins `{current}` and crates.io now publishes `{latest}`.\n\n"
458 ));
459 body.push_str("## Release Window\n\n");
460 body.push_str("| Version | Published | Yanked | Links |\n");
461 body.push_str("|---|---:|:---:|---|\n");
462 for version in &released_versions {
463 let release = find_release(releases, &version.num);
464 let release_link = release
465 .and_then(|release| release.html_url.as_deref())
466 .map(|url| format!(" [release]({url})"))
467 .unwrap_or_default();
468 body.push_str(&format!(
469 "| `{}` | `{}` | {} | [crates.io](https://crates.io/crates/rmcp/{}){} |\n",
470 version.num,
471 version.created_at,
472 if version.yanked { "yes" } else { "no" },
473 version.num,
474 release_link
475 ));
476 }
477 body.push('\n');
478 }
479 if let Some(report) = schema_report {
480 append_schema_section(&mut body, report);
481 }
482 if let Some(report) = conformance_report {
483 append_conformance_section(&mut body, report);
484 }
485 body.push_str("## Review Links\n\n");
486 body.push_str("- [rmcp on crates.io](https://crates.io/crates/rmcp)\n");
487 if let Some(docs) = &metadata.crate_info.documentation {
488 body.push_str(&format!("- [docs.rs]({docs})\n"));
489 }
490 if let Some(repo) = repository {
491 body.push_str(&format!("- [upstream repository]({repo})\n"));
492 }
493 if let Some(url) = compare_url {
494 body.push_str(&format!("- [upstream compare]({url})\n"));
495 }
496 body.push('\n');
497 if latest > current {
498 body.push_str("## Release Notes\n\n");
499 for version in &released_versions {
500 let release = find_release(releases, &version.num);
501 body.push_str(&format!("### rmcp v{}\n\n", version.num));
502 if let Some(release) = release {
503 if let Some(published_at) = &release.published_at {
504 body.push_str(&format!("Published: `{published_at}`\n\n"));
505 }
506 if let Some(name) = &release.name {
507 body.push_str(&format!("Release: `{name}`\n\n"));
508 }
509 let notes = release.body.as_deref().unwrap_or("").trim();
510 if notes.is_empty() {
511 body.push_str("_No GitHub release notes were published for this tag._\n\n");
512 } else {
513 body.push_str(notes);
514 body.push_str("\n\n");
515 }
516 } else {
517 body.push_str("_No matching GitHub release was found for this crate version._\n\n");
518 }
519 }
520 }
521 body.push_str("## Suggested Follow-Up\n\n");
522 body.push_str(
523 "- Read the release, schema, and conformance sections above for source-breaking changes.\n",
524 );
525 body.push_str("- Update all `rmcp` pins together when rmcp drift is present.\n");
526 body.push_str("- Refresh the pinned MCP schema baseline after reviewing schema drift.\n");
527 body.push_str(
528 "- Refresh the pinned MCP conformance baseline after reviewing conformance drift.\n",
529 );
530 body.push_str(
531 "- Run `cargo update -p rmcp`, `cargo test`, and the MCP dispatch/schema/conformance checks.\n",
532 );
533 body.push_str("- Update Soma docs/examples if the rmcp API or feature flags changed.\n");
534 Ok(clamp_issue_body(body, max_body_bytes))
535}
536
537fn build_schema_report(input: &SchemaMonitorInput) -> Result<SchemaReport> {
538 let baseline_hash = sha256_hex(input.baseline.as_bytes());
539 let upstream_hash = sha256_hex(input.upstream.as_bytes());
540 let drift = baseline_hash != upstream_hash;
541 let commits = input
542 .commits_json
543 .as_deref()
544 .map(|json| serde_json::from_str(json).context("failed to parse MCP schema commit JSON"))
545 .transpose()?
546 .unwrap_or_default();
547 let changed_terms = if drift {
548 changed_terms_from_text_diff(&input.baseline, &input.upstream)
549 } else {
550 BTreeSet::new()
551 };
552 Ok(SchemaReport {
553 drift,
554 baseline_hash,
555 upstream_hash,
556 url: input.url.clone(),
557 diff: if drift {
558 simple_unified_diff(
559 "docs/references/mcp/schema/2025-11-25/schema.ts",
560 &input.url,
561 &input.baseline,
562 &input.upstream,
563 30_000,
564 )
565 } else {
566 String::new()
567 },
568 commits,
569 impacts: if drift {
570 scan_repo_impacts(&input.repo_root, &changed_terms)?
571 } else {
572 Vec::new()
573 },
574 })
575}
576
577fn build_conformance_report(input: &ConformanceMonitorInput) -> Result<ConformanceReport> {
578 let head: ConformanceHead = serde_json::from_str(&input.head_json)
579 .context("failed to parse MCP conformance head JSON")?;
580 let baseline_sha = input.baseline_sha.trim().to_owned();
581 let drift = baseline_sha != head.sha;
582 let compare = input
583 .compare_json
584 .as_deref()
585 .map(|json| {
586 serde_json::from_str::<ConformanceCompare>(json)
587 .context("failed to parse MCP conformance compare JSON")
588 })
589 .transpose()?;
590 let commits = compare
591 .as_ref()
592 .map(|compare| compare.commits.clone())
593 .unwrap_or_default();
594 let files = compare.map(|compare| compare.files).unwrap_or_default();
595 let changed_terms = if drift {
596 changed_terms_from_conformance_files(&files)
597 } else {
598 BTreeSet::new()
599 };
600 Ok(ConformanceReport {
601 drift,
602 baseline_sha,
603 head_sha: head.sha,
604 url: input.url.clone(),
605 head_date: head.commit.author.date,
606 head_message: head.commit.message,
607 head_html_url: head.html_url,
608 commits,
609 files,
610 impacts: if drift {
611 scan_repo_impacts(&input.repo_root, &changed_terms)?
612 } else {
613 Vec::new()
614 },
615 })
616}
617
618fn append_schema_section(body: &mut String, report: &SchemaReport) {
619 body.push_str("## MCP Schema Watch\n\n");
620 body.push_str(&format!(
621 "- Upstream schema: [{}]({})\n",
622 report.url, report.url
623 ));
624 body.push_str(&format!("- Baseline SHA-256: `{}`\n", report.baseline_hash));
625 body.push_str(&format!("- Upstream SHA-256: `{}`\n", report.upstream_hash));
626 body.push_str(&format!("- Drift: `{}`\n\n", report.drift));
627 if !report.commits.is_empty() {
628 body.push_str("### Recent schema commits\n\n");
629 for commit in report.commits.iter().take(5) {
630 let summary = commit.commit.message.lines().next().unwrap_or("").trim();
631 body.push_str(&format!(
632 "- [`{}`]({}) `{}` {}\n",
633 short_sha(&commit.sha),
634 commit.html_url,
635 commit.commit.author.date,
636 summary
637 ));
638 }
639 body.push('\n');
640 }
641 append_impact_section(
642 body,
643 "Potential schema impact in this repo",
644 &report.impacts,
645 );
646 if report.drift {
647 body.push_str("<details><summary>MCP schema diff</summary>\n\n");
648 body.push_str("```diff\n");
649 body.push_str(&report.diff);
650 if !report.diff.ends_with('\n') {
651 body.push('\n');
652 }
653 body.push_str("```\n\n</details>\n\n");
654 }
655}
656
657fn append_conformance_section(body: &mut String, report: &ConformanceReport) {
658 body.push_str("## MCP Conformance Watch\n\n");
659 body.push_str(&format!(
660 "- Upstream repo: [{}]({})\n",
661 report.url, report.url
662 ));
663 body.push_str(&format!("- Baseline SHA: `{}`\n", report.baseline_sha));
664 body.push_str(&format!(
665 "- Head SHA: [`{}`]({})\n",
666 short_sha(&report.head_sha),
667 report.head_html_url
668 ));
669 body.push_str(&format!("- Head date: `{}`\n", report.head_date));
670 body.push_str(&format!("- Drift: `{}`\n\n", report.drift));
671 let head_summary = report.head_message.lines().next().unwrap_or("").trim();
672 if !head_summary.is_empty() {
673 body.push_str(&format!("Latest commit: {head_summary}\n\n"));
674 }
675 if !report.commits.is_empty() {
676 body.push_str("### New conformance commits\n\n");
677 for commit in report.commits.iter().take(10) {
678 let summary = commit.commit.message.lines().next().unwrap_or("").trim();
679 body.push_str(&format!(
680 "- [`{}`]({}) `{}` {}\n",
681 short_sha(&commit.sha),
682 commit.html_url,
683 commit.commit.author.date,
684 summary
685 ));
686 }
687 body.push('\n');
688 }
689 if !report.files.is_empty() {
690 body.push_str("### Changed conformance files\n\n");
691 body.push_str("| File | Status | +/- | Changes |\n");
692 body.push_str("|---|---:|---:|---:|\n");
693 for file in report.files.iter().take(20) {
694 let file_link = file
695 .blob_url
696 .as_ref()
697 .map(|url| format!("[`{}`]({url})", file.filename))
698 .unwrap_or_else(|| format!("`{}`", file.filename));
699 body.push_str(&format!(
700 "| {file_link} | `{}` | +{} / -{} | {} |\n",
701 file.status, file.additions, file.deletions, file.changes
702 ));
703 }
704 if report.files.len() > 20 {
705 body.push_str(&format!(
706 "| _{} more files_ | | | |\n",
707 report.files.len() - 20
708 ));
709 }
710 body.push('\n');
711 }
712 append_impact_section(
713 body,
714 "Potential conformance impact in this repo",
715 &report.impacts,
716 );
717}
718
719fn append_impact_section(body: &mut String, title: &str, impacts: &[RepoImpact]) {
720 body.push_str(&format!("### {title}\n\n"));
721 body.push_str("_Static identifier matches from upstream changes. Treat this as an inspection shortlist, not a complete migration plan._\n\n");
722 if impacts.is_empty() {
723 body.push_str("No direct local references to changed upstream terms were found.\n\n");
724 return;
725 }
726 body.push_str("| Local file | Changed upstream terms referenced |\n");
727 body.push_str("|---|---|\n");
728 for impact in impacts.iter().take(25) {
729 let terms = impact
730 .identifiers
731 .iter()
732 .take(8)
733 .map(|term| format!("`{term}`"))
734 .collect::<Vec<_>>()
735 .join(", ");
736 body.push_str(&format!("| `{}` | {} |\n", impact.path, terms));
737 }
738 if impacts.len() > 25 {
739 body.push_str(&format!("| _{} more files_ | |\n", impacts.len() - 25));
740 }
741 body.push('\n');
742}
743
744fn sha256_hex(bytes: &[u8]) -> String {
745 Sha256::digest(bytes)
746 .iter()
747 .map(|byte| format!("{byte:02x}"))
748 .collect()
749}
750
751fn short_sha(sha: &str) -> &str {
752 sha.get(..12).unwrap_or(sha)
753}
754
755fn simple_unified_diff(
756 old_label: &str,
757 new_label: &str,
758 old: &str,
759 new: &str,
760 max_bytes: usize,
761) -> String {
762 let mut diff = String::new();
763 diff.push_str(&format!("--- {old_label}\n"));
764 diff.push_str(&format!("+++ {new_label}\n"));
765 let old_lines = old.lines().collect::<Vec<_>>();
766 let new_lines = new.lines().collect::<Vec<_>>();
767 let max_len = old_lines.len().max(new_lines.len());
768 for index in 0..max_len {
769 match (old_lines.get(index), new_lines.get(index)) {
770 (Some(left), Some(right)) if left == right => {}
771 (Some(left), Some(right)) => {
772 diff.push_str(&format!("@@ line {} @@\n", index + 1));
773 diff.push_str(&format!("-{left}\n"));
774 diff.push_str(&format!("+{right}\n"));
775 }
776 (Some(left), None) => {
777 diff.push_str(&format!("@@ line {} @@\n", index + 1));
778 diff.push_str(&format!("-{left}\n"));
779 }
780 (None, Some(right)) => {
781 diff.push_str(&format!("@@ line {} @@\n", index + 1));
782 diff.push_str(&format!("+{right}\n"));
783 }
784 (None, None) => {}
785 }
786 if diff.len() > max_bytes {
787 diff.truncate(max_bytes);
788 diff.push_str("\n... diff truncated ...\n");
789 break;
790 }
791 }
792 diff
793}
794
795fn changed_terms_from_text_diff(old: &str, new: &str) -> BTreeSet<String> {
796 let mut terms = BTreeSet::new();
797 let old_lines = old.lines().collect::<Vec<_>>();
798 let new_lines = new.lines().collect::<Vec<_>>();
799 let max_len = old_lines.len().max(new_lines.len());
800 for index in 0..max_len {
801 match (old_lines.get(index), new_lines.get(index)) {
802 (Some(left), Some(right)) if left == right => {}
803 (Some(left), Some(right)) => {
804 collect_identifiers(left, &mut terms);
805 collect_identifiers(right, &mut terms);
806 }
807 (Some(left), None) => collect_identifiers(left, &mut terms),
808 (None, Some(right)) => collect_identifiers(right, &mut terms),
809 (None, None) => {}
810 }
811 }
812 terms
813}
814
815fn changed_terms_from_conformance_files(files: &[ConformanceFile]) -> BTreeSet<String> {
816 let mut terms = BTreeSet::new();
817 for file in files {
818 collect_identifiers(&file.filename, &mut terms);
819 if let Some(patch) = &file.patch {
820 for line in patch.lines() {
821 if line.starts_with('+') || line.starts_with('-') {
822 collect_identifiers(line, &mut terms);
823 }
824 }
825 }
826 }
827 terms
828}
829
830fn collect_identifiers(text: &str, terms: &mut BTreeSet<String>) {
831 let mut current = String::new();
832 for ch in text.chars() {
833 if ch == '_' || ch == '-' || ch.is_ascii_alphanumeric() {
834 current.push(ch);
835 } else {
836 push_identifier(¤t, terms);
837 current.clear();
838 }
839 }
840 push_identifier(¤t, terms);
841}
842
843fn push_identifier(identifier: &str, terms: &mut BTreeSet<String>) {
844 let trimmed = identifier.trim_matches(|ch: char| ch == '_' || ch == '-');
845 if trimmed.len() < 3 || trimmed.chars().all(|ch| ch.is_ascii_digit()) {
846 return;
847 }
848 let normalized = trimmed.replace('-', "_");
849 if is_stop_identifier(&normalized) {
850 return;
851 }
852 terms.insert(normalized);
853}
854
855fn is_stop_identifier(identifier: &str) -> bool {
856 matches!(
857 identifier,
858 "add"
859 | "all"
860 | "and"
861 | "any"
862 | "api"
863 | "are"
864 | "arr"
865 | "auth"
866 | "body"
867 | "bool"
868 | "const"
869 | "default"
870 | "derive"
871 | "else"
872 | "enum"
873 | "export"
874 | "false"
875 | "for"
876 | "from"
877 | "get"
878 | "impl"
879 | "interface"
880 | "let"
881 | "main"
882 | "mod"
883 | "new"
884 | "not"
885 | "null"
886 | "number"
887 | "object"
888 | "one"
889 | "option"
890 | "pub"
891 | "ref"
892 | "self"
893 | "serde"
894 | "some"
895 | "string"
896 | "test"
897 | "this"
898 | "true"
899 | "type"
900 | "undefined"
901 | "use"
902 | "vec"
903 | "with"
904 )
905}
906
907fn scan_repo_impacts(root: &Path, terms: &BTreeSet<String>) -> Result<Vec<RepoImpact>> {
908 if terms.is_empty() {
909 return Ok(Vec::new());
910 }
911 let mut impacts = Vec::new();
912 for entry in WalkDir::new(root).follow_links(false).into_iter() {
913 let entry = entry?;
914 let path = entry.path();
915 if entry.file_type().is_dir() {
916 continue;
917 }
918 let relative = path
919 .strip_prefix(root)
920 .unwrap_or(path)
921 .to_string_lossy()
922 .replace('\\', "/");
923 if !is_repo_scan_file(path) || is_skipped_repo_path(&relative) {
924 continue;
925 }
926 let text = match fs::read_to_string(path) {
927 Ok(text) => text,
928 Err(_) => continue,
929 };
930 let matched = terms
931 .iter()
932 .filter(|term| text.contains(term.as_str()))
933 .take(12)
934 .cloned()
935 .collect::<Vec<_>>();
936 if matched.is_empty() {
937 continue;
938 }
939 impacts.push(RepoImpact {
940 path: relative,
941 identifiers: matched,
942 });
943 if impacts.len() >= 40 {
944 break;
945 }
946 }
947 impacts.sort_by(|left, right| {
948 right
949 .identifiers
950 .len()
951 .cmp(&left.identifiers.len())
952 .then_with(|| left.path.cmp(&right.path))
953 });
954 Ok(impacts)
955}
956
957fn is_repo_scan_file(path: &Path) -> bool {
958 matches!(
959 path.extension().and_then(|ext| ext.to_str()),
960 Some("rs" | "toml" | "json" | "yaml" | "yml" | "md" | "mdx" | "ts" | "tsx" | "js" | "jsx")
961 )
962}
963
964fn is_skipped_repo_path(text: &str) -> bool {
965 [
966 ".git/",
967 "target/",
968 "node_modules/",
969 "dist/",
970 ".next/",
971 "docs/references/mcp/schema/",
972 ]
973 .iter()
974 .any(|needle| {
975 text == needle.trim_end_matches('/')
976 || text.starts_with(needle)
977 || text.contains(&format!("/{needle}"))
978 }) || text == "Cargo.lock"
979 || text.ends_with("/Cargo.lock")
980}
981
982fn released_versions_between<'a>(
983 metadata: &'a CratesIoResponse,
984 current: &Version,
985 latest: &Version,
986) -> Vec<&'a CrateVersion> {
987 let mut versions = metadata
988 .versions
989 .iter()
990 .filter(|version| !version.yanked)
991 .filter(|version| {
992 Version::parse(&version.num)
993 .map(|parsed| parsed > *current && parsed <= *latest)
994 .unwrap_or(false)
995 })
996 .collect::<Vec<_>>();
997 versions.sort_by(|left, right| {
998 Version::parse(&left.num)
999 .unwrap_or_else(|_| Version::new(0, 0, 0))
1000 .cmp(&Version::parse(&right.num).unwrap_or_else(|_| Version::new(0, 0, 0)))
1001 });
1002 versions
1003}
1004
1005fn find_release<'a>(releases: &'a [GithubRelease], version: &str) -> Option<&'a GithubRelease> {
1006 let tag = format!("rmcp-v{version}");
1007 releases.iter().find(|release| release.tag_name == tag)
1008}
1009
1010fn github_compare_url(repo: &str, current: &Version, latest: &Version) -> Option<String> {
1011 let trimmed = repo.trim_end_matches('/').trim_end_matches(".git");
1012 let path = trimmed.strip_prefix("https://github.com/")?;
1013 Some(format!(
1014 "https://github.com/{path}/compare/rmcp-v{current}...rmcp-v{latest}"
1015 ))
1016}
1017
1018fn clamp_issue_body(mut body: String, max_body_bytes: usize) -> String {
1019 let marker = "\n\n<!-- rmcp-release-monitor-truncated: true -->\n\n_Release notes were truncated to keep this issue body under GitHub's size limit. Use the release and compare links above for the full upstream changes._\n";
1020 if body.len() <= max_body_bytes || max_body_bytes <= marker.len() {
1021 return body;
1022 }
1023 let mut keep_bytes = max_body_bytes - marker.len();
1024 while !body.is_char_boundary(keep_bytes) {
1025 keep_bytes = keep_bytes.saturating_sub(1);
1026 }
1027 body.truncate(keep_bytes);
1028 body.push_str(marker);
1029 body
1030}
1031
1032fn write_github_output(key: &str, value: &str) -> Result<()> {
1033 let Some(path) = std::env::var_os("GITHUB_OUTPUT").map(PathBuf::from) else {
1034 return Ok(());
1035 };
1036 let mut file = fs::OpenOptions::new()
1037 .create(true)
1038 .append(true)
1039 .open(&path)
1040 .with_context(|| format!("failed to open {}", path.display()))?;
1041 writeln!(file, "{key}={value}")?;
1042 Ok(())
1043}
1044
1045impl Options {
1046 fn parse(args: &[String]) -> Result<Self> {
1047 let mut crate_json = None;
1048 let mut releases_json = None;
1049 let mut issue_body = None;
1050 let mut schema_baseline = None;
1051 let mut schema_upstream = None;
1052 let mut schema_commits_json = None;
1053 let mut schema_url =
1054 "https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-11-25/schema.ts"
1055 .to_owned();
1056 let mut conformance_baseline = None;
1057 let mut conformance_head_json = None;
1058 let mut conformance_compare_json = None;
1059 let mut conformance_url = "https://github.com/modelcontextprotocol/conformance".to_owned();
1060 let mut current_version = None;
1061 let mut max_body_bytes = DEFAULT_MAX_BODY_BYTES;
1062 let mut index = 0usize;
1063 while index < args.len() {
1064 match args[index].as_str() {
1065 "--crate-json" => {
1066 index += 1;
1067 crate_json = Some(PathBuf::from(value_arg(args, index, "--crate-json")?));
1068 }
1069 "--releases-json" => {
1070 index += 1;
1071 releases_json = Some(PathBuf::from(value_arg(args, index, "--releases-json")?));
1072 }
1073 "--issue-body" => {
1074 index += 1;
1075 issue_body = Some(PathBuf::from(value_arg(args, index, "--issue-body")?));
1076 }
1077 "--schema-baseline" => {
1078 index += 1;
1079 schema_baseline =
1080 Some(PathBuf::from(value_arg(args, index, "--schema-baseline")?));
1081 }
1082 "--schema-upstream" => {
1083 index += 1;
1084 schema_upstream =
1085 Some(PathBuf::from(value_arg(args, index, "--schema-upstream")?));
1086 }
1087 "--schema-commits-json" => {
1088 index += 1;
1089 schema_commits_json = Some(PathBuf::from(value_arg(
1090 args,
1091 index,
1092 "--schema-commits-json",
1093 )?));
1094 }
1095 "--schema-url" => {
1096 index += 1;
1097 schema_url = value_arg(args, index, "--schema-url")?.to_owned();
1098 }
1099 "--conformance-baseline" => {
1100 index += 1;
1101 conformance_baseline = Some(PathBuf::from(value_arg(
1102 args,
1103 index,
1104 "--conformance-baseline",
1105 )?));
1106 }
1107 "--conformance-head-json" => {
1108 index += 1;
1109 conformance_head_json = Some(PathBuf::from(value_arg(
1110 args,
1111 index,
1112 "--conformance-head-json",
1113 )?));
1114 }
1115 "--conformance-compare-json" => {
1116 index += 1;
1117 conformance_compare_json = Some(PathBuf::from(value_arg(
1118 args,
1119 index,
1120 "--conformance-compare-json",
1121 )?));
1122 }
1123 "--conformance-url" => {
1124 index += 1;
1125 conformance_url = value_arg(args, index, "--conformance-url")?.to_owned();
1126 }
1127 "--current-version" => {
1128 index += 1;
1129 current_version = Some(value_arg(args, index, "--current-version")?.to_owned());
1130 }
1131 "--max-body-bytes" => {
1132 index += 1;
1133 max_body_bytes = value_arg(args, index, "--max-body-bytes")?
1134 .parse::<usize>()
1135 .context("--max-body-bytes must be an integer")?;
1136 }
1137 "--help" | "-h" => bail!(
1138 "Usage: cargo xtask rmcp-release-monitor --crate-json rmcp.json --releases-json releases.json --issue-body issue.md [--schema-baseline schema.ts --schema-upstream upstream.ts] [--conformance-baseline main.sha --conformance-head-json head.json] [--current-version VERSION] [--max-body-bytes N]"
1139 ),
1140 unknown => bail!("unknown rmcp-release-monitor option: {unknown}"),
1141 }
1142 index += 1;
1143 }
1144 Ok(Self {
1145 crate_json: crate_json.context("--crate-json is required")?,
1146 releases_json: releases_json.context("--releases-json is required")?,
1147 issue_body: issue_body.context("--issue-body is required")?,
1148 schema_baseline,
1149 schema_upstream,
1150 schema_commits_json,
1151 schema_url,
1152 conformance_baseline,
1153 conformance_head_json,
1154 conformance_compare_json,
1155 conformance_url,
1156 current_version,
1157 max_body_bytes,
1158 })
1159 }
1160
1161 fn schema_input(&self) -> Result<Option<SchemaMonitorInput>> {
1162 match (&self.schema_baseline, &self.schema_upstream) {
1163 (Some(baseline), Some(upstream)) => Ok(Some(SchemaMonitorInput {
1164 baseline: fs::read_to_string(baseline)
1165 .with_context(|| format!("failed to read {}", baseline.display()))?,
1166 upstream: fs::read_to_string(upstream)
1167 .with_context(|| format!("failed to read {}", upstream.display()))?,
1168 commits_json: self
1169 .schema_commits_json
1170 .as_ref()
1171 .map(|path| {
1172 fs::read_to_string(path)
1173 .with_context(|| format!("failed to read {}", path.display()))
1174 })
1175 .transpose()?,
1176 url: self.schema_url.clone(),
1177 repo_root: PathBuf::from("."),
1178 })),
1179 (None, None) => Ok(None),
1180 _ => bail!("--schema-baseline and --schema-upstream must be provided together"),
1181 }
1182 }
1183
1184 fn conformance_input(&self) -> Result<Option<ConformanceMonitorInput>> {
1185 match (&self.conformance_baseline, &self.conformance_head_json) {
1186 (Some(baseline), Some(head_json)) => Ok(Some(ConformanceMonitorInput {
1187 baseline_sha: fs::read_to_string(baseline)
1188 .with_context(|| format!("failed to read {}", baseline.display()))?,
1189 head_json: fs::read_to_string(head_json)
1190 .with_context(|| format!("failed to read {}", head_json.display()))?,
1191 compare_json: self
1192 .conformance_compare_json
1193 .as_ref()
1194 .map(|path| {
1195 fs::read_to_string(path)
1196 .with_context(|| format!("failed to read {}", path.display()))
1197 })
1198 .transpose()?,
1199 url: self.conformance_url.clone(),
1200 repo_root: PathBuf::from("."),
1201 })),
1202 (None, None) => Ok(None),
1203 _ => bail!(
1204 "--conformance-baseline and --conformance-head-json must be provided together"
1205 ),
1206 }
1207 }
1208}
1209
1210fn value_arg<'a>(args: &'a [String], index: usize, flag: &str) -> Result<&'a str> {
1211 args.get(index)
1212 .map(String::as_str)
1213 .with_context(|| format!("{flag} requires a value"))
1214}
1215
1216#[cfg(test)]
1217mod tests {
1218 use super::*;
1219 use std::fs;
1220 use tempfile::TempDir;
1221
1222 const CRATE_JSON: &str = r#"{
1223 "crate": {
1224 "name": "rmcp",
1225 "max_version": "1.8.0",
1226 "repository": "https://github.com/modelcontextprotocol/rust-sdk/",
1227 "homepage": "https://github.com/modelcontextprotocol/rust-sdk",
1228 "documentation": "https://docs.rs/rmcp"
1229 },
1230 "versions": [
1231 {"num": "1.8.0", "created_at": "2026-06-23T12:28:57.399938Z", "yanked": false},
1232 {"num": "1.7.0", "created_at": "2026-05-13T13:44:43.260847Z", "yanked": false}
1233 ]
1234 }"#;
1235
1236 const RELEASES_JSON: &str = r#"[
1237 {
1238 "tag_name": "rmcp-v1.8.0",
1239 "name": "rmcp-v1.8.0",
1240 "html_url": "https://github.com/modelcontextprotocol/rust-sdk/releases/tag/rmcp-v1.8.0",
1241 "published_at": "2026-06-23T12:29:09Z",
1242 "body": "> [!WARNING]\n> Breaking Changes\n\nPeer::peer_info() return type changed.\n\n### Fixed\n- strip and validate tool outputSchema and inputSchema"
1243 },
1244 {
1245 "tag_name": "rmcp-v1.7.0",
1246 "name": "rmcp-v1.7.0",
1247 "html_url": "https://github.com/modelcontextprotocol/rust-sdk/releases/tag/rmcp-v1.7.0",
1248 "published_at": "2026-05-13T13:44:49Z",
1249 "body": "already pinned"
1250 }
1251 ]"#;
1252
1253 const COMMITS_JSON: &str = r#"[
1254 {
1255 "sha": "357adac47ab2654b64799f994e6db8d3df4ee19d",
1256 "html_url": "https://github.com/modelcontextprotocol/modelcontextprotocol/commit/357adac47ab2654b64799f994e6db8d3df4ee19d",
1257 "commit": {
1258 "message": "schema: allow null for Task.ttl in generated JSON schema\n\nbody",
1259 "author": {"date": "2026-03-15T17:36:29Z"}
1260 }
1261 }
1262 ]"#;
1263
1264 const CONFORMANCE_HEAD_JSON: &str = r#"{
1265 "sha": "32523cc21a344373408c622c772ba09866e58158",
1266 "html_url": "https://github.com/modelcontextprotocol/conformance/commit/32523cc21a344373408c622c772ba09866e58158",
1267 "commit": {
1268 "message": "feat: CIMD support check for authorization-server metadata\n\nbody",
1269 "author": {"date": "2026-06-24T15:53:00Z"}
1270 }
1271 }"#;
1272
1273 const CONFORMANCE_COMPARE_JSON: &str = r#"{
1274 "commits": [
1275 {
1276 "sha": "32523cc21a344373408c622c772ba09866e58158",
1277 "html_url": "https://github.com/modelcontextprotocol/conformance/commit/32523cc21a344373408c622c772ba09866e58158",
1278 "commit": {
1279 "message": "feat: CIMD support check for authorization-server metadata\n\nbody",
1280 "author": {"date": "2026-06-24T15:53:00Z"}
1281 }
1282 }
1283 ],
1284 "files": [
1285 {
1286 "filename": "src/scenarios/authorization-server/authorization-server-metadata.ts",
1287 "status": "modified",
1288 "additions": 39,
1289 "deletions": 3,
1290 "changes": 42,
1291 "blob_url": "https://github.com/modelcontextprotocol/conformance/blob/32523cc/src/scenarios/authorization-server/authorization-server-metadata.ts",
1292 "patch": "+ id: 'authorization-server-metadata-cimd'\n+ client_id_metadata_document_supported: true\n"
1293 }
1294 ]
1295 }"#;
1296
1297 #[test]
1298 fn report_detects_new_rmcp_release_and_includes_release_notes() {
1299 let report = build_monitor_report("1.7.0", CRATE_JSON, RELEASES_JSON, None, None, 60_000)
1300 .expect("monitor report");
1301
1302 assert!(report.drift);
1303 assert!(report.rmcp_drift);
1304 assert!(!report.mcp_schema_drift);
1305 assert!(!report.conformance_drift);
1306 assert_eq!(report.current_version, "1.7.0");
1307 assert_eq!(report.latest_version, "1.8.0");
1308 assert!(report.issue_title.contains("rmcp 1.8.0 released"));
1309 assert!(report.issue_body.contains("<!-- rmcp-release-monitor -->"));
1310 assert!(report
1311 .issue_body
1312 .contains("<!-- rmcp-latest-version: 1.8.0 -->"));
1313 assert!(report
1314 .issue_body
1315 .contains("Peer::peer_info() return type changed"));
1316 assert!(report
1317 .issue_body
1318 .contains("strip and validate tool outputSchema"));
1319 assert!(report.issue_body.contains(
1320 "https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v1.7.0...rmcp-v1.8.0"
1321 ));
1322 }
1323
1324 #[test]
1325 fn report_includes_mcp_schema_drift_when_schema_hash_changes() {
1326 let temp = TempDir::new().unwrap();
1327 fs::create_dir_all(temp.path().join("crates/soma/mcp/src")).unwrap();
1328 fs::write(
1329 temp.path().join("crates/soma/mcp/src/rmcp_server.rs"),
1330 "fn inspect_schema() { let _schema_type = \"NewThing\"; }\n",
1331 )
1332 .unwrap();
1333 let schema = SchemaMonitorInput {
1334 baseline: "export const LATEST_PROTOCOL_VERSION = \"2025-11-25\";\n".to_owned(),
1335 upstream: "export const LATEST_PROTOCOL_VERSION = \"2025-11-25\";\nexport interface NewThing {}\n".to_owned(),
1336 commits_json: Some(COMMITS_JSON.to_owned()),
1337 url: "https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-11-25/schema.ts".to_owned(),
1338 repo_root: temp.path().to_path_buf(),
1339 };
1340
1341 let report = build_monitor_report(
1342 "1.8.0",
1343 CRATE_JSON,
1344 RELEASES_JSON,
1345 Some(&schema),
1346 None,
1347 60_000,
1348 )
1349 .expect("monitor report");
1350
1351 assert!(report.drift);
1352 assert!(!report.rmcp_drift);
1353 assert!(report.mcp_schema_drift);
1354 assert!(!report.conformance_drift);
1355 assert_eq!(report.issue_title, "MCP schema changed upstream");
1356 assert!(report.issue_body.contains("## MCP Schema Watch"));
1357 assert!(report.issue_body.contains("mcp-schema-baseline-sha256"));
1358 assert!(report.issue_body.contains("mcp-schema-upstream-sha256"));
1359 assert!(report
1360 .issue_body
1361 .contains("schema: allow null for Task.ttl"));
1362 assert!(report
1363 .issue_body
1364 .contains("Potential schema impact in this repo"));
1365 assert!(report
1366 .issue_body
1367 .contains("crates/soma/mcp/src/rmcp_server.rs"));
1368 assert!(report.issue_body.contains("`NewThing`"));
1369 assert!(report.issue_body.contains("+export interface NewThing {}"));
1370 }
1371
1372 #[test]
1373 fn matching_mcp_schema_hash_does_not_create_drift_by_itself() {
1374 let temp = TempDir::new().unwrap();
1375 let schema = SchemaMonitorInput {
1376 baseline: "same schema\n".to_owned(),
1377 upstream: "same schema\n".to_owned(),
1378 commits_json: None,
1379 url: "https://example.test/schema.ts".to_owned(),
1380 repo_root: temp.path().to_path_buf(),
1381 };
1382
1383 let report = build_monitor_report(
1384 "1.8.0",
1385 CRATE_JSON,
1386 RELEASES_JSON,
1387 Some(&schema),
1388 None,
1389 60_000,
1390 )
1391 .expect("monitor report");
1392
1393 assert!(!report.drift);
1394 assert!(!report.rmcp_drift);
1395 assert!(!report.mcp_schema_drift);
1396 assert!(!report.conformance_drift);
1397 }
1398
1399 #[test]
1400 fn report_includes_conformance_drift_and_repo_impact_candidates() {
1401 let temp = TempDir::new().unwrap();
1402 fs::create_dir_all(temp.path().join("crates/soma/runtime/src")).unwrap();
1403 fs::write(
1404 temp.path().join("crates/soma/runtime/src/server.rs"),
1405 "const AUTH_METADATA_FIELD: &str = \"client_id_metadata_document_supported\";\n",
1406 )
1407 .unwrap();
1408 let conformance = ConformanceMonitorInput {
1409 baseline_sha: "565eaffc902017060cb8bc38517af7de0f2e2adb\n".to_owned(),
1410 head_json: CONFORMANCE_HEAD_JSON.to_owned(),
1411 compare_json: Some(CONFORMANCE_COMPARE_JSON.to_owned()),
1412 url: "https://github.com/modelcontextprotocol/conformance".to_owned(),
1413 repo_root: temp.path().to_path_buf(),
1414 };
1415
1416 let report = build_monitor_report(
1417 "1.8.0",
1418 CRATE_JSON,
1419 RELEASES_JSON,
1420 None,
1421 Some(&conformance),
1422 60_000,
1423 )
1424 .expect("monitor report");
1425
1426 assert!(report.drift);
1427 assert!(!report.rmcp_drift);
1428 assert!(!report.mcp_schema_drift);
1429 assert!(report.conformance_drift);
1430 assert_eq!(report.issue_title, "MCP conformance changed upstream");
1431 assert!(report.issue_body.contains("## MCP Conformance Watch"));
1432 assert!(report.issue_body.contains("mcp-conformance-baseline-sha"));
1433 assert!(report.issue_body.contains("feat: CIMD support check"));
1434 assert!(report
1435 .issue_body
1436 .contains("authorization-server-metadata.ts"));
1437 assert!(report
1438 .issue_body
1439 .contains("Potential conformance impact in this repo"));
1440 assert!(report
1441 .issue_body
1442 .contains("crates/soma/runtime/src/server.rs"));
1443 assert!(report
1444 .issue_body
1445 .contains("`client_id_metadata_document_supported`"));
1446 }
1447
1448 #[test]
1449 fn current_version_discovery_requires_consistent_rmcp_pins() {
1450 let temp = TempDir::new().unwrap();
1451 let root = temp.path();
1452 for crate_path in [
1453 "apps/soma",
1454 "crates/shared/auth",
1455 "crates/soma/mcp",
1456 "crates/shared/traces",
1457 ] {
1458 fs::create_dir_all(root.join(crate_path)).unwrap();
1459 fs::write(
1460 root.join(format!("{crate_path}/Cargo.toml")),
1461 "rmcp = { version = \"1.7.0\", default-features = false }\n",
1462 )
1463 .unwrap();
1464 }
1465 fs::create_dir_all(root.join("crates/no-rmcp")).unwrap();
1466 fs::write(
1467 root.join("crates/no-rmcp/Cargo.toml"),
1468 "[package]\nname = \"no-rmcp\"\n",
1469 )
1470 .unwrap();
1471 fs::create_dir_all(root.join(".worktrees/stale/crates/stale")).unwrap();
1472 fs::write(
1473 root.join(".worktrees/stale/crates/stale/Cargo.toml"),
1474 "rmcp = { version = \"9.9.9\", default-features = false }\n",
1475 )
1476 .unwrap();
1477
1478 assert_eq!(detect_current_rmcp_version(root).unwrap(), "1.7.0");
1479
1480 fs::write(
1481 root.join("crates/shared/traces/Cargo.toml"),
1482 "rmcp = { version = \"1.8.0\", default-features = false }\n",
1483 )
1484 .unwrap();
1485 let error = detect_current_rmcp_version(root).expect_err("mixed pins should fail");
1486 let message = error.to_string();
1487 let normalized_message = message.replace('\\', "/");
1488 assert!(message.contains("conflicting rmcp versions"));
1489 assert!(normalized_message.contains("crates/shared/traces/Cargo.toml=1.8.0"));
1490 assert!(!message.contains("9.9.9"));
1491 }
1492
1493 #[test]
1494 fn workflow_uses_hidden_marker_and_stable_issue_update_path() {
1495 let workflow = include_str!("../../.github/workflows/rmcp-release-monitor.yml");
1496
1497 assert!(workflow.contains("rmcp-release-monitor in:body"));
1498 assert!(workflow.contains("gh issue edit"));
1499 assert!(workflow.contains("gh issue create"));
1500 assert!(workflow.contains("cargo xtask rmcp-release-monitor"));
1501 assert!(workflow.contains("--schema-baseline"));
1502 assert!(workflow.contains("--schema-upstream"));
1503 assert!(workflow.contains("schema/2025-11-25/schema.ts"));
1504 assert!(workflow.contains("--conformance-baseline"));
1505 assert!(workflow.contains("--conformance-head-json"));
1506 assert!(workflow.contains("modelcontextprotocol/conformance"));
1507 assert!(workflow.contains("issues: write"));
1508 }
1509
1510 #[test]
1511 fn issue_body_truncation_preserves_utf8_boundary() {
1512 let body = format!("{}{}", "a".repeat(200), "⚠️".repeat(10));
1513 let truncated = clamp_issue_body(body, 230);
1514
1515 assert!(truncated.contains("rmcp-release-monitor-truncated"));
1516 assert!(std::str::from_utf8(truncated.as_bytes()).is_ok());
1517 }
1518}