From 4a30959fdd8483ddeaaaca3f074489d59d415d1c Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 7 May 2026 16:10:17 +0900 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(kebab-cli):=20kebab=20mcp=20su?= =?UTF-8?q?bcommand=20(fb-30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires kebab_mcp::serve_stdio into kebab-cli. `--config ` honored via the established Config::load pattern. Updated serve_stdio signature to (Config, Option) so the doctor tool's path-aware behavior works correctly via KebabAppState. Smoke test spawns the binary + sends initialize + initialized + tools/list over stdin, asserts 4 tools returned. Confirms the MCP server boots end-to-end via the real binary (rmcp 1.6 has no in-memory test transport, so this is the only end-to-end assertion). Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 1 + crates/kebab-cli/Cargo.toml | 2 + crates/kebab-cli/src/main.rs | 10 ++++ crates/kebab-cli/tests/cli_mcp_smoke.rs | 77 +++++++++++++++++++++++++ crates/kebab-mcp/src/lib.rs | 15 +++-- 5 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 crates/kebab-cli/tests/cli_mcp_smoke.rs diff --git a/Cargo.lock b/Cargo.lock index c520efb..fe7bb5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3592,6 +3592,7 @@ dependencies = [ "kebab-config", "kebab-core", "kebab-eval", + "kebab-mcp", "kebab-tui", "serde", "serde_json", diff --git a/crates/kebab-cli/Cargo.toml b/crates/kebab-cli/Cargo.toml index 91ca172..a033b49 100644 --- a/crates/kebab-cli/Cargo.toml +++ b/crates/kebab-cli/Cargo.toml @@ -27,6 +27,8 @@ kebab-eval = { path = "../kebab-eval" } # enforces the §8 boundary in its own Cargo.toml; kb-cli just # launches it. kebab-tui = { path = "../kebab-tui" } +# p9-fb-30: MCP stdio server. `Cmd::Mcp` delegates entirely to this crate. +kebab-mcp = { path = "../kebab-mcp" } anyhow = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/kebab-cli/src/main.rs b/crates/kebab-cli/src/main.rs index 3da549d..db0f4b1 100644 --- a/crates/kebab-cli/src/main.rs +++ b/crates/kebab-cli/src/main.rs @@ -188,6 +188,11 @@ enum Cmd { #[command(subcommand)] what: EvalWhat, }, + + /// Run the MCP (Model Context Protocol) stdio server. Used by + /// agent hosts (Claude Code / Cursor / OpenAI Agents) to call kebab + /// tools (search / ask / schema / doctor). + Mcp, } #[derive(Subcommand, Debug)] @@ -739,6 +744,11 @@ fn run(cli: &Cli) -> anyhow::Result<()> { Ok(()) } }, + + Cmd::Mcp => { + let cfg = kebab_config::Config::load(cli.config.as_deref())?; + kebab_mcp::serve_stdio(cfg, cli.config.clone()) + } } } diff --git a/crates/kebab-cli/tests/cli_mcp_smoke.rs b/crates/kebab-cli/tests/cli_mcp_smoke.rs new file mode 100644 index 0000000..bdfe335 --- /dev/null +++ b/crates/kebab-cli/tests/cli_mcp_smoke.rs @@ -0,0 +1,77 @@ +//! Spawn `target/debug/kebab mcp` and exercise initialize → tools/list. +//! +//! rmcp 1.6 has no public in-memory test transport, so this is the only +//! end-to-end MCP assertion in the suite. The binary is located via +//! `CARGO_BIN_EXE_kebab` which cargo injects at test compile time. + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Command, Stdio}; + +#[test] +fn cli_mcp_initialize_then_tools_list() { + let bin = env!("CARGO_BIN_EXE_kebab"); + let mut child = Command::new(bin) + .arg("mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut reader = BufReader::new(stdout); + + // rmcp 1.6 defaults to protocol version "2025-03-26" (confirmed by + // manual smoke in Task 10). The server echoes whatever version the + // client sends during the handshake, so this literal must match. + let init_req = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}"#; + writeln!(stdin, "{init_req}").unwrap(); + writeln!( + stdin, + r#"{{"jsonrpc":"2.0","method":"notifications/initialized"}}"# + ) + .unwrap(); + writeln!( + stdin, + r#"{{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{{}}}}"# + ) + .unwrap(); + + // Read initialize response. + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let init: serde_json::Value = serde_json::from_str(line.trim()).unwrap(); + assert_eq!( + init.get("id").and_then(|i| i.as_i64()), + Some(1), + "unexpected id in initialize response: {init}" + ); + assert!( + init.get("result").is_some(), + "initialize result missing: {init}" + ); + + // Read tools/list response. + line.clear(); + reader.read_line(&mut line).unwrap(); + let list: serde_json::Value = serde_json::from_str(line.trim()).unwrap(); + assert_eq!( + list.get("id").and_then(|i| i.as_i64()), + Some(2), + "unexpected id in tools/list response: {list}" + ); + let tools = list["result"]["tools"] + .as_array() + .expect("tools/list result.tools must be an array"); + assert_eq!( + tools.len(), + 4, + "expected 4 tools (schema, doctor, search, ask), got {}: {list}", + tools.len() + ); + + // Gracefully close stdin so the server shuts down cleanly. + drop(stdin); + let _ = child.wait().unwrap(); +} diff --git a/crates/kebab-mcp/src/lib.rs b/crates/kebab-mcp/src/lib.rs index 9cbafa9..d50febf 100644 --- a/crates/kebab-mcp/src/lib.rs +++ b/crates/kebab-mcp/src/lib.rs @@ -4,6 +4,8 @@ //! //! See spec `docs/superpowers/specs/2026-05-07-p9-fb-30-mcp-server-design.md`. +use std::path::PathBuf; + use anyhow::Result; use rmcp::ServerHandler; @@ -142,16 +144,21 @@ impl ServerHandler for KebabHandler { /// Run the MCP server on stdio JSON-RPC. Blocks until the client closes /// the stream (typically when the agent host exits). -pub fn serve_stdio(cfg: Config) -> Result<()> { +/// +/// `config_path` is the path passed via `--config `, if any. +/// It is forwarded to `KebabAppState` so the doctor tool can honour the +/// same config file the server was started with (falls back to XDG default +/// when `None`). +pub fn serve_stdio(cfg: Config, config_path: Option) -> Result<()> { let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .build()?; - runtime.block_on(serve_stdio_async(cfg)) + runtime.block_on(serve_stdio_async(cfg, config_path)) } -async fn serve_stdio_async(cfg: Config) -> Result<()> { +async fn serve_stdio_async(cfg: Config, config_path: Option) -> Result<()> { tracing::info!("kebab-mcp: starting stdio server"); - let state = KebabAppState::new(cfg, None); // Plan Task 10 will thread the actual path + let state = KebabAppState::new(cfg, config_path); let handler = KebabHandler::new(state); let service = handler.serve(stdio()).await?; service.waiting().await?;