Skip to main content

kaizen/web/
tools.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Web tool registry. Names mirror MCP; execution reuses MCP handlers in-process.
3
4use crate::mcp::KaizenMcp;
5use rmcp::ServiceExt;
6use rmcp::model::{CallToolRequestParams, CallToolResult};
7use serde::Serialize;
8use serde_json::{Map, Value};
9
10pub const WEB_TOOL_NAMES: &[&str] = &[
11    "get_session_span_tree",
12    "kaizen_alerts_check",
13    "kaizen_annotate_session",
14    "kaizen_capabilities",
15    "kaizen_cases_archive",
16    "kaizen_cases_create",
17    "kaizen_cases_list",
18    "kaizen_cases_mine",
19    "kaizen_cases_show",
20    "kaizen_exp_archive",
21    "kaizen_exp_conclude",
22    "kaizen_exp_list",
23    "kaizen_exp_new",
24    "kaizen_exp_report",
25    "kaizen_exp_start",
26    "kaizen_exp_status",
27    "kaizen_exp_tag",
28    "kaizen_ingest_hook",
29    "kaizen_init",
30    "kaizen_insights",
31    "kaizen_metrics",
32    "kaizen_metrics_index",
33    "kaizen_query",
34    "kaizen_retro",
35    "kaizen_review_dismiss",
36    "kaizen_review_list",
37    "kaizen_review_resolve",
38    "kaizen_review_show",
39    "kaizen_rules_create",
40    "kaizen_rules_disable",
41    "kaizen_rules_enable",
42    "kaizen_rules_list",
43    "kaizen_rules_run",
44    "kaizen_session_show",
45    "kaizen_sessions_list",
46    "kaizen_summary",
47    "kaizen_sync_run",
48    "kaizen_sync_status",
49    "kaizen_tui",
50    "mcp/search_sessions",
51];
52
53#[derive(Debug, Serialize)]
54#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
55pub enum ToolOutput {
56    Json(Value),
57    Text(String),
58}
59
60pub async fn call(name: &str, args: Value) -> Result<ToolOutput, String> {
61    if !WEB_TOOL_NAMES.contains(&name) {
62        return Err(format!("unknown web tool: {name}"));
63    }
64    call_mcp(name, args_map(args)?).await.and_then(output)
65}
66
67async fn call_mcp(name: &str, args: Map<String, Value>) -> Result<CallToolResult, String> {
68    let (server_half, client_half) = tokio::io::duplex(1_048_576);
69    let server = tokio::spawn(async move {
70        let svc = KaizenMcp.serve(server_half).await?;
71        svc.waiting().await?;
72        Ok::<_, anyhow::Error>(())
73    });
74    let client = ().serve(client_half).await.map_err(|e| e.to_string())?;
75    let params = CallToolRequestParams::new(name.to_string()).with_arguments(args);
76    let result = client.call_tool(params).await.map_err(|e| e.to_string());
77    drop(client);
78    server.abort();
79    result
80}
81
82fn args_map(args: Value) -> Result<Map<String, Value>, String> {
83    match args {
84        Value::Null => Ok(Map::new()),
85        Value::Object(map) => Ok(map),
86        _ => Err("tool args must be a JSON object".into()),
87    }
88}
89
90fn output(result: CallToolResult) -> Result<ToolOutput, String> {
91    let text = result_text(&result);
92    if result.is_error == Some(true) {
93        return Err(text);
94    }
95    Ok(match result.structured_content {
96        Some(value) => ToolOutput::Json(value),
97        None => parsed_json(&text).map_or(ToolOutput::Text(text), ToolOutput::Json),
98    })
99}
100
101fn parsed_json(text: &str) -> Option<Value> {
102    let trimmed = text.trim();
103    if !(trimmed.starts_with('{') || trimmed.starts_with('[')) {
104        return None;
105    }
106    serde_json::from_str(trimmed).ok()
107}
108
109fn result_text(result: &CallToolResult) -> String {
110    result
111        .content
112        .iter()
113        .filter_map(|c| c.raw.as_text().map(|t| t.text.as_str()))
114        .collect::<Vec<_>>()
115        .join("\n")
116}