financeops-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/ISSUE_TEMPLATE/bug_report.md +29 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +24 -0
- package/.github/assets/banner.svg +104 -0
- package/.github/pull_request_template.md +25 -0
- package/CODE_OF_CONDUCT.md +40 -0
- package/CONTRIBUTING.md +71 -0
- package/LICENSE +21 -0
- package/README.md +390 -0
- package/SECURITY.md +30 -0
- package/dist/adapters/stripe.d.ts +15 -0
- package/dist/adapters/stripe.js +175 -0
- package/dist/adapters/types.d.ts +30 -0
- package/dist/adapters/types.js +2 -0
- package/dist/adapters/xero.d.ts +19 -0
- package/dist/adapters/xero.js +261 -0
- package/dist/analysis/anomaly.d.ts +12 -0
- package/dist/analysis/anomaly.js +103 -0
- package/dist/analysis/cashflow.d.ts +10 -0
- package/dist/analysis/cashflow.js +134 -0
- package/dist/analysis/pnl.d.ts +8 -0
- package/dist/analysis/pnl.js +56 -0
- package/dist/analysis/reconciliation.d.ts +14 -0
- package/dist/analysis/reconciliation.js +98 -0
- package/dist/analysis/tax.d.ts +11 -0
- package/dist/analysis/tax.js +81 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +565 -0
- package/dist/lib/audit.d.ts +17 -0
- package/dist/lib/audit.js +70 -0
- package/dist/lib/providers.d.ts +6 -0
- package/dist/lib/providers.js +25 -0
- package/dist/premium/gate.d.ts +12 -0
- package/dist/premium/gate.js +22 -0
- package/dist/types.d.ts +138 -0
- package/dist/types.js +7 -0
- package/mcpize.yaml +10 -0
- package/package.json +35 -0
- package/src/adapters/stripe.ts +190 -0
- package/src/adapters/types.ts +34 -0
- package/src/adapters/xero.ts +317 -0
- package/src/analysis/anomaly.ts +119 -0
- package/src/analysis/cashflow.ts +158 -0
- package/src/analysis/pnl.ts +80 -0
- package/src/analysis/reconciliation.ts +117 -0
- package/src/analysis/tax.ts +98 -0
- package/src/index.ts +649 -0
- package/src/lib/audit.ts +92 -0
- package/src/lib/providers.ts +29 -0
- package/src/premium/gate.ts +24 -0
- package/src/types.ts +153 -0
- package/tests/adapters/stripe.test.ts +150 -0
- package/tests/adapters/xero.test.ts +188 -0
- package/tests/analysis/anomaly.test.ts +137 -0
- package/tests/analysis/cashflow.test.ts +112 -0
- package/tests/analysis/pnl.test.ts +95 -0
- package/tests/analysis/reconciliation.test.ts +121 -0
- package/tests/analysis/tax.test.ts +163 -0
- package/tests/helpers/mock-data.ts +135 -0
- package/tests/lib/audit.test.ts +89 -0
- package/tests/lib/providers.test.ts +129 -0
- package/tests/premium/cash_flow_forecast.test.ts +157 -0
- package/tests/premium/detect_anomalies.test.ts +189 -0
- package/tests/premium/gate.test.ts +59 -0
- package/tests/premium/generate_pnl.test.ts +155 -0
- package/tests/premium/multi_currency_report.test.ts +141 -0
- package/tests/premium/reconcile.test.ts +174 -0
- package/tests/premium/tax_summary.test.ts +166 -0
- package/tests/tools/expense_tracker.test.ts +181 -0
- package/tests/tools/financial_health.test.ts +196 -0
- package/tests/tools/get_balances.test.ts +160 -0
- package/tests/tools/list_invoices.test.ts +191 -0
- package/tests/tools/list_transactions.test.ts +210 -0
- package/tests/tools/revenue_summary.test.ts +188 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +9 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { logAudit, cleanup } from './lib/audit.js';
|
|
7
|
+
import { getProvider, getProviders } from './lib/providers.js';
|
|
8
|
+
import { generatePnL } from './analysis/pnl.js';
|
|
9
|
+
import { forecastCashFlow } from './analysis/cashflow.js';
|
|
10
|
+
import { reconcile } from './analysis/reconciliation.js';
|
|
11
|
+
import { detectAnomalies } from './analysis/anomaly.js';
|
|
12
|
+
import { calculateTax } from './analysis/tax.js';
|
|
13
|
+
import { requirePro, isPro } from './premium/gate.js';
|
|
14
|
+
// ─── Tool input schemas ───────────────────────────────────────────────────────
|
|
15
|
+
const ListTransactionsSchema = z.object({
|
|
16
|
+
provider: z.enum(['stripe', 'xero']),
|
|
17
|
+
date_from: z.string().optional(),
|
|
18
|
+
date_to: z.string().optional(),
|
|
19
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
20
|
+
cursor: z.string().optional(),
|
|
21
|
+
status: z.string().optional(),
|
|
22
|
+
});
|
|
23
|
+
const GetBalancesSchema = z.object({
|
|
24
|
+
provider: z.enum(['stripe', 'xero']),
|
|
25
|
+
});
|
|
26
|
+
const ListInvoicesSchema = z.object({
|
|
27
|
+
provider: z.enum(['stripe', 'xero']),
|
|
28
|
+
status: z.enum(['paid', 'pending', 'overdue']).optional(),
|
|
29
|
+
date_from: z.string().optional(),
|
|
30
|
+
date_to: z.string().optional(),
|
|
31
|
+
cursor: z.string().optional(),
|
|
32
|
+
});
|
|
33
|
+
const RevenueSummarySchema = z.object({
|
|
34
|
+
provider: z.enum(['stripe', 'xero']),
|
|
35
|
+
period: z.enum(['day', 'week', 'month', 'quarter', 'year']),
|
|
36
|
+
});
|
|
37
|
+
const ExpenseTrackerSchema = z.object({
|
|
38
|
+
provider: z.enum(['stripe', 'xero']),
|
|
39
|
+
period: z.string().optional(),
|
|
40
|
+
categories: z.array(z.string()).optional(),
|
|
41
|
+
});
|
|
42
|
+
const FinancialHealthSchema = z.object({
|
|
43
|
+
providers: z.array(z.enum(['stripe', 'xero'])).min(1),
|
|
44
|
+
});
|
|
45
|
+
const GeneratePnLSchema = z.object({
|
|
46
|
+
providers: z.array(z.enum(['stripe', 'xero'])).min(1),
|
|
47
|
+
period: z.enum(['month', 'quarter', 'year']),
|
|
48
|
+
date_from: z.string().optional(),
|
|
49
|
+
date_to: z.string().optional(),
|
|
50
|
+
});
|
|
51
|
+
const CashFlowForecastSchema = z.object({
|
|
52
|
+
providers: z.array(z.enum(['stripe', 'xero'])).min(1),
|
|
53
|
+
months_ahead: z.union([z.literal(3), z.literal(6), z.literal(12)]),
|
|
54
|
+
});
|
|
55
|
+
const ReconcileSchema = z.object({
|
|
56
|
+
date_from: z.string().optional(),
|
|
57
|
+
date_to: z.string().optional(),
|
|
58
|
+
});
|
|
59
|
+
const DetectAnomaliesSchema = z.object({
|
|
60
|
+
providers: z.array(z.enum(['stripe', 'xero'])).min(1),
|
|
61
|
+
period: z.string().optional(),
|
|
62
|
+
sensitivity: z.enum(['low', 'medium', 'high']).optional(),
|
|
63
|
+
});
|
|
64
|
+
const TaxSummarySchema = z.object({
|
|
65
|
+
provider: z.enum(['stripe', 'xero']),
|
|
66
|
+
tax_period: z.enum(['quarter', 'year']),
|
|
67
|
+
jurisdiction: z.string().optional(),
|
|
68
|
+
});
|
|
69
|
+
const MultiCurrencyReportSchema = z.object({
|
|
70
|
+
providers: z.array(z.enum(['stripe', 'xero'])).min(1),
|
|
71
|
+
base_currency: z.enum(['EUR', 'USD', 'GBP']),
|
|
72
|
+
date: z.string().optional(),
|
|
73
|
+
});
|
|
74
|
+
// ─── Server ──────────────────────────────────────────────────────────────────
|
|
75
|
+
const server = new Server({ name: 'financeops-mcp', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
76
|
+
// ─── List tools ──────────────────────────────────────────────────────────────
|
|
77
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
78
|
+
const proTools = isPro()
|
|
79
|
+
? [
|
|
80
|
+
{
|
|
81
|
+
name: 'generate_pnl',
|
|
82
|
+
description: 'Generate a Profit & Loss statement (PRO)',
|
|
83
|
+
inputSchema: {
|
|
84
|
+
type: 'object',
|
|
85
|
+
properties: {
|
|
86
|
+
providers: { type: 'array', items: { type: 'string', enum: ['stripe', 'xero'] } },
|
|
87
|
+
period: { type: 'string', enum: ['month', 'quarter', 'year'] },
|
|
88
|
+
date_from: { type: 'string' },
|
|
89
|
+
date_to: { type: 'string' },
|
|
90
|
+
},
|
|
91
|
+
required: ['providers', 'period'],
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: 'cash_flow_forecast',
|
|
96
|
+
description: 'Predict future cash flow based on historical patterns (PRO)',
|
|
97
|
+
inputSchema: {
|
|
98
|
+
type: 'object',
|
|
99
|
+
properties: {
|
|
100
|
+
providers: { type: 'array', items: { type: 'string', enum: ['stripe', 'xero'] } },
|
|
101
|
+
months_ahead: { type: 'number', enum: [3, 6, 12] },
|
|
102
|
+
},
|
|
103
|
+
required: ['providers', 'months_ahead'],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: 'reconcile',
|
|
108
|
+
description: 'Cross-reference Stripe payments with Xero records (PRO)',
|
|
109
|
+
inputSchema: {
|
|
110
|
+
type: 'object',
|
|
111
|
+
properties: {
|
|
112
|
+
date_from: { type: 'string' },
|
|
113
|
+
date_to: { type: 'string' },
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'detect_anomalies',
|
|
119
|
+
description: 'Find unusual financial activity (PRO)',
|
|
120
|
+
inputSchema: {
|
|
121
|
+
type: 'object',
|
|
122
|
+
properties: {
|
|
123
|
+
providers: { type: 'array', items: { type: 'string', enum: ['stripe', 'xero'] } },
|
|
124
|
+
period: { type: 'string' },
|
|
125
|
+
sensitivity: { type: 'string', enum: ['low', 'medium', 'high'] },
|
|
126
|
+
},
|
|
127
|
+
required: ['providers'],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: 'tax_summary',
|
|
132
|
+
description: 'VAT/sales tax calculation and summary (PRO)',
|
|
133
|
+
inputSchema: {
|
|
134
|
+
type: 'object',
|
|
135
|
+
properties: {
|
|
136
|
+
provider: { type: 'string', enum: ['stripe', 'xero'] },
|
|
137
|
+
tax_period: { type: 'string', enum: ['quarter', 'year'] },
|
|
138
|
+
jurisdiction: { type: 'string' },
|
|
139
|
+
},
|
|
140
|
+
required: ['provider', 'tax_period'],
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: 'multi_currency_report',
|
|
145
|
+
description: 'Consolidated reporting across currencies (PRO)',
|
|
146
|
+
inputSchema: {
|
|
147
|
+
type: 'object',
|
|
148
|
+
properties: {
|
|
149
|
+
providers: { type: 'array', items: { type: 'string', enum: ['stripe', 'xero'] } },
|
|
150
|
+
base_currency: { type: 'string', enum: ['EUR', 'USD', 'GBP'] },
|
|
151
|
+
date: { type: 'string' },
|
|
152
|
+
},
|
|
153
|
+
required: ['providers', 'base_currency'],
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
]
|
|
157
|
+
: [];
|
|
158
|
+
return {
|
|
159
|
+
tools: [
|
|
160
|
+
{
|
|
161
|
+
name: 'list_transactions',
|
|
162
|
+
description: 'Fetch transactions from Stripe or Xero',
|
|
163
|
+
inputSchema: {
|
|
164
|
+
type: 'object',
|
|
165
|
+
properties: {
|
|
166
|
+
provider: { type: 'string', enum: ['stripe', 'xero'] },
|
|
167
|
+
date_from: { type: 'string' },
|
|
168
|
+
date_to: { type: 'string' },
|
|
169
|
+
limit: { type: 'number' },
|
|
170
|
+
cursor: { type: 'string' },
|
|
171
|
+
status: { type: 'string' },
|
|
172
|
+
},
|
|
173
|
+
required: ['provider'],
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: 'get_balances',
|
|
178
|
+
description: 'Get current account balances',
|
|
179
|
+
inputSchema: {
|
|
180
|
+
type: 'object',
|
|
181
|
+
properties: {
|
|
182
|
+
provider: { type: 'string', enum: ['stripe', 'xero'] },
|
|
183
|
+
},
|
|
184
|
+
required: ['provider'],
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: 'list_invoices',
|
|
189
|
+
description: 'Fetch invoices with status',
|
|
190
|
+
inputSchema: {
|
|
191
|
+
type: 'object',
|
|
192
|
+
properties: {
|
|
193
|
+
provider: { type: 'string', enum: ['stripe', 'xero'] },
|
|
194
|
+
status: { type: 'string', enum: ['paid', 'pending', 'overdue'] },
|
|
195
|
+
date_from: { type: 'string' },
|
|
196
|
+
date_to: { type: 'string' },
|
|
197
|
+
cursor: { type: 'string' },
|
|
198
|
+
},
|
|
199
|
+
required: ['provider'],
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
name: 'revenue_summary',
|
|
204
|
+
description: 'Calculate revenue metrics',
|
|
205
|
+
inputSchema: {
|
|
206
|
+
type: 'object',
|
|
207
|
+
properties: {
|
|
208
|
+
provider: { type: 'string', enum: ['stripe', 'xero'] },
|
|
209
|
+
period: { type: 'string', enum: ['day', 'week', 'month', 'quarter', 'year'] },
|
|
210
|
+
},
|
|
211
|
+
required: ['provider', 'period'],
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
name: 'expense_tracker',
|
|
216
|
+
description: 'Categorize and summarize expenses',
|
|
217
|
+
inputSchema: {
|
|
218
|
+
type: 'object',
|
|
219
|
+
properties: {
|
|
220
|
+
provider: { type: 'string', enum: ['stripe', 'xero'] },
|
|
221
|
+
period: { type: 'string' },
|
|
222
|
+
categories: { type: 'array', items: { type: 'string' } },
|
|
223
|
+
},
|
|
224
|
+
required: ['provider'],
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: 'financial_health',
|
|
229
|
+
description: 'Quick financial health check: burn rate, runway, MRR, churn',
|
|
230
|
+
inputSchema: {
|
|
231
|
+
type: 'object',
|
|
232
|
+
properties: {
|
|
233
|
+
providers: { type: 'array', items: { type: 'string', enum: ['stripe', 'xero'] } },
|
|
234
|
+
},
|
|
235
|
+
required: ['providers'],
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
...proTools,
|
|
239
|
+
],
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
// ─── Call tool ───────────────────────────────────────────────────────────────
|
|
243
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
244
|
+
const { name, arguments: args } = request.params;
|
|
245
|
+
try {
|
|
246
|
+
switch (name) {
|
|
247
|
+
case 'list_transactions': {
|
|
248
|
+
const input = ListTransactionsSchema.parse(args);
|
|
249
|
+
const provider = getProvider(input.provider);
|
|
250
|
+
const result = await provider.getTransactions({
|
|
251
|
+
date_from: input.date_from,
|
|
252
|
+
date_to: input.date_to,
|
|
253
|
+
limit: input.limit,
|
|
254
|
+
cursor: input.cursor,
|
|
255
|
+
status: input.status,
|
|
256
|
+
});
|
|
257
|
+
logAudit({ tool: name, provider: input.provider, input_summary: JSON.stringify({ limit: input.limit }), success: true });
|
|
258
|
+
return {
|
|
259
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
case 'get_balances': {
|
|
263
|
+
const input = GetBalancesSchema.parse(args);
|
|
264
|
+
const provider = getProvider(input.provider);
|
|
265
|
+
const balances = await provider.getBalances();
|
|
266
|
+
logAudit({ tool: name, provider: input.provider, success: true });
|
|
267
|
+
return {
|
|
268
|
+
content: [{ type: 'text', text: JSON.stringify(balances, null, 2) }],
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
case 'list_invoices': {
|
|
272
|
+
const input = ListInvoicesSchema.parse(args);
|
|
273
|
+
const provider = getProvider(input.provider);
|
|
274
|
+
const result = await provider.getInvoices({
|
|
275
|
+
status: input.status,
|
|
276
|
+
date_from: input.date_from,
|
|
277
|
+
date_to: input.date_to,
|
|
278
|
+
cursor: input.cursor,
|
|
279
|
+
});
|
|
280
|
+
logAudit({ tool: name, provider: input.provider, success: true });
|
|
281
|
+
return {
|
|
282
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
case 'revenue_summary': {
|
|
286
|
+
const input = RevenueSummarySchema.parse(args);
|
|
287
|
+
const provider = getProvider(input.provider);
|
|
288
|
+
const periodDates = getPeriodDates(input.period);
|
|
289
|
+
const txResult = await provider.getTransactions(periodDates);
|
|
290
|
+
const incomeTransactions = txResult.data.filter((t) => t.type === 'income');
|
|
291
|
+
const totalRevenueCents = incomeTransactions.reduce((sum, t) => sum + t.amount, 0);
|
|
292
|
+
const byCustomer = {};
|
|
293
|
+
for (const t of incomeTransactions) {
|
|
294
|
+
const key = t.description;
|
|
295
|
+
byCustomer[key] = (byCustomer[key] ?? 0) + t.amount;
|
|
296
|
+
}
|
|
297
|
+
const topCustomers = Object.entries(byCustomer)
|
|
298
|
+
.sort(([, a], [, b]) => b - a)
|
|
299
|
+
.slice(0, 5)
|
|
300
|
+
.map(([name, amountCents]) => ({ name, amount: amountCents / 100 }));
|
|
301
|
+
const summary = {
|
|
302
|
+
period: input.period,
|
|
303
|
+
provider: input.provider,
|
|
304
|
+
total_revenue: totalRevenueCents / 100,
|
|
305
|
+
transaction_count: incomeTransactions.length,
|
|
306
|
+
average_transaction: incomeTransactions.length > 0
|
|
307
|
+
? totalRevenueCents / incomeTransactions.length / 100
|
|
308
|
+
: 0,
|
|
309
|
+
top_customers: topCustomers,
|
|
310
|
+
};
|
|
311
|
+
logAudit({ tool: name, provider: input.provider, success: true });
|
|
312
|
+
return {
|
|
313
|
+
content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }],
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
case 'expense_tracker': {
|
|
317
|
+
const input = ExpenseTrackerSchema.parse(args);
|
|
318
|
+
const provider = getProvider(input.provider);
|
|
319
|
+
const expResult = await provider.getExpenses({
|
|
320
|
+
categories: input.categories,
|
|
321
|
+
});
|
|
322
|
+
const byCategory = {};
|
|
323
|
+
for (const e of expResult.data) {
|
|
324
|
+
const cat = e.category ?? 'Uncategorized';
|
|
325
|
+
byCategory[cat] = (byCategory[cat] ?? 0) + e.amount;
|
|
326
|
+
}
|
|
327
|
+
const total = expResult.data.reduce((sum, e) => sum + e.amount, 0);
|
|
328
|
+
const result = {
|
|
329
|
+
provider: input.provider,
|
|
330
|
+
total_expenses: total / 100,
|
|
331
|
+
by_category: Object.fromEntries(Object.entries(byCategory).map(([k, v]) => [k, v / 100])),
|
|
332
|
+
count: expResult.data.length,
|
|
333
|
+
};
|
|
334
|
+
logAudit({ tool: name, provider: input.provider, success: true });
|
|
335
|
+
return {
|
|
336
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
case 'financial_health': {
|
|
340
|
+
const input = FinancialHealthSchema.parse(args);
|
|
341
|
+
const providers = getProviders(input.providers);
|
|
342
|
+
const health = {
|
|
343
|
+
providers: input.providers,
|
|
344
|
+
notes: [],
|
|
345
|
+
};
|
|
346
|
+
for (let i = 0; i < providers.length; i++) {
|
|
347
|
+
const provider = providers[i];
|
|
348
|
+
const providerName = input.providers[i];
|
|
349
|
+
const [balances, txResult] = await Promise.all([
|
|
350
|
+
provider.getBalances(),
|
|
351
|
+
provider.getTransactions(getPeriodDates('month')),
|
|
352
|
+
]);
|
|
353
|
+
const monthlyIncome = txResult.data
|
|
354
|
+
.filter((t) => t.type === 'income')
|
|
355
|
+
.reduce((sum, t) => sum + t.amount, 0);
|
|
356
|
+
const monthlyExpenses = txResult.data
|
|
357
|
+
.filter((t) => t.type === 'expense')
|
|
358
|
+
.reduce((sum, t) => sum + t.amount, 0);
|
|
359
|
+
const totalAvailable = balances.reduce((sum, b) => sum + b.available, 0);
|
|
360
|
+
const burnRate = monthlyExpenses / 100;
|
|
361
|
+
const runway = burnRate > 0 ? (totalAvailable / 100) / burnRate : null;
|
|
362
|
+
health[providerName] = {
|
|
363
|
+
total_balance: totalAvailable / 100,
|
|
364
|
+
monthly_income: monthlyIncome / 100,
|
|
365
|
+
monthly_expenses: monthlyExpenses / 100,
|
|
366
|
+
burn_rate: burnRate,
|
|
367
|
+
runway_months: runway !== null ? Math.round(runway * 10) / 10 : 'N/A',
|
|
368
|
+
};
|
|
369
|
+
// Important Fix I4: note when subscription data is unavailable
|
|
370
|
+
if (!provider.getSubscriptions) {
|
|
371
|
+
health.notes.push(`Subscription data (MRR, churn) only available from Stripe.`);
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
const subscriptions = await provider.getSubscriptions();
|
|
375
|
+
const activeSubs = subscriptions.filter((s) => s.status === 'active');
|
|
376
|
+
const mrr = activeSubs.reduce((sum, s) => {
|
|
377
|
+
const monthly = s.interval === 'year' ? s.amount / 12 : s.amount;
|
|
378
|
+
return sum + monthly;
|
|
379
|
+
}, 0);
|
|
380
|
+
const totalSubs = subscriptions.length;
|
|
381
|
+
const canceledSubs = subscriptions.filter((s) => s.status === 'canceled').length;
|
|
382
|
+
const churnRate = totalSubs > 0 ? (canceledSubs / totalSubs) * 100 : 0;
|
|
383
|
+
health[providerName].mrr = mrr / 100;
|
|
384
|
+
health[providerName].churn_rate_pct =
|
|
385
|
+
Math.round(churnRate * 10) / 10;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
logAudit({ tool: name, success: true });
|
|
389
|
+
return {
|
|
390
|
+
content: [{ type: 'text', text: JSON.stringify(health, null, 2) }],
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
// ─── Pro tools ────────────────────────────────────────────────────────
|
|
394
|
+
case 'generate_pnl': {
|
|
395
|
+
requirePro(name);
|
|
396
|
+
const input = GeneratePnLSchema.parse(args);
|
|
397
|
+
const periodDates = input.date_from && input.date_to
|
|
398
|
+
? { date_from: input.date_from, date_to: input.date_to }
|
|
399
|
+
: getPeriodDates(input.period);
|
|
400
|
+
const allTransactions = [];
|
|
401
|
+
const allExpenses = [];
|
|
402
|
+
for (const providerName of input.providers) {
|
|
403
|
+
const provider = getProvider(providerName);
|
|
404
|
+
const [txResult, expResult] = await Promise.all([
|
|
405
|
+
provider.getTransactions(periodDates),
|
|
406
|
+
provider.getExpenses({}),
|
|
407
|
+
]);
|
|
408
|
+
allTransactions.push(...txResult.data);
|
|
409
|
+
allExpenses.push(...expResult.data);
|
|
410
|
+
}
|
|
411
|
+
const pnl = generatePnL(allTransactions, allExpenses, periodDates.date_from ?? '', periodDates.date_to ?? '');
|
|
412
|
+
logAudit({ tool: name, success: true });
|
|
413
|
+
return {
|
|
414
|
+
content: [{ type: 'text', text: JSON.stringify(pnl, null, 2) }],
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
case 'cash_flow_forecast': {
|
|
418
|
+
requirePro(name);
|
|
419
|
+
const input = CashFlowForecastSchema.parse(args);
|
|
420
|
+
const allTransactions = [];
|
|
421
|
+
for (const providerName of input.providers) {
|
|
422
|
+
const provider = getProvider(providerName);
|
|
423
|
+
const txResult = await provider.getTransactions({});
|
|
424
|
+
allTransactions.push(...txResult.data);
|
|
425
|
+
}
|
|
426
|
+
const forecast = forecastCashFlow(allTransactions, input.months_ahead);
|
|
427
|
+
logAudit({ tool: name, success: true });
|
|
428
|
+
return {
|
|
429
|
+
content: [{ type: 'text', text: JSON.stringify(forecast, null, 2) }],
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
case 'reconcile': {
|
|
433
|
+
requirePro(name);
|
|
434
|
+
const input = ReconcileSchema.parse(args);
|
|
435
|
+
const stripeProvider = getProvider('stripe');
|
|
436
|
+
const xeroProvider = getProvider('xero');
|
|
437
|
+
const [stripeResult, xeroResult] = await Promise.all([
|
|
438
|
+
stripeProvider.getTransactions({
|
|
439
|
+
date_from: input.date_from,
|
|
440
|
+
date_to: input.date_to,
|
|
441
|
+
}),
|
|
442
|
+
xeroProvider.getTransactions({
|
|
443
|
+
date_from: input.date_from,
|
|
444
|
+
date_to: input.date_to,
|
|
445
|
+
}),
|
|
446
|
+
]);
|
|
447
|
+
const result = reconcile(stripeResult.data, xeroResult.data);
|
|
448
|
+
logAudit({ tool: name, success: true });
|
|
449
|
+
return {
|
|
450
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
case 'detect_anomalies': {
|
|
454
|
+
requirePro(name);
|
|
455
|
+
const input = DetectAnomaliesSchema.parse(args);
|
|
456
|
+
const allTransactions = [];
|
|
457
|
+
for (const providerName of input.providers) {
|
|
458
|
+
const provider = getProvider(providerName);
|
|
459
|
+
const txResult = await provider.getTransactions({});
|
|
460
|
+
allTransactions.push(...txResult.data);
|
|
461
|
+
}
|
|
462
|
+
const anomalies = detectAnomalies(allTransactions, input.sensitivity ?? 'medium');
|
|
463
|
+
logAudit({ tool: name, success: true });
|
|
464
|
+
return {
|
|
465
|
+
content: [{ type: 'text', text: JSON.stringify(anomalies, null, 2) }],
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
case 'tax_summary': {
|
|
469
|
+
requirePro(name);
|
|
470
|
+
const input = TaxSummarySchema.parse(args);
|
|
471
|
+
const provider = getProvider(input.provider);
|
|
472
|
+
const txResult = await provider.getTransactions({});
|
|
473
|
+
const summary = calculateTax(txResult.data, input.tax_period, input.jurisdiction);
|
|
474
|
+
logAudit({ tool: name, provider: input.provider, success: true });
|
|
475
|
+
return {
|
|
476
|
+
content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }],
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
case 'multi_currency_report': {
|
|
480
|
+
requirePro(name);
|
|
481
|
+
const input = MultiCurrencyReportSchema.parse(args);
|
|
482
|
+
// Placeholder — full FX logic handled separately
|
|
483
|
+
logAudit({ tool: name, success: true });
|
|
484
|
+
return {
|
|
485
|
+
content: [
|
|
486
|
+
{
|
|
487
|
+
type: 'text',
|
|
488
|
+
text: JSON.stringify({
|
|
489
|
+
message: 'multi_currency_report requires EXCHANGERATE_API_KEY. See docs.',
|
|
490
|
+
base_currency: input.base_currency,
|
|
491
|
+
providers: input.providers,
|
|
492
|
+
}),
|
|
493
|
+
},
|
|
494
|
+
],
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
default:
|
|
498
|
+
return {
|
|
499
|
+
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
500
|
+
isError: true,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
catch (err) {
|
|
505
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
506
|
+
logAudit({ tool: name, success: false, error: message });
|
|
507
|
+
return {
|
|
508
|
+
content: [{ type: 'text', text: `Error: ${message}` }],
|
|
509
|
+
isError: true,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
514
|
+
function getPeriodDates(period) {
|
|
515
|
+
const now = new Date();
|
|
516
|
+
const to = now.toISOString().substring(0, 10);
|
|
517
|
+
let from;
|
|
518
|
+
switch (period) {
|
|
519
|
+
case 'day':
|
|
520
|
+
from = new Date(now);
|
|
521
|
+
from.setDate(from.getDate() - 1);
|
|
522
|
+
break;
|
|
523
|
+
case 'week':
|
|
524
|
+
from = new Date(now);
|
|
525
|
+
from.setDate(from.getDate() - 7);
|
|
526
|
+
break;
|
|
527
|
+
case 'month':
|
|
528
|
+
from = new Date(now);
|
|
529
|
+
from.setMonth(from.getMonth() - 1);
|
|
530
|
+
break;
|
|
531
|
+
case 'quarter':
|
|
532
|
+
from = new Date(now);
|
|
533
|
+
from.setMonth(from.getMonth() - 3);
|
|
534
|
+
break;
|
|
535
|
+
case 'year':
|
|
536
|
+
from = new Date(now);
|
|
537
|
+
from.setFullYear(from.getFullYear() - 1);
|
|
538
|
+
break;
|
|
539
|
+
default:
|
|
540
|
+
from = new Date(now);
|
|
541
|
+
from.setMonth(from.getMonth() - 1);
|
|
542
|
+
}
|
|
543
|
+
return {
|
|
544
|
+
date_from: from.toISOString().substring(0, 10),
|
|
545
|
+
date_to: to,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
549
|
+
async function main() {
|
|
550
|
+
// Clean up audit entries older than 90 days (Important Fix I9: only here, not in getAuditLog)
|
|
551
|
+
try {
|
|
552
|
+
cleanup(90);
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
// Audit cleanup failure should not prevent server start
|
|
556
|
+
}
|
|
557
|
+
const transport = new StdioServerTransport();
|
|
558
|
+
await server.connect(transport);
|
|
559
|
+
console.error('[financeops-mcp] Server started');
|
|
560
|
+
}
|
|
561
|
+
main().catch((err) => {
|
|
562
|
+
console.error('[financeops-mcp] Fatal error:', err);
|
|
563
|
+
process.exit(1);
|
|
564
|
+
});
|
|
565
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface AuditEntry {
|
|
2
|
+
tool: string;
|
|
3
|
+
provider?: string;
|
|
4
|
+
input_summary?: string;
|
|
5
|
+
success?: boolean;
|
|
6
|
+
error?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function logAudit(entry: AuditEntry, dbPath?: string): void;
|
|
9
|
+
/**
|
|
10
|
+
* Delete entries older than maxAgeDays.
|
|
11
|
+
* Called on server start (Important Fix I9: only call from main, not from singleton).
|
|
12
|
+
*/
|
|
13
|
+
export declare function cleanup(maxAgeDays?: number, dbPath?: string): void;
|
|
14
|
+
export declare function getAuditLog(limit?: number, dbPath?: string): AuditEntry[];
|
|
15
|
+
/** Reset the singleton — for testing only. */
|
|
16
|
+
export declare function resetAuditDb(): void;
|
|
17
|
+
//# sourceMappingURL=audit.d.ts.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
const DB_PATH = path.join(os.homedir(), '.financeops', 'audit.db');
|
|
6
|
+
let db = null;
|
|
7
|
+
function getDb(dbPath) {
|
|
8
|
+
if (db)
|
|
9
|
+
return db;
|
|
10
|
+
const targetPath = dbPath ?? DB_PATH;
|
|
11
|
+
const dir = path.dirname(targetPath);
|
|
12
|
+
if (!fs.existsSync(dir)) {
|
|
13
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
db = new Database(targetPath);
|
|
16
|
+
db.exec(`
|
|
17
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
18
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
19
|
+
tool TEXT NOT NULL,
|
|
20
|
+
provider TEXT,
|
|
21
|
+
timestamp TEXT DEFAULT (datetime('now')),
|
|
22
|
+
input_summary TEXT,
|
|
23
|
+
success INTEGER DEFAULT 1,
|
|
24
|
+
error TEXT
|
|
25
|
+
)
|
|
26
|
+
`);
|
|
27
|
+
return db;
|
|
28
|
+
}
|
|
29
|
+
export function logAudit(entry, dbPath) {
|
|
30
|
+
const database = getDb(dbPath);
|
|
31
|
+
const stmt = database.prepare(`
|
|
32
|
+
INSERT INTO audit_log (tool, provider, input_summary, success, error)
|
|
33
|
+
VALUES (@tool, @provider, @input_summary, @success, @error)
|
|
34
|
+
`);
|
|
35
|
+
stmt.run({
|
|
36
|
+
tool: entry.tool,
|
|
37
|
+
provider: entry.provider ?? null,
|
|
38
|
+
input_summary: entry.input_summary ?? null,
|
|
39
|
+
success: entry.success !== false ? 1 : 0,
|
|
40
|
+
error: entry.error ?? null,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Delete entries older than maxAgeDays.
|
|
45
|
+
* Called on server start (Important Fix I9: only call from main, not from singleton).
|
|
46
|
+
*/
|
|
47
|
+
export function cleanup(maxAgeDays = 90, dbPath) {
|
|
48
|
+
const database = getDb(dbPath);
|
|
49
|
+
database.prepare(`
|
|
50
|
+
DELETE FROM audit_log
|
|
51
|
+
WHERE timestamp < datetime('now', '-' || ? || ' days')
|
|
52
|
+
`).run(maxAgeDays);
|
|
53
|
+
}
|
|
54
|
+
export function getAuditLog(limit = 100, dbPath) {
|
|
55
|
+
const database = getDb(dbPath);
|
|
56
|
+
return database.prepare(`
|
|
57
|
+
SELECT tool, provider, timestamp, input_summary, success, error
|
|
58
|
+
FROM audit_log
|
|
59
|
+
ORDER BY id DESC
|
|
60
|
+
LIMIT ?
|
|
61
|
+
`).all(limit);
|
|
62
|
+
}
|
|
63
|
+
/** Reset the singleton — for testing only. */
|
|
64
|
+
export function resetAuditDb() {
|
|
65
|
+
if (db) {
|
|
66
|
+
db.close();
|
|
67
|
+
db = null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=audit.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { FinanceProvider } from '../adapters/types.js';
|
|
2
|
+
export declare function getProvider(name: 'stripe' | 'xero'): FinanceProvider;
|
|
3
|
+
export declare function getProviders(names: ('stripe' | 'xero')[]): FinanceProvider[];
|
|
4
|
+
/** Clear the cache — useful for testing. */
|
|
5
|
+
export declare function clearProviderCache(): void;
|
|
6
|
+
//# sourceMappingURL=providers.d.ts.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Critical Fix 1: Cached provider factory.
|
|
3
|
+
* Adapters are created once and reused across calls to avoid:
|
|
4
|
+
* - Repeated credential validation
|
|
5
|
+
* - Multiple Stripe client instances
|
|
6
|
+
* - Multiple Xero token file reads
|
|
7
|
+
*/
|
|
8
|
+
import { StripeAdapter } from '../adapters/stripe.js';
|
|
9
|
+
import { XeroAdapter } from '../adapters/xero.js';
|
|
10
|
+
const cache = new Map();
|
|
11
|
+
export function getProvider(name) {
|
|
12
|
+
if (cache.has(name))
|
|
13
|
+
return cache.get(name);
|
|
14
|
+
const adapter = name === 'stripe' ? new StripeAdapter() : new XeroAdapter();
|
|
15
|
+
cache.set(name, adapter);
|
|
16
|
+
return adapter;
|
|
17
|
+
}
|
|
18
|
+
export function getProviders(names) {
|
|
19
|
+
return names.map((n) => getProvider(n));
|
|
20
|
+
}
|
|
21
|
+
/** Clear the cache — useful for testing. */
|
|
22
|
+
export function clearProviderCache() {
|
|
23
|
+
cache.clear();
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=providers.js.map
|