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(),
--
2.49.1
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")
+}
--
2.49.1
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());
+}
--
2.49.1
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}"
+ );
+}
--
2.49.1
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(
--
2.49.1
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"
+ );
+}
--
2.49.1
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"
+ );
+}
--
2.49.1
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()),
},
)
})
--
2.49.1
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"));
+}
--
2.49.1
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"}),
--
2.49.1
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:?}"
+ );
+ }
+ }
+}
--
2.49.1
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?;
--
2.49.1
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)"
);
}
--
2.49.1
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:**
--
2.49.1
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.
## 증상 / 동기
--
2.49.1
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 트리
--
2.49.1
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 추가).
--
2.49.1