Skip to main content

xtask/
rmcp_release_monitor.rs

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        &current_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            &current,
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(&current, terms);
837            current.clear();
838        }
839    }
840    push_identifier(&current, 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}