| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839 |
- use std::collections::{BTreeMap, BTreeSet};
- use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
- use serde::{Deserialize, Serialize};
- fn now_secs() -> u64 {
- SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .unwrap_or_default()
- .as_secs()
- }
- #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
- #[serde(rename_all = "snake_case")]
- pub enum McpLifecyclePhase {
- ConfigLoad,
- ServerRegistration,
- SpawnConnect,
- InitializeHandshake,
- ToolDiscovery,
- ResourceDiscovery,
- Ready,
- Invocation,
- ErrorSurfacing,
- Shutdown,
- Cleanup,
- }
- impl McpLifecyclePhase {
- #[must_use]
- pub fn all() -> [Self; 11] {
- [
- Self::ConfigLoad,
- Self::ServerRegistration,
- Self::SpawnConnect,
- Self::InitializeHandshake,
- Self::ToolDiscovery,
- Self::ResourceDiscovery,
- Self::Ready,
- Self::Invocation,
- Self::ErrorSurfacing,
- Self::Shutdown,
- Self::Cleanup,
- ]
- }
- }
- impl std::fmt::Display for McpLifecyclePhase {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- match self {
- Self::ConfigLoad => write!(f, "config_load"),
- Self::ServerRegistration => write!(f, "server_registration"),
- Self::SpawnConnect => write!(f, "spawn_connect"),
- Self::InitializeHandshake => write!(f, "initialize_handshake"),
- Self::ToolDiscovery => write!(f, "tool_discovery"),
- Self::ResourceDiscovery => write!(f, "resource_discovery"),
- Self::Ready => write!(f, "ready"),
- Self::Invocation => write!(f, "invocation"),
- Self::ErrorSurfacing => write!(f, "error_surfacing"),
- Self::Shutdown => write!(f, "shutdown"),
- Self::Cleanup => write!(f, "cleanup"),
- }
- }
- }
- #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
- pub struct McpErrorSurface {
- pub phase: McpLifecyclePhase,
- pub server_name: Option<String>,
- pub message: String,
- pub context: BTreeMap<String, String>,
- pub recoverable: bool,
- pub timestamp: u64,
- }
- impl McpErrorSurface {
- #[must_use]
- pub fn new(
- phase: McpLifecyclePhase,
- server_name: Option<String>,
- message: impl Into<String>,
- context: BTreeMap<String, String>,
- recoverable: bool,
- ) -> Self {
- Self {
- phase,
- server_name,
- message: message.into(),
- context,
- recoverable,
- timestamp: now_secs(),
- }
- }
- }
- impl std::fmt::Display for McpErrorSurface {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- write!(
- f,
- "MCP lifecycle error during {}: {}",
- self.phase, self.message
- )?;
- if let Some(server_name) = &self.server_name {
- write!(f, " (server: {server_name})")?;
- }
- if !self.context.is_empty() {
- write!(f, " with context {:?}", self.context)?;
- }
- if self.recoverable {
- write!(f, " [recoverable]")?;
- }
- Ok(())
- }
- }
- impl std::error::Error for McpErrorSurface {}
- #[derive(Debug, Clone, PartialEq, Eq)]
- pub enum McpPhaseResult {
- Success {
- phase: McpLifecyclePhase,
- duration: Duration,
- },
- Failure {
- phase: McpLifecyclePhase,
- error: McpErrorSurface,
- },
- Timeout {
- phase: McpLifecyclePhase,
- waited: Duration,
- error: McpErrorSurface,
- },
- }
- impl McpPhaseResult {
- #[must_use]
- pub fn phase(&self) -> McpLifecyclePhase {
- match self {
- Self::Success { phase, .. }
- | Self::Failure { phase, .. }
- | Self::Timeout { phase, .. } => *phase,
- }
- }
- }
- #[derive(Debug, Clone, Default)]
- pub struct McpLifecycleState {
- current_phase: Option<McpLifecyclePhase>,
- phase_errors: BTreeMap<McpLifecyclePhase, Vec<McpErrorSurface>>,
- phase_timestamps: BTreeMap<McpLifecyclePhase, u64>,
- phase_results: Vec<McpPhaseResult>,
- }
- impl McpLifecycleState {
- #[must_use]
- pub fn new() -> Self {
- Self::default()
- }
- #[must_use]
- pub fn current_phase(&self) -> Option<McpLifecyclePhase> {
- self.current_phase
- }
- #[must_use]
- pub fn errors_for_phase(&self, phase: McpLifecyclePhase) -> &[McpErrorSurface] {
- self.phase_errors
- .get(&phase)
- .map(Vec::as_slice)
- .unwrap_or(&[])
- }
- #[must_use]
- pub fn results(&self) -> &[McpPhaseResult] {
- &self.phase_results
- }
- #[must_use]
- pub fn phase_timestamps(&self) -> &BTreeMap<McpLifecyclePhase, u64> {
- &self.phase_timestamps
- }
- #[must_use]
- pub fn phase_timestamp(&self, phase: McpLifecyclePhase) -> Option<u64> {
- self.phase_timestamps.get(&phase).copied()
- }
- fn record_phase(&mut self, phase: McpLifecyclePhase) {
- self.current_phase = Some(phase);
- self.phase_timestamps.insert(phase, now_secs());
- }
- fn record_error(&mut self, error: McpErrorSurface) {
- self.phase_errors
- .entry(error.phase)
- .or_default()
- .push(error);
- }
- fn record_result(&mut self, result: McpPhaseResult) {
- self.phase_results.push(result);
- }
- fn can_resume_after_error(&self) -> bool {
- match self.phase_results.last() {
- Some(McpPhaseResult::Failure { error, .. } | McpPhaseResult::Timeout { error, .. }) => {
- error.recoverable
- }
- _ => false,
- }
- }
- }
- #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
- pub struct McpFailedServer {
- pub server_name: String,
- pub phase: McpLifecyclePhase,
- pub error: McpErrorSurface,
- }
- #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
- pub struct McpDegradedReport {
- pub working_servers: Vec<String>,
- pub failed_servers: Vec<McpFailedServer>,
- pub available_tools: Vec<String>,
- pub missing_tools: Vec<String>,
- }
- impl McpDegradedReport {
- #[must_use]
- pub fn new(
- working_servers: Vec<String>,
- failed_servers: Vec<McpFailedServer>,
- available_tools: Vec<String>,
- expected_tools: Vec<String>,
- ) -> Self {
- let working_servers = dedupe_sorted(working_servers);
- let available_tools = dedupe_sorted(available_tools);
- let available_tool_set: BTreeSet<_> = available_tools.iter().cloned().collect();
- let expected_tools = dedupe_sorted(expected_tools);
- let missing_tools = expected_tools
- .into_iter()
- .filter(|tool| !available_tool_set.contains(tool))
- .collect();
- Self {
- working_servers,
- failed_servers,
- available_tools,
- missing_tools,
- }
- }
- }
- #[derive(Debug, Clone, Default)]
- pub struct McpLifecycleValidator {
- state: McpLifecycleState,
- }
- impl McpLifecycleValidator {
- #[must_use]
- pub fn new() -> Self {
- Self::default()
- }
- #[must_use]
- pub fn state(&self) -> &McpLifecycleState {
- &self.state
- }
- #[must_use]
- pub fn validate_phase_transition(from: McpLifecyclePhase, to: McpLifecyclePhase) -> bool {
- match (from, to) {
- (McpLifecyclePhase::ConfigLoad, McpLifecyclePhase::ServerRegistration)
- | (McpLifecyclePhase::ServerRegistration, McpLifecyclePhase::SpawnConnect)
- | (McpLifecyclePhase::SpawnConnect, McpLifecyclePhase::InitializeHandshake)
- | (McpLifecyclePhase::InitializeHandshake, McpLifecyclePhase::ToolDiscovery)
- | (McpLifecyclePhase::ToolDiscovery, McpLifecyclePhase::ResourceDiscovery)
- | (McpLifecyclePhase::ToolDiscovery, McpLifecyclePhase::Ready)
- | (McpLifecyclePhase::ResourceDiscovery, McpLifecyclePhase::Ready)
- | (McpLifecyclePhase::Ready, McpLifecyclePhase::Invocation)
- | (McpLifecyclePhase::Invocation, McpLifecyclePhase::Ready)
- | (McpLifecyclePhase::ErrorSurfacing, McpLifecyclePhase::Ready)
- | (McpLifecyclePhase::ErrorSurfacing, McpLifecyclePhase::Shutdown)
- | (McpLifecyclePhase::Shutdown, McpLifecyclePhase::Cleanup) => true,
- (_, McpLifecyclePhase::Shutdown) => from != McpLifecyclePhase::Cleanup,
- (_, McpLifecyclePhase::ErrorSurfacing) => {
- from != McpLifecyclePhase::Cleanup && from != McpLifecyclePhase::Shutdown
- }
- _ => false,
- }
- }
- pub fn run_phase(&mut self, phase: McpLifecyclePhase) -> McpPhaseResult {
- let started = Instant::now();
- if let Some(current_phase) = self.state.current_phase() {
- if current_phase == McpLifecyclePhase::ErrorSurfacing
- && phase == McpLifecyclePhase::Ready
- && !self.state.can_resume_after_error()
- {
- return self.record_failure(McpErrorSurface::new(
- phase,
- None,
- "cannot return to ready after a non-recoverable MCP lifecycle failure",
- BTreeMap::from([
- ("from".to_string(), current_phase.to_string()),
- ("to".to_string(), phase.to_string()),
- ]),
- false,
- ));
- }
- if !Self::validate_phase_transition(current_phase, phase) {
- return self.record_failure(McpErrorSurface::new(
- phase,
- None,
- format!("invalid MCP lifecycle transition from {current_phase} to {phase}"),
- BTreeMap::from([
- ("from".to_string(), current_phase.to_string()),
- ("to".to_string(), phase.to_string()),
- ]),
- false,
- ));
- }
- } else if phase != McpLifecyclePhase::ConfigLoad {
- return self.record_failure(McpErrorSurface::new(
- phase,
- None,
- format!("invalid initial MCP lifecycle phase {phase}"),
- BTreeMap::from([("phase".to_string(), phase.to_string())]),
- false,
- ));
- }
- self.state.record_phase(phase);
- let result = McpPhaseResult::Success {
- phase,
- duration: started.elapsed(),
- };
- self.state.record_result(result.clone());
- result
- }
- pub fn record_failure(&mut self, error: McpErrorSurface) -> McpPhaseResult {
- let phase = error.phase;
- self.state.record_error(error.clone());
- self.state.record_phase(McpLifecyclePhase::ErrorSurfacing);
- let result = McpPhaseResult::Failure { phase, error };
- self.state.record_result(result.clone());
- result
- }
- pub fn record_timeout(
- &mut self,
- phase: McpLifecyclePhase,
- waited: Duration,
- server_name: Option<String>,
- mut context: BTreeMap<String, String>,
- ) -> McpPhaseResult {
- context.insert("waited_ms".to_string(), waited.as_millis().to_string());
- let error = McpErrorSurface::new(
- phase,
- server_name,
- format!(
- "MCP lifecycle phase {phase} timed out after {} ms",
- waited.as_millis()
- ),
- context,
- true,
- );
- self.state.record_error(error.clone());
- self.state.record_phase(McpLifecyclePhase::ErrorSurfacing);
- let result = McpPhaseResult::Timeout {
- phase,
- waited,
- error,
- };
- self.state.record_result(result.clone());
- result
- }
- }
- fn dedupe_sorted(mut values: Vec<String>) -> Vec<String> {
- values.sort();
- values.dedup();
- values
- }
- #[cfg(test)]
- mod tests {
- use super::*;
- use serde_json::json;
- #[test]
- fn phase_display_matches_serde_name() {
- // given
- let phases = McpLifecyclePhase::all();
- // when
- let serialized = phases
- .into_iter()
- .map(|phase| {
- (
- phase.to_string(),
- serde_json::to_value(phase).expect("serialize phase"),
- )
- })
- .collect::<Vec<_>>();
- // then
- for (display, json_value) in serialized {
- assert_eq!(json_value, json!(display));
- }
- }
- #[test]
- fn given_startup_path_when_running_to_cleanup_then_each_control_transition_succeeds() {
- // given
- let mut validator = McpLifecycleValidator::new();
- let phases = [
- McpLifecyclePhase::ConfigLoad,
- McpLifecyclePhase::ServerRegistration,
- McpLifecyclePhase::SpawnConnect,
- McpLifecyclePhase::InitializeHandshake,
- McpLifecyclePhase::ToolDiscovery,
- McpLifecyclePhase::ResourceDiscovery,
- McpLifecyclePhase::Ready,
- McpLifecyclePhase::Invocation,
- McpLifecyclePhase::Ready,
- McpLifecyclePhase::Shutdown,
- McpLifecyclePhase::Cleanup,
- ];
- // when
- let results = phases
- .into_iter()
- .map(|phase| validator.run_phase(phase))
- .collect::<Vec<_>>();
- // then
- assert!(results
- .iter()
- .all(|result| matches!(result, McpPhaseResult::Success { .. })));
- assert_eq!(
- validator.state().current_phase(),
- Some(McpLifecyclePhase::Cleanup)
- );
- for phase in [
- McpLifecyclePhase::ConfigLoad,
- McpLifecyclePhase::ServerRegistration,
- McpLifecyclePhase::SpawnConnect,
- McpLifecyclePhase::InitializeHandshake,
- McpLifecyclePhase::ToolDiscovery,
- McpLifecyclePhase::ResourceDiscovery,
- McpLifecyclePhase::Ready,
- McpLifecyclePhase::Invocation,
- McpLifecyclePhase::Shutdown,
- McpLifecyclePhase::Cleanup,
- ] {
- assert!(validator.state().phase_timestamp(phase).is_some());
- }
- }
- #[test]
- fn given_tool_discovery_when_resource_discovery_is_skipped_then_ready_is_still_allowed() {
- // given
- let mut validator = McpLifecycleValidator::new();
- for phase in [
- McpLifecyclePhase::ConfigLoad,
- McpLifecyclePhase::ServerRegistration,
- McpLifecyclePhase::SpawnConnect,
- McpLifecyclePhase::InitializeHandshake,
- McpLifecyclePhase::ToolDiscovery,
- ] {
- let result = validator.run_phase(phase);
- assert!(matches!(result, McpPhaseResult::Success { .. }));
- }
- // when
- let result = validator.run_phase(McpLifecyclePhase::Ready);
- // then
- assert!(matches!(result, McpPhaseResult::Success { .. }));
- assert_eq!(
- validator.state().current_phase(),
- Some(McpLifecyclePhase::Ready)
- );
- }
- #[test]
- fn validates_expected_phase_transitions() {
- // given
- let valid_transitions = [
- (
- McpLifecyclePhase::ConfigLoad,
- McpLifecyclePhase::ServerRegistration,
- ),
- (
- McpLifecyclePhase::ServerRegistration,
- McpLifecyclePhase::SpawnConnect,
- ),
- (
- McpLifecyclePhase::SpawnConnect,
- McpLifecyclePhase::InitializeHandshake,
- ),
- (
- McpLifecyclePhase::InitializeHandshake,
- McpLifecyclePhase::ToolDiscovery,
- ),
- (
- McpLifecyclePhase::ToolDiscovery,
- McpLifecyclePhase::ResourceDiscovery,
- ),
- (McpLifecyclePhase::ToolDiscovery, McpLifecyclePhase::Ready),
- (
- McpLifecyclePhase::ResourceDiscovery,
- McpLifecyclePhase::Ready,
- ),
- (McpLifecyclePhase::Ready, McpLifecyclePhase::Invocation),
- (McpLifecyclePhase::Invocation, McpLifecyclePhase::Ready),
- (McpLifecyclePhase::Ready, McpLifecyclePhase::Shutdown),
- (
- McpLifecyclePhase::Invocation,
- McpLifecyclePhase::ErrorSurfacing,
- ),
- (
- McpLifecyclePhase::ErrorSurfacing,
- McpLifecyclePhase::Shutdown,
- ),
- (McpLifecyclePhase::Shutdown, McpLifecyclePhase::Cleanup),
- ];
- // when / then
- for (from, to) in valid_transitions {
- assert!(McpLifecycleValidator::validate_phase_transition(from, to));
- }
- assert!(!McpLifecycleValidator::validate_phase_transition(
- McpLifecyclePhase::Ready,
- McpLifecyclePhase::ConfigLoad,
- ));
- assert!(!McpLifecycleValidator::validate_phase_transition(
- McpLifecyclePhase::Cleanup,
- McpLifecyclePhase::Ready,
- ));
- }
- #[test]
- fn given_invalid_transition_when_running_phase_then_structured_failure_is_recorded() {
- // given
- let mut validator = McpLifecycleValidator::new();
- let _ = validator.run_phase(McpLifecyclePhase::ConfigLoad);
- let _ = validator.run_phase(McpLifecyclePhase::ServerRegistration);
- // when
- let result = validator.run_phase(McpLifecyclePhase::Ready);
- // then
- match result {
- McpPhaseResult::Failure { phase, error } => {
- assert_eq!(phase, McpLifecyclePhase::Ready);
- assert!(!error.recoverable);
- assert_eq!(error.phase, McpLifecyclePhase::Ready);
- assert_eq!(
- error.context.get("from").map(String::as_str),
- Some("server_registration")
- );
- assert_eq!(error.context.get("to").map(String::as_str), Some("ready"));
- }
- other => panic!("expected failure result, got {other:?}"),
- }
- assert_eq!(
- validator.state().current_phase(),
- Some(McpLifecyclePhase::ErrorSurfacing)
- );
- assert_eq!(
- validator
- .state()
- .errors_for_phase(McpLifecyclePhase::Ready)
- .len(),
- 1
- );
- }
- #[test]
- fn given_each_phase_when_failure_is_recorded_then_error_is_tracked_per_phase() {
- // given
- let mut validator = McpLifecycleValidator::new();
- // when / then
- for phase in McpLifecyclePhase::all() {
- let result = validator.record_failure(McpErrorSurface::new(
- phase,
- Some("alpha".to_string()),
- format!("failure at {phase}"),
- BTreeMap::from([("server".to_string(), "alpha".to_string())]),
- phase == McpLifecyclePhase::ResourceDiscovery,
- ));
- match result {
- McpPhaseResult::Failure { phase: failed_phase, error } => {
- assert_eq!(failed_phase, phase);
- assert_eq!(error.phase, phase);
- assert_eq!(
- error.recoverable,
- phase == McpLifecyclePhase::ResourceDiscovery
- );
- }
- other => panic!("expected failure result, got {other:?}"),
- }
- assert_eq!(validator.state().errors_for_phase(phase).len(), 1);
- }
- }
- #[test]
- fn given_spawn_connect_timeout_when_recorded_then_waited_duration_is_preserved() {
- // given
- let mut validator = McpLifecycleValidator::new();
- let waited = Duration::from_millis(250);
- // when
- let result = validator.record_timeout(
- McpLifecyclePhase::SpawnConnect,
- waited,
- Some("alpha".to_string()),
- BTreeMap::from([("attempt".to_string(), "1".to_string())]),
- );
- // then
- match result {
- McpPhaseResult::Timeout {
- phase,
- waited: actual,
- error,
- } => {
- assert_eq!(phase, McpLifecyclePhase::SpawnConnect);
- assert_eq!(actual, waited);
- assert!(error.recoverable);
- assert_eq!(error.server_name.as_deref(), Some("alpha"));
- }
- other => panic!("expected timeout result, got {other:?}"),
- }
- let errors = validator
- .state()
- .errors_for_phase(McpLifecyclePhase::SpawnConnect);
- assert_eq!(errors.len(), 1);
- assert_eq!(
- errors[0].context.get("waited_ms").map(String::as_str),
- Some("250")
- );
- assert_eq!(
- validator.state().current_phase(),
- Some(McpLifecyclePhase::ErrorSurfacing)
- );
- }
- #[test]
- fn given_partial_server_health_when_building_degraded_report_then_missing_tools_are_reported() {
- // given
- let failed = vec![McpFailedServer {
- server_name: "broken".to_string(),
- phase: McpLifecyclePhase::InitializeHandshake,
- error: McpErrorSurface::new(
- McpLifecyclePhase::InitializeHandshake,
- Some("broken".to_string()),
- "initialize failed",
- BTreeMap::from([("reason".to_string(), "broken pipe".to_string())]),
- false,
- ),
- }];
- // when
- let report = McpDegradedReport::new(
- vec!["alpha".to_string(), "beta".to_string(), "alpha".to_string()],
- failed,
- vec![
- "alpha.echo".to_string(),
- "beta.search".to_string(),
- "alpha.echo".to_string(),
- ],
- vec![
- "alpha.echo".to_string(),
- "beta.search".to_string(),
- "broken.fetch".to_string(),
- ],
- );
- // then
- assert_eq!(
- report.working_servers,
- vec!["alpha".to_string(), "beta".to_string()]
- );
- assert_eq!(report.failed_servers.len(), 1);
- assert_eq!(report.failed_servers[0].server_name, "broken");
- assert_eq!(
- report.available_tools,
- vec!["alpha.echo".to_string(), "beta.search".to_string()]
- );
- assert_eq!(report.missing_tools, vec!["broken.fetch".to_string()]);
- }
- #[test]
- fn given_failure_during_resource_discovery_when_shutting_down_then_cleanup_still_succeeds() {
- // given
- let mut validator = McpLifecycleValidator::new();
- for phase in [
- McpLifecyclePhase::ConfigLoad,
- McpLifecyclePhase::ServerRegistration,
- McpLifecyclePhase::SpawnConnect,
- McpLifecyclePhase::InitializeHandshake,
- McpLifecyclePhase::ToolDiscovery,
- ] {
- let result = validator.run_phase(phase);
- assert!(matches!(result, McpPhaseResult::Success { .. }));
- }
- let _ = validator.record_failure(McpErrorSurface::new(
- McpLifecyclePhase::ResourceDiscovery,
- Some("alpha".to_string()),
- "resource listing failed",
- BTreeMap::from([("reason".to_string(), "timeout".to_string())]),
- true,
- ));
- // when
- let shutdown = validator.run_phase(McpLifecyclePhase::Shutdown);
- let cleanup = validator.run_phase(McpLifecyclePhase::Cleanup);
- // then
- assert!(matches!(shutdown, McpPhaseResult::Success { .. }));
- assert!(matches!(cleanup, McpPhaseResult::Success { .. }));
- assert_eq!(
- validator.state().current_phase(),
- Some(McpLifecyclePhase::Cleanup)
- );
- assert!(validator
- .state()
- .phase_timestamp(McpLifecyclePhase::ErrorSurfacing)
- .is_some());
- }
- #[test]
- fn error_surface_display_includes_phase_server_and_recoverable_flag() {
- // given
- let error = McpErrorSurface::new(
- McpLifecyclePhase::SpawnConnect,
- Some("alpha".to_string()),
- "process exited early",
- BTreeMap::from([("exit_code".to_string(), "1".to_string())]),
- true,
- );
- // when
- let rendered = error.to_string();
- // then
- assert!(rendered.contains("spawn_connect"));
- assert!(rendered.contains("process exited early"));
- assert!(rendered.contains("server: alpha"));
- assert!(rendered.contains("recoverable"));
- let trait_object: &dyn std::error::Error = &error;
- assert_eq!(trait_object.to_string(), rendered);
- }
- #[test]
- fn given_nonrecoverable_failure_when_returning_to_ready_then_validator_rejects_resume() {
- // given
- let mut validator = McpLifecycleValidator::new();
- for phase in [
- McpLifecyclePhase::ConfigLoad,
- McpLifecyclePhase::ServerRegistration,
- McpLifecyclePhase::SpawnConnect,
- McpLifecyclePhase::InitializeHandshake,
- McpLifecyclePhase::ToolDiscovery,
- McpLifecyclePhase::Ready,
- ] {
- let result = validator.run_phase(phase);
- assert!(matches!(result, McpPhaseResult::Success { .. }));
- }
- let _ = validator.record_failure(McpErrorSurface::new(
- McpLifecyclePhase::Invocation,
- Some("alpha".to_string()),
- "tool call corrupted the session",
- BTreeMap::from([("reason".to_string(), "invalid frame".to_string())]),
- false,
- ));
- // when
- let result = validator.run_phase(McpLifecyclePhase::Ready);
- // then
- match result {
- McpPhaseResult::Failure { phase, error } => {
- assert_eq!(phase, McpLifecyclePhase::Ready);
- assert!(!error.recoverable);
- assert!(error.message.contains("non-recoverable"));
- }
- other => panic!("expected failure result, got {other:?}"),
- }
- assert_eq!(
- validator.state().current_phase(),
- Some(McpLifecyclePhase::ErrorSurfacing)
- );
- }
- #[test]
- fn given_recoverable_failure_when_returning_to_ready_then_validator_allows_resume() {
- // given
- let mut validator = McpLifecycleValidator::new();
- for phase in [
- McpLifecyclePhase::ConfigLoad,
- McpLifecyclePhase::ServerRegistration,
- McpLifecyclePhase::SpawnConnect,
- McpLifecyclePhase::InitializeHandshake,
- McpLifecyclePhase::ToolDiscovery,
- McpLifecyclePhase::Ready,
- ] {
- let result = validator.run_phase(phase);
- assert!(matches!(result, McpPhaseResult::Success { .. }));
- }
- let _ = validator.record_failure(McpErrorSurface::new(
- McpLifecyclePhase::Invocation,
- Some("alpha".to_string()),
- "tool call failed but can be retried",
- BTreeMap::from([("reason".to_string(), "upstream timeout".to_string())]),
- true,
- ));
- // when
- let result = validator.run_phase(McpLifecyclePhase::Ready);
- // then
- assert!(matches!(result, McpPhaseResult::Success { .. }));
- assert_eq!(
- validator.state().current_phase(),
- Some(McpLifecyclePhase::Ready)
- );
- }
- }
|