diff --git a/crates/kebab-mcp/src/lib.rs b/crates/kebab-mcp/src/lib.rs index fc6a2a4..93f39df 100644 --- a/crates/kebab-mcp/src/lib.rs +++ b/crates/kebab-mcp/src/lib.rs @@ -1,6 +1,7 @@ -//! MCP (Model Context Protocol) server over stdio. Exposes 6 tools -//! (`search` / `ask` / `schema` / `doctor` / `ingest_file` / `ingest_stdin`) -//! backed by `kebab-app` facade methods. Used by `kebab-cli`'s `Cmd::Mcp` arm. +//! MCP (Model Context Protocol) server over stdio. Exposes 7 tools +//! (`search` / `ask` / `schema` / `doctor` / `ingest_file` / `ingest_stdin` +//! / `fetch`) 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`. @@ -61,6 +62,11 @@ pub fn build_tools_vec() -> Vec { "Ingest markdown content into the knowledge base. v1 markdown only. Frontmatter (title + source_uri) auto-injected.", schema_for_type::(), ), + Tool::new( + "fetch", + "Verbatim fetch — chunk / doc / span modes. Returns fetch_result.v1 with the indexed text (no LLM rewrite).", + schema_for_type::(), + ), ] } @@ -157,6 +163,13 @@ impl ServerHandler for KebabHandler { }) .await } + "fetch" => { + let args = request.arguments.unwrap_or_default(); + self.spawn_tool(args, |state, input| { + tools::fetch::handle(&state, input) + }) + .await + } _other => Err(ErrorData::method_not_found::< rmcp::model::CallToolRequestMethod, >()), diff --git a/crates/kebab-mcp/src/tools/fetch.rs b/crates/kebab-mcp/src/tools/fetch.rs new file mode 100644 index 0000000..3f0ea5b --- /dev/null +++ b/crates/kebab-mcp/src/tools/fetch.rs @@ -0,0 +1,99 @@ +//! p9-fb-35 `fetch` tool — wraps `kebab_app::fetch_with_config`. +//! +//! Three modes (chunk / doc / span). Output is `fetch_result.v1`. +//! +//! Mirrors the CLI surface (`kebab fetch ...`): same input shape, +//! same wire envelope. Missing kind-specific fields produce an `error.v1` +//! with `code = "invalid_input"`. + +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 FetchInput { + /// "chunk" | "doc" | "span" + pub kind: String, + /// Required when kind = "chunk". + pub chunk_id: Option, + /// Required when kind = "doc" or "span". + pub doc_id: Option, + /// Required when kind = "span" (1-based, inclusive). + pub line_start: Option, + pub line_end: Option, + /// chunk only: ±N surrounding chunks. + pub context: Option, + /// doc/span only: chars/4 budget. + pub max_tokens: Option, +} + +pub fn handle(state: &KebabAppState, input: FetchInput) -> CallToolResult { + let query = match input.kind.as_str() { + "chunk" => match input.chunk_id { + Some(id) => kebab_core::FetchQuery::Chunk(kebab_core::ChunkId(id)), + None => return invalid_input("kind=chunk requires chunk_id"), + }, + "doc" => match input.doc_id { + Some(id) => kebab_core::FetchQuery::Doc(kebab_core::DocumentId(id)), + None => return invalid_input("kind=doc requires doc_id"), + }, + "span" => match (input.doc_id, input.line_start, input.line_end) { + (Some(id), Some(start), Some(end)) => kebab_core::FetchQuery::Span { + doc_id: kebab_core::DocumentId(id), + line_start: start, + line_end: end, + }, + _ => return invalid_input("kind=span requires doc_id, line_start, line_end"), + }, + other => { + return invalid_input(&format!( + "unknown kind '{other}'; expected chunk|doc|span" + )); + } + }; + + let opts = kebab_core::FetchOpts { + context: input.context, + max_tokens: input.max_tokens, + }; + + let cfg_clone = (*state.config).clone(); + match kebab_app::fetch_with_config(cfg_clone, query, opts) { + Ok(r) => { + // FetchResult does not carry a `schema_version` field, so we + // tag the envelope inline (mirrors search.rs's pattern). + let mut v = match serde_json::to_value(&r) { + Ok(v) => v, + Err(e) => { + return to_tool_error(&anyhow::anyhow!("FetchResult serialize: {e}")); + } + }; + if let serde_json::Value::Object(ref mut map) = v { + map.insert( + "schema_version".to_string(), + serde_json::Value::String("fetch_result.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), + } +} + +fn invalid_input(msg: &str) -> CallToolResult { + use kebab_app::{ErrorV1, StructuredError}; + let err = anyhow::Error::new(StructuredError(ErrorV1 { + schema_version: "error.v1".to_string(), + code: "invalid_input".to_string(), + message: msg.to_string(), + details: serde_json::Value::Null, + hint: None, + })); + to_tool_error(&err) +} diff --git a/crates/kebab-mcp/src/tools/mod.rs b/crates/kebab-mcp/src/tools/mod.rs index 087d630..22b5569 100644 --- a/crates/kebab-mcp/src/tools/mod.rs +++ b/crates/kebab-mcp/src/tools/mod.rs @@ -6,3 +6,4 @@ pub mod search; pub mod ask; pub mod ingest_file; pub mod ingest_stdin; +pub mod fetch; diff --git a/crates/kebab-mcp/tests/tools_call_fetch.rs b/crates/kebab-mcp/tests/tools_call_fetch.rs new file mode 100644 index 0000000..5627e93 --- /dev/null +++ b/crates/kebab-mcp/tests/tools_call_fetch.rs @@ -0,0 +1,215 @@ +//! p9-fb-35: tools/call name=fetch — chunk happy path + invalid_input. +//! +//! Mirrors `tools_call_search.rs` setup: a TempDir KB with embedding +//! provider = "none" (no Ollama / fastembed) and a single ingested +//! markdown doc. We discover a `chunk_id` via the search tool, call +//! `fetch` with it, then exercise the missing-arg branch separately. + +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 fetch_tool_chunk_returns_fetch_result_v1() { + 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); + + fs::write( + workspace_root.join("a.md"), + "# Alpha\n\nThis document mentions kebab and bread.", + ) + .unwrap(); + + 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); + + // Discover a chunk_id via the search tool. + let search_result = kebab_mcp::tools::search::handle( + handler.state(), + kebab_mcp::tools::search::SearchInput { + query: "kebab".to_string(), + mode: Some("lexical".to_string()), + k: Some(1), + max_tokens: None, + snippet_chars: None, + cursor: None, + }, + ); + let search_text = match &search_result.content.first().unwrap().raw { + RawContent::Text(t) => t.text.clone(), + other => panic!("expected text content, got {other:?}"), + }; + let search_v: serde_json::Value = serde_json::from_str(&search_text).unwrap(); + let chunk_id = search_v["hits"][0]["chunk_id"] + .as_str() + .expect("chunk_id on first hit") + .to_string(); + + // Call fetch with kind=chunk. + let result = kebab_mcp::tools::fetch::handle( + handler.state(), + kebab_mcp::tools::fetch::FetchInput { + kind: "chunk".to_string(), + chunk_id: Some(chunk_id), + doc_id: None, + line_start: None, + line_end: None, + context: None, + max_tokens: None, + }, + ); + + 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(); + assert_eq!( + v.get("schema_version").and_then(|s| s.as_str()), + Some("fetch_result.v1"), + "envelope must carry schema_version=fetch_result.v1" + ); + assert_eq!( + v.get("kind").and_then(|s| s.as_str()), + Some("chunk"), + "kind must be 'chunk'" + ); + assert!( + v.get("chunk").is_some_and(|c| c.is_object()), + "chunk payload must be populated for kind=chunk" + ); +} + +#[tokio::test] +async fn fetch_tool_invalid_kind_returns_invalid_input() { + 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); + + let state = KebabAppState::new(config, None); + let handler = KebabHandler::new(state); + + let result = kebab_mcp::tools::fetch::handle( + handler.state(), + kebab_mcp::tools::fetch::FetchInput { + kind: "garbage".to_string(), + chunk_id: None, + doc_id: None, + line_start: None, + line_end: None, + context: None, + max_tokens: None, + }, + ); + + assert!( + result.is_error.unwrap_or(false), + "expected isError=true for unknown kind" + ); + 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("error.v1"), + "must carry error.v1 envelope" + ); + assert_eq!( + v.get("code").and_then(|s| s.as_str()), + Some("invalid_input"), + "code must be invalid_input for unknown kind" + ); +} + +#[tokio::test] +async fn fetch_tool_chunk_missing_id_returns_invalid_input() { + 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); + + let state = KebabAppState::new(config, None); + let handler = KebabHandler::new(state); + + // kind=chunk but no chunk_id — invalid_input. + let result = kebab_mcp::tools::fetch::handle( + handler.state(), + kebab_mcp::tools::fetch::FetchInput { + kind: "chunk".to_string(), + chunk_id: None, + doc_id: None, + line_start: None, + line_end: None, + context: None, + max_tokens: None, + }, + ); + + assert!( + result.is_error.unwrap_or(false), + "expected isError=true when chunk_id is missing" + ); + 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("code").and_then(|s| s.as_str()), + Some("invalid_input") + ); +} diff --git a/crates/kebab-mcp/tests/tools_list.rs b/crates/kebab-mcp/tests/tools_list.rs index 01bfe6e..c56c4a3 100644 --- a/crates/kebab-mcp/tests/tools_list.rs +++ b/crates/kebab-mcp/tests/tools_list.rs @@ -1,13 +1,13 @@ -//! Integration: `build_tools_vec` returns 6 tools with correct names and +//! Integration: `build_tools_vec` returns 7 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_six_tools() { +fn tools_list_returns_seven_tools() { let tools = build_tools_vec(); - assert_eq!(tools.len(), 6, "expected exactly 6 tools, got {}", tools.len()); + assert_eq!(tools.len(), 7, "expected exactly 7 tools, got {}", tools.len()); let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); assert!(names.contains(&"schema"), "missing 'schema' tool"); @@ -16,6 +16,7 @@ fn tools_list_returns_six_tools() { assert!(names.contains(&"ask"), "missing 'ask' tool"); assert!(names.contains(&"ingest_file"), "missing 'ingest_file' tool"); assert!(names.contains(&"ingest_stdin"), "missing 'ingest_stdin' tool"); + assert!(names.contains(&"fetch"), "missing 'fetch' tool"); } #[test]