yoi/crates/mcp/tests/stdio_lifecycle.rs

123 lines
4.4 KiB
Rust

use std::time::Duration;
use mcp::stdio::{McpErrorKind, McpPhase, McpStdioClient, McpStdioLimits, McpStdioServerSpec};
fn mock_server(mode: &str) -> McpStdioServerSpec {
McpStdioServerSpec::new("mock", env!("CARGO_BIN_EXE_mcp-stdio-mock-server"))
.env("YOI_MCP_MOCK_MODE", mode)
}
fn tight_limits() -> McpStdioLimits {
McpStdioLimits {
startup_timeout: Duration::from_secs(2),
request_timeout: Duration::from_secs(2),
shutdown_timeout: Duration::from_millis(100),
kill_timeout: Duration::from_millis(100),
max_diagnostic_lines: 2,
max_stderr_line_bytes: 256,
..Default::default()
}
}
#[test]
fn stdio_server_spec_debug_redacts_resolved_env_values() {
let spec = McpStdioServerSpec::new("debug-mock", "/bin/mock-mcp")
.arg("--stdio")
.cwd("/tmp/mock-mcp")
.env("LITERAL_VALUE", "literal-plaintext")
.env("INHERITED_VALUE", "inherited-plaintext")
.env("ENV_REF_VALUE", "env-ref-plaintext")
.env("SECRET_REF_VALUE", "secret-ref-plaintext");
let debug = format!("{spec:?}");
assert!(debug.contains("debug-mock"));
assert!(debug.contains("/bin/mock-mcp"));
assert!(debug.contains("--stdio"));
assert!(debug.contains("/tmp/mock-mcp"));
assert!(debug.contains("LITERAL_VALUE"));
assert!(debug.contains("INHERITED_VALUE"));
assert!(debug.contains("ENV_REF_VALUE"));
assert!(debug.contains("SECRET_REF_VALUE"));
assert!(debug.contains("[redacted]"));
assert!(!debug.contains("literal-plaintext"));
assert!(!debug.contains("inherited-plaintext"));
assert!(!debug.contains("env-ref-plaintext"));
assert!(!debug.contains("secret-ref-plaintext"));
}
#[tokio::test]
async fn initializes_mock_stdio_server() {
let mut client = McpStdioClient::connect(mock_server("success"), tight_limits())
.await
.expect("initialize succeeds");
let result = client.initialize_result().expect("initialize result");
assert_eq!(result.protocol_version, "2025-11-25");
assert_eq!(result.server_info.name, "mock-mcp");
let shutdown = client.shutdown().await.expect("shutdown succeeds");
assert!(!shutdown.terminated);
assert!(!shutdown.killed);
assert!(shutdown.exit_status.is_some_and(|status| status.success()));
}
#[tokio::test]
async fn initialize_failure_reports_server_phase_and_redacted_bounded_stderr() {
let spec = mock_server("fail-init").env("MCP_TEST_SECRET", "super-secret-token");
let err = match McpStdioClient::connect(spec, tight_limits()).await {
Ok(mut client) => {
let _ = client.shutdown().await;
panic!("initialize unexpectedly succeeded");
}
Err(err) => err,
};
assert_eq!(err.server_name, "mock");
assert_eq!(err.phase, McpPhase::Initialize);
match &err.kind {
McpErrorKind::JsonRpcError { code, message } => {
assert_eq!(*code, -32000);
assert!(!message.contains("super-secret-token"));
assert!(message.contains("[redacted]"));
}
other => panic!("unexpected error kind: {other:?}"),
}
let rendered = err.to_string();
assert!(rendered.contains("mock"));
assert!(rendered.contains("initialize"));
let diagnostics = err.diagnostics().expect("diagnostics");
assert_eq!(diagnostics.server_name, "mock");
assert_eq!(diagnostics.stderr.len(), 2);
assert!(diagnostics.dropped_stderr_lines >= 3);
assert!(
diagnostics
.stderr
.iter()
.all(|line| !line.contains("super-secret-token"))
);
assert!(
diagnostics
.stderr
.iter()
.any(|line| line.contains("[redacted]"))
);
}
#[tokio::test]
async fn shutdown_terminates_or_kills_uncooperative_server() {
let mut client = McpStdioClient::connect(mock_server("shutdown-hang"), tight_limits())
.await
.expect("initialize succeeds");
let shutdown = client.shutdown().await.expect("shutdown succeeds");
assert!(shutdown.terminated || shutdown.killed);
}
#[tokio::test]
async fn sampling_requests_fail_closed_and_are_not_advertised() {
let mut client = McpStdioClient::connect(mock_server("sampling"), tight_limits())
.await
.expect("initialize succeeds");
tokio::time::sleep(Duration::from_millis(50)).await;
let shutdown = client.shutdown().await.expect("shutdown succeeds");
assert!(shutdown.exit_status.is_some_and(|status| status.success()));
}