mcp: implement stdio lifecycle client
This commit is contained in:
parent
c0e760d73e
commit
a114fa9d0a
13
Cargo.lock
generated
13
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
23
crates/mcp/Cargo.toml
Normal 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
7
crates/mcp/src/lib.rs
Normal 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
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
116
crates/mcp/tests/fixtures/mock_server.rs
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
94
crates/mcp/tests/stdio_lifecycle.rs
Normal file
94
crates/mcp/tests/stdio_lifecycle.rs
Normal 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()));
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user