Merge pull request 'feat(fb-30): MCP server (stdio) — agent integration MVP' (#108) from feat/p9-fb-30-mcp-server into main
Reviewed-on: #108
This commit was merged in pull request #108.
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
121
Cargo.lock
generated
121
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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<PathBuf>)` 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 <path>` 를 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()` 로 마이그레이션.
|
||||
|
||||
20
README.md
20
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 <id> --json` 으로 영속 — skill 이 conversation id 관리하면 외부 agent 도 `--repl` 없이 stateful 대화 가능 (p9-fb-18).
|
||||
- **Codex / 기타 agent host** — `--json` + frozen wire schema v1 가 stable contract. 동일 패턴으로 ~50줄 wrapper 작성 가능. `integrations/<host>/` 에 추가 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 참조.
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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::<ConfigInvalid>() {
|
||||
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::<NotIndexed>() {
|
||||
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::<std::io::Error>() {
|
||||
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}),
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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): {:?}",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}),
|
||||
|
||||
77
crates/kebab-cli/tests/cli_mcp_smoke.rs
Normal file
77
crates/kebab-cli/tests/cli_mcp_smoke.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
//! Spawn `target/debug/kebab mcp` and exercise initialize → tools/list.
|
||||
//!
|
||||
//! rmcp 1.6 has no public in-memory test transport, so this is the only
|
||||
//! end-to-end MCP assertion in the suite. The binary is located via
|
||||
//! `CARGO_BIN_EXE_kebab` which cargo injects at test compile time.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
#[test]
|
||||
fn cli_mcp_initialize_then_tools_list() {
|
||||
let bin = env!("CARGO_BIN_EXE_kebab");
|
||||
let mut child = Command::new(bin)
|
||||
.arg("mcp")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let mut reader = BufReader::new(stdout);
|
||||
|
||||
// rmcp 1.6 defaults to protocol version "2025-03-26" (confirmed by
|
||||
// manual smoke in Task 10). The server echoes whatever version the
|
||||
// client sends during the handshake, so this literal must match.
|
||||
let init_req = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}"#;
|
||||
writeln!(stdin, "{init_req}").unwrap();
|
||||
writeln!(
|
||||
stdin,
|
||||
r#"{{"jsonrpc":"2.0","method":"notifications/initialized"}}"#
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(
|
||||
stdin,
|
||||
r#"{{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{{}}}}"#
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Read initialize response.
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
let init: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
|
||||
assert_eq!(
|
||||
init.get("id").and_then(|i| i.as_i64()),
|
||||
Some(1),
|
||||
"unexpected id in initialize response: {init}"
|
||||
);
|
||||
assert!(
|
||||
init.get("result").is_some(),
|
||||
"initialize result missing: {init}"
|
||||
);
|
||||
|
||||
// Read tools/list response.
|
||||
line.clear();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
let list: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
|
||||
assert_eq!(
|
||||
list.get("id").and_then(|i| i.as_i64()),
|
||||
Some(2),
|
||||
"unexpected id in tools/list response: {list}"
|
||||
);
|
||||
let tools = list["result"]["tools"]
|
||||
.as_array()
|
||||
.expect("tools/list result.tools must be an array");
|
||||
assert_eq!(
|
||||
tools.len(),
|
||||
4,
|
||||
"expected 4 tools (schema, doctor, search, ask), got {}: {list}",
|
||||
tools.len()
|
||||
);
|
||||
|
||||
// Gracefully close stdin so the server shuts down cleanly.
|
||||
drop(stdin);
|
||||
let _ = child.wait().unwrap();
|
||||
}
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
27
crates/kebab-mcp/Cargo.toml
Normal file
27
crates/kebab-mcp/Cargo.toml
Normal file
@@ -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 }
|
||||
22
crates/kebab-mcp/src/error.rs
Normal file
22
crates/kebab-mcp/src/error.rs
Normal file
@@ -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)])
|
||||
}
|
||||
164
crates/kebab-mcp/src/lib.rs
Normal file
164
crates/kebab-mcp/src/lib.rs
Normal file
@@ -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<Tool> {
|
||||
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::<tools::search::SearchInput>(),
|
||||
),
|
||||
Tool::new(
|
||||
"ask",
|
||||
"RAG question answering over the knowledge base. Returns answer.v1 JSON. Pass session_id for multi-turn context.",
|
||||
schema_for_type::<tools::ask::AskInput>(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[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<I, F>(
|
||||
&self,
|
||||
args: serde_json::Map<String, serde_json::Value>,
|
||||
handle: F,
|
||||
) -> Result<CallToolResult, ErrorData>
|
||||
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<rmcp::model::PaginatedRequestParams>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListToolsResult, ErrorData> {
|
||||
Ok(ListToolsResult::with_all_items(build_tools_vec()))
|
||||
}
|
||||
|
||||
async fn call_tool(
|
||||
&self,
|
||||
request: CallToolRequestParams,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
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 <path>`, if any.
|
||||
/// It is forwarded to `KebabAppState` so the doctor tool can honour the
|
||||
/// same config file the server was started with (falls back to XDG default
|
||||
/// when `None`).
|
||||
pub fn serve_stdio(cfg: Config, config_path: Option<PathBuf>) -> Result<()> {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
runtime.block_on(serve_stdio_async(cfg, config_path))
|
||||
}
|
||||
|
||||
async fn serve_stdio_async(cfg: Config, config_path: Option<PathBuf>) -> 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(())
|
||||
}
|
||||
26
crates/kebab-mcp/src/state.rs
Normal file
26
crates/kebab-mcp/src/state.rs
Normal file
@@ -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>,
|
||||
/// `--config <path>` from CLI when present, else `None` (XDG default
|
||||
/// fallback applies in `doctor_with_config_path`).
|
||||
pub config_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl KebabAppState {
|
||||
pub fn new(config: Config, config_path: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
config: Arc::new(config),
|
||||
config_path,
|
||||
}
|
||||
}
|
||||
}
|
||||
68
crates/kebab-mcp/src/tools/ask.rs
Normal file
68
crates/kebab-mcp/src/tools/ask.rs
Normal file
@@ -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<String>,
|
||||
/// Optional retrieval mode override ("lexical" / "vector" / "hybrid"). Default "hybrid".
|
||||
pub mode: Option<String>,
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
28
crates/kebab-mcp/src/tools/doctor.rs
Normal file
28
crates/kebab-mcp/src/tools/doctor.rs
Normal file
@@ -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 <path>` 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),
|
||||
}
|
||||
}
|
||||
6
crates/kebab-mcp/src/tools/mod.rs
Normal file
6
crates/kebab-mcp/src/tools/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
//! Tool implementations — one module per tool.
|
||||
|
||||
pub mod schema;
|
||||
pub mod doctor;
|
||||
pub mod search;
|
||||
pub mod ask;
|
||||
22
crates/kebab-mcp/src/tools/schema.rs
Normal file
22
crates/kebab-mcp/src/tools/schema.rs
Normal file
@@ -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),
|
||||
}
|
||||
}
|
||||
71
crates/kebab-mcp/src/tools/search.rs
Normal file
71
crates/kebab-mcp/src/tools/search.rs
Normal file
@@ -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::<SearchInput>()`.
|
||||
|
||||
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<serde_json::Value> = 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),
|
||||
}
|
||||
}
|
||||
36
crates/kebab-mcp/tests/error_mapping.rs
Normal file
36
crates/kebab-mcp/tests/error_mapping.rs
Normal file
@@ -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"));
|
||||
}
|
||||
19
crates/kebab-mcp/tests/initialize.rs
Normal file
19
crates/kebab-mcp/tests/initialize.rs
Normal file
@@ -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());
|
||||
}
|
||||
92
crates/kebab-mcp/tests/tools_call_ask.rs
Normal file
92
crates/kebab-mcp/tests/tools_call_ask.rs
Normal file
@@ -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"
|
||||
);
|
||||
}
|
||||
50
crates/kebab-mcp/tests/tools_call_doctor.rs
Normal file
50
crates/kebab-mcp/tests/tools_call_doctor.rs
Normal file
@@ -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}"
|
||||
);
|
||||
}
|
||||
75
crates/kebab-mcp/tests/tools_call_schema.rs
Normal file
75
crates/kebab-mcp/tests/tools_call_schema.rs
Normal file
@@ -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<RawContent>; 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",
|
||||
);
|
||||
}
|
||||
90
crates/kebab-mcp/tests/tools_call_search.rs
Normal file
90
crates/kebab-mcp/tests/tools_call_search.rs
Normal file
@@ -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"
|
||||
);
|
||||
}
|
||||
68
crates/kebab-mcp/tests/tools_list.rs
Normal file
68
crates/kebab-mcp/tests/tools_list.rs
Normal file
@@ -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<JsonObject> (serde_json::Map<String, Value>).
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ flowchart TB
|
||||
subgraph UI ["UI binary"]
|
||||
cli["kebab-cli"]
|
||||
tui["kebab-tui"]
|
||||
mcp["kebab-mcp<br/>(P9-FB-30)"]
|
||||
desktop["kebab-desktop<br/>(P9-5)"]
|
||||
end
|
||||
app["kebab-app<br/>(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 트리
|
||||
|
||||
@@ -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. 동결 범위 / 변경 정책
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -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 <path>` honor.
|
||||
- 신규 crate `kebab-mcp` (lib only) — `serve_stdio(Config, Option<PathBuf>)` 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: <CARGO_PKG_VERSION> }`.
|
||||
- `KebabAppState` 가 `(Config, Option<PathBuf>)` 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/<id>` 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<SqliteStore>` 도입 검토 (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 분기 필요.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 증상 / 동기
|
||||
|
||||
|
||||
Reference in New Issue
Block a user