✨ feat(kebab-mcp): doctor tool (fb-30)
Second tool — `doctor` (no input args, returns doctor.v1 JSON via kebab_app::doctor_with_config_path). Mirrors schema tool's manual-dispatch pattern: Tool::new entry in list_tools, match arm in call_tool, per-tool module in tools/doctor.rs. doctor_with_config_path takes Option<&Path> (not &Config), so KebabAppState is extended with config_path: Option<PathBuf>. All existing callers (initialize.rs, tools_call_schema.rs, serve_stdio_async) pass None for now; Plan Task 10 (Cmd::Mcp wiring) will thread the actual --config path through. doctor_with_config falls back to XDG default when config_path is None — same behavior as bare `kebab doctor`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -49,11 +49,18 @@ impl ServerHandler for KebabHandler {
|
||||
_request: Option<rmcp::model::PaginatedRequestParams>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListToolsResult, ErrorData> {
|
||||
Ok(ListToolsResult::with_all_items(vec![Tool::new(
|
||||
"schema",
|
||||
"Introspection — wire schemas, capabilities, model versions, index stats.",
|
||||
schema_for_empty_input(),
|
||||
)]))
|
||||
Ok(ListToolsResult::with_all_items(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(),
|
||||
),
|
||||
]))
|
||||
}
|
||||
|
||||
async fn call_tool(
|
||||
@@ -66,6 +73,10 @@ impl ServerHandler for KebabHandler {
|
||||
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))
|
||||
}
|
||||
_other => Err(ErrorData::method_not_found::<
|
||||
rmcp::model::CallToolRequestMethod,
|
||||
>()),
|
||||
@@ -84,7 +95,7 @@ pub fn serve_stdio(cfg: Config) -> Result<()> {
|
||||
|
||||
async fn serve_stdio_async(cfg: Config) -> Result<()> {
|
||||
tracing::info!("kebab-mcp: starting stdio server");
|
||||
let state = KebabAppState::new(cfg);
|
||||
let state = KebabAppState::new(cfg, None); // Plan Task 10 will thread the actual path
|
||||
let handler = KebabHandler::new(state);
|
||||
let service = handler.serve(stdio()).await?;
|
||||
service.waiting().await?;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! 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;
|
||||
@@ -10,12 +11,19 @@ use kebab_config::Config;
|
||||
#[derive(Clone)]
|
||||
pub struct KebabAppState {
|
||||
pub config: Arc<Config>,
|
||||
/// Original config file path passed via `--config <path>`, if any.
|
||||
/// Forwarded to `kebab_app::doctor_with_config_path` so the doctor
|
||||
/// report reflects the same config file the server was started with.
|
||||
/// Plan Task 10 (Cmd::Mcp wiring) will pass the actual path; all
|
||||
/// existing callers pass `None` which falls back to the XDG default.
|
||||
pub config_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl KebabAppState {
|
||||
pub fn new(config: Config) -> Self {
|
||||
pub fn new(config: Config, config_path: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
config: Arc::new(config),
|
||||
config_path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Tool implementations — one module per tool.
|
||||
|
||||
pub mod schema;
|
||||
// pub mod doctor; // wired in Plan Task 5
|
||||
pub mod doctor;
|
||||
// pub mod search; // wired in Plan Task 6
|
||||
// pub mod ask; // wired in Plan Task 7
|
||||
|
||||
@@ -9,7 +9,7 @@ use rmcp::ServerHandler;
|
||||
#[tokio::test]
|
||||
async fn initialize_returns_kebab_server_info() {
|
||||
let cfg = Config::defaults();
|
||||
let state = KebabAppState::new(cfg);
|
||||
let state = KebabAppState::new(cfg, None);
|
||||
let handler = KebabHandler::new(state);
|
||||
|
||||
let info = handler.get_info();
|
||||
|
||||
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}"
|
||||
);
|
||||
}
|
||||
@@ -39,7 +39,7 @@ async fn schema_tool_returns_schema_v1_json() {
|
||||
};
|
||||
let _ = kebab_app::ingest_with_config(config.clone(), scope, false).unwrap();
|
||||
|
||||
let state = KebabAppState::new(config);
|
||||
let state = KebabAppState::new(config, None);
|
||||
let handler = KebabHandler::new(state);
|
||||
|
||||
let result = kebab_mcp::tools::schema::handle(
|
||||
|
||||
Reference in New Issue
Block a user