fix: bound MCP stdio requests and shutdown across stdin waits

This commit is contained in:
2026-10-12 02:35:42 +09:00
parent 98023b9872
commit 520a898bd8
5 changed files with 602 additions and 30 deletions
+18 -1
View File
@@ -6,8 +6,11 @@ use std::time::Duration;
use serde_json::{Value, json};
fn main() {
let mode = env::var("YOI_MCP_MOCK_MODE").unwrap_or_else(|_| "success".to_string());
let mode = env::args()
.nth(1)
.unwrap_or_else(|| env::var("YOI_MCP_MOCK_MODE").unwrap_or_else(|_| "success".to_string()));
match mode.as_str() {
"stdin-unread" => stdin_unread(),
"success" => success(),
"tools" => tools_list(),
"tools-call-normal" => tools_call_normal(),
@@ -22,6 +25,20 @@ fn main() {
}
}
fn stdin_unread() {
let init = read_json();
assert_eq!(init["method"], "initialize");
write_json(json!({"jsonrpc": "2.0", "id": init["id"], "result": initialize_result()}));
assert_eq!(read_json()["method"], "notifications/initialized");
// Protocol barrier: the host only writes its oversized call after this ack.
let ready = read_json();
assert_eq!(ready["method"], "fixture/ready");
write_json(json!({"jsonrpc": "2.0", "id": ready["id"], "result": {"ready": true}}));
loop {
thread::park();
}
}
fn success() {
let init = read_json();
assert_eq!(init["method"], "initialize");
+45
View File
@@ -278,3 +278,48 @@ async fn sampling_requests_fail_closed_and_are_not_advertised() {
let shutdown = client.shutdown().await.expect("shutdown succeeds");
assert!(shutdown.exit_status.is_some_and(|status| status.success()));
}
#[tokio::test]
async fn unread_stdin_request_times_out_during_write_and_closes_before_reuse() {
let limits = McpStdioLimits {
request_timeout: Duration::from_millis(100),
..tight_limits()
};
let spec = McpStdioServerSpec::new("unread-stdin", env!("CARGO_BIN_EXE_mcp-stdio-mock-server"))
.arg("stdin-unread");
let mut client = McpStdioClient::connect(spec, limits).await.unwrap();
let ready: serde_json::Value = client
.request(McpPhase::Running, "fixture/ready", serde_json::json!({}))
.await
.unwrap();
assert_eq!(ready["ready"], true);
// Far beyond OS pipe capacity, but inside the existing protocol bound.
let call = CallToolRequest::new(
"write-blocked",
serde_json::json!({"payload": "x".repeat(768 * 1024)}),
);
let err = tokio::time::timeout(Duration::from_secs(1), client.call_tool(call))
.await
.expect("the whole request, not only response wait, is bounded")
.unwrap_err();
assert!(
matches!(err.kind, McpErrorKind::Timeout { ref operation, .. } if operation == "tools/call")
);
assert_eq!(
err.request_stage(),
Some(mcp::stdio::McpRequestStage::WritingFrame)
);
// The fixture barrier and reported stage establish actual write backpressure;
// the separate controlled fixture also verifies the exact partial bytes.
let next = client
.request::<serde_json::Value>(McpPhase::Running, "fixture/next", serde_json::json!({}))
.await
.unwrap_err();
assert!(next.to_string().contains("stdin is closed"));
let report = tokio::time::timeout(Duration::from_secs(1), client.shutdown())
.await
.unwrap()
.unwrap();
assert!(report.terminated || report.killed);
assert!(report.exit_status.is_some());
}