Skip to main content

kaizen/web/
tools.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Implemented Web actions reuse 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] = &["kaizen_sessions_list"];
11
12#[derive(Debug, Serialize)]
13#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
14pub enum ToolOutput {
15    Json(Value),
16    Text(String),
17}
18
19pub async fn call(name: &str, args: Value) -> Result<ToolOutput, String> {
20    if !WEB_TOOL_NAMES.contains(&name) {
21        return Err(format!("unknown web tool: {name}"));
22    }
23    reject_refresh_scan(&args)?;
24    call_mcp(name, args_map(args)?).await.and_then(output)
25}
26
27fn reject_refresh_scan(args: &Value) -> Result<(), String> {
28    match args.get("refresh").and_then(Value::as_bool) {
29        Some(true) => Err("web Observe does not allow refresh scans".into()),
30        _ => Ok(()),
31    }
32}
33
34async fn call_mcp(name: &str, args: Map<String, Value>) -> Result<CallToolResult, String> {
35    let (server_half, client_half) = tokio::io::duplex(1_048_576);
36    let server = tokio::spawn(async move {
37        let svc = KaizenMcp.serve(server_half).await?;
38        svc.waiting().await?;
39        Ok::<_, anyhow::Error>(())
40    });
41    let client = ().serve(client_half).await.map_err(|e| e.to_string())?;
42    let params = CallToolRequestParams::new(name.to_string()).with_arguments(args);
43    let result = client.call_tool(params).await.map_err(|e| e.to_string());
44    drop(client);
45    server.abort();
46    result
47}
48
49fn args_map(args: Value) -> Result<Map<String, Value>, String> {
50    match args {
51        Value::Null => Ok(Map::new()),
52        Value::Object(map) => Ok(map),
53        _ => Err("tool args must be a JSON object".into()),
54    }
55}
56
57fn output(result: CallToolResult) -> Result<ToolOutput, String> {
58    let text = result_text(&result);
59    if result.is_error == Some(true) {
60        return Err(text);
61    }
62    Ok(match result.structured_content {
63        Some(value) => ToolOutput::Json(value),
64        None => parsed_json(&text).map_or(ToolOutput::Text(text), ToolOutput::Json),
65    })
66}
67
68fn parsed_json(text: &str) -> Option<Value> {
69    let trimmed = text.trim();
70    if !(trimmed.starts_with('{') || trimmed.starts_with('[')) {
71        return None;
72    }
73    serde_json::from_str(trimmed).ok()
74}
75
76fn result_text(result: &CallToolResult) -> String {
77    result
78        .content
79        .iter()
80        .filter_map(|c| c.raw.as_text().map(|t| t.text.as_str()))
81        .collect::<Vec<_>>()
82        .join("\n")
83}