mcp: implement stdio lifecycle client

This commit is contained in:
Keisuke Hirata 2026-06-20 16:45:05 +09:00
parent c0e760d73e
commit a114fa9d0a
No known key found for this signature in database
8 changed files with 1369 additions and 1 deletions

13
Cargo.lock generated
View File

@ -2078,6 +2078,19 @@ dependencies = [
"regex-automata",
]
[[package]]
name = "mcp"
version = "0.1.0"
dependencies = [
"libc",
"manifest",
"secrets",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
]
[[package]]
name = "memchr"
version = "2.8.0"

View File

@ -8,6 +8,7 @@ members = [
"crates/session-store",
"crates/secrets",
"crates/manifest",
"crates/mcp",
"crates/pod",
"crates/plugin-pdk",
"crates/yoi",
@ -34,6 +35,7 @@ default-members = [
"crates/session-store",
"crates/secrets",
"crates/manifest",
"crates/mcp",
"crates/pod",
"crates/plugin-pdk",
"crates/yoi",
@ -62,6 +64,7 @@ client = { path = "crates/client" }
llm-worker = { path = "crates/llm-worker", version = "0.2" }
llm-worker-macros = { path = "crates/llm-worker-macros", version = "0.2" }
manifest = { path = "crates/manifest" }
mcp = { path = "crates/mcp" }
lint-common = { path = "crates/lint-common" }
memory = { path = "crates/memory" }
ticket = { path = "crates/ticket" }

23
crates/mcp/Cargo.toml Normal file
View File

@ -0,0 +1,23 @@
[package]
name = "mcp"
version = "0.1.0"
edition.workspace = true
[dependencies]
libc = "0.2"
manifest = { workspace = true }
secrets = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["io-util", "process", "sync", "time"] }
[dev-dependencies]
tokio = { workspace = true, features = ["io-util", "macros", "process", "rt-multi-thread", "sync", "time"] }
[[bin]]
name = "mcp-stdio-mock-server"
path = "tests/fixtures/mock_server.rs"
test = false
bench = false
doc = false

7
crates/mcp/src/lib.rs Normal file
View File

@ -0,0 +1,7 @@
//! Model Context Protocol client foundations.
//!
//! This crate intentionally only owns protocol/lifecycle plumbing. It does not
//! register MCP tools, resources, or prompts into Yoi's model-visible tool
//! surface.
pub mod stdio;

1112
crates/mcp/src/stdio.rs Normal file

File diff suppressed because it is too large Load Diff

116
crates/mcp/tests/fixtures/mock_server.rs vendored Normal file
View File

@ -0,0 +1,116 @@
use std::env;
use std::io::{self, BufRead, Write};
use std::thread;
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());
match mode.as_str() {
"success" => success(),
"fail-init" => fail_init(),
"sampling" => sampling_request(),
"shutdown-hang" => shutdown_hang(),
other => panic!("unknown mock mode: {other}"),
}
}
fn success() {
let init = read_json();
assert_eq!(init["method"], "initialize");
assert!(init["params"]["capabilities"].get("sampling").is_none());
assert!(init["params"]["capabilities"].get("elicitation").is_none());
write_json(json!({
"jsonrpc": "2.0",
"id": init["id"],
"result": initialize_result(),
}));
let initialized = read_json();
assert_eq!(initialized["method"], "notifications/initialized");
drain_stdin();
}
fn fail_init() {
let secret = env::var("MCP_TEST_SECRET").unwrap_or_default();
for idx in 0..5 {
eprintln!("diagnostic {idx}: secret={secret}");
}
let init = read_json();
write_json(json!({
"jsonrpc": "2.0",
"id": init["id"],
"error": {
"code": -32000,
"message": format!("init rejected with {secret}"),
}
}));
}
fn sampling_request() {
let init = read_json();
write_json(json!({
"jsonrpc": "2.0",
"id": init["id"],
"result": initialize_result(),
}));
let initialized = read_json();
assert_eq!(initialized["method"], "notifications/initialized");
write_json(json!({
"jsonrpc": "2.0",
"id": 99,
"method": "sampling/createMessage",
"params": {},
}));
let response = read_json();
assert_eq!(response["id"], 99);
assert_eq!(response["error"]["code"], -32601);
}
fn shutdown_hang() {
let init = read_json();
write_json(json!({
"jsonrpc": "2.0",
"id": init["id"],
"result": initialize_result(),
}));
let initialized = read_json();
assert_eq!(initialized["method"], "notifications/initialized");
loop {
thread::sleep(Duration::from_secs(60));
}
}
fn initialize_result() -> Value {
json!({
"protocolVersion": "2025-11-25",
"capabilities": {
"tools": { "listChanged": true }
},
"serverInfo": {
"name": "mock-mcp",
"version": "0.1.0"
}
})
}
fn read_json() -> Value {
let mut line = String::new();
let read = io::stdin().lock().read_line(&mut line).expect("read stdin");
assert_ne!(read, 0, "stdin closed before JSON-RPC message");
serde_json::from_str(&line).expect("valid JSON-RPC line")
}
fn write_json(value: Value) {
let mut stdout = io::stdout().lock();
serde_json::to_writer(&mut stdout, &value).expect("write JSON");
stdout.write_all(b"\n").expect("write newline");
stdout.flush().expect("flush stdout");
}
fn drain_stdin() {
let mut line = String::new();
while io::stdin().lock().read_line(&mut line).unwrap_or(0) != 0 {
line.clear();
}
}

View File

@ -0,0 +1,94 @@
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()
}
}
#[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()));
}

View File

@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter;
};
cargoHash = "sha256-Q+z7HDTkLtflth79ptEFy1lkDR9Y5VRrmX0m9NtLVqM=";
cargoHash = "sha256-EH4zdakrFxqVrgaNBx3dICN6KoLqskTEGYnU73XMVsU=";
depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint,