victauri-plugin 0.9.0

Tauri plugin for Victauri — embedded MCP server with full-stack introspection
Documentation
use schemars::JsonSchema;
use serde::Deserialize;

/// Deserialize `args` as a JSON object (or absent), rejecting scalars/arrays. The documented
/// contract is that `args` is an object of `{parameter_name: value}`; forwarding a scalar or
/// array to `__TAURI_INTERNALS__.invoke` would break the handler's argument expectations, so we
/// reject it with a clear error at the boundary instead of letting it slip through.
///
/// One liberal-in exception (same paper-cut class as the `sql` alias below): a STRING whose
/// content parses to a JSON object is accepted as that object. Several MCP clients stringify
/// structured tool parameters — live 2026-07-21, Claude Code sent
/// `args: "{\"days\":7,\"maxNodes\":150}"` and every `invoke_command` call bounced with this
/// error, forcing agents to fall back to `eval_js` + `__TAURI_INTERNALS__.invoke`. The intent
/// of a stringified object is unambiguous; anything else (scalar, array, non-object string)
/// stays rejected.
fn deserialize_optional_object<'de, D>(
    deserializer: D,
) -> Result<Option<serde_json::Value>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    const CONTRACT: &str = "`args` must be a JSON object of {parameter_name: value} — or that \
         object as a JSON string — (got a scalar, array, or non-object string)";
    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
    match value {
        None | Some(serde_json::Value::Object(_)) => Ok(value),
        Some(serde_json::Value::String(s)) => match serde_json::from_str::<serde_json::Value>(&s) {
            Ok(obj @ serde_json::Value::Object(_)) => Ok(Some(obj)),
            _ => Err(serde::de::Error::custom(CONTRACT)),
        },
        Some(_) => Err(serde::de::Error::custom(CONTRACT)),
    }
}

/// Parameters for the `get_registry` tool.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct RegistryParams {
    /// Search query to filter commands by name or description.
    pub query: Option<String>,
}

/// Parameters for the `invoke_command` tool.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct InvokeCommandParams {
    /// The Tauri command name to invoke (e.g. "greet", "`save_settings`").
    pub command: String,
    /// Command arguments as a JSON OBJECT nested under this `args` key — keys are the IPC
    /// argument keys, which Tauri camelCases from the Rust parameter names by default
    /// (`item_id` → `itemId`; the registry reports each argument's `key` when it differs),
    /// e.g. `{"command":"get_item","args":{"itemId":42}}`. Do NOT
    /// put parameters at the top level next to `command` (a flat `{"command":...,"itemId":42}`
    /// leaves `args` empty and the handler sees a missing argument). Omit for no-arg commands.
    /// A JSON STRING containing that object is also accepted (some MCP clients stringify
    /// structured params). Forwarded to `__TAURI_INTERNALS__.invoke(command, args)` —
    /// identical via the MCP tool and the REST `POST /api/tools/invoke_command` endpoint.
    #[serde(default, deserialize_with = "deserialize_optional_object")]
    pub args: Option<serde_json::Value>,
    /// Target webview label.
    #[serde(alias = "window", alias = "window_label")]
    pub webview_label: Option<String>,
    /// How long to wait for the command's result, in ms (default: the plugin's eval timeout,
    /// 30s; max 300000). Raise it for commands that are legitimately slow.
    #[serde(default)]
    pub timeout_ms: Option<u64>,
}

/// Which app directory to target.
#[derive(Debug, Deserialize, JsonSchema, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum AppDir {
    /// Per-user app data directory.
    Data,
    /// Per-user config directory.
    Config,
    /// Log directory.
    Log,
    /// Local data directory.
    LocalData,
}

/// Parameters for the `list_app_dir` tool.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ListAppDirParams {
    /// Which app directory to list. Default: data.
    pub directory: Option<AppDir>,
    /// Optional subdirectory path relative to the chosen root (e.g. "databases").
    pub path: Option<String>,
    /// Only return entries matching this glob pattern (e.g. "*.sqlite", "*.db").
    pub pattern: Option<String>,
    /// Maximum directory depth to recurse. Default: 1 (immediate children only).
    pub max_depth: Option<u32>,
}

