diff --git a/crates/traverse-mcp/src/lib.rs b/crates/traverse-mcp/src/lib.rs index 35c52ec7..139e4d46 100644 --- a/crates/traverse-mcp/src/lib.rs +++ b/crates/traverse-mcp/src/lib.rs @@ -2,6 +2,7 @@ //! //! Governed by spec 015-capability-discovery-mcp and spec 042-mcp-library-surface +mod prepare_cache; mod stdio_server; pub mod context; @@ -9,6 +10,9 @@ pub mod error; pub mod tools; pub use context::McpContext; +pub use prepare_cache::{ + PrepareCacheError, PrepareCacheEvidence, parse_registry_ref, prepare_verified_cache, +}; pub use stdio_server::*; use std::collections::HashMap; diff --git a/crates/traverse-mcp/src/main.rs b/crates/traverse-mcp/src/main.rs index 8b627099..e56acc91 100644 --- a/crates/traverse-mcp/src/main.rs +++ b/crates/traverse-mcp/src/main.rs @@ -1,36 +1,145 @@ +use std::path::PathBuf; use std::process::ExitCode; -use traverse_mcp::run_stdio_server; +use traverse_mcp::{prepare_verified_cache, run_stdio_server}; fn main() -> ExitCode { run(std::env::args().skip(1)) } +const USAGE: &str = "Usage: traverse-mcp stdio [--cache ] [--simulate-startup-failure]\n traverse-mcp prepare-cache --synced-state --cache [--ref ]... [--json]"; + /// Testable core of [`main`]: takes the argument iterator directly instead of /// reading `std::env::args()` so the CLI parsing branches can be exercised /// without spawning a subprocess. -fn run(mut args: impl Iterator) -> ExitCode { +fn run(args: impl Iterator) -> ExitCode { + match parse_command(args) { + Err(message) => { + eprintln!("{message}"); + ExitCode::from(1) + } + Ok(Command::Stdio { + simulate_startup_failure, + cache, + }) => match run_stdio_server(simulate_startup_failure, cache) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("traverse-mcp stdio server failed: {error:?}"); + ExitCode::from(1) + } + }, + Ok(Command::PrepareCache { + synced_state, + cache, + refs, + json_output, + }) => match prepare_verified_cache(&synced_state, &cache, &refs) { + Ok(evidence) => { + if json_output { + println!("{}", evidence.envelope()); + } else { + println!("{}", evidence.render()); + } + ExitCode::SUCCESS + } + Err(error) => { + if json_output { + println!("{}", error.envelope()); + } else { + eprintln!("{error}"); + } + ExitCode::from(1) + } + }, + } +} + +enum Command { + Stdio { + simulate_startup_failure: bool, + cache: Option, + }, + PrepareCache { + synced_state: PathBuf, + cache: PathBuf, + refs: Vec, + json_output: bool, + }, +} + +fn parse_command(mut args: impl Iterator) -> Result { let Some(command) = args.next() else { - eprintln!("Usage: traverse-mcp stdio [--simulate-startup-failure]"); - return ExitCode::from(1); + return Err(USAGE.to_string()); }; + match command.as_str() { + "stdio" => parse_stdio(args), + "prepare-cache" => parse_prepare_cache(args), + other => Err(format!("Unsupported command: {other}")), + } +} - if command != "stdio" { - eprintln!("Unsupported command: {command}"); - return ExitCode::from(1); +fn parse_stdio(mut args: impl Iterator) -> Result { + let mut simulate_startup_failure = false; + let mut cache = None; + while let Some(argument) = args.next() { + match argument.as_str() { + "--simulate-startup-failure" => simulate_startup_failure = true, + "--cache" => { + let value = args + .next() + .ok_or_else(|| "stdio --cache requires ".to_string())?; + cache = Some(PathBuf::from(value)); + } + other => return Err(format!("Unsupported stdio flag: {other}")), + } } + Ok(Command::Stdio { + simulate_startup_failure, + cache, + }) +} - let simulate_startup_failure = args.any(|argument| argument == "--simulate-startup-failure"); - match run_stdio_server(simulate_startup_failure) { - Ok(()) => ExitCode::SUCCESS, - Err(error) => { - eprintln!("traverse-mcp stdio server failed: {error:?}"); - ExitCode::from(1) +fn parse_prepare_cache(mut args: impl Iterator) -> Result { + let mut synced_state = None; + let mut cache = None; + let mut refs = Vec::new(); + let mut json_output = false; + while let Some(argument) = args.next() { + match argument.as_str() { + "--synced-state" => { + let value = args + .next() + .ok_or_else(|| "prepare-cache --synced-state requires ".to_string())?; + synced_state = Some(PathBuf::from(value)); + } + "--cache" => { + let value = args + .next() + .ok_or_else(|| "prepare-cache --cache requires ".to_string())?; + cache = Some(PathBuf::from(value)); + } + "--ref" => { + let value = args.next().ok_or_else(|| { + "prepare-cache --ref requires ".to_string() + })?; + refs.push(value); + } + "--json" => json_output = true, + other => return Err(format!("Unsupported prepare-cache flag: {other}")), } } + Ok(Command::PrepareCache { + synced_state: synced_state + .ok_or_else(|| "prepare-cache requires --synced-state ".to_string())?, + cache: cache.ok_or_else(|| "prepare-cache requires --cache ".to_string())?, + refs, + json_output, + }) } #[cfg(test)] mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + use super::*; #[test] @@ -54,4 +163,260 @@ mod tests { ExitCode::from(1) ); } + + #[test] + fn stdio_rejects_unknown_flags() { + assert_eq!( + run(["stdio".to_string(), "--unknown".to_string()].into_iter()), + ExitCode::from(1) + ); + } + + #[test] + fn stdio_cache_requires_a_directory() { + assert_eq!( + run(["stdio".to_string(), "--cache".to_string()].into_iter()), + ExitCode::from(1) + ); + } + + #[test] + fn prepare_cache_requires_synced_state_and_cache() { + assert_eq!( + run(["prepare-cache".to_string()].into_iter()), + ExitCode::from(1) + ); + assert_eq!( + run([ + "prepare-cache".to_string(), + "--synced-state".to_string(), + "/tmp/missing-synced-state.json".to_string() + ] + .into_iter()), + ExitCode::from(1) + ); + assert_eq!( + run([ + "prepare-cache".to_string(), + "--cache".to_string(), + "/tmp/missing-mode-b-cache".to_string() + ] + .into_iter()), + ExitCode::from(1) + ); + } + + #[test] + fn prepare_cache_rejects_unknown_flags_and_missing_ref_value() { + assert_eq!( + run([ + "prepare-cache".to_string(), + "--synced-state".to_string(), + "state.json".to_string(), + "--cache".to_string(), + "cache".to_string(), + "--bogus".to_string() + ] + .into_iter()), + ExitCode::from(1) + ); + assert_eq!( + run([ + "prepare-cache".to_string(), + "--synced-state".to_string(), + "state.json".to_string(), + "--cache".to_string(), + "cache".to_string(), + "--ref".to_string() + ] + .into_iter()), + ExitCode::from(1) + ); + } + + #[test] + fn parse_stdio_accepts_cache_flag() { + let command = parse_command( + [ + "stdio".to_string(), + "--cache".to_string(), + "/srv/cache".to_string(), + ] + .into_iter(), + ) + .expect("parse"); + match command { + Command::Stdio { + simulate_startup_failure, + cache, + } => { + assert!(!simulate_startup_failure); + assert_eq!(cache, Some(PathBuf::from("/srv/cache"))); + } + Command::PrepareCache { .. } => panic!("expected stdio"), + } + } + + #[test] + fn prepare_cache_flag_values_are_required() { + assert_eq!( + run(["prepare-cache".to_string(), "--synced-state".to_string()].into_iter()), + ExitCode::from(1) + ); + assert_eq!( + run([ + "prepare-cache".to_string(), + "--synced-state".to_string(), + "state.json".to_string(), + "--cache".to_string() + ] + .into_iter()), + ExitCode::from(1) + ); + } + + #[test] + fn prepare_cache_run_reports_json_and_text_failures() { + assert_eq!( + run([ + "prepare-cache".to_string(), + "--synced-state".to_string(), + "/tmp/missing-traverse-mode-b-state.json".to_string(), + "--cache".to_string(), + "/tmp/missing-traverse-mode-b-cache".to_string(), + "--json".to_string() + ] + .into_iter()), + ExitCode::from(1) + ); + assert_eq!( + run([ + "prepare-cache".to_string(), + "--synced-state".to_string(), + "/tmp/missing-traverse-mode-b-state.json".to_string(), + "--cache".to_string(), + "/tmp/missing-traverse-mode-b-cache".to_string() + ] + .into_iter()), + ExitCode::from(1) + ); + } + + #[test] + fn prepare_cache_run_prepares_a_local_file_snapshot() { + let root = std::env::temp_dir().join(format!( + "traverse-mcp-mode-b-cli-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos() + )); + std::fs::create_dir_all(&root).expect("temp"); + let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(std::path::Path::parent) + .expect("workspace") + .to_path_buf(); + let wasm = repo.join( + "examples/core-normalize-participants/artifacts/core-normalize-participants.wasm", + ); + let contract = repo.join("examples/core-normalize-participants/contract.json"); + let wasm_bytes = std::fs::read(&wasm).expect("wasm"); + let contract_bytes = std::fs::read(&contract).expect("contract"); + let digest = |bytes: &[u8]| { + use sha2::{Digest, Sha256}; + let hashed = Sha256::digest(bytes); + let mut out = String::from("sha256:"); + for byte in hashed { + use std::fmt::Write as _; + let _ = write!(out, "{byte:02x}"); + } + out + }; + let snapshot = serde_json::json!({ + "schema_version": "1.0.0", + "workspace_id": "mode-b-cli", + "state_scope": "public_registry_synced", + "source_repo": "traverse-framework/registry", + "release_tag": "index-v1", + "index_version": 1, + "generated_at": "2026-09-11T00:00:00Z", + "source_commit": null, + "synced_at": "2026-09-11T00:00:00Z", + "record_count": 1, + "validation_status": "valid", + "governing_spec": "055-registry-sync", + "capabilities": [{ + "namespace": "core", + "id": "core.normalize-participants", + "version": "1.1.0", + "digest": digest(&wasm_bytes), + "artifact_url": format!("file://{}", wasm.display()), + "contract_digest": digest(&contract_bytes), + "contract_url": format!("file://{}", contract.display()), + "deprecated": false + }], + "events": [] + }); + let state = root.join("state.json"); + std::fs::write(&state, serde_json::to_vec(&snapshot).expect("json")).expect("write"); + let cache = root.join("cache"); + assert_eq!( + run([ + "prepare-cache".to_string(), + "--synced-state".to_string(), + state.display().to_string(), + "--cache".to_string(), + cache.display().to_string(), + "--json".to_string() + ] + .into_iter()), + ExitCode::SUCCESS + ); + assert_eq!( + run([ + "prepare-cache".to_string(), + "--synced-state".to_string(), + state.display().to_string(), + "--cache".to_string(), + cache.display().to_string() + ] + .into_iter()), + ExitCode::SUCCESS + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn parse_prepare_cache_accepts_refs_and_json() { + let command = parse_command( + [ + "prepare-cache".to_string(), + "--synced-state".to_string(), + "state.json".to_string(), + "--cache".to_string(), + "cache".to_string(), + "--ref".to_string(), + "core/core.normalize-participants@=1.1.0".to_string(), + "--json".to_string(), + ] + .into_iter(), + ) + .expect("parse"); + match command { + Command::PrepareCache { + synced_state, + cache, + refs, + json_output, + } => { + assert_eq!(synced_state, PathBuf::from("state.json")); + assert_eq!(cache, PathBuf::from("cache")); + assert_eq!(refs, vec!["core/core.normalize-participants@=1.1.0"]); + assert!(json_output); + } + Command::Stdio { .. } => panic!("expected prepare-cache"), + } + } } diff --git a/crates/traverse-mcp/src/prepare_cache.rs b/crates/traverse-mcp/src/prepare_cache.rs new file mode 100644 index 00000000..7154b6c6 --- /dev/null +++ b/crates/traverse-mcp/src/prepare_cache.rs @@ -0,0 +1,509 @@ +//! Mode B host CLI: prepare a Spec 080 / Spec 520 verified registry cache. +//! +//! Network I/O is confined to this explicit prepare step. The MCP stdio host +//! then serves discover/validate/execute/report from the resulting cache only. + +use serde_json::{Value, json}; +use std::fs; +use std::path::Path; +use std::process::Command; +use traverse_embedder::{ + HostRegistryCache, RegistryArtifactFetcher, RegistryCacheError, RegistryCacheErrorCode, + RegistryPrepareEvidence, prepare_registry_dependency, publish_public_metadata, +}; +use traverse_registry::{RegistryReference, SyncedPublicRegistryState}; + +const GOVERNING_SPEC: &str = "080-embedded-registry-cache"; + +/// Secret-free Mode B prepare-cache failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PrepareCacheError { + /// Stable Spec 080 FR-007 code (or `registry_ref_invalid` for CLI parse). + pub code: String, + /// Human-readable explanation without paths, credentials, or bytes. + pub message: String, +} + +impl PrepareCacheError { + fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + } + } + + fn from_cache(error: RegistryCacheError) -> Self { + Self { + code: error.code.as_str().to_string(), + message: error.message, + } + } + + /// Machine-readable error envelope for `--json` callers. + #[must_use] + pub fn envelope(&self) -> Value { + json!({ + "kind": "mcp_mode_b_prepare_cache_error", + "governing_spec": GOVERNING_SPEC, + "code": self.code, + "message": self.message, + }) + } +} + +impl std::fmt::Display for PrepareCacheError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for PrepareCacheError {} + +/// Successful Mode B cache preparation evidence. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PrepareCacheEvidence { + /// One prepare result per requested (or snapshot-derived) `registry_ref`. + pub entries: Vec, +} + +impl PrepareCacheEvidence { + /// Machine-readable success envelope for `--json` callers. + #[must_use] + pub fn envelope(&self) -> Value { + json!({ + "kind": "mcp_mode_b_prepare_cache", + "governing_spec": GOVERNING_SPEC, + "cache_prepared": true, + "entry_count": self.entries.len(), + "entries": self.entries.iter().map(|entry| json!({ + "namespace": entry.namespace, + "id": entry.id, + "selected_version": entry.selected_version, + "version_range": entry.version_range, + "source_release": entry.source_release, + "index_digest": entry.index_digest, + "artifact_digest": entry.artifact_digest, + "outcome": entry.outcome, + })).collect::>(), + }) + } + + /// Human-readable success text without cache paths. + #[must_use] + pub fn render(&self) -> String { + let mut lines = vec![format!( + "prepared {} registry_ref(s) into a verified Spec 520 cache", + self.entries.len() + )]; + for entry in &self.entries { + lines.push(format!( + "{}@{} {}", + entry.id, entry.selected_version, entry.artifact_digest + )); + } + lines.join("\n") + } +} + +/// Prepare one or more public `registry_ref` values into a host-owned cache. +/// +/// When `refs` is empty, every non-deprecated snapshot capability is prepared +/// at its exact published version (`={version}`). +/// +/// # Errors +/// +/// Returns a stable, secret-free code when the synced snapshot is missing or +/// invalid, a ref cannot be parsed, or Spec 080 prepare fails. +pub fn prepare_verified_cache( + synced_state_path: &Path, + cache_root: &Path, + refs: &[String], +) -> Result { + let snapshot = load_synced_state(synced_state_path)?; + let references = if refs.is_empty() { + snapshot_exact_refs(&snapshot)? + } else { + refs.iter() + .map(|raw| parse_registry_ref(raw)) + .collect::, _>>()? + }; + + prepare_refs(&snapshot, cache_root, &references) +} + +/// Parse `--ref /@`. +/// +/// # Errors +/// +/// Returns `registry_ref_invalid` when the token is missing a namespace, id, +/// or version range. +pub fn parse_registry_ref(raw: &str) -> Result { + let (namespace, rest) = raw.split_once('/').ok_or_else(|| { + PrepareCacheError::new( + "registry_ref_invalid", + "registry_ref must be namespace/id@version_range", + ) + })?; + let (id, version_range) = rest.split_once('@').ok_or_else(|| { + PrepareCacheError::new( + "registry_ref_invalid", + "registry_ref must be namespace/id@version_range", + ) + })?; + if namespace.is_empty() || id.is_empty() || version_range.is_empty() { + return Err(PrepareCacheError::new( + "registry_ref_invalid", + "registry_ref must be namespace/id@version_range", + )); + } + Ok(RegistryReference { + namespace: namespace.to_string(), + id: id.to_string(), + version_range: version_range.to_string(), + }) +} + +fn load_synced_state(path: &Path) -> Result { + let raw = fs::read(path).map_err(|_| { + PrepareCacheError::new( + RegistryCacheErrorCode::RegistrySyncMissing.as_str(), + "synced registry index snapshot is missing", + ) + })?; + serde_json::from_slice(&raw).map_err(|_| { + PrepareCacheError::new( + RegistryCacheErrorCode::RegistrySyncMissing.as_str(), + "synced registry index snapshot is missing or malformed", + ) + }) +} + +fn snapshot_exact_refs( + snapshot: &SyncedPublicRegistryState, +) -> Result, PrepareCacheError> { + let refs = snapshot + .capabilities + .iter() + .filter(|record| !record.deprecated) + .map(|record| RegistryReference { + namespace: record.namespace.clone(), + id: record.id.clone(), + version_range: format!("={}", record.version), + }) + .collect::>(); + if refs.is_empty() { + return Err(PrepareCacheError::new( + RegistryCacheErrorCode::RegistrySyncMissing.as_str(), + "synced registry index snapshot contains no preparable capabilities", + )); + } + Ok(refs) +} + +fn prepare_refs( + snapshot: &SyncedPublicRegistryState, + cache_root: &Path, + references: &[RegistryReference], +) -> Result { + let cache = HostRegistryCache::new(cache_root); + let fetcher = HostCliFetcher; + let mut entries = Vec::with_capacity(references.len()); + for reference in references { + let evidence = prepare_registry_dependency(&cache, snapshot, reference, &fetcher) + .map_err(PrepareCacheError::from_cache)?; + entries.push(evidence); + } + publish_public_metadata(&cache, snapshot, false).map_err(PrepareCacheError::from_cache)?; + Ok(PrepareCacheEvidence { entries }) +} + +/// Host-owned fetcher: `file://` from local bytes, `http(s)://` via curl. +/// +/// Errors are secret-free and never echo URLs, paths, or artifact bytes. +struct HostCliFetcher; + +impl RegistryArtifactFetcher for HostCliFetcher { + fn fetch(&self, url: &str) -> Result, String> { + if let Some(path) = url.strip_prefix("file://") { + return fs::read(path).map_err(|_| "host registry artifact fetch failed".to_string()); + } + if url.starts_with("https://") || url.starts_with("http://") { + return fetch_http(url); + } + Err("host registry artifact fetch failed: unsupported url scheme".to_string()) + } +} + +fn fetch_http(url: &str) -> Result, String> { + let output = Command::new("curl") + .args(["-fsSL", url]) + .output() + .map_err(|_| "host registry artifact fetch failed".to_string())?; + if output.status.success() { + Ok(output.stdout) + } else { + Err("host registry artifact fetch failed".to_string()) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use super::*; + use sha2::{Digest, Sha256}; + use std::fmt::Write as _; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + use traverse_embedder::read_public_metadata; + use traverse_registry::{PublicRegistryCapabilityRecord, PublicUseCaseSummary}; + + const KIT_ID: &str = "core.normalize-participants"; + const KIT_NAMESPACE: &str = "core"; + const KIT_VERSION: &str = "1.1.0"; + + fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("workspace root") + .to_path_buf() + } + + fn sha256_prefixed(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + let mut out = String::from("sha256:"); + for byte in hasher.finalize() { + write!(out, "{byte:02x}").expect("writing to a String cannot fail"); + } + out + } + + fn kit_wasm_bytes() -> Vec { + fs::read(repo_root().join( + "examples/core-normalize-participants/artifacts/core-normalize-participants.wasm", + )) + .expect("kit wasm") + } + + fn kit_contract_bytes() -> Vec { + fs::read(repo_root().join("examples/core-normalize-participants/contract.json")) + .expect("kit contract") + } + + fn fresh_root(tag: &str) -> PathBuf { + static SEQ: AtomicU64 = AtomicU64::new(0); + let seq = SEQ.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "traverse-mcp-mode-b-{tag}-{}-{seq}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).expect("temp root"); + root + } + + fn write_snapshot(dir: &Path) -> PathBuf { + let wasm = repo_root().join( + "examples/core-normalize-participants/artifacts/core-normalize-participants.wasm", + ); + let contract = repo_root().join("examples/core-normalize-participants/contract.json"); + let record = PublicRegistryCapabilityRecord { + namespace: KIT_NAMESPACE.to_string(), + id: KIT_ID.to_string(), + version: KIT_VERSION.to_string(), + digest: sha256_prefixed(&kit_wasm_bytes()), + artifact_url: format!("file://{}", wasm.display()), + contract_digest: sha256_prefixed(&kit_contract_bytes()), + contract_url: format!("file://{}", contract.display()), + deprecated: false, + summary: "Normalize raw participants into canonical records.".to_string(), + description: "Verified public kit fixture for Mode B prepare.".to_string(), + use_cases: vec![PublicUseCaseSummary { + scenario: "Resolve extracted names and emails to workspace members.".to_string(), + }], + service_type: "stateless".to_string(), + permitted_targets: vec!["wasm".to_string()], + lifecycle: "active".to_string(), + provenance: None, + }; + let snapshot = SyncedPublicRegistryState { + schema_version: "1.0.0".to_string(), + workspace_id: "mode-b-fixture".to_string(), + state_scope: "public_registry_synced".to_string(), + source_repo: "traverse-framework/registry".to_string(), + release_tag: "index-v1".to_string(), + index_version: 1, + generated_at: "2026-09-11T00:00:00Z".to_string(), + source_commit: None, + synced_at: "2026-09-11T00:00:00Z".to_string(), + record_count: 1, + validation_status: "valid".to_string(), + governing_spec: "055-registry-sync".to_string(), + capabilities: vec![record], + events: Vec::new(), + }; + let path = dir.join("synced-state.json"); + fs::write(&path, serde_json::to_vec(&snapshot).expect("snapshot json")).expect("write"); + path + } + + #[test] + fn parse_registry_ref_accepts_namespace_id_and_range() { + let parsed = parse_registry_ref("core/core.normalize-participants@=1.1.0").unwrap(); + assert_eq!(parsed.namespace, "core"); + assert_eq!(parsed.id, KIT_ID); + assert_eq!(parsed.version_range, "=1.1.0"); + } + + #[test] + fn parse_registry_ref_rejects_malformed_tokens() { + for raw in ["", "core", "core/id", "/id@=1", "core/@=1", "core/id@"] { + let error = parse_registry_ref(raw).expect_err(raw); + assert_eq!(error.code, "registry_ref_invalid"); + assert!(!error.envelope().to_string().contains(raw) || raw.is_empty()); + } + } + + #[test] + fn prepare_writes_verified_cache_and_public_metadata() { + let root = fresh_root("prepare"); + let state = write_snapshot(&root); + let cache = root.join("cache"); + let evidence = prepare_verified_cache( + &state, + &cache, + &[format!("{KIT_NAMESPACE}/{KIT_ID}@={KIT_VERSION}")], + ) + .expect("prepare"); + assert_eq!(evidence.entries.len(), 1); + assert_eq!(evidence.entries[0].id, KIT_ID); + assert_eq!(evidence.entries[0].selected_version, KIT_VERSION); + assert_eq!(evidence.entries[0].outcome, "prepared"); + let envelope = evidence.envelope(); + assert_eq!(envelope["kind"], json!("mcp_mode_b_prepare_cache")); + assert_eq!(envelope["governing_spec"], json!(GOVERNING_SPEC)); + assert!( + !envelope + .to_string() + .contains(cache.to_string_lossy().as_ref()) + ); + let generation = read_public_metadata(&HostRegistryCache::new(&cache)).expect("metadata"); + assert_eq!(generation.records.len(), 1); + assert_eq!(generation.records[0].id, KIT_ID); + let rendered = evidence.render(); + assert!(rendered.contains(KIT_ID)); + assert!(!rendered.contains(cache.to_string_lossy().as_ref())); + } + + #[test] + fn prepare_without_refs_uses_exact_snapshot_versions() { + let root = fresh_root("all-refs"); + let state = write_snapshot(&root); + let cache = root.join("cache"); + let evidence = prepare_verified_cache(&state, &cache, &[]).expect("prepare all"); + assert_eq!(evidence.entries[0].version_range, format!("={KIT_VERSION}")); + } + + #[test] + fn prepare_fails_closed_for_missing_and_malformed_state() { + let root = fresh_root("missing"); + let cache = root.join("cache"); + let missing = prepare_verified_cache(&root.join("absent.json"), &cache, &[]) + .expect_err("missing state"); + assert_eq!(missing.code, "registry_sync_missing"); + fs::write(root.join("bad.json"), b"{not-json").expect("bad json"); + let malformed = + prepare_verified_cache(&root.join("bad.json"), &cache, &[]).expect_err("malformed"); + assert_eq!(malformed.code, "registry_sync_missing"); + assert_eq!( + malformed.envelope()["kind"], + json!("mcp_mode_b_prepare_cache_error") + ); + } + + #[test] + fn prepare_fails_closed_for_malformed_ref_token() { + let root = fresh_root("bad-ref"); + let state = write_snapshot(&root); + let error = prepare_verified_cache(&state, &root.join("cache"), &["not-a-ref".to_string()]) + .expect_err("malformed ref"); + assert_eq!(error.code, "registry_ref_invalid"); + } + + #[test] + fn prepare_fails_closed_for_unknown_ref() { + let root = fresh_root("unknown-ref"); + let state = write_snapshot(&root); + let cache = root.join("cache"); + let error = prepare_verified_cache( + &state, + &cache, + &["other/unknown.capability@=1.0.0".to_string()], + ) + .expect_err("unknown ref"); + assert_eq!(error.code, "registry_version_not_found"); + } + + #[test] + fn prepare_error_display_is_secret_free() { + let error = PrepareCacheError::new("registry_sync_missing", "synced snapshot is missing"); + assert_eq!( + format!("{error}"), + "registry_sync_missing: synced snapshot is missing" + ); + assert_eq!( + error.to_string(), + "registry_sync_missing: synced snapshot is missing" + ); + } + + #[test] + fn prepare_rejects_snapshot_with_only_deprecated_capabilities() { + let root = fresh_root("deprecated"); + let state = write_snapshot(&root); + let mut snapshot: SyncedPublicRegistryState = + serde_json::from_slice(&fs::read(&state).expect("read")).expect("parse"); + snapshot.capabilities[0].deprecated = true; + fs::write(&state, serde_json::to_vec(&snapshot).expect("write")).expect("update"); + let error = + prepare_verified_cache(&state, &root.join("cache"), &[]).expect_err("deprecated"); + assert_eq!(error.code, "registry_sync_missing"); + } + + #[test] + fn host_fetcher_http_urls_fail_closed_without_echoing_the_url() { + let error = HostCliFetcher + .fetch("https://127.0.0.1:1/traverse-mode-b-missing") + .expect_err("http"); + assert_eq!(error, "host registry artifact fetch failed"); + assert!(!error.contains("127.0.0.1")); + let http = HostCliFetcher + .fetch("http://127.0.0.1:1/traverse-mode-b-missing") + .expect_err("http scheme"); + assert_eq!(http, "host registry artifact fetch failed"); + } + + #[test] + fn host_fetcher_reads_file_urls_and_rejects_unknown_schemes() { + let wasm = repo_root().join( + "examples/core-normalize-participants/artifacts/core-normalize-participants.wasm", + ); + let bytes = HostCliFetcher + .fetch(&format!("file://{}", wasm.display())) + .expect("file fetch"); + assert_eq!(bytes, kit_wasm_bytes()); + let error = HostCliFetcher + .fetch("ftp://example.test/module.wasm") + .expect_err("scheme"); + assert_eq!( + error, + "host registry artifact fetch failed: unsupported url scheme" + ); + let missing = HostCliFetcher + .fetch("file:///definitely-missing-traverse-mode-b.wasm") + .expect_err("missing file"); + assert_eq!(missing, "host registry artifact fetch failed"); + } +} diff --git a/crates/traverse-mcp/src/stdio_server.rs b/crates/traverse-mcp/src/stdio_server.rs index 33478e0c..6d8e270e 100644 --- a/crates/traverse-mcp/src/stdio_server.rs +++ b/crates/traverse-mcp/src/stdio_server.rs @@ -1493,7 +1493,10 @@ fn observation_message_summary(message: McpObservationMessage) -> Value { /// # Errors /// /// Returns `catalog_load_failed` when the canonical expedition bundle cannot be loaded. -pub fn run_stdio_server(simulate_startup_failure: bool) -> Result<(), StdioServerFailure> { +pub fn run_stdio_server( + simulate_startup_failure: bool, + cache_root: Option, +) -> Result<(), StdioServerFailure> { let canonical_execution = CanonicalExecutionContext::load_canonical()?; let catalog = McpDiscoveryCatalog::load_canonical()?; @@ -1531,8 +1534,9 @@ pub fn run_stdio_server(simulate_startup_failure: bool) -> Result<(), StdioServe // Loading fails closed — a missing or invalid prepared state stops startup // with a stable error envelope rather than silently falling back to the // expedition catalog (FR-003). - if let Some(cache_root) = std::env::var_os(MODE_A_CACHE_ENV) { - match ModeAContext::load(PathBuf::from(cache_root)) { + let cache_root = cache_root.or_else(|| std::env::var_os(MODE_A_CACHE_ENV).map(PathBuf::from)); + if let Some(cache_root) = cache_root { + match ModeAContext::load(cache_root) { Ok(context) => { server = server.with_mode_a(Box::leak(Box::new(context))); } @@ -2905,7 +2909,7 @@ mod tests { #[test] fn run_stdio_server_reports_simulated_startup_failure() { - let result = run_stdio_server(true); + let result = run_stdio_server(true, None); assert!(result.is_err()); } diff --git a/docs/mcp-mode-b-release-evidence.md b/docs/mcp-mode-b-release-evidence.md new file mode 100644 index 00000000..482f6b85 --- /dev/null +++ b/docs/mcp-mode-b-release-evidence.md @@ -0,0 +1,64 @@ +# Traverse MCP Mode B — Release & Provenance Evidence + +Governed by spec [`080-embedded-registry-cache`](../specs/520-embedded-registry-cache/spec.md) +(Spec 520 lineage). Mode B is the embedded-cache host track: the shipped +`traverse-mcp` binary **prepares** a host-owned verified cache from public +registry refs, then serves discover/validate/execute/report from that cache +only. Consumers do not rewrite App-References trees via `registry materialize`. + +Mode A (Spec 119) remains the default Claude Desktop / Cursor path that +**consumes** an already-prepared cache. Mode B adds the prepare host CLI. + +## Release form + +Mode B ships in the same versioned `traverse-mcp` binary as Mode A. Pin and +verify that binary through the packaged MCP server artifact path in +[docs/packaged-traverse-mcp-server-artifact.md](packaged-traverse-mcp-server-artifact.md) +and [docs/mcp-mode-a-release-evidence.md](mcp-mode-a-release-evidence.md): + +1. Record the pinned version, e.g. `traverse-mcp 0.11.0`. +2. Download the binary for the host target and its published `.sha256`. +3. Recompute and compare: `shasum -a 256 traverse-mcp` must equal the published + digest. +4. Confirm the provenance attestation names the same version tag and the + `cargo build --locked` build invocation. + +App-References `apps/llm-mcp-reference/mode-b/serve.sh` should invoke this +pinned binary — not a source checkout and not a materialize rewrite. + +## Documented prepare → serve path + +Preparation is the only network-capable step. Serving is offline and +fail-closed when the cache is missing or invalid. + +```bash +traverse-mcp prepare-cache \ + --synced-state /path/to/synced-public-registry-state.json \ + --cache /path/to/verified-registry-cache \ + --ref core/core.normalize-participants@=1.1.0 \ + --json + +traverse-mcp stdio --cache /path/to/verified-registry-cache +``` + +Equivalent serve form (Mode A env, same verified-cache host): + +```bash +TRAVERSE_MCP_REGISTRY_CACHE=/path/to/verified-registry-cache \ + traverse-mcp stdio +``` + +`--ref` may be repeated. When omitted, every non-deprecated capability in the +synced snapshot is prepared at its exact published version. + +See [docs/mcp-stdio-server.md](mcp-stdio-server.md) for the command surface. + +## Verification + +```bash +bash scripts/ci/mcp_stdio_server_mode_b_smoke.sh +``` + +That smoke prepares a fixture cache from public-registry refs, drives stdio +MCP execute against one digest-pinned capability, and asserts an unprepared +cache fails closed. diff --git a/docs/mcp-stdio-server.md b/docs/mcp-stdio-server.md index 676bdda1..0c2569df 100644 --- a/docs/mcp-stdio-server.md +++ b/docs/mcp-stdio-server.md @@ -9,8 +9,8 @@ For the first `youaskm3` release-facing client path, use [docs/youaskm3-canonica It is intentionally narrow: - it stays a façade over Traverse runtime authority -- it uses the canonical expedition registry bundle as its source of truth -- it exposes discovery, description, validation, execution, and execution-report rendering through one stdio command surface +- contributor `stdio` without a cache uses the canonical expedition registry bundle as its source of truth +- Mode A / Mode B serve discover, description, validation, execution, and execution-report from a host-owned verified cache - it is documented and runnable locally ## Supported Bootstrap Path @@ -21,12 +21,15 @@ The supported developer bootstrap path for the dedicated MCP server is: cargo run -p traverse-mcp -- stdio ``` -That `stdio` command is the only supported bootstrap mode in the current app-consumable release path. +Supported host commands: + +- `stdio [--cache ] [--simulate-startup-failure]` — serve MCP on stdio +- `prepare-cache --synced-state --cache [--ref ]... [--json]` — Mode B Spec 520 cache prepare Unsupported bootstrap attempts fail loudly: - omitting the command prints the usage line and exits non-zero -- using any command other than `stdio` prints `Unsupported command: ` and exits non-zero +- using any command other than `stdio` or `prepare-cache` prints `Unsupported command: ` and exits non-zero Developers and agents should treat other bootstrap ideas as unsupported unless they are explicitly documented in this page or in the packaged artifact docs. @@ -166,6 +169,38 @@ the checked-in verified kit fixture at and asserts Mode A discovery + inline execute succeed and that an unprepared cache fails closed. +## Mode B Embedded Verified-Cache Host + +Governed by spec [`080-embedded-registry-cache`](../specs/520-embedded-registry-cache/spec.md). + +Mode B prepares a host-owned Spec 520 verified cache from public registry refs, +then serves MCP from that cache only. App-References consumers point +`mode-b/serve.sh` at the shipped `traverse-mcp` binary — they do not rewrite +trees via `registry materialize`. + +```bash +cargo run -p traverse-mcp -- prepare-cache \ + --synced-state /path/to/synced-public-registry-state.json \ + --cache /path/to/verified-registry-cache \ + --ref core/core.normalize-participants@=1.1.0 \ + --json + +cargo run -p traverse-mcp -- stdio --cache /path/to/verified-registry-cache +``` + +Prepare is the only network-capable step. A missing or invalid cache fails +closed with `registry_sync_missing` / `registry_metadata_cache_invalid` / +`registry_cache_entry_missing`. The versioned binary pin path for App-Refs +Mode B is documented in +[docs/mcp-mode-b-release-evidence.md](mcp-mode-b-release-evidence.md). + +```bash +bash scripts/ci/mcp_stdio_server_mode_b_smoke.sh +``` + +The smoke prepares a fixture cache, drives stdio MCP execute against one +digest-pinned capability, and asserts an unprepared `--cache` fails closed. + Run repository checks: ```bash diff --git a/docs/releases/next.md b/docs/releases/next.md index 3af58c2a..573fcef8 100644 --- a/docs/releases/next.md +++ b/docs/releases/next.md @@ -1,5 +1,13 @@ # Next Release Notes +## Mode B embedded MCP host CLI + +`traverse-mcp prepare-cache` prepares a Spec 520 host-owned verified registry +cache from public `registry_ref` values. `traverse-mcp stdio --cache ` +then serves discover/validate/execute/report from that cache only, without an +expedition checkout or App-References materialize rewrite. Pin and verify the +same versioned `traverse-mcp` binary documented for Mode A. + ## ArtifactRouter WASI diagnosis `ArtifactRouter` now forwards the concrete `WasmExecutor` failure text diff --git a/scripts/ci/mcp_stdio_server_mode_b_smoke.sh b/scripts/ci/mcp_stdio_server_mode_b_smoke.sh new file mode 100755 index 00000000..85427fab --- /dev/null +++ b/scripts/ci/mcp_stdio_server_mode_b_smoke.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash + +# Spec 520 / Spec 080 Mode B: prepare a host-owned verified cache from public +# registry refs, then serve stdio MCP from that cache only. No expedition +# checkout and no App-Refs materialize rewrite. + +set -euo pipefail + +repo_root=$(git rev-parse --show-toplevel) +work_dir=$(mktemp -d) +cache_dir="${work_dir}/verified-cache" +state_path="${work_dir}/synced-state.json" +stdout_log=$(mktemp) +stderr_log=$(mktemp) +prepare_log=$(mktemp) +absent_stdout_log=$(mktemp) +absent_stderr_log=$(mktemp) +absent_cache_dir=$(mktemp -d) + +cleanup() { + rm -f "${stdout_log}" "${stderr_log}" "${prepare_log}" "${absent_stdout_log}" "${absent_stderr_log}" + rm -rf "${work_dir}" "${absent_cache_dir}" +} +trap cleanup EXIT + +wasm="${repo_root}/examples/core-normalize-participants/artifacts/core-normalize-participants.wasm" +contract="${repo_root}/examples/core-normalize-participants/contract.json" +if [[ ! -f "${wasm}" || ! -f "${contract}" ]]; then + echo "Missing Mode B kit fixture under examples/core-normalize-participants." >&2 + exit 1 +fi + +python3 - "${state_path}" "${wasm}" "${contract}" <<'PY' +import hashlib, json, sys +from pathlib import Path + +state_path, wasm_path, contract_path = sys.argv[1], Path(sys.argv[2]), Path(sys.argv[3]) +wasm = wasm_path.read_bytes() +contract = contract_path.read_bytes() + +def digest(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + +record = { + "namespace": "core", + "id": "core.normalize-participants", + "version": "1.1.0", + "digest": digest(wasm), + "artifact_url": f"file://{wasm_path}", + "contract_digest": digest(contract), + "contract_url": f"file://{contract_path}", + "deprecated": False, + "summary": "Normalize raw participants into canonical records.", + "description": "Mode B prepare-cache smoke fixture.", + "use_cases": [{"scenario": "Resolve extracted names and emails to workspace members."}], + "service_type": "stateless", + "permitted_targets": ["wasm"], + "lifecycle": "active", + "provenance": None, +} +state = { + "schema_version": "1.0.0", + "workspace_id": "mode-b-smoke", + "state_scope": "public_registry_synced", + "source_repo": "traverse-framework/registry", + "release_tag": "index-v1", + "index_version": 1, + "generated_at": "2026-09-11T00:00:00Z", + "source_commit": None, + "synced_at": "2026-09-11T00:00:00Z", + "record_count": 1, + "validation_status": "valid", + "governing_spec": "055-registry-sync", + "capabilities": [record], + "events": [], +} +Path(state_path).write_text(json.dumps(state), encoding="utf-8") +PY + +cargo run -p traverse-mcp -- prepare-cache \ + --synced-state "${state_path}" \ + --cache "${cache_dir}" \ + --ref 'core/core.normalize-participants@=1.1.0' \ + --json >"${prepare_log}" + +grep -q '"kind":"mcp_mode_b_prepare_cache"' "${prepare_log}" +grep -q '"governing_spec":"080-embedded-registry-cache"' "${prepare_log}" +grep -q '"cache_prepared":true' "${prepare_log}" +grep -q '"id":"core.normalize-participants"' "${prepare_log}" +if grep -q "${cache_dir}" "${prepare_log}"; then + echo "Mode B prepare evidence must not echo the cache path." >&2 + exit 1 +fi + +kit_id="core.normalize-participants" +kit_version="1.1.0" +inline_request=$(python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin)))' \ + <"${repo_root}/examples/core-normalize-participants/runtime-requests/uc01-mixed-match.json") + +printf '%s\n' \ + '{"command":"describe_server"}' \ + '{"command":"list_entrypoints"}' \ + "{\"command\":\"execute_entrypoint\",\"entrypoint_kind\":\"capability\",\"id\":\"${kit_id}\",\"version\":\"${kit_version}\",\"request\":${inline_request}}" \ + '{"command":"shutdown"}' \ + | cargo run -p traverse-mcp -- stdio --cache "${cache_dir}" \ + >"${stdout_log}" 2>"${stderr_log}" + +grep -q '"mode":"verified_public"' "${stdout_log}" +grep -q '"kind":"host_verified_public_registry"' "${stdout_log}" +grep -q "\"id\":\"${kit_id}\"" "${stdout_log}" +grep -q '"kind":"mcp_stdio_server_entrypoint_execution"' "${stdout_log}" +grep -q '"status":"completed"' "${stdout_log}" +grep -q '"digest_matches_public_state":true' "${stdout_log}" + +if grep -q 'expedition' "${stdout_log}"; then + echo "Mode B output must not reference the expedition catalog." >&2 + exit 1 +fi + +set +e +printf '%s\n' '{"command":"describe_server"}' \ + | cargo run -p traverse-mcp -- stdio --cache "${absent_cache_dir}" \ + >"${absent_stdout_log}" 2>"${absent_stderr_log}" +absent_status=$? +set -e + +if [[ ${absent_status} -eq 0 ]]; then + echo "Expected Mode B to fail closed without prepared verified state." >&2 + exit 1 +fi +grep -q '"code":"registry_sync_missing"' "${absent_stderr_log}" +test ! -s "${absent_stdout_log}" + +echo "MCP stdio server Mode B smoke passed." diff --git a/scripts/ci/repository_checks.sh b/scripts/ci/repository_checks.sh index 023640d5..d9f0f9c1 100644 --- a/scripts/ci/repository_checks.sh +++ b/scripts/ci/repository_checks.sh @@ -31,6 +31,7 @@ required_files=( "docs/mcp-consumption-validation.md" "docs/mcp-stdio-server.md" "docs/mcp-mode-a-release-evidence.md" + "docs/mcp-mode-b-release-evidence.md" "docs/youaskm3-canonical-mcp-client-path.md" "docs/mcp-real-agent-exercise.md" "docs/app-consumable-release-checklist.md" @@ -153,6 +154,7 @@ required_files=( "scripts/ci/mcp_stdio_server_discovery_smoke.sh" "scripts/ci/mcp_stdio_server_execution_report_smoke.sh" "scripts/ci/mcp_stdio_server_mode_a_smoke.sh" + "scripts/ci/mcp_stdio_server_mode_b_smoke.sh" "scripts/ci/mcp_real_agent_exercise_smoke.sh" "scripts/ci/project_board_audit.sh" "scripts/scaffold/hello_world_agent_scaffold.sh" @@ -513,6 +515,12 @@ grep -q "119-verified-registry-mcp-mode-a" docs/mcp-stdio-server.md grep -q "docs/mcp-mode-a-release-evidence.md" docs/mcp-stdio-server.md grep -q "bash scripts/ci/mcp_stdio_server_mode_a_smoke.sh" docs/mcp-mode-a-release-evidence.md grep -q "docs/mcp-stdio-server.md" docs/mcp-mode-a-release-evidence.md +grep -q "bash scripts/ci/mcp_stdio_server_mode_b_smoke.sh" docs/mcp-stdio-server.md +grep -q "prepare-cache" docs/mcp-stdio-server.md +grep -q "docs/mcp-mode-b-release-evidence.md" docs/mcp-stdio-server.md +grep -q "bash scripts/ci/mcp_stdio_server_mode_b_smoke.sh" docs/mcp-mode-b-release-evidence.md +grep -q "docs/mcp-stdio-server.md" docs/mcp-mode-b-release-evidence.md +grep -q "traverse-mcp prepare-cache" docs/mcp-mode-b-release-evidence.md grep -q "render_execution_report" docs/mcp-stdio-server.md grep -q "list_entrypoints" docs/mcp-stdio-server.md grep -q "describe_entrypoint" docs/mcp-stdio-server.md