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/Cargo.lock b/Cargo.lock index b9c82ed..fe7bb5b 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" @@ -3516,6 +3550,7 @@ dependencies = [ "kebab-store-vector", "lopdf", "lru", + "reqwest", "rusqlite", "serde", "serde_json", @@ -3557,8 +3592,8 @@ dependencies = [ "kebab-config", "kebab-core", "kebab-eval", + "kebab-mcp", "kebab-tui", - "reqwest", "serde", "serde_json", "tempfile", @@ -3666,6 +3701,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 +5509,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 +6364,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 +6604,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 +6712,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/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/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 90% rename from crates/kebab-cli/src/error_classify.rs rename to crates/kebab-app/src/error_wire.rs index a82ddd4..e1d91e1 100644 --- a/crates/kebab-cli/src/error_classify.rs +++ b/crates/kebab-app/src/error_wire.rs @@ -9,10 +9,15 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use kebab_app::error_signal::{ConfigInvalid, LlmError, NotIndexed}; +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, pub code: String, pub message: String, pub details: Value, @@ -22,6 +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_ID.to_string(), code: "config_invalid".to_string(), message: s.to_string(), details: json!({ @@ -33,6 +39,7 @@ pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 { } if let Some(s) = err.downcast_ref::() { return ErrorV1 { + schema_version: ERROR_V1_ID.to_string(), code: "not_indexed".to_string(), message: s.to_string(), details: json!({ @@ -47,6 +54,7 @@ pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 { } if let Some(io) = err.downcast_ref::() { return ErrorV1 { + schema_version: ERROR_V1_ID.to_string(), code: "io_error".to_string(), message: io.to_string(), details: json!({"kind": format!("{:?}", io.kind())}), @@ -59,6 +67,7 @@ pub fn classify(err: &anyhow::Error, verbose: bool) -> ErrorV1 { details = json!({"chain": chain}); } ErrorV1 { + schema_version: ERROR_V1_ID.to_string(), code: "generic".to_string(), message: err.to_string(), details, @@ -69,6 +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_ID.to_string(), code: "model_unreachable".to_string(), message: format!("ollama unreachable at {endpoint}"), details: json!({ @@ -78,24 +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_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_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_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_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 cb26402..58c4062 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::{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-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/Cargo.toml b/crates/kebab-cli/Cargo.toml index cf97e6d..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 } @@ -44,6 +46,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..db0f4b1 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; @@ -189,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)] @@ -282,7 +286,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() @@ -740,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/src/wire.rs b/crates/kebab-cli/src/wire.rs index cbeef3c..8fd58e7 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,8 +262,9 @@ mod tests { #[test] fn error_wrapper_tags_schema_version_and_emits_code() { - use crate::error_classify::ErrorV1; + 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"}), 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-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)" ); } 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/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 new file mode 100644 index 0000000..a190673 --- /dev/null +++ b/crates/kebab-mcp/src/lib.rs @@ -0,0 +1,164 @@ +//! 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 std::path::PathBuf; + +use anyhow::Result; + +use rmcp::ServerHandler; +use rmcp::handler::server::common::{schema_for_empty_input, schema_for_type}; +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; + +/// 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, +} + +impl KebabHandler { + pub fn new(state: KebabAppState) -> Self { + Self { state } + } + + 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 { + fn get_info(&self) -> ServerInfo { + 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(build_tools_vec())) + } + + 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)) + } + "doctor" => { + let input = tools::doctor::DoctorInput::default(); + Ok(tools::doctor::handle(&self.state, input)) + } + "search" => { + let args = request.arguments.unwrap_or_default(); + self.spawn_tool(args, |state, input| { + tools::search::handle(&state, input) + }) + .await + } + "ask" => { + let args = request.arguments.unwrap_or_default(); + self.spawn_tool(args, |state, input| { + tools::ask::handle(&state, input) + }) + .await + } + _other => Err(ErrorData::method_not_found::< + rmcp::model::CallToolRequestMethod, + >()), + } + } +} + +/// Run the MCP server on stdio JSON-RPC. Blocks until the client closes +/// the stream (typically when the agent host exits). +/// +/// `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, config_path)) +} + +async fn serve_stdio_async(cfg: Config, config_path: Option) -> Result<()> { + tracing::info!("kebab-mcp: starting stdio server"); + let state = KebabAppState::new(cfg, config_path); + 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..30debf0 --- /dev/null +++ b/crates/kebab-mcp/src/state.rs @@ -0,0 +1,26 @@ +//! 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::path::PathBuf; +use std::sync::Arc; + +use kebab_config::Config; + +#[derive(Clone)] +pub struct KebabAppState { + pub config: Arc, + /// `--config ` from CLI when present, else `None` (XDG default + /// fallback applies in `doctor_with_config_path`). + pub config_path: Option, +} + +impl KebabAppState { + pub fn new(config: Config, config_path: Option) -> Self { + Self { + config: Arc::new(config), + config_path, + } + } +} diff --git a/crates/kebab-mcp/src/tools/ask.rs b/crates/kebab-mcp/src/tools/ask.rs new file mode 100644 index 0000000..283bf4f --- /dev/null +++ b/crates/kebab-mcp/src/tools/ask.rs @@ -0,0 +1,68 @@ +//! `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?, 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`. + +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, + /// Optional retrieval mode override ("lexical" / "vector" / "hybrid"). Default "hybrid". + pub mode: Option, +} + +pub fn handle(state: &KebabAppState, input: AskInput) -> CallToolResult { + 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, + 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 = 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())); + } + 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/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 new file mode 100644 index 0000000..3e3d898 --- /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; +pub mod search; +pub mod ask; 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/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/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")); +} diff --git a/crates/kebab-mcp/tests/initialize.rs b/crates/kebab-mcp/tests/initialize.rs new file mode 100644 index 0000000..8a360cb --- /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, None); + 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()); +} 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..09657d1 --- /dev/null +++ b/crates/kebab-mcp/tests/tools_call_ask.rs @@ -0,0 +1,92 @@ +//! `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, + // Test env uses provider="none" — Hybrid would hard-error on embedding. + // Pass Lexical explicitly so the test stays functional. + mode: Some("lexical".to_string()), + }, + ) + }) + .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" + ); +} 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 new file mode 100644 index 0000000..c47a874 --- /dev/null +++ b/crates/kebab-mcp/tests/tools_call_schema.rs @@ -0,0 +1,75 @@ +//! 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, None); + 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}" + ); + 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/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" + ); +} 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:?}" + ); + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1e13e38..0c12084 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: 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/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:** diff --git a/tasks/HOTFIXES.md b/tasks/HOTFIXES.md index 8a95d1d..6dc93e2 100644 --- a/tasks/HOTFIXES.md +++ b/tasks/HOTFIXES.md @@ -14,6 +14,46 @@ 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 추가하면 매크로 도입 재검토. +- `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 추가). +- 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. ## 증상 / 동기