ynab-mcp-deluxe 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +25 -0
- package/README.md +99 -0
- package/dist/backup.d.ts +28 -0
- package/dist/backup.d.ts.map +1 -0
- package/dist/backup.js +78 -0
- package/dist/drift-detection.d.ts +110 -0
- package/dist/drift-detection.d.ts.map +1 -0
- package/dist/drift-detection.js +366 -0
- package/dist/drift-snapshot.d.ts +74 -0
- package/dist/drift-snapshot.d.ts.map +1 -0
- package/dist/drift-snapshot.js +120 -0
- package/dist/helpers.d.ts +65 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/helpers.js +226 -0
- package/dist/local-budget.d.ts +86 -0
- package/dist/local-budget.d.ts.map +1 -0
- package/dist/local-budget.js +277 -0
- package/dist/logger.d.ts +50 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +136 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +1626 -0
- package/dist/sync-history.d.ts +95 -0
- package/dist/sync-history.d.ts.map +1 -0
- package/dist/sync-history.js +205 -0
- package/dist/sync-providers.d.ts +71 -0
- package/dist/sync-providers.d.ts.map +1 -0
- package/dist/sync-providers.js +105 -0
- package/dist/types.d.ts +535 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/ynab-client.d.ts +257 -0
- package/dist/ynab-client.d.ts.map +1 -0
- package/dist/ynab-client.js +1248 -0
- package/package.json +102 -0
package/dist/helpers.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper functions for the YNAB MCP server
|
|
3
|
+
*/
|
|
4
|
+
import jmespath from '@metrichor/jmespath';
|
|
5
|
+
/**
|
|
6
|
+
* Apply a JMESPath query to data
|
|
7
|
+
* @throws Error with helpful message if query is invalid
|
|
8
|
+
*/
|
|
9
|
+
export function applyJMESPath(data, query) {
|
|
10
|
+
try {
|
|
11
|
+
// Cast through unknown for type safety with jmespath
|
|
12
|
+
const jsonData = data;
|
|
13
|
+
return jmespath.search(jsonData, query);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
17
|
+
throw new Error(`Invalid JMESPath expression: ${message}. Expression: '${query}'.`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Sort transactions by the specified criteria
|
|
22
|
+
*/
|
|
23
|
+
export function sortTransactions(transactions, sortBy) {
|
|
24
|
+
const sorted = [...transactions];
|
|
25
|
+
switch (sortBy) {
|
|
26
|
+
case 'newest':
|
|
27
|
+
sorted.sort((a, b) => b.date.localeCompare(a.date));
|
|
28
|
+
break;
|
|
29
|
+
case 'oldest':
|
|
30
|
+
sorted.sort((a, b) => a.date.localeCompare(b.date));
|
|
31
|
+
break;
|
|
32
|
+
case 'amount_desc':
|
|
33
|
+
// Largest outflows first (most negative first)
|
|
34
|
+
sorted.sort((a, b) => a.amount - b.amount);
|
|
35
|
+
break;
|
|
36
|
+
case 'amount_asc':
|
|
37
|
+
// Largest inflows first (most positive first)
|
|
38
|
+
sorted.sort((a, b) => b.amount - a.amount);
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
return sorted;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Filter transactions by payee name (case-insensitive partial match)
|
|
45
|
+
*/
|
|
46
|
+
export function filterByPayee(transactions, payeeContains) {
|
|
47
|
+
const searchLower = payeeContains.toLowerCase();
|
|
48
|
+
return transactions.filter((tx) => {
|
|
49
|
+
const payeeName = tx.payee_name?.toLowerCase() ?? '';
|
|
50
|
+
const importPayeeName = tx.import_payee_name?.toLowerCase() ?? '';
|
|
51
|
+
const importPayeeNameOriginal = tx.import_payee_name_original?.toLowerCase() ?? '';
|
|
52
|
+
return (payeeName.includes(searchLower) ||
|
|
53
|
+
importPayeeName.includes(searchLower) ||
|
|
54
|
+
importPayeeNameOriginal.includes(searchLower));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Filter transactions by date range
|
|
59
|
+
*/
|
|
60
|
+
export function filterByDateRange(transactions, sinceDate, untilDate) {
|
|
61
|
+
return transactions.filter((tx) => {
|
|
62
|
+
if (sinceDate !== undefined && sinceDate !== '' && tx.date < sinceDate)
|
|
63
|
+
return false;
|
|
64
|
+
if (untilDate !== undefined && untilDate !== '' && tx.date > untilDate)
|
|
65
|
+
return false;
|
|
66
|
+
return true;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Filter transactions by account ID
|
|
71
|
+
*/
|
|
72
|
+
export function filterByAccount(transactions, accountId) {
|
|
73
|
+
return transactions.filter((tx) => tx.account_id === accountId);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Calculate category distribution for a set of transactions
|
|
77
|
+
*/
|
|
78
|
+
export function calculateCategoryDistribution(transactions) {
|
|
79
|
+
const counts = new Map();
|
|
80
|
+
for (const tx of transactions) {
|
|
81
|
+
const key = tx.category_id ?? 'uncategorized';
|
|
82
|
+
const existing = counts.get(key);
|
|
83
|
+
if (existing !== undefined) {
|
|
84
|
+
existing.count++;
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
counts.set(key, {
|
|
88
|
+
count: 1,
|
|
89
|
+
groupName: tx.category_group_name,
|
|
90
|
+
name: tx.category_name,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const total = transactions.length;
|
|
95
|
+
const distribution = [];
|
|
96
|
+
for (const { name, groupName, count } of counts.values()) {
|
|
97
|
+
distribution.push({
|
|
98
|
+
category_group_name: groupName,
|
|
99
|
+
category_name: name,
|
|
100
|
+
count,
|
|
101
|
+
percentage: Math.round((count / total) * 1000) / 10, // One decimal place
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
// Sort by count descending
|
|
105
|
+
distribution.sort((a, b) => b.count - a.count);
|
|
106
|
+
return distribution;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Create an MCP error response
|
|
110
|
+
*/
|
|
111
|
+
export function createErrorResponse(message) {
|
|
112
|
+
return {
|
|
113
|
+
content: [{ text: message, type: 'text' }],
|
|
114
|
+
isError: true,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Validate a selector has exactly one of name or id
|
|
119
|
+
*/
|
|
120
|
+
export function validateSelector(selector, entityType) {
|
|
121
|
+
if (selector === undefined || selector === null)
|
|
122
|
+
return;
|
|
123
|
+
const hasName = selector.name !== undefined && selector.name !== '';
|
|
124
|
+
const hasId = selector.id !== undefined && selector.id !== '';
|
|
125
|
+
if (hasName && hasId) {
|
|
126
|
+
throw new Error(`${entityType} selector must specify exactly one of: 'name' or 'id'.`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Check if a value looks like it might be a JMESPath query result
|
|
131
|
+
* (i.e., it's been transformed by a projection)
|
|
132
|
+
*/
|
|
133
|
+
export function isTransformed(value) {
|
|
134
|
+
if (!Array.isArray(value))
|
|
135
|
+
return true;
|
|
136
|
+
if (value.length === 0)
|
|
137
|
+
return false;
|
|
138
|
+
// If the first item doesn't have standard transaction fields, it's been transformed
|
|
139
|
+
const first = value[0];
|
|
140
|
+
if (typeof first !== 'object' || first === null)
|
|
141
|
+
return true;
|
|
142
|
+
return !('id' in first && 'date' in first && 'amount' in first);
|
|
143
|
+
}
|
|
144
|
+
// ============================================================================
|
|
145
|
+
// YNAB Error Handling
|
|
146
|
+
// ============================================================================
|
|
147
|
+
/**
|
|
148
|
+
* Type guard for YNAB SDK ResponseError (HTTP errors from API)
|
|
149
|
+
*/
|
|
150
|
+
function isYnabResponseError(error) {
|
|
151
|
+
return (error !== null &&
|
|
152
|
+
typeof error === 'object' &&
|
|
153
|
+
'response' in error &&
|
|
154
|
+
'name' in error &&
|
|
155
|
+
error.name === 'ResponseError');
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Type guard for YNAB SDK FetchError (network errors)
|
|
159
|
+
*/
|
|
160
|
+
function isYnabFetchError(error) {
|
|
161
|
+
return (error !== null &&
|
|
162
|
+
typeof error === 'object' &&
|
|
163
|
+
'cause' in error &&
|
|
164
|
+
'name' in error &&
|
|
165
|
+
error.name === 'FetchError');
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Create an enhanced MCP error response with context from YNAB errors
|
|
169
|
+
*
|
|
170
|
+
* Extracts HTTP status codes and error details from YNAB SDK errors
|
|
171
|
+
* to provide actionable error messages for the LLM.
|
|
172
|
+
*/
|
|
173
|
+
export async function createEnhancedErrorResponse(error, operation) {
|
|
174
|
+
let message;
|
|
175
|
+
if (isYnabResponseError(error)) {
|
|
176
|
+
const statusCode = error.response.status;
|
|
177
|
+
// Try to extract YNAB error detail from response body
|
|
178
|
+
let detail = null;
|
|
179
|
+
try {
|
|
180
|
+
const body = (await error.response.json());
|
|
181
|
+
if (body?.error?.detail !== undefined &&
|
|
182
|
+
body.error.detail !== null &&
|
|
183
|
+
body.error.detail !== '') {
|
|
184
|
+
detail = body.error.detail;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// Response body not available or not JSON
|
|
189
|
+
}
|
|
190
|
+
if (detail !== null) {
|
|
191
|
+
message = `${operation} failed: ${detail}`;
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
message = `${operation} failed: HTTP ${statusCode}`;
|
|
195
|
+
}
|
|
196
|
+
// Add specific guidance for common HTTP errors
|
|
197
|
+
if (statusCode === 401) {
|
|
198
|
+
message += ' (Check your YNAB API token)';
|
|
199
|
+
}
|
|
200
|
+
else if (statusCode === 404) {
|
|
201
|
+
message += ' (Resource not found - verify the ID exists)';
|
|
202
|
+
}
|
|
203
|
+
else if (statusCode === 429) {
|
|
204
|
+
message += ' (Rate limited - wait before retrying)';
|
|
205
|
+
}
|
|
206
|
+
else if (statusCode >= 500) {
|
|
207
|
+
message += ' (YNAB server error - try again later)';
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
else if (isYnabFetchError(error)) {
|
|
211
|
+
const causeMessage = error.cause?.message ?? error.message;
|
|
212
|
+
message = `${operation} failed: Network error - ${causeMessage}`;
|
|
213
|
+
}
|
|
214
|
+
else if (error instanceof Error) {
|
|
215
|
+
// Application-level errors (validation, resolution failures, etc.)
|
|
216
|
+
// These already have good messages from ynab-client.ts
|
|
217
|
+
message = error.message;
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
message = `${operation} failed: Unknown error`;
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
content: [{ text: message, type: 'text' }],
|
|
224
|
+
isError: true,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LocalBudget building and merging utilities.
|
|
3
|
+
*
|
|
4
|
+
* This module handles:
|
|
5
|
+
* - Building a LocalBudget from a full YNAB API response
|
|
6
|
+
* - Merging delta sync responses into an existing LocalBudget
|
|
7
|
+
* - Rebuilding O(1) lookup maps after modifications
|
|
8
|
+
*/
|
|
9
|
+
import type { LocalBudget } from './types.js';
|
|
10
|
+
import type { BudgetDetail, MonthDetail } from 'ynab';
|
|
11
|
+
/**
|
|
12
|
+
* Entity with an ID and optional deleted flag (for delta sync)
|
|
13
|
+
*/
|
|
14
|
+
interface EntityWithId {
|
|
15
|
+
deleted?: boolean;
|
|
16
|
+
id: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Rebuild all O(1) lookup maps from the arrays in a LocalBudget.
|
|
20
|
+
* Call this after any modification to the arrays.
|
|
21
|
+
*
|
|
22
|
+
* @param localBudget - The LocalBudget to rebuild maps for (mutated in place)
|
|
23
|
+
*/
|
|
24
|
+
export declare function rebuildLookupMaps(localBudget: LocalBudget): void;
|
|
25
|
+
/**
|
|
26
|
+
* Build a LocalBudget from a full YNAB API budget response.
|
|
27
|
+
*
|
|
28
|
+
* @param budgetId - The budget ID
|
|
29
|
+
* @param budget - The BudgetDetail from YNAB API
|
|
30
|
+
* @param serverKnowledge - The server_knowledge from the API response
|
|
31
|
+
* @returns A fully populated LocalBudget with lookup maps
|
|
32
|
+
*/
|
|
33
|
+
export declare function buildLocalBudget(budgetId: string, budget: BudgetDetail, serverKnowledge: number): LocalBudget;
|
|
34
|
+
/**
|
|
35
|
+
* Merge an array of entities from a delta response into an existing array.
|
|
36
|
+
* Handles additions, updates, and deletions (entities with deleted: true).
|
|
37
|
+
*
|
|
38
|
+
* @param existing - The current array of entities
|
|
39
|
+
* @param delta - The delta array from the API (may include deleted entities)
|
|
40
|
+
* @returns A new merged array with deletions removed
|
|
41
|
+
*/
|
|
42
|
+
export declare function mergeEntityArray<T extends EntityWithId>(existing: T[], delta: T[]): T[];
|
|
43
|
+
/**
|
|
44
|
+
* Merge an array of MonthDetail entities (keyed by 'month' instead of 'id').
|
|
45
|
+
* MonthDetail doesn't have an 'id' field - it uses 'month' as its unique key.
|
|
46
|
+
*
|
|
47
|
+
* IMPORTANT: MonthDetail contains a nested `categories` array that must be
|
|
48
|
+
* merged separately. Delta responses may only include CHANGED categories,
|
|
49
|
+
* so we must merge them with existing categories rather than replacing.
|
|
50
|
+
*
|
|
51
|
+
* @param existing - The current array of months
|
|
52
|
+
* @param delta - The delta array from the API
|
|
53
|
+
* @returns A new merged array
|
|
54
|
+
*/
|
|
55
|
+
export declare function mergeMonthArray(existing: MonthDetail[], delta: MonthDetail[]): MonthDetail[];
|
|
56
|
+
/**
|
|
57
|
+
* Merge a delta sync response into an existing LocalBudget.
|
|
58
|
+
* Returns a new LocalBudget with the merged data.
|
|
59
|
+
*
|
|
60
|
+
* @param existing - The current LocalBudget
|
|
61
|
+
* @param deltaBudget - The delta BudgetDetail from YNAB API
|
|
62
|
+
* @param newServerKnowledge - The new server_knowledge from the delta response
|
|
63
|
+
* @returns A new LocalBudget with merged data and change counts
|
|
64
|
+
*/
|
|
65
|
+
export declare function mergeDelta(existing: LocalBudget, deltaBudget: BudgetDetail, newServerKnowledge: number): {
|
|
66
|
+
changesReceived: {
|
|
67
|
+
accounts: number;
|
|
68
|
+
categories: number;
|
|
69
|
+
months: number;
|
|
70
|
+
payees: number;
|
|
71
|
+
scheduledTransactions: number;
|
|
72
|
+
transactions: number;
|
|
73
|
+
};
|
|
74
|
+
localBudget: LocalBudget;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Compare two LocalBudgets and report any discrepancies.
|
|
78
|
+
* Useful for sanity checking after a full re-fetch.
|
|
79
|
+
*
|
|
80
|
+
* @param local - The local budget (before full re-fetch)
|
|
81
|
+
* @param remote - The remote budget (from full re-fetch)
|
|
82
|
+
* @returns Array of discrepancy descriptions, empty if no drift
|
|
83
|
+
*/
|
|
84
|
+
export declare function detectDrift(local: LocalBudget, remote: LocalBudget): string[];
|
|
85
|
+
export {};
|
|
86
|
+
//# sourceMappingURL=local-budget.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"local-budget.d.ts","sourceRoot":"","sources":["../src/local-budget.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,YAAY,CAAC;AAC5C,OAAO,KAAK,EAEV,YAAY,EAGZ,WAAW,EAOZ,MAAM,MAAM,CAAC;AAEd;;GAEG;AACH,UAAU,YAAY;IACpB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,EAAE,EAAE,MAAM,CAAC;CACZ;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,WAAW,GAAG,IAAI,CAsEhE;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,YAAY,EACpB,eAAe,EAAE,MAAM,GACtB,WAAW,CAwCb;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,YAAY,EACrD,QAAQ,EAAE,CAAC,EAAE,EACb,KAAK,EAAE,CAAC,EAAE,GACT,CAAC,EAAE,CAmBL;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,WAAW,EAAE,EACvB,KAAK,EAAE,WAAW,EAAE,GACnB,WAAW,EAAE,CA+Bf;AAYD;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CACxB,QAAQ,EAAE,WAAW,EACrB,WAAW,EAAE,YAAY,EACzB,kBAAkB,EAAE,MAAM,GACzB;IACD,eAAe,EAAE;QACf,QAAQ,EAAE,MAAM,CAAC;QACjB,UAAU,EAAE,MAAM,CAAC;QACnB,MAAM,EAAE,MAAM,CAAC;QACf,MAAM,EAAE,MAAM,CAAC;QACf,qBAAqB,EAAE,MAAM,CAAC;QAC9B,YAAY,EAAE,MAAM,CAAC;KACtB,CAAC;IACF,WAAW,EAAE,WAAW,CAAC;CAC1B,CA2EA;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,GAAG,MAAM,EAAE,CA0B7E"}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LocalBudget building and merging utilities.
|
|
3
|
+
*
|
|
4
|
+
* This module handles:
|
|
5
|
+
* - Building a LocalBudget from a full YNAB API response
|
|
6
|
+
* - Merging delta sync responses into an existing LocalBudget
|
|
7
|
+
* - Rebuilding O(1) lookup maps after modifications
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Rebuild all O(1) lookup maps from the arrays in a LocalBudget.
|
|
11
|
+
* Call this after any modification to the arrays.
|
|
12
|
+
*
|
|
13
|
+
* @param localBudget - The LocalBudget to rebuild maps for (mutated in place)
|
|
14
|
+
*/
|
|
15
|
+
export function rebuildLookupMaps(localBudget) {
|
|
16
|
+
// Account maps
|
|
17
|
+
localBudget.accountById.clear();
|
|
18
|
+
localBudget.accountByName.clear();
|
|
19
|
+
for (const account of localBudget.accounts) {
|
|
20
|
+
localBudget.accountById.set(account.id, account);
|
|
21
|
+
localBudget.accountByName.set(account.name.toLowerCase(), account);
|
|
22
|
+
}
|
|
23
|
+
// Category maps
|
|
24
|
+
localBudget.categoryById.clear();
|
|
25
|
+
localBudget.categoryByName.clear();
|
|
26
|
+
for (const category of localBudget.categories) {
|
|
27
|
+
localBudget.categoryById.set(category.id, category);
|
|
28
|
+
localBudget.categoryByName.set(category.name.toLowerCase(), category);
|
|
29
|
+
}
|
|
30
|
+
// Category group name map
|
|
31
|
+
localBudget.categoryGroupNameById.clear();
|
|
32
|
+
for (const group of localBudget.categoryGroups) {
|
|
33
|
+
localBudget.categoryGroupNameById.set(group.id, group.name);
|
|
34
|
+
}
|
|
35
|
+
// Payee map
|
|
36
|
+
localBudget.payeeById.clear();
|
|
37
|
+
for (const payee of localBudget.payees) {
|
|
38
|
+
localBudget.payeeById.set(payee.id, payee);
|
|
39
|
+
}
|
|
40
|
+
// Subtransaction maps (for O(1) joins during enrichment)
|
|
41
|
+
// Guard against null/undefined transaction_id (defensive against malformed API responses)
|
|
42
|
+
localBudget.subtransactionsByTransactionId.clear();
|
|
43
|
+
for (const sub of localBudget.subtransactions) {
|
|
44
|
+
// Skip orphaned subtransactions with missing transaction_id
|
|
45
|
+
if (sub.transaction_id === null || sub.transaction_id === undefined) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const existing = localBudget.subtransactionsByTransactionId.get(sub.transaction_id);
|
|
49
|
+
if (existing !== undefined) {
|
|
50
|
+
existing.push(sub);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
localBudget.subtransactionsByTransactionId.set(sub.transaction_id, [sub]);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Guard against null/undefined scheduled_transaction_id (defensive against malformed API responses)
|
|
57
|
+
localBudget.scheduledSubtransactionsByScheduledTransactionId.clear();
|
|
58
|
+
for (const sub of localBudget.scheduledSubtransactions) {
|
|
59
|
+
// Skip orphaned scheduled subtransactions with missing scheduled_transaction_id
|
|
60
|
+
if (sub.scheduled_transaction_id === null ||
|
|
61
|
+
sub.scheduled_transaction_id === undefined) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const existing = localBudget.scheduledSubtransactionsByScheduledTransactionId.get(sub.scheduled_transaction_id);
|
|
65
|
+
if (existing !== undefined) {
|
|
66
|
+
existing.push(sub);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
localBudget.scheduledSubtransactionsByScheduledTransactionId.set(sub.scheduled_transaction_id, [sub]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Build a LocalBudget from a full YNAB API budget response.
|
|
75
|
+
*
|
|
76
|
+
* @param budgetId - The budget ID
|
|
77
|
+
* @param budget - The BudgetDetail from YNAB API
|
|
78
|
+
* @param serverKnowledge - The server_knowledge from the API response
|
|
79
|
+
* @returns A fully populated LocalBudget with lookup maps
|
|
80
|
+
*/
|
|
81
|
+
export function buildLocalBudget(budgetId, budget, serverKnowledge) {
|
|
82
|
+
const localBudget = {
|
|
83
|
+
// Lookup maps (will be populated by rebuildLookupMaps)
|
|
84
|
+
accountById: new Map(),
|
|
85
|
+
accountByName: new Map(),
|
|
86
|
+
// Budget data (arrays from the API response)
|
|
87
|
+
accounts: budget.accounts ?? [],
|
|
88
|
+
// Budget identity
|
|
89
|
+
budgetId,
|
|
90
|
+
budgetName: budget.name,
|
|
91
|
+
categories: budget.categories ?? [],
|
|
92
|
+
categoryById: new Map(),
|
|
93
|
+
categoryByName: new Map(),
|
|
94
|
+
categoryGroupNameById: new Map(),
|
|
95
|
+
categoryGroups: budget.category_groups ?? [],
|
|
96
|
+
// Budget settings
|
|
97
|
+
currencyFormat: budget.currency_format ?? null,
|
|
98
|
+
// Sync metadata
|
|
99
|
+
lastSyncedAt: new Date(),
|
|
100
|
+
months: budget.months ?? [],
|
|
101
|
+
needsSync: false,
|
|
102
|
+
payeeById: new Map(),
|
|
103
|
+
payeeLocations: budget.payee_locations ?? [],
|
|
104
|
+
payees: budget.payees ?? [],
|
|
105
|
+
scheduledSubtransactions: budget.scheduled_subtransactions ?? [],
|
|
106
|
+
scheduledSubtransactionsByScheduledTransactionId: new Map(),
|
|
107
|
+
scheduledTransactions: budget.scheduled_transactions ?? [],
|
|
108
|
+
serverKnowledge,
|
|
109
|
+
subtransactions: budget.subtransactions ?? [],
|
|
110
|
+
subtransactionsByTransactionId: new Map(),
|
|
111
|
+
transactions: budget.transactions ?? [],
|
|
112
|
+
};
|
|
113
|
+
// Build the lookup maps
|
|
114
|
+
rebuildLookupMaps(localBudget);
|
|
115
|
+
return localBudget;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Merge an array of entities from a delta response into an existing array.
|
|
119
|
+
* Handles additions, updates, and deletions (entities with deleted: true).
|
|
120
|
+
*
|
|
121
|
+
* @param existing - The current array of entities
|
|
122
|
+
* @param delta - The delta array from the API (may include deleted entities)
|
|
123
|
+
* @returns A new merged array with deletions removed
|
|
124
|
+
*/
|
|
125
|
+
export function mergeEntityArray(existing, delta) {
|
|
126
|
+
// Build a map from existing entities
|
|
127
|
+
const byId = new Map();
|
|
128
|
+
for (const entity of existing) {
|
|
129
|
+
byId.set(entity.id, entity);
|
|
130
|
+
}
|
|
131
|
+
// Apply delta changes
|
|
132
|
+
for (const entity of delta) {
|
|
133
|
+
if (entity.deleted === true) {
|
|
134
|
+
// Remove deleted entities
|
|
135
|
+
byId.delete(entity.id);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
// Add or update entity
|
|
139
|
+
byId.set(entity.id, entity);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return Array.from(byId.values());
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Merge an array of MonthDetail entities (keyed by 'month' instead of 'id').
|
|
146
|
+
* MonthDetail doesn't have an 'id' field - it uses 'month' as its unique key.
|
|
147
|
+
*
|
|
148
|
+
* IMPORTANT: MonthDetail contains a nested `categories` array that must be
|
|
149
|
+
* merged separately. Delta responses may only include CHANGED categories,
|
|
150
|
+
* so we must merge them with existing categories rather than replacing.
|
|
151
|
+
*
|
|
152
|
+
* @param existing - The current array of months
|
|
153
|
+
* @param delta - The delta array from the API
|
|
154
|
+
* @returns A new merged array
|
|
155
|
+
*/
|
|
156
|
+
export function mergeMonthArray(existing, delta) {
|
|
157
|
+
// Build a map from existing months using 'month' as key
|
|
158
|
+
const byMonth = new Map();
|
|
159
|
+
for (const month of existing) {
|
|
160
|
+
byMonth.set(month.month, month);
|
|
161
|
+
}
|
|
162
|
+
// Apply delta changes
|
|
163
|
+
for (const deltaMonth of delta) {
|
|
164
|
+
if (deltaMonth.deleted === true) {
|
|
165
|
+
byMonth.delete(deltaMonth.month);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
const existingMonth = byMonth.get(deltaMonth.month);
|
|
169
|
+
if (existingMonth !== undefined) {
|
|
170
|
+
// Month exists - merge the nested categories array
|
|
171
|
+
const mergedCategories = mergeEntityArray(existingMonth.categories, deltaMonth.categories);
|
|
172
|
+
byMonth.set(deltaMonth.month, {
|
|
173
|
+
...deltaMonth,
|
|
174
|
+
categories: mergedCategories,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
// New month - use as-is
|
|
179
|
+
byMonth.set(deltaMonth.month, deltaMonth);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return Array.from(byMonth.values());
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Count changes in a delta array.
|
|
187
|
+
*
|
|
188
|
+
* @param delta - The delta array from the API
|
|
189
|
+
* @returns The number of entities in the delta
|
|
190
|
+
*/
|
|
191
|
+
function countChanges(delta) {
|
|
192
|
+
return delta?.length ?? 0;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Merge a delta sync response into an existing LocalBudget.
|
|
196
|
+
* Returns a new LocalBudget with the merged data.
|
|
197
|
+
*
|
|
198
|
+
* @param existing - The current LocalBudget
|
|
199
|
+
* @param deltaBudget - The delta BudgetDetail from YNAB API
|
|
200
|
+
* @param newServerKnowledge - The new server_knowledge from the delta response
|
|
201
|
+
* @returns A new LocalBudget with merged data and change counts
|
|
202
|
+
*/
|
|
203
|
+
export function mergeDelta(existing, deltaBudget, newServerKnowledge) {
|
|
204
|
+
// Count changes before merging
|
|
205
|
+
const changesReceived = {
|
|
206
|
+
accounts: countChanges(deltaBudget.accounts),
|
|
207
|
+
categories: countChanges(deltaBudget.categories),
|
|
208
|
+
months: countChanges(deltaBudget.months),
|
|
209
|
+
payees: countChanges(deltaBudget.payees),
|
|
210
|
+
scheduledTransactions: countChanges(deltaBudget.scheduled_transactions),
|
|
211
|
+
transactions: countChanges(deltaBudget.transactions),
|
|
212
|
+
};
|
|
213
|
+
// Create new LocalBudget with merged arrays
|
|
214
|
+
const localBudget = {
|
|
215
|
+
// Lookup maps (will be rebuilt)
|
|
216
|
+
accountById: new Map(),
|
|
217
|
+
accountByName: new Map(),
|
|
218
|
+
// Merge all entity arrays
|
|
219
|
+
accounts: mergeEntityArray(existing.accounts, deltaBudget.accounts ?? []),
|
|
220
|
+
// Budget identity (unchanged)
|
|
221
|
+
budgetId: existing.budgetId,
|
|
222
|
+
budgetName: deltaBudget.name ?? existing.budgetName,
|
|
223
|
+
categories: mergeEntityArray(existing.categories, deltaBudget.categories ?? []),
|
|
224
|
+
categoryById: new Map(),
|
|
225
|
+
categoryByName: new Map(),
|
|
226
|
+
categoryGroupNameById: new Map(),
|
|
227
|
+
categoryGroups: mergeEntityArray(existing.categoryGroups, deltaBudget.category_groups ?? []),
|
|
228
|
+
// Budget settings (may be updated)
|
|
229
|
+
currencyFormat: deltaBudget.currency_format ?? existing.currencyFormat,
|
|
230
|
+
// Update sync metadata
|
|
231
|
+
lastSyncedAt: new Date(),
|
|
232
|
+
months: mergeMonthArray(existing.months, deltaBudget.months ?? []),
|
|
233
|
+
needsSync: false,
|
|
234
|
+
payeeById: new Map(),
|
|
235
|
+
payeeLocations: mergeEntityArray(existing.payeeLocations, deltaBudget.payee_locations ?? []),
|
|
236
|
+
payees: mergeEntityArray(existing.payees, deltaBudget.payees ?? []),
|
|
237
|
+
scheduledSubtransactions: mergeEntityArray(existing.scheduledSubtransactions, deltaBudget.scheduled_subtransactions ?? []),
|
|
238
|
+
scheduledSubtransactionsByScheduledTransactionId: new Map(),
|
|
239
|
+
scheduledTransactions: mergeEntityArray(existing.scheduledTransactions, deltaBudget.scheduled_transactions ?? []),
|
|
240
|
+
serverKnowledge: newServerKnowledge,
|
|
241
|
+
subtransactions: mergeEntityArray(existing.subtransactions, deltaBudget.subtransactions ?? []),
|
|
242
|
+
subtransactionsByTransactionId: new Map(),
|
|
243
|
+
transactions: mergeEntityArray(existing.transactions, deltaBudget.transactions ?? []),
|
|
244
|
+
};
|
|
245
|
+
// Rebuild lookup maps
|
|
246
|
+
rebuildLookupMaps(localBudget);
|
|
247
|
+
return { changesReceived, localBudget };
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Compare two LocalBudgets and report any discrepancies.
|
|
251
|
+
* Useful for sanity checking after a full re-fetch.
|
|
252
|
+
*
|
|
253
|
+
* @param local - The local budget (before full re-fetch)
|
|
254
|
+
* @param remote - The remote budget (from full re-fetch)
|
|
255
|
+
* @returns Array of discrepancy descriptions, empty if no drift
|
|
256
|
+
*/
|
|
257
|
+
export function detectDrift(local, remote) {
|
|
258
|
+
const discrepancies = [];
|
|
259
|
+
// Check array lengths
|
|
260
|
+
const checks = [
|
|
261
|
+
{ field: 'accounts', name: 'accounts' },
|
|
262
|
+
{ field: 'categories', name: 'categories' },
|
|
263
|
+
{ field: 'categoryGroups', name: 'categoryGroups' },
|
|
264
|
+
{ field: 'months', name: 'months' },
|
|
265
|
+
{ field: 'payees', name: 'payees' },
|
|
266
|
+
{ field: 'transactions', name: 'transactions' },
|
|
267
|
+
{ field: 'scheduledTransactions', name: 'scheduledTransactions' },
|
|
268
|
+
];
|
|
269
|
+
for (const { field, name } of checks) {
|
|
270
|
+
const localArray = local[field];
|
|
271
|
+
const remoteArray = remote[field];
|
|
272
|
+
if (localArray.length !== remoteArray.length) {
|
|
273
|
+
discrepancies.push(`${name}: local=${localArray.length}, remote=${remoteArray.length}, drift=${remoteArray.length - localArray.length}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return discrepancies;
|
|
277
|
+
}
|
package/dist/logger.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pino-based logger for the MCP server.
|
|
3
|
+
*
|
|
4
|
+
* Writes JSON logs to ~/.config/ynab-mcp-deluxe/logs/ with date-based rotation.
|
|
5
|
+
* Use `bun run logs` to tail the log file, optionally piping through pino-pretty.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Logger that implements FastMCP's Logger interface.
|
|
9
|
+
* Wraps pino to write structured JSON logs to a rotating file.
|
|
10
|
+
*/
|
|
11
|
+
export declare const logger: {
|
|
12
|
+
debug(...args: unknown[]): void;
|
|
13
|
+
error(...args: unknown[]): void;
|
|
14
|
+
info(...args: unknown[]): void;
|
|
15
|
+
log(...args: unknown[]): void;
|
|
16
|
+
warn(...args: unknown[]): void;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Context logger interface (matches FastMCP's context.log)
|
|
20
|
+
*/
|
|
21
|
+
interface ContextLog {
|
|
22
|
+
debug: (message: string, data?: unknown) => void;
|
|
23
|
+
error: (message: string, data?: unknown) => void;
|
|
24
|
+
info: (message: string, data?: unknown) => void;
|
|
25
|
+
warn: (message: string, data?: unknown) => void;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* File-only logger for use when there's no context logger available.
|
|
29
|
+
* Writes directly to the pino log file.
|
|
30
|
+
*/
|
|
31
|
+
export declare const fileLogger: ContextLog;
|
|
32
|
+
/**
|
|
33
|
+
* Create a combined logger that writes to both the file logger and a context logger.
|
|
34
|
+
* This ensures logs are visible both in `bun run logs` and in MCP clients that surface context logs.
|
|
35
|
+
*
|
|
36
|
+
* @param contextLog - The FastMCP context logger (from tool execute context)
|
|
37
|
+
* @returns A logger that writes to both destinations
|
|
38
|
+
*/
|
|
39
|
+
export declare function createCombinedLogger(contextLog: ContextLog): ContextLog;
|
|
40
|
+
/**
|
|
41
|
+
* Get the glob pattern for log files.
|
|
42
|
+
* pino-roll names files as server.N.log where N is the rotation counter.
|
|
43
|
+
*/
|
|
44
|
+
export declare function getLogFilePattern(): string;
|
|
45
|
+
/**
|
|
46
|
+
* Get the log directory path.
|
|
47
|
+
*/
|
|
48
|
+
export declare function getLogDir(): string;
|
|
49
|
+
export {};
|
|
50
|
+
//# sourceMappingURL=logger.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAoDH;;;GAGG;AACH,eAAO,MAAM,MAAM;mBACF,OAAO,EAAE,GAAG,IAAI;mBAGhB,OAAO,EAAE,GAAG,IAAI;kBAGjB,OAAO,EAAE,GAAG,IAAI;iBAGjB,OAAO,EAAE,GAAG,IAAI;kBAIf,OAAO,EAAE,GAAG,IAAI;CAG/B,CAAC;AAEF;;GAEG;AACH,UAAU,UAAU;IAClB,KAAK,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IACjD,KAAK,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IACjD,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IAChD,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;CACjD;AAED;;;GAGG;AACH,eAAO,MAAM,UAAU,EAAE,UAaxB,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,UAAU,GAAG,UAAU,CAwBvE;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,CAE1C;AAED;;GAEG;AACH,wBAAgB,SAAS,IAAI,MAAM,CAElC"}
|