merge: mcp stdio lifecycle client
This commit is contained in:
commit
9cf5344fc5
13
Cargo.lock
generated
13
Cargo.lock
generated
|
|
@ -2078,6 +2078,19 @@ dependencies = [
|
||||||
"regex-automata",
|
"regex-automata",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mcp"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"manifest",
|
||||||
|
"secrets",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.0"
|
version = "2.8.0"
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ members = [
|
||||||
"crates/session-store",
|
"crates/session-store",
|
||||||
"crates/secrets",
|
"crates/secrets",
|
||||||
"crates/manifest",
|
"crates/manifest",
|
||||||
|
"crates/mcp",
|
||||||
"crates/pod",
|
"crates/pod",
|
||||||
"crates/plugin-pdk",
|
"crates/plugin-pdk",
|
||||||
"crates/yoi",
|
"crates/yoi",
|
||||||
|
|
@ -34,6 +35,7 @@ default-members = [
|
||||||
"crates/session-store",
|
"crates/session-store",
|
||||||
"crates/secrets",
|
"crates/secrets",
|
||||||
"crates/manifest",
|
"crates/manifest",
|
||||||
|
"crates/mcp",
|
||||||
"crates/pod",
|
"crates/pod",
|
||||||
"crates/plugin-pdk",
|
"crates/plugin-pdk",
|
||||||
"crates/yoi",
|
"crates/yoi",
|
||||||
|
|
@ -62,6 +64,7 @@ client = { path = "crates/client" }
|
||||||
llm-worker = { path = "crates/llm-worker", version = "0.2" }
|
llm-worker = { path = "crates/llm-worker", version = "0.2" }
|
||||||
llm-worker-macros = { path = "crates/llm-worker-macros", version = "0.2" }
|
llm-worker-macros = { path = "crates/llm-worker-macros", version = "0.2" }
|
||||||
manifest = { path = "crates/manifest" }
|
manifest = { path = "crates/manifest" }
|
||||||
|
mcp = { path = "crates/mcp" }
|
||||||
lint-common = { path = "crates/lint-common" }
|
lint-common = { path = "crates/lint-common" }
|
||||||
memory = { path = "crates/memory" }
|
memory = { path = "crates/memory" }
|
||||||
ticket = { path = "crates/ticket" }
|
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;
|
||||||
1130
crates/mcp/src/stdio.rs
Normal file
1130
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
122
crates/mcp/tests/stdio_lifecycle.rs
Normal file
122
crates/mcp/tests/stdio_lifecycle.rs
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
|
@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec {
|
||||||
filter = sourceFilter;
|
filter = sourceFilter;
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoHash = "sha256-Q+z7HDTkLtflth79ptEFy1lkDR9Y5VRrmX0m9NtLVqM=";
|
cargoHash = "sha256-EH4zdakrFxqVrgaNBx3dICN6KoLqskTEGYnU73XMVsU=";
|
||||||
|
|
||||||
depsExtraArgs = {
|
depsExtraArgs = {
|
||||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user