talon-cli 0.4.1

Talon CLI: hybrid retrieval over Obsidian vaults and markdown corpora, with grounded answers, MCP server, and agent-native output.
Documentation
use serde_json::{Value, json};
use talon_core::{ErrorCode, TalonEnvelope, TalonInput};

use crate::agent_contract;
use crate::output;

use super::dispatch;
use super::error::ToolError;

/// Returns `tools/list` entries for all named public tools.
pub(super) fn tools_list_entries() -> Vec<Value> {
    vec![
        json!({
            "name": agent_contract::SEARCH.name,
            "description": agent_contract::SEARCH.description,
            "inputSchema": search_input_schema()
        }),
        json!({
            "name": agent_contract::READ.name,
            "description": agent_contract::READ.description,
            "inputSchema": read_input_schema()
        }),
        json!({
            "name": agent_contract::RELATED.name,
            "description": agent_contract::RELATED.description,
            "inputSchema": related_input_schema()
        }),
    ]
}

/// Dispatches to a named tool if `name` matches; returns `None` if unknown.
pub(super) fn dispatch_named(
    name: &str,
    arguments: Value,
) -> Option<Result<TalonEnvelope, ToolError>> {
    match name {
        n if n == agent_contract::SEARCH.name => Some(dispatch_search(arguments)),
        n if n == agent_contract::READ.name => Some(dispatch_read(arguments)),
        n if n == agent_contract::RELATED.name => Some(dispatch_related(arguments)),
        _ => None,
    }
}

fn dispatch_search(arguments: Value) -> Result<TalonEnvelope, ToolError> {
    // Reuse the existing typed dispatcher behind the named MCP surface.
    let mut args = arguments;
    inject_action(&mut args, "search");
    dispatch_input(agent_contract::SEARCH.name, args)
}

fn dispatch_read(arguments: Value) -> Result<TalonEnvelope, ToolError> {
    let mut args = arguments;
    inject_action(&mut args, "read");
    dispatch_input(agent_contract::READ.name, args)
}

fn dispatch_related(arguments: Value) -> Result<TalonEnvelope, ToolError> {
    let mut args = arguments;
    inject_action(&mut args, "related");
    dispatch_input(agent_contract::RELATED.name, args)
}

fn inject_action(arguments: &mut Value, action: &'static str) {
    if let Some(obj) = arguments.as_object_mut() {
        obj.insert("action".to_owned(), Value::String(action.to_owned()));
    }
}

fn dispatch_input(tool_name: &'static str, arguments: Value) -> Result<TalonEnvelope, ToolError> {
    let input: TalonInput = serde_json::from_value(arguments).map_err(|e| {
        ToolError::with_detail(
            tool_name,
            ErrorCode::Internal,
            "invalid tool arguments",
            json!({ "message": e.to_string() }),
        )
    })?;
    dispatch::dispatch_input(input)
        .map_err(|e| ToolError::new(tool_name, ErrorCode::Internal, format!("{e:#}")))
}

/// Builds the MCP `tools/call` result for a named tool envelope.
///
/// `structuredContent` mirrors the compact agent-contract shape used by
/// `content[0].text` so MCP clients that prefer structured output don't end
/// up loading the verbose `TalonEnvelope`. Error envelopes have no compact
/// representation, so they fall back to the full envelope to preserve
/// `error.code` / `error.message` for callers.
pub(super) fn named_content_result(envelope: &TalonEnvelope) -> Value {
    let agent_value = output::json::agent::to_agent_value(envelope);
    let text = agent_value
        .as_ref()
        .and_then(|v| serde_json::to_string(v).ok())
        .unwrap_or_else(|| serde_json::to_string(envelope).unwrap_or_default());
    let structured =
        agent_value.unwrap_or_else(|| serde_json::to_value(envelope).unwrap_or(Value::Null));
    json!({
        "content": [
            {
                "type": "text",
                "text": text
            }
        ],
        "isError": !envelope.ok,
        "structuredContent": structured
    })
}

// ── Input schemas ─────────────────────────────────────────────────────────────

fn search_input_schema() -> Value {
    json!({
        "type": "object",
        "properties": {
            "query": { "type": "string" },
            "scope": {
                "type": "array",
                "items": { "type": "string" },
                "description": "Add configured scopes to the default search pool, for example `raw` for recall-injected raw/ notes."
            },
            "scopeOnly": {
                "type": "array",
                "items": { "type": "string" },
                "description": "Search only these configured scopes, ignoring the default scope set."
            },
            "scopeAll": {
                "type": "boolean",
                "default": false,
                "description": "Include every configured scope, overriding `default = false` exclusions such as raw/, archive/, and private/."
            },
            "mode": { "type": "string", "enum": ["hybrid", "semantic", "fulltext", "title"] },
            "fast": { "type": "boolean", "default": false },
            "limit": { "type": "integer", "default": 10 },
            "candidateLimit": { "type": "integer", "default": 40 },
            "where": { "type": "array", "items": { "type": "string" } },
            "includeSnippets": { "type": "boolean", "default": true },
            "anchors": { "type": "boolean", "default": false }
        },
        "required": ["query"]
    })
}

fn read_input_schema() -> Value {
    json!({
        "type": "object",
        "properties": {
            "path": { "type": "string" },
            "raw": { "type": "boolean", "default": false },
            "fromLine": { "type": "integer" },
            "maxLines": { "type": "integer" }
        },
        "required": ["path"]
    })
}

fn related_input_schema() -> Value {
    json!({
        "type": "object",
        "properties": {
            "path": { "type": "string" },
            "direction": { "type": "string", "enum": ["outgoing", "backlinks", "both"] },
            "depth": { "type": "integer", "default": 1 },
            "limit": {
                "type": "integer",
                "default": 10,
                "description": "MCP-only cap for ranked related results after graph scoring."
            }
        },
        "required": ["path"]
    })
}