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}