Skip to main content

tiller_sync/mcp/
tools.rs

1//! Implementation of the sync_up and sync_down commands for MCP
2
3use crate::args::{
4    DeleteAutoCatsArgs, DeleteCategoriesArgs, DeleteTransactionsArgs, InsertAutoCatArgs,
5    InsertCategoryArgs, InsertTransactionArgs, QueryArgs, SchemaArgs, UpdateAutoCatsArgs,
6    UpdateCategoriesArgs, UpdateTransactionsArgs,
7};
8use crate::commands::{self, FormulasMode};
9use crate::mcp::mcp_utils::tool_result;
10use crate::mcp::TillerServer;
11use rmcp::handler::server::wrapper::Parameters;
12use rmcp::model::CallToolResult;
13use rmcp::ErrorData as McpError;
14use rmcp::{tool, tool_router};
15use schemars::JsonSchema;
16use serde::Deserialize;
17use tracing::info;
18
19/// Parameters for the sync_up tool.
20#[derive(Debug, Deserialize, JsonSchema)]
21#[schemars(title = "SyncUpParams")]
22pub struct SyncUpParams {
23    /// Force sync even if conflicts are detected or sync-down backup is missing. Use with caution
24    /// as this may overwrite remote changes.
25    #[serde(default)]
26    pub force: bool,
27
28    /// How to handle formulas: 'unknown' (error if formulas exist), 'preserve' (write formulas
29    /// back), or 'ignore' (skip formulas, write values only). Default is 'unknown'.
30    #[serde(default)]
31    pub formulas: FormulasMode,
32}
33
34#[tool_router(vis = "pub(super)")]
35impl TillerServer {
36    #[tool]
37    /// Initialize the tiller MCP service for this session and return usage instructions. You
38    /// **MUST** call this **ONCE** before using other tools so that you have the full usage
39    /// instructions. You **MAY** call it more than once if you have forgotten the usage
40    /// instructions.
41    async fn initialize_service(&self) -> Result<CallToolResult, McpError> {
42        let mut initialized = self.initialized.lock().await;
43        *initialized = true;
44        Ok(CallToolResult::success(vec![rmcp::model::Content::text(
45            include_str!("docs/INSTRUCTIONS.md"),
46        )]))
47    }
48
49    /// Download Transactions, Categories, and AutoCat data from the configured Tiller Google
50    /// Sheet to the local SQLite database. Creates a backup first.
51    ///
52    /// # Backup Procedures (Automatic)
53    ///
54    /// Before any writes:
55    ///
56    /// 1. **SQLite backup** (`tiller.sqlite.YYYY-MM-DD-NNN`): Timestamped copy of the existing
57    ///    database (if it exists).
58    /// 2. **JSON snapshot** (`sync-down.YYYY-MM-DD-NNN.json`): Captures the downloaded sheet data
59    ///    for conflict detection during future `sync_up` operations.
60    ///
61    /// # Database Updates
62    ///
63    /// - **Transactions**: Upsert semantics. New rows are inserted, existing rows are updated,
64    ///   and rows no longer in the sheet are deleted. Each row's `original_order` is set to its
65    ///   0-indexed position from the sheet.
66    /// - **Categories and AutoCat**: Full replacement. All existing rows are deleted, then all
67    ///   sheet rows are inserted.
68    /// - **Formulas**: Cell formulas are captured and stored in the `formulas` table for optional
69    ///   preservation during `sync_up`.
70    ///
71    /// # Caution
72    ///
73    /// This operation overwrites local changes with sheet data. If you have local modifications
74    /// that haven't been synced up, they will be lost. The SQLite backup allows manual recovery
75    /// if needed.
76    #[tool]
77    async fn sync_down(&self) -> Result<CallToolResult, McpError> {
78        require_init!(self);
79        info!("MCP: sync_down called");
80        let config = (*self.config).clone();
81        let out = commands::sync_down(config, self.mode).await;
82        tool_result(out)
83    }
84
85    /// Upload Transactions, Categories, and AutoCat data from the local SQLite database to the
86    /// Google Sheet. Creates backups before writing. Use 'force' to override conflict detection,
87    /// 'formulas' to control formula handling.
88    ///
89    /// # Strategy
90    ///
91    /// This command treats the local SQLite database as the authoritative source of truth and
92    /// completely replaces the Google Sheet contents using a clear-and-write approach.
93    ///
94    /// # Backup Procedures (Automatic)
95    ///
96    /// Before any destructive writes, the following backups are created:
97    ///
98    /// 1. **Pre-upload JSON snapshot** (`sync-up-pre.YYYY-MM-DD-NNN.json`): Captures the current
99    ///    state of the Google Sheet before modification.
100    /// 2. **SQLite backup** (`tiller.sqlite.YYYY-MM-DD-NNN`): Creates a timestamped copy of the
101    ///    local database.
102    /// 3. **Google Sheet copy**: Uses the Drive API to create a full copy of the spreadsheet
103    ///    named `tiller-backup-YYYY-MM-DD-HHMMSS`.
104    ///
105    /// # Conflict Detection
106    ///
107    /// Before uploading, the tool compares the current Google Sheet state against the last
108    /// `sync-down` backup. If differences are detected (indicating the sheet was modified since
109    /// last download):
110    ///
111    /// - **Without `force`**: Returns an error recommending `sync down` first to merge changes.
112    /// - **With `force=true`**: Proceeds with upload, overwriting any remote changes.
113    ///
114    /// If no `sync-down` backup exists:
115    ///
116    /// - **Without `force`**: Returns an error recommending `sync down` first.
117    /// - **With `force=true`**: Skips conflict detection entirely.
118    ///
119    /// # Formula Handling
120    ///
121    /// Tiller sheets may contain formulas (e.g., `=SUM(...)` in balance columns). The `formulas`
122    /// parameter controls how these are handled:
123    ///
124    /// - **`unknown`** (default): If formulas exist in the database, returns an error prompting
125    ///   the user to explicitly choose `preserve` or `ignore`.
126    /// - **`preserve`**: Writes formulas back to their original cell positions. This uses the
127    ///   `original_order` column to maintain row alignment.
128    /// - **`ignore`**: Skips all formulas; only values are written to the sheet.
129    ///
130    /// ## Formula Preservation Caveats
131    ///
132    /// When `formulas=preserve` is used and rows have been deleted locally (detected as gaps in
133    /// `original_order`), formulas may reference incorrect cells because row positions have
134    /// shifted:
135    ///
136    /// - **Without `force`**: Returns an error explaining that formula positions may be corrupted.
137    /// - **With `force=true`**: Proceeds anyway, writing formulas to their original positions.
138    ///
139    /// # Preconditions
140    ///
141    /// - The local database must contain transactions. Run `sync down` first if empty.
142    /// - Authentication must be valid.
143    ///
144    /// # Verification
145    ///
146    /// After writing, the tool re-fetches row counts from each sheet tab and verifies they match
147    /// what was written.
148    #[tool]
149    async fn sync_up(
150        &self,
151        Parameters(params): Parameters<SyncUpParams>,
152    ) -> Result<CallToolResult, McpError> {
153        require_init!(self);
154
155        info!(
156            "MCP: sync_up called with force={}, formulas={}",
157            params.force, params.formulas
158        );
159
160        let config = (*self.config).clone();
161        let out = commands::sync_up(config, self.mode, params.force, params.formulas).await;
162        tool_result(out)
163    }
164
165    /// Update one or more transactions in the local database by their IDs.
166    ///
167    /// This tool modifies transaction fields in the local SQLite database. When more than one ID
168    /// is provided, all specified transactions receive the same updates. Changes are NOT
169    /// automatically synced to the Google Sheet - call `sync_up` to upload local changes.
170    ///
171    /// # Parameters
172    ///
173    /// - `ids`: One or more transaction IDs to update. All specified transactions will receive the
174    ///   same field updates.
175    /// - `updates`: The fields to update. Only fields with values will be modified; unspecified
176    ///   fields remain unchanged. See `TransactionUpdates` for available fields.
177    ///
178    /// # Returns
179    ///
180    /// On success, returns a message indicating how many transactions were updated and a JSON array
181    /// of the updated transaction objects.
182    ///
183    /// # Example
184    ///
185    /// Update one transaction:
186    ///
187    /// ```json
188    /// {
189    ///   "ids": ["abc123"],
190    ///   "category": "Groceries",
191    ///   "note": "Weekly shopping"
192    /// }
193    /// ```
194    ///
195    /// Update more than one transaction with the same values:
196    ///
197    /// ```json
198    /// {
199    ///   "ids": ["abc123", "def456"],
200    ///   "category": "Entertainment"
201    /// }
202    /// ```
203    #[tool]
204    async fn update_transactions(
205        &self,
206        Parameters(args): Parameters<UpdateTransactionsArgs>,
207    ) -> Result<CallToolResult, McpError> {
208        require_init!(self);
209
210        let config = (*self.config).clone();
211        let out = commands::update_transactions(config, args).await;
212        tool_result(out)
213    }
214
215    /// Update one or more categories in the local database by their names.
216    ///
217    /// This tool modifies category fields in the local SQLite database. The category name is the
218    /// primary key. To rename a category, provide the current name and include the new name in the
219    /// updates. Changes are NOT automatically synced to the Google Sheet - call `sync_up` to
220    /// upload local changes. When updating multiple categories, the operation is atomic: either
221    /// all updates succeed or none do.
222    ///
223    /// # Renaming Categories
224    ///
225    /// Due to `ON UPDATE CASCADE` foreign key constraints, renaming a category automatically
226    /// updates all references in transactions and autocat rules. This is a safe operation that
227    /// maintains data integrity.
228    ///
229    /// # Parameters
230    ///
231    /// - `name`: The name of the category to update (this is the primary key).
232    /// - `updates`: The fields to update. Only fields with values will be modified; unspecified
233    ///   fields remain unchanged. See `CategoryUpdates` for available fields:
234    ///   - `category`: New name for the category (renames it)
235    ///   - `group`: The group this category belongs to (e.g., "Food", "Transportation")
236    ///   - `type`: Category type ("Expense", "Income", or "Transfer")
237    ///   - `hide_from_reports`: Set to "Hide" to exclude from reports
238    ///
239    /// # Returns
240    ///
241    /// On success, returns a message indicating the category was updated and a JSON object of
242    /// the updated category.
243    ///
244    /// # Example
245    ///
246    /// Update a category's group:
247    ///
248    /// ```json
249    /// {
250    ///   "name": "Groceries",
251    ///   "group": "Food & Dining"
252    /// }
253    /// ```
254    ///
255    /// Rename a category:
256    ///
257    /// ```json
258    /// {
259    ///   "name": "Food",
260    ///   "category": "Groceries"
261    /// }
262    /// ```
263    #[tool]
264    async fn update_categories(
265        &self,
266        Parameters(args): Parameters<UpdateCategoriesArgs>,
267    ) -> Result<CallToolResult, McpError> {
268        require_init!(self);
269
270        let config = (*self.config).clone();
271        let out = commands::update_categories(config, args).await;
272        tool_result(out)
273    }
274
275    /// Update one or more AutoCat rules in the local database by their IDs.
276    ///
277    /// This tool modifies AutoCat rule fields in the local SQLite database. AutoCat rules have a
278    /// synthetic auto-increment primary key that is assigned when first synced down or inserted
279    /// locally. Changes are NOT automatically synced to the Google Sheet - call `sync_up` to
280    /// upload local changes. When updating multiple rules, the operation is atomic: either all
281    /// updates succeed or none do.
282    ///
283    /// # AutoCat Overview
284    ///
285    /// AutoCat rules automatically categorize transactions based on matching criteria. Rules are
286    /// processed sequentially from top to bottom, so organize specific rules above broader ones.
287    ///
288    /// # Parameters
289    ///
290    /// - `id`: The ID of the AutoCat rule to update (synthetic auto-increment primary key).
291    /// - `updates`: The fields to update. Only fields with values will be modified; unspecified
292    ///   fields remain unchanged. Available fields:
293    ///
294    ///   **Override columns** (applied when rule matches):
295    ///   - `category`: The category to assign to matching transactions
296    ///   - `description`: Override to standardize transaction descriptions
297    ///
298    ///   **Filter criteria** (all non-blank criteria are AND-ed):
299    ///   - `description_contains`: Text to search for in Description (case-insensitive, supports
300    ///     multiple comma-separated keywords in quotes that are OR-ed)
301    ///   - `account_contains`: Text to search for in Account column
302    ///   - `institution_contains`: Text to search for in Institution column
303    ///   - `amount_min`: Minimum amount (absolute value)
304    ///   - `amount_max`: Maximum amount (absolute value)
305    ///   - `amount_equals`: Exact amount to match
306    ///   - `description_equals`: Exact match for Description
307    ///   - `full_description_contains`: Text to search for in Full Description
308    ///
309    /// # Returns
310    ///
311    /// On success, returns a message indicating the rule was updated and a JSON object of
312    /// the updated AutoCat rule (including its ID).
313    ///
314    /// # Example
315    ///
316    /// Update an AutoCat rule's filter criteria:
317    ///
318    /// ```json
319    /// {
320    ///   "id": "1",
321    ///   "description_contains": "starbucks,coffee shop",
322    ///   "category": "Food & Dining"
323    /// }
324    /// ```
325    #[tool]
326    async fn update_autocats(
327        &self,
328        Parameters(args): Parameters<UpdateAutoCatsArgs>,
329    ) -> Result<CallToolResult, McpError> {
330        require_init!(self);
331
332        let config = (*self.config).clone();
333        let out = commands::update_autocats(config, args).await;
334        tool_result(out)
335    }
336
337    /// Delete one or more transactions from the local database by their IDs.
338    ///
339    /// This tool permanently removes transactions from the local SQLite database. Changes are NOT
340    /// automatically synced to the Google Sheet - call `sync_up` to upload local changes.
341    ///
342    /// # Warning
343    ///
344    /// This operation cannot be undone locally. However, if you haven't run `sync_up` yet, you can
345    /// restore the transactions by running `sync_down` to re-download from the sheet.
346    ///
347    /// # Parameters
348    ///
349    /// - `ids`: One or more transaction IDs to delete. All specified transactions will be removed.
350    ///
351    /// # Returns
352    ///
353    /// On success, returns a message indicating how many transactions were deleted and a JSON array
354    /// of the deleted transaction IDs.
355    ///
356    /// # Errors
357    ///
358    /// - Returns an error if a transaction ID is not found.
359    /// - The operation is atomic: if any error occurs, all changes are rolled back and no
360    ///   transactions are deleted.
361    ///
362    /// # Example
363    ///
364    /// Delete a single transaction:
365    ///
366    /// ```json
367    /// {
368    ///   "ids": ["abc123"]
369    /// }
370    /// ```
371    ///
372    /// Delete multiple transactions:
373    ///
374    /// ```json
375    /// {
376    ///   "ids": ["abc123", "def456", "ghi789"]
377    /// }
378    /// ```
379    #[tool]
380    async fn delete_transactions(
381        &self,
382        Parameters(args): Parameters<DeleteTransactionsArgs>,
383    ) -> Result<CallToolResult, McpError> {
384        require_init!(self);
385
386        let config = (*self.config).clone();
387        let out = commands::delete_transactions(config, args).await;
388        tool_result(out)
389    }
390
391    /// Delete one or more categories from the local database by their names.
392    ///
393    /// This tool permanently removes categories from the local SQLite database. Changes are NOT
394    /// automatically synced to the Google Sheet - call `sync_up` to upload local changes.
395    ///
396    /// # Foreign Key Constraints
397    ///
398    /// Due to `ON DELETE RESTRICT` foreign key constraints, a category cannot be deleted if any
399    /// transactions or AutoCat rules reference it. Those references must be updated or deleted
400    /// first using `update_transactions`, `update_autocats`, or `delete_transactions`.
401    ///
402    /// # Warning
403    ///
404    /// This operation cannot be undone locally. However, if you haven't run `sync_up` yet, you can
405    /// restore the categories by running `sync_down` to re-download from the sheet.
406    ///
407    /// # Parameters
408    ///
409    /// - `names`: One or more category names to delete.
410    ///
411    /// # Returns
412    ///
413    /// On success, returns a message indicating how many categories were deleted and a JSON array
414    /// of the deleted category names.
415    ///
416    /// # Errors
417    ///
418    /// - Returns an error if a category is not found.
419    /// - Returns an error if a foreign key constraint prevents deletion.
420    /// - The operation is atomic: if any error occurs, all changes are rolled back and no
421    ///   categories are deleted.
422    ///
423    /// # Example
424    ///
425    /// Delete a single category:
426    ///
427    /// ```json
428    /// {
429    ///   "names": ["Old Category"]
430    /// }
431    /// ```
432    ///
433    /// Delete multiple categories:
434    ///
435    /// ```json
436    /// {
437    ///   "names": ["Category1", "Category2", "Category3"]
438    /// }
439    /// ```
440    #[tool]
441    async fn delete_categories(
442        &self,
443        Parameters(args): Parameters<DeleteCategoriesArgs>,
444    ) -> Result<CallToolResult, McpError> {
445        require_init!(self);
446
447        let config = (*self.config).clone();
448        let out = commands::delete_categories(config, args).await;
449        tool_result(out)
450    }
451
452    /// Delete one or more AutoCat rules from the local database by their IDs.
453    ///
454    /// This tool permanently removes AutoCat rules from the local SQLite database. Changes are NOT
455    /// automatically synced to the Google Sheet - call `sync_up` to upload local changes.
456    ///
457    /// AutoCat rules have synthetic auto-increment IDs assigned when first synced down or inserted
458    /// locally.
459    ///
460    /// # Warning
461    ///
462    /// This operation cannot be undone locally. However, if you haven't run `sync_up` yet, you can
463    /// restore the rules by running `sync_down` to re-download from the sheet.
464    ///
465    /// # Parameters
466    ///
467    /// - `ids`: One or more AutoCat rule IDs to delete.
468    ///
469    /// # Returns
470    ///
471    /// On success, returns a message indicating how many AutoCat rules were deleted and a JSON
472    /// array of the deleted rule IDs.
473    ///
474    /// # Errors
475    ///
476    /// - Returns an error if an AutoCat rule ID is not found.
477    /// - The operation is atomic: if any error occurs, all changes are rolled back and no
478    ///   rules are deleted.
479    ///
480    /// # Example
481    ///
482    /// Delete a single AutoCat rule:
483    ///
484    /// ```json
485    /// {
486    ///   "ids": ["1"]
487    /// }
488    /// ```
489    ///
490    /// Delete multiple AutoCat rules:
491    ///
492    /// ```json
493    /// {
494    ///   "ids": ["1", "2", "3"]
495    /// }
496    /// ```
497    #[tool]
498    async fn delete_autocats(
499        &self,
500        Parameters(args): Parameters<DeleteAutoCatsArgs>,
501    ) -> Result<CallToolResult, McpError> {
502        require_init!(self);
503
504        let config = (*self.config).clone();
505        let out = commands::delete_autocats(config, args).await;
506        tool_result(out)
507    }
508
509    /// Insert a new transaction into the local database.
510    ///
511    /// This tool creates a new transaction in the local SQLite database. A unique transaction ID
512    /// is automatically generated with a `user-` prefix to distinguish it from Tiller-created
513    /// transactions. The generated ID is returned on success. Changes are NOT automatically synced
514    /// to the Google Sheet - call `sync_up` to upload local changes.
515    ///
516    /// # Parameters
517    ///
518    /// - `date`: The posted date (when the transaction cleared) or transaction date. **Required.**
519    /// - `amount`: Transaction value where income and credits are positive; expenses and debits
520    ///   are negative. **Required.**
521    /// - All other fields are optional. See the `InsertTransactionArgs` schema for the full list.
522    ///
523    /// # Foreign Key Constraints
524    ///
525    /// If a `category` is specified, it must reference an existing category in the database.
526    /// The insert will fail if the category does not exist. Either create the category first
527    /// or leave the category field empty.
528    ///
529    /// # Returns
530    ///
531    /// On success, returns a message indicating the transaction was inserted and the generated
532    /// transaction ID.
533    ///
534    /// # Example
535    ///
536    /// Insert a transaction with minimal fields:
537    ///
538    /// ```json
539    /// {
540    ///   "date": "2025-01-20",
541    ///   "amount": "-25.50"
542    /// }
543    /// ```
544    ///
545    /// Insert a transaction with more details:
546    ///
547    /// ```json
548    /// {
549    ///   "date": "2025-01-20",
550    ///   "amount": "-25.50",
551    ///   "description": "Coffee Shop",
552    ///   "account": "Checking",
553    ///   "category": "Food",
554    ///   "note": "Morning coffee"
555    /// }
556    /// ```
557    #[tool]
558    async fn insert_transaction(
559        &self,
560        Parameters(args): Parameters<InsertTransactionArgs>,
561    ) -> Result<CallToolResult, McpError> {
562        require_init!(self);
563
564        let config = (*self.config).clone();
565        let out = commands::insert_transaction(config, args).await;
566        tool_result(out)
567    }
568
569    /// Insert a new category into the local database.
570    ///
571    /// This tool creates a new category in the local SQLite database. The category name is the
572    /// primary key and must be unique. The name is returned on success. Changes are NOT
573    /// automatically synced to the Google Sheet - call `sync_up` to upload local changes.
574    ///
575    /// # Parameters
576    ///
577    /// - `name`: The name of the category. This is the primary key and must be unique. **Required.**
578    /// - `group`: The group this category belongs to (e.g., "Food", "Transportation").
579    /// - `type`: Category type classification ("Expense", "Income", or "Transfer").
580    /// - `hide_from_reports`: Set to "Hide" to exclude this category from reports.
581    ///
582    /// # Returns
583    ///
584    /// On success, returns a message indicating the category was inserted and the category name.
585    ///
586    /// # Errors
587    ///
588    /// - Returns an error if a category with the same name already exists.
589    ///
590    /// # Example
591    ///
592    /// Insert a category with minimal fields:
593    ///
594    /// ```json
595    /// {
596    ///   "name": "Groceries"
597    /// }
598    /// ```
599    ///
600    /// Insert a category with all fields:
601    ///
602    /// ```json
603    /// {
604    ///   "name": "Groceries",
605    ///   "group": "Food",
606    ///   "type": "Expense",
607    ///   "hide_from_reports": ""
608    /// }
609    /// ```
610    #[tool]
611    async fn insert_category(
612        &self,
613        Parameters(args): Parameters<InsertCategoryArgs>,
614    ) -> Result<CallToolResult, McpError> {
615        require_init!(self);
616
617        let config = (*self.config).clone();
618        let out = commands::insert_category(config, args).await;
619        tool_result(out)
620    }
621
622    /// Insert a new AutoCat rule into the local database.
623    ///
624    /// This tool creates a new AutoCat rule in the local SQLite database. AutoCat rules define
625    /// automatic categorization criteria for transactions. The primary key is auto-generated
626    /// (synthetic auto-increment) and returned on success. Changes are NOT automatically synced
627    /// to the Google Sheet - call `sync_up` to upload local changes.
628    ///
629    /// # AutoCat Overview
630    ///
631    /// AutoCat rules automatically categorize transactions based on matching criteria. Rules are
632    /// processed sequentially from top to bottom, so organize specific rules above broader ones.
633    ///
634    /// # Parameters
635    ///
636    /// All fields are optional - an empty rule can be created and updated later. However, a useful
637    /// rule typically needs at least a category and one or more filter criteria.
638    ///
639    /// **Override columns** (applied when rule matches):
640    /// - `category`: The category to assign to matching transactions. Must reference an existing
641    ///   category.
642    /// - `description`: Override to standardize transaction descriptions (e.g., replace
643    ///   "Seattle Starbucks store 1234" with simply "Starbucks").
644    ///
645    /// **Filter criteria** (all non-blank criteria are AND-ed):
646    /// - `description_contains`: Text to search for in Description (case-insensitive, supports
647    ///   multiple comma-separated keywords in quotes that are OR-ed).
648    /// - `account_contains`: Text to search for in Account column.
649    /// - `institution_contains`: Text to search for in Institution column.
650    /// - `amount_min`: Minimum amount (absolute value).
651    /// - `amount_max`: Maximum amount (absolute value).
652    /// - `amount_equals`: Exact amount to match.
653    /// - `description_equals`: Exact match for Description.
654    /// - `description_full`: Override column for the full/raw description field.
655    /// - `full_description_contains`: Text to search for in Full Description.
656    /// - `amount_contains`: Text pattern to search for in Amount column.
657    ///
658    /// # Foreign Key Constraints
659    ///
660    /// If a `category` is specified, it must reference an existing category in the database.
661    /// The insert will fail if the category does not exist. Either create the category first
662    /// or leave the category field empty.
663    ///
664    /// # Returns
665    ///
666    /// On success, returns a message indicating the AutoCat rule was inserted and the generated
667    /// rule ID.
668    ///
669    /// # Example
670    ///
671    /// Insert a simple AutoCat rule:
672    ///
673    /// ```json
674    /// {
675    ///   "category": "Food & Dining",
676    ///   "description_contains": "starbucks,coffee"
677    /// }
678    /// ```
679    ///
680    /// Insert an AutoCat rule with amount filter:
681    ///
682    /// ```json
683    /// {
684    ///   "category": "Subscriptions",
685    ///   "description_contains": "netflix",
686    ///   "amount_min": "10.00",
687    ///   "amount_max": "20.00"
688    /// }
689    /// ```
690    #[tool]
691    async fn insert_autocat(
692        &self,
693        Parameters(args): Parameters<InsertAutoCatArgs>,
694    ) -> Result<CallToolResult, McpError> {
695        require_init!(self);
696
697        let config = (*self.config).clone();
698        let out = commands::insert_autocat(config, args).await;
699        tool_result(out)
700    }
701
702    /// Execute a read-only SQL query against the local SQLite database.
703    ///
704    /// The query interface enforces read-only access using a separate SQLite connection opened with
705    /// `?mode=ro`. Any write attempt (INSERT, UPDATE, DELETE) will be rejected by SQLite.
706    ///
707    /// # Parameters
708    ///
709    /// - `sql`: The SQL query to execute. Must be a valid SQLite SELECT statement. **Required.**
710    /// - `format`: Output format. One of `json`, `markdown`, or `csv`. **Required.**
711    ///
712    /// # Output Formats
713    ///
714    /// - **`json`**: Returns a JSON array of objects where each row is a self-describing object
715    ///   with column names as keys.
716    /// - **`markdown`**: Returns a Markdown-formatted table suitable for display.
717    /// - **`csv`**: Returns CSV format with header row followed by data rows.
718    ///
719    /// # Returns
720    ///
721    /// On success, returns a message indicating the row count and the query results in the
722    /// requested format.
723    ///
724    /// # Warning
725    ///
726    /// Large result sets are returned in full. Consider using LIMIT clauses for potentially
727    /// large queries.
728    ///
729    /// # Example
730    ///
731    /// Query recent transactions:
732    ///
733    /// ```json
734    /// {
735    ///   "sql": "SELECT date, description, amount, category FROM transactions ORDER BY date DESC LIMIT 10",
736    ///   "format": "json"
737    /// }
738    /// ```
739    ///
740    /// Get category spending summary:
741    ///
742    /// ```json
743    /// {
744    ///   "sql": "SELECT category, SUM(amount) as total FROM transactions WHERE amount < 0 GROUP BY category ORDER BY total",
745    ///   "format": "markdown"
746    /// }
747    /// ```
748    #[tool]
749    async fn query(
750        &self,
751        Parameters(args): Parameters<QueryArgs>,
752    ) -> Result<CallToolResult, McpError> {
753        require_init!(self);
754
755        let config = (*self.config).clone();
756        let out = commands::query(config, args).await;
757        tool_result(out)
758    }
759
760    /// Retrieve database schema information.
761    ///
762    /// Returns tables, columns, types, indexes, foreign keys, column descriptions, and row counts.
763    /// This tool helps AI agents understand the database structure before writing queries.
764    ///
765    /// # Parameters
766    ///
767    /// - `include_metadata`: If `true`, includes internal metadata tables (`sheet_metadata`,
768    ///   `formulas`, `schema_version`) in addition to data tables. Default is `false`.
769    ///
770    /// # Returns
771    ///
772    /// Returns a JSON object containing schema information for each table:
773    ///
774    /// - `tables`: Array of table information objects:
775    ///   - `name`: Table name
776    ///   - `row_count`: Number of rows in the table
777    ///   - `columns`: Array of column information:
778    ///     - `name`: Column name
779    ///     - `data_type`: SQLite data type
780    ///     - `nullable`: Whether the column allows NULL values
781    ///     - `primary_key`: Whether the column is part of the primary key
782    ///     - `description`: Description from model doc comments (if available)
783    ///   - `indexes`: Array of index information:
784    ///     - `name`: Index name
785    ///     - `columns`: Columns in the index
786    ///     - `unique`: Whether the index enforces uniqueness
787    ///   - `foreign_keys`: Array of foreign key information:
788    ///     - `columns`: Local columns in the foreign key
789    ///     - `references_table`: Referenced table
790    ///     - `references_columns`: Referenced columns
791    ///
792    /// # Data Tables
793    ///
794    /// The primary data tables are:
795    ///
796    /// - `transactions`: Financial transactions with columns like `transaction_id`, `date`,
797    ///   `description`, `amount`, `category`, etc.
798    /// - `categories`: Budget categories with `category`, `group`, `type`, and `hide_from_reports`.
799    /// - `autocat`: Automatic categorization rules with filter criteria and override columns.
800    ///
801    /// # Example
802    ///
803    /// Get schema for data tables only:
804    ///
805    /// ```json
806    /// {
807    ///   "include_metadata": false
808    /// }
809    /// ```
810    ///
811    /// Get schema including metadata tables:
812    ///
813    /// ```json
814    /// {
815    ///   "include_metadata": true
816    /// }
817    /// ```
818    #[tool]
819    async fn schema(
820        &self,
821        Parameters(args): Parameters<SchemaArgs>,
822    ) -> Result<CallToolResult, McpError> {
823        require_init!(self);
824
825        let config = (*self.config).clone();
826        let out = commands::schema(config, args).await;
827        tool_result(out)
828    }
829}
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834
835    /// Test that tool descriptions can be retrieved from the generated metadata functions.
836    /// This test verifies that when no explicit `description` attribute is provided,
837    /// doc comments above the `#[tool]` macro are used as the description.
838    #[test]
839    fn test_tool_descriptions_from_doc_comments() {
840        // sync_down uses doc comments for its description (no explicit description attribute)
841        let sync_down_tool = TillerServer::sync_down_tool_attr();
842        let description = sync_down_tool
843            .description
844            .expect("sync_down should have a description");
845
846        // The description should come from doc comments
847        assert!(
848            description.contains("Download"),
849            "Expected description from doc comments, got: {description}"
850        );
851
852        // sync_up uses an explicit description attribute
853        let sync_up_tool = TillerServer::sync_up_tool_attr();
854        let description = sync_up_tool
855            .description
856            .expect("sync_up should have a description");
857        assert!(
858            description.contains("Upload"),
859            "Expected explicit description, got: {description}"
860        );
861    }
862
863    /// A test that verifies doc comments are being presented in the JSON schema.
864    #[test]
865    fn sync_up_params_schema_description() {
866        let schema_object = schemars::schema_for!(SyncUpParams);
867        let schema = serde_json::to_string_pretty(&schema_object).unwrap();
868        let expected_snippet = "error if formulas exist";
869        let contains_snippet = schema.contains(expected_snippet);
870        assert!(
871            contains_snippet,
872            "Expected JSON schema to contain '{expected_snippet}' \
873        but it did not. Schema:\n\n{schema}\n\n"
874        );
875    }
876}