feat(kebab-cli): kebab mcp subcommand (fb-30)

Wires kebab_mcp::serve_stdio into kebab-cli. `--config <path>` honored
via the established Config::load pattern.

Updated serve_stdio signature to (Config, Option<PathBuf>) 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) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-05-07 16:10:17 +09:00
parent 61eef9bc82
commit 4a30959fdd
5 changed files with 101 additions and 4 deletions

1
Cargo.lock generated
View File

@@ -3592,6 +3592,7 @@ dependencies = [
"kebab-config", "kebab-config",
"kebab-core", "kebab-core",
"kebab-eval", "kebab-eval",
"kebab-mcp",
"kebab-tui", "kebab-tui",
"serde", "serde",
"serde_json", "serde_json",

View File

@@ -27,6 +27,8 @@ kebab-eval = { path = "../kebab-eval" }
# enforces the §8 boundary in its own Cargo.toml; kb-cli just # enforces the §8 boundary in its own Cargo.toml; kb-cli just
# launches it. # launches it.
kebab-tui = { path = "../kebab-tui" } 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 } anyhow = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }

View File

@@ -188,6 +188,11 @@ enum Cmd {
#[command(subcommand)] #[command(subcommand)]
what: EvalWhat, 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)] #[derive(Subcommand, Debug)]
@@ -739,6 +744,11 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
}, },
Cmd::Mcp => {
let cfg = kebab_config::Config::load(cli.config.as_deref())?;
kebab_mcp::serve_stdio(cfg, cli.config.clone())
}
} }
} }

View File

@@ -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();
}

View File

@@ -4,6 +4,8 @@
//! //!
//! See spec `docs/superpowers/specs/2026-05-07-p9-fb-30-mcp-server-design.md`. //! See spec `docs/superpowers/specs/2026-05-07-p9-fb-30-mcp-server-design.md`.
use std::path::PathBuf;
use anyhow::Result; use anyhow::Result;
use rmcp::ServerHandler; use rmcp::ServerHandler;
@@ -142,16 +144,21 @@ impl ServerHandler for KebabHandler {
/// Run the MCP server on stdio JSON-RPC. Blocks until the client closes /// Run the MCP server on stdio JSON-RPC. Blocks until the client closes
/// the stream (typically when the agent host exits). /// the stream (typically when the agent host exits).
pub fn serve_stdio(cfg: Config) -> Result<()> { ///
/// `config_path` is the path passed via `--config <path>`, 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<PathBuf>) -> Result<()> {
let runtime = tokio::runtime::Builder::new_multi_thread() let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all() .enable_all()
.build()?; .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<PathBuf>) -> Result<()> {
tracing::info!("kebab-mcp: starting stdio server"); 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 handler = KebabHandler::new(state);
let service = handler.serve(stdio()).await?; let service = handler.serve(stdio()).await?;
service.waiting().await?; service.waiting().await?;