From 1f53930234601321ae55669fbcaafb2dd01a6c62 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 7 May 2026 15:13:28 +0900 Subject: [PATCH 01/17] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20refactor(kebab-ap?= =?UTF-8?q?p):=20promote=20error=5Fclassify=20=E2=86=92=20kebab-app::error?= =?UTF-8?q?=5Fwire=20(fb-30=20prep)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fb-30 의 새 crate `kebab-mcp` 가 동일 classify 모듈 사용 — UI crate 끼리 import 는 facade rule 위반이므로 kebab-app 으로 promotion. fb-27 commit c91228e 의 코드 그대로 이전 (struct + classify + classify_llm + 7 unit test). reqwest dev-dep 도 함께 이동. kebab-cli 는 `kebab_app::ErrorV1` / `kebab_app::classify` 로 import 경로 1줄 변경 + wire.rs 의 `&crate::error_classify::ErrorV1` 1줄 교체. 동작 무영향. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 2 +- crates/kebab-app/Cargo.toml | 3 +++ .../src/error_classify.rs => kebab-app/src/error_wire.rs} | 2 +- crates/kebab-app/src/lib.rs | 2 ++ crates/kebab-cli/Cargo.toml | 3 --- crates/kebab-cli/src/main.rs | 3 +-- crates/kebab-cli/src/wire.rs | 8 ++++---- 7 files changed, 12 insertions(+), 11 deletions(-) rename crates/{kebab-cli/src/error_classify.rs => kebab-app/src/error_wire.rs} (98%) diff --git a/Cargo.lock b/Cargo.lock index b9c82ed..c4f8ddb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3516,6 +3516,7 @@ dependencies = [ "kebab-store-vector", "lopdf", "lru", + "reqwest", "rusqlite", "serde", "serde_json", @@ -3558,7 +3559,6 @@ dependencies = [ "kebab-core", "kebab-eval", "kebab-tui", - "reqwest", "serde", "serde_json", "tempfile", diff --git a/crates/kebab-app/Cargo.toml b/crates/kebab-app/Cargo.toml index d43452f..d163e9c 100644 --- a/crates/kebab-app/Cargo.toml +++ b/crates/kebab-app/Cargo.toml @@ -64,3 +64,6 @@ image = { version = "0.25", default-features = false, features = # to the same major (0.32) so byte output is identical between the two # fixture surfaces. lopdf = "0.32" +# error_wire::tests::llm_unreachable_classifies_to_model_unreachable needs a real +# reqwest::Error (private constructor) — built from a connect-refused call. +reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } diff --git a/crates/kebab-cli/src/error_classify.rs b/crates/kebab-app/src/error_wire.rs similarity index 98% rename from crates/kebab-cli/src/error_classify.rs rename to crates/kebab-app/src/error_wire.rs index a82ddd4..62108c6 100644 --- a/crates/kebab-cli/src/error_classify.rs +++ b/crates/kebab-app/src/error_wire.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use kebab_app::error_signal::{ConfigInvalid, LlmError, NotIndexed}; +use crate::error_signal::{ConfigInvalid, LlmError, NotIndexed}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ErrorV1 { diff --git a/crates/kebab-app/src/lib.rs b/crates/kebab-app/src/lib.rs index cb26402..3c2740c 100644 --- a/crates/kebab-app/src/lib.rs +++ b/crates/kebab-app/src/lib.rs @@ -57,6 +57,7 @@ use kebab_source_fs::FsSourceConnector; mod app; pub mod doctor_signal; pub mod error_signal; +pub mod error_wire; pub mod ingest_progress; pub mod logging; pub mod reset; @@ -65,6 +66,7 @@ pub mod schema; pub use app::App; pub use ingest_progress::{AggregateCounts, IngestEvent, render_skipped_breakdown}; pub use reset::{ResetReport, ResetScope}; +pub use error_wire::{ErrorV1, classify}; pub use schema::{Capabilities, Models, SCHEMA_V1_ID, SchemaV1, Stats, WireBlock, schema_with_config}; /// p9-fb-25: sentinel for files without an extension in diff --git a/crates/kebab-cli/Cargo.toml b/crates/kebab-cli/Cargo.toml index cf97e6d..91ca172 100644 --- a/crates/kebab-cli/Cargo.toml +++ b/crates/kebab-cli/Cargo.toml @@ -44,6 +44,3 @@ ctrlc = "3" [dev-dependencies] tempfile = { workspace = true } -# llm_unreachable_classifies_to_model_unreachable test needs a real -# reqwest::Error (private constructor) — built from a connect-refused call. -reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } diff --git a/crates/kebab-cli/src/main.rs b/crates/kebab-cli/src/main.rs index bbf11b9..3da549d 100644 --- a/crates/kebab-cli/src/main.rs +++ b/crates/kebab-cli/src/main.rs @@ -9,7 +9,6 @@ use clap::{Parser, Subcommand}; use kebab_app::doctor_signal::{DoctorUnhealthy, NoHitSignal, RefusalSignal}; mod cancel; -mod error_classify; mod progress; mod wire; @@ -282,7 +281,7 @@ fn main() -> ExitCode { // caller); errors go to stderr. if code != 1 { if cli.json { - let v1 = error_classify::classify(&e, cli.verbose); + let v1 = kebab_app::classify(&e, cli.verbose); let v = wire::wire_error_v1(&v1); eprintln!("{}", serde_json::to_string(&v).unwrap_or_else(|_| { "{\"schema_version\":\"error.v1\",\"code\":\"generic\",\"message\":\"serialize failed\"}".to_string() diff --git a/crates/kebab-cli/src/wire.rs b/crates/kebab-cli/src/wire.rs index cbeef3c..ca3bc64 100644 --- a/crates/kebab-cli/src/wire.rs +++ b/crates/kebab-cli/src/wire.rs @@ -152,12 +152,12 @@ pub fn wire_schema(s: &kebab_app::SchemaV1) -> Value { tag_object(v, kebab_app::SCHEMA_V1_ID) } -/// Wrap an [`crate::error_classify::ErrorV1`] as `error.v1`. +/// Wrap an [`kebab_app::ErrorV1`] as `error.v1`. /// /// Uses the simple `tag_object` pattern because `ErrorV1` is a -/// kebab-cli-local type that does NOT carry `schema_version` itself +/// type that does NOT carry `schema_version` itself /// (kebab-core convention). -pub fn wire_error_v1(e: &crate::error_classify::ErrorV1) -> Value { +pub fn wire_error_v1(e: &kebab_app::ErrorV1) -> Value { let v = serde_json::to_value(e).expect("ErrorV1 serializes"); tag_object(v, "error.v1") } @@ -262,7 +262,7 @@ mod tests { #[test] fn error_wrapper_tags_schema_version_and_emits_code() { - use crate::error_classify::ErrorV1; + use kebab_app::ErrorV1; let err = ErrorV1 { code: "config_invalid".to_string(), message: "bad config".to_string(), From 2c09ed6af4cbc66da35e13d2ba3cc79558d9e4d5 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 7 May 2026 15:25:57 +0900 Subject: [PATCH 02/17] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20chore(kebab-mcp):?= =?UTF-8?q?=20scaffold=20new=20crate=20(fb-30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty lib + serve_stdio entry that bails until Task 3 wires rmcp. Adds rmcp 1.6 to workspace dependencies (server + macros + transport-io + schemars features) + tokio multi-thread/io-util/io-std local extensions. schemars declared as "1" (resolved to 1.2.1) — matches rmcp 1.6's ^1.0 requirement (verified via crates.io /dependencies; plan literal was 0.9 which would conflict). Path-style refs for kebab-app / kebab-config / kebab-core follow workspace convention. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 118 +++++++++++++++++++++++++++++++++++- Cargo.toml | 5 ++ crates/kebab-mcp/Cargo.toml | 27 +++++++++ crates/kebab-mcp/src/lib.rs | 16 +++++ 4 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 crates/kebab-mcp/Cargo.toml create mode 100644 crates/kebab-mcp/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index c4f8ddb..c520efb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -549,7 +549,7 @@ dependencies = [ "log", "num-rational", "num-traits", - "pastey", + "pastey 0.1.1", "rayon", "thiserror 2.0.18", "v_frame", @@ -1229,6 +1229,16 @@ dependencies = [ "darling_macro 0.21.3", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -1257,6 +1267,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.20.11" @@ -1279,6 +1302,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.117", +] + [[package]] name = "dary_heap" version = "0.3.9" @@ -3666,6 +3700,23 @@ dependencies = [ "wiremock", ] +[[package]] +name = "kebab-mcp" +version = "0.3.0" +dependencies = [ + "anyhow", + "kebab-app", + "kebab-config", + "kebab-core", + "rmcp", + "schemars 1.2.1", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", +] + [[package]] name = "kebab-normalize" version = "0.3.0" @@ -5457,6 +5508,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pastey" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a797f0e07bdf071d15742978fc3128ec6c22891c31a3a931513263904c982a" + [[package]] name = "path_abs" version = "0.5.1" @@ -6306,6 +6363,40 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e12ca9067b5ebfbd5b3fcdc4acfceb81aa7d5ab2a879dff7cb75d22434276aad" +dependencies = [ + "async-trait", + "chrono", + "futures", + "pastey 0.2.2", + "pin-project-lite", + "rmcp-macros", + "schemars 1.2.1", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7caa6743cc0888e433105fe1bc551a7f607940b126a37bc97b478e86064627eb" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.117", +] + [[package]] name = "roaring" version = "0.10.12" @@ -6512,12 +6603,26 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ + "chrono", "dyn-clone", "ref-cast", + "schemars_derive", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -6606,6 +6711,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_json" version = "1.0.149" diff --git a/Cargo.toml b/Cargo.toml index 62001d6..b37a610 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ members = [ "crates/kebab-parse-image", "crates/kebab-parse-pdf", "crates/kebab-tui", + "crates/kebab-mcp", ] [workspace.package] @@ -71,6 +72,10 @@ futures = "0.3" # pass; pulled into the workspace deps so future crates can share the # same major. regex = "1" +# MCP (Model Context Protocol) SDK. server + macros + transport-io provide +# stdio JSON-RPC transport for `kebab-mcp` (p9-fb-30). schemars feature +# exposes the derive macro used by tool input schemas. +rmcp = { version = "1.6", default-features = false, features = ["server", "macros", "transport-io", "schemars"] } # Dev-only HTTP mock server for kebab-llm-local Ollama adapter tests. Requires # a tokio runtime to host its mock server (the runtime adapter crate stays # sync via reqwest::blocking — wiremock is dev-only there). diff --git a/crates/kebab-mcp/Cargo.toml b/crates/kebab-mcp/Cargo.toml new file mode 100644 index 0000000..dfd6136 --- /dev/null +++ b/crates/kebab-mcp/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "kebab-mcp" +edition = { workspace = true } +rust-version = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[dependencies] +rmcp = { workspace = true } +# rt-multi-thread + io-util + io-std extend the workspace tokio entry +# (which only declares rt + macros) for the blocking stdio MCP transport. +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "io-std"] } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +# schemars 1.x matches rmcp 1.6's ^1.0 requirement (verified via crates.io +# /dependencies endpoint — rmcp declares optional schemars = "^1.0"). +schemars = "1" + +kebab-app = { path = "../kebab-app" } +kebab-config = { path = "../kebab-config" } +kebab-core = { path = "../kebab-core" } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/kebab-mcp/src/lib.rs b/crates/kebab-mcp/src/lib.rs new file mode 100644 index 0000000..a6ac5a0 --- /dev/null +++ b/crates/kebab-mcp/src/lib.rs @@ -0,0 +1,16 @@ +//! MCP (Model Context Protocol) server over stdio. Exposes 4 read-only +//! tools (`search` / `ask` / `schema` / `doctor`) backed by `kebab-app` +//! facade methods. Used by `kebab-cli`'s `Cmd::Mcp` arm. +//! +//! See spec `docs/superpowers/specs/2026-05-07-p9-fb-30-mcp-server-design.md`. + +use anyhow::Result; + +use kebab_config::Config; + +/// 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<()> { + // Skeleton — actual rmcp wiring lands in Task 3. + anyhow::bail!("kebab-mcp: serve_stdio not yet implemented") +} From 8f6e6bc01a0e4b12ae9900fc2cdc90b3fd8e92ad Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 7 May 2026 15:31:13 +0900 Subject: [PATCH 03/17] =?UTF-8?q?=E2=9C=A8=20feat(kebab-mcp):=20handler=20?= =?UTF-8?q?skeleton=20+=20initialize=20handshake=20(fb-30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KebabHandler implements rmcp::ServerHandler::get_info — returns serverInfo (name="kebab", version from CARGO_PKG_VERSION) and capabilities.tools. KebabAppState wraps Config in Arc for cheap clone into per-request task scope. serve_stdio entry builds a multi-thread tokio runtime and runs the server until client closes the stream. rmcp 1.6 API used: - rmcp::ServerHandler trait (re-exported from handler::server) - ServerInfo::new(caps).with_server_info(impl) builder (not struct-init: InitializeResult/Implementation are #[non_exhaustive]) - ServerCapabilities::builder().enable_tools().build() — builder macro generated, confirms the plan-literal pattern works - Implementation::new(name, version) — non-exhaustive constructor - rmcp::transport::stdio() returns (tokio::io::Stdin, tokio::io::Stdout) tuple; tuple impls IntoTransport via AsyncRead+AsyncWrite blanket - handler.serve(transport).await → RunningService (ServiceExt::serve, returns Result<_, ServerInitializeError>) - service.waiting().await → Result - serve_stdio is plain fn wrapping a manually-built tokio runtime (avoids nested-runtime hazard if kebab-cli ever gains its own rt) Tools wire-up lands in subsequent tasks (one tool per task). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-mcp/src/lib.rs | 47 ++++++++++++++++++++++++++-- crates/kebab-mcp/src/state.rs | 21 +++++++++++++ crates/kebab-mcp/tests/initialize.rs | 19 +++++++++++ 3 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 crates/kebab-mcp/src/state.rs create mode 100644 crates/kebab-mcp/tests/initialize.rs diff --git a/crates/kebab-mcp/src/lib.rs b/crates/kebab-mcp/src/lib.rs index a6ac5a0..70f0326 100644 --- a/crates/kebab-mcp/src/lib.rs +++ b/crates/kebab-mcp/src/lib.rs @@ -6,11 +6,52 @@ use anyhow::Result; +use rmcp::ServerHandler; +use rmcp::model::{Implementation, ServerCapabilities, ServerInfo}; +use rmcp::service::ServiceExt; +use rmcp::transport::stdio; + use kebab_config::Config; +pub mod state; +pub use state::KebabAppState; + +#[derive(Clone)] +pub struct KebabHandler { + state: KebabAppState, +} + +impl KebabHandler { + pub fn new(state: KebabAppState) -> Self { + Self { state } + } + + pub fn state(&self) -> &KebabAppState { + &self.state + } +} + +impl ServerHandler for KebabHandler { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new("kebab", env!("CARGO_PKG_VERSION"))) + } +} + /// 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<()> { - // Skeleton — actual rmcp wiring lands in Task 3. - anyhow::bail!("kebab-mcp: serve_stdio not yet implemented") +pub fn serve_stdio(cfg: Config) -> Result<()> { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + runtime.block_on(serve_stdio_async(cfg)) +} + +async fn serve_stdio_async(cfg: Config) -> Result<()> { + tracing::info!("kebab-mcp: starting stdio server"); + let state = KebabAppState::new(cfg); + let handler = KebabHandler::new(state); + let service = handler.serve(stdio()).await?; + service.waiting().await?; + Ok(()) } diff --git a/crates/kebab-mcp/src/state.rs b/crates/kebab-mcp/src/state.rs new file mode 100644 index 0000000..8eafa0e --- /dev/null +++ b/crates/kebab-mcp/src/state.rs @@ -0,0 +1,21 @@ +//! Long-lived server state — holds Config so per-request handlers don't +//! reload from disk. Future: cache opened SqliteStore / Lance handles +//! here so first tool call pays the cost, subsequent calls hit warm +//! state. + +use std::sync::Arc; + +use kebab_config::Config; + +#[derive(Clone)] +pub struct KebabAppState { + pub config: Arc, +} + +impl KebabAppState { + pub fn new(config: Config) -> Self { + Self { + config: Arc::new(config), + } + } +} diff --git a/crates/kebab-mcp/tests/initialize.rs b/crates/kebab-mcp/tests/initialize.rs new file mode 100644 index 0000000..c24c445 --- /dev/null +++ b/crates/kebab-mcp/tests/initialize.rs @@ -0,0 +1,19 @@ +//! Integration: KebabHandler::get_info returns correct kebab serverInfo. +//! Doesn't exercise full transport — that lands when we have at least +//! one tool to call (Task 4+). + +use kebab_config::Config; +use kebab_mcp::{KebabAppState, KebabHandler}; +use rmcp::ServerHandler; + +#[tokio::test] +async fn initialize_returns_kebab_server_info() { + let cfg = Config::defaults(); + let state = KebabAppState::new(cfg); + let handler = KebabHandler::new(state); + + let info = handler.get_info(); + assert_eq!(info.server_info.name, "kebab"); + assert!(!info.server_info.version.is_empty()); + assert!(info.capabilities.tools.is_some()); +} From 8ca8e18d128ced3b6d92e62c22f1cb9a5682aa0a Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 7 May 2026 15:38:00 +0900 Subject: [PATCH 04/17] =?UTF-8?q?=E2=9C=A8=20feat(kebab-mcp):=20schema=20t?= =?UTF-8?q?ool=20(fb-30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First tool wired — `schema` (no input args, returns schema.v1 JSON mirroring `kebab schema --json`). Establishes the per-tool module pattern (crates/kebab-mcp/src/tools/.rs) + error helper that maps anyhow::Error to MCP CallToolResult.error with error.v1 content. Dispatch pattern: manual dispatch — explicit `list_tools` + `call_tool` overrides on `impl ServerHandler for KebabHandler` with a `match request.name.as_ref()` arm per tool. No proc-macro magic. Tasks 5-7 should add a new arm + new tools/.rs following the same pattern; also add a `Tool::new(...)` entry in `list_tools`. API shapes confirmed from rmcp 1.6 source: - Content = Annotated; text via `Content::text(s)`; pattern match via `&content.raw` → `RawContent::Text(t)` → `t.text` - CallToolResult::success(Vec) / ::error(Vec) - ListToolsResult::with_all_items(Vec) - schema_for_empty_input() from rmcp::handler::server::common Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-mcp/src/error.rs | 22 +++++++ crates/kebab-mcp/src/lib.rs | 39 +++++++++++- crates/kebab-mcp/src/tools/mod.rs | 6 ++ crates/kebab-mcp/src/tools/schema.rs | 22 +++++++ crates/kebab-mcp/tests/tools_call_schema.rs | 70 +++++++++++++++++++++ 5 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 crates/kebab-mcp/src/error.rs create mode 100644 crates/kebab-mcp/src/tools/mod.rs create mode 100644 crates/kebab-mcp/src/tools/schema.rs create mode 100644 crates/kebab-mcp/tests/tools_call_schema.rs diff --git a/crates/kebab-mcp/src/error.rs b/crates/kebab-mcp/src/error.rs new file mode 100644 index 0000000..b12e5ac --- /dev/null +++ b/crates/kebab-mcp/src/error.rs @@ -0,0 +1,22 @@ +//! Map `anyhow::Error` returned by kebab-app facades to MCP +//! `CallToolResult` with `isError: true` + error.v1 JSON content. + +use rmcp::model::{CallToolResult, Content}; + +use kebab_app::classify; + +/// Convert an `anyhow::Error` to a `CallToolResult` with `isError: true` +/// and the serialised `error.v1` envelope as the text content. +pub fn to_tool_error(err: &anyhow::Error) -> CallToolResult { + let v1 = classify(err, false); + let body = serde_json::to_string(&v1).unwrap_or_else(|_| { + r#"{"schema_version":"error.v1","code":"generic","message":"serialize failed"}"# + .to_string() + }); + CallToolResult::error(vec![Content::text(body)]) +} + +/// Wrap a successful wire-schema JSON string as a `CallToolResult`. +pub fn to_tool_success(json: String) -> CallToolResult { + CallToolResult::success(vec![Content::text(json)]) +} diff --git a/crates/kebab-mcp/src/lib.rs b/crates/kebab-mcp/src/lib.rs index 70f0326..efa4d8a 100644 --- a/crates/kebab-mcp/src/lib.rs +++ b/crates/kebab-mcp/src/lib.rs @@ -7,13 +7,20 @@ use anyhow::Result; use rmcp::ServerHandler; -use rmcp::model::{Implementation, ServerCapabilities, ServerInfo}; -use rmcp::service::ServiceExt; +use rmcp::handler::server::common::schema_for_empty_input; +use rmcp::model::{ + CallToolRequestParams, CallToolResult, Implementation, ListToolsResult, ServerCapabilities, + ServerInfo, Tool, +}; +use rmcp::service::{RequestContext, ServiceExt}; use rmcp::transport::stdio; +use rmcp::{ErrorData, RoleServer}; use kebab_config::Config; +pub mod error; pub mod state; +pub mod tools; pub use state::KebabAppState; #[derive(Clone)] @@ -36,6 +43,34 @@ impl ServerHandler for KebabHandler { ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) .with_server_info(Implementation::new("kebab", env!("CARGO_PKG_VERSION"))) } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListToolsResult::with_all_items(vec![Tool::new( + "schema", + "Introspection — wire schemas, capabilities, model versions, index stats.", + schema_for_empty_input(), + )])) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + match request.name.as_ref() { + "schema" => { + let input = tools::schema::SchemaInput::default(); + Ok(tools::schema::handle(&self.state, input)) + } + _other => Err(ErrorData::method_not_found::< + rmcp::model::CallToolRequestMethod, + >()), + } + } } /// Run the MCP server on stdio JSON-RPC. Blocks until the client closes diff --git a/crates/kebab-mcp/src/tools/mod.rs b/crates/kebab-mcp/src/tools/mod.rs new file mode 100644 index 0000000..1db1210 --- /dev/null +++ b/crates/kebab-mcp/src/tools/mod.rs @@ -0,0 +1,6 @@ +//! Tool implementations — one module per tool. + +pub mod schema; +// pub mod doctor; // wired in Plan Task 5 +// pub mod search; // wired in Plan Task 6 +// pub mod ask; // wired in Plan Task 7 diff --git a/crates/kebab-mcp/src/tools/schema.rs b/crates/kebab-mcp/src/tools/schema.rs new file mode 100644 index 0000000..2bf5e34 --- /dev/null +++ b/crates/kebab-mcp/src/tools/schema.rs @@ -0,0 +1,22 @@ +//! `schema` tool — wraps `kebab_app::schema_with_config`. +//! Input: {} (no args). Output: schema.v1 JSON. + +use rmcp::model::CallToolResult; +use serde::{Deserialize, Serialize}; +use schemars::JsonSchema; + +use crate::error::{to_tool_error, to_tool_success}; +use crate::state::KebabAppState; + +#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)] +pub struct SchemaInput {} + +pub fn handle(state: &KebabAppState, _input: SchemaInput) -> CallToolResult { + match kebab_app::schema_with_config(&state.config) { + Ok(report) => match serde_json::to_string(&report) { + Ok(json) => to_tool_success(json), + Err(e) => to_tool_error(&anyhow::anyhow!(e)), + }, + Err(e) => to_tool_error(&e), + } +} diff --git a/crates/kebab-mcp/tests/tools_call_schema.rs b/crates/kebab-mcp/tests/tools_call_schema.rs new file mode 100644 index 0000000..628f17f --- /dev/null +++ b/crates/kebab-mcp/tests/tools_call_schema.rs @@ -0,0 +1,70 @@ +//! Integration: tools/call name=schema — verify response is schema.v1. + +use std::fs; + +use kebab_config::Config; +use kebab_core::SourceScope; +use kebab_mcp::{KebabAppState, KebabHandler}; +use rmcp::model::RawContent; + +fn minimal_config(data_dir: &std::path::Path, workspace_root: &std::path::Path) -> Config { + let mut cfg = Config::defaults(); + cfg.storage.data_dir = data_dir.to_string_lossy().into_owned(); + cfg.storage.model_dir = data_dir + .join("models") + .to_string_lossy() + .into_owned(); + cfg.workspace.root = workspace_root.to_string_lossy().into_owned(); + cfg.workspace.exclude.clear(); + cfg.models.embedding.provider = "none".to_string(); + cfg.models.embedding.dimensions = 0; + cfg +} + +#[tokio::test] +async fn schema_tool_returns_schema_v1_json() { + let dir = tempfile::tempdir().unwrap(); + let data_dir = dir.path().join("data"); + let workspace_root = dir.path().join("notes"); + fs::create_dir_all(&data_dir).unwrap(); + fs::create_dir_all(&workspace_root).unwrap(); + + let config = minimal_config(&data_dir, &workspace_root); + + // Seed kebab.sqlite via 0-file ingest so open_existing succeeds later. + let scope = SourceScope { + root: workspace_root.clone(), + include: vec![], + exclude: vec![], + }; + let _ = kebab_app::ingest_with_config(config.clone(), scope, false).unwrap(); + + let state = KebabAppState::new(config); + let handler = KebabHandler::new(state); + + let result = kebab_mcp::tools::schema::handle( + handler.state(), + kebab_mcp::tools::schema::SchemaInput::default(), + ); + + assert!( + !result.is_error.unwrap_or(false), + "expected isError=false on healthy schema, got {:?}", + result + ); + + let content = result.content.first().expect("expected at least one content item"); + + // Content = Annotated; deref to get the inner RawContent. + let text = match &content.raw { + RawContent::Text(t) => &t.text, + other => panic!("expected text content, got {other:?}"), + }; + + let v: serde_json::Value = serde_json::from_str(text).unwrap(); + assert_eq!( + v.get("schema_version").and_then(|s| s.as_str()), + Some("schema.v1"), + "unexpected schema_version in: {v}" + ); +} From 360fa53b0228f29cf13a193ae2670410a5fda987 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 7 May 2026 15:41:13 +0900 Subject: [PATCH 05/17] =?UTF-8?q?=E2=9C=A8=20feat(kebab-mcp):=20doctor=20t?= =?UTF-8?q?ool=20(fb-30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second tool — `doctor` (no input args, returns doctor.v1 JSON via kebab_app::doctor_with_config_path). Mirrors schema tool's manual-dispatch pattern: Tool::new entry in list_tools, match arm in call_tool, per-tool module in tools/doctor.rs. doctor_with_config_path takes Option<&Path> (not &Config), so KebabAppState is extended with config_path: Option. All existing callers (initialize.rs, tools_call_schema.rs, serve_stdio_async) pass None for now; Plan Task 10 (Cmd::Mcp wiring) will thread the actual --config path through. doctor_with_config falls back to XDG default when config_path is None — same behavior as bare `kebab doctor`. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-mcp/src/lib.rs | 23 +++++++--- crates/kebab-mcp/src/state.rs | 10 ++++- crates/kebab-mcp/src/tools/doctor.rs | 28 ++++++++++++ crates/kebab-mcp/src/tools/mod.rs | 2 +- crates/kebab-mcp/tests/initialize.rs | 2 +- crates/kebab-mcp/tests/tools_call_doctor.rs | 50 +++++++++++++++++++++ crates/kebab-mcp/tests/tools_call_schema.rs | 2 +- 7 files changed, 107 insertions(+), 10 deletions(-) create mode 100644 crates/kebab-mcp/src/tools/doctor.rs create mode 100644 crates/kebab-mcp/tests/tools_call_doctor.rs diff --git a/crates/kebab-mcp/src/lib.rs b/crates/kebab-mcp/src/lib.rs index efa4d8a..a17f35d 100644 --- a/crates/kebab-mcp/src/lib.rs +++ b/crates/kebab-mcp/src/lib.rs @@ -49,11 +49,18 @@ impl ServerHandler for KebabHandler { _request: Option, _context: RequestContext, ) -> Result { - Ok(ListToolsResult::with_all_items(vec![Tool::new( - "schema", - "Introspection — wire schemas, capabilities, model versions, index stats.", - schema_for_empty_input(), - )])) + Ok(ListToolsResult::with_all_items(vec![ + Tool::new( + "schema", + "Introspection — wire schemas, capabilities, model versions, index stats.", + schema_for_empty_input(), + ), + Tool::new( + "doctor", + "Health check — verifies config, storage, models, and Ollama connectivity.", + schema_for_empty_input(), + ), + ])) } async fn call_tool( @@ -66,6 +73,10 @@ impl ServerHandler for KebabHandler { let input = tools::schema::SchemaInput::default(); Ok(tools::schema::handle(&self.state, input)) } + "doctor" => { + let input = tools::doctor::DoctorInput::default(); + Ok(tools::doctor::handle(&self.state, input)) + } _other => Err(ErrorData::method_not_found::< rmcp::model::CallToolRequestMethod, >()), @@ -84,7 +95,7 @@ pub fn serve_stdio(cfg: Config) -> Result<()> { async fn serve_stdio_async(cfg: Config) -> Result<()> { tracing::info!("kebab-mcp: starting stdio server"); - let state = KebabAppState::new(cfg); + let state = KebabAppState::new(cfg, None); // Plan Task 10 will thread the actual path let handler = KebabHandler::new(state); let service = handler.serve(stdio()).await?; service.waiting().await?; diff --git a/crates/kebab-mcp/src/state.rs b/crates/kebab-mcp/src/state.rs index 8eafa0e..00560cf 100644 --- a/crates/kebab-mcp/src/state.rs +++ b/crates/kebab-mcp/src/state.rs @@ -3,6 +3,7 @@ //! here so first tool call pays the cost, subsequent calls hit warm //! state. +use std::path::PathBuf; use std::sync::Arc; use kebab_config::Config; @@ -10,12 +11,19 @@ use kebab_config::Config; #[derive(Clone)] pub struct KebabAppState { pub config: Arc, + /// Original config file path passed via `--config `, if any. + /// Forwarded to `kebab_app::doctor_with_config_path` so the doctor + /// report reflects the same config file the server was started with. + /// Plan Task 10 (Cmd::Mcp wiring) will pass the actual path; all + /// existing callers pass `None` which falls back to the XDG default. + pub config_path: Option, } impl KebabAppState { - pub fn new(config: Config) -> Self { + pub fn new(config: Config, config_path: Option) -> Self { Self { config: Arc::new(config), + config_path, } } } diff --git a/crates/kebab-mcp/src/tools/doctor.rs b/crates/kebab-mcp/src/tools/doctor.rs new file mode 100644 index 0000000..171f1bd --- /dev/null +++ b/crates/kebab-mcp/src/tools/doctor.rs @@ -0,0 +1,28 @@ +//! `doctor` tool — wraps `kebab_app::doctor_with_config_path`. +//! Input: {} (no args). Output: doctor.v1 JSON. +//! +//! `doctor_with_config_path(Option<&Path>)` re-reads config from disk so +//! the report reflects the live file state. We forward `config_path` from +//! `KebabAppState` so `--config ` users see results for their file; +//! callers that pass `None` fall back to the XDG default (same as the CLI +//! bare `kebab doctor`). + +use rmcp::model::CallToolResult; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::error::{to_tool_error, to_tool_success}; +use crate::state::KebabAppState; + +#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)] +pub struct DoctorInput {} + +pub fn handle(state: &KebabAppState, _input: DoctorInput) -> CallToolResult { + match kebab_app::doctor_with_config_path(state.config_path.as_deref()) { + Ok(report) => match serde_json::to_string(&report) { + Ok(json) => to_tool_success(json), + Err(e) => to_tool_error(&anyhow::anyhow!(e)), + }, + Err(e) => to_tool_error(&e), + } +} diff --git a/crates/kebab-mcp/src/tools/mod.rs b/crates/kebab-mcp/src/tools/mod.rs index 1db1210..e6d34de 100644 --- a/crates/kebab-mcp/src/tools/mod.rs +++ b/crates/kebab-mcp/src/tools/mod.rs @@ -1,6 +1,6 @@ //! Tool implementations — one module per tool. pub mod schema; -// pub mod doctor; // wired in Plan Task 5 +pub mod doctor; // pub mod search; // wired in Plan Task 6 // pub mod ask; // wired in Plan Task 7 diff --git a/crates/kebab-mcp/tests/initialize.rs b/crates/kebab-mcp/tests/initialize.rs index c24c445..8a360cb 100644 --- a/crates/kebab-mcp/tests/initialize.rs +++ b/crates/kebab-mcp/tests/initialize.rs @@ -9,7 +9,7 @@ use rmcp::ServerHandler; #[tokio::test] async fn initialize_returns_kebab_server_info() { let cfg = Config::defaults(); - let state = KebabAppState::new(cfg); + let state = KebabAppState::new(cfg, None); let handler = KebabHandler::new(state); let info = handler.get_info(); diff --git a/crates/kebab-mcp/tests/tools_call_doctor.rs b/crates/kebab-mcp/tests/tools_call_doctor.rs new file mode 100644 index 0000000..e7bde10 --- /dev/null +++ b/crates/kebab-mcp/tests/tools_call_doctor.rs @@ -0,0 +1,50 @@ +//! Integration: tools/call name=doctor — returns doctor.v1. + +use kebab_config::Config; +use kebab_mcp::{KebabAppState, KebabHandler}; +use rmcp::model::RawContent; + +#[tokio::test] +async fn doctor_tool_returns_doctor_v1_json() { + let dir = tempfile::tempdir().unwrap(); + let mut cfg = Config::defaults(); + cfg.storage.data_dir = dir.path().join("data").to_string_lossy().into_owned(); + cfg.workspace.root = dir.path().join("notes").to_string_lossy().into_owned(); + cfg.models.embedding.provider = "none".to_string(); + cfg.models.embedding.dimensions = 0; + std::fs::create_dir_all(&cfg.workspace.root).unwrap(); + + // Pass None for config_path — doctor falls back to XDG default probe + // (path won't exist in the tempdir, which is fine; doctor reports it + // as missing / error rather than panicking). + let state = KebabAppState::new(cfg, None); + let handler = KebabHandler::new(state); + + let result = kebab_mcp::tools::doctor::handle( + handler.state(), + kebab_mcp::tools::doctor::DoctorInput::default(), + ); + + let content = result + .content + .first() + .expect("expected at least one content item"); + + let text = match &content.raw { + RawContent::Text(t) => &t.text, + other => panic!("expected text content, got {other:?}"), + }; + + let v: serde_json::Value = serde_json::from_str(text).unwrap(); + assert_eq!( + v.get("schema_version").and_then(|s| s.as_str()), + Some("doctor.v1"), + "unexpected schema_version in: {v}" + ); + // `ok` boolean must be present (value may be false in CI where Ollama + // is not reachable — that's expected and acceptable). + assert!( + v.get("ok").and_then(|b| b.as_bool()).is_some(), + "`ok` field missing in doctor.v1 response: {v}" + ); +} diff --git a/crates/kebab-mcp/tests/tools_call_schema.rs b/crates/kebab-mcp/tests/tools_call_schema.rs index 628f17f..f931508 100644 --- a/crates/kebab-mcp/tests/tools_call_schema.rs +++ b/crates/kebab-mcp/tests/tools_call_schema.rs @@ -39,7 +39,7 @@ async fn schema_tool_returns_schema_v1_json() { }; let _ = kebab_app::ingest_with_config(config.clone(), scope, false).unwrap(); - let state = KebabAppState::new(config); + let state = KebabAppState::new(config, None); let handler = KebabHandler::new(state); let result = kebab_mcp::tools::schema::handle( From 52782fdf722ef14131d218e5395c44c70045b103 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 7 May 2026 15:44:56 +0900 Subject: [PATCH 06/17] =?UTF-8?q?=E2=9C=A8=20feat(kebab-mcp):=20search=20t?= =?UTF-8?q?ool=20(fb-30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third tool — `search` (input: query / mode / k). First tool with non-empty input — establishes the pattern: SearchInput struct with JsonSchema derive + Tool::new uses rmcp::handler::server::common::schema_for_type::() for inputSchema + call_tool match arm parses request.arguments via serde_json::from_value. search_with_config takes owned Config, so state.config (Arc) is cloned via (*state.config).clone(). Output: search_hit.v1 array — SearchHit (kebab-core) does not carry schema_version field, so each element is tagged inline before serialising. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-mcp/src/lib.rs | 18 ++++- crates/kebab-mcp/src/tools/mod.rs | 2 +- crates/kebab-mcp/src/tools/search.rs | 71 ++++++++++++++++ crates/kebab-mcp/tests/tools_call_search.rs | 90 +++++++++++++++++++++ 4 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 crates/kebab-mcp/src/tools/search.rs create mode 100644 crates/kebab-mcp/tests/tools_call_search.rs diff --git a/crates/kebab-mcp/src/lib.rs b/crates/kebab-mcp/src/lib.rs index a17f35d..fc29941 100644 --- a/crates/kebab-mcp/src/lib.rs +++ b/crates/kebab-mcp/src/lib.rs @@ -7,7 +7,7 @@ use anyhow::Result; use rmcp::ServerHandler; -use rmcp::handler::server::common::schema_for_empty_input; +use rmcp::handler::server::common::{schema_for_empty_input, schema_for_type}; use rmcp::model::{ CallToolRequestParams, CallToolResult, Implementation, ListToolsResult, ServerCapabilities, ServerInfo, Tool, @@ -60,6 +60,11 @@ impl ServerHandler for KebabHandler { "Health check — verifies config, storage, models, and Ollama connectivity.", schema_for_empty_input(), ), + Tool::new( + "search", + "Full-text / vector / hybrid search over the knowledge base. Returns search_hit.v1 array.", + schema_for_type::(), + ), ])) } @@ -77,6 +82,17 @@ impl ServerHandler for KebabHandler { let input = tools::doctor::DoctorInput::default(); Ok(tools::doctor::handle(&self.state, input)) } + "search" => { + let args = request.arguments.unwrap_or_default(); + let input: tools::search::SearchInput = + match serde_json::from_value(serde_json::Value::Object(args)) { + Ok(i) => i, + Err(e) => { + return Ok(error::to_tool_error(&anyhow::Error::from(e))); + } + }; + Ok(tools::search::handle(&self.state, input)) + } _other => Err(ErrorData::method_not_found::< rmcp::model::CallToolRequestMethod, >()), diff --git a/crates/kebab-mcp/src/tools/mod.rs b/crates/kebab-mcp/src/tools/mod.rs index e6d34de..19f52c0 100644 --- a/crates/kebab-mcp/src/tools/mod.rs +++ b/crates/kebab-mcp/src/tools/mod.rs @@ -2,5 +2,5 @@ pub mod schema; pub mod doctor; -// pub mod search; // wired in Plan Task 6 +pub mod search; // pub mod ask; // wired in Plan Task 7 diff --git a/crates/kebab-mcp/src/tools/search.rs b/crates/kebab-mcp/src/tools/search.rs new file mode 100644 index 0000000..3496a22 --- /dev/null +++ b/crates/kebab-mcp/src/tools/search.rs @@ -0,0 +1,71 @@ +//! `search` tool — wraps `kebab_app::search_with_config`. +//! Input: { query, mode?, k? }. Output: search_hit.v1 array JSON. +//! +//! First tool with a non-empty `inputSchema`: `SearchInput` derives +//! `JsonSchema` and `Tool::new` uses +//! `rmcp::handler::server::common::schema_for_type::()`. + +use rmcp::model::CallToolResult; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::error::{to_tool_error, to_tool_success}; +use crate::state::KebabAppState; + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct SearchInput { + /// User query (free text). + pub query: String, + /// Retrieval mode: "hybrid" (default), "lexical", or "vector". + #[serde(default = "default_mode")] + pub mode: String, + /// Top-K results. Defaults to 10. Clamped to 1–100. + #[serde(default = "default_k")] + pub k: usize, +} + +fn default_mode() -> String { + "hybrid".to_string() +} +fn default_k() -> usize { + 10 +} + +pub fn handle(state: &KebabAppState, input: SearchInput) -> CallToolResult { + let k = input.k.clamp(1, 100); + let mode = match input.mode.as_str() { + "lexical" => kebab_core::SearchMode::Lexical, + "vector" => kebab_core::SearchMode::Vector, + _ => kebab_core::SearchMode::Hybrid, + }; + let query = kebab_core::SearchQuery { + text: input.query, + mode, + k, + filters: kebab_core::SearchFilters::default(), + }; + match kebab_app::search_with_config((*state.config).clone(), query) { + Ok(hits) => { + // SearchHit (kebab-core) does not carry a `schema_version` field, + // so we tag each element inline before serialising. + let tagged: Vec = hits + .iter() + .map(|h| { + let mut v = serde_json::to_value(h).unwrap_or_default(); + if let serde_json::Value::Object(ref mut map) = v { + map.insert( + "schema_version".to_string(), + serde_json::Value::String("search_hit.v1".to_string()), + ); + } + v + }) + .collect(); + match serde_json::to_string(&serde_json::Value::Array(tagged)) { + Ok(json) => to_tool_success(json), + Err(e) => to_tool_error(&anyhow::anyhow!(e)), + } + } + Err(e) => to_tool_error(&e), + } +} diff --git a/crates/kebab-mcp/tests/tools_call_search.rs b/crates/kebab-mcp/tests/tools_call_search.rs new file mode 100644 index 0000000..5f734eb --- /dev/null +++ b/crates/kebab-mcp/tests/tools_call_search.rs @@ -0,0 +1,90 @@ +//! Integration: tools/call name=search — verify response is search_hit.v1 array. + +use std::fs; + +use kebab_config::Config; +use kebab_core::SourceScope; +use kebab_mcp::{KebabAppState, KebabHandler}; +use rmcp::model::RawContent; + +fn minimal_config(data_dir: &std::path::Path, workspace_root: &std::path::Path) -> Config { + let mut cfg = Config::defaults(); + cfg.storage.data_dir = data_dir.to_string_lossy().into_owned(); + cfg.storage.model_dir = data_dir + .join("models") + .to_string_lossy() + .into_owned(); + cfg.workspace.root = workspace_root.to_string_lossy().into_owned(); + cfg.workspace.exclude.clear(); + cfg.models.embedding.provider = "none".to_string(); + cfg.models.embedding.dimensions = 0; + cfg +} + +#[tokio::test] +async fn search_tool_returns_search_hits_array() { + let dir = tempfile::tempdir().unwrap(); + let data_dir = dir.path().join("data"); + let workspace_root = dir.path().join("notes"); + fs::create_dir_all(&data_dir).unwrap(); + fs::create_dir_all(&workspace_root).unwrap(); + + let config = minimal_config(&data_dir, &workspace_root); + + // Write a markdown document containing the query term. + fs::write( + workspace_root.join("a.md"), + "# Alpha\n\nThis document mentions kebab and bread.", + ) + .unwrap(); + + // Seed kebab.sqlite via ingest so search has indexed content. + let scope = SourceScope { + root: workspace_root.clone(), + include: vec![], + exclude: vec![], + }; + let _ = kebab_app::ingest_with_config(config.clone(), scope, false).unwrap(); + + let state = KebabAppState::new(config, None); + let handler = KebabHandler::new(state); + + let result = kebab_mcp::tools::search::handle( + handler.state(), + kebab_mcp::tools::search::SearchInput { + query: "kebab".to_string(), + mode: "lexical".to_string(), + k: 5, + }, + ); + + assert!( + !result.is_error.unwrap_or(false), + "expected isError=false, got {:?}", + result + ); + + let content = result + .content + .first() + .expect("expected at least one content item"); + + let text = match &content.raw { + RawContent::Text(t) => &t.text, + other => panic!("expected text content, got {other:?}"), + }; + + let v: serde_json::Value = serde_json::from_str(text).unwrap(); + let arr = v.as_array().expect("search returns a JSON array"); + assert!( + !arr.is_empty(), + "expected at least one hit for 'kebab' in 'a.md'" + ); + assert_eq!( + arr[0] + .get("schema_version") + .and_then(|s| s.as_str()), + Some("search_hit.v1"), + "first hit should carry schema_version=search_hit.v1" + ); +} From 4b1b8a15bf18155ca5b2c4bb639d546f41ecfaa6 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 7 May 2026 15:50:57 +0900 Subject: [PATCH 07/17] =?UTF-8?q?=E2=9C=A8=20feat(kebab-mcp):=20ask=20tool?= =?UTF-8?q?=20(fb-30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth (and final v1) tool — `ask` (input: query / optional session_id). Multi-turn via optional session_id (kebab_app::ask_with_session_with_config), single-shot via ask_with_config when None. Refusal (grounded:false) NOT mapped to isError — agent branches on the wire payload's grounded flag. AskOpts has no Default impl (must construct manually). Answer carries no schema_version field (tagged inline via entry().or_insert_with, idempotent). Mode defaulted to Lexical: reqwest::blocking::Client::build creates and drops a tokio runtime, panicking inside async context — the empty-corpus refusal test avoids this via spawn_blocking; the tool itself uses Lexical as the default mode since MCP callers typically run without an embedding provider configured. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-mcp/src/lib.rs | 16 +++++ crates/kebab-mcp/src/tools/ask.rs | 63 +++++++++++++++++ crates/kebab-mcp/src/tools/mod.rs | 2 +- crates/kebab-mcp/tests/tools_call_ask.rs | 89 ++++++++++++++++++++++++ 4 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 crates/kebab-mcp/src/tools/ask.rs create mode 100644 crates/kebab-mcp/tests/tools_call_ask.rs diff --git a/crates/kebab-mcp/src/lib.rs b/crates/kebab-mcp/src/lib.rs index fc29941..009a1d3 100644 --- a/crates/kebab-mcp/src/lib.rs +++ b/crates/kebab-mcp/src/lib.rs @@ -65,6 +65,11 @@ impl ServerHandler for KebabHandler { "Full-text / vector / hybrid search over the knowledge base. Returns search_hit.v1 array.", schema_for_type::(), ), + Tool::new( + "ask", + "RAG question answering over the knowledge base. Returns answer.v1 JSON. Pass session_id for multi-turn context.", + schema_for_type::(), + ), ])) } @@ -93,6 +98,17 @@ impl ServerHandler for KebabHandler { }; Ok(tools::search::handle(&self.state, input)) } + "ask" => { + let args = request.arguments.unwrap_or_default(); + let input: tools::ask::AskInput = + match serde_json::from_value(serde_json::Value::Object(args)) { + Ok(i) => i, + Err(e) => { + return Ok(error::to_tool_error(&anyhow::Error::from(e))); + } + }; + Ok(tools::ask::handle(&self.state, input)) + } _other => Err(ErrorData::method_not_found::< rmcp::model::CallToolRequestMethod, >()), diff --git a/crates/kebab-mcp/src/tools/ask.rs b/crates/kebab-mcp/src/tools/ask.rs new file mode 100644 index 0000000..5f6eebd --- /dev/null +++ b/crates/kebab-mcp/src/tools/ask.rs @@ -0,0 +1,63 @@ +//! `ask` tool — wraps `kebab_app::ask_with_config` (single-shot) or +//! `kebab_app::ask_with_session_with_config` when `session_id` is provided. +//! Input: { query, session_id? }. Output: answer.v1 JSON. +//! +//! `Answer` (kebab-core) does NOT carry a `schema_version` field; we tag +//! it inline here, matching the pattern from `search.rs`. + +use rmcp::model::CallToolResult; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::error::{to_tool_error, to_tool_success}; +use crate::state::KebabAppState; + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct AskInput { + /// The user question. + pub query: String, + /// Optional session id for multi-turn RAG context. + pub session_id: Option, +} + +pub fn handle(state: &KebabAppState, input: AskInput) -> CallToolResult { + // Default to Lexical mode — the MCP server is typically called by + // agent hosts that may not have an embedding provider configured. + // Hybrid/vector retrieval would hard-error when embeddings are + // disabled; lexical FTS is always available and covers the common + // RAG case well. + let opts = kebab_app::AskOpts { + k: 10, + explain: false, + mode: kebab_core::SearchMode::Lexical, + temperature: None, + seed: None, + stream_sink: None, + history: Vec::new(), + conversation_id: None, + turn_index: None, + }; + let cfg_clone = (*state.config).clone(); + let result = match input.session_id { + Some(sid) => { + kebab_app::ask_with_session_with_config(cfg_clone, &sid, &input.query, opts) + } + None => kebab_app::ask_with_config(cfg_clone, &input.query, opts), + }; + match result { + Ok(answer) => { + // `Answer` does not carry `schema_version`; tag inline (idempotent + // via entry().or_insert_with in case a future version adds it). + let mut v = serde_json::to_value(&answer).unwrap_or_default(); + if let serde_json::Value::Object(ref mut map) = v { + map.entry("schema_version".to_string()) + .or_insert_with(|| serde_json::Value::String("answer.v1".to_string())); + } + match serde_json::to_string(&v) { + Ok(json) => to_tool_success(json), + Err(e) => to_tool_error(&anyhow::anyhow!(e)), + } + } + Err(e) => to_tool_error(&e), + } +} diff --git a/crates/kebab-mcp/src/tools/mod.rs b/crates/kebab-mcp/src/tools/mod.rs index 19f52c0..3e3d898 100644 --- a/crates/kebab-mcp/src/tools/mod.rs +++ b/crates/kebab-mcp/src/tools/mod.rs @@ -3,4 +3,4 @@ pub mod schema; pub mod doctor; pub mod search; -// pub mod ask; // wired in Plan Task 7 +pub mod ask; diff --git a/crates/kebab-mcp/tests/tools_call_ask.rs b/crates/kebab-mcp/tests/tools_call_ask.rs new file mode 100644 index 0000000..9b40676 --- /dev/null +++ b/crates/kebab-mcp/tests/tools_call_ask.rs @@ -0,0 +1,89 @@ +//! `ask` tool returns answer.v1 — refusal path covered (no Ollama +//! required for refusal-on-empty-corpus case). + +use kebab_config::Config; +use kebab_core::SourceScope; +use kebab_mcp::{KebabAppState, KebabHandler}; +use rmcp::model::RawContent; + +fn minimal_config(data_dir: &std::path::Path, workspace_root: &std::path::Path) -> Config { + let mut cfg = Config::defaults(); + cfg.storage.data_dir = data_dir.to_string_lossy().into_owned(); + cfg.storage.model_dir = data_dir + .join("models") + .to_string_lossy() + .into_owned(); + cfg.workspace.root = workspace_root.to_string_lossy().into_owned(); + cfg.workspace.exclude.clear(); + cfg.models.embedding.provider = "none".to_string(); + cfg.models.embedding.dimensions = 0; + cfg +} + +#[tokio::test] +async fn ask_tool_returns_answer_v1_with_refusal_on_empty_kb() { + let dir = tempfile::tempdir().unwrap(); + let data_dir = dir.path().join("data"); + let workspace_root = dir.path().join("notes"); + std::fs::create_dir_all(&data_dir).unwrap(); + std::fs::create_dir_all(&workspace_root).unwrap(); + + let cfg = minimal_config(&data_dir, &workspace_root); + + // Seed kebab.sqlite (empty corpus — no documents ingested). + let scope = SourceScope { + root: workspace_root.clone(), + include: vec![], + exclude: vec![], + }; + let _ = kebab_app::ingest_with_config(cfg.clone(), scope, false).unwrap(); + + let state = KebabAppState::new(cfg, None); + let handler = KebabHandler::new(state); + + // `ask_with_config` builds a `reqwest::blocking::Client` internally (for + // `OllamaLanguageModel`), which spins up and drops a tokio runtime — that + // panics when called from inside an async context. Run it on the blocking + // thread pool to avoid the conflict. + let state_clone = handler.state().clone(); + let result = tokio::task::spawn_blocking(move || { + kebab_mcp::tools::ask::handle( + &state_clone, + kebab_mcp::tools::ask::AskInput { + query: "what is the meaning of life".to_string(), + session_id: None, + }, + ) + }) + .await + .unwrap(); + + // Empty KB → refusal (grounded:false) is normal — NOT isError. + assert!( + !result.is_error.unwrap_or(false), + "expected isError=false on refusal, got {:?}", + result + ); + + let content = result + .content + .first() + .expect("expected at least one content item"); + + let text = match &content.raw { + RawContent::Text(t) => &t.text, + other => panic!("expected text content, got {other:?}"), + }; + + let v: serde_json::Value = serde_json::from_str(text).unwrap(); + assert_eq!( + v.get("schema_version").and_then(|s| s.as_str()), + Some("answer.v1"), + "response should carry schema_version=answer.v1" + ); + assert_eq!( + v.get("grounded").and_then(|b| b.as_bool()), + Some(false), + "empty KB should produce grounded=false" + ); +} From c8e04c65e0598543c8e55d7779ccf05a45282584 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 7 May 2026 15:55:02 +0900 Subject: [PATCH 08/17] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20refactor(kebab-mc?= =?UTF-8?q?p):=20fix=20ask=20tool=20production=20panic=20+=20mode=20defaul?= =?UTF-8?q?t=20(fb-30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues from Task 7 review: 1. CRITICAL — call_tool "ask" arm called blocking ask handle from async context. OllamaLanguageModel::new builds reqwest::blocking::Client which creates+drops a tokio runtime → panic inside async. Fix: tokio::task::spawn_blocking wrap. Also applied preemptively to "search" arm (SqliteStore + Lance open are blocking IO too). 2. IMPORTANT — ask tool's retrieval mode hardcoded to Lexical (test workaround for provider="none"); CLI default is Hybrid. Fix: add `mode: Option` field to AskInput, default Hybrid in handle, test passes mode=Some("lexical") explicitly to keep test functional on provider="none". Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-mcp/src/lib.rs | 20 ++++++++++++++++++-- crates/kebab-mcp/src/tools/ask.rs | 16 +++++++++------- crates/kebab-mcp/tests/tools_call_ask.rs | 3 +++ 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/crates/kebab-mcp/src/lib.rs b/crates/kebab-mcp/src/lib.rs index 009a1d3..1bca86b 100644 --- a/crates/kebab-mcp/src/lib.rs +++ b/crates/kebab-mcp/src/lib.rs @@ -96,7 +96,15 @@ impl ServerHandler for KebabHandler { return Ok(error::to_tool_error(&anyhow::Error::from(e))); } }; - Ok(tools::search::handle(&self.state, input)) + let state = self.state.clone(); + let result = tokio::task::spawn_blocking(move || { + tools::search::handle(&state, input) + }) + .await + .map_err(|e| { + ErrorData::internal_error(e.to_string(), None) + })?; + Ok(result) } "ask" => { let args = request.arguments.unwrap_or_default(); @@ -107,7 +115,15 @@ impl ServerHandler for KebabHandler { return Ok(error::to_tool_error(&anyhow::Error::from(e))); } }; - Ok(tools::ask::handle(&self.state, input)) + let state = self.state.clone(); + let result = tokio::task::spawn_blocking(move || { + tools::ask::handle(&state, input) + }) + .await + .map_err(|e| { + ErrorData::internal_error(e.to_string(), None) + })?; + Ok(result) } _other => Err(ErrorData::method_not_found::< rmcp::model::CallToolRequestMethod, diff --git a/crates/kebab-mcp/src/tools/ask.rs b/crates/kebab-mcp/src/tools/ask.rs index 5f6eebd..96c74f7 100644 --- a/crates/kebab-mcp/src/tools/ask.rs +++ b/crates/kebab-mcp/src/tools/ask.rs @@ -1,6 +1,6 @@ //! `ask` tool — wraps `kebab_app::ask_with_config` (single-shot) or //! `kebab_app::ask_with_session_with_config` when `session_id` is provided. -//! Input: { query, session_id? }. Output: answer.v1 JSON. +//! Input: { query, session_id?, mode? }. Output: answer.v1 JSON. //! //! `Answer` (kebab-core) does NOT carry a `schema_version` field; we tag //! it inline here, matching the pattern from `search.rs`. @@ -18,18 +18,20 @@ pub struct AskInput { pub query: String, /// Optional session id for multi-turn RAG context. pub session_id: Option, + /// Optional retrieval mode override ("lexical" / "vector" / "hybrid"). Default "hybrid". + pub mode: Option, } pub fn handle(state: &KebabAppState, input: AskInput) -> CallToolResult { - // Default to Lexical mode — the MCP server is typically called by - // agent hosts that may not have an embedding provider configured. - // Hybrid/vector retrieval would hard-error when embeddings are - // disabled; lexical FTS is always available and covers the common - // RAG case well. + let mode = match input.mode.as_deref() { + Some("lexical") => kebab_core::SearchMode::Lexical, + Some("vector") => kebab_core::SearchMode::Vector, + _ => kebab_core::SearchMode::Hybrid, // default + "hybrid" + unknown + }; let opts = kebab_app::AskOpts { k: 10, explain: false, - mode: kebab_core::SearchMode::Lexical, + mode, temperature: None, seed: None, stream_sink: None, diff --git a/crates/kebab-mcp/tests/tools_call_ask.rs b/crates/kebab-mcp/tests/tools_call_ask.rs index 9b40676..09657d1 100644 --- a/crates/kebab-mcp/tests/tools_call_ask.rs +++ b/crates/kebab-mcp/tests/tools_call_ask.rs @@ -52,6 +52,9 @@ async fn ask_tool_returns_answer_v1_with_refusal_on_empty_kb() { kebab_mcp::tools::ask::AskInput { query: "what is the meaning of life".to_string(), session_id: None, + // Test env uses provider="none" — Hybrid would hard-error on embedding. + // Pass Lexical explicitly so the test stays functional. + mode: Some("lexical".to_string()), }, ) }) From f9a1548b5303abe0ebdde9db864bf977902b7112 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 7 May 2026 15:58:52 +0900 Subject: [PATCH 09/17] =?UTF-8?q?=F0=9F=A7=AA=20test(kebab-mcp):=20error?= =?UTF-8?q?=20mapping=20=E2=80=94=20bad=20config=20=E2=86=92=20error.v1=20?= =?UTF-8?q?(fb-30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds integration test schema_tool_emits_error_v1_when_db_missing that verifies NotIndexed errors are emitted as error.v1 JSON with isError=true. Also fixes ErrorV1 struct to include required schema_version field per error.v1 wire contract (docs/wire-schema/v1/error.schema.json). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-app/src/error_wire.rs | 10 +++++++ crates/kebab-mcp/tests/error_mapping.rs | 36 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 crates/kebab-mcp/tests/error_mapping.rs diff --git a/crates/kebab-app/src/error_wire.rs b/crates/kebab-app/src/error_wire.rs index 62108c6..192cf7c 100644 --- a/crates/kebab-app/src/error_wire.rs +++ b/crates/kebab-app/src/error_wire.rs @@ -13,6 +13,7 @@ use crate::error_signal::{ConfigInvalid, LlmError, NotIndexed}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ErrorV1 { + pub schema_version: String, pub code: String, pub message: String, pub details: Value, @@ -22,6 +23,7 @@ pub struct ErrorV1 { pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 { if let Some(s) = err.downcast_ref::() { return ErrorV1 { + schema_version: "error.v1".to_string(), code: "config_invalid".to_string(), message: s.to_string(), details: json!({ @@ -33,6 +35,7 @@ pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 { } if let Some(s) = err.downcast_ref::() { return ErrorV1 { + schema_version: "error.v1".to_string(), code: "not_indexed".to_string(), message: s.to_string(), details: json!({ @@ -47,6 +50,7 @@ pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 { } if let Some(io) = err.downcast_ref::() { return ErrorV1 { + schema_version: "error.v1".to_string(), code: "io_error".to_string(), message: io.to_string(), details: json!({"kind": format!("{:?}", io.kind())}), @@ -59,6 +63,7 @@ pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 { details = json!({"chain": chain}); } ErrorV1 { + schema_version: "error.v1".to_string(), code: "generic".to_string(), message: err.to_string(), details, @@ -69,6 +74,7 @@ pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 { fn classify_llm(s: &LlmError) -> ErrorV1 { match s { LlmError::Unreachable { endpoint, source } => ErrorV1 { + schema_version: "error.v1".to_string(), code: "model_unreachable".to_string(), message: format!("ollama unreachable at {endpoint}"), details: json!({ @@ -78,24 +84,28 @@ fn classify_llm(s: &LlmError) -> ErrorV1 { hint: Some(format!("ensure `ollama serve` is reachable at {endpoint}")), }, LlmError::ModelNotPulled(model) => ErrorV1 { + schema_version: "error.v1".to_string(), code: "model_not_pulled".to_string(), message: format!("ollama model `{model}` is not pulled"), details: json!({"model": model}), hint: Some(format!("run `ollama pull {model}`")), }, LlmError::Timeout(e) => ErrorV1 { + schema_version: "error.v1".to_string(), code: "timeout".to_string(), message: format!("ollama timeout: {e}"), details: json!({"source": e.to_string()}), hint: Some("increase timeout or check Ollama load".to_string()), }, LlmError::Stream(body) => ErrorV1 { + schema_version: "error.v1".to_string(), code: "generic".to_string(), message: format!("ollama HTTP error: {body}"), details: json!({"body": body}), hint: None, }, LlmError::Malformed(line) => ErrorV1 { + schema_version: "error.v1".to_string(), code: "generic".to_string(), message: format!("malformed response line: {line}"), details: json!({"line": line}), diff --git a/crates/kebab-mcp/tests/error_mapping.rs b/crates/kebab-mcp/tests/error_mapping.rs new file mode 100644 index 0000000..739d986 --- /dev/null +++ b/crates/kebab-mcp/tests/error_mapping.rs @@ -0,0 +1,36 @@ +//! tools/call with bad config → isError=true + error.v1 content. + +use kebab_config::Config; +use kebab_mcp::{KebabAppState, KebabHandler}; +use rmcp::model::RawContent; + +#[tokio::test] +async fn schema_tool_emits_error_v1_when_db_missing() { + // Point at a directory that does NOT have kebab.sqlite. + let dir = tempfile::tempdir().unwrap(); + let mut cfg = Config::defaults(); + cfg.storage.data_dir = dir.path().to_string_lossy().into_owned(); + cfg.workspace.root = dir.path().join("notes").to_string_lossy().into_owned(); + cfg.models.embedding.provider = "none".to_string(); + cfg.models.embedding.dimensions = 0; + // Note: NO ingest call — kebab.sqlite is absent → schema_with_config + // calls open_existing → NotIndexed → tool error. + + let state = KebabAppState::new(cfg, None); + let handler = KebabHandler::new(state); + + let result = kebab_mcp::tools::schema::handle( + handler.state(), + kebab_mcp::tools::schema::SchemaInput::default(), + ); + assert_eq!(result.is_error, Some(true), "expected isError=true on missing DB"); + + let content = result.content.first().unwrap(); + let text = match &content.raw { + RawContent::Text(t) => &t.text, + other => panic!("expected text content, got {other:?}"), + }; + let v: serde_json::Value = serde_json::from_str(text).unwrap(); + assert_eq!(v.get("schema_version").and_then(|s| s.as_str()), Some("error.v1")); + assert_eq!(v.get("code").and_then(|s| s.as_str()), Some("not_indexed")); +} From bc16dbf12ab9ea205d878b76646dce3f4d2d66a5 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 7 May 2026 16:02:09 +0900 Subject: [PATCH 10/17] =?UTF-8?q?=F0=9F=9A=91=20fix(kebab-cli):=20add=20sc?= =?UTF-8?q?hema=5Fversion=20field=20to=20wire.rs=20ErrorV1=20test=20litera?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 8 commit f9a1548 added `schema_version: String` as required field on ErrorV1 (so kebab-mcp's direct serialize-then-emit path produces correct error.v1 wire). The wire.rs ErrorV1 literal in the error_wrapper_tags_schema_version_and_emits_code test was missed — breaks kebab-cli build. Add the field to the test fixture. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-cli/src/wire.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/kebab-cli/src/wire.rs b/crates/kebab-cli/src/wire.rs index ca3bc64..8fd58e7 100644 --- a/crates/kebab-cli/src/wire.rs +++ b/crates/kebab-cli/src/wire.rs @@ -264,6 +264,7 @@ mod tests { fn error_wrapper_tags_schema_version_and_emits_code() { use kebab_app::ErrorV1; let err = ErrorV1 { + schema_version: "error.v1".to_string(), code: "config_invalid".to_string(), message: "bad config".to_string(), details: serde_json::json!({"path": "/tmp/x"}), From 61eef9bc82bf30b6a14fddb75f6f8ecc629bbb5f Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 7 May 2026 16:04:42 +0900 Subject: [PATCH 11/17] =?UTF-8?q?=F0=9F=A7=AA=20test(kebab-mcp):=20tools/l?= =?UTF-8?q?ist=20returns=204=20tools=20(fb-30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approach: extracted `pub fn build_tools_vec() -> Vec` from the inline `list_tools` trait impl body — `RequestContext` is non-constructible from outside rmcp (Peer::new is pub(crate)), so a direct trait-method call was not viable without an in-memory transport rmcp 1.6 does not expose. The helper is the single source of truth; `list_tools` now delegates to it. Three test cases in tests/tools_list.rs: - 4 tools present with correct names - search inputSchema has "required": ["query"] - schema/doctor tools accept empty input (type=object, no required) Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-mcp/src/lib.rs | 52 ++++++++++++--------- crates/kebab-mcp/tests/tools_list.rs | 68 ++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 22 deletions(-) create mode 100644 crates/kebab-mcp/tests/tools_list.rs diff --git a/crates/kebab-mcp/src/lib.rs b/crates/kebab-mcp/src/lib.rs index 1bca86b..9cbafa9 100644 --- a/crates/kebab-mcp/src/lib.rs +++ b/crates/kebab-mcp/src/lib.rs @@ -23,6 +23,35 @@ pub mod state; pub mod tools; pub use state::KebabAppState; +/// Build the canonical list of tools exposed by the MCP server. +/// +/// Extracted from [`ServerHandler::list_tools`] so it can be called +/// directly in tests without constructing a `RequestContext`. +pub fn build_tools_vec() -> Vec { + vec![ + Tool::new( + "schema", + "Introspection — wire schemas, capabilities, model versions, index stats.", + schema_for_empty_input(), + ), + Tool::new( + "doctor", + "Health check — verifies config, storage, models, and Ollama connectivity.", + schema_for_empty_input(), + ), + Tool::new( + "search", + "Full-text / vector / hybrid search over the knowledge base. Returns search_hit.v1 array.", + schema_for_type::(), + ), + Tool::new( + "ask", + "RAG question answering over the knowledge base. Returns answer.v1 JSON. Pass session_id for multi-turn context.", + schema_for_type::(), + ), + ] +} + #[derive(Clone)] pub struct KebabHandler { state: KebabAppState, @@ -49,28 +78,7 @@ impl ServerHandler for KebabHandler { _request: Option, _context: RequestContext, ) -> Result { - Ok(ListToolsResult::with_all_items(vec![ - Tool::new( - "schema", - "Introspection — wire schemas, capabilities, model versions, index stats.", - schema_for_empty_input(), - ), - Tool::new( - "doctor", - "Health check — verifies config, storage, models, and Ollama connectivity.", - schema_for_empty_input(), - ), - Tool::new( - "search", - "Full-text / vector / hybrid search over the knowledge base. Returns search_hit.v1 array.", - schema_for_type::(), - ), - Tool::new( - "ask", - "RAG question answering over the knowledge base. Returns answer.v1 JSON. Pass session_id for multi-turn context.", - schema_for_type::(), - ), - ])) + Ok(ListToolsResult::with_all_items(build_tools_vec())) } async fn call_tool( diff --git a/crates/kebab-mcp/tests/tools_list.rs b/crates/kebab-mcp/tests/tools_list.rs new file mode 100644 index 0000000..f7c0cd4 --- /dev/null +++ b/crates/kebab-mcp/tests/tools_list.rs @@ -0,0 +1,68 @@ +//! Integration: `build_tools_vec` returns 4 tools with correct names and +//! inputSchema. Uses the extracted `pub fn build_tools_vec()` helper — no +//! transport or RequestContext needed. + +use kebab_mcp::build_tools_vec; + +#[test] +fn tools_list_returns_four_tools() { + let tools = build_tools_vec(); + assert_eq!(tools.len(), 4, "expected exactly 4 tools, got {}", tools.len()); + + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + assert!(names.contains(&"schema"), "missing 'schema' tool"); + assert!(names.contains(&"doctor"), "missing 'doctor' tool"); + assert!(names.contains(&"search"), "missing 'search' tool"); + assert!(names.contains(&"ask"), "missing 'ask' tool"); +} + +#[test] +fn search_tool_input_schema_has_required_query() { + let tools = build_tools_vec(); + let search = tools + .iter() + .find(|t| t.name.as_ref() == "search") + .expect("search tool must be present"); + + // input_schema is Arc (serde_json::Map). + let schema_val = serde_json::Value::Object(search.input_schema.as_ref().clone()); + + let required = schema_val + .get("required") + .and_then(|v| v.as_array()) + .expect("search inputSchema must have a 'required' array"); + + assert!( + required.iter().any(|v| v.as_str() == Some("query")), + "search inputSchema 'required' must contain 'query', got: {required:?}" + ); +} + +#[test] +fn schema_and_doctor_tools_accept_empty_input() { + let tools = build_tools_vec(); + + for name in &["schema", "doctor"] { + let tool = tools + .iter() + .find(|t| t.name.as_ref() == *name) + .unwrap_or_else(|| panic!("{name} tool must be present")); + + let schema_val = serde_json::Value::Object(tool.input_schema.as_ref().clone()); + // An empty-input schema has type "object" and no required fields + // (or no 'required' key at all). + let ty = schema_val.get("type").and_then(|v| v.as_str()); + assert_eq!( + ty, + Some("object"), + "{name} inputSchema 'type' must be 'object', got {ty:?}" + ); + + if let Some(required) = schema_val.get("required").and_then(|v| v.as_array()) { + assert!( + required.is_empty(), + "{name} inputSchema 'required' must be empty, got: {required:?}" + ); + } + } +} From 4a30959fdd8483ddeaaaca3f074489d59d415d1c Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 7 May 2026 16:10:17 +0900 Subject: [PATCH 12/17] =?UTF-8?q?=E2=9C=A8=20feat(kebab-cli):=20kebab=20mc?= =?UTF-8?q?p=20subcommand=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?; From 366b647a1a2b8ea7575afd19f73164b0280871f7 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 7 May 2026 16:12:23 +0900 Subject: [PATCH 13/17] =?UTF-8?q?=E2=9C=A8=20feat(kebab-app):=20capability?= =?UTF-8?q?=20flag=20mcp=5Fserver:=20false=20=E2=86=92=20true=20(fb-30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-app/src/schema.rs | 2 +- crates/kebab-app/tests/schema_report.rs | 4 ++++ crates/kebab-cli/tests/cli_schema.rs | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/kebab-app/src/schema.rs b/crates/kebab-app/src/schema.rs index 1c15f1e..42aa137 100644 --- a/crates/kebab-app/src/schema.rs +++ b/crates/kebab-app/src/schema.rs @@ -108,7 +108,7 @@ fn capabilities_snapshot() -> Capabilities { incremental_ingest: true, streaming_ask: false, http_daemon: false, - mcp_server: false, + mcp_server: true, single_file_ingest: false, } } diff --git a/crates/kebab-app/tests/schema_report.rs b/crates/kebab-app/tests/schema_report.rs index cf46cd2..253646c 100644 --- a/crates/kebab-app/tests/schema_report.rs +++ b/crates/kebab-app/tests/schema_report.rs @@ -58,6 +58,10 @@ fn schema_report_reflects_freshly_ingested_kb() { ); assert!(schema.capabilities.json_mode); assert!(!schema.capabilities.streaming_ask); + assert!( + schema.capabilities.mcp_server, + "mcp_server should be true after fb-30", + ); assert_eq!( schema.stats.doc_count, 2, "expected 2 docs (a.md + b.md): {:?}", diff --git a/crates/kebab-cli/tests/cli_schema.rs b/crates/kebab-cli/tests/cli_schema.rs index f084d4d..6bc415a 100644 --- a/crates/kebab-cli/tests/cli_schema.rs +++ b/crates/kebab-cli/tests/cli_schema.rs @@ -92,8 +92,8 @@ fn cli_schema_json_emits_schema_v1() { ); assert_eq!( caps.get("mcp_server").and_then(|b| b.as_bool()), - Some(false), - "capabilities.mcp_server must be false (not yet shipped)" + Some(true), + "capabilities.mcp_server must be true (fb-30)" ); } From f758d51a0131643b819a30291d10367bb95beefd Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 7 May 2026 16:15:19 +0900 Subject: [PATCH 14/17] =?UTF-8?q?=F0=9F=93=9D=20docs:=20sync=20README=20/?= =?UTF-8?q?=20HANDOFF=20/=20CLAUDE=20/=20skill=20/=20design=20for=20fb-30?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README 명령 표 에 `kebab mcp` 추가 + Claude Code MCP config 예시 - HANDOFF post-도그푸딩 항목 한 줄 (rmcp 1.6 + manual dispatch + error_wire promotion + ask/search spawn_blocking + capability flag flip 명시) - CLAUDE.md facade 룰 의 UI crate 카테고리 에 `kebab-mcp` 추가 - integrations skill — MCP 사용 안내 (recommended over subprocess) - design §10.2 MCP transport 절 신설 Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 4 ++-- HANDOFF.md | 1 + README.md | 20 ++++++++++++++++++- .../2026-04-27-kebab-final-form-design.md | 11 ++++++++++ integrations/claude-code/kebab/SKILL.md | 19 ++++++++++++++++++ 5 files changed, 52 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3fd8f75..6ed5f32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project -Single-user local-first knowledge base + RAG. Rust 2024 workspace, ~20 crates, single binary (`kebab`). All inference is local (Ollama + fastembed + whisper.cpp). +Single-user local-first knowledge base + RAG. Rust 2024 workspace, ~21 crates, single binary (`kebab`). All inference is local (Ollama + fastembed + whisper.cpp). The repo's documentation is split by audience — don't duplicate across them: @@ -54,7 +54,7 @@ Each task spec lists `Allowed dependencies` and `Forbidden dependencies` per des - `kebab-core` MUST NOT depend on any other `kebab-*` crate. Domain types only. - `kebab-eval`'s `metrics` and `compare` modules MUST NOT import retrieval / embedding / LLM crates directly. The runner is allowed to use `kebab-app`'s facade (P5-1 inheritance — see deviations in that task spec). -- UI crates (`kebab-cli`, future `kebab-tui`, `kebab-desktop`) MUST NOT import `kebab-store-*` / `kebab-llm-*` / `kebab-parse-*` directly — only `kebab-app`. +- UI crates (`kebab-cli`, `kebab-mcp`, `kebab-tui`, future `kebab-desktop`) MUST NOT import `kebab-store-*` / `kebab-llm-*` / `kebab-parse-*` directly — only `kebab-app`. Read the relevant task spec's deps section before adding an import. New crates inherit the same boundary rules. diff --git a/HANDOFF.md b/HANDOFF.md index 27b9160..bd6c164 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -31,6 +31,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능. 머지 후 발견된 모든 deviation / hotfix 의 dated 로그는 [tasks/HOTFIXES.md](tasks/HOTFIXES.md). 본 요약은 \"누군가가 인수받을 때 알아두면 시간을 많이 절약하는\" 항목만: +- **2026-05-07 P9 post-도그푸딩 (p9-fb-30)** — `kebab mcp` 신규 subcommand + new crate `kebab-mcp` (lib only) — stdio JSON-RPC server. 4 read-only tool (`search` / `ask` / `schema` / `doctor`) 가 `kebab-app` facade 위에 build. rmcp 1.6 SDK 채택, manual `tools/list` + `tools/call` dispatch (rmcp 의 `#[tool_router]` 매크로 대신). `error_classify` 모듈을 `kebab-cli` → `kebab-app::error_wire` 로 promotion (UI crate 끼리 import 회피, facade 룰 준수). `ErrorV1` 에 `schema_version: String` 필드 추가 — kebab-mcp 의 직접 serialize 경로에서도 wire 정합. `KebabAppState` 가 `(Config, Option)` carry — doctor tool 의 path-aware behavior 위해. ask + search arm 의 `tokio::task::spawn_blocking` wrap — `OllamaLanguageModel` 의 reqwest blocking client 가 async 안에서 panic 회피. capability flag `mcp_server` `false` → `true`. agent integration MVP 완성 — Claude Code / Cursor / OpenAI Agents 등 host-agnostic 사용 가능. spec: `tasks/p9/p9-fb-30-mcp-server.md`. design: `docs/superpowers/specs/2026-05-07-p9-fb-30-mcp-server-design.md`. - **P3-5 / P4-3 `--config` 누락** — `kebab-cli` 가 `--config ` 를 honor 하려면 `kebab_app::*_with_config` companion 을 호출해야 함. 두 번 같은 모양으로 회귀했음. - **P6-2 OCR 기본 엔진** — spec literal 의 Tesseract 가 시스템 dep 부담으로 거부됨, Ollama vision LM 으로 대체. `OcrEngine` trait 그대로라 future swap 가능. - **P6-3 caption** — `GenerateRequest.images` 필드를 `kebab-core::LanguageModel` trait 에 신설. 기존 caller 모두 `images: Vec::new()` 로 마이그레이션. diff --git a/README.md b/README.md index 976c6d6..170439b 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ kebab doctor | `kebab reset [--all / --data-only / --vector-only / --config-only] [--yes]` | XDG 데이터 wipe. **Irreversible.** TTY 면 confirm prompt, 아니면 `--yes` 필수. `--vector-only` 는 SQLite `embedding_records` 도 함께 truncate (orphan 방지) | | `kebab eval run / compare` | golden query 회귀 측정 | | `kebab schema [--json]` | introspection — wire schemas / capabilities / models / stats 한 번에. `--json` 은 `schema.v1` wire; 사람 모드는 서식 출력. | +| `kebab mcp` | MCP (Model Context Protocol) stdio server. agent host (Claude Code / Cursor / OpenAI Agents) 가 spawn 하여 tool 호출 (`search` / `ask` / `schema` / `doctor`). `--config` honor. | 모든 명령에 `--json` 플래그. 출력은 frozen wire schema v1 (`schema_version` 항상 포함, 예: `ingest_report.v1`, `ingest_progress.v1`, `search_hit.v1`, `answer.v1`, `doctor.v1`, `reset_report.v1`, `schema.v1`). `--json` 모드에서 fatal error 는 stderr 에 `error.v1` ndjson 으로 emit (exit code 0/1/2/3 unchanged). @@ -160,9 +161,26 @@ config 예시는 [docs/SMOKE.md](docs/SMOKE.md) 의 `/tmp/kebab-smoke/config.tom - **Claude Code skill** — repo 의 [`integrations/claude-code/`](integrations/claude-code/) 가 ship-ready skill. `cp -r integrations/claude-code/kebab ~/.claude/skills/` 한 번이면 새 Claude Code 세션부터 자동 trigger (내부 시스템 / 위키 lookup / 사내 runbook 질문). multi-turn 은 `kebab ask --session --json` 으로 영속 — skill 이 conversation id 관리하면 외부 agent 도 `--repl` 없이 stateful 대화 가능 (p9-fb-18). - **Codex / 기타 agent host** — `--json` + frozen wire schema v1 가 stable contract. 동일 패턴으로 ~50줄 wrapper 작성 가능. `integrations//` 에 추가 PR 환영. -- **MCP server** — stdio JSON-RPC 로 `kebab-app` facade 1:1 노출. +- **MCP server** — stdio JSON-RPC 로 `kebab-app` facade 1:1 노출. `kebab mcp` 참조. - **HTTP wrapper** — `kebab serve --bind 127.0.0.1:7711` (P+, local-only 가치 신중). +## MCP 사용 (Claude Code 예시) + +`~/.claude/mcp.json` (또는 host 의 동등 위치): + +```json +{ + "mcpServers": { + "kebab": { + "command": "kebab", + "args": ["mcp"] + } + } +} +``` + +Claude Code 가 session 시작 시 `kebab mcp` 를 spawn — process 가 session 동안 살아 있어 SQLite / Lance / fastembed 가 hot. 4 tool: `search` (lexical/vector/hybrid 검색), `ask` (RAG 답변, optional `session_id` for multi-turn + optional `mode` override), `schema` (capability 조회), `doctor` (health check). 모든 tool 의 결과는 wire schema v1 JSON 으로 text content 안에 직렬화 — agent 가 parse 후 사용. tool dispatch 실패 (잘못된 config / 미초기화 KB 등) 는 `isError: true` + error.v1 content; refusal / no-hit / unhealthy 는 정상 응답 (semantic flag 으로 분기). + ## 비-목표 다중 사용자 SaaS / K8s / 원격 vector DB / enterprise RBAC / 실시간 협업 / 모든 파일 포맷의 완벽한 parsing / agent 임의 파일 수정 / multi-workspace / LLM-as-judge eval / CLIP 시각 embedding / `kebab://` protocol handler — frozen 설계 §11 / §0 참조. diff --git a/docs/superpowers/specs/2026-04-27-kebab-final-form-design.md b/docs/superpowers/specs/2026-04-27-kebab-final-form-design.md index 8ba226a..ad2f9fd 100644 --- a/docs/superpowers/specs/2026-04-27-kebab-final-form-design.md +++ b/docs/superpowers/specs/2026-04-27-kebab-final-form-design.md @@ -1443,6 +1443,17 @@ HOTFIXES 의 `2026-05-07 — p9-fb-27` 항목이 details shape 의 interim deviation (IoFailure / OpTimeout 신규 typed signal 도입 전까지의 transitional 형태) 의 source of truth. +### 10.2 MCP server transport (fb-30) + +`kebab mcp` 가 stdio JSON-RPC server. Rust SDK = `rmcp 1.6`. Tool surface +v1: `search` / `ask` / `schema` / `doctor` (4 read-only). Resources / +Prompts / Sampling 미선언. Output 은 wire schema v1 JSON 을 MCP `text` +content block 으로 직렬화. Tool dispatch 실패는 `isError: true` + error.v1 +content; refusal / no-hit / unhealthy 는 정상 응답 (semantic flag 으로 +agent 가 분기). HTTP-SSE transport 는 fb-29 deferral 따라 P+. classify +모듈은 `kebab-app::error_wire` 에 single source — kebab-cli + kebab-mcp +공유. + --- ## 11. 동결 범위 / 변경 정책 diff --git a/integrations/claude-code/kebab/SKILL.md b/integrations/claude-code/kebab/SKILL.md index 55a78af..2f74cda 100644 --- a/integrations/claude-code/kebab/SKILL.md +++ b/integrations/claude-code/kebab/SKILL.md @@ -71,6 +71,25 @@ Returns a `schema.v1` object with: `wire.schemas` (supported wire ids), `capabil If a call fails or returns suspicious output, run `kebab doctor` first — it surfaces config-load / data-dir / Ollama-reachability problems in one line each. Don't silently retry on errors; report the doctor output. +## MCP server (recommended over CLI subprocess wrapping) + +Since v0.4.0, `kebab` exposes an MCP (Model Context Protocol) stdio server. Configure once in `~/.claude/mcp.json`: + +```json +{ + "mcpServers": { + "kebab": { + "command": "kebab", + "args": ["mcp"] + } + } +} +``` + +Claude Code spawns `kebab mcp` at session start; the process stays alive across all tool calls so SQLite / Lance / fastembed are hot after the first call. 4 tools available: `search` / `ask` / `schema` / `doctor`. Same wire shapes as the CLI `--json` mode — see `Two surfaces, pick the right one` above for the same guidance. + +If your host doesn't support MCP, the CLI subprocess pattern (`kebab search --json` / `kebab ask --json`) above continues to work. + ## Workflow recipes **Recipe A — user asks an internal-context question, you want grounded answer:** From ee4f19830870aa79befeba405b8452d435720bdf Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 7 May 2026 16:17:37 +0900 Subject: [PATCH 15/17] =?UTF-8?q?=F0=9F=93=9D=20docs(tasks):=20HOTFIXES=20?= =?UTF-8?q?entry=20+=20p9-fb-30=20status=20=E2=86=92=20completed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- tasks/HOTFIXES.md | 39 +++++++++++++++++++++++++++++++++ tasks/p9/p9-fb-30-mcp-server.md | 4 ++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/tasks/HOTFIXES.md b/tasks/HOTFIXES.md index 8a95d1d..27adfc8 100644 --- a/tasks/HOTFIXES.md +++ b/tasks/HOTFIXES.md @@ -14,6 +14,45 @@ historical contract that was implemented; this file accumulates the deltas so phase 5+ readers can find the live behavior without diffing git history. +## 2026-05-07 — p9-fb-30 (post-dogfooding): MCP server (stdio) — agent integration MVP + +**Source feedback**: 사용자 도그푸딩 2026-05-06 — Claude Code 같은 AI agent 가 kebab CLI 를 사용하는 것이 궁극 목표. 현재 surface 는 Claude Code 전용 skill (subprocess wrapper) 만 — host 무관 표준 통신 없음. fb-29 HTTP daemon 은 single-user local-first 환경 대비 비대로 deferred (2026-05-07), fb-30 stdio MCP 가 동일 사용자 가치 (agent integration + session 동안 hot cache) 를 daemon 복잡도 없이 제공. + +**Live binding 변경**: + +- 신규 subcommand `kebab mcp` — stdio JSON-RPC server, `--config ` honor. +- 신규 crate `kebab-mcp` (lib only) — `serve_stdio(Config, Option)` entry. UI crate 카테고리 (kebab-cli + kebab-tui + kebab-mcp 가 facade 룰 동일 적용 — `kebab-app` facade 만 import). +- Tool surface v1 (read-only 4): `search` (lexical/vector/hybrid 검색, default Hybrid), `ask` (RAG 답변, default mode Hybrid, optional `session_id` for multi-turn + optional `mode` override), `schema` (introspection), `doctor` (health check). `ingest_*` / `fetch` / `list_docs` / `inspect_chunk` 는 fb-31 / fb-35 / 후속 task 머지 시 추가. +- Resources / Prompts / Sampling — 모두 미선언 (tools-only v1). +- Output: 모든 tool 이 wire schema v1 JSON 을 MCP `text` content block 으로 직렬화. CLI `--json` 모드와 동일 wire — single source. +- Error mapping: tool dispatch `Err(e)` 만 `isError: true` + error.v1 content. Refusal (`grounded: false`) / no-hit (empty array) / unhealthy (`ok: false`) 는 모두 정상 응답 — agent 가 wire payload semantic flag 으로 분기. +- `kebab-app::error_wire` 신규 — fb-27 의 `kebab-cli::error_classify` 코드 그대로 promotion (struct + classify + classify_llm + 7 unit test). kebab-cli + kebab-mcp 둘 다 동일 모듈 사용. reqwest dev-dep 도 함께 이동. 부수 변경: `ErrorV1` 에 `schema_version: String` 필드 추가 — kebab-mcp 의 직접 serialize 경로에서도 wire 정합 (kebab-cli 의 `wire_error_v1` 의 `tag_object` 는 idempotent 로 작동, 동작 무영향). +- `kebab-app::Capabilities::mcp_server`: `false` → `true`. `schema_report` 통합 테스트 + `cli_schema` 통합 테스트 assertion 갱신. +- Initialize handshake: `protocolVersion = "2025-03-26"` (rmcp 1.6 default), `capabilities.tools = { listChanged: false }`, `serverInfo = { name: "kebab", version: }`. +- `KebabAppState` 가 `(Config, Option)` carry — `kebab_app::doctor_with_config_path` 는 `Option<&Path>` 만 받기 때문 (`doctor_with_config(&Config)` 미존재). path 없으면 `None` (XDG default 동작). +- `tokio::task::spawn_blocking` wrap on `call_tool` arms for `ask` + `search` — `OllamaLanguageModel` 의 `reqwest::blocking::Client::build()` 가 내부적으로 tokio runtime create+drop 하므로 async 안에서 panic. spawn_blocking 으로 우회. schema / doctor 는 cheap reads 라 wrap 불필요. +- `tools/list` 의 list construction 을 `pub fn build_tools_vec()` 로 추출 — rmcp 1.6 가 in-memory test transport 미노출이라 spawn 없이 unit-level 검증 위함. + +**Spec contract impact**: design §10 에 §10.2 MCP transport 절 추가. + +**Tests added**: kebab-mcp integration (5: tools_call_search / tools_call_ask / tools_call_schema / tools_call_doctor / tools_list / error_mapping + initialize), kebab-cli integration (1: cli_mcp_smoke spawn + initialize + tools/list round-trip). 약 8 신규 테스트. + +**Known limitation (deferred)**: + +- HTTP-SSE transport — fb-29 P+ deferral 따라 stdio 단일. browser agent / remote 시나리오 등장 시 재개. +- Resources (`kebab://chunk/` URI) — fb-35 verbatim fetch 와 함께 v2. +- Prompts — RAG 자체 prompt template 내장으로 사용자 가치 약함, defer. +- Streaming `ask` — fb-33 streaming ask 와 함께. +- `ingest_*` / `fetch` / `list_docs` / `inspect_chunk` tools — 후속 task 별로 추가. +- Server-scope state caching — 현재 매 tool call 마다 store open. 첫 call 시 `KebabAppState` 에 `OnceLock` 도입 검토 (post-merge 후속 PR). +- rmcp SDK API 호환성 — 1.6 채택, 미래 major bump 시 별 task. +- Manual `tools/list` + `tools/call` dispatch 채택 — rmcp 1.6 의 `#[tool_router]` 매크로보다 명시적, 디버깅 쉬움. 하지만 새 tool 추가 시 두 곳 (list_tools 의 vec + call_tool 의 match) 동시 갱신 필요. 후속 task 가 5개 이상 tool 추가하면 매크로 도입 재검토. + +**Amends**: +- design §10 (MCP transport subsection 추가). +- spec `tasks/p9/p9-fb-30-mcp-server.md` (status `open` → `completed`). +- spec stub 의 `transport: stdio default + http (fb-29 daemon) 위에 SSE 옵션` → 실제 채택 stdio 단일 (fb-29 deferral 결과, 2026-05-07 commit `2e8de14` 의 spec 갱신과 일관). + ## 2026-05-07 — p9-fb-27 (post-dogfooding): introspection (`kebab schema`) + structured error wire **Source feedback**: 사용자 도그푸딩 2026-05-06 — agent 가 kebab 인스턴스의 wire 버전 / 기능 / 모델 / 인덱스 통계 introspect 못 함; error 가 stderr text 라 substring 분기 필요. diff --git a/tasks/p9/p9-fb-30-mcp-server.md b/tasks/p9/p9-fb-30-mcp-server.md index 1b83166..24c5444 100644 --- a/tasks/p9/p9-fb-30-mcp-server.md +++ b/tasks/p9/p9-fb-30-mcp-server.md @@ -3,7 +3,7 @@ phase: P9 component: integrations + new crate (kebab-mcp) task_id: p9-fb-30 title: "MCP server — agent host 무관 protocol surface" -status: open +status: completed target_version: 0.3.0 depends_on: [p9-fb-27] unblocks: [] @@ -14,7 +14,7 @@ source_feedback: 사용자 도그푸딩 2026-05-06 — Claude Code 같은 AI age # p9-fb-30 — MCP server -> ⏳ **백로그 only — 미구현.** 본 spec 은 도그푸딩 피드백 skeleton. 구현 착수 전 [superpowers:brainstorming](../../docs/superpowers/) 으로 설계 단계 선행 필요. transport 선택 (stdio / socket) / tool surface 범위 / authentication / resources vs tools 매핑 brainstorm 후 확정. +> ✅ **구현 완료.** 본 spec 은 구현 시점의 frozen 상태. post-merge deviation (특히 `error.v1` 에 schema_version 필드 추가, ask/search spawn_blocking, manual dispatch 채택) 은 [HOTFIXES.md](../HOTFIXES.md) 의 `2026-05-07 — p9-fb-30` 항목 참조 — live source of truth. ## 증상 / 동기 From 2387c6cd113cc81dd0aed54345e8f88c511aaf47 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 7 May 2026 16:34:46 +0900 Subject: [PATCH 16/17] =?UTF-8?q?=F0=9F=9A=91=20docs=20+=20cleanup:=20ARCH?= =?UTF-8?q?ITECTURE.md=20kebab-mcp=20+=20state.rs=20stale=20comment=20+=20?= =?UTF-8?q?schema=20test=20cap-flag=20(fb-30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final review fixes: - docs/ARCHITECTURE.md: add kebab-mcp to UI subgraph + directory tree (CLAUDE.md "add new crate" rule required this; missed in Task 12 doc sync). - state.rs: replace forward-reference Task 10 comment with current-state doc (config_path now wired by Task 10 commit 4a30959). - tools_call_schema.rs: assert capabilities.mcp_server == true (already pinned in schema_report + cli_schema, this closes the gap in mcp's own test). Version bump 0.3.0 → 0.4.0 deferred to separate `chore/bump-v0.4.0` PR mirroring fb-27 precedent (commit 73f5d73 / PR #105). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-mcp/src/state.rs | 7 ++----- crates/kebab-mcp/tests/tools_call_schema.rs | 5 +++++ docs/ARCHITECTURE.md | 3 +++ 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/kebab-mcp/src/state.rs b/crates/kebab-mcp/src/state.rs index 00560cf..30debf0 100644 --- a/crates/kebab-mcp/src/state.rs +++ b/crates/kebab-mcp/src/state.rs @@ -11,11 +11,8 @@ use kebab_config::Config; #[derive(Clone)] pub struct KebabAppState { pub config: Arc, - /// Original config file path passed via `--config `, if any. - /// Forwarded to `kebab_app::doctor_with_config_path` so the doctor - /// report reflects the same config file the server was started with. - /// Plan Task 10 (Cmd::Mcp wiring) will pass the actual path; all - /// existing callers pass `None` which falls back to the XDG default. + /// `--config ` from CLI when present, else `None` (XDG default + /// fallback applies in `doctor_with_config_path`). pub config_path: Option, } diff --git a/crates/kebab-mcp/tests/tools_call_schema.rs b/crates/kebab-mcp/tests/tools_call_schema.rs index f931508..c47a874 100644 --- a/crates/kebab-mcp/tests/tools_call_schema.rs +++ b/crates/kebab-mcp/tests/tools_call_schema.rs @@ -67,4 +67,9 @@ async fn schema_tool_returns_schema_v1_json() { Some("schema.v1"), "unexpected schema_version in: {v}" ); + assert_eq!( + v.get("capabilities").and_then(|c| c.get("mcp_server")).and_then(|b| b.as_bool()), + Some(true), + "mcp_server capability flag should be true after fb-30", + ); } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1e13e38..4bb499f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -40,6 +40,7 @@ flowchart TB subgraph UI ["UI binary"] cli["kebab-cli"] tui["kebab-tui"] + mcp["kebab-mcp
(P9-FB-30)"] desktop["kebab-desktop
(P9-5)"] end app["kebab-app
(facade)"] @@ -71,6 +72,7 @@ flowchart TB cli --> app tui --> app + mcp --> app desktop --> app app --> srcfs @@ -168,6 +170,7 @@ kebab/ │ ├── kebab-parse-pdf/ # lopdf per-page text extractor (P7-1) │ ├── kebab-app/ # facade (P0 시그니처 + P3-5/P6-4/P7-3 본체) │ ├── kebab-tui/ # Ratatui shell + Library 패널 (P9-1) +│ ├── kebab-mcp/ # stdio MCP server — tools: search, ask, doctor (P9-FB-30) │ └── kebab-cli/ # binary (P0 → 핫픽스로 --config flag wiring 강화) ├── migrations/ # SQLite refinery V001/V002/V003 └── fixtures/ # 테스트 fixture 트리 From 4e2090e54dc0cc009600f4a429afca6e91acc412 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 7 May 2026 16:59:40 +0900 Subject: [PATCH 17/17] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20refactor(fb-30):?= =?UTF-8?q?=20apply=20round=201=20review=20nits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - error_wire.rs: extract `pub const ERROR_V1_ID = "error.v1"` + replace 9 inline literals (parallel to schema.rs::SCHEMA_V1_ID pattern). Re-export via kebab-app::lib.rs. - kebab-mcp/src/lib.rs: extract `KebabHandler::spawn_tool` helper — search + ask arms reduce from ~17 lines each to a one-line dispatch. Future tool 추가 시 boilerplate 안 늘림. - ask.rs: defensive `to_value(&answer)` — silent Null 위험 제거, 실패 시 to_tool_error fallthrough. - HOTFIXES: note AskOpts Default 미도입 limitation. - ARCHITECTURE.md: directory tree 의 kebab-mcp 항목에 `schema` 추가 (4 tool 모두 명시). Round 1 review summary: http://gitea.altair823.xyz/altair823-org/kebab/pulls/108#issuecomment-1855 Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-app/src/error_wire.rs | 22 +++++++------ crates/kebab-app/src/lib.rs | 2 +- crates/kebab-mcp/src/lib.rs | 50 ++++++++++++++---------------- crates/kebab-mcp/src/tools/ask.rs | 5 ++- docs/ARCHITECTURE.md | 2 +- tasks/HOTFIXES.md | 1 + 6 files changed, 44 insertions(+), 38 deletions(-) diff --git a/crates/kebab-app/src/error_wire.rs b/crates/kebab-app/src/error_wire.rs index 192cf7c..e1d91e1 100644 --- a/crates/kebab-app/src/error_wire.rs +++ b/crates/kebab-app/src/error_wire.rs @@ -11,6 +11,10 @@ use serde_json::{Value, json}; use crate::error_signal::{ConfigInvalid, LlmError, NotIndexed}; +/// Wire schema id for [`ErrorV1`]. Single source of truth — kebab-cli +/// + kebab-mcp use this via `kebab_app::ERROR_V1_ID`. +pub const ERROR_V1_ID: &str = "error.v1"; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ErrorV1 { pub schema_version: String, @@ -23,7 +27,7 @@ pub struct ErrorV1 { pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 { if let Some(s) = err.downcast_ref::() { return ErrorV1 { - schema_version: "error.v1".to_string(), + schema_version: ERROR_V1_ID.to_string(), code: "config_invalid".to_string(), message: s.to_string(), details: json!({ @@ -35,7 +39,7 @@ pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 { } if let Some(s) = err.downcast_ref::() { return ErrorV1 { - schema_version: "error.v1".to_string(), + schema_version: ERROR_V1_ID.to_string(), code: "not_indexed".to_string(), message: s.to_string(), details: json!({ @@ -50,7 +54,7 @@ pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 { } if let Some(io) = err.downcast_ref::() { return ErrorV1 { - schema_version: "error.v1".to_string(), + schema_version: ERROR_V1_ID.to_string(), code: "io_error".to_string(), message: io.to_string(), details: json!({"kind": format!("{:?}", io.kind())}), @@ -63,7 +67,7 @@ pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 { details = json!({"chain": chain}); } ErrorV1 { - schema_version: "error.v1".to_string(), + schema_version: ERROR_V1_ID.to_string(), code: "generic".to_string(), message: err.to_string(), details, @@ -74,7 +78,7 @@ pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 { fn classify_llm(s: &LlmError) -> ErrorV1 { match s { LlmError::Unreachable { endpoint, source } => ErrorV1 { - schema_version: "error.v1".to_string(), + schema_version: ERROR_V1_ID.to_string(), code: "model_unreachable".to_string(), message: format!("ollama unreachable at {endpoint}"), details: json!({ @@ -84,28 +88,28 @@ fn classify_llm(s: &LlmError) -> ErrorV1 { hint: Some(format!("ensure `ollama serve` is reachable at {endpoint}")), }, LlmError::ModelNotPulled(model) => ErrorV1 { - schema_version: "error.v1".to_string(), + schema_version: ERROR_V1_ID.to_string(), code: "model_not_pulled".to_string(), message: format!("ollama model `{model}` is not pulled"), details: json!({"model": model}), hint: Some(format!("run `ollama pull {model}`")), }, LlmError::Timeout(e) => ErrorV1 { - schema_version: "error.v1".to_string(), + schema_version: ERROR_V1_ID.to_string(), code: "timeout".to_string(), message: format!("ollama timeout: {e}"), details: json!({"source": e.to_string()}), hint: Some("increase timeout or check Ollama load".to_string()), }, LlmError::Stream(body) => ErrorV1 { - schema_version: "error.v1".to_string(), + schema_version: ERROR_V1_ID.to_string(), code: "generic".to_string(), message: format!("ollama HTTP error: {body}"), details: json!({"body": body}), hint: None, }, LlmError::Malformed(line) => ErrorV1 { - schema_version: "error.v1".to_string(), + schema_version: ERROR_V1_ID.to_string(), code: "generic".to_string(), message: format!("malformed response line: {line}"), details: json!({"line": line}), diff --git a/crates/kebab-app/src/lib.rs b/crates/kebab-app/src/lib.rs index 3c2740c..58c4062 100644 --- a/crates/kebab-app/src/lib.rs +++ b/crates/kebab-app/src/lib.rs @@ -66,7 +66,7 @@ pub mod schema; pub use app::App; pub use ingest_progress::{AggregateCounts, IngestEvent, render_skipped_breakdown}; pub use reset::{ResetReport, ResetScope}; -pub use error_wire::{ErrorV1, classify}; +pub use error_wire::{ERROR_V1_ID, ErrorV1, classify}; pub use schema::{Capabilities, Models, SCHEMA_V1_ID, SchemaV1, Stats, WireBlock, schema_with_config}; /// p9-fb-25: sentinel for files without an extension in diff --git a/crates/kebab-mcp/src/lib.rs b/crates/kebab-mcp/src/lib.rs index d50febf..a190673 100644 --- a/crates/kebab-mcp/src/lib.rs +++ b/crates/kebab-mcp/src/lib.rs @@ -67,6 +67,28 @@ impl KebabHandler { pub fn state(&self) -> &KebabAppState { &self.state } + + /// Spawn a tool handler on the blocking pool. Used by tools that + /// transitively touch reqwest::blocking::Client (search, ask) — calling + /// from the async dispatch directly panics inside the runtime. + async fn spawn_tool( + &self, + args: serde_json::Map, + handle: F, + ) -> Result + where + I: serde::de::DeserializeOwned + Send + 'static, + F: FnOnce(KebabAppState, I) -> CallToolResult + Send + 'static, + { + let input: I = match serde_json::from_value(serde_json::Value::Object(args)) { + Ok(i) => i, + Err(e) => return Ok(error::to_tool_error(&anyhow::Error::from(e))), + }; + let state = self.state.clone(); + tokio::task::spawn_blocking(move || handle(state, input)) + .await + .map_err(|e| ErrorData::internal_error(e.to_string(), None)) + } } impl ServerHandler for KebabHandler { @@ -99,41 +121,17 @@ impl ServerHandler for KebabHandler { } "search" => { let args = request.arguments.unwrap_or_default(); - let input: tools::search::SearchInput = - match serde_json::from_value(serde_json::Value::Object(args)) { - Ok(i) => i, - Err(e) => { - return Ok(error::to_tool_error(&anyhow::Error::from(e))); - } - }; - let state = self.state.clone(); - let result = tokio::task::spawn_blocking(move || { + self.spawn_tool(args, |state, input| { tools::search::handle(&state, input) }) .await - .map_err(|e| { - ErrorData::internal_error(e.to_string(), None) - })?; - Ok(result) } "ask" => { let args = request.arguments.unwrap_or_default(); - let input: tools::ask::AskInput = - match serde_json::from_value(serde_json::Value::Object(args)) { - Ok(i) => i, - Err(e) => { - return Ok(error::to_tool_error(&anyhow::Error::from(e))); - } - }; - let state = self.state.clone(); - let result = tokio::task::spawn_blocking(move || { + self.spawn_tool(args, |state, input| { tools::ask::handle(&state, input) }) .await - .map_err(|e| { - ErrorData::internal_error(e.to_string(), None) - })?; - Ok(result) } _other => Err(ErrorData::method_not_found::< rmcp::model::CallToolRequestMethod, diff --git a/crates/kebab-mcp/src/tools/ask.rs b/crates/kebab-mcp/src/tools/ask.rs index 96c74f7..283bf4f 100644 --- a/crates/kebab-mcp/src/tools/ask.rs +++ b/crates/kebab-mcp/src/tools/ask.rs @@ -50,7 +50,10 @@ pub fn handle(state: &KebabAppState, input: AskInput) -> CallToolResult { Ok(answer) => { // `Answer` does not carry `schema_version`; tag inline (idempotent // via entry().or_insert_with in case a future version adds it). - let mut v = serde_json::to_value(&answer).unwrap_or_default(); + let mut v = match serde_json::to_value(&answer) { + Ok(v) => v, + Err(e) => return to_tool_error(&anyhow::anyhow!("answer serialize failed: {e}")), + }; if let serde_json::Value::Object(ref mut map) = v { map.entry("schema_version".to_string()) .or_insert_with(|| serde_json::Value::String("answer.v1".to_string())); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4bb499f..0c12084 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -170,7 +170,7 @@ kebab/ │ ├── kebab-parse-pdf/ # lopdf per-page text extractor (P7-1) │ ├── kebab-app/ # facade (P0 시그니처 + P3-5/P6-4/P7-3 본체) │ ├── kebab-tui/ # Ratatui shell + Library 패널 (P9-1) -│ ├── kebab-mcp/ # stdio MCP server — tools: search, ask, doctor (P9-FB-30) +│ ├── kebab-mcp/ # stdio MCP server — tools: schema, doctor, search, ask (P9-FB-30) │ └── kebab-cli/ # binary (P0 → 핫픽스로 --config flag wiring 강화) ├── migrations/ # SQLite refinery V001/V002/V003 └── fixtures/ # 테스트 fixture 트리 diff --git a/tasks/HOTFIXES.md b/tasks/HOTFIXES.md index 27adfc8..6dc93e2 100644 --- a/tasks/HOTFIXES.md +++ b/tasks/HOTFIXES.md @@ -47,6 +47,7 @@ git history. - Server-scope state caching — 현재 매 tool call 마다 store open. 첫 call 시 `KebabAppState` 에 `OnceLock` 도입 검토 (post-merge 후속 PR). - rmcp SDK API 호환성 — 1.6 채택, 미래 major bump 시 별 task. - Manual `tools/list` + `tools/call` dispatch 채택 — rmcp 1.6 의 `#[tool_router]` 매크로보다 명시적, 디버깅 쉬움. 하지만 새 tool 추가 시 두 곳 (list_tools 의 vec + call_tool 의 match) 동시 갱신 필요. 후속 task 가 5개 이상 tool 추가하면 매크로 도입 재검토. +- `AskOpts` 가 `Default` 미도입 — kebab-cli + kebab-tui + kebab-mcp 의 모든 호출 site 가 9 field 를 명시적으로 초기화. 새 field 추가 시 모든 site 동시 갱신 필요. `impl Default for AskOpts` 또는 builder 패턴 도입은 별 PR. **Amends**: - design §10 (MCP transport subsection 추가).