copilot-money-mcp 1.7.1 → 2.0.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.
Files changed (3) hide show
  1. package/dist/cli.js +1038 -1219
  2. package/dist/server.js +1038 -1219
  3. package/package.json +5 -5
package/dist/cli.js CHANGED
@@ -28537,7 +28537,6 @@ function getAccountDisplayName(account) {
28537
28537
  }
28538
28538
 
28539
28539
  // src/models/recurring.ts
28540
- var RECURRING_STATES = ["active", "paused", "archived"];
28541
28540
  var DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
28542
28541
  var RecurringSchema = exports_external.object({
28543
28542
  recurring_id: exports_external.string(),
@@ -28571,7 +28570,6 @@ function getRecurringDisplayName(recurring) {
28571
28570
  }
28572
28571
 
28573
28572
  // src/models/budget.ts
28574
- var KNOWN_PERIODS = ["monthly", "yearly", "weekly", "daily"];
28575
28573
  var DATE_REGEX2 = /^\d{4}-\d{2}-\d{2}$/;
28576
28574
  var BudgetSchema = exports_external.object({
28577
28575
  budget_id: exports_external.string(),
@@ -32208,33 +32206,661 @@ class CopilotDatabase {
32208
32206
  }
32209
32207
  }
32210
32208
 
32211
- // src/core/format/firestore-rest.ts
32212
- function toFirestoreValue(value) {
32213
- if (value === null)
32214
- return { nullValue: null };
32215
- if (typeof value === "string")
32216
- return { stringValue: value };
32217
- if (typeof value === "boolean")
32218
- return { booleanValue: value };
32219
- if (typeof value === "number") {
32220
- return Number.isInteger(value) ? { integerValue: String(value) } : { doubleValue: value };
32209
+ // src/core/graphql/client.ts
32210
+ var ENDPOINT = "https://app.copilot.money/api/graphql";
32211
+
32212
+ class GraphQLError extends Error {
32213
+ code;
32214
+ operationName;
32215
+ httpStatus;
32216
+ serverErrors;
32217
+ constructor(code, message, operationName, httpStatus, serverErrors) {
32218
+ super(message);
32219
+ this.code = code;
32220
+ this.operationName = operationName;
32221
+ this.httpStatus = httpStatus;
32222
+ this.serverErrors = serverErrors;
32223
+ this.name = "GraphQLError";
32221
32224
  }
32222
- if (Array.isArray(value)) {
32223
- return { arrayValue: { values: value.map(toFirestoreValue) } };
32225
+ }
32226
+
32227
+ class GraphQLClient {
32228
+ auth;
32229
+ constructor(auth) {
32230
+ this.auth = auth;
32224
32231
  }
32225
- if (typeof value === "object") {
32226
- return { mapValue: { fields: toFirestoreFields(value) } };
32232
+ async mutate(operationName, query, variables) {
32233
+ const idToken = await this.auth.getIdToken();
32234
+ let response;
32235
+ try {
32236
+ response = await fetch(ENDPOINT, {
32237
+ method: "POST",
32238
+ headers: {
32239
+ Authorization: `Bearer ${idToken}`,
32240
+ "Content-Type": "application/json"
32241
+ },
32242
+ body: JSON.stringify({ operationName, query, variables })
32243
+ });
32244
+ } catch (e) {
32245
+ const msg = e instanceof Error ? e.message : String(e);
32246
+ this.logError(operationName, "NETWORK", undefined);
32247
+ throw new GraphQLError("NETWORK", msg, operationName);
32248
+ }
32249
+ if (response.status === 401) {
32250
+ const text = await response.text().catch(() => "");
32251
+ this.logError(operationName, "AUTH_FAILED", 401);
32252
+ throw new GraphQLError("AUTH_FAILED", `401 Unauthorized: ${text || "no body"}`, operationName, 401);
32253
+ }
32254
+ if (response.status === 400 || response.status === 500) {
32255
+ const text = await response.text().catch(() => "");
32256
+ this.logError(operationName, "SCHEMA_ERROR", response.status);
32257
+ throw new GraphQLError("SCHEMA_ERROR", `${response.status} Schema Error: ${text || "no body"}`, operationName, response.status);
32258
+ }
32259
+ if (!response.ok) {
32260
+ const text = await response.text().catch(() => "");
32261
+ this.logError(operationName, "UNKNOWN", response.status);
32262
+ throw new GraphQLError("UNKNOWN", `${response.status}: ${text || "no body"}`, operationName, response.status);
32263
+ }
32264
+ let body;
32265
+ try {
32266
+ body = await response.json();
32267
+ } catch (e) {
32268
+ const msg = e instanceof Error ? e.message : String(e);
32269
+ this.logError(operationName, "UNKNOWN", response.status);
32270
+ throw new GraphQLError("UNKNOWN", `Invalid JSON response: ${msg}`, operationName, response.status);
32271
+ }
32272
+ if (body.errors && body.errors.length > 0) {
32273
+ const firstMessage = body.errors[0]?.message ?? "GraphQL error (no message)";
32274
+ this.logError(operationName, "USER_ACTION_REQUIRED", response.status);
32275
+ throw new GraphQLError("USER_ACTION_REQUIRED", firstMessage, operationName, response.status, body.errors);
32276
+ }
32277
+ if (!body.data) {
32278
+ this.logError(operationName, "UNKNOWN", response.status);
32279
+ throw new GraphQLError("UNKNOWN", "Response missing data field", operationName, response.status);
32280
+ }
32281
+ return body.data;
32282
+ }
32283
+ logError(operationName, code, httpStatus) {
32284
+ const statusPart = httpStatus !== undefined ? ` status=${httpStatus}` : "";
32285
+ console.error(`[graphql] ${operationName} failed: code=${code}${statusPart}`);
32227
32286
  }
32228
- throw new Error(`Unsupported value type: ${typeof value}`);
32229
32287
  }
32230
- function toFirestoreFields(obj) {
32231
- const fields = {};
32232
- for (const [key, value] of Object.entries(obj)) {
32233
- if (value !== undefined) {
32234
- fields[key] = toFirestoreValue(value);
32235
- }
32288
+
32289
+ // src/core/graphql/operations.generated.ts
32290
+ var EDIT_TRANSACTION = `mutation EditTransaction($itemId: ID!, $accountId: ID!, $id: ID!, $input: EditTransactionInput) {
32291
+ editTransaction(itemId: $itemId, accountId: $accountId, id: $id, input: $input) {
32292
+ __typename
32293
+ transaction {
32294
+ __typename
32295
+ ...TransactionFields
32296
+ }
32297
+ }
32298
+ }
32299
+
32300
+ fragment TagFields on Tag {
32301
+ __typename
32302
+ colorName
32303
+ name
32304
+ id
32305
+ }
32306
+
32307
+ fragment GoalFields on Goal {
32308
+ __typename
32309
+ name
32310
+ icon {
32311
+ __typename
32312
+ ... on EmojiUnicode {
32313
+ __typename
32314
+ unicode
32315
+ }
32316
+ ... on Genmoji {
32317
+ __typename
32318
+ id
32319
+ src
32320
+ }
32321
+ }
32322
+ id
32323
+ }
32324
+
32325
+ fragment TransactionFields on Transaction {
32326
+ __typename
32327
+ suggestedCategoryIds
32328
+ recurringId
32329
+ categoryId
32330
+ isReviewed
32331
+ accountId
32332
+ createdAt
32333
+ isPending
32334
+ tipAmount
32335
+ userNotes
32336
+ itemId
32337
+ amount
32338
+ date
32339
+ name
32340
+ type
32341
+ id
32342
+ tags {
32343
+ __typename
32344
+ ...TagFields
32345
+ }
32346
+ goal {
32347
+ __typename
32348
+ ...GoalFields
32349
+ }
32350
+ }`;
32351
+ var CREATE_CATEGORY = `mutation CreateCategory($input: CreateCategoryInput!, $spend: Boolean = false, $budget: Boolean = false) {
32352
+ createCategory(input: $input) {
32353
+ __typename
32354
+ ...CategoryFields
32355
+ spend @include(if: $spend) {
32356
+ __typename
32357
+ ...SpendFields
32358
+ }
32359
+ budget @include(if: $budget) {
32360
+ __typename
32361
+ ...BudgetFields
32362
+ }
32363
+ childCategories {
32364
+ __typename
32365
+ ...CategoryFields
32366
+ spend @include(if: $spend) {
32367
+ __typename
32368
+ ...SpendFields
32369
+ }
32370
+ budget @include(if: $budget) {
32371
+ __typename
32372
+ ...BudgetFields
32373
+ }
32374
+ }
32375
+ }
32376
+ }
32377
+
32378
+ fragment SpendMonthlyFields on CategoryMonthlySpent {
32379
+ __typename
32380
+ unpaidRecurringAmount
32381
+ paidRecurringAmount
32382
+ comparisonAmount
32383
+ amount
32384
+ month
32385
+ id
32386
+ }
32387
+
32388
+ fragment BudgetMonthlyFields on CategoryMonthlyBudget {
32389
+ __typename
32390
+ unassignedRolloverAmount
32391
+ childRolloverAmount
32392
+ unassignedAmount
32393
+ resolvedAmount
32394
+ rolloverAmount
32395
+ childAmount
32396
+ goalAmount
32397
+ amount
32398
+ month
32399
+ id
32400
+ }
32401
+
32402
+ fragment CategoryFields on Category {
32403
+ __typename
32404
+ isRolloverDisabled
32405
+ canBeDeleted
32406
+ isExcluded
32407
+ templateId
32408
+ colorName
32409
+ icon {
32410
+ __typename
32411
+ ... on EmojiUnicode {
32412
+ __typename
32413
+ unicode
32414
+ }
32415
+ ... on Genmoji {
32416
+ __typename
32417
+ id
32418
+ src
32419
+ }
32420
+ }
32421
+ name
32422
+ id
32423
+ }
32424
+
32425
+ fragment SpendFields on CategorySpend {
32426
+ __typename
32427
+ current {
32428
+ __typename
32429
+ ...SpendMonthlyFields
32430
+ }
32431
+ histories {
32432
+ __typename
32433
+ ...SpendMonthlyFields
32434
+ }
32435
+ }
32436
+
32437
+ fragment BudgetFields on CategoryBudget {
32438
+ __typename
32439
+ current {
32440
+ __typename
32441
+ ...BudgetMonthlyFields
32442
+ }
32443
+ histories {
32444
+ __typename
32445
+ ...BudgetMonthlyFields
32446
+ }
32447
+ }`;
32448
+ var EDIT_CATEGORY = `mutation EditCategory($id: ID!, $input: EditCategoryInput!, $spend: Boolean = false, $budget: Boolean = false, $rollovers: Boolean = false) {
32449
+ editCategory(id: $id, input: $input) {
32450
+ __typename
32451
+ category {
32452
+ __typename
32453
+ ...CategoryFields
32454
+ spend @include(if: $spend) {
32455
+ __typename
32456
+ ...SpendFields
32457
+ }
32458
+ budget(isRolloverEnabled: $rollovers) @include(if: $budget) {
32459
+ __typename
32460
+ ...BudgetFields
32461
+ }
32462
+ }
32463
+ }
32464
+ }
32465
+
32466
+ fragment SpendMonthlyFields on CategoryMonthlySpent {
32467
+ __typename
32468
+ unpaidRecurringAmount
32469
+ paidRecurringAmount
32470
+ comparisonAmount
32471
+ amount
32472
+ month
32473
+ id
32474
+ }
32475
+
32476
+ fragment BudgetMonthlyFields on CategoryMonthlyBudget {
32477
+ __typename
32478
+ unassignedRolloverAmount
32479
+ childRolloverAmount
32480
+ unassignedAmount
32481
+ resolvedAmount
32482
+ rolloverAmount
32483
+ childAmount
32484
+ goalAmount
32485
+ amount
32486
+ month
32487
+ id
32488
+ }
32489
+
32490
+ fragment CategoryFields on Category {
32491
+ __typename
32492
+ isRolloverDisabled
32493
+ canBeDeleted
32494
+ isExcluded
32495
+ templateId
32496
+ colorName
32497
+ icon {
32498
+ __typename
32499
+ ... on EmojiUnicode {
32500
+ __typename
32501
+ unicode
32502
+ }
32503
+ ... on Genmoji {
32504
+ __typename
32505
+ id
32506
+ src
32507
+ }
32508
+ }
32509
+ name
32510
+ id
32511
+ }
32512
+
32513
+ fragment SpendFields on CategorySpend {
32514
+ __typename
32515
+ current {
32516
+ __typename
32517
+ ...SpendMonthlyFields
32518
+ }
32519
+ histories {
32520
+ __typename
32521
+ ...SpendMonthlyFields
32522
+ }
32523
+ }
32524
+
32525
+ fragment BudgetFields on CategoryBudget {
32526
+ __typename
32527
+ current {
32528
+ __typename
32529
+ ...BudgetMonthlyFields
32530
+ }
32531
+ histories {
32532
+ __typename
32533
+ ...BudgetMonthlyFields
32534
+ }
32535
+ }`;
32536
+ var DELETE_CATEGORY = `mutation DeleteCategory($id: ID!) {
32537
+ deleteCategory(id: $id)
32538
+ }`;
32539
+ var CREATE_TAG = `mutation CreateTag($input: CreateTagInput!) {
32540
+ createTag(input: $input) {
32541
+ __typename
32542
+ ...TagFields
32543
+ }
32544
+ }
32545
+
32546
+ fragment TagFields on Tag {
32547
+ __typename
32548
+ colorName
32549
+ name
32550
+ id
32551
+ }`;
32552
+ var EDIT_TAG = `mutation EditTag($id: ID!, $input: EditTagInput!) {
32553
+ editTag(id: $id, input: $input) {
32554
+ __typename
32555
+ ...TagFields
32556
+ }
32557
+ }
32558
+
32559
+ fragment TagFields on Tag {
32560
+ __typename
32561
+ colorName
32562
+ name
32563
+ id
32564
+ }`;
32565
+ var DELETE_TAG = `mutation DeleteTag($id: ID!) {
32566
+ deleteTag(id: $id)
32567
+ }`;
32568
+ var CREATE_RECURRING = `mutation CreateRecurring($input: CreateRecurringInput!) {
32569
+ createRecurring(input: $input) {
32570
+ __typename
32571
+ ...RecurringFields
32572
+ rule {
32573
+ __typename
32574
+ ...RecurringRuleFields
32575
+ }
32576
+ payments {
32577
+ __typename
32578
+ ...RecurringPaymentFields
32579
+ }
32580
+ }
32581
+ }
32582
+
32583
+ fragment RecurringFields on Recurring {
32584
+ __typename
32585
+ nextPaymentAmount
32586
+ nextPaymentDate
32587
+ categoryId
32588
+ frequency
32589
+ emoji
32590
+ icon {
32591
+ __typename
32592
+ ... on EmojiUnicode {
32593
+ __typename
32594
+ unicode
32595
+ }
32596
+ ... on Genmoji {
32597
+ __typename
32598
+ id
32599
+ src
32600
+ }
32601
+ }
32602
+ state
32603
+ name
32604
+ id
32605
+ }
32606
+
32607
+ fragment RecurringRuleFields on RecurringRule {
32608
+ __typename
32609
+ nameContains
32610
+ minAmount
32611
+ maxAmount
32612
+ days
32613
+ }
32614
+
32615
+ fragment RecurringPaymentFields on RecurringPayment {
32616
+ __typename
32617
+ amount
32618
+ isPaid
32619
+ date
32620
+ }`;
32621
+ var EDIT_RECURRING = `mutation EditRecurring($id: ID!, $input: EditRecurringInput!) {
32622
+ editRecurring(id: $id, input: $input) {
32623
+ __typename
32624
+ recurring {
32625
+ __typename
32626
+ ...RecurringFields
32627
+ rule {
32628
+ __typename
32629
+ ...RecurringRuleFields
32630
+ }
32631
+ payments {
32632
+ __typename
32633
+ ...RecurringPaymentFields
32634
+ }
32635
+ }
32636
+ }
32637
+ }
32638
+
32639
+ fragment RecurringFields on Recurring {
32640
+ __typename
32641
+ nextPaymentAmount
32642
+ nextPaymentDate
32643
+ categoryId
32644
+ frequency
32645
+ emoji
32646
+ icon {
32647
+ __typename
32648
+ ... on EmojiUnicode {
32649
+ __typename
32650
+ unicode
32651
+ }
32652
+ ... on Genmoji {
32653
+ __typename
32654
+ id
32655
+ src
32656
+ }
32657
+ }
32658
+ state
32659
+ name
32660
+ id
32661
+ }
32662
+
32663
+ fragment RecurringRuleFields on RecurringRule {
32664
+ __typename
32665
+ nameContains
32666
+ minAmount
32667
+ maxAmount
32668
+ days
32669
+ }
32670
+
32671
+ fragment RecurringPaymentFields on RecurringPayment {
32672
+ __typename
32673
+ amount
32674
+ isPaid
32675
+ date
32676
+ }`;
32677
+ var DELETE_RECURRING = `mutation DeleteRecurring($deleteRecurringId: ID!) {
32678
+ deleteRecurring(id: $deleteRecurringId)
32679
+ }`;
32680
+ var EDIT_BUDGET = `mutation EditBudget($categoryId: ID!, $input: EditCategoryBudgetInput!) {
32681
+ editCategoryBudget(categoryId: $categoryId, input: $input)
32682
+ }`;
32683
+ var EDIT_BUDGET_MONTHLY = `mutation EditBudgetMonthly($categoryId: ID!, $input: [EditCategoryBudgetMonthlyInput!]!) {
32684
+ editCategoryBudgetMonthly(categoryId: $categoryId, input: $input)
32685
+ }`;
32686
+
32687
+ // src/core/graphql/transactions.ts
32688
+ async function editTransaction(client, args) {
32689
+ const data = await client.mutate("EditTransaction", EDIT_TRANSACTION, args);
32690
+ const tx = data.editTransaction.transaction;
32691
+ const changed = {};
32692
+ if ("categoryId" in args.input)
32693
+ changed.categoryId = tx.categoryId;
32694
+ if ("userNotes" in args.input)
32695
+ changed.userNotes = tx.userNotes;
32696
+ if ("isReviewed" in args.input)
32697
+ changed.isReviewed = tx.isReviewed;
32698
+ if ("tagIds" in args.input)
32699
+ changed.tagIds = tx.tags.map((t) => t.id);
32700
+ return { id: tx.id, changed };
32701
+ }
32702
+
32703
+ // src/core/graphql/categories.ts
32704
+ async function createCategory(client, args) {
32705
+ const data = await client.mutate("CreateCategory", CREATE_CATEGORY, { spend: false, budget: false, input: args.input });
32706
+ return {
32707
+ id: data.createCategory.id,
32708
+ name: data.createCategory.name,
32709
+ colorName: data.createCategory.colorName
32710
+ };
32711
+ }
32712
+ async function editCategory(client, args) {
32713
+ const data = await client.mutate("EditCategory", EDIT_CATEGORY, {
32714
+ id: args.id,
32715
+ spend: false,
32716
+ budget: false,
32717
+ input: args.input
32718
+ });
32719
+ const cat = data.editCategory.category;
32720
+ const changed = {};
32721
+ if ("name" in args.input)
32722
+ changed.name = cat.name;
32723
+ if ("colorName" in args.input)
32724
+ changed.colorName = cat.colorName;
32725
+ if ("emoji" in args.input)
32726
+ changed.emoji = args.input.emoji;
32727
+ if ("isExcluded" in args.input)
32728
+ changed.isExcluded = args.input.isExcluded;
32729
+ return { id: cat.id, changed };
32730
+ }
32731
+ async function deleteCategory(client, args) {
32732
+ await client.mutate("DeleteCategory", DELETE_CATEGORY, { id: args.id });
32733
+ return { id: args.id, deleted: true };
32734
+ }
32735
+
32736
+ // src/core/graphql/tags.ts
32737
+ async function createTag(client, args) {
32738
+ const data = await client.mutate("CreateTag", CREATE_TAG, args);
32739
+ return {
32740
+ id: data.createTag.id,
32741
+ name: data.createTag.name,
32742
+ colorName: data.createTag.colorName
32743
+ };
32744
+ }
32745
+ async function editTag(client, args) {
32746
+ const data = await client.mutate("EditTag", EDIT_TAG, args);
32747
+ const tag = data.editTag;
32748
+ const changed = {};
32749
+ if ("name" in args.input)
32750
+ changed.name = tag.name;
32751
+ if ("colorName" in args.input)
32752
+ changed.colorName = tag.colorName;
32753
+ return { id: tag.id, changed };
32754
+ }
32755
+ async function deleteTag(client, args) {
32756
+ await client.mutate("DeleteTag", DELETE_TAG, {
32757
+ id: args.id
32758
+ });
32759
+ return { id: args.id, deleted: true };
32760
+ }
32761
+
32762
+ // src/core/graphql/recurrings.ts
32763
+ async function createRecurring(client, args) {
32764
+ const data = await client.mutate("CreateRecurring", CREATE_RECURRING, args);
32765
+ return {
32766
+ id: data.createRecurring.id,
32767
+ name: data.createRecurring.name,
32768
+ state: data.createRecurring.state,
32769
+ frequency: data.createRecurring.frequency
32770
+ };
32771
+ }
32772
+ function toFloatOrThrow(value, path3) {
32773
+ if (!/^\d+(\.\d+)?$/.test(value)) {
32774
+ throw new Error(`editRecurring: invalid ${path3} ${value}`);
32775
+ }
32776
+ const n = parseFloat(value);
32777
+ if (!Number.isFinite(n) || n < 0) {
32778
+ throw new Error(`editRecurring: invalid ${path3} ${value}`);
32779
+ }
32780
+ return n;
32781
+ }
32782
+ async function editRecurring(client, args) {
32783
+ const wireInput = {};
32784
+ if ("state" in args.input)
32785
+ wireInput.state = args.input.state;
32786
+ if ("rule" in args.input && args.input.rule) {
32787
+ const rule = args.input.rule;
32788
+ const wireRule = {};
32789
+ if ("nameContains" in rule)
32790
+ wireRule.nameContains = rule.nameContains;
32791
+ if ("minAmount" in rule && rule.minAmount !== undefined) {
32792
+ wireRule.minAmount = toFloatOrThrow(rule.minAmount, "rule.minAmount");
32793
+ }
32794
+ if ("maxAmount" in rule && rule.maxAmount !== undefined) {
32795
+ wireRule.maxAmount = toFloatOrThrow(rule.maxAmount, "rule.maxAmount");
32796
+ }
32797
+ if ("days" in rule)
32798
+ wireRule.days = rule.days;
32799
+ wireInput.rule = wireRule;
32800
+ }
32801
+ const data = await client.mutate("EditRecurring", EDIT_RECURRING, { id: args.id, input: wireInput });
32802
+ const recurring = data.editRecurring.recurring;
32803
+ const changed = {};
32804
+ if ("state" in args.input)
32805
+ changed.state = recurring.state;
32806
+ if ("rule" in args.input && recurring.rule) {
32807
+ const serverRule = recurring.rule;
32808
+ const stringRule = {};
32809
+ if ("nameContains" in serverRule)
32810
+ stringRule.nameContains = serverRule.nameContains;
32811
+ if ("minAmount" in serverRule && serverRule.minAmount !== undefined) {
32812
+ stringRule.minAmount = String(serverRule.minAmount);
32813
+ }
32814
+ if ("maxAmount" in serverRule && serverRule.maxAmount !== undefined) {
32815
+ stringRule.maxAmount = String(serverRule.maxAmount);
32816
+ }
32817
+ if ("days" in serverRule)
32818
+ stringRule.days = serverRule.days;
32819
+ changed.rule = stringRule;
32820
+ }
32821
+ return { id: recurring.id, changed };
32822
+ }
32823
+ async function deleteRecurring(client, args) {
32824
+ await client.mutate("DeleteRecurring", DELETE_RECURRING, { deleteRecurringId: args.id });
32825
+ return { id: args.id, deleted: true };
32826
+ }
32827
+
32828
+ // src/core/graphql/budgets.ts
32829
+ async function setBudget(client, args) {
32830
+ const cleared = args.amount === "0";
32831
+ const amountFloat = parseFloat(args.amount);
32832
+ if (!Number.isFinite(amountFloat) || amountFloat < 0) {
32833
+ throw new Error(`setBudget: invalid amount ${args.amount}`);
32834
+ }
32835
+ if (args.month) {
32836
+ await client.mutate("EditBudgetMonthly", EDIT_BUDGET_MONTHLY, {
32837
+ categoryId: args.categoryId,
32838
+ input: [{ amount: amountFloat, month: args.month }]
32839
+ });
32840
+ return { categoryId: args.categoryId, amount: args.amount, month: args.month, cleared };
32841
+ }
32842
+ await client.mutate("EditBudget", EDIT_BUDGET, {
32843
+ categoryId: args.categoryId,
32844
+ input: { amount: amountFloat }
32845
+ });
32846
+ return { categoryId: args.categoryId, amount: args.amount, cleared };
32847
+ }
32848
+
32849
+ // src/tools/errors.ts
32850
+ function graphQLErrorToMcpError(e) {
32851
+ switch (e.code) {
32852
+ case "AUTH_FAILED":
32853
+ return "Authentication with Copilot failed. Sign in to the Copilot web app and try again.";
32854
+ case "SCHEMA_ERROR":
32855
+ return "Copilot's API changed in a way this tool doesn't handle yet. Please report this issue.";
32856
+ case "USER_ACTION_REQUIRED":
32857
+ return e.message;
32858
+ case "NETWORK":
32859
+ return `Network error contacting Copilot: ${e.message}`;
32860
+ case "UNKNOWN":
32861
+ default:
32862
+ return `Copilot API request failed: ${e.message}`;
32236
32863
  }
32237
- return fields;
32238
32864
  }
32239
32865
 
32240
32866
  // src/utils/date.ts
@@ -32317,28 +32943,11 @@ function validateDocId(id, label) {
32317
32943
  throw new Error(`Invalid ${label} format: ${id}`);
32318
32944
  }
32319
32945
  }
32320
- var HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/;
32321
- function validateHexColor(color) {
32322
- if (!HEX_COLOR_RE.test(color)) {
32323
- throw new Error(`Invalid color format: ${color} (expected #RRGGBB)`);
32324
- }
32325
- }
32326
- function hexToBgColor(hex3) {
32327
- const r = parseInt(hex3.slice(1, 3), 16);
32328
- const g = parseInt(hex3.slice(3, 5), 16);
32329
- const b = parseInt(hex3.slice(5, 7), 16);
32330
- const t = 0.05;
32331
- const blend = (c) => Math.round(255 - t * (255 - c));
32332
- const toHex = (n) => n.toString(16).padStart(2, "0").toUpperCase();
32333
- return `#${toHex(blend(r))}${toHex(blend(g))}${toHex(blend(b))}`;
32334
- }
32335
32946
  var CATEGORY_FOREIGN_TX_FEE_SNAKE = "bank_fees_foreign_transaction_fees";
32336
32947
  var CATEGORY_FOREIGN_TX_FEE_NUMERIC = "10005000";
32337
32948
  var MAX_QUERY_LIMIT = 1e4;
32338
32949
  var DEFAULT_QUERY_LIMIT = 100;
32339
32950
  var MIN_QUERY_LIMIT = 1;
32340
- var REVIEW_BATCH_SIZE = 10;
32341
- var VALID_RECURRING_FREQUENCIES = ["weekly", "biweekly", "monthly", "yearly"];
32342
32951
  function validateLimit(limit, defaultValue = DEFAULT_QUERY_LIMIT) {
32343
32952
  if (limit === undefined)
32344
32953
  return defaultValue;
@@ -32417,18 +33026,18 @@ function normalizeMerchantName(name) {
32417
33026
 
32418
33027
  class CopilotMoneyTools {
32419
33028
  db;
32420
- firestoreClient;
33029
+ graphqlClient;
32421
33030
  _userCategoryMap = null;
32422
33031
  _excludedCategoryIds = null;
32423
- constructor(database, firestoreClient) {
33032
+ constructor(database, graphqlClient) {
32424
33033
  this.db = database;
32425
- this.firestoreClient = firestoreClient ?? null;
33034
+ this.graphqlClient = graphqlClient ?? null;
32426
33035
  }
32427
- getFirestoreClient() {
32428
- if (!this.firestoreClient) {
32429
- throw new Error("Write mode is not enabled. Start the server with --write to use write tools.");
33036
+ getGraphQLClient() {
33037
+ if (!this.graphqlClient) {
33038
+ throw new Error("Write tools require --write flag to be set");
32430
33039
  }
32431
- return this.firestoreClient;
33040
+ return this.graphqlClient;
32432
33041
  }
32433
33042
  async getUserCategoryMap() {
32434
33043
  if (this._userCategoryMap === null) {
@@ -33451,92 +34060,41 @@ class CopilotMoneyTools {
33451
34060
  };
33452
34061
  }
33453
34062
  async createCategory(args) {
33454
- const client = this.getFirestoreClient();
33455
- const { name, emoji: emoji3, color, parent_category_id, excluded = false } = args;
33456
- if (!name.trim()) {
34063
+ const client = this.getGraphQLClient();
34064
+ if (!args.name?.trim())
33457
34065
  throw new Error("Category name must not be empty");
34066
+ if (!args.color_name?.trim())
34067
+ throw new Error("color_name is required");
34068
+ if (!args.emoji?.trim())
34069
+ throw new Error("emoji is required");
34070
+ if (args.parent_id !== undefined) {
34071
+ throw new Error("parent_id is not supported on create_category: Copilot's GraphQL API " + "does not accept parentId on CreateCategoryInput. Create the category " + "without a parent; the Copilot web app does not currently expose a " + "mutation to re-parent categories.");
33458
34072
  }
33459
- const existingCategories = await this.db.getUserCategories();
33460
- if (parent_category_id) {
33461
- validateDocId(parent_category_id, "parent_category_id");
33462
- const parent = existingCategories.find((c) => c.category_id === parent_category_id);
33463
- if (!parent) {
33464
- throw new Error(`Parent category not found: ${parent_category_id}`);
33465
- }
33466
- }
33467
- const duplicate = existingCategories.find((c) => c.name?.toLowerCase() === name.trim().toLowerCase());
33468
- if (duplicate) {
33469
- throw new Error(`Category with name "${name.trim()}" already exists (id: ${duplicate.category_id})`);
33470
- }
33471
- const userIdFromCategories = existingCategories.find((c) => c.user_id)?.user_id;
33472
- const userId = userIdFromCategories ?? await client.requireUserId();
33473
- const maxOrder = existingCategories.reduce((max, c) => Math.max(max, typeof c.order === "number" ? c.order : -1), -1);
33474
- const trimmedName = name.trim();
33475
- if (color)
33476
- validateHexColor(color);
33477
- const categoryColor = color ?? "#808080";
33478
- const categoryEmoji = emoji3 ?? "\uD83D\uDCC1";
33479
- const docFields = {
33480
- name: trimmedName,
33481
- emoji: categoryEmoji,
33482
- color: categoryColor,
33483
- bg_color: hexToBgColor(categoryColor),
33484
- order: maxOrder + 1,
33485
- excluded,
33486
- is_other: false,
33487
- auto_budget_lock: false,
33488
- auto_delete_lock: false,
33489
- plaid_category_ids: [],
33490
- partial_name_rules: []
33491
- };
33492
- if (parent_category_id)
33493
- docFields.parent_category_id = parent_category_id;
33494
- const collectionPath = `users/${userId}/categories`;
33495
- const firestoreFields = toFirestoreFields(docFields);
33496
- const categoryId = await client.createDocument(collectionPath, undefined, firestoreFields);
33497
- const idFields = toFirestoreFields({ id: categoryId });
33498
- await client.updateDocument(collectionPath, categoryId, idFields, ["id"]);
33499
- this.db.clearCache();
33500
- this._userCategoryMap = null;
33501
- const result = {
33502
- success: true,
33503
- category_id: categoryId,
33504
- name: trimmedName,
33505
- emoji: categoryEmoji,
33506
- color: categoryColor,
33507
- excluded
33508
- };
33509
- if (parent_category_id)
33510
- result.parent_category_id = parent_category_id;
33511
- return result;
33512
- }
33513
- async resolveTransaction(transactionId) {
33514
- validateDocId(transactionId, "transaction_id");
33515
- const transactions = await this.db.getAllTransactions();
33516
- const txn = transactions.find((t) => t.transaction_id === transactionId);
33517
- if (!txn) {
33518
- throw new Error(`Transaction not found: ${transactionId}`);
33519
- }
33520
- if (!txn.item_id || !txn.account_id) {
33521
- throw new Error(`Transaction ${transactionId} is missing item_id or account_id — cannot determine Firestore path`);
34073
+ try {
34074
+ const result = await createCategory(client, {
34075
+ input: {
34076
+ name: args.name.trim(),
34077
+ colorName: args.color_name,
34078
+ emoji: args.emoji,
34079
+ isExcluded: args.is_excluded ?? false
34080
+ }
34081
+ });
34082
+ return {
34083
+ success: true,
34084
+ category_id: result.id,
34085
+ name: result.name,
34086
+ color_name: result.colorName
34087
+ };
34088
+ } catch (e) {
34089
+ if (e instanceof GraphQLError)
34090
+ throw new Error(graphQLErrorToMcpError(e), { cause: e });
34091
+ throw e;
33522
34092
  }
33523
- return {
33524
- txn,
33525
- collectionPath: `items/${txn.item_id}/accounts/${txn.account_id}/transactions`
33526
- };
33527
34093
  }
33528
34094
  async updateTransaction(args) {
34095
+ const client = this.getGraphQLClient();
33529
34096
  const { transaction_id } = args;
33530
- const allowedKeys = new Set([
33531
- "transaction_id",
33532
- "category_id",
33533
- "note",
33534
- "tag_ids",
33535
- "excluded",
33536
- "name",
33537
- "internal_transfer",
33538
- "goal_id"
33539
- ]);
34097
+ const allowedKeys = new Set(["transaction_id", "category_id", "note", "tag_ids"]);
33540
34098
  for (const key of Object.keys(args)) {
33541
34099
  if (!allowedKeys.has(key)) {
33542
34100
  throw new Error(`update_transaction: unknown field "${key}"`);
@@ -33546,19 +34104,16 @@ class CopilotMoneyTools {
33546
34104
  if (mutableKeys.length === 0) {
33547
34105
  throw new Error("update_transaction requires at least one field to update");
33548
34106
  }
33549
- const { collectionPath } = await this.resolveTransaction(transaction_id);
33550
- let trimmedName;
33551
- if ("name" in args && args.name !== undefined) {
33552
- trimmedName = args.name.trim();
33553
- if (trimmedName.length === 0) {
33554
- throw new Error("Transaction name must not be empty");
33555
- }
34107
+ validateDocId(transaction_id, "transaction_id");
34108
+ const allTxns = await this.db.getAllTransactions();
34109
+ const txn = allTxns.find((t) => t.transaction_id === transaction_id);
34110
+ if (!txn) {
34111
+ throw new Error(`Transaction not found: ${transaction_id}`);
33556
34112
  }
33557
34113
  if ("category_id" in args && args.category_id !== undefined) {
33558
34114
  validateDocId(args.category_id, "category_id");
33559
34115
  const categories = await this.db.getUserCategories();
33560
- const category = categories.find((c) => c.category_id === args.category_id);
33561
- if (!category) {
34116
+ if (!categories.find((c) => c.category_id === args.category_id)) {
33562
34117
  throw new Error(`Category not found: ${args.category_id}`);
33563
34118
  }
33564
34119
  }
@@ -33575,61 +34130,44 @@ class CopilotMoneyTools {
33575
34130
  }
33576
34131
  }
33577
34132
  }
33578
- if ("goal_id" in args && args.goal_id !== null && args.goal_id !== undefined) {
33579
- validateDocId(args.goal_id, "goal_id");
33580
- const goals = await this.db.getGoals();
33581
- const goal = goals.find((g) => g.goal_id === args.goal_id);
33582
- if (!goal) {
33583
- throw new Error(`Goal not found: ${args.goal_id}`);
33584
- }
33585
- }
33586
- const firestoreFields = {};
33587
- const cacheFields = {};
33588
- if ("category_id" in args && args.category_id !== undefined) {
33589
- firestoreFields.category_id = args.category_id;
33590
- cacheFields.category_id = args.category_id;
34133
+ const input = {};
34134
+ if ("category_id" in args && args.category_id !== undefined)
34135
+ input.categoryId = args.category_id;
34136
+ if ("note" in args && args.note !== undefined)
34137
+ input.userNotes = args.note;
34138
+ if ("tag_ids" in args && args.tag_ids !== undefined)
34139
+ input.tagIds = args.tag_ids;
34140
+ if (!txn.account_id || !txn.item_id) {
34141
+ throw new Error(`Transaction ${transaction_id} missing account_id or item_id in local cache`);
33591
34142
  }
33592
- if ("note" in args && args.note !== undefined) {
33593
- firestoreFields.user_note = args.note;
33594
- cacheFields.user_note = args.note;
34143
+ try {
34144
+ const result = await editTransaction(client, {
34145
+ id: transaction_id,
34146
+ accountId: txn.account_id,
34147
+ itemId: txn.item_id,
34148
+ input
34149
+ });
34150
+ const graphqlToApiName = {
34151
+ categoryId: "category_id",
34152
+ userNotes: "note",
34153
+ tagIds: "tag_ids",
34154
+ isReviewed: "reviewed"
34155
+ };
34156
+ const updated = Object.keys(result.changed).map((k) => graphqlToApiName[k] ?? k);
34157
+ return {
34158
+ success: true,
34159
+ transaction_id: result.id,
34160
+ updated
34161
+ };
34162
+ } catch (e) {
34163
+ if (e instanceof GraphQLError) {
34164
+ throw new Error(graphQLErrorToMcpError(e), { cause: e });
34165
+ }
34166
+ throw e;
33595
34167
  }
33596
- if ("tag_ids" in args && args.tag_ids !== undefined) {
33597
- firestoreFields.tag_ids = args.tag_ids;
33598
- cacheFields.tag_ids = args.tag_ids;
33599
- }
33600
- if ("excluded" in args && args.excluded !== undefined) {
33601
- firestoreFields.excluded = args.excluded;
33602
- cacheFields.excluded = args.excluded;
33603
- }
33604
- if ("name" in args && trimmedName !== undefined) {
33605
- firestoreFields.name = trimmedName;
33606
- cacheFields.name = trimmedName;
33607
- }
33608
- if ("internal_transfer" in args && args.internal_transfer !== undefined) {
33609
- firestoreFields.internal_transfer = args.internal_transfer;
33610
- cacheFields.internal_transfer = args.internal_transfer;
33611
- }
33612
- if ("goal_id" in args && args.goal_id !== undefined) {
33613
- firestoreFields.goal_id = args.goal_id ?? "";
33614
- cacheFields.goal_id = args.goal_id ?? undefined;
33615
- }
33616
- const client = this.getFirestoreClient();
33617
- const firestoreValue = toFirestoreFields(firestoreFields);
33618
- const updateMask = Object.keys(firestoreFields);
33619
- await client.updateDocument(collectionPath, transaction_id, firestoreValue, updateMask);
33620
- if (!this.db.patchCachedTransaction(transaction_id, cacheFields)) {
33621
- this.db.clearCache();
33622
- }
33623
- const firestoreToApiName = { user_note: "note" };
33624
- const updated = updateMask.map((k) => firestoreToApiName[k] ?? k);
33625
- return {
33626
- success: true,
33627
- transaction_id,
33628
- updated
33629
- };
33630
34168
  }
33631
34169
  async reviewTransactions(args) {
33632
- const client = this.getFirestoreClient();
34170
+ const client = this.getGraphQLClient();
33633
34171
  const { transaction_ids, reviewed = true } = args;
33634
34172
  if (!Array.isArray(transaction_ids) || transaction_ids.length === 0) {
33635
34173
  throw new Error("transaction_ids must be a non-empty array");
@@ -33639,663 +34177,274 @@ class CopilotMoneyTools {
33639
34177
  }
33640
34178
  const allTransactions = await this.db.getAllTransactions();
33641
34179
  const txnMap = new Map(allTransactions.map((t) => [t.transaction_id, t]));
33642
- const resolvedTxns = [];
34180
+ const missing = transaction_ids.filter((id) => !txnMap.has(id));
34181
+ if (missing.length > 0) {
34182
+ throw new Error(`Transactions not found: ${missing.join(", ")}`);
34183
+ }
33643
34184
  for (const id of transaction_ids) {
33644
34185
  const txn = txnMap.get(id);
33645
- if (!txn) {
33646
- throw new Error(`Transaction not found: ${id}`);
33647
- }
33648
- if (!txn.item_id || !txn.account_id) {
33649
- throw new Error(`Transaction ${id} is missing item_id or account_id — cannot determine Firestore path`);
34186
+ if (!txn.account_id || !txn.item_id) {
34187
+ throw new Error(`Transaction ${id} missing account_id or item_id in local cache`);
33650
34188
  }
33651
- resolvedTxns.push(txn);
33652
34189
  }
33653
- const firestoreFields = toFirestoreFields({ user_reviewed: reviewed });
33654
- for (let i = 0;i < resolvedTxns.length; i += REVIEW_BATCH_SIZE) {
33655
- const batch = resolvedTxns.slice(i, i + REVIEW_BATCH_SIZE);
33656
- await Promise.all(batch.map(async (txn) => {
33657
- const collectionPath = `items/${txn.item_id}/accounts/${txn.account_id}/transactions`;
33658
- await client.updateDocument(collectionPath, txn.transaction_id, firestoreFields, [
33659
- "user_reviewed"
33660
- ]);
33661
- if (!this.db.patchCachedTransaction(txn.transaction_id, { user_reviewed: reviewed })) {
33662
- this.db.clearCache();
34190
+ const CONCURRENCY = 5;
34191
+ let reviewed_count = 0;
34192
+ let firstError = null;
34193
+ let cursor = 0;
34194
+ const worker = async () => {
34195
+ while (true) {
34196
+ if (firstError)
34197
+ return;
34198
+ const idx = cursor++;
34199
+ if (idx >= transaction_ids.length)
34200
+ return;
34201
+ const id = transaction_ids[idx];
34202
+ const txn = txnMap.get(id);
34203
+ try {
34204
+ await editTransaction(client, {
34205
+ id,
34206
+ accountId: txn.account_id,
34207
+ itemId: txn.item_id,
34208
+ input: { isReviewed: reviewed }
34209
+ });
34210
+ reviewed_count++;
34211
+ } catch (e) {
34212
+ if (!firstError)
34213
+ firstError = { id, error: e };
34214
+ return;
33663
34215
  }
33664
- }));
34216
+ }
34217
+ };
34218
+ const workerCount = Math.min(CONCURRENCY, transaction_ids.length);
34219
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
34220
+ if (firstError) {
34221
+ const { id, error: error48 } = firstError;
34222
+ if (error48 instanceof GraphQLError) {
34223
+ throw new Error(`review_transactions failed at id=${id} (${reviewed_count}/${transaction_ids.length} succeeded): ${graphQLErrorToMcpError(error48)}`, { cause: error48 });
34224
+ }
34225
+ throw error48;
33665
34226
  }
33666
34227
  return {
33667
34228
  success: true,
33668
- reviewed_count: resolvedTxns.length,
33669
- transaction_ids: resolvedTxns.map((t) => t.transaction_id)
34229
+ reviewed_count,
34230
+ transaction_ids
33670
34231
  };
33671
34232
  }
33672
34233
  async createTag(args) {
33673
- const client = this.getFirestoreClient();
33674
- const { name, color_name, hex_color } = args;
33675
- const trimmedName = name.trim();
33676
- if (!trimmedName) {
34234
+ const client = this.getGraphQLClient();
34235
+ if (!args.name?.trim())
33677
34236
  throw new Error("Tag name must not be empty");
34237
+ const colorName = args.color_name ?? "PURPLE2";
34238
+ try {
34239
+ const result = await createTag(client, {
34240
+ input: { name: args.name.trim(), colorName }
34241
+ });
34242
+ return {
34243
+ success: true,
34244
+ tag_id: result.id,
34245
+ name: result.name,
34246
+ color_name: result.colorName
34247
+ };
34248
+ } catch (e) {
34249
+ if (e instanceof GraphQLError)
34250
+ throw new Error(graphQLErrorToMcpError(e), { cause: e });
34251
+ throw e;
33678
34252
  }
33679
- const tag_id = trimmedName.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_-]/g, "");
33680
- if (!tag_id) {
33681
- throw new Error(`Cannot generate a valid tag_id from name: ${trimmedName}`);
33682
- }
33683
- if (hex_color !== undefined)
33684
- validateHexColor(hex_color);
33685
- const existingTags = await this.db.getTags();
33686
- const duplicate = existingTags.find((t) => t.tag_id === tag_id);
33687
- if (duplicate) {
33688
- throw new Error(`Tag "${trimmedName}" already exists (id: ${tag_id})`);
33689
- }
33690
- const userId = await client.requireUserId();
33691
- const docFields = { name: trimmedName };
33692
- if (color_name !== undefined)
33693
- docFields.color_name = color_name;
33694
- if (hex_color !== undefined)
33695
- docFields.hex_color = hex_color;
33696
- const firestoreFields = toFirestoreFields(docFields);
33697
- const collectionPath = `users/${userId}/tags`;
33698
- await client.createDocument(collectionPath, tag_id, firestoreFields);
33699
- this.db.clearCache();
33700
- const result = {
33701
- success: true,
33702
- tag_id,
33703
- name: trimmedName
33704
- };
33705
- if (color_name !== undefined)
33706
- result.color_name = color_name;
33707
- if (hex_color !== undefined)
33708
- result.hex_color = hex_color;
33709
- return result;
33710
34253
  }
33711
34254
  async deleteTag(args) {
33712
- const client = this.getFirestoreClient();
33713
- const { tag_id } = args;
33714
- validateDocId(tag_id, "tag_id");
33715
- const existingTags = await this.db.getTags();
33716
- const tag = existingTags.find((t) => t.tag_id === tag_id);
33717
- if (!tag) {
33718
- throw new Error(`Tag not found: ${tag_id}`);
33719
- }
33720
- const userId = await client.requireUserId();
33721
- const collectionPath = `users/${userId}/tags`;
33722
- await client.deleteDocument(collectionPath, tag_id);
33723
- this.db.clearCache();
33724
- return {
33725
- success: true,
33726
- tag_id,
33727
- deleted_name: tag.name ?? tag_id
33728
- };
34255
+ const client = this.getGraphQLClient();
34256
+ try {
34257
+ const result = await deleteTag(client, { id: args.tag_id });
34258
+ return { success: true, tag_id: result.id, deleted: true };
34259
+ } catch (e) {
34260
+ if (e instanceof GraphQLError)
34261
+ throw new Error(graphQLErrorToMcpError(e), { cause: e });
34262
+ throw e;
34263
+ }
33729
34264
  }
33730
34265
  async updateCategory(args) {
33731
- const client = this.getFirestoreClient();
33732
- const { category_id, name, emoji: emoji3, color, excluded, parent_category_id } = args;
33733
- validateDocId(category_id, "category_id");
33734
- const existingCategories = await this.db.getUserCategories();
33735
- const category = existingCategories.find((c) => c.category_id === category_id);
33736
- if (!category) {
33737
- throw new Error(`Category not found: ${category_id}`);
33738
- }
33739
- const fieldsToUpdate = {};
33740
- const updateMask = [];
33741
- if (name !== undefined) {
33742
- const trimmedName = name.trim();
33743
- if (!trimmedName) {
33744
- throw new Error("Category name must not be empty");
33745
- }
33746
- const duplicate = existingCategories.find((c) => c.category_id !== category_id && c.name?.toLowerCase() === trimmedName.toLowerCase());
33747
- if (duplicate) {
33748
- throw new Error(`Category with name "${trimmedName}" already exists (id: ${duplicate.category_id})`);
33749
- }
33750
- fieldsToUpdate.name = trimmedName;
33751
- updateMask.push("name");
33752
- }
33753
- if (emoji3 !== undefined) {
33754
- fieldsToUpdate.emoji = emoji3;
33755
- updateMask.push("emoji");
33756
- }
33757
- if (color !== undefined) {
33758
- validateHexColor(color);
33759
- fieldsToUpdate.color = color;
33760
- fieldsToUpdate.bg_color = hexToBgColor(color);
33761
- updateMask.push("color", "bg_color");
33762
- }
33763
- if (excluded !== undefined) {
33764
- fieldsToUpdate.excluded = excluded;
33765
- updateMask.push("excluded");
33766
- }
33767
- if (parent_category_id !== undefined) {
33768
- if (parent_category_id !== null) {
33769
- validateDocId(parent_category_id, "parent_category_id");
33770
- if (parent_category_id === category_id) {
33771
- throw new Error("A category cannot be its own parent");
33772
- }
33773
- const parent = existingCategories.find((c) => c.category_id === parent_category_id);
33774
- if (!parent) {
33775
- throw new Error(`Parent category not found: ${parent_category_id}`);
33776
- }
33777
- }
33778
- fieldsToUpdate.parent_category_id = parent_category_id ?? "";
33779
- updateMask.push("parent_category_id");
33780
- }
33781
- if (updateMask.length === 0) {
33782
- throw new Error("No fields to update");
33783
- }
33784
- const userIdFromCategories = existingCategories.find((c) => c.user_id)?.user_id;
33785
- const userId = userIdFromCategories ?? await client.requireUserId();
33786
- const collectionPath = `users/${userId}/categories`;
33787
- const firestoreFields = toFirestoreFields(fieldsToUpdate);
33788
- await client.updateDocument(collectionPath, category_id, firestoreFields, updateMask);
33789
- this.db.clearCache();
33790
- this._userCategoryMap = null;
33791
- return {
33792
- success: true,
33793
- category_id,
33794
- updated_fields: updateMask
33795
- };
34266
+ const client = this.getGraphQLClient();
34267
+ const input = {};
34268
+ if (args.name !== undefined)
34269
+ input.name = args.name;
34270
+ if (args.color_name !== undefined)
34271
+ input.colorName = args.color_name;
34272
+ if (args.emoji !== undefined)
34273
+ input.emoji = args.emoji;
34274
+ if (args.is_excluded !== undefined)
34275
+ input.isExcluded = args.is_excluded;
34276
+ if (Object.keys(input).length === 0) {
34277
+ throw new Error("update_category requires at least one field to update");
34278
+ }
34279
+ try {
34280
+ const result = await editCategory(client, { id: args.category_id, input });
34281
+ return {
34282
+ success: true,
34283
+ category_id: result.id,
34284
+ updated: Object.keys(result.changed)
34285
+ };
34286
+ } catch (e) {
34287
+ if (e instanceof GraphQLError)
34288
+ throw new Error(graphQLErrorToMcpError(e), { cause: e });
34289
+ throw e;
34290
+ }
33796
34291
  }
33797
34292
  async deleteCategory(args) {
33798
- const client = this.getFirestoreClient();
33799
- const { category_id } = args;
33800
- validateDocId(category_id, "category_id");
33801
- const existingCategories = await this.db.getUserCategories();
33802
- const category = existingCategories.find((c) => c.category_id === category_id);
33803
- if (!category) {
33804
- throw new Error(`Category not found: ${category_id}`);
33805
- }
33806
- const userIdFromCategories = existingCategories.find((c) => c.user_id)?.user_id;
33807
- const userId = userIdFromCategories ?? await client.requireUserId();
33808
- const collectionPath = `users/${userId}/categories`;
33809
- await client.deleteDocument(collectionPath, category_id);
33810
- this.db.clearCache();
33811
- this._userCategoryMap = null;
33812
- return {
33813
- success: true,
33814
- category_id,
33815
- deleted_name: category.name ?? category_id
33816
- };
33817
- }
33818
- async createBudget(args) {
33819
- const client = this.getFirestoreClient();
33820
- const { category_id, amount, period = "monthly", name } = args;
33821
- validateDocId(category_id, "category_id");
33822
- if (amount <= 0) {
33823
- throw new Error("Budget amount must be greater than 0");
33824
- }
33825
- if (!KNOWN_PERIODS.includes(period)) {
33826
- throw new Error(`Invalid period: ${period}. Must be one of: ${KNOWN_PERIODS.join(", ")}`);
33827
- }
33828
- const categories = await this.db.getUserCategories();
33829
- const category = categories.find((c) => c.category_id === category_id);
33830
- if (!category) {
33831
- throw new Error(`Category not found: ${category_id}`);
33832
- }
33833
- const existingBudgets = await this.db.getBudgets();
33834
- const duplicate = existingBudgets.find((b) => b.category_id === category_id);
33835
- if (duplicate) {
33836
- throw new Error(`A budget already exists for category "${category_id}" (budget_id: ${duplicate.budget_id})`);
33837
- }
33838
- const budgetId = `budget_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
33839
- const userId = await client.requireUserId();
33840
- const docFields = {
33841
- budget_id: budgetId,
33842
- category_id,
33843
- amount,
33844
- period,
33845
- is_active: true
33846
- };
33847
- if (name)
33848
- docFields.name = name;
33849
- const collectionPath = `users/${userId}/budgets`;
33850
- const firestoreFields = toFirestoreFields(docFields);
33851
- await client.createDocument(collectionPath, budgetId, firestoreFields);
33852
- this.db.clearCache();
33853
- const result = {
33854
- success: true,
33855
- budget_id: budgetId,
33856
- category_id,
33857
- amount,
33858
- period
33859
- };
33860
- if (name)
33861
- result.name = name;
33862
- return result;
33863
- }
33864
- async updateBudget(args) {
33865
- const client = this.getFirestoreClient();
33866
- const { budget_id, amount, period, name, is_active } = args;
33867
- validateDocId(budget_id, "budget_id");
33868
- const existingBudgets = await this.db.getBudgets();
33869
- const budget = existingBudgets.find((b) => b.budget_id === budget_id);
33870
- if (!budget) {
33871
- throw new Error(`Budget not found: ${budget_id}`);
33872
- }
33873
- const updateFields = {};
33874
- const updatedFieldNames = [];
33875
- if (amount !== undefined) {
33876
- if (amount <= 0) {
33877
- throw new Error("Budget amount must be greater than 0");
33878
- }
33879
- updateFields.amount = amount;
33880
- updatedFieldNames.push("amount");
33881
- }
33882
- if (period !== undefined) {
33883
- if (!KNOWN_PERIODS.includes(period)) {
33884
- throw new Error(`Invalid period: ${period}. Must be one of: ${KNOWN_PERIODS.join(", ")}`);
33885
- }
33886
- updateFields.period = period;
33887
- updatedFieldNames.push("period");
33888
- }
33889
- if (name !== undefined) {
33890
- const trimmedName = name.trim();
33891
- if (!trimmedName) {
33892
- throw new Error("Budget name must not be empty");
33893
- }
33894
- updateFields.name = trimmedName;
33895
- updatedFieldNames.push("name");
33896
- }
33897
- if (is_active !== undefined) {
33898
- updateFields.is_active = is_active;
33899
- updatedFieldNames.push("is_active");
33900
- }
33901
- if (updatedFieldNames.length === 0) {
33902
- throw new Error("No fields to update. Provide at least one of: amount, period, name, is_active");
33903
- }
33904
- const userId = await client.requireUserId();
33905
- const collectionPath = `users/${userId}/budgets`;
33906
- const firestoreFields = toFirestoreFields(updateFields);
33907
- await client.updateDocument(collectionPath, budget_id, firestoreFields, updatedFieldNames);
33908
- this.db.clearCache();
33909
- return {
33910
- success: true,
33911
- budget_id,
33912
- updated_fields: updatedFieldNames
33913
- };
33914
- }
33915
- async deleteBudget(args) {
33916
- const client = this.getFirestoreClient();
33917
- const { budget_id } = args;
33918
- validateDocId(budget_id, "budget_id");
33919
- const existingBudgets = await this.db.getBudgets();
33920
- const budget = existingBudgets.find((b) => b.budget_id === budget_id);
33921
- if (!budget) {
33922
- throw new Error(`Budget not found: ${budget_id}`);
33923
- }
33924
- const userId = await client.requireUserId();
33925
- const collectionPath = `users/${userId}/budgets`;
33926
- await client.deleteDocument(collectionPath, budget_id);
33927
- this.db.clearCache();
33928
- return {
33929
- success: true,
33930
- budget_id,
33931
- deleted_name: budget.name ?? budget.category_id ?? budget_id
33932
- };
33933
- }
33934
- async setRecurringState(args) {
33935
- const client = this.getFirestoreClient();
33936
- const { recurring_id, state } = args;
33937
- validateDocId(recurring_id, "recurring_id");
33938
- if (!RECURRING_STATES.includes(state)) {
33939
- throw new Error(`Invalid state: ${state}. Must be one of: ${RECURRING_STATES.join(", ")}`);
33940
- }
33941
- const allRecurring = await this.db.getRecurring(false);
33942
- const recurring = allRecurring.find((r) => r.recurring_id === recurring_id);
33943
- if (!recurring) {
33944
- throw new Error(`Recurring item not found: ${recurring_id}`);
33945
- }
33946
- const displayName = getRecurringDisplayName(recurring);
33947
- const oldState = recurring.state ?? (recurring.is_active ? "active" : "paused");
33948
- const userId = await client.requireUserId();
33949
- const is_active = state === "active";
33950
- const firestoreFields = toFirestoreFields({ state, is_active });
33951
- const collectionPath = `users/${userId}/recurring`;
33952
- await client.updateDocument(collectionPath, recurring_id, firestoreFields, [
33953
- "state",
33954
- "is_active"
33955
- ]);
33956
- this.db.clearCache();
33957
- return {
33958
- success: true,
33959
- recurring_id,
33960
- name: displayName,
33961
- old_state: oldState,
33962
- new_state: state
33963
- };
33964
- }
33965
- async deleteRecurring(args) {
33966
- const client = this.getFirestoreClient();
33967
- const { recurring_id } = args;
33968
- validateDocId(recurring_id, "recurring_id");
33969
- const allRecurring = await this.db.getRecurring(false);
33970
- const recurring = allRecurring.find((r) => r.recurring_id === recurring_id);
33971
- if (!recurring) {
33972
- throw new Error(`Recurring item not found: ${recurring_id}`);
33973
- }
33974
- const displayName = getRecurringDisplayName(recurring);
33975
- const userId = await client.requireUserId();
33976
- const collectionPath = `users/${userId}/recurring`;
33977
- await client.deleteDocument(collectionPath, recurring_id);
33978
- this.db.clearCache();
33979
- return {
33980
- success: true,
33981
- recurring_id,
33982
- deleted_name: displayName
33983
- };
33984
- }
33985
- async updateGoal(args) {
33986
- const client = this.getFirestoreClient();
33987
- const { goal_id, name, emoji: emoji3, target_amount, monthly_contribution, status } = args;
33988
- validateDocId(goal_id, "goal_id");
33989
- const goals = await this.db.getGoals(false);
33990
- const goal = goals.find((g) => g.goal_id === goal_id);
33991
- if (!goal) {
33992
- throw new Error(`Goal not found: ${goal_id}`);
33993
- }
33994
- const fieldsToUpdate = {};
33995
- const updateMask = [];
33996
- if (name !== undefined) {
33997
- if (!name.trim()) {
33998
- throw new Error("Goal name must not be empty");
33999
- }
34000
- fieldsToUpdate.name = name.trim();
34001
- updateMask.push("name");
34293
+ const client = this.getGraphQLClient();
34294
+ try {
34295
+ const result = await deleteCategory(client, { id: args.category_id });
34296
+ return { success: true, category_id: result.id, deleted: true };
34297
+ } catch (e) {
34298
+ if (e instanceof GraphQLError)
34299
+ throw new Error(graphQLErrorToMcpError(e), { cause: e });
34300
+ throw e;
34002
34301
  }
34003
- if (emoji3 !== undefined) {
34004
- fieldsToUpdate.emoji = emoji3;
34005
- updateMask.push("emoji");
34302
+ }
34303
+ async setBudget(args) {
34304
+ const client = this.getGraphQLClient();
34305
+ if (!args.category_id?.trim())
34306
+ throw new Error("category_id is required");
34307
+ if (typeof args.amount !== "string") {
34308
+ throw new Error('amount must be a string (e.g. "250.00")');
34006
34309
  }
34007
- const savingsUpdate = {};
34008
- if (target_amount !== undefined) {
34009
- if (target_amount <= 0) {
34010
- throw new Error("target_amount must be greater than 0");
34011
- }
34012
- savingsUpdate.target_amount = target_amount;
34310
+ if (!/^\d+(\.\d{1,2})?$/.test(args.amount)) {
34311
+ throw new Error('amount must be a non-negative decimal like "250.00" or "0" to clear the budget');
34013
34312
  }
34014
- if (monthly_contribution !== undefined) {
34015
- if (monthly_contribution < 0) {
34016
- throw new Error("monthly_contribution must be >= 0");
34017
- }
34018
- savingsUpdate.tracking_type_monthly_contribution = monthly_contribution;
34313
+ if (args.month !== undefined && !/^\d{4}-\d{2}$/.test(args.month)) {
34314
+ throw new Error('month must be "YYYY-MM"');
34019
34315
  }
34020
- if (status !== undefined) {
34021
- savingsUpdate.status = status;
34316
+ try {
34317
+ const result = await setBudget(client, {
34318
+ categoryId: args.category_id,
34319
+ amount: args.amount,
34320
+ month: args.month
34321
+ });
34322
+ return {
34323
+ success: true,
34324
+ category_id: result.categoryId,
34325
+ amount: result.amount,
34326
+ ...result.month ? { month: result.month } : {},
34327
+ cleared: result.cleared
34328
+ };
34329
+ } catch (e) {
34330
+ if (e instanceof GraphQLError)
34331
+ throw new Error(graphQLErrorToMcpError(e), { cause: e });
34332
+ throw e;
34022
34333
  }
34023
- if (Object.keys(savingsUpdate).length > 0) {
34024
- fieldsToUpdate.savings = savingsUpdate;
34025
- updateMask.push("savings");
34334
+ }
34335
+ async setRecurringState(args) {
34336
+ const client = this.getGraphQLClient();
34337
+ const VALID_STATES = ["ACTIVE", "PAUSED", "ARCHIVED"];
34338
+ if (!VALID_STATES.includes(args.state)) {
34339
+ throw new Error(`state must be one of: ${VALID_STATES.join(", ")}. Got: ${args.state}`);
34026
34340
  }
34027
- if (updateMask.length === 0) {
34028
- throw new Error("No fields to update");
34341
+ try {
34342
+ const result = await editRecurring(client, {
34343
+ id: args.recurring_id,
34344
+ input: { state: args.state }
34345
+ });
34346
+ return { success: true, recurring_id: result.id, state: args.state };
34347
+ } catch (e) {
34348
+ if (e instanceof GraphQLError)
34349
+ throw new Error(graphQLErrorToMcpError(e), { cause: e });
34350
+ throw e;
34029
34351
  }
34030
- const userId = await client.requireUserId();
34031
- const collectionPath = `users/${userId}/financial_goals`;
34032
- const firestoreFields = toFirestoreFields(fieldsToUpdate);
34033
- await client.updateDocument(collectionPath, goal_id, firestoreFields, updateMask);
34034
- this.db.clearCache();
34035
- return {
34036
- success: true,
34037
- goal_id,
34038
- updated_fields: updateMask
34039
- };
34040
34352
  }
34041
- async deleteGoal(args) {
34042
- const client = this.getFirestoreClient();
34043
- const { goal_id } = args;
34044
- validateDocId(goal_id, "goal_id");
34045
- const goals = await this.db.getGoals(false);
34046
- const goal = goals.find((g) => g.goal_id === goal_id);
34047
- if (!goal) {
34048
- throw new Error(`Goal not found: ${goal_id}`);
34049
- }
34050
- const userId = await client.requireUserId();
34051
- const collectionPath = `users/${userId}/financial_goals`;
34052
- await client.deleteDocument(collectionPath, goal_id);
34053
- this.db.clearCache();
34054
- return {
34055
- success: true,
34056
- goal_id,
34057
- deleted_name: goal.name ?? goal_id
34058
- };
34353
+ async deleteRecurring(args) {
34354
+ const client = this.getGraphQLClient();
34355
+ try {
34356
+ const result = await deleteRecurring(client, { id: args.recurring_id });
34357
+ return { success: true, recurring_id: result.id, deleted: true };
34358
+ } catch (e) {
34359
+ if (e instanceof GraphQLError)
34360
+ throw new Error(graphQLErrorToMcpError(e), { cause: e });
34361
+ throw e;
34362
+ }
34059
34363
  }
34060
34364
  async updateTag(args) {
34061
- const client = this.getFirestoreClient();
34062
- const { tag_id, name, color_name, hex_color } = args;
34063
- validateDocId(tag_id, "tag_id");
34064
- const existingTags = await this.db.getTags();
34065
- const tag = existingTags.find((t) => t.tag_id === tag_id);
34066
- if (!tag) {
34067
- throw new Error(`Tag not found: ${tag_id}`);
34068
- }
34069
- const fieldsToUpdate = {};
34070
- const updateMask = [];
34071
- if (name !== undefined) {
34072
- const trimmedName = name.trim();
34073
- if (!trimmedName) {
34074
- throw new Error("Tag name must not be empty");
34075
- }
34076
- fieldsToUpdate.name = trimmedName;
34077
- updateMask.push("name");
34078
- }
34079
- if (color_name !== undefined) {
34080
- fieldsToUpdate.color_name = color_name;
34081
- updateMask.push("color_name");
34082
- }
34083
- if (hex_color !== undefined) {
34084
- validateHexColor(hex_color);
34085
- fieldsToUpdate.hex_color = hex_color;
34086
- updateMask.push("hex_color");
34087
- }
34088
- if (updateMask.length === 0) {
34089
- throw new Error("No fields to update. Provide at least one of: name, color_name, hex_color");
34090
- }
34091
- const userId = await client.requireUserId();
34092
- const collectionPath = `users/${userId}/tags`;
34093
- const firestoreFields = toFirestoreFields(fieldsToUpdate);
34094
- await client.updateDocument(collectionPath, tag_id, firestoreFields, updateMask);
34095
- this.db.clearCache();
34096
- return {
34097
- success: true,
34098
- tag_id,
34099
- updated_fields: updateMask
34100
- };
34365
+ const client = this.getGraphQLClient();
34366
+ const input = {};
34367
+ if (args.name !== undefined)
34368
+ input.name = args.name;
34369
+ if (args.color_name !== undefined)
34370
+ input.colorName = args.color_name;
34371
+ if (Object.keys(input).length === 0) {
34372
+ throw new Error("update_tag requires at least one field to update");
34373
+ }
34374
+ try {
34375
+ const result = await editTag(client, { id: args.tag_id, input });
34376
+ return { success: true, tag_id: result.id, updated: Object.keys(result.changed) };
34377
+ } catch (e) {
34378
+ if (e instanceof GraphQLError)
34379
+ throw new Error(graphQLErrorToMcpError(e), { cause: e });
34380
+ throw e;
34381
+ }
34101
34382
  }
34102
34383
  async createRecurring(args) {
34103
- const client = this.getFirestoreClient();
34104
- const { name, amount, frequency, category_id, account_id, merchant_name, start_date } = args;
34105
- const trimmedName = name.trim();
34106
- if (!trimmedName) {
34107
- throw new Error("Recurring name must not be empty");
34108
- }
34109
- if (amount <= 0) {
34110
- throw new Error("Recurring amount must be greater than 0");
34111
- }
34112
- if (!VALID_RECURRING_FREQUENCIES.includes(frequency)) {
34113
- throw new Error(`Invalid frequency: ${frequency}. Must be one of: ${VALID_RECURRING_FREQUENCIES.join(", ")}`);
34114
- }
34115
- if (category_id !== undefined)
34116
- validateDocId(category_id, "category_id");
34117
- if (account_id !== undefined)
34118
- validateDocId(account_id, "account_id");
34119
- if (start_date !== undefined)
34120
- validateDate(start_date, "start_date");
34121
- const recurringId = crypto.randomUUID();
34122
- const userId = await client.requireUserId();
34123
- const today = new Date().toISOString().slice(0, 10);
34124
- const docFields = {
34125
- recurring_id: recurringId,
34126
- name: trimmedName,
34127
- amount,
34128
- frequency,
34129
- is_active: true,
34130
- state: "active",
34131
- latest_date: start_date ?? today
34132
- };
34133
- if (category_id !== undefined)
34134
- docFields.category_id = category_id;
34135
- if (account_id !== undefined)
34136
- docFields.account_id = account_id;
34137
- if (merchant_name !== undefined)
34138
- docFields.merchant_name = merchant_name;
34139
- if (start_date !== undefined)
34140
- docFields.start_date = start_date;
34141
- const collectionPath = `users/${userId}/recurring`;
34142
- const firestoreFields = toFirestoreFields(docFields);
34143
- await client.createDocument(collectionPath, recurringId, firestoreFields);
34144
- this.db.clearCache();
34145
- return {
34146
- success: true,
34147
- recurring_id: recurringId,
34148
- name: trimmedName,
34149
- amount,
34150
- frequency
34151
- };
34152
- }
34153
- async createGoal(args) {
34154
- const client = this.getFirestoreClient();
34155
- const { name, target_amount, emoji: emoji3, monthly_contribution, start_date } = args;
34156
- const trimmedName = name.trim();
34157
- if (!trimmedName) {
34158
- throw new Error("Goal name must not be empty");
34384
+ const client = this.getGraphQLClient();
34385
+ const VALID_FREQUENCIES = ["WEEKLY", "BIWEEKLY", "MONTHLY", "YEARLY"];
34386
+ if (!VALID_FREQUENCIES.includes(args.frequency)) {
34387
+ throw new Error(`frequency must be one of: ${VALID_FREQUENCIES.join(", ")}. Got: ${args.frequency}`);
34159
34388
  }
34160
- if (target_amount <= 0) {
34161
- throw new Error("target_amount must be greater than 0");
34389
+ const all = await this.db.getAllTransactions();
34390
+ const txn = all.find((t) => t.transaction_id === args.transaction_id);
34391
+ if (!txn)
34392
+ throw new Error(`Transaction not found: ${args.transaction_id}`);
34393
+ if (!txn.account_id || !txn.item_id) {
34394
+ throw new Error(`Transaction ${args.transaction_id} missing account_id or item_id in local cache`);
34162
34395
  }
34163
- if (monthly_contribution !== undefined && monthly_contribution < 0) {
34164
- throw new Error("monthly_contribution must be >= 0");
34396
+ try {
34397
+ const result = await createRecurring(client, {
34398
+ input: {
34399
+ frequency: args.frequency,
34400
+ transaction: {
34401
+ accountId: txn.account_id,
34402
+ itemId: txn.item_id,
34403
+ transactionId: args.transaction_id
34404
+ }
34405
+ }
34406
+ });
34407
+ return {
34408
+ success: true,
34409
+ recurring_id: result.id,
34410
+ name: result.name,
34411
+ state: result.state,
34412
+ frequency: result.frequency
34413
+ };
34414
+ } catch (e) {
34415
+ if (e instanceof GraphQLError)
34416
+ throw new Error(graphQLErrorToMcpError(e), { cause: e });
34417
+ throw e;
34165
34418
  }
34166
- if (start_date !== undefined)
34167
- validateDate(start_date, "start_date");
34168
- const goalId = crypto.randomUUID();
34169
- const userId = await client.requireUserId();
34170
- const today = new Date().toISOString().slice(0, 10);
34171
- const docFields = {
34172
- goal_id: goalId,
34173
- name: trimmedName,
34174
- savings: {
34175
- type: "savings",
34176
- status: "active",
34177
- target_amount,
34178
- tracking_type: monthly_contribution !== undefined ? "monthly_contribution" : "manual",
34179
- tracking_type_monthly_contribution: monthly_contribution ?? 0,
34180
- start_date: start_date ?? today,
34181
- is_ongoing: false
34182
- }
34183
- };
34184
- if (emoji3 !== undefined)
34185
- docFields.emoji = emoji3;
34186
- const collectionPath = `users/${userId}/financial_goals`;
34187
- const firestoreFields = toFirestoreFields(docFields);
34188
- await client.createDocument(collectionPath, goalId, firestoreFields);
34189
- this.db.clearCache();
34190
- return {
34191
- success: true,
34192
- goal_id: goalId,
34193
- name: trimmedName,
34194
- target_amount
34195
- };
34196
34419
  }
34197
34420
  async updateRecurring(args) {
34198
- const client = this.getFirestoreClient();
34199
- const {
34200
- recurring_id,
34201
- name,
34202
- amount,
34203
- frequency,
34204
- category_id,
34205
- account_id,
34206
- merchant_name,
34207
- emoji: emoji3,
34208
- match_string,
34209
- transaction_ids,
34210
- excluded_transaction_ids,
34211
- included_transaction_ids,
34212
- days_filter
34213
- } = args;
34214
- validateDocId(recurring_id, "recurring_id");
34215
- const allRecurring = await this.db.getRecurring(false);
34216
- const recurring = allRecurring.find((r) => r.recurring_id === recurring_id);
34217
- if (!recurring) {
34218
- throw new Error(`Recurring not found: ${recurring_id}`);
34219
- }
34220
- const fieldsToUpdate = {};
34221
- const updateMask = [];
34222
- if (name !== undefined) {
34223
- if (!name.trim()) {
34224
- throw new Error("Recurring name must not be empty");
34225
- }
34226
- fieldsToUpdate.name = name.trim();
34227
- updateMask.push("name");
34228
- }
34229
- if (amount !== undefined) {
34230
- if (amount <= 0) {
34231
- throw new Error("amount must be greater than 0");
34232
- }
34233
- fieldsToUpdate.amount = amount;
34234
- updateMask.push("amount");
34235
- }
34236
- if (frequency !== undefined) {
34237
- if (!VALID_RECURRING_FREQUENCIES.includes(frequency)) {
34238
- throw new Error(`Invalid frequency: ${frequency}. Must be one of: ${VALID_RECURRING_FREQUENCIES.join(", ")}`);
34239
- }
34240
- fieldsToUpdate.frequency = frequency;
34241
- updateMask.push("frequency");
34242
- }
34243
- if (category_id !== undefined) {
34244
- validateDocId(category_id, "category_id");
34245
- fieldsToUpdate.category_id = category_id;
34246
- updateMask.push("category_id");
34247
- }
34248
- if (account_id !== undefined) {
34249
- validateDocId(account_id, "account_id");
34250
- fieldsToUpdate.account_id = account_id;
34251
- updateMask.push("account_id");
34252
- }
34253
- if (merchant_name !== undefined) {
34254
- fieldsToUpdate.merchant_name = merchant_name;
34255
- updateMask.push("merchant_name");
34256
- }
34257
- if (emoji3 !== undefined) {
34258
- fieldsToUpdate.emoji = emoji3;
34259
- updateMask.push("emoji");
34260
- }
34261
- if (match_string !== undefined) {
34262
- if (!match_string.trim()) {
34263
- throw new Error("match_string must not be empty");
34264
- }
34265
- fieldsToUpdate.match_string = match_string.trim();
34266
- updateMask.push("match_string");
34267
- }
34268
- if (transaction_ids !== undefined) {
34269
- fieldsToUpdate.transaction_ids = transaction_ids;
34270
- updateMask.push("transaction_ids");
34271
- }
34272
- if (excluded_transaction_ids !== undefined) {
34273
- fieldsToUpdate.excluded_transaction_ids = excluded_transaction_ids;
34274
- updateMask.push("excluded_transaction_ids");
34275
- }
34276
- if (included_transaction_ids !== undefined) {
34277
- fieldsToUpdate.included_transaction_ids = included_transaction_ids;
34278
- updateMask.push("included_transaction_ids");
34279
- }
34280
- if (days_filter !== undefined) {
34281
- fieldsToUpdate.days_filter = days_filter;
34282
- updateMask.push("days_filter");
34283
- }
34284
- if (updateMask.length === 0) {
34285
- throw new Error("No fields to update");
34286
- }
34287
- const userId = await client.requireUserId();
34288
- const collectionPath = `users/${userId}/recurring`;
34289
- const firestoreFields = toFirestoreFields(fieldsToUpdate);
34290
- await client.updateDocument(collectionPath, recurring_id, firestoreFields, updateMask);
34291
- this.db.clearCache();
34292
- const displayName = name?.trim() ?? recurring.name ?? recurring.merchant_name ?? recurring_id;
34293
- return {
34294
- success: true,
34295
- recurring_id,
34296
- name: displayName,
34297
- updated_fields: updateMask
34298
- };
34421
+ const client = this.getGraphQLClient();
34422
+ const input = {};
34423
+ if (args.state !== undefined)
34424
+ input.state = args.state;
34425
+ if (args.rule !== undefined) {
34426
+ const rule = {};
34427
+ if (args.rule.name_contains !== undefined)
34428
+ rule.nameContains = args.rule.name_contains;
34429
+ if (args.rule.min_amount !== undefined)
34430
+ rule.minAmount = args.rule.min_amount;
34431
+ if (args.rule.max_amount !== undefined)
34432
+ rule.maxAmount = args.rule.max_amount;
34433
+ if (args.rule.days !== undefined)
34434
+ rule.days = args.rule.days;
34435
+ input.rule = rule;
34436
+ }
34437
+ if (Object.keys(input).length === 0) {
34438
+ throw new Error("update_recurring requires at least one field to update");
34439
+ }
34440
+ try {
34441
+ const result = await editRecurring(client, { id: args.recurring_id, input });
34442
+ return { success: true, recurring_id: result.id, updated: Object.keys(result.changed) };
34443
+ } catch (e) {
34444
+ if (e instanceof GraphQLError)
34445
+ throw new Error(graphQLErrorToMcpError(e), { cause: e });
34446
+ throw e;
34447
+ }
34299
34448
  }
34300
34449
  async getBalanceHistory(options) {
34301
34450
  const { account_id, start_date, end_date, granularity } = options;
@@ -34746,7 +34895,7 @@ function createToolSchemas() {
34746
34895
  },
34747
34896
  {
34748
34897
  name: "get_budgets",
34749
- description: "Get budgets from Copilot's native budget tracking. " + "Retrieves user-defined spending limits and budget rules stored in the app. " + "Returns budget details including amounts, periods (monthly/yearly/weekly), " + "category associations, and active status. Calculates total budgeted amount as monthly equivalent.",
34898
+ description: "Get budgets from Copilot's native budget tracking. " + "Retrieves user-defined spending limits and budget rules stored in the app. " + "Returns budget details including amounts, periods (monthly/yearly/weekly), " + "category associations, and active status. Calculates total budgeted amount as monthly equivalent. " + "Sync note: after `set_budget` writes, budget changes can take significantly longer " + "to reflect in this read than other collections (transactions/tags/categories/recurrings " + "sync in seconds; budgets may take minutes). Do not assume a fresh `set_budget` is " + "immediately observable here — poll with `refresh_database` or verify directly in the " + "Copilot app. Per-month overrides written via `set_budget(month=...)` are not surfaced " + "in this view; only the all-months default `amount` is returned.",
34750
34899
  inputSchema: {
34751
34900
  type: "object",
34752
34901
  properties: {
@@ -34905,7 +35054,7 @@ function createToolSchemas() {
34905
35054
  },
34906
35055
  {
34907
35056
  name: "get_investment_performance",
34908
- description: "Get per-security investment performance data. Returns structured performance records " + "from Firestore, enriched with ticker symbol and name from the securities collection. " + "Filter by ticker symbol or security ID.",
35057
+ description: "Get per-security investment performance data. Returns structured performance records " + "from the local LevelDB cache, enriched with ticker symbol and name from the securities collection. " + "Filter by ticker symbol or security ID.",
34909
35058
  inputSchema: {
34910
35059
  type: "object",
34911
35060
  properties: {
@@ -35033,7 +35182,7 @@ function createWriteToolSchemas() {
35033
35182
  return [
35034
35183
  {
35035
35184
  name: "update_transaction",
35036
- description: "Update one or more fields on a transaction in a single atomic write. " + "Pass transaction_id plus any combination of category_id, note, tag_ids, " + "excluded, name, internal_transfer, or goal_id. Omitted fields are preserved " + '(e.g., sending only tag_ids does not erase the note). Pass note="" to clear ' + "the note. Pass tag_ids=[] to clear all tags. Pass goal_id=null to unlink the " + "goal. At least one mutable field must be provided besides transaction_id.",
35185
+ description: "Update a single transaction's category, note, or tags. Pass transaction_id plus " + "any combination of category_id, note, or tag_ids only specified fields are changed. " + 'Pass note="" to clear the note. Pass tag_ids=[] to clear all tags. At least one mutable ' + "field must be provided besides transaction_id. Other fields (name, excluded, " + "internal_transfer, goal_id) are not writable through the GraphQL API and were removed " + "from this tool when the backend was migrated.",
35037
35186
  inputSchema: {
35038
35187
  type: "object",
35039
35188
  additionalProperties: false,
@@ -35054,22 +35203,6 @@ function createWriteToolSchemas() {
35054
35203
  type: "array",
35055
35204
  items: { type: "string" },
35056
35205
  description: "Tag IDs to set. Pass empty array to clear all tags."
35057
- },
35058
- excluded: {
35059
- type: "boolean",
35060
- description: "Whether the transaction is excluded from spending reports."
35061
- },
35062
- name: {
35063
- type: "string",
35064
- description: "Display name (will be trimmed; must be non-empty if present)."
35065
- },
35066
- internal_transfer: {
35067
- type: "boolean",
35068
- description: "Whether the transaction is an internal transfer."
35069
- },
35070
- goal_id: {
35071
- type: ["string", "null"],
35072
- description: "Financial goal ID to link to. Pass null to unlink the existing goal."
35073
35206
  }
35074
35207
  },
35075
35208
  required: ["transaction_id"]
@@ -35082,7 +35215,7 @@ function createWriteToolSchemas() {
35082
35215
  },
35083
35216
  {
35084
35217
  name: "review_transactions",
35085
- description: "Mark one or more transactions as reviewed (or unreviewed). " + "Accepts an array of transaction_ids. Writes directly to Copilot Money via Firestore.",
35218
+ description: "Mark one or more transactions as reviewed (or unreviewed). " + "Accepts an array of transaction_ids. Writes directly to Copilot Money via GraphQL.",
35086
35219
  inputSchema: {
35087
35220
  type: "object",
35088
35221
  properties: {
@@ -35106,7 +35239,7 @@ function createWriteToolSchemas() {
35106
35239
  },
35107
35240
  {
35108
35241
  name: "create_tag",
35109
- description: "Create a new user-defined tag for categorizing transactions. Tags appear in the " + "Copilot Money app and are stored in the tag_ids field on transactions. " + "Optionally set a color. Writes directly to Copilot Money via Firestore.",
35242
+ description: "Create a new user-defined tag for categorizing transactions. Tags appear in the " + "Copilot Money app and are stored in the tag_ids field on transactions. " + "Optionally set a color. Writes directly to Copilot Money via GraphQL.",
35110
35243
  inputSchema: {
35111
35244
  type: "object",
35112
35245
  properties: {
@@ -35133,7 +35266,7 @@ function createWriteToolSchemas() {
35133
35266
  },
35134
35267
  {
35135
35268
  name: "delete_tag",
35136
- description: "Delete a user-defined tag. The tag_id can be obtained from the tag definitions " + "in the local cache. Writes directly to Copilot Money via Firestore.",
35269
+ description: "Delete a user-defined tag. The tag_id can be obtained from the tag definitions " + "in the local cache. Writes directly to Copilot Money via GraphQL.",
35137
35270
  inputSchema: {
35138
35271
  type: "object",
35139
35272
  properties: {
@@ -35152,7 +35285,7 @@ function createWriteToolSchemas() {
35152
35285
  },
35153
35286
  {
35154
35287
  name: "create_category",
35155
- description: "Create a new custom category in Copilot Money. Provide a name (required) " + "and optionally an emoji, color, parent category, or excluded flag. " + "Returns the generated category_id. The new category can then be used " + "with update_transaction.",
35288
+ description: "Create a new custom category in Copilot Money. Provide name, color_name, " + "and emoji (all required). Optionally set is_excluded. Returns the generated " + "category_id. The new category can then be used with update_transaction. " + "Note: parent/child category hierarchies are not writable through Copilot's " + "GraphQL API — create flat categories only. Writes directly to Copilot Money via GraphQL.",
35156
35289
  inputSchema: {
35157
35290
  type: "object",
35158
35291
  properties: {
@@ -35160,25 +35293,22 @@ function createWriteToolSchemas() {
35160
35293
  type: "string",
35161
35294
  description: 'Display name for the new category (e.g., "Subscriptions")'
35162
35295
  },
35163
- emoji: {
35164
- type: "string",
35165
- description: 'Emoji icon for the category (e.g., "\uD83C\uDFAC")'
35166
- },
35167
- color: {
35296
+ color_name: {
35168
35297
  type: "string",
35169
- description: 'Hex color code for the category (e.g., "#FF5733")'
35298
+ description: 'Named color from the Copilot palette (e.g., "RED1", "OLIVE1", "PURPLE2"). ' + "See existing categories via get_categories for valid values."
35170
35299
  },
35171
- parent_category_id: {
35300
+ emoji: {
35172
35301
  type: "string",
35173
- description: "Parent category ID to nest under (from get_categories). " + "Creates a subcategory when provided."
35302
+ description: 'Emoji icon for the category (e.g., "\uD83C\uDFAC")'
35174
35303
  },
35175
- excluded: {
35304
+ is_excluded: {
35176
35305
  type: "boolean",
35177
35306
  description: "Exclude this category from spending totals (default: false)",
35178
35307
  default: false
35179
35308
  }
35180
35309
  },
35181
- required: ["name"]
35310
+ required: ["name", "color_name", "emoji"],
35311
+ additionalProperties: false
35182
35312
  },
35183
35313
  annotations: {
35184
35314
  readOnlyHint: false,
@@ -35188,7 +35318,7 @@ function createWriteToolSchemas() {
35188
35318
  },
35189
35319
  {
35190
35320
  name: "update_category",
35191
- description: "Update an existing user-defined category. Provide category_id (required) and any " + "fields to change: name, emoji, color, excluded, or parent_category_id (null to ungroup). " + "Only the specified fields are updated. Writes directly to Copilot Money via Firestore.",
35321
+ description: "Update an existing user-defined category. Provide category_id (required) and any " + "fields to change: name, emoji, color_name, or is_excluded. Only the specified " + "fields are updated. Note: parent/child category hierarchies are not writable " + "through Copilot's GraphQL API. Writes directly to Copilot Money via GraphQL.",
35192
35322
  inputSchema: {
35193
35323
  type: "object",
35194
35324
  properties: {
@@ -35204,20 +35334,17 @@ function createWriteToolSchemas() {
35204
35334
  type: "string",
35205
35335
  description: 'New emoji icon for the category (e.g., "\uD83C\uDFAC")'
35206
35336
  },
35207
- color: {
35337
+ color_name: {
35208
35338
  type: "string",
35209
- description: 'New hex color code for the category (e.g., "#FF5733")'
35339
+ description: 'New named color from the Copilot palette (e.g., "RED1", "OLIVE1", "PURPLE2").'
35210
35340
  },
35211
- excluded: {
35341
+ is_excluded: {
35212
35342
  type: "boolean",
35213
35343
  description: "Exclude this category from spending totals"
35214
- },
35215
- parent_category_id: {
35216
- type: ["string", "null"],
35217
- description: "Parent category ID to nest under, or null to ungroup. " + "Use get_categories to find valid parent IDs."
35218
35344
  }
35219
35345
  },
35220
- required: ["category_id"]
35346
+ required: ["category_id"],
35347
+ additionalProperties: false
35221
35348
  },
35222
35349
  annotations: {
35223
35350
  readOnlyHint: false,
@@ -35227,7 +35354,7 @@ function createWriteToolSchemas() {
35227
35354
  },
35228
35355
  {
35229
35356
  name: "delete_category",
35230
- description: "Delete a user-defined category. The category_id can be obtained from get_categories. " + "Writes directly to Copilot Money via Firestore.",
35357
+ description: "Delete a user-defined category. The category_id can be obtained from get_categories. " + "Writes directly to Copilot Money via GraphQL.",
35231
35358
  inputSchema: {
35232
35359
  type: "object",
35233
35360
  properties: {
@@ -35245,66 +35372,26 @@ function createWriteToolSchemas() {
35245
35372
  }
35246
35373
  },
35247
35374
  {
35248
- name: "create_budget",
35249
- description: "Create a new budget in Copilot Money. Requires a category_id and amount. " + "Only one budget per category is allowed. Optionally set a period (default: monthly) " + "and a display name. Writes directly to Copilot Money via Firestore.",
35375
+ name: "set_budget",
35376
+ description: 'Set the monthly budget amount for a category. amount="0" clears the budget. ' + 'Pass month="YYYY-MM" for a single-month override; omit for the all-months default. ' + 'Note: if the user has disabled "Enable budgeting" or "Enable rollover" in ' + "Copilot → Settings → General, the budget write still succeeds on the server, but " + "the value will not appear in the Copilot UI until those toggles are re-enabled. " + 'Rollover behavior also depends on the "Rollover categories" selection in the same ' + "settings pane, which is not writable through this tool. " + "Sync delay: successful writes may not be visible via `get_budgets` for minutes — " + "budget docs sync on a slower cadence than other collections. Do not retry the write " + "just because the read does not reflect it yet.",
35250
35377
  inputSchema: {
35251
35378
  type: "object",
35252
35379
  properties: {
35253
35380
  category_id: {
35254
35381
  type: "string",
35255
- description: "Category ID to budget for (from get_categories)"
35382
+ description: "ID of the category to budget."
35256
35383
  },
35257
35384
  amount: {
35258
- type: "number",
35259
- description: "Budget amount (must be greater than 0)"
35260
- },
35261
- period: {
35262
35385
  type: "string",
35263
- description: "Budget period: monthly, yearly, weekly, or daily (default: monthly)",
35264
- enum: ["monthly", "yearly", "weekly", "daily"]
35386
+ description: 'Decimal amount as a string (e.g. "250.00"). "0" clears the budget.'
35265
35387
  },
35266
- name: {
35388
+ month: {
35267
35389
  type: "string",
35268
- description: "Optional display name for the budget"
35390
+ description: "Optional. YYYY-MM for a single-month override. Omit to set the all-months default."
35269
35391
  }
35270
35392
  },
35271
- required: ["category_id", "amount"]
35272
- },
35273
- annotations: {
35274
- readOnlyHint: false,
35275
- destructiveHint: false,
35276
- idempotentHint: false
35277
- }
35278
- },
35279
- {
35280
- name: "update_budget",
35281
- description: "Update an existing budget in Copilot Money. Requires budget_id and at least one " + "field to update (amount, period, name, or is_active). Only provided fields are changed.",
35282
- inputSchema: {
35283
- type: "object",
35284
- properties: {
35285
- budget_id: {
35286
- type: "string",
35287
- description: "Budget ID to update (from get_budgets)"
35288
- },
35289
- amount: {
35290
- type: "number",
35291
- description: "New budget amount (must be greater than 0)"
35292
- },
35293
- period: {
35294
- type: "string",
35295
- description: "New budget period: monthly, yearly, weekly, or daily",
35296
- enum: ["monthly", "yearly", "weekly", "daily"]
35297
- },
35298
- name: {
35299
- type: "string",
35300
- description: "New display name for the budget"
35301
- },
35302
- is_active: {
35303
- type: "boolean",
35304
- description: "Set to false to deactivate the budget, true to reactivate"
35305
- }
35306
- },
35307
- required: ["budget_id"]
35393
+ required: ["category_id", "amount"],
35394
+ additionalProperties: false
35308
35395
  },
35309
35396
  annotations: {
35310
35397
  readOnlyHint: false,
@@ -35312,28 +35399,9 @@ function createWriteToolSchemas() {
35312
35399
  idempotentHint: true
35313
35400
  }
35314
35401
  },
35315
- {
35316
- name: "delete_budget",
35317
- description: "Delete a budget from Copilot Money. Requires the budget_id (from get_budgets). " + "This permanently removes the budget. Writes directly to Copilot Money via Firestore.",
35318
- inputSchema: {
35319
- type: "object",
35320
- properties: {
35321
- budget_id: {
35322
- type: "string",
35323
- description: "Budget ID to delete (from get_budgets)"
35324
- }
35325
- },
35326
- required: ["budget_id"]
35327
- },
35328
- annotations: {
35329
- readOnlyHint: false,
35330
- destructiveHint: true,
35331
- idempotentHint: true
35332
- }
35333
- },
35334
35402
  {
35335
35403
  name: "set_recurring_state",
35336
- description: "Change the state of a recurring item (subscription/charge). " + "Set to active, paused, or archived. Requires recurring_id (from get_recurring_transactions). " + "Writes directly to Copilot Money via Firestore.",
35404
+ description: "Change the state of a recurring item (subscription/charge). " + "Set to ACTIVE, PAUSED, or ARCHIVED (uppercase, matching the GraphQL API). " + "Requires recurring_id (from get_recurring_transactions). " + "Writes directly to Copilot Money via GraphQL.",
35337
35405
  inputSchema: {
35338
35406
  type: "object",
35339
35407
  properties: {
@@ -35343,8 +35411,8 @@ function createWriteToolSchemas() {
35343
35411
  },
35344
35412
  state: {
35345
35413
  type: "string",
35346
- enum: ["active", "paused", "archived"],
35347
- description: "New state for the recurring item"
35414
+ enum: ["ACTIVE", "PAUSED", "ARCHIVED"],
35415
+ description: "New state for the recurring item (uppercase: ACTIVE, PAUSED, ARCHIVED)"
35348
35416
  }
35349
35417
  },
35350
35418
  required: ["recurring_id", "state"]
@@ -35357,7 +35425,7 @@ function createWriteToolSchemas() {
35357
35425
  },
35358
35426
  {
35359
35427
  name: "delete_recurring",
35360
- description: "Delete a recurring item (subscription/charge). " + "Requires recurring_id (from get_recurring_transactions). " + "Writes directly to Copilot Money via Firestore.",
35428
+ description: "Delete a recurring item (subscription/charge). " + "Requires recurring_id (from get_recurring_transactions). " + "Writes directly to Copilot Money via GraphQL.",
35361
35429
  inputSchema: {
35362
35430
  type: "object",
35363
35431
  properties: {
@@ -35374,68 +35442,9 @@ function createWriteToolSchemas() {
35374
35442
  idempotentHint: true
35375
35443
  }
35376
35444
  },
35377
- {
35378
- name: "update_goal",
35379
- description: "Update a financial goal's properties. Provide goal_id (required) and any combination " + "of name, emoji, target_amount, monthly_contribution, or status. Only the fields you " + "provide will be updated. Writes directly to Copilot Money via Firestore.",
35380
- inputSchema: {
35381
- type: "object",
35382
- properties: {
35383
- goal_id: {
35384
- type: "string",
35385
- description: "Goal ID to update (from get_goals results)"
35386
- },
35387
- name: {
35388
- type: "string",
35389
- description: "New display name for the goal"
35390
- },
35391
- emoji: {
35392
- type: "string",
35393
- description: "New emoji icon for the goal"
35394
- },
35395
- target_amount: {
35396
- type: "number",
35397
- description: "New target savings amount (must be > 0)"
35398
- },
35399
- monthly_contribution: {
35400
- type: "number",
35401
- description: "New monthly contribution amount (must be >= 0)"
35402
- },
35403
- status: {
35404
- type: "string",
35405
- enum: ["active", "paused"],
35406
- description: "Set goal status to active or paused"
35407
- }
35408
- },
35409
- required: ["goal_id"]
35410
- },
35411
- annotations: {
35412
- readOnlyHint: false,
35413
- destructiveHint: false,
35414
- idempotentHint: true
35415
- }
35416
- },
35417
- {
35418
- name: "delete_goal",
35419
- description: "Delete a financial goal. The goal_id can be obtained from get_goals results. " + "Writes directly to Copilot Money via Firestore.",
35420
- inputSchema: {
35421
- type: "object",
35422
- properties: {
35423
- goal_id: {
35424
- type: "string",
35425
- description: "Goal ID to delete"
35426
- }
35427
- },
35428
- required: ["goal_id"]
35429
- },
35430
- annotations: {
35431
- readOnlyHint: false,
35432
- destructiveHint: true,
35433
- idempotentHint: true
35434
- }
35435
- },
35436
35445
  {
35437
35446
  name: "update_tag",
35438
- description: "Update an existing tag. Provide tag_id (required) and at least one of name, " + "color_name, or hex_color. Only the specified fields are updated. " + "Writes directly to Copilot Money via Firestore.",
35447
+ description: "Update an existing tag. Provide tag_id (required) and at least one of name, " + "color_name, or hex_color. Only the specified fields are updated. " + "Writes directly to Copilot Money via GraphQL.",
35439
35448
  inputSchema: {
35440
35449
  type: "object",
35441
35450
  properties: {
@@ -35466,76 +35475,22 @@ function createWriteToolSchemas() {
35466
35475
  },
35467
35476
  {
35468
35477
  name: "create_recurring",
35469
- description: "Create a new recurring/subscription item. Requires a name, amount, and frequency. " + "Optionally set category, account, merchant name, or start date. " + "Writes directly to Copilot Money via Firestore.",
35478
+ description: "Create a new recurring/subscription item by seeding it from an existing transaction. " + "The recurring inherits its merchant name, account, and initial amount from that transaction; " + "you only supply the cadence (frequency). Writes directly to Copilot Money via GraphQL.",
35470
35479
  inputSchema: {
35471
35480
  type: "object",
35472
35481
  properties: {
35473
- name: {
35482
+ transaction_id: {
35474
35483
  type: "string",
35475
- description: 'Name of the recurring item (e.g. "Netflix", "Gym Membership")'
35476
- },
35477
- amount: {
35478
- type: "number",
35479
- description: "Recurring amount (must be greater than 0)"
35484
+ description: "ID of an existing transaction to seed the recurring from. The recurring inherits its merchant name, account, and initial amount from this transaction."
35480
35485
  },
35481
35486
  frequency: {
35482
35487
  type: "string",
35483
- enum: ["weekly", "biweekly", "monthly", "yearly"],
35484
- description: "How often the charge recurs"
35485
- },
35486
- category_id: {
35487
- type: "string",
35488
- description: "Category ID for this recurring item (from get_categories)"
35489
- },
35490
- account_id: {
35491
- type: "string",
35492
- description: "Account ID for this recurring item (from get_accounts)"
35493
- },
35494
- merchant_name: {
35495
- type: "string",
35496
- description: "Merchant name for the recurring charge"
35497
- },
35498
- start_date: {
35499
- type: "string",
35500
- description: "Start date in ISO format (YYYY-MM-DD). Defaults to today."
35501
- }
35502
- },
35503
- required: ["name", "amount", "frequency"]
35504
- },
35505
- annotations: {
35506
- readOnlyHint: false,
35507
- destructiveHint: false,
35508
- idempotentHint: false
35509
- }
35510
- },
35511
- {
35512
- name: "create_goal",
35513
- description: "Create a new financial goal. Requires a name and target amount. " + "Optionally set an emoji, monthly contribution, or start date. " + "Writes directly to Copilot Money via Firestore.",
35514
- inputSchema: {
35515
- type: "object",
35516
- properties: {
35517
- name: {
35518
- type: "string",
35519
- description: 'Name of the financial goal (e.g. "Emergency Fund", "Vacation")'
35520
- },
35521
- target_amount: {
35522
- type: "number",
35523
- description: "Target savings amount (must be greater than 0)"
35524
- },
35525
- emoji: {
35526
- type: "string",
35527
- description: 'Emoji icon for the goal (e.g. "\uD83C\uDFD6️")'
35528
- },
35529
- monthly_contribution: {
35530
- type: "number",
35531
- description: "Monthly contribution amount (must be >= 0). " + "Sets tracking to monthly_contribution mode when provided."
35532
- },
35533
- start_date: {
35534
- type: "string",
35535
- description: "Start date in ISO format (YYYY-MM-DD). Defaults to today."
35488
+ enum: ["WEEKLY", "BIWEEKLY", "MONTHLY", "YEARLY"],
35489
+ description: "How often the recurring payment occurs."
35536
35490
  }
35537
35491
  },
35538
- required: ["name", "target_amount"]
35492
+ required: ["transaction_id", "frequency"],
35493
+ additionalProperties: false
35539
35494
  },
35540
35495
  annotations: {
35541
35496
  readOnlyHint: false,
@@ -35545,68 +35500,46 @@ function createWriteToolSchemas() {
35545
35500
  },
35546
35501
  {
35547
35502
  name: "update_recurring",
35548
- description: "Update an existing recurring/subscription item. Can modify name, amount, frequency, " + "category, account, match string, and transaction ID lists. " + "Useful for fixing recurring detectionupdate match_string and transaction_ids " + "to teach Copilot which transactions belong to this recurring charge. " + "Writes directly to Copilot Money via Firestore.",
35503
+ description: "Update an existing recurring transaction. Pass recurring_id plus any combination of " + "state or rule (name_contains, min_amount, max_amount, days). The recurring cannot be " + "renamed or re-linked to a different transaction through this tool those fields must " + "be changed in the Copilot Money web app. Writes directly to Copilot Money via GraphQL.",
35549
35504
  inputSchema: {
35550
35505
  type: "object",
35551
35506
  properties: {
35552
35507
  recurring_id: {
35553
35508
  type: "string",
35554
- description: "ID of the recurring item to update (from get_recurring_transactions)"
35555
- },
35556
- name: {
35557
- type: "string",
35558
- description: "New display name for the recurring charge"
35559
- },
35560
- amount: {
35561
- type: "number",
35562
- description: "Expected recurring amount (must be > 0)"
35563
- },
35564
- frequency: {
35565
- type: "string",
35566
- enum: ["weekly", "biweekly", "monthly", "yearly"],
35567
- description: "How often this charge recurs"
35568
- },
35569
- category_id: {
35570
- type: "string",
35571
- description: "Category ID to assign (from get_categories)"
35572
- },
35573
- account_id: {
35574
- type: "string",
35575
- description: "Account ID this recurring charge is associated with"
35576
- },
35577
- merchant_name: {
35578
- type: "string",
35579
- description: "Merchant name for the recurring charge"
35580
- },
35581
- emoji: {
35582
- type: "string",
35583
- description: "Emoji icon for the recurring item"
35509
+ description: "ID of the recurring to update."
35584
35510
  },
35585
- match_string: {
35511
+ state: {
35586
35512
  type: "string",
35587
- description: "Pattern used to auto-match incoming transactions to this recurring item " + '(e.g., "NETFLIX" matches transactions with "NETFLIX" in the name)'
35588
- },
35589
- transaction_ids: {
35590
- type: "array",
35591
- items: { type: "string" },
35592
- description: "Transaction IDs that belong to this recurring item"
35593
- },
35594
- excluded_transaction_ids: {
35595
- type: "array",
35596
- items: { type: "string" },
35597
- description: "Transaction IDs explicitly excluded from this recurring item"
35598
- },
35599
- included_transaction_ids: {
35600
- type: "array",
35601
- items: { type: "string" },
35602
- description: "Transaction IDs explicitly included in this recurring item"
35513
+ enum: ["ACTIVE", "PAUSED", "ARCHIVED"],
35514
+ description: "State of the recurring. Use set_recurring_state instead if you only want to " + "change state — this tool is for broader edits."
35603
35515
  },
35604
- days_filter: {
35605
- type: "number",
35606
- description: "Expected day-of-month for matching (e.g., 1 for charges on the 1st)"
35516
+ rule: {
35517
+ type: "object",
35518
+ description: "Matching rule. Controls how Copilot auto-detects future payments.",
35519
+ properties: {
35520
+ name_contains: {
35521
+ type: "string",
35522
+ description: "Substring that must appear in the merchant/payee name."
35523
+ },
35524
+ min_amount: {
35525
+ type: "string",
35526
+ description: "Minimum amount (as a decimal string) for a transaction to match."
35527
+ },
35528
+ max_amount: {
35529
+ type: "string",
35530
+ description: "Maximum amount (as a decimal string) for a transaction to match."
35531
+ },
35532
+ days: {
35533
+ type: "array",
35534
+ items: { type: "number" },
35535
+ description: "Days of the month (1-31) when this recurring is expected."
35536
+ }
35537
+ },
35538
+ additionalProperties: false
35607
35539
  }
35608
35540
  },
35609
- required: ["recurring_id"]
35541
+ required: ["recurring_id"],
35542
+ additionalProperties: false
35610
35543
  },
35611
35544
  annotations: {
35612
35545
  readOnlyHint: false,
@@ -35616,100 +35549,6 @@ function createWriteToolSchemas() {
35616
35549
  }
35617
35550
  ];
35618
35551
  }
35619
- // src/core/firestore-client.ts
35620
- var FIRESTORE_PROJECT_ID = "copilot-production-22904";
35621
- var FIRESTORE_BASE_URL = "https://firestore.googleapis.com/v1";
35622
-
35623
- class FirestoreClient {
35624
- auth;
35625
- constructor(auth) {
35626
- this.auth = auth;
35627
- }
35628
- getUserId() {
35629
- return this.auth.getUserId();
35630
- }
35631
- async requireUserId() {
35632
- await this.auth.getIdToken();
35633
- const uid = this.auth.getUserId();
35634
- if (!uid) {
35635
- throw new Error("Firebase user ID unavailable after authentication");
35636
- }
35637
- return uid;
35638
- }
35639
- async updateDocument(collectionPath, documentId, fields, updateMask) {
35640
- const idToken = await this.auth.getIdToken();
35641
- const docPath = `projects/${FIRESTORE_PROJECT_ID}/databases/(default)/documents/${collectionPath}/${documentId}`;
35642
- const url2 = new URL(`${FIRESTORE_BASE_URL}/${docPath}`);
35643
- for (const field of updateMask) {
35644
- url2.searchParams.append("updateMask.fieldPaths", field);
35645
- }
35646
- const response = await fetch(url2.toString(), {
35647
- method: "PATCH",
35648
- headers: {
35649
- Authorization: `Bearer ${idToken}`,
35650
- "Content-Type": "application/json"
35651
- },
35652
- body: JSON.stringify({ fields })
35653
- });
35654
- if (!response.ok) {
35655
- const errorBody = await response.text();
35656
- throw new Error(`Firestore update failed (${response.status}): ${errorBody}`);
35657
- }
35658
- }
35659
- async getDocument(collectionPath, documentId) {
35660
- const idToken = await this.auth.getIdToken();
35661
- const docPath = `projects/${FIRESTORE_PROJECT_ID}/databases/(default)/documents/${collectionPath}/${documentId}`;
35662
- const response = await fetch(`${FIRESTORE_BASE_URL}/${docPath}`, {
35663
- method: "GET",
35664
- headers: {
35665
- Authorization: `Bearer ${idToken}`
35666
- }
35667
- });
35668
- if (!response.ok) {
35669
- const errorBody = await response.text();
35670
- throw new Error(`Firestore get failed (${response.status}): ${errorBody}`);
35671
- }
35672
- const doc2 = await response.json();
35673
- return doc2.fields ?? {};
35674
- }
35675
- async createDocument(collectionPath, documentId, fields) {
35676
- const idToken = await this.auth.getIdToken();
35677
- const parentPath = `projects/${FIRESTORE_PROJECT_ID}/databases/(default)/documents`;
35678
- const url2 = new URL(`${FIRESTORE_BASE_URL}/${parentPath}/${collectionPath}`);
35679
- if (documentId) {
35680
- url2.searchParams.set("documentId", documentId);
35681
- }
35682
- const response = await fetch(url2.toString(), {
35683
- method: "POST",
35684
- headers: {
35685
- Authorization: `Bearer ${idToken}`,
35686
- "Content-Type": "application/json"
35687
- },
35688
- body: JSON.stringify({ fields })
35689
- });
35690
- if (!response.ok) {
35691
- const errorBody = await response.text();
35692
- throw new Error(`Firestore create failed (${response.status}): ${errorBody}`);
35693
- }
35694
- const doc2 = await response.json();
35695
- return doc2.name.split("/").pop();
35696
- }
35697
- async deleteDocument(collectionPath, documentId) {
35698
- const idToken = await this.auth.getIdToken();
35699
- const docPath = `projects/${FIRESTORE_PROJECT_ID}/databases/(default)/documents/${collectionPath}/${documentId}`;
35700
- const response = await fetch(`${FIRESTORE_BASE_URL}/${docPath}`, {
35701
- method: "DELETE",
35702
- headers: {
35703
- Authorization: `Bearer ${idToken}`
35704
- }
35705
- });
35706
- if (!response.ok) {
35707
- const errorBody = await response.text();
35708
- throw new Error(`Firestore delete failed (${response.status}): ${errorBody}`);
35709
- }
35710
- }
35711
- }
35712
-
35713
35552
  // src/core/auth/firebase-auth.ts
35714
35553
  var FIREBASE_API_KEY = "AIzaSyAMgjkeOSkHj4J4rlswOkD16N3WQOoNPpk";
35715
35554
  var TOKEN_ENDPOINT = `https://securetoken.googleapis.com/v1/token?key=${FIREBASE_API_KEY}`;
@@ -35918,12 +35757,12 @@ class CopilotMoneyServer {
35918
35757
  constructor(dbPath, decodeTimeoutMs, writeEnabled = false) {
35919
35758
  this.db = new CopilotDatabase(dbPath, decodeTimeoutMs);
35920
35759
  this.writeEnabled = writeEnabled;
35921
- let firestoreClient;
35760
+ let graphqlClient;
35922
35761
  if (writeEnabled) {
35923
35762
  const auth = new FirebaseAuth(() => extractRefreshToken());
35924
- firestoreClient = new FirestoreClient(auth);
35763
+ graphqlClient = new GraphQLClient(auth);
35925
35764
  }
35926
- this.tools = new CopilotMoneyTools(this.db, firestoreClient);
35765
+ this.tools = new CopilotMoneyTools(this.db, graphqlClient);
35927
35766
  this.server = new Server({
35928
35767
  name: "copilot-money-mcp",
35929
35768
  version: SERVER_VERSION
@@ -35949,21 +35788,16 @@ class CopilotMoneyServer {
35949
35788
  "update_transaction",
35950
35789
  "review_transactions",
35951
35790
  "create_tag",
35791
+ "update_tag",
35952
35792
  "delete_tag",
35953
35793
  "create_category",
35954
35794
  "update_category",
35955
35795
  "delete_category",
35956
- "create_budget",
35957
- "update_budget",
35958
- "delete_budget",
35796
+ "set_budget",
35959
35797
  "set_recurring_state",
35960
- "delete_recurring",
35961
- "update_goal",
35962
- "delete_goal",
35963
- "update_tag",
35964
35798
  "create_recurring",
35965
- "create_goal",
35966
- "update_recurring"
35799
+ "update_recurring",
35800
+ "delete_recurring"
35967
35801
  ]);
35968
35802
  async handleCallTool(name, typedArgs) {
35969
35803
  if (CopilotMoneyServer.WRITE_TOOLS.has(name) && !this.writeEnabled) {
@@ -36062,14 +35896,8 @@ class CopilotMoneyServer {
36062
35896
  case "delete_category":
36063
35897
  result = await this.tools.deleteCategory(typedArgs);
36064
35898
  break;
36065
- case "create_budget":
36066
- result = await this.tools.createBudget(typedArgs);
36067
- break;
36068
- case "update_budget":
36069
- result = await this.tools.updateBudget(typedArgs);
36070
- break;
36071
- case "delete_budget":
36072
- result = await this.tools.deleteBudget(typedArgs);
35899
+ case "set_budget":
35900
+ result = await this.tools.setBudget(typedArgs);
36073
35901
  break;
36074
35902
  case "set_recurring_state":
36075
35903
  result = await this.tools.setRecurringState(typedArgs);
@@ -36077,21 +35905,12 @@ class CopilotMoneyServer {
36077
35905
  case "delete_recurring":
36078
35906
  result = await this.tools.deleteRecurring(typedArgs);
36079
35907
  break;
36080
- case "update_goal":
36081
- result = await this.tools.updateGoal(typedArgs);
36082
- break;
36083
- case "delete_goal":
36084
- result = await this.tools.deleteGoal(typedArgs);
36085
- break;
36086
35908
  case "update_tag":
36087
35909
  result = await this.tools.updateTag(typedArgs);
36088
35910
  break;
36089
35911
  case "create_recurring":
36090
35912
  result = await this.tools.createRecurring(typedArgs);
36091
35913
  break;
36092
- case "create_goal":
36093
- result = await this.tools.createGoal(typedArgs);
36094
- break;
36095
35914
  case "update_recurring":
36096
35915
  result = await this.tools.updateRecurring(typedArgs);
36097
35916
  break;