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:
th-kim0823
2026-05-07 15:41:13 +09:00
parent 8ca8e18d12
commit 360fa53b02
7 changed files with 107 additions and 10 deletions

View File

@@ -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?;

View File

@@ -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,
}
}
}

View 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),
}
}

View File

@@ -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

View File

@@ -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();

View 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}"
);
}

View File

@@ -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(