Skip to main content

talon_cli/mcp/tool/
mod.rs

1mod dispatch;
2mod error;
3pub(super) mod hook;
4mod hook_recall;
5mod public;
6mod status;
7mod sync;
8
9#[cfg(test)]
10mod tests;
11
12use std::sync::Arc;
13
14use serde::Deserialize;
15use serde_json::{Value, json};
16use talon_core::{ErrorCode, TalonEnvelope};
17
18use self::error::ToolError;
19
20#[derive(Debug, Deserialize)]
21struct ToolCallParams {
22    name: String,
23    #[serde(default)]
24    arguments: Value,
25}
26
27/// Returns the MCP `tools/list` payload.
28#[must_use]
29pub fn tools_list_result() -> Value {
30    let mut tools = public::tools_list_entries();
31    tools.extend(hook::hook_tools_list_entries());
32    json!({ "tools": tools })
33}
34
35/// Executes one MCP `tools/call` request, with access to session state for
36/// hook-only tools.
37#[must_use]
38pub fn tools_call_result_with_state(
39    params: Option<Value>,
40    state: &Arc<crate::mcp::state::McpServerState>,
41) -> Value {
42    let (name, arguments) = match parse_name_and_arguments(params) {
43        Ok(pair) => pair,
44        Err(error) => return content_result(&error.envelope()),
45    };
46
47    // Try hook tools first — they require state and produce hook-formatted output.
48    if let Some(result) = hook::dispatch_hook(&name, &arguments, state) {
49        return result;
50    }
51
52    // Try named public tools next.
53    if let Some(result) = public::dispatch_named(&name, arguments) {
54        let envelope = result.unwrap_or_else(ToolError::envelope);
55        return public::named_content_result(&envelope);
56    }
57
58    content_result(&unknown_tool_error(&name).envelope())
59}
60
61/// Executes one MCP `tools/call` request.
62#[must_use]
63pub fn tools_call_result(params: Option<Value>) -> Value {
64    // Parse params enough to extract name and arguments.
65    let (name, arguments) = match parse_name_and_arguments(params) {
66        Ok(pair) => pair,
67        Err(error) => return content_result(&error.envelope()),
68    };
69
70    // Try named tools.
71    if let Some(result) = public::dispatch_named(&name, arguments) {
72        let envelope = result.unwrap_or_else(ToolError::envelope);
73        return public::named_content_result(&envelope);
74    }
75
76    content_result(&unknown_tool_error(&name).envelope())
77}
78
79fn parse_name_and_arguments(params: Option<Value>) -> Result<(String, Value), ToolError> {
80    let params = params.ok_or_else(|| {
81        ToolError::new(
82            "talon",
83            ErrorCode::Internal,
84            "tools/call requires params with name and arguments",
85        )
86    })?;
87    let call: ToolCallParams = serde_json::from_value(params).map_err(|error| {
88        ToolError::with_detail(
89            "talon",
90            ErrorCode::Internal,
91            "invalid tools/call params",
92            json!({ "message": error.to_string() }),
93        )
94    })?;
95    Ok((call.name, call.arguments))
96}
97
98fn unknown_tool_error(name: &str) -> ToolError {
99    ToolError::with_detail(
100        "talon",
101        ErrorCode::Internal,
102        format!("unknown tool '{name}'"),
103        json!({ "expected": ["talon_search", "talon_read", "talon_related"] }),
104    )
105}
106
107fn content_result(envelope: &TalonEnvelope) -> Value {
108    json!({
109        "content": [
110            {
111                "type": "text",
112                "text": serde_json::to_string(envelope).unwrap_or_else(|_| "{}".to_owned())
113            }
114        ],
115        "isError": !envelope.ok,
116        "structuredContent": envelope
117    })
118}
119
120#[must_use]
121pub fn panic_tool_result() -> Value {
122    json!({
123        "content": [
124            {
125                "type": "text",
126                "text": "{\"error\":\"talon MCP tool handler panicked; see talon status for diagnostics\"}"
127            }
128        ],
129        "isError": true
130    })
131}