Skip to main content

xtask/
rmcp_release_monitor.rs

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