feat(mcp): kebab__fetch tool — chunk / doc / span (fb-35)
Mirrors CLI surface: same input shape, same fetch_result.v1 output. invalid_input error for missing kind-specific fields. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<Tool> {
|
||||
"Ingest markdown content into the knowledge base. v1 markdown only. Frontmatter (title + source_uri) auto-injected.",
|
||||
schema_for_type::<tools::ingest_stdin::IngestStdinInput>(),
|
||||
),
|
||||
Tool::new(
|
||||
"fetch",
|
||||
"Verbatim fetch — chunk / doc / span modes. Returns fetch_result.v1 with the indexed text (no LLM rewrite).",
|
||||
schema_for_type::<tools::fetch::FetchInput>(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
>()),
|
||||
|
||||
99
crates/kebab-mcp/src/tools/fetch.rs
Normal file
99
crates/kebab-mcp/src/tools/fetch.rs
Normal file
@@ -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 <kind> ...`): 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<String>,
|
||||
/// Required when kind = "doc" or "span".
|
||||
pub doc_id: Option<String>,
|
||||
/// Required when kind = "span" (1-based, inclusive).
|
||||
pub line_start: Option<u32>,
|
||||
pub line_end: Option<u32>,
|
||||
/// chunk only: ±N surrounding chunks.
|
||||
pub context: Option<u32>,
|
||||
/// doc/span only: chars/4 budget.
|
||||
pub max_tokens: Option<usize>,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -6,3 +6,4 @@ pub mod search;
|
||||
pub mod ask;
|
||||
pub mod ingest_file;
|
||||
pub mod ingest_stdin;
|
||||
pub mod fetch;
|
||||
|
||||
215
crates/kebab-mcp/tests/tools_call_fetch.rs
Normal file
215
crates/kebab-mcp/tests/tools_call_fetch.rs
Normal file
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user