@oliverames/ynab-mcp-server 1.2.3 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -26
- package/index.js +1241 -1169
- package/package.json +8 -5
package/index.js
CHANGED
|
@@ -1,1169 +1,1241 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import { execFileSync } from "node:child_process";
|
|
4
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
-
import { z } from "zod";
|
|
7
|
-
import * as ynab from "ynab";
|
|
8
|
-
|
|
9
|
-
// --- Init ---
|
|
10
|
-
|
|
11
|
-
let API_TOKEN = process.env.YNAB_API_TOKEN;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
process.
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
);
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
)
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
"
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
);
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
)
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
)
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
);
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
server.
|
|
440
|
-
"
|
|
441
|
-
"
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
)
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
);
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
name: z.string().describe("
|
|
471
|
-
},
|
|
472
|
-
({ budgetId,
|
|
473
|
-
run(async () => {
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
)
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
"
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
})
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
);
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
server.
|
|
601
|
-
"
|
|
602
|
-
"List all
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
)
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
)
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
)
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
"
|
|
695
|
-
{
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
const
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
)
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
)
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
)
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
)
|
|
888
|
-
.
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
});
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
)
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
)
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
"
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
);
|
|
1061
|
-
return ok(formatScheduledTransaction(data.scheduled_transaction));
|
|
1062
|
-
})
|
|
1063
|
-
);
|
|
1064
|
-
|
|
1065
|
-
server.
|
|
1066
|
-
"
|
|
1067
|
-
"
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
)
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
)
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import * as ynab from "ynab";
|
|
8
|
+
|
|
9
|
+
// --- Init ---
|
|
10
|
+
|
|
11
|
+
let API_TOKEN = process.env.YNAB_API_TOKEN;
|
|
12
|
+
let opLookupError;
|
|
13
|
+
if (!API_TOKEN && process.env.YNAB_OP_PATH) {
|
|
14
|
+
try {
|
|
15
|
+
API_TOKEN = execFileSync(
|
|
16
|
+
"op", ["read", process.env.YNAB_OP_PATH],
|
|
17
|
+
{ encoding: "utf-8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] }
|
|
18
|
+
).trim();
|
|
19
|
+
} catch (e) {
|
|
20
|
+
opLookupError = e.stderr?.toString().trim() || e.message || "unknown 1Password CLI error";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (!API_TOKEN) {
|
|
24
|
+
const opMessage = process.env.YNAB_OP_PATH
|
|
25
|
+
? ` Could not read YNAB_OP_PATH via 1Password CLI: ${opLookupError}.`
|
|
26
|
+
: " Set YNAB_OP_PATH to enable 1Password CLI fallback (e.g. op://Vault/Item/credential).";
|
|
27
|
+
console.error(`YNAB_API_TOKEN environment variable is required.${opMessage}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const api = new ynab.API(API_TOKEN);
|
|
32
|
+
const DEFAULT_BUDGET_ID = process.env.YNAB_BUDGET_ID;
|
|
33
|
+
|
|
34
|
+
// --- Helpers ---
|
|
35
|
+
|
|
36
|
+
function resolveBudgetId(input) {
|
|
37
|
+
return input || DEFAULT_BUDGET_ID || "last-used";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function dollars(milliunits) {
|
|
41
|
+
return milliunits == null ? null : milliunits / 1000;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function milliunits(dollars) {
|
|
45
|
+
return Math.round(dollars * 1000);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function dollarsMap(obj) {
|
|
49
|
+
return obj ? Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, dollars(v)])) : obj;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function withCurrencyFields(out, source, fields) {
|
|
53
|
+
for (const field of fields) {
|
|
54
|
+
const formatted = `${field}_formatted`;
|
|
55
|
+
const currency = `${field}_currency`;
|
|
56
|
+
if (formatted in source) out[formatted] = source[formatted];
|
|
57
|
+
if (currency in source) out[currency] = source[currency];
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function mapTransactionInput(t) {
|
|
63
|
+
const out = {
|
|
64
|
+
account_id: t.accountId,
|
|
65
|
+
date: t.date,
|
|
66
|
+
amount: milliunits(t.amount),
|
|
67
|
+
payee_id: t.payeeId,
|
|
68
|
+
payee_name: t.payeeName,
|
|
69
|
+
category_id: t.categoryId,
|
|
70
|
+
memo: t.memo,
|
|
71
|
+
cleared: t.cleared,
|
|
72
|
+
approved: t.approved,
|
|
73
|
+
flag_color: t.flagColor,
|
|
74
|
+
import_id: t.importId,
|
|
75
|
+
};
|
|
76
|
+
if (t.subtransactions) {
|
|
77
|
+
out.subtransactions = t.subtransactions.map((s) => ({
|
|
78
|
+
amount: milliunits(s.amount),
|
|
79
|
+
category_id: s.categoryId,
|
|
80
|
+
payee_id: s.payeeId,
|
|
81
|
+
payee_name: s.payeeName,
|
|
82
|
+
memo: s.memo,
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Sparse patch mapper for update_transaction / update_transactions - only includes fields that were explicitly provided
|
|
89
|
+
function mapTransactionUpdate(t) {
|
|
90
|
+
const out = {};
|
|
91
|
+
if (t.accountId !== undefined) out.account_id = t.accountId;
|
|
92
|
+
if (t.date !== undefined) out.date = t.date;
|
|
93
|
+
if (t.amount !== undefined) out.amount = milliunits(t.amount);
|
|
94
|
+
if (t.payeeId !== undefined) out.payee_id = t.payeeId;
|
|
95
|
+
if (t.payeeName !== undefined) out.payee_name = t.payeeName;
|
|
96
|
+
if (t.categoryId !== undefined) out.category_id = t.categoryId;
|
|
97
|
+
if (t.memo !== undefined) out.memo = t.memo;
|
|
98
|
+
if (t.cleared !== undefined) out.cleared = t.cleared;
|
|
99
|
+
if (t.approved !== undefined) out.approved = t.approved;
|
|
100
|
+
if (t.flagColor !== undefined) out.flag_color = t.flagColor;
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function ok(data) {
|
|
105
|
+
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function collection(data, key, items, lastKnowledgeOfServer) {
|
|
109
|
+
return lastKnowledgeOfServer === undefined
|
|
110
|
+
? items
|
|
111
|
+
: { [key]: items, server_knowledge: data.server_knowledge };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function run(fn) {
|
|
115
|
+
try {
|
|
116
|
+
return await fn();
|
|
117
|
+
} catch (e) {
|
|
118
|
+
const detail = e?.error?.detail;
|
|
119
|
+
const name = e?.error?.name;
|
|
120
|
+
const msg = detail
|
|
121
|
+
? (name ? `${name}: ${detail}` : detail)
|
|
122
|
+
: (e?.message || String(e));
|
|
123
|
+
return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Direct API helper for endpoints not yet in the ynab SDK
|
|
128
|
+
const BASE_URL = "https://api.ynab.com/v1";
|
|
129
|
+
async function ynabFetch(path, { method = "GET", body, query } = {}) {
|
|
130
|
+
const url = new URL(`${BASE_URL}${path}`);
|
|
131
|
+
for (const [key, value] of Object.entries(query || {})) {
|
|
132
|
+
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
|
133
|
+
}
|
|
134
|
+
const opts = {
|
|
135
|
+
method,
|
|
136
|
+
headers: { Authorization: `Bearer ${API_TOKEN}`, "Content-Type": "application/json" },
|
|
137
|
+
};
|
|
138
|
+
if (body) opts.body = JSON.stringify(body);
|
|
139
|
+
const res = await fetch(url, opts);
|
|
140
|
+
const text = await res.text();
|
|
141
|
+
const json = text ? JSON.parse(text) : {};
|
|
142
|
+
if (!res.ok) {
|
|
143
|
+
const err = new Error(json?.error?.detail || `HTTP ${res.status}`);
|
|
144
|
+
err.error = json?.error;
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
return json.data;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --- Server ---
|
|
151
|
+
|
|
152
|
+
const server = new McpServer({
|
|
153
|
+
name: "ynab-mcp-server",
|
|
154
|
+
version: "1.3.0",
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ==================== User & Budgets ====================
|
|
158
|
+
|
|
159
|
+
server.registerTool(
|
|
160
|
+
"get_user",
|
|
161
|
+
{ description: "Get the authenticated user" },
|
|
162
|
+
() =>
|
|
163
|
+
run(async () => {
|
|
164
|
+
const { data } = await api.user.getUser();
|
|
165
|
+
return ok(data.user);
|
|
166
|
+
})
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
server.registerTool(
|
|
170
|
+
"list_budgets",
|
|
171
|
+
{ description: "List all budgets. Use a budget ID from the results in other tools, or omit budgetId to use the last-used budget." },
|
|
172
|
+
() =>
|
|
173
|
+
run(async () => {
|
|
174
|
+
const { data } = await api.budgets.getBudgets();
|
|
175
|
+
const result = {
|
|
176
|
+
budgets: data.budgets.map((b) => ({ id: b.id, name: b.name, last_modified_on: b.last_modified_on, first_month: b.first_month, last_month: b.last_month, date_format: b.date_format, currency_format: b.currency_format })),
|
|
177
|
+
};
|
|
178
|
+
if (data.default_budget) {
|
|
179
|
+
result.default_budget = { id: data.default_budget.id, name: data.default_budget.name };
|
|
180
|
+
}
|
|
181
|
+
return ok(result);
|
|
182
|
+
})
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
server.registerTool(
|
|
186
|
+
"get_budget",
|
|
187
|
+
{ description: "Get a budget summary including name, currency format, and account/category/payee counts", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
|
|
188
|
+
({ budgetId }) =>
|
|
189
|
+
run(async () => {
|
|
190
|
+
const { data } = await api.budgets.getBudgetById(resolveBudgetId(budgetId));
|
|
191
|
+
const b = data.budget;
|
|
192
|
+
return ok({
|
|
193
|
+
id: b.id,
|
|
194
|
+
name: b.name,
|
|
195
|
+
last_modified_on: b.last_modified_on,
|
|
196
|
+
first_month: b.first_month,
|
|
197
|
+
last_month: b.last_month,
|
|
198
|
+
date_format: b.date_format,
|
|
199
|
+
currency_format: b.currency_format,
|
|
200
|
+
accounts: b.accounts?.length,
|
|
201
|
+
categories: b.categories?.length,
|
|
202
|
+
payees: b.payees?.length,
|
|
203
|
+
});
|
|
204
|
+
})
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
server.registerTool(
|
|
208
|
+
"get_budget_settings",
|
|
209
|
+
{ description: "Get budget settings (currency format, date format)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
|
|
210
|
+
({ budgetId }) =>
|
|
211
|
+
run(async () => {
|
|
212
|
+
const { data } = await api.budgets.getBudgetSettingsById(resolveBudgetId(budgetId));
|
|
213
|
+
return ok(data.settings);
|
|
214
|
+
})
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// ==================== Accounts ====================
|
|
218
|
+
|
|
219
|
+
function formatAccount(a) {
|
|
220
|
+
const out = {
|
|
221
|
+
id: a.id,
|
|
222
|
+
name: a.name,
|
|
223
|
+
type: a.type,
|
|
224
|
+
on_budget: a.on_budget,
|
|
225
|
+
closed: a.closed,
|
|
226
|
+
balance: dollars(a.balance),
|
|
227
|
+
cleared_balance: dollars(a.cleared_balance),
|
|
228
|
+
uncleared_balance: dollars(a.uncleared_balance),
|
|
229
|
+
transfer_payee_id: a.transfer_payee_id,
|
|
230
|
+
direct_import_linked: a.direct_import_linked,
|
|
231
|
+
direct_import_in_error: a.direct_import_in_error,
|
|
232
|
+
last_reconciled_at: a.last_reconciled_at,
|
|
233
|
+
debt_original_balance: dollars(a.debt_original_balance),
|
|
234
|
+
debt_interest_rates: a.debt_interest_rates,
|
|
235
|
+
debt_minimum_payments: dollarsMap(a.debt_minimum_payments),
|
|
236
|
+
debt_escrow_amounts: dollarsMap(a.debt_escrow_amounts),
|
|
237
|
+
deleted: a.deleted,
|
|
238
|
+
};
|
|
239
|
+
if ("note" in a) out.note = a.note;
|
|
240
|
+
return withCurrencyFields(out, a, ["balance", "cleared_balance", "uncleared_balance"]);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
server.registerTool(
|
|
244
|
+
"list_accounts",
|
|
245
|
+
{ description: "List all accounts in a budget", inputSchema: {
|
|
246
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
247
|
+
lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { accounts, server_knowledge }."),
|
|
248
|
+
} },
|
|
249
|
+
({ budgetId, lastKnowledgeOfServer }) =>
|
|
250
|
+
run(async () => {
|
|
251
|
+
const { data } = await api.accounts.getAccounts(resolveBudgetId(budgetId), lastKnowledgeOfServer);
|
|
252
|
+
const accounts = data.accounts.map(formatAccount);
|
|
253
|
+
return ok(collection(data, "accounts", accounts, lastKnowledgeOfServer));
|
|
254
|
+
})
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
server.registerTool(
|
|
258
|
+
"get_account",
|
|
259
|
+
{ description: "Get details for a specific account", inputSchema: {
|
|
260
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
261
|
+
accountId: z.string().describe("Account ID"),
|
|
262
|
+
} },
|
|
263
|
+
({ budgetId, accountId }) =>
|
|
264
|
+
run(async () => {
|
|
265
|
+
const { data } = await api.accounts.getAccountById(resolveBudgetId(budgetId), accountId);
|
|
266
|
+
return ok(formatAccount(data.account));
|
|
267
|
+
})
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
server.registerTool(
|
|
271
|
+
"create_account",
|
|
272
|
+
{ description: "Create a new account", inputSchema: {
|
|
273
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
274
|
+
name: z.string().describe("Account name"),
|
|
275
|
+
type: z.enum(["checking", "savings", "cash", "creditCard", "lineOfCredit", "otherAsset", "otherLiability", "mortgage", "autoLoan", "studentLoan", "personalLoan", "medicalDebt", "otherDebt"]).describe("Account type"),
|
|
276
|
+
balance: z.number().describe("Starting balance in dollars"),
|
|
277
|
+
} },
|
|
278
|
+
({ budgetId, name, type, balance: bal }) =>
|
|
279
|
+
run(async () => {
|
|
280
|
+
const { data } = await api.accounts.createAccount(resolveBudgetId(budgetId), {
|
|
281
|
+
account: { name, type, balance: milliunits(bal) },
|
|
282
|
+
});
|
|
283
|
+
return ok(formatAccount(data.account));
|
|
284
|
+
})
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
// ==================== Categories ====================
|
|
288
|
+
|
|
289
|
+
function formatCategory(c) {
|
|
290
|
+
const out = {
|
|
291
|
+
id: c.id,
|
|
292
|
+
category_group_id: c.category_group_id,
|
|
293
|
+
category_group_name: c.category_group_name,
|
|
294
|
+
original_category_group_id: c.original_category_group_id,
|
|
295
|
+
name: c.name,
|
|
296
|
+
hidden: c.hidden,
|
|
297
|
+
note: c.note,
|
|
298
|
+
budgeted: dollars(c.budgeted),
|
|
299
|
+
activity: dollars(c.activity),
|
|
300
|
+
balance: dollars(c.balance),
|
|
301
|
+
goal_type: c.goal_type,
|
|
302
|
+
goal_day: c.goal_day,
|
|
303
|
+
goal_cadence: c.goal_cadence,
|
|
304
|
+
goal_cadence_frequency: c.goal_cadence_frequency,
|
|
305
|
+
goal_creation_month: c.goal_creation_month,
|
|
306
|
+
goal_target: dollars(c.goal_target),
|
|
307
|
+
goal_target_month: c.goal_target_month,
|
|
308
|
+
goal_target_date: c.goal_target_date,
|
|
309
|
+
goal_percentage_complete: c.goal_percentage_complete,
|
|
310
|
+
goal_months_to_budget: c.goal_months_to_budget,
|
|
311
|
+
goal_under_funded: dollars(c.goal_under_funded),
|
|
312
|
+
goal_overall_funded: dollars(c.goal_overall_funded),
|
|
313
|
+
goal_overall_left: dollars(c.goal_overall_left),
|
|
314
|
+
goal_needs_whole_amount: c.goal_needs_whole_amount,
|
|
315
|
+
goal_snoozed_at: c.goal_snoozed_at,
|
|
316
|
+
deleted: c.deleted,
|
|
317
|
+
};
|
|
318
|
+
return withCurrencyFields(out, c, [
|
|
319
|
+
"budgeted",
|
|
320
|
+
"activity",
|
|
321
|
+
"balance",
|
|
322
|
+
"goal_target",
|
|
323
|
+
"goal_under_funded",
|
|
324
|
+
"goal_overall_funded",
|
|
325
|
+
"goal_overall_left",
|
|
326
|
+
]);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
server.registerTool(
|
|
330
|
+
"list_categories",
|
|
331
|
+
{ description: "List all category groups and their categories", inputSchema: {
|
|
332
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
333
|
+
lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { category_groups, server_knowledge }."),
|
|
334
|
+
} },
|
|
335
|
+
({ budgetId, lastKnowledgeOfServer }) =>
|
|
336
|
+
run(async () => {
|
|
337
|
+
const { data } = await api.categories.getCategories(resolveBudgetId(budgetId), lastKnowledgeOfServer);
|
|
338
|
+
const categoryGroups = data.category_groups.map((g) => ({
|
|
339
|
+
id: g.id,
|
|
340
|
+
name: g.name,
|
|
341
|
+
hidden: g.hidden,
|
|
342
|
+
deleted: g.deleted,
|
|
343
|
+
categories: g.categories.map((c) =>
|
|
344
|
+
withCurrencyFields(
|
|
345
|
+
{
|
|
346
|
+
id: c.id,
|
|
347
|
+
name: c.name,
|
|
348
|
+
hidden: c.hidden,
|
|
349
|
+
budgeted: dollars(c.budgeted),
|
|
350
|
+
activity: dollars(c.activity),
|
|
351
|
+
balance: dollars(c.balance),
|
|
352
|
+
goal_type: c.goal_type,
|
|
353
|
+
goal_needs_whole_amount: c.goal_needs_whole_amount,
|
|
354
|
+
deleted: c.deleted,
|
|
355
|
+
},
|
|
356
|
+
c,
|
|
357
|
+
["budgeted", "activity", "balance"]
|
|
358
|
+
)
|
|
359
|
+
),
|
|
360
|
+
}));
|
|
361
|
+
return ok(collection(data, "category_groups", categoryGroups, lastKnowledgeOfServer));
|
|
362
|
+
})
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
server.registerTool(
|
|
366
|
+
"get_category",
|
|
367
|
+
{ description: "Get a specific category", inputSchema: {
|
|
368
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
369
|
+
categoryId: z.string().describe("Category ID"),
|
|
370
|
+
} },
|
|
371
|
+
({ budgetId, categoryId }) =>
|
|
372
|
+
run(async () => {
|
|
373
|
+
const { data } = await api.categories.getCategoryById(resolveBudgetId(budgetId), categoryId);
|
|
374
|
+
return ok(formatCategory(data.category));
|
|
375
|
+
})
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
server.registerTool(
|
|
379
|
+
"get_month_category",
|
|
380
|
+
{ description: "Get category budget for a specific month", inputSchema: {
|
|
381
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
382
|
+
month: z.string().describe("Month in YYYY-MM-DD format (first of month)"),
|
|
383
|
+
categoryId: z.string().describe("Category ID"),
|
|
384
|
+
} },
|
|
385
|
+
({ budgetId, month, categoryId }) =>
|
|
386
|
+
run(async () => {
|
|
387
|
+
const { data } = await api.categories.getMonthCategoryById(resolveBudgetId(budgetId), month, categoryId);
|
|
388
|
+
return ok(formatCategory(data.category));
|
|
389
|
+
})
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
server.registerTool(
|
|
393
|
+
"update_month_category",
|
|
394
|
+
{ description: "Set the budgeted amount for a category in a specific month", inputSchema: {
|
|
395
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
396
|
+
month: z.string().describe("Month in YYYY-MM-DD format (first of month)"),
|
|
397
|
+
categoryId: z.string().describe("Category ID"),
|
|
398
|
+
budgeted: z.number().describe("Amount to budget in dollars"),
|
|
399
|
+
} },
|
|
400
|
+
({ budgetId, month, categoryId, budgeted }) =>
|
|
401
|
+
run(async () => {
|
|
402
|
+
const { data } = await api.categories.updateMonthCategory(resolveBudgetId(budgetId), month, categoryId, {
|
|
403
|
+
category: { budgeted: milliunits(budgeted) },
|
|
404
|
+
});
|
|
405
|
+
return ok(formatCategory(data.category));
|
|
406
|
+
})
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
server.registerTool(
|
|
410
|
+
"update_category",
|
|
411
|
+
{ description: "Update a category's name, note, goal target, or move it to a different group", inputSchema: {
|
|
412
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
413
|
+
categoryId: z.string().describe("Category ID"),
|
|
414
|
+
name: z.string().optional().describe("New category name"),
|
|
415
|
+
note: z.string().nullable().optional().describe("Category note (null to clear)"),
|
|
416
|
+
categoryGroupId: z.string().optional().describe("Move to a different category group"),
|
|
417
|
+
goalTarget: z.number().nullable().optional().describe("Goal target amount in dollars (only if category already has a goal)"),
|
|
418
|
+
goalTargetDate: z.string().nullable().optional().describe("Goal target date in ISO format (e.g. 2026-12-01, null to clear)"),
|
|
419
|
+
goalNeedsWholeAmount: z.boolean().nullable().optional().describe("For NEED goals, true uses 'Set aside another' behavior and false uses 'Refill up to' behavior"),
|
|
420
|
+
} },
|
|
421
|
+
({ budgetId, categoryId, name, note, categoryGroupId, goalTarget, goalTargetDate, goalNeedsWholeAmount }) =>
|
|
422
|
+
run(async () => {
|
|
423
|
+
const cat = {};
|
|
424
|
+
if (name !== undefined) cat.name = name;
|
|
425
|
+
if (note !== undefined) cat.note = note;
|
|
426
|
+
if (categoryGroupId !== undefined) cat.category_group_id = categoryGroupId;
|
|
427
|
+
if (goalTarget !== undefined) cat.goal_target = goalTarget != null ? milliunits(goalTarget) : null;
|
|
428
|
+
if (goalTargetDate !== undefined) cat.goal_target_date = goalTargetDate;
|
|
429
|
+
if (goalNeedsWholeAmount !== undefined) cat.goal_needs_whole_amount = goalNeedsWholeAmount;
|
|
430
|
+
|
|
431
|
+
const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/categories/${categoryId}`, {
|
|
432
|
+
method: "PATCH",
|
|
433
|
+
body: { category: cat },
|
|
434
|
+
});
|
|
435
|
+
return ok(formatCategory(data.category));
|
|
436
|
+
})
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
server.registerTool(
|
|
440
|
+
"create_category",
|
|
441
|
+
{ description: "Create a new category in a category group", inputSchema: {
|
|
442
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
443
|
+
categoryGroupId: z.string().describe("Category group ID to create the category in"),
|
|
444
|
+
name: z.string().describe("Category name"),
|
|
445
|
+
note: z.string().optional().describe("Category note"),
|
|
446
|
+
goalTarget: z.number().optional().describe("Goal target amount in dollars (creates a 'Needed for Spending' goal)"),
|
|
447
|
+
goalTargetDate: z.string().optional().describe("Goal target date in ISO format (e.g. 2026-12-01)"),
|
|
448
|
+
goalNeedsWholeAmount: z.boolean().optional().describe("For NEED goals, true uses 'Set aside another' behavior and false uses 'Refill up to' behavior"),
|
|
449
|
+
} },
|
|
450
|
+
({ budgetId, categoryGroupId, name, note, goalTarget, goalTargetDate, goalNeedsWholeAmount }) =>
|
|
451
|
+
run(async () => {
|
|
452
|
+
const bid = resolveBudgetId(budgetId);
|
|
453
|
+
const cat = { category_group_id: categoryGroupId, name };
|
|
454
|
+
if (note !== undefined) cat.note = note;
|
|
455
|
+
if (goalTarget !== undefined) cat.goal_target = milliunits(goalTarget);
|
|
456
|
+
if (goalTargetDate !== undefined) cat.goal_target_date = goalTargetDate;
|
|
457
|
+
if (goalNeedsWholeAmount !== undefined) cat.goal_needs_whole_amount = goalNeedsWholeAmount;
|
|
458
|
+
const data = await ynabFetch(`/plans/${bid}/categories`, {
|
|
459
|
+
method: "POST",
|
|
460
|
+
body: { category: cat },
|
|
461
|
+
});
|
|
462
|
+
return ok(formatCategory(data.category));
|
|
463
|
+
})
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
server.registerTool(
|
|
467
|
+
"create_category_group",
|
|
468
|
+
{ description: "Create a new category group", inputSchema: {
|
|
469
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
470
|
+
name: z.string().describe("Category group name (max 50 characters)"),
|
|
471
|
+
} },
|
|
472
|
+
({ budgetId, name }) =>
|
|
473
|
+
run(async () => {
|
|
474
|
+
const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/category_groups`, {
|
|
475
|
+
method: "POST",
|
|
476
|
+
body: { category_group: { name } },
|
|
477
|
+
});
|
|
478
|
+
return ok(data.category_group);
|
|
479
|
+
})
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
server.registerTool(
|
|
483
|
+
"update_category_group",
|
|
484
|
+
{ description: "Rename a category group", inputSchema: {
|
|
485
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
486
|
+
categoryGroupId: z.string().describe("Category group ID"),
|
|
487
|
+
name: z.string().describe("New category group name (max 50 characters)"),
|
|
488
|
+
} },
|
|
489
|
+
({ budgetId, categoryGroupId, name }) =>
|
|
490
|
+
run(async () => {
|
|
491
|
+
const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/category_groups/${categoryGroupId}`, {
|
|
492
|
+
method: "PATCH",
|
|
493
|
+
body: { category_group: { name } },
|
|
494
|
+
});
|
|
495
|
+
return ok(data.category_group);
|
|
496
|
+
})
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
// ==================== Payees ====================
|
|
500
|
+
|
|
501
|
+
server.registerTool(
|
|
502
|
+
"list_payees",
|
|
503
|
+
{ description: "List all payees", inputSchema: {
|
|
504
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
505
|
+
lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { payees, server_knowledge }."),
|
|
506
|
+
} },
|
|
507
|
+
({ budgetId, lastKnowledgeOfServer }) =>
|
|
508
|
+
run(async () => {
|
|
509
|
+
const { data } = await api.payees.getPayees(resolveBudgetId(budgetId), lastKnowledgeOfServer);
|
|
510
|
+
const payees = data.payees.map((p) => ({ id: p.id, name: p.name, transfer_account_id: p.transfer_account_id, deleted: p.deleted }));
|
|
511
|
+
return ok(collection(data, "payees", payees, lastKnowledgeOfServer));
|
|
512
|
+
})
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
server.registerTool(
|
|
516
|
+
"get_payee",
|
|
517
|
+
{ description: "Get a specific payee", inputSchema: {
|
|
518
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
519
|
+
payeeId: z.string().describe("Payee ID"),
|
|
520
|
+
} },
|
|
521
|
+
({ budgetId, payeeId }) =>
|
|
522
|
+
run(async () => {
|
|
523
|
+
const { data } = await api.payees.getPayeeById(resolveBudgetId(budgetId), payeeId);
|
|
524
|
+
return ok(data.payee);
|
|
525
|
+
})
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
server.registerTool(
|
|
529
|
+
"update_payee",
|
|
530
|
+
{ description: "Rename a payee", inputSchema: {
|
|
531
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
532
|
+
payeeId: z.string().describe("Payee ID"),
|
|
533
|
+
name: z.string().describe("New payee name"),
|
|
534
|
+
} },
|
|
535
|
+
({ budgetId, payeeId, name }) =>
|
|
536
|
+
run(async () => {
|
|
537
|
+
const { data } = await api.payees.updatePayee(resolveBudgetId(budgetId), payeeId, {
|
|
538
|
+
payee: { name },
|
|
539
|
+
});
|
|
540
|
+
return ok(data.payee);
|
|
541
|
+
})
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
server.registerTool(
|
|
545
|
+
"create_payee",
|
|
546
|
+
{ description: "Create a new payee", inputSchema: {
|
|
547
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
548
|
+
name: z.string().max(500).describe("Payee name (max 500 characters)"),
|
|
549
|
+
} },
|
|
550
|
+
({ budgetId, name }) =>
|
|
551
|
+
run(async () => {
|
|
552
|
+
const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/payees`, {
|
|
553
|
+
method: "POST",
|
|
554
|
+
body: { payee: { name } },
|
|
555
|
+
});
|
|
556
|
+
return ok(data.payee);
|
|
557
|
+
})
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
// ==================== Payee Locations ====================
|
|
561
|
+
|
|
562
|
+
server.registerTool(
|
|
563
|
+
"list_payee_locations",
|
|
564
|
+
{ description: "List all payee locations (GPS coordinates where transactions occurred)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
|
|
565
|
+
({ budgetId }) =>
|
|
566
|
+
run(async () => {
|
|
567
|
+
const { data } = await api.payeeLocations.getPayeeLocations(resolveBudgetId(budgetId));
|
|
568
|
+
return ok(data.payee_locations);
|
|
569
|
+
})
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
server.registerTool(
|
|
573
|
+
"get_payee_location",
|
|
574
|
+
{ description: "Get a specific payee location", inputSchema: {
|
|
575
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
576
|
+
payeeLocationId: z.string().describe("Payee location ID"),
|
|
577
|
+
} },
|
|
578
|
+
({ budgetId, payeeLocationId }) =>
|
|
579
|
+
run(async () => {
|
|
580
|
+
const { data } = await api.payeeLocations.getPayeeLocationById(resolveBudgetId(budgetId), payeeLocationId);
|
|
581
|
+
return ok(data.payee_location);
|
|
582
|
+
})
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
server.registerTool(
|
|
586
|
+
"get_payee_locations_by_payee",
|
|
587
|
+
{ description: "Get all locations for a specific payee", inputSchema: {
|
|
588
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
589
|
+
payeeId: z.string().describe("Payee ID"),
|
|
590
|
+
} },
|
|
591
|
+
({ budgetId, payeeId }) =>
|
|
592
|
+
run(async () => {
|
|
593
|
+
const { data } = await api.payeeLocations.getPayeeLocationsByPayee(resolveBudgetId(budgetId), payeeId);
|
|
594
|
+
return ok(data.payee_locations);
|
|
595
|
+
})
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
// ==================== Months ====================
|
|
599
|
+
|
|
600
|
+
server.registerTool(
|
|
601
|
+
"list_months",
|
|
602
|
+
{ description: "List all budget months", inputSchema: {
|
|
603
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
604
|
+
lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { months, server_knowledge }."),
|
|
605
|
+
} },
|
|
606
|
+
({ budgetId, lastKnowledgeOfServer }) =>
|
|
607
|
+
run(async () => {
|
|
608
|
+
const { data } = await api.months.getBudgetMonths(resolveBudgetId(budgetId), lastKnowledgeOfServer);
|
|
609
|
+
const months = data.months.map((m) =>
|
|
610
|
+
withCurrencyFields(
|
|
611
|
+
{
|
|
612
|
+
month: m.month,
|
|
613
|
+
note: m.note,
|
|
614
|
+
income: dollars(m.income),
|
|
615
|
+
budgeted: dollars(m.budgeted),
|
|
616
|
+
activity: dollars(m.activity),
|
|
617
|
+
to_be_budgeted: dollars(m.to_be_budgeted),
|
|
618
|
+
age_of_money: m.age_of_money,
|
|
619
|
+
deleted: m.deleted,
|
|
620
|
+
},
|
|
621
|
+
m,
|
|
622
|
+
["income", "budgeted", "activity", "to_be_budgeted"]
|
|
623
|
+
)
|
|
624
|
+
);
|
|
625
|
+
return ok(collection(data, "months", months, lastKnowledgeOfServer));
|
|
626
|
+
})
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
server.registerTool(
|
|
630
|
+
"get_month",
|
|
631
|
+
{ description: "Get budget month detail with per-category breakdown", inputSchema: {
|
|
632
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
633
|
+
month: z.string().describe("Month in YYYY-MM-DD format (first of month)"),
|
|
634
|
+
} },
|
|
635
|
+
({ budgetId, month }) =>
|
|
636
|
+
run(async () => {
|
|
637
|
+
const { data } = await api.months.getBudgetMonth(resolveBudgetId(budgetId), month);
|
|
638
|
+
const m = data.month;
|
|
639
|
+
const out = {
|
|
640
|
+
month: m.month,
|
|
641
|
+
note: m.note,
|
|
642
|
+
income: dollars(m.income),
|
|
643
|
+
budgeted: dollars(m.budgeted),
|
|
644
|
+
activity: dollars(m.activity),
|
|
645
|
+
to_be_budgeted: dollars(m.to_be_budgeted),
|
|
646
|
+
age_of_money: m.age_of_money,
|
|
647
|
+
deleted: m.deleted,
|
|
648
|
+
categories: m.categories?.map((c) =>
|
|
649
|
+
withCurrencyFields(
|
|
650
|
+
{
|
|
651
|
+
id: c.id,
|
|
652
|
+
name: c.name,
|
|
653
|
+
hidden: c.hidden,
|
|
654
|
+
category_group_name: c.category_group_name,
|
|
655
|
+
budgeted: dollars(c.budgeted),
|
|
656
|
+
activity: dollars(c.activity),
|
|
657
|
+
balance: dollars(c.balance),
|
|
658
|
+
goal_type: c.goal_type,
|
|
659
|
+
goal_needs_whole_amount: c.goal_needs_whole_amount,
|
|
660
|
+
goal_target: dollars(c.goal_target),
|
|
661
|
+
goal_target_month: c.goal_target_month,
|
|
662
|
+
goal_target_date: c.goal_target_date,
|
|
663
|
+
goal_under_funded: dollars(c.goal_under_funded),
|
|
664
|
+
goal_snoozed_at: c.goal_snoozed_at,
|
|
665
|
+
deleted: c.deleted,
|
|
666
|
+
},
|
|
667
|
+
c,
|
|
668
|
+
["budgeted", "activity", "balance", "goal_target", "goal_under_funded"]
|
|
669
|
+
)
|
|
670
|
+
),
|
|
671
|
+
};
|
|
672
|
+
return ok(withCurrencyFields(out, m, ["income", "budgeted", "activity", "to_be_budgeted"]));
|
|
673
|
+
})
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
// ==================== Money Movements ====================
|
|
677
|
+
|
|
678
|
+
function formatMoneyMovement(m) {
|
|
679
|
+
return withCurrencyFields({
|
|
680
|
+
id: m.id,
|
|
681
|
+
month: m.month,
|
|
682
|
+
moved_at: m.moved_at,
|
|
683
|
+
note: m.note,
|
|
684
|
+
money_movement_group_id: m.money_movement_group_id,
|
|
685
|
+
performed_by_user_id: m.performed_by_user_id,
|
|
686
|
+
from_category_id: m.from_category_id,
|
|
687
|
+
to_category_id: m.to_category_id,
|
|
688
|
+
amount: dollars(m.amount),
|
|
689
|
+
deleted: m.deleted,
|
|
690
|
+
}, m, ["amount"]);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
server.registerTool(
|
|
694
|
+
"list_money_movements",
|
|
695
|
+
{ description: "List all money movements (budget re-allocations between categories)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
|
|
696
|
+
({ budgetId }) =>
|
|
697
|
+
run(async () => {
|
|
698
|
+
const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/money_movements`);
|
|
699
|
+
return ok(data.money_movements.map(formatMoneyMovement));
|
|
700
|
+
})
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
server.registerTool(
|
|
704
|
+
"get_money_movements_by_month",
|
|
705
|
+
{ description: "Get money movements for a specific month", inputSchema: {
|
|
706
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
707
|
+
month: z.string().describe("Month in YYYY-MM-DD format (first of month), or 'current'"),
|
|
708
|
+
} },
|
|
709
|
+
({ budgetId, month }) =>
|
|
710
|
+
run(async () => {
|
|
711
|
+
const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/months/${month}/money_movements`);
|
|
712
|
+
return ok(data.money_movements.map(formatMoneyMovement));
|
|
713
|
+
})
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
server.registerTool(
|
|
717
|
+
"list_money_movement_groups",
|
|
718
|
+
{ description: "List all money movement groups (batches of related money movements)", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
|
|
719
|
+
({ budgetId }) =>
|
|
720
|
+
run(async () => {
|
|
721
|
+
const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/money_movement_groups`);
|
|
722
|
+
return ok(data.money_movement_groups);
|
|
723
|
+
})
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
server.registerTool(
|
|
727
|
+
"get_money_movement_groups_by_month",
|
|
728
|
+
{ description: "Get money movement groups for a specific month", inputSchema: {
|
|
729
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
730
|
+
month: z.string().describe("Month in YYYY-MM-DD format (first of month), or 'current'"),
|
|
731
|
+
} },
|
|
732
|
+
({ budgetId, month }) =>
|
|
733
|
+
run(async () => {
|
|
734
|
+
const data = await ynabFetch(`/plans/${resolveBudgetId(budgetId)}/months/${month}/money_movement_groups`);
|
|
735
|
+
return ok(data.money_movement_groups);
|
|
736
|
+
})
|
|
737
|
+
);
|
|
738
|
+
|
|
739
|
+
// ==================== Transactions ====================
|
|
740
|
+
|
|
741
|
+
function formatTransaction(t) {
|
|
742
|
+
const out = {
|
|
743
|
+
id: t.id,
|
|
744
|
+
date: t.date,
|
|
745
|
+
amount: dollars(t.amount),
|
|
746
|
+
memo: t.memo ?? null,
|
|
747
|
+
cleared: t.cleared,
|
|
748
|
+
approved: t.approved,
|
|
749
|
+
flag_color: t.flag_color ?? null,
|
|
750
|
+
flag_name: t.flag_name ?? null,
|
|
751
|
+
account_id: t.account_id,
|
|
752
|
+
account_name: t.account_name,
|
|
753
|
+
payee_id: t.payee_id ?? null,
|
|
754
|
+
payee_name: t.payee_name ?? null,
|
|
755
|
+
category_id: t.category_id ?? null,
|
|
756
|
+
category_name: t.category_name ?? null,
|
|
757
|
+
transfer_account_id: t.transfer_account_id ?? null,
|
|
758
|
+
transfer_transaction_id: t.transfer_transaction_id ?? null,
|
|
759
|
+
matched_transaction_id: t.matched_transaction_id ?? null,
|
|
760
|
+
import_id: t.import_id ?? null,
|
|
761
|
+
import_payee_name: t.import_payee_name ?? null,
|
|
762
|
+
import_payee_name_original: t.import_payee_name_original ?? null,
|
|
763
|
+
debt_transaction_type: t.debt_transaction_type ?? null,
|
|
764
|
+
deleted: t.deleted,
|
|
765
|
+
subtransactions: t.subtransactions?.map((s) =>
|
|
766
|
+
withCurrencyFields(
|
|
767
|
+
{
|
|
768
|
+
id: s.id,
|
|
769
|
+
transaction_id: s.transaction_id,
|
|
770
|
+
amount: dollars(s.amount),
|
|
771
|
+
memo: s.memo ?? null,
|
|
772
|
+
payee_id: s.payee_id ?? null,
|
|
773
|
+
payee_name: s.payee_name ?? null,
|
|
774
|
+
category_id: s.category_id ?? null,
|
|
775
|
+
category_name: s.category_name ?? null,
|
|
776
|
+
transfer_account_id: s.transfer_account_id ?? null,
|
|
777
|
+
transfer_transaction_id: s.transfer_transaction_id ?? null,
|
|
778
|
+
deleted: s.deleted,
|
|
779
|
+
},
|
|
780
|
+
s,
|
|
781
|
+
["amount"]
|
|
782
|
+
)
|
|
783
|
+
),
|
|
784
|
+
};
|
|
785
|
+
return withCurrencyFields(out, t, ["amount"]);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
server.registerTool(
|
|
789
|
+
"get_transactions",
|
|
790
|
+
{ description: "Get transactions with optional filters. Use type='unapproved' or type='uncategorized' to filter. Optionally filter by account, category, payee, or month.", inputSchema: {
|
|
791
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
792
|
+
sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)"),
|
|
793
|
+
type: z.enum(["unapproved", "uncategorized"]).optional().describe("Filter by approval/categorization status"),
|
|
794
|
+
accountId: z.string().optional().describe("Filter by account ID"),
|
|
795
|
+
categoryId: z.string().optional().describe("Filter by category ID"),
|
|
796
|
+
payeeId: z.string().optional().describe("Filter by payee ID"),
|
|
797
|
+
month: z.string().optional().describe("Filter by month (YYYY-MM-DD, first of month)"),
|
|
798
|
+
lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { transactions, server_knowledge }."),
|
|
799
|
+
} },
|
|
800
|
+
({ budgetId, sinceDate, type, accountId, categoryId, payeeId, month, lastKnowledgeOfServer }) =>
|
|
801
|
+
run(async () => {
|
|
802
|
+
const bid = resolveBudgetId(budgetId);
|
|
803
|
+
let transactions;
|
|
804
|
+
let data;
|
|
805
|
+
const resourceFilters = [accountId, categoryId, payeeId, month].filter((value) => value !== undefined && value !== null && value !== "");
|
|
806
|
+
if (resourceFilters.length > 1) {
|
|
807
|
+
throw new Error("Provide only one of accountId, categoryId, payeeId, or month.");
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (accountId) {
|
|
811
|
+
({ data } = await api.transactions.getTransactionsByAccount(bid, accountId, sinceDate, type, lastKnowledgeOfServer));
|
|
812
|
+
transactions = data.transactions;
|
|
813
|
+
} else if (categoryId) {
|
|
814
|
+
({ data } = await api.transactions.getTransactionsByCategory(bid, categoryId, sinceDate, type, lastKnowledgeOfServer));
|
|
815
|
+
transactions = data.transactions;
|
|
816
|
+
} else if (payeeId) {
|
|
817
|
+
({ data } = await api.transactions.getTransactionsByPayee(bid, payeeId, sinceDate, type, lastKnowledgeOfServer));
|
|
818
|
+
transactions = data.transactions;
|
|
819
|
+
} else if (month) {
|
|
820
|
+
({ data } = await api.transactions.getTransactionsByMonth(bid, month, sinceDate, type, lastKnowledgeOfServer));
|
|
821
|
+
transactions = data.transactions;
|
|
822
|
+
} else {
|
|
823
|
+
({ data } = await api.transactions.getTransactions(bid, sinceDate, type, lastKnowledgeOfServer));
|
|
824
|
+
transactions = data.transactions;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
return ok(collection(data, "transactions", transactions.map(formatTransaction), lastKnowledgeOfServer));
|
|
828
|
+
})
|
|
829
|
+
);
|
|
830
|
+
|
|
831
|
+
server.registerTool(
|
|
832
|
+
"get_transaction",
|
|
833
|
+
{ description: "Get a single transaction by ID", inputSchema: {
|
|
834
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
835
|
+
transactionId: z.string().describe("Transaction ID"),
|
|
836
|
+
} },
|
|
837
|
+
({ budgetId, transactionId }) =>
|
|
838
|
+
run(async () => {
|
|
839
|
+
const { data } = await api.transactions.getTransactionById(resolveBudgetId(budgetId), transactionId);
|
|
840
|
+
return ok(formatTransaction(data.transaction));
|
|
841
|
+
})
|
|
842
|
+
);
|
|
843
|
+
|
|
844
|
+
server.registerTool(
|
|
845
|
+
"create_transaction",
|
|
846
|
+
{ description: "Create a new transaction. Amounts are in dollars (positive for inflows, negative for outflows). Note: future-dated transactions cannot be created here - use create_scheduled_transaction instead.", inputSchema: {
|
|
847
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
848
|
+
accountId: z.string().describe("Account ID"),
|
|
849
|
+
date: z.string().describe("Transaction date (YYYY-MM-DD)"),
|
|
850
|
+
amount: z.number().describe("Amount in dollars (negative for outflows, positive for inflows)"),
|
|
851
|
+
payeeId: z.string().optional().describe("Payee ID"),
|
|
852
|
+
payeeName: z.string().optional().describe("Payee name (creates new payee if no payeeId)"),
|
|
853
|
+
categoryId: z.string().optional().describe("Category ID"),
|
|
854
|
+
memo: z.string().optional().describe("Transaction memo"),
|
|
855
|
+
cleared: z.enum(["cleared", "uncleared", "reconciled"]).optional().describe("Cleared status"),
|
|
856
|
+
approved: z.boolean().optional().describe("Whether transaction is approved"),
|
|
857
|
+
flagColor: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).optional().describe("Flag color"),
|
|
858
|
+
importId: z.string().optional().describe("Unique import ID for deduplication (max 36 chars). If omitted and the transaction is later imported, duplicates may be created."),
|
|
859
|
+
subtransactions: z.array(z.object({
|
|
860
|
+
amount: z.number().describe("Subtransaction amount in dollars"),
|
|
861
|
+
categoryId: z.string().optional().describe("Category ID"),
|
|
862
|
+
payeeId: z.string().optional().describe("Payee ID"),
|
|
863
|
+
payeeName: z.string().optional().describe("Payee name"),
|
|
864
|
+
memo: z.string().optional().describe("Memo"),
|
|
865
|
+
})).optional().describe("Split transaction into subtransactions. The subtransaction amounts must sum to the total transaction amount."),
|
|
866
|
+
} },
|
|
867
|
+
({ budgetId, ...txnFields }) =>
|
|
868
|
+
run(async () => {
|
|
869
|
+
const { data } = await api.transactions.createTransaction(resolveBudgetId(budgetId), {
|
|
870
|
+
transaction: mapTransactionInput(txnFields),
|
|
871
|
+
});
|
|
872
|
+
return ok(formatTransaction(data.transaction));
|
|
873
|
+
})
|
|
874
|
+
);
|
|
875
|
+
|
|
876
|
+
server.registerTool(
|
|
877
|
+
"create_transactions",
|
|
878
|
+
{ description: "Create multiple transactions at once. Amounts are in dollars. Returns created transactions and any duplicate import IDs. Future-dated transactions are not supported - use create_scheduled_transaction instead.", inputSchema: {
|
|
879
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
880
|
+
transactions: z.array(z.object({
|
|
881
|
+
accountId: z.string().describe("Account ID"),
|
|
882
|
+
date: z.string().describe("Transaction date (YYYY-MM-DD)"),
|
|
883
|
+
amount: z.number().describe("Amount in dollars (negative for outflows, positive for inflows)"),
|
|
884
|
+
payeeId: z.string().optional().describe("Payee ID"),
|
|
885
|
+
payeeName: z.string().optional().describe("Payee name (creates new payee if no payeeId)"),
|
|
886
|
+
categoryId: z.string().optional().describe("Category ID"),
|
|
887
|
+
memo: z.string().optional().describe("Transaction memo"),
|
|
888
|
+
cleared: z.enum(["cleared", "uncleared", "reconciled"]).optional().describe("Cleared status"),
|
|
889
|
+
approved: z.boolean().optional().describe("Whether transaction is approved"),
|
|
890
|
+
flagColor: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).optional().describe("Flag color"),
|
|
891
|
+
importId: z.string().optional().describe("Unique import ID for deduplication (max 36 chars)"),
|
|
892
|
+
subtransactions: z.array(z.object({
|
|
893
|
+
amount: z.number().describe("Subtransaction amount in dollars"),
|
|
894
|
+
categoryId: z.string().optional().describe("Category ID"),
|
|
895
|
+
payeeId: z.string().optional().describe("Payee ID"),
|
|
896
|
+
payeeName: z.string().optional().describe("Payee name"),
|
|
897
|
+
memo: z.string().optional().describe("Memo"),
|
|
898
|
+
})).optional().describe("Split transaction into subtransactions"),
|
|
899
|
+
})).describe("Array of transactions to create"),
|
|
900
|
+
} },
|
|
901
|
+
({ budgetId, transactions: txns }) =>
|
|
902
|
+
run(async () => {
|
|
903
|
+
const { data } = await api.transactions.createTransactions(resolveBudgetId(budgetId), {
|
|
904
|
+
transactions: txns.map(mapTransactionInput),
|
|
905
|
+
});
|
|
906
|
+
return ok({
|
|
907
|
+
created: data.transactions?.map(formatTransaction),
|
|
908
|
+
duplicate_import_ids: data.duplicate_import_ids,
|
|
909
|
+
});
|
|
910
|
+
})
|
|
911
|
+
);
|
|
912
|
+
|
|
913
|
+
server.registerTool(
|
|
914
|
+
"update_transaction",
|
|
915
|
+
{ description: "Update an existing transaction. Only provided fields are changed. Amounts in dollars.", inputSchema: {
|
|
916
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
917
|
+
transactionId: z.string().describe("Transaction ID"),
|
|
918
|
+
accountId: z.string().optional().describe("Account ID"),
|
|
919
|
+
date: z.string().optional().describe("Transaction date (YYYY-MM-DD)"),
|
|
920
|
+
amount: z.number().optional().describe("Amount in dollars"),
|
|
921
|
+
payeeId: z.string().nullable().optional().describe("Payee ID (null to remove)"),
|
|
922
|
+
payeeName: z.string().nullable().optional().describe("Payee name (null to clear)"),
|
|
923
|
+
categoryId: z.string().nullable().optional().describe("Category ID (null to uncategorize)"),
|
|
924
|
+
memo: z.string().nullable().optional().describe("Transaction memo (null to clear)"),
|
|
925
|
+
cleared: z.enum(["cleared", "uncleared", "reconciled"]).optional().describe("Cleared status"),
|
|
926
|
+
approved: z.boolean().optional().describe("Whether transaction is approved"),
|
|
927
|
+
flagColor: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).nullable().optional().describe("Flag color (null to remove)"),
|
|
928
|
+
} },
|
|
929
|
+
({ budgetId, transactionId, accountId, date, amount, payeeId, payeeName, categoryId, memo, cleared, approved, flagColor }) =>
|
|
930
|
+
run(async () => {
|
|
931
|
+
const { data } = await api.transactions.updateTransaction(resolveBudgetId(budgetId), transactionId, {
|
|
932
|
+
transaction: mapTransactionUpdate({ accountId, date, amount, payeeId, payeeName, categoryId, memo, cleared, approved, flagColor }),
|
|
933
|
+
});
|
|
934
|
+
return ok(formatTransaction(data.transaction));
|
|
935
|
+
})
|
|
936
|
+
);
|
|
937
|
+
|
|
938
|
+
server.registerTool(
|
|
939
|
+
"delete_transaction",
|
|
940
|
+
{ description: "Delete a transaction", inputSchema: {
|
|
941
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
942
|
+
transactionId: z.string().describe("Transaction ID"),
|
|
943
|
+
} },
|
|
944
|
+
({ budgetId, transactionId }) =>
|
|
945
|
+
run(async () => {
|
|
946
|
+
const { data } = await api.transactions.deleteTransaction(resolveBudgetId(budgetId), transactionId);
|
|
947
|
+
return ok(formatTransaction(data.transaction));
|
|
948
|
+
})
|
|
949
|
+
);
|
|
950
|
+
|
|
951
|
+
server.registerTool(
|
|
952
|
+
"update_transactions",
|
|
953
|
+
{ description: "Batch update multiple transactions. Each transaction object must include its id and the fields to update.", inputSchema: {
|
|
954
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
955
|
+
transactions: z
|
|
956
|
+
.array(
|
|
957
|
+
z.object({
|
|
958
|
+
id: z.string().describe("Transaction ID"),
|
|
959
|
+
accountId: z.string().optional().describe("Account ID"),
|
|
960
|
+
date: z.string().optional().describe("Transaction date (YYYY-MM-DD)"),
|
|
961
|
+
amount: z.number().optional().describe("Amount in dollars"),
|
|
962
|
+
payeeId: z.string().nullable().optional().describe("Payee ID (null to remove)"),
|
|
963
|
+
payeeName: z.string().nullable().optional().describe("Payee name (null to clear)"),
|
|
964
|
+
categoryId: z.string().nullable().optional().describe("Category ID (null to uncategorize)"),
|
|
965
|
+
memo: z.string().nullable().optional().describe("Transaction memo (null to clear)"),
|
|
966
|
+
cleared: z.enum(["cleared", "uncleared", "reconciled"]).optional().describe("Cleared status"),
|
|
967
|
+
approved: z.boolean().optional().describe("Whether transaction is approved"),
|
|
968
|
+
flagColor: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).nullable().optional().describe("Flag color (null to remove)"),
|
|
969
|
+
})
|
|
970
|
+
)
|
|
971
|
+
.describe("Array of transaction updates"),
|
|
972
|
+
} },
|
|
973
|
+
({ budgetId, transactions: txns }) =>
|
|
974
|
+
run(async () => {
|
|
975
|
+
const mapped = txns.map((t) => ({ id: t.id, ...mapTransactionUpdate(t) }));
|
|
976
|
+
const { data } = await api.transactions.updateTransactions(resolveBudgetId(budgetId), {
|
|
977
|
+
transactions: mapped,
|
|
978
|
+
});
|
|
979
|
+
return ok({
|
|
980
|
+
updated: data.transactions?.map(formatTransaction),
|
|
981
|
+
duplicate_import_ids: data.duplicate_import_ids,
|
|
982
|
+
});
|
|
983
|
+
})
|
|
984
|
+
);
|
|
985
|
+
|
|
986
|
+
server.registerTool(
|
|
987
|
+
"import_transactions",
|
|
988
|
+
{ description: "Trigger import of linked account transactions", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
|
|
989
|
+
({ budgetId }) =>
|
|
990
|
+
run(async () => {
|
|
991
|
+
const { data } = await api.transactions.importTransactions(resolveBudgetId(budgetId));
|
|
992
|
+
return ok(data);
|
|
993
|
+
})
|
|
994
|
+
);
|
|
995
|
+
|
|
996
|
+
// ==================== Scheduled Transactions ====================
|
|
997
|
+
|
|
998
|
+
function formatScheduledTransaction(t) {
|
|
999
|
+
const out = {
|
|
1000
|
+
id: t.id,
|
|
1001
|
+
date_first: t.date_first,
|
|
1002
|
+
date_next: t.date_next,
|
|
1003
|
+
frequency: t.frequency,
|
|
1004
|
+
amount: dollars(t.amount),
|
|
1005
|
+
memo: t.memo ?? null,
|
|
1006
|
+
flag_color: t.flag_color ?? null,
|
|
1007
|
+
flag_name: t.flag_name ?? null,
|
|
1008
|
+
account_id: t.account_id,
|
|
1009
|
+
account_name: t.account_name,
|
|
1010
|
+
payee_id: t.payee_id ?? null,
|
|
1011
|
+
payee_name: t.payee_name ?? null,
|
|
1012
|
+
category_id: t.category_id ?? null,
|
|
1013
|
+
category_name: t.category_name ?? null,
|
|
1014
|
+
transfer_account_id: t.transfer_account_id ?? null,
|
|
1015
|
+
deleted: t.deleted,
|
|
1016
|
+
subtransactions: t.subtransactions?.map((s) =>
|
|
1017
|
+
withCurrencyFields(
|
|
1018
|
+
{
|
|
1019
|
+
id: s.id,
|
|
1020
|
+
scheduled_transaction_id: s.scheduled_transaction_id,
|
|
1021
|
+
amount: dollars(s.amount),
|
|
1022
|
+
memo: s.memo ?? null,
|
|
1023
|
+
payee_id: s.payee_id ?? null,
|
|
1024
|
+
payee_name: s.payee_name ?? null,
|
|
1025
|
+
category_id: s.category_id ?? null,
|
|
1026
|
+
category_name: s.category_name ?? null,
|
|
1027
|
+
transfer_account_id: s.transfer_account_id ?? null,
|
|
1028
|
+
deleted: s.deleted,
|
|
1029
|
+
},
|
|
1030
|
+
s,
|
|
1031
|
+
["amount"]
|
|
1032
|
+
)
|
|
1033
|
+
),
|
|
1034
|
+
};
|
|
1035
|
+
return withCurrencyFields(out, t, ["amount"]);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
server.registerTool(
|
|
1039
|
+
"list_scheduled_transactions",
|
|
1040
|
+
{ description: "List all scheduled (recurring) transactions", inputSchema: {
|
|
1041
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
1042
|
+
lastKnowledgeOfServer: z.number().int().nonnegative().optional().describe("Delta request server knowledge. When provided, returns { scheduled_transactions, server_knowledge }."),
|
|
1043
|
+
} },
|
|
1044
|
+
({ budgetId, lastKnowledgeOfServer }) =>
|
|
1045
|
+
run(async () => {
|
|
1046
|
+
const { data } = await api.scheduledTransactions.getScheduledTransactions(resolveBudgetId(budgetId), lastKnowledgeOfServer);
|
|
1047
|
+
const scheduledTransactions = data.scheduled_transactions.map(formatScheduledTransaction);
|
|
1048
|
+
return ok(collection(data, "scheduled_transactions", scheduledTransactions, lastKnowledgeOfServer));
|
|
1049
|
+
})
|
|
1050
|
+
);
|
|
1051
|
+
|
|
1052
|
+
server.registerTool(
|
|
1053
|
+
"get_scheduled_transaction",
|
|
1054
|
+
{ description: "Get a specific scheduled transaction", inputSchema: {
|
|
1055
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
1056
|
+
scheduledTransactionId: z.string().describe("Scheduled transaction ID"),
|
|
1057
|
+
} },
|
|
1058
|
+
({ budgetId, scheduledTransactionId }) =>
|
|
1059
|
+
run(async () => {
|
|
1060
|
+
const { data } = await api.scheduledTransactions.getScheduledTransactionById(resolveBudgetId(budgetId), scheduledTransactionId);
|
|
1061
|
+
return ok(formatScheduledTransaction(data.scheduled_transaction));
|
|
1062
|
+
})
|
|
1063
|
+
);
|
|
1064
|
+
|
|
1065
|
+
server.registerTool(
|
|
1066
|
+
"create_scheduled_transaction",
|
|
1067
|
+
{ description: "Create a new scheduled (recurring) transaction", inputSchema: {
|
|
1068
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
1069
|
+
accountId: z.string().describe("Account ID"),
|
|
1070
|
+
dateFirst: z.string().describe("First occurrence date (YYYY-MM-DD)"),
|
|
1071
|
+
frequency: z.enum(["never", "daily", "weekly", "everyOtherWeek", "twiceAMonth", "every4Weeks", "monthly", "everyOtherMonth", "every3Months", "every4Months", "twiceAYear", "yearly", "everyOtherYear"]).describe("Recurrence frequency"),
|
|
1072
|
+
amount: z.number().describe("Amount in dollars (negative for outflows)"),
|
|
1073
|
+
payeeId: z.string().optional().describe("Payee ID"),
|
|
1074
|
+
payeeName: z.string().optional().describe("Payee name"),
|
|
1075
|
+
categoryId: z.string().optional().describe("Category ID"),
|
|
1076
|
+
memo: z.string().optional().describe("Memo"),
|
|
1077
|
+
flagColor: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).optional().describe("Flag color"),
|
|
1078
|
+
} },
|
|
1079
|
+
({ budgetId, accountId, dateFirst, frequency, amount, payeeId, payeeName, categoryId, memo, flagColor }) =>
|
|
1080
|
+
run(async () => {
|
|
1081
|
+
const { data } = await api.scheduledTransactions.createScheduledTransaction(resolveBudgetId(budgetId), {
|
|
1082
|
+
scheduled_transaction: {
|
|
1083
|
+
account_id: accountId,
|
|
1084
|
+
date: dateFirst,
|
|
1085
|
+
frequency,
|
|
1086
|
+
amount: milliunits(amount),
|
|
1087
|
+
payee_id: payeeId,
|
|
1088
|
+
payee_name: payeeName,
|
|
1089
|
+
category_id: categoryId,
|
|
1090
|
+
memo,
|
|
1091
|
+
flag_color: flagColor,
|
|
1092
|
+
},
|
|
1093
|
+
});
|
|
1094
|
+
return ok(formatScheduledTransaction(data.scheduled_transaction));
|
|
1095
|
+
})
|
|
1096
|
+
);
|
|
1097
|
+
|
|
1098
|
+
server.registerTool(
|
|
1099
|
+
"update_scheduled_transaction",
|
|
1100
|
+
{ description: "Update an existing scheduled transaction. Only provided fields are changed. Amounts in dollars.", inputSchema: {
|
|
1101
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
1102
|
+
scheduledTransactionId: z.string().describe("Scheduled transaction ID"),
|
|
1103
|
+
accountId: z.string().optional().describe("Account ID"),
|
|
1104
|
+
date: z.string().optional().describe("Next occurrence date (YYYY-MM-DD)"),
|
|
1105
|
+
frequency: z.enum(["never", "daily", "weekly", "everyOtherWeek", "twiceAMonth", "every4Weeks", "monthly", "everyOtherMonth", "every3Months", "every4Months", "twiceAYear", "yearly", "everyOtherYear"]).optional().describe("Recurrence frequency"),
|
|
1106
|
+
amount: z.number().optional().describe("Amount in dollars (negative for outflows)"),
|
|
1107
|
+
payeeId: z.string().nullable().optional().describe("Payee ID"),
|
|
1108
|
+
payeeName: z.string().nullable().optional().describe("Payee name"),
|
|
1109
|
+
categoryId: z.string().nullable().optional().describe("Category ID"),
|
|
1110
|
+
memo: z.string().nullable().optional().describe("Memo"),
|
|
1111
|
+
flagColor: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).nullable().optional().describe("Flag color"),
|
|
1112
|
+
} },
|
|
1113
|
+
({ budgetId, scheduledTransactionId, accountId, date, frequency, amount, payeeId, payeeName, categoryId, memo, flagColor }) =>
|
|
1114
|
+
run(async () => {
|
|
1115
|
+
const bid = resolveBudgetId(budgetId);
|
|
1116
|
+
// PUT replaces the full resource - fetch current values to merge with updates
|
|
1117
|
+
const { data: current } = await api.scheduledTransactions.getScheduledTransactionById(bid, scheduledTransactionId);
|
|
1118
|
+
const existing = current.scheduled_transaction;
|
|
1119
|
+
|
|
1120
|
+
const st = {
|
|
1121
|
+
account_id: accountId ?? existing.account_id,
|
|
1122
|
+
date: date ?? existing.date_next,
|
|
1123
|
+
frequency: frequency ?? existing.frequency,
|
|
1124
|
+
amount: amount !== undefined ? milliunits(amount) : existing.amount,
|
|
1125
|
+
payee_id: payeeId !== undefined ? payeeId : existing.payee_id,
|
|
1126
|
+
payee_name: payeeName !== undefined ? payeeName : existing.payee_name,
|
|
1127
|
+
category_id: categoryId !== undefined ? categoryId : existing.category_id,
|
|
1128
|
+
memo: memo !== undefined ? memo : existing.memo,
|
|
1129
|
+
flag_color: flagColor !== undefined ? flagColor : existing.flag_color,
|
|
1130
|
+
};
|
|
1131
|
+
|
|
1132
|
+
const { data } = await api.scheduledTransactions.updateScheduledTransaction(
|
|
1133
|
+
bid,
|
|
1134
|
+
scheduledTransactionId,
|
|
1135
|
+
{ scheduled_transaction: st }
|
|
1136
|
+
);
|
|
1137
|
+
return ok(formatScheduledTransaction(data.scheduled_transaction));
|
|
1138
|
+
})
|
|
1139
|
+
);
|
|
1140
|
+
|
|
1141
|
+
server.registerTool(
|
|
1142
|
+
"delete_scheduled_transaction",
|
|
1143
|
+
{ description: "Delete a scheduled transaction", inputSchema: {
|
|
1144
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
1145
|
+
scheduledTransactionId: z.string().describe("Scheduled transaction ID"),
|
|
1146
|
+
} },
|
|
1147
|
+
({ budgetId, scheduledTransactionId }) =>
|
|
1148
|
+
run(async () => {
|
|
1149
|
+
const { data } = await api.scheduledTransactions.deleteScheduledTransaction(resolveBudgetId(budgetId), scheduledTransactionId);
|
|
1150
|
+
return ok(formatScheduledTransaction(data.scheduled_transaction));
|
|
1151
|
+
})
|
|
1152
|
+
);
|
|
1153
|
+
|
|
1154
|
+
// ==================== Convenience Tools ====================
|
|
1155
|
+
|
|
1156
|
+
server.registerTool(
|
|
1157
|
+
"search_categories",
|
|
1158
|
+
{ description: "Search categories by partial name match (case-insensitive). Useful for finding category IDs when you only know part of the name.", inputSchema: {
|
|
1159
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
1160
|
+
query: z.string().describe("Partial category name to search for (e.g. 'work' matches '💻 Work Expenses (Oliver LLC)')"),
|
|
1161
|
+
} },
|
|
1162
|
+
({ budgetId, query }) =>
|
|
1163
|
+
run(async () => {
|
|
1164
|
+
const { data } = await api.categories.getCategories(resolveBudgetId(budgetId));
|
|
1165
|
+
const q = query.toLowerCase();
|
|
1166
|
+
const matches = [];
|
|
1167
|
+
for (const g of data.category_groups) {
|
|
1168
|
+
if (g.hidden) continue;
|
|
1169
|
+
for (const c of g.categories) {
|
|
1170
|
+
if (c.hidden) continue;
|
|
1171
|
+
if (c.name.toLowerCase().includes(q)) {
|
|
1172
|
+
matches.push(withCurrencyFields({
|
|
1173
|
+
id: c.id,
|
|
1174
|
+
name: c.name,
|
|
1175
|
+
group: g.name,
|
|
1176
|
+
budgeted: dollars(c.budgeted),
|
|
1177
|
+
activity: dollars(c.activity),
|
|
1178
|
+
balance: dollars(c.balance),
|
|
1179
|
+
}, c, ["budgeted", "activity", "balance"]));
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
if (matches.length === 0) return ok({ message: `No categories matching "${query}"`, suggestions: "Try a shorter search term" });
|
|
1184
|
+
return ok(matches);
|
|
1185
|
+
})
|
|
1186
|
+
);
|
|
1187
|
+
|
|
1188
|
+
server.registerTool(
|
|
1189
|
+
"search_payees",
|
|
1190
|
+
{ description: "Search payees by partial name match (case-insensitive). Useful for finding payee IDs.", inputSchema: {
|
|
1191
|
+
budgetId: z.string().optional().describe("Budget ID (uses default if not provided)"),
|
|
1192
|
+
query: z.string().describe("Partial payee name to search for"),
|
|
1193
|
+
} },
|
|
1194
|
+
({ budgetId, query }) =>
|
|
1195
|
+
run(async () => {
|
|
1196
|
+
const { data } = await api.payees.getPayees(resolveBudgetId(budgetId));
|
|
1197
|
+
const q = query.toLowerCase();
|
|
1198
|
+
const matches = data.payees
|
|
1199
|
+
.filter((p) => p.name.toLowerCase().includes(q))
|
|
1200
|
+
.map((p) => ({ id: p.id, name: p.name, transfer_account_id: p.transfer_account_id, deleted: p.deleted }));
|
|
1201
|
+
if (matches.length === 0) return ok({ message: `No payees matching "${query}"` });
|
|
1202
|
+
return ok(matches);
|
|
1203
|
+
})
|
|
1204
|
+
);
|
|
1205
|
+
|
|
1206
|
+
server.registerTool(
|
|
1207
|
+
"review_unapproved",
|
|
1208
|
+
{ description: "Get all unapproved transactions grouped by status: those already categorized (ready to approve) and those still uncategorized (need category first). Never approve uncategorized transactions without explicit user instruction.", inputSchema: { budgetId: z.string().optional().describe("Budget ID (uses default if not provided)") } },
|
|
1209
|
+
({ budgetId }) =>
|
|
1210
|
+
run(async () => {
|
|
1211
|
+
const { data } = await api.transactions.getTransactions(resolveBudgetId(budgetId), undefined, "unapproved");
|
|
1212
|
+
const txns = data.transactions.map(formatTransaction);
|
|
1213
|
+
const isCategorized = (t) => (t.category_id && t.category_name !== "Uncategorized")
|
|
1214
|
+
|| (t.subtransactions && t.subtransactions.length > 0) // split transactions are categorized via subtransactions
|
|
1215
|
+
|| t.transfer_account_id; // transfers don't need categories
|
|
1216
|
+
const categorized = [], uncategorized = [];
|
|
1217
|
+
for (const t of txns) (isCategorized(t) ? categorized : uncategorized).push(t);
|
|
1218
|
+
return ok({
|
|
1219
|
+
total: txns.length,
|
|
1220
|
+
ready_to_approve: {
|
|
1221
|
+
count: categorized.length,
|
|
1222
|
+
transactions: categorized,
|
|
1223
|
+
},
|
|
1224
|
+
needs_category_first: {
|
|
1225
|
+
count: uncategorized.length,
|
|
1226
|
+
warning: "Do NOT approve these without assigning a category first",
|
|
1227
|
+
transactions: uncategorized,
|
|
1228
|
+
},
|
|
1229
|
+
});
|
|
1230
|
+
})
|
|
1231
|
+
);
|
|
1232
|
+
|
|
1233
|
+
// --- Start ---
|
|
1234
|
+
|
|
1235
|
+
process.on("uncaughtException", (err) => {
|
|
1236
|
+
console.error("Uncaught exception:", err);
|
|
1237
|
+
process.exit(1);
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
const transport = new StdioServerTransport();
|
|
1241
|
+
await server.connect(transport);
|