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?;