@dizzlkheinz/ynab-mcpb 0.26.2 → 0.26.4

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.
@@ -257,6 +257,31 @@ export const ExecutionActionRecordSchema = z.discriminatedUnion("type", [
257
257
  reason: z.string(),
258
258
  bulk_chunk_index: z.number(),
259
259
  }),
260
+ z.object({
261
+ type: z.literal("batch_update_failed"),
262
+ transaction: z.null(),
263
+ reason: z.string(),
264
+ }),
265
+ z.object({
266
+ type: z.literal("batch_reconcile_failed"),
267
+ transaction: z.null(),
268
+ reason: z.string(),
269
+ }),
270
+ z.object({
271
+ type: z.literal("reconciliation_complete"),
272
+ transaction: z.null(),
273
+ reason: z.string(),
274
+ }),
275
+ z.object({
276
+ type: z.literal("diagnostic_step3_entry"),
277
+ transaction: z.null(),
278
+ reason: z.string(),
279
+ }),
280
+ z.object({
281
+ type: z.literal("diagnostic_unmatched_ynab"),
282
+ transaction: z.record(z.string(), z.unknown()),
283
+ reason: z.string(),
284
+ }),
260
285
  ]);
261
286
  export const ExecutionSummarySchema = z.object({
262
287
  bank_transactions_count: z.number(),
@@ -319,78 +344,25 @@ export const CsvFormatMetadataSchema = z.object({
319
344
  amount_column: z.string().nullable(),
320
345
  payee_column: z.string().nullable(),
321
346
  });
322
- const StructuredReconciliationDataBaseSchema = z.object({
323
- version: z.string(),
324
- schema_url: z.string(),
325
- generated_at: z.string(),
326
- account: z.object({
327
- id: z.string().optional(),
328
- name: z.string().optional(),
329
- }),
330
- summary: ReconciliationSummarySchema,
331
- balance: BalanceInfoSchema.extend({
332
- discrepancy_direction: z.enum(["balanced", "ynab_higher", "bank_higher"]),
333
- }),
334
- insights: z.array(ReconciliationInsightSchema),
335
- next_steps: z.array(z.string()),
336
- matches: z.object({
337
- auto: z.array(TransactionMatchSchema),
338
- suggested: z.array(TransactionMatchSchema),
339
- }),
340
- unmatched: z.object({
341
- bank: z.array(BankTransactionSchema),
342
- ynab: z.array(YNABTransactionSimpleSchema),
343
- ynab_outside_date_range: z.array(YNABTransactionSimpleSchema).optional(),
344
- }),
345
- recommendations: z.array(ActionableRecommendationSchema).optional(),
346
- csv_format: CsvFormatMetadataSchema.optional(),
347
- execution: ExecutionResultSchema.optional(),
348
- audit: AuditMetadataSchema.optional(),
347
+ export const ExecutionSummaryOutputSchema = z.object({
348
+ transactions_created: z.number().int(),
349
+ transactions_updated: z.number().int(),
350
+ dates_adjusted: z.number().int(),
351
+ dry_run: z.boolean(),
352
+ balance_status: z.enum(["balanced", "unbalanced", "not_verified"]).optional(),
353
+ recommendations: z.array(z.string()).optional(),
349
354
  });
350
355
  export const StructuredReconciliationUnmatchedOnlySchema = z
351
356
  .object({
352
357
  unmatched_bank: z.array(BankTransactionSchema),
353
358
  unmatched_ynab: z.array(YNABTransactionSimpleSchema),
354
359
  suggestions: z.array(TransactionMatchSchema),
360
+ execution_summary: ExecutionSummaryOutputSchema.optional(),
355
361
  })
356
362
  .strict();
357
363
  export const ReconcileAccountOutputSchema = z
358
- .union([
359
- z
360
- .object({
361
- human: z.string(),
362
- structured: z.union([
363
- StructuredReconciliationDataBaseSchema,
364
- StructuredReconciliationUnmatchedOnlySchema,
365
- ]),
366
- })
367
- .strict(),
368
- z
369
- .object({
370
- human: z.string(),
371
- })
372
- .strict(),
373
- ])
374
- .refine((data) => {
375
- if ("structured" in data &&
376
- data.structured &&
377
- "balance" in data.structured &&
378
- typeof data.structured.balance === "object" &&
379
- data.structured.balance !== null) {
380
- const discrepancyAmount = data.structured.balance.discrepancy.value;
381
- const direction = data.structured.balance.discrepancy_direction;
382
- if (Math.abs(discrepancyAmount) < 0.01) {
383
- return direction === "balanced";
384
- }
385
- if (discrepancyAmount > 0) {
386
- return direction === "ynab_higher";
387
- }
388
- if (discrepancyAmount < 0) {
389
- return direction === "bank_higher";
390
- }
391
- }
392
- return true;
393
- }, {
394
- message: "Discrepancy direction mismatch: direction must match the numeric discrepancy amount",
395
- path: ["balance", "discrepancy_direction"],
396
- });
364
+ .object({
365
+ human: z.string(),
366
+ structured: StructuredReconciliationUnmatchedOnlySchema,
367
+ })
368
+ .strict();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dizzlkheinz/ynab-mcpb",
3
- "version": "0.26.2",
3
+ "version": "0.26.4",
4
4
  "mcpName": "io.github.dizzlkheinz/ynab-mcpb",
5
5
  "description": "Model Context Protocol server for YNAB (You Need A Budget) integration",
6
6
  "main": "dist/index.js",
@@ -242,16 +242,14 @@ function buildPerformanceParams(
242
242
  account_id: "account-performance",
243
243
  csv_data: "Date,Description,Amount",
244
244
  statement_balance: statementBalance,
245
- statement_date: "2025-08-31",
245
+ statement_end_date: "2025-08-31",
246
246
  date_tolerance_days: 1,
247
- auto_match_threshold: 90,
248
- suggestion_threshold: 60,
247
+ match_strictness: "strict" as const,
249
248
  auto_create_transactions: true,
250
249
  auto_update_cleared_status: false,
251
250
  auto_unclear_missing: false,
252
251
  auto_adjust_dates: false,
253
252
  dry_run: false,
254
- include_structured_data: false,
255
253
  ...overrides,
256
254
  };
257
255
  }
@@ -376,16 +376,14 @@ function buildIntegrationParams(
376
376
  account_id: accountId,
377
377
  csv_data: "Date,Description,Amount",
378
378
  statement_balance: statementBalance,
379
- statement_date: new Date().toISOString().slice(0, 10),
379
+ statement_end_date: new Date().toISOString().slice(0, 10),
380
380
  date_tolerance_days: 1,
381
- auto_match_threshold: 90,
382
- suggestion_threshold: 60,
381
+ match_strictness: "strict" as const,
383
382
  auto_create_transactions: true,
384
383
  auto_update_cleared_status: false,
385
384
  auto_unclear_missing: false,
386
385
  auto_adjust_dates: false,
387
386
  dry_run: false,
388
- include_structured_data: false,
389
387
  ...overrides,
390
388
  };
391
389
  }
@@ -155,10 +155,9 @@ const buildBulkParams = (
155
155
  account_id: "account-bulk",
156
156
  csv_data: "Date,Payee,Amount",
157
157
  statement_balance: statementBalance.value, // Use decimal value for params
158
- statement_date: "2025-10-31",
158
+ statement_end_date: "2025-10-31",
159
159
  date_tolerance_days: 1,
160
- auto_match_threshold: 90,
161
- suggestion_threshold: 60,
160
+ match_strictness: "strict" as const,
162
161
  auto_create_transactions: true,
163
162
  auto_update_cleared_status: false,
164
163
  auto_unclear_missing: false,
@@ -240,8 +239,7 @@ describe("executeReconciliation (dry run)", () => {
240
239
  csv_data: "Date,Description,Amount",
241
240
  statement_balance: -921.24,
242
241
  date_tolerance_days: 2,
243
- auto_match_threshold: 90,
244
- suggestion_threshold: 60,
242
+ match_strictness: "strict" as const,
245
243
  auto_create_transactions: true,
246
244
  auto_update_cleared_status: true,
247
245
  auto_unclear_missing: true,
@@ -284,10 +282,9 @@ describe("executeReconciliation (apply mode)", () => {
284
282
  account_id: "account-apply",
285
283
  csv_data: "Date,Description,Amount",
286
284
  statement_balance: -921.24,
287
- statement_date: "2025-10-31",
285
+ statement_end_date: "2025-10-31",
288
286
  date_tolerance_days: 2,
289
- auto_match_threshold: 90,
290
- suggestion_threshold: 60,
287
+ match_strictness: "strict" as const,
291
288
  auto_create_transactions: true,
292
289
  auto_update_cleared_status: true,
293
290
  auto_unclear_missing: true,
@@ -448,8 +445,7 @@ describe("executeReconciliation (ordered halting)", () => {
448
445
  csv_data: "Date,Description,Amount",
449
446
  statement_balance: 100,
450
447
  date_tolerance_days: 2,
451
- auto_match_threshold: 90,
452
- suggestion_threshold: 60,
448
+ match_strictness: "strict" as const,
453
449
  auto_create_transactions: false,
454
450
  auto_update_cleared_status: true,
455
451
  auto_unclear_missing: false,
@@ -4,7 +4,7 @@ import { DeltaFetcher } from "../../deltaFetcher.js";
4
4
  import { handleReconcileAccount, ReconcileAccountSchema } from "../index.js";
5
5
 
6
6
  describe("ReconcileAccountSchema", () => {
7
- it("parses new response options and defaults structured_content to full", () => {
7
+ it("parses params and respects max_suggestions_in_output", () => {
8
8
  const result = ReconcileAccountSchema.parse({
9
9
  budget_id: "budget-1",
10
10
  account_id: "account-1",
@@ -13,7 +13,6 @@ describe("ReconcileAccountSchema", () => {
13
13
  max_suggestions_in_output: 3,
14
14
  });
15
15
 
16
- expect(result.structured_content).toBe("full");
17
16
  expect(result.max_suggestions_in_output).toBe(3);
18
17
  });
19
18
 
@@ -48,7 +47,7 @@ describe("ReconcileAccountSchema", () => {
48
47
  });
49
48
 
50
49
  describe("handleReconcileAccount", () => {
51
- it('returns filtered structured content for "unmatched_only"', async () => {
50
+ it("always returns unmatched_only structured content", async () => {
52
51
  const ynabAPI = {
53
52
  accounts: {
54
53
  getAccounts: async () => ({
@@ -96,10 +95,7 @@ describe("handleReconcileAccount", () => {
96
95
  account_id: "account-1",
97
96
  csv_data: "Date,Description,Amount\n2025-01-01,Coffee,-10.00",
98
97
  statement_balance: -10,
99
- include_structured_data: true,
100
- structured_content: "unmatched_only",
101
98
  auto_unclear_missing: false,
102
- force_full_refresh: true,
103
99
  });
104
100
 
105
101
  const structured = (result.structuredContent as Record<string, unknown>)
@@ -115,5 +111,13 @@ describe("handleReconcileAccount", () => {
115
111
  expect(Array.isArray(structured["unmatched_bank"])).toBe(true);
116
112
  expect(Array.isArray(structured["unmatched_ynab"])).toBe(true);
117
113
  expect(Array.isArray(structured["suggestions"])).toBe(true);
114
+ expect(structured).toHaveProperty("execution_summary");
115
+ expect(
116
+ (
117
+ structured["execution_summary"] as {
118
+ balance_status?: string;
119
+ }
120
+ ).balance_status,
121
+ ).toBe("unbalanced");
118
122
  });
119
123
  });
@@ -1,4 +1,3 @@
1
- import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
1
  import {
3
2
  afterEach,
4
3
  beforeAll,
@@ -34,50 +33,6 @@ describeIntegration("Reconciliation delta isolation", () => {
34
33
  let deltaFetcher: DeltaFetcher;
35
34
  let previousNodeEnv: string | undefined;
36
35
  let setupRateLimited = false;
37
- const parseStructuredPayload = (result: CallToolResult) => {
38
- if (result.isError) {
39
- const errorContent = result.content?.find(
40
- (entry) => entry.type === "text",
41
- );
42
- throw new Error(
43
- errorContent && errorContent.type === "text"
44
- ? errorContent.text
45
- : "Unexpected reconciliation error response",
46
- );
47
- }
48
-
49
- // Find the last text entry that contains valid JSON with an "audit" key
50
- const textEntries =
51
- result.content?.filter((entry) => entry.type === "text") ?? [];
52
- for (let i = textEntries.length - 1; i >= 0; i--) {
53
- const entry = textEntries[i];
54
- if (entry.type === "text") {
55
- try {
56
- const parsed = JSON.parse(entry.text);
57
- if (parsed && typeof parsed === "object") {
58
- // Check if audit is at top level (old format)
59
- if ("audit" in parsed) {
60
- return parsed;
61
- }
62
- // Check if audit is nested under structured (current format: { human, structured: { audit, ... } })
63
- const structured = (parsed as any).structured;
64
- if (
65
- structured &&
66
- typeof structured === "object" &&
67
- "audit" in structured
68
- ) {
69
- return structured;
70
- }
71
- }
72
- } catch {
73
- // Not valid JSON, continue searching
74
- }
75
- }
76
- }
77
- throw new Error(
78
- 'Expected structured reconciliation payload with "audit" key to be present',
79
- );
80
- };
81
36
 
82
37
  beforeAll(async () => {
83
38
  try {
@@ -161,7 +116,6 @@ describeIntegration("Reconciliation delta isolation", () => {
161
116
  account_id: testAccountId,
162
117
  csv_data: csvData,
163
118
  statement_balance: 0,
164
- include_structured_data: true,
165
119
  };
166
120
 
167
121
  const accountsFullSpy = vi.spyOn(deltaFetcher, "fetchAccountsFull");
@@ -171,11 +125,7 @@ describeIntegration("Reconciliation delta isolation", () => {
171
125
  );
172
126
  const txDeltaSpy = vi.spyOn(deltaFetcher, "fetchTransactionsByAccount");
173
127
 
174
- const result = await handleReconcileAccount(
175
- ynabAPI,
176
- deltaFetcher,
177
- params,
178
- );
128
+ await handleReconcileAccount(ynabAPI, deltaFetcher, params);
179
129
 
180
130
  expect(accountsFullSpy).toHaveBeenCalledWith(testBudgetId);
181
131
  expect(txFullSpy).toHaveBeenCalledWith(
@@ -184,65 +134,6 @@ describeIntegration("Reconciliation delta isolation", () => {
184
134
  expect.any(String),
185
135
  );
186
136
  expect(txDeltaSpy).not.toHaveBeenCalled();
187
-
188
- const structuredPayload = parseStructuredPayload(result);
189
- expect(structuredPayload.audit).toMatchObject({
190
- data_freshness: "guaranteed_fresh",
191
- data_source: "full_api_fetch_no_delta",
192
- });
193
- expect(structuredPayload.audit).toHaveProperty("server_knowledge");
194
- expect(structuredPayload.audit).toHaveProperty("transactions_count");
195
- });
196
- });
197
-
198
- it("can opt into delta-backed fetches when force_full_refresh is false", {
199
- meta: { tier: "domain", domain: "delta" },
200
- }, async (ctx) => {
201
- await withRateLimitSkip(ctx, async () => {
202
- const csvData = ["Date,Amount,Description", "2024-01-01,10,Coffee"].join(
203
- "\n",
204
- );
205
- const params = {
206
- budget_id: testBudgetId,
207
- account_id: testAccountId,
208
- csv_data: csvData,
209
- statement_balance: 0,
210
- include_structured_data: true,
211
- force_full_refresh: false,
212
- };
213
-
214
- const accountsFullSpy = vi.spyOn(deltaFetcher, "fetchAccountsFull");
215
- const txFullSpy = vi.spyOn(
216
- deltaFetcher,
217
- "fetchTransactionsByAccountFull",
218
- );
219
- const accountsDeltaSpy = vi.spyOn(deltaFetcher, "fetchAccounts");
220
- const txDeltaSpy = vi.spyOn(deltaFetcher, "fetchTransactionsByAccount");
221
-
222
- const result = await handleReconcileAccount(
223
- ynabAPI,
224
- deltaFetcher,
225
- params,
226
- );
227
-
228
- expect(accountsFullSpy).not.toHaveBeenCalled();
229
- expect(txFullSpy).not.toHaveBeenCalled();
230
- expect(accountsDeltaSpy).toHaveBeenCalledWith(testBudgetId);
231
- expect(txDeltaSpy).toHaveBeenCalledWith(
232
- testBudgetId,
233
- testAccountId,
234
- expect.any(String),
235
- );
236
-
237
- const structuredPayload = parseStructuredPayload(result);
238
- expect(structuredPayload.audit).toMatchObject({
239
- data_source: expect.stringMatching(/^delta_fetch_/),
240
- });
241
- expect(structuredPayload.audit.cache_status).toMatchObject({
242
- accounts_cached: expect.any(Boolean),
243
- transactions_cached: expect.any(Boolean),
244
- delta_merge_applied: expect.any(Boolean),
245
- });
246
137
  });
247
138
  });
248
139
  });
@@ -130,17 +130,10 @@ function resolveStatementWindow(
130
130
  params: ReconcileAccountRequest,
131
131
  analysisDateRange?: string | undefined,
132
132
  ): StatementWindow | undefined {
133
- const start = parseISODate(params.statement_start_date);
134
- const end =
135
- parseISODate(params.statement_end_date ?? params.statement_date) ??
136
- // If only start provided, end stays undefined
137
- undefined;
138
-
139
- if (start || end) {
140
- const window: StatementWindow = {};
141
- if (start) window.start = start;
142
- if (end) window.end = end;
143
- return window;
133
+ const end = parseISODate(params.statement_end_date);
134
+
135
+ if (end) {
136
+ return { end };
144
137
  }
145
138
 
146
139
  if (analysisDateRange?.includes(" to ")) {
@@ -941,12 +934,12 @@ export async function executeReconciliation(
941
934
 
942
935
  // STEP 5: Balance reconciliation snapshot (only once per execution)
943
936
  let balance_reconciliation: ExecutionResult["balance_reconciliation"];
944
- if (params.statement_balance !== undefined && params.statement_date) {
937
+ if (params.statement_balance !== undefined && params.statement_end_date) {
945
938
  balance_reconciliation = await buildBalanceReconciliation({
946
939
  ynabAPI,
947
940
  budgetId,
948
941
  accountId,
949
- statementDate: params.statement_date,
942
+ statementDate: params.statement_end_date,
950
943
  statementBalanceMilli: statementTargetMilli,
951
944
  analysis,
952
945
  });