/// Parameters for the `read_app_file` tool.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ReadAppFileParams {
    /// Which app directory the file is relative to. Default: data.
    pub directory: Option<AppDir>,
    /// File path relative to the chosen directory (e.g. "settings.json", "databases/app.db").
    pub path: String,
    /// Maximum number of bytes to read. Default: 1MB. Set lower for large files.
    pub max_bytes: Option<usize>,
    /// If true, return raw base64-encoded bytes instead of UTF-8 text.
    pub binary: Option<bool>,
}

/// Parameters for the `query_db` tool.
///
/// The fields are consumed by the `sqlite`-gated query implementation; without that
/// feature `query_db` is a stub that returns a clear error, so the fields are accepted
/// (for a stable schema) but not read.
#[derive(Debug, Deserialize, JsonSchema)]
#[cfg_attr(not(feature = "sqlite"), allow(dead_code))]
pub struct QueryDbParams {
    /// Path to the `SQLite` database file, relative to the app data directory.
    /// If omitted, Victauri auto-discovers `SQLite` databases in the app data directory.
    pub path: Option<String>,
    /// SQL query to execute. Must be a SELECT/PRAGMA/EXPLAIN statement (read-only).
    ///
    /// Accepts `sql` as an alias — the intuitive name an agent reaches for first.
    /// Live-4DA dogfood (2026-06-16): passing `sql` returned an opaque HTTP 400 with
    /// no hint that the field is `query`; the alias removes that paper-cut.
    #[serde(alias = "sql")]
    pub query: String,
    /// Positional bind parameters for the query (e.g. `["value1", 42]`).
    pub params: Option<Vec<serde_json::Value>>,
    /// Maximum number of rows to return. Default: 100.
    pub max_rows: Option<usize>,
}

#[cfg(test)]
mod tests {
    use super::*;

    // Live-4DA dogfood (2026-07-21): Claude Code's MCP layer stringified the `args` object and
    // every invoke_command call bounced, forcing an eval_js fallback. A stringified object is
    // accepted; genuinely wrong shapes stay rejected.
    #[test]
    fn invoke_args_accepts_object_and_stringified_object() {
        let direct: InvokeCommandParams =
            serde_json::from_str(r#"{"command":"c","args":{"days":7,"maxNodes":150}}"#)
                .expect("object args must deserialize");
        let stringified: InvokeCommandParams =
            serde_json::from_str(r#"{"command":"c","args":"{\"days\":7,\"maxNodes\":150}"}"#)
                .expect("stringified object args must deserialize");
        assert_eq!(
            direct.args, stringified.args,
            "both forms yield the same object"
        );
        assert_eq!(
            direct
                .args
                .as_ref()
                .and_then(|v| v.get("days"))
                .and_then(serde_json::Value::as_i64),
            Some(7)
        );
        // Absent args stays None.
        let none: InvokeCommandParams =
            serde_json::from_str(r#"{"command":"c"}"#).expect("absent args ok");
        assert!(none.args.is_none());
    }

    #[test]
    fn invoke_args_still_rejects_non_object_shapes() {
        for bad in [
            r#"{"command":"c","args":42}"#,
            r#"{"command":"c","args":[1,2]}"#,
            r#"{"command":"c","args":"not json"}"#,
            r#"{"command":"c","args":"[1,2]"}"#,
            r#"{"command":"c","args":"\"scalar\""}"#,
        ] {
            let r: Result<InvokeCommandParams, _> = serde_json::from_str(bad);
            assert!(r.is_err(), "must reject: {bad}");
        }
    }

    // Live-4DA dogfood (2026-06-16): `query_db` accepted only `query`; passing the intuitive
    // `sql` key 400'd with no hint. The `sql` alias removes that paper-cut.
    #[test]
    fn query_db_accepts_sql_alias() {
        for key in ["query", "sql"] {
            let json = format!(r#"{{"{key}":"SELECT 1"}}"#);
            let p: QueryDbParams = serde_json::from_str(&json)
                .unwrap_or_else(|e| panic!("key `{key}` must deserialize: {e}"));
            assert_eq!(
                p.query, "SELECT 1",
                "key `{key}` must populate the query field"
            );
        }
    }
}