From 8ca8e18d128ced3b6d92e62c22f1cb9a5682aa0a Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 7 May 2026 15:38:00 +0900 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(kebab-mcp):=20schema=20tool=20?= =?UTF-8?q?(fb-30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First tool wired — `schema` (no input args, returns schema.v1 JSON mirroring `kebab schema --json`). Establishes the per-tool module pattern (crates/kebab-mcp/src/tools/.rs) + error helper that maps anyhow::Error to MCP CallToolResult.error with error.v1 content. Dispatch pattern: manual dispatch — explicit `list_tools` + `call_tool` overrides on `impl ServerHandler for KebabHandler` with a `match request.name.as_ref()` arm per tool. No proc-macro magic. Tasks 5-7 should add a new arm + new tools/.rs following the same pattern; also add a `Tool::new(...)` entry in `list_tools`. API shapes confirmed from rmcp 1.6 source: - Content = Annotated; text via `Content::text(s)`; pattern match via `&content.raw` → `RawContent::Text(t)` → `t.text` - CallToolResult::success(Vec) / ::error(Vec) - ListToolsResult::with_all_items(Vec) - schema_for_empty_input() from rmcp::handler::server::common Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-mcp/src/error.rs | 22 +++++++ crates/kebab-mcp/src/lib.rs | 39 +++++++++++- crates/kebab-mcp/src/tools/mod.rs | 6 ++ crates/kebab-mcp/src/tools/schema.rs | 22 +++++++ crates/kebab-mcp/tests/tools_call_schema.rs | 70 +++++++++++++++++++++ 5 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 crates/kebab-mcp/src/error.rs create mode 100644 crates/kebab-mcp/src/tools/mod.rs create mode 100644 crates/kebab-mcp/src/tools/schema.rs create mode 100644 crates/kebab-mcp/tests/tools_call_schema.rs diff --git a/crates/kebab-mcp/src/error.rs b/crates/kebab-mcp/src/error.rs new file mode 100644 index 0000000..b12e5ac --- /dev/null +++ b/crates/kebab-mcp/src/error.rs @@ -0,0 +1,22 @@ +//! Map `anyhow::Error` returned by kebab-app facades to MCP +//! `CallToolResult` with `isError: true` + error.v1 JSON content. + +use rmcp::model::{CallToolResult, Content}; + +use kebab_app::classify; + +/// Convert an `anyhow::Error` to a `CallToolResult` with `isError: true` +/// and the serialised `error.v1` envelope as the text content. +pub fn to_tool_error(err: &anyhow::Error) -> CallToolResult { + let v1 = classify(err, false); + let body = serde_json::to_string(&v1).unwrap_or_else(|_| { + r#"{"schema_version":"error.v1","code":"generic","message":"serialize failed"}"# + .to_string() + }); + CallToolResult::error(vec![Content::text(body)]) +} + +/// Wrap a successful wire-schema JSON string as a `CallToolResult`. +pub fn to_tool_success(json: String) -> CallToolResult { + CallToolResult::success(vec![Content::text(json)]) +} diff --git a/crates/kebab-mcp/src/lib.rs b/crates/kebab-mcp/src/lib.rs index 70f0326..efa4d8a 100644 --- a/crates/kebab-mcp/src/lib.rs +++ b/crates/kebab-mcp/src/lib.rs @@ -7,13 +7,20 @@ use anyhow::Result; use rmcp::ServerHandler; -use rmcp::model::{Implementation, ServerCapabilities, ServerInfo}; -use rmcp::service::ServiceExt; +use rmcp::handler::server::common::schema_for_empty_input; +use rmcp::model::{ + CallToolRequestParams, CallToolResult, Implementation, ListToolsResult, ServerCapabilities, + ServerInfo, Tool, +}; +use rmcp::service::{RequestContext, ServiceExt}; use rmcp::transport::stdio; +use rmcp::{ErrorData, RoleServer}; use kebab_config::Config; +pub mod error; pub mod state; +pub mod tools; pub use state::KebabAppState; #[derive(Clone)] @@ -36,6 +43,34 @@ impl ServerHandler for KebabHandler { ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) .with_server_info(Implementation::new("kebab", env!("CARGO_PKG_VERSION"))) } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListToolsResult::with_all_items(vec![Tool::new( + "schema", + "Introspection — wire schemas, capabilities, model versions, index stats.", + schema_for_empty_input(), + )])) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + match request.name.as_ref() { + "schema" => { + let input = tools::schema::SchemaInput::default(); + Ok(tools::schema::handle(&self.state, input)) + } + _other => Err(ErrorData::method_not_found::< + rmcp::model::CallToolRequestMethod, + >()), + } + } } /// Run the MCP server on stdio JSON-RPC. Blocks until the client closes diff --git a/crates/kebab-mcp/src/tools/mod.rs b/crates/kebab-mcp/src/tools/mod.rs new file mode 100644 index 0000000..1db1210 --- /dev/null +++ b/crates/kebab-mcp/src/tools/mod.rs @@ -0,0 +1,6 @@ +//! Tool implementations — one module per tool. + +pub mod schema; +// pub mod doctor; // wired in Plan Task 5 +// pub mod search; // wired in Plan Task 6 +// pub mod ask; // wired in Plan Task 7 diff --git a/crates/kebab-mcp/src/tools/schema.rs b/crates/kebab-mcp/src/tools/schema.rs new file mode 100644 index 0000000..2bf5e34 --- /dev/null +++ b/crates/kebab-mcp/src/tools/schema.rs @@ -0,0 +1,22 @@ +//! `schema` tool — wraps `kebab_app::schema_with_config`. +//! Input: {} (no args). Output: schema.v1 JSON. + +use rmcp::model::CallToolResult; +use serde::{Deserialize, Serialize}; +use schemars::JsonSchema; + +use crate::error::{to_tool_error, to_tool_success}; +use crate::state::KebabAppState; + +#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)] +pub struct SchemaInput {} + +pub fn handle(state: &KebabAppState, _input: SchemaInput) -> CallToolResult { + match kebab_app::schema_with_config(&state.config) { + Ok(report) => match serde_json::to_string(&report) { + Ok(json) => to_tool_success(json), + Err(e) => to_tool_error(&anyhow::anyhow!(e)), + }, + Err(e) => to_tool_error(&e), + } +} diff --git a/crates/kebab-mcp/tests/tools_call_schema.rs b/crates/kebab-mcp/tests/tools_call_schema.rs new file mode 100644 index 0000000..628f17f --- /dev/null +++ b/crates/kebab-mcp/tests/tools_call_schema.rs @@ -0,0 +1,70 @@ +//! Integration: tools/call name=schema — verify response is schema.v1. + +use std::fs; + +use kebab_config::Config; +use kebab_core::SourceScope; +use kebab_mcp::{KebabAppState, KebabHandler}; +use rmcp::model::RawContent; + +fn minimal_config(data_dir: &std::path::Path, workspace_root: &std::path::Path) -> Config { + let mut cfg = Config::defaults(); + cfg.storage.data_dir = data_dir.to_string_lossy().into_owned(); + cfg.storage.model_dir = data_dir + .join("models") + .to_string_lossy() + .into_owned(); + cfg.workspace.root = workspace_root.to_string_lossy().into_owned(); + cfg.workspace.exclude.clear(); + cfg.models.embedding.provider = "none".to_string(); + cfg.models.embedding.dimensions = 0; + cfg +} + +#[tokio::test] +async fn schema_tool_returns_schema_v1_json() { + let dir = tempfile::tempdir().unwrap(); + let data_dir = dir.path().join("data"); + let workspace_root = dir.path().join("notes"); + fs::create_dir_all(&data_dir).unwrap(); + fs::create_dir_all(&workspace_root).unwrap(); + + let config = minimal_config(&data_dir, &workspace_root); + + // Seed kebab.sqlite via 0-file ingest so open_existing succeeds later. + let scope = SourceScope { + root: workspace_root.clone(), + include: vec![], + exclude: vec![], + }; + let _ = kebab_app::ingest_with_config(config.clone(), scope, false).unwrap(); + + let state = KebabAppState::new(config); + let handler = KebabHandler::new(state); + + let result = kebab_mcp::tools::schema::handle( + handler.state(), + kebab_mcp::tools::schema::SchemaInput::default(), + ); + + assert!( + !result.is_error.unwrap_or(false), + "expected isError=false on healthy schema, got {:?}", + result + ); + + let content = result.content.first().expect("expected at least one content item"); + + // Content = Annotated; deref to get the inner RawContent. + let text = match &content.raw { + RawContent::Text(t) => &t.text, + other => panic!("expected text content, got {other:?}"), + }; + + let v: serde_json::Value = serde_json::from_str(text).unwrap(); + assert_eq!( + v.get("schema_version").and_then(|s| s.as_str()), + Some("schema.v1"), + "unexpected schema_version in: {v}" + ); +}