Skip to main content

xtask/rmcp_release_monitor/
schema.rs

1//! MCP schema drift watch.
2//!
3//! Hashes the pinned schema mirror against the upstream copy, and - when they
4//! diverge - renders the diff, the recent upstream commits, and the local
5//! impact shortlist into the issue body.
6
7use anyhow::{Context, Result};
8use serde::Deserialize;
9use sha2::{Digest, Sha256};
10use std::collections::BTreeSet;
11use std::path::PathBuf;
12
13use super::diagnostics::json_parse_context;
14use super::impact::{RepoImpact, append_impact_section, collect_identifiers, scan_repo_impacts};
15use super::short_sha;
16
17#[derive(Debug)]
18pub(super) struct SchemaMonitorInput {
19    pub(super) baseline: String,
20    pub(super) upstream: String,
21    pub(super) commits_json: Option<String>,
22    pub(super) url: String,
23    pub(super) repo_root: PathBuf,
24}
25
26#[derive(Debug)]
27pub(super) struct SchemaReport {
28    pub(super) drift: bool,
29    pub(super) baseline_hash: String,
30    pub(super) upstream_hash: String,
31    url: String,
32    diff: String,
33    commits: Vec<SchemaCommit>,
34    impacts: Vec<RepoImpact>,
35}
36
37#[derive(Debug, Deserialize)]
38struct SchemaCommit {
39    sha: String,
40    html_url: String,
41    commit: SchemaCommitDetails,
42}
43
44#[derive(Debug, Deserialize)]
45struct SchemaCommitDetails {
46    message: String,
47    author: SchemaCommitAuthor,
48}
49
50#[derive(Debug, Deserialize)]
51struct SchemaCommitAuthor {
52    date: String,
53}
54
55pub(super) fn build_schema_report(input: &SchemaMonitorInput) -> Result<SchemaReport> {
56    let baseline_hash = sha256_hex(input.baseline.as_bytes());
57    let upstream_hash = sha256_hex(input.upstream.as_bytes());
58    let drift = baseline_hash != upstream_hash;
59    let commits = input
60        .commits_json
61        .as_deref()
62        .map(|json| {
63            serde_json::from_str(json).with_context(|| {
64                json_parse_context("MCP schema commit JSON (--schema-commits-json)", json)
65            })
66        })
67        .transpose()?
68        .unwrap_or_default();
69    let changed_terms = if drift {
70        changed_terms_from_text_diff(&input.baseline, &input.upstream)
71    } else {
72        BTreeSet::new()
73    };
74    Ok(SchemaReport {
75        drift,
76        baseline_hash,
77        upstream_hash,
78        url: input.url.clone(),
79        diff: if drift {
80            simple_unified_diff(
81                "docs/references/mcp/schema/2025-11-25/schema.ts",
82                &input.url,
83                &input.baseline,
84                &input.upstream,
85                30_000,
86            )
87        } else {
88            String::new()
89        },
90        commits,
91        impacts: if drift {
92            scan_repo_impacts(&input.repo_root, &changed_terms)?
93        } else {
94            Vec::new()
95        },
96    })
97}
98
99pub(super) fn append_schema_section(body: &mut String, report: &SchemaReport) {
100    body.push_str("## MCP Schema Watch\n\n");
101    body.push_str(&format!(
102        "- Upstream schema: [{}]({})\n",
103        report.url, report.url
104    ));
105    body.push_str(&format!("- Baseline SHA-256: `{}`\n", report.baseline_hash));
106    body.push_str(&format!("- Upstream SHA-256: `{}`\n", report.upstream_hash));
107    body.push_str(&format!("- Drift: `{}`\n\n", report.drift));
108    if !report.commits.is_empty() {
109        body.push_str("### Recent schema commits\n\n");
110        for commit in report.commits.iter().take(5) {
111            let summary = commit.commit.message.lines().next().unwrap_or("").trim();
112            body.push_str(&format!(
113                "- [`{}`]({}) `{}` {}\n",
114                short_sha(&commit.sha),
115                commit.html_url,
116                commit.commit.author.date,
117                summary
118            ));
119        }
120        body.push('\n');
121    }
122    append_impact_section(
123        body,
124        "Potential schema impact in this repo",
125        &report.impacts,
126    );
127    if report.drift {
128        body.push_str("<details><summary>MCP schema diff</summary>\n\n");
129        body.push_str("```diff\n");
130        body.push_str(&report.diff);
131        if !report.diff.ends_with('\n') {
132            body.push('\n');
133        }
134        body.push_str("```\n\n</details>\n\n");
135    }
136}
137
138fn sha256_hex(bytes: &[u8]) -> String {
139    Sha256::digest(bytes)
140        .iter()
141        .map(|byte| format!("{byte:02x}"))
142        .collect()
143}
144
145fn simple_unified_diff(
146    old_label: &str,
147    new_label: &str,
148    old: &str,
149    new: &str,
150    max_bytes: usize,
151) -> String {
152    let mut diff = String::new();
153    diff.push_str(&format!("--- {old_label}\n"));
154    diff.push_str(&format!("+++ {new_label}\n"));
155    let old_lines = old.lines().collect::<Vec<_>>();
156    let new_lines = new.lines().collect::<Vec<_>>();
157    let max_len = old_lines.len().max(new_lines.len());
158    for index in 0..max_len {
159        match (old_lines.get(index), new_lines.get(index)) {
160            (Some(left), Some(right)) if left == right => {}
161            (Some(left), Some(right)) => {
162                diff.push_str(&format!("@@ line {} @@\n", index + 1));
163                diff.push_str(&format!("-{left}\n"));
164                diff.push_str(&format!("+{right}\n"));
165            }
166            (Some(left), None) => {
167                diff.push_str(&format!("@@ line {} @@\n", index + 1));
168                diff.push_str(&format!("-{left}\n"));
169            }
170            (None, Some(right)) => {
171                diff.push_str(&format!("@@ line {} @@\n", index + 1));
172                diff.push_str(&format!("+{right}\n"));
173            }
174            (None, None) => {}
175        }
176        if diff.len() > max_bytes {
177            diff.truncate(max_bytes);
178            diff.push_str("\n... diff truncated ...\n");
179            break;
180        }
181    }
182    diff
183}
184
185fn changed_terms_from_text_diff(old: &str, new: &str) -> BTreeSet<String> {
186    let mut terms = BTreeSet::new();
187    let old_lines = old.lines().collect::<Vec<_>>();
188    let new_lines = new.lines().collect::<Vec<_>>();
189    let max_len = old_lines.len().max(new_lines.len());
190    for index in 0..max_len {
191        match (old_lines.get(index), new_lines.get(index)) {
192            (Some(left), Some(right)) if left == right => {}
193            (Some(left), Some(right)) => {
194                collect_identifiers(left, &mut terms);
195                collect_identifiers(right, &mut terms);
196            }
197            (Some(left), None) => collect_identifiers(left, &mut terms),
198            (None, Some(right)) => collect_identifiers(right, &mut terms),
199            (None, None) => {}
200        }
201    }
202    terms
203}