@profullstack/coinpay 0.11.2 → 0.12.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/CHANGELOG.md +12 -0
- package/README.md +26 -0
- package/bin/coinpay.js +24 -0
- package/package.json +1 -1
- package/src/finances-commands.js +127 -1
- package/src/finances-reports.js +16 -0
- package/src/mcp.js +152 -0
- package/src/statements-fetch.js +959 -0
- package/src/x402.js +12 -10
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ All notable changes to `@profullstack/coinpay` will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.12.0] - 2026-10-04
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `coinpay finances statements banks|login|fetch|assist|coverage|runs|local|retry`:
|
|
12
|
+
download the original PDF statements behind every linked account from the
|
|
13
|
+
banks themselves (SimpleFIN has none) and import them into the statement
|
|
14
|
+
library with their account and period. Bank sessions stay in local Chrome
|
|
15
|
+
profiles; `fetch` exits 3 when a bank needs signing in again.
|
|
16
|
+
- `coinpay mcp`: a stdio MCP server with finance and statement tools,
|
|
17
|
+
including `statements_fetch`.
|
|
18
|
+
- SDK: `reportStatementFetchRun`, `listStatementFetchRuns`, `getStatementCoverage`.
|
|
19
|
+
|
|
8
20
|
## [0.11.2] - 2026-09-16
|
|
9
21
|
|
|
10
22
|
### Added
|
package/README.md
CHANGED
|
@@ -758,6 +758,32 @@ const snapshot = await collectFinanceSnapshot(client, { days: 30 });
|
|
|
758
758
|
console.log(snapshot.earnings.netUsd, snapshot.bank.liabilities, snapshot.invoices.totals.overdue);
|
|
759
759
|
```
|
|
760
760
|
|
|
761
|
+
#### Statements from the banks
|
|
762
|
+
|
|
763
|
+
SimpleFIN carries transactions, not the PDF statements. The CLI downloads
|
|
764
|
+
them from each bank on your machine and files them in the statement library:
|
|
765
|
+
|
|
766
|
+
```bash
|
|
767
|
+
coinpay finances statements login chase # once per bank, in a window; no password is stored
|
|
768
|
+
coinpay finances statements fetch # every new statement, imported with its account and period
|
|
769
|
+
coinpay finances statements coverage # which account-months are still missing
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
Each bank's session lives in a local Chrome profile under
|
|
773
|
+
`~/.coinpay/statements`. `fetch` exits 3 when a bank needs signing in again.
|
|
774
|
+
Needs Node 22+ and Chrome or Chromium. See
|
|
775
|
+
[docs/STATEMENTS.md](https://github.com/profullstack/coinpayportal/blob/master/docs/STATEMENTS.md).
|
|
776
|
+
|
|
777
|
+
### MCP
|
|
778
|
+
|
|
779
|
+
```bash
|
|
780
|
+
claude mcp add coinpay -- coinpay mcp
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
`coinpay mcp` is a stdio MCP server using your `coinpay login` session. Tools:
|
|
784
|
+
`finance_accounts`, `statements_list`, `statements_coverage`,
|
|
785
|
+
`statements_fetch_runs`, `statements_banks` and `statements_fetch`.
|
|
786
|
+
|
|
761
787
|
---
|
|
762
788
|
|
|
763
789
|
## Webhook Integration
|
package/bin/coinpay.js
CHANGED
|
@@ -164,6 +164,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
164
164
|
// finances reports / statements
|
|
165
165
|
'wait',
|
|
166
166
|
'strict',
|
|
167
|
+
'headed',
|
|
167
168
|
'setup-token-stdin',
|
|
168
169
|
'overwrite',
|
|
169
170
|
'no-pending',
|
|
@@ -483,6 +484,13 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
483
484
|
statements import <pdf> Keep an original bank statement (--account, --period|--from/--to)
|
|
484
485
|
statements list|get|download|reconcile|delete
|
|
485
486
|
Statement library; reconcile takes --report --opening --closing --currency
|
|
487
|
+
statements banks Linked banks: signed in for statement downloads? last fetch?
|
|
488
|
+
statements login <bank> Sign in once in a window (no password is stored; Chrome profile on this machine)
|
|
489
|
+
statements fetch [bank…] Download every new PDF statement from the banks and import it
|
|
490
|
+
(--since YYYY-MM, --max N, --headed, --render S; exits 3 if a bank needs you)
|
|
491
|
+
statements assist <bank> A window: every PDF you download is imported
|
|
492
|
+
statements coverage Which account-months have a statement (--months 12)
|
|
493
|
+
statements runs|local|retry Fetch history, the local archive, re-import what failed
|
|
486
494
|
books [queue] Transactions awaiting category review (--status, --scope, --search)
|
|
487
495
|
books confirm <id> Confirm a row (--category, --tax, --scope, --note, --always makes a rule)
|
|
488
496
|
books confirm-all Accept every pending suggestion (--yes)
|
|
@@ -534,6 +542,8 @@ ${colors.cyan}Commands:${colors.reset}
|
|
|
534
542
|
|
|
535
543
|
${colors.bright}self${colors.reset}
|
|
536
544
|
update Update the coinpay CLI to the latest version
|
|
545
|
+
mcp Serve CoinPay finances to an AI agent over MCP (stdio):
|
|
546
|
+
claude mcp add coinpay -- coinpay mcp
|
|
537
547
|
upgrade Alias for update
|
|
538
548
|
remove Uninstall the coinpay CLI
|
|
539
549
|
uninstall Alias for remove
|
|
@@ -4566,6 +4576,20 @@ async function handleLightning(subcommand, args, flags) {
|
|
|
4566
4576
|
await handleFinances(subcommand, args, flags);
|
|
4567
4577
|
break;
|
|
4568
4578
|
|
|
4579
|
+
case 'mcp': {
|
|
4580
|
+
// stdout is the protocol channel: nothing else may print to it.
|
|
4581
|
+
const { serveStdio } = await import('../src/mcp.js');
|
|
4582
|
+
await serveStdio({
|
|
4583
|
+
version: VERSION,
|
|
4584
|
+
getClient: async () => {
|
|
4585
|
+
const token = process.env.COINPAY_SESSION_TOKEN || loadConfig().jwtToken;
|
|
4586
|
+
if (!token) throw new Error('Not logged in: run `coinpay login` (finance tools need the merchant session)');
|
|
4587
|
+
return new CoinPayClient({ apiKey: token, baseUrl: getBaseUrl(), timeout: 120000 });
|
|
4588
|
+
},
|
|
4589
|
+
});
|
|
4590
|
+
break;
|
|
4591
|
+
}
|
|
4592
|
+
|
|
4569
4593
|
case 'tui':
|
|
4570
4594
|
case 'dashboard':
|
|
4571
4595
|
await handleFinances('tui', [subcommand, ...args].filter(Boolean), flags);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/coinpay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "CoinPay SDK & CLI \u2014 Accept cryptocurrency and card payments (BTC, ETH, SOL, POL, BCH, USDC) with wallet, swap and a live finances dashboard",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
package/src/finances-commands.js
CHANGED
|
@@ -123,6 +123,131 @@ function describeReport(report) {
|
|
|
123
123
|
return parts.filter(Boolean).join(' · ');
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
const STATEMENT_FETCH_ACTIONS = new Set(['banks', 'login', 'fetch', 'assist', 'coverage', 'runs', 'local', 'retry']);
|
|
127
|
+
|
|
128
|
+
function ago(iso) {
|
|
129
|
+
if (!iso) return 'never';
|
|
130
|
+
const hours = (Date.now() - Date.parse(iso)) / 3_600_000;
|
|
131
|
+
if (hours < 1) return 'just now';
|
|
132
|
+
return hours < 48 ? `${Math.round(hours)}h ago` : `${Math.round(hours / 24)}d ago`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Downloading statements from the banks themselves (SimpleFIN has no
|
|
137
|
+
* documents). Runs on this machine: each bank's session lives in a local
|
|
138
|
+
* Chrome profile under ~/.coinpay/statements and never reaches CoinPay.
|
|
139
|
+
* `fetch` exits 3 when any bank needs signing in again or showed nothing.
|
|
140
|
+
*/
|
|
141
|
+
async function runStatementFetchAction(action, rest, flags, { client, out, emit, progress }) {
|
|
142
|
+
const sf = await import('./statements-fetch.js');
|
|
143
|
+
const home = sf.statementsHome();
|
|
144
|
+
const chrome = typeof flags.chrome === 'string' ? flags.chrome : sf.findChrome();
|
|
145
|
+
const months = (value, fallback) => {
|
|
146
|
+
if (value === undefined) return fallback;
|
|
147
|
+
const n = Number(value);
|
|
148
|
+
if (!Number.isInteger(n) || n < 1 || n > 36) throw new CliExit(EXIT.INVALID, '--months is a whole number from 1 to 36');
|
|
149
|
+
return n;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
if (action === 'coverage') {
|
|
154
|
+
const data = await api.getStatementCoverage(client, { months: months(flags.months, 12) });
|
|
155
|
+
const mark = { have: '■', missing: '·', open: '○' };
|
|
156
|
+
const lines = data.accounts.map((a) => `${a.months.map((m) => mark[m.state]).join('')} ${a.institutionKey} ${a.accountName}${a.missing ? ` (${a.missing} missing)` : ''}`);
|
|
157
|
+
const fetchers = (data.fetchers || []).map((r) => `${r.institutionKey}: ${r.status} ${ago(r.finishedAt)}${r.message ? ` (${r.message})` : ''}`);
|
|
158
|
+
emit(data, [`${data.months[0]} … ${data.months[data.months.length - 1]} ■ have · missing ○ this month`, ...lines, '', `${data.missing} account-months missing`, ...(fetchers.length ? ['', 'Last fetch per bank:', ...fetchers] : ['', 'No statement fetch has run yet: coinpay finances statements banks'])].join('\n'));
|
|
159
|
+
return EXIT.OK;
|
|
160
|
+
}
|
|
161
|
+
if (action === 'runs') {
|
|
162
|
+
const data = await api.listStatementFetchRuns(client, { limit: flags.limit ? Number(flags.limit) : undefined });
|
|
163
|
+
emit(data, data.runs.length ? data.runs.map((r) => `${r.finishedAt} ${r.institutionKey} ${r.status} ${r.filed} imported, ${r.duplicates} had, ${r.unmatched} unmatched${r.message ? ` ${r.message}` : ''}`).join('\n') : 'No statement fetch has run yet.');
|
|
164
|
+
return EXIT.OK;
|
|
165
|
+
}
|
|
166
|
+
if (action === 'local') {
|
|
167
|
+
const state = sf.loadLocal(home);
|
|
168
|
+
emit({ home, entries: state.entries }, state.entries.length
|
|
169
|
+
? state.entries.map((e) => `${e.statementId ? 'imported ' : 'local '} ${e.path}${e.importError ? ` (${e.importError})` : ''}`).join('\n')
|
|
170
|
+
: `Nothing downloaded yet (${home})`);
|
|
171
|
+
return EXIT.OK;
|
|
172
|
+
}
|
|
173
|
+
if (action === 'retry') {
|
|
174
|
+
const result = await sf.retryImports({ api: await sf.clientApi(client), home, log: progress });
|
|
175
|
+
emit(result, `${result.imported} imported, ${result.skipped} skipped (no account or period), ${result.failed} failed`);
|
|
176
|
+
return result.failed ? EXIT.FAILURE : EXIT.OK;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const bound = await sf.clientApi(client);
|
|
180
|
+
const institutions = sf.groupInstitutions(await bound.listAccounts());
|
|
181
|
+
if (action === 'banks') {
|
|
182
|
+
const state = sf.loadLocal(home);
|
|
183
|
+
let latest = [];
|
|
184
|
+
try {
|
|
185
|
+
latest = (await api.listStatementFetchRuns(client, { limit: 200 })).latest || [];
|
|
186
|
+
} catch {
|
|
187
|
+
// An older deployment without the route still lists the banks.
|
|
188
|
+
}
|
|
189
|
+
const rows = institutions.map((i) => ({ ...i, signedIn: sf.signedIn(i.key, home), local: state.institutions[i.key] || {}, lastRun: latest.find((r) => r.institutionKey === i.key) || null }));
|
|
190
|
+
emit({ home, chrome, banks: rows }, [
|
|
191
|
+
...rows.map((b) => `${b.key} ${b.name} · ${b.signedIn ? `signed in ${ago(b.local.loggedInAt)}` : 'not signed in'} · last fetch ${b.lastRun ? `${b.lastRun.status} ${ago(b.lastRun.finishedAt)}` : 'never'}\n${b.accounts.map((a) => ` ${a.name}${a.last4 ? '' : ' (no last four digits: its statements may need filing by hand)'}`).join('\n')}`),
|
|
192
|
+
...(rows.some((b) => !b.signedIn) ? ['', `Next: coinpay finances statements login ${rows.find((b) => !b.signedIn).key}`] : []),
|
|
193
|
+
...(chrome ? [] : ['', sf.NO_CHROME]),
|
|
194
|
+
].join('\n'));
|
|
195
|
+
return EXIT.OK;
|
|
196
|
+
}
|
|
197
|
+
if (action === 'login' || action === 'assist') {
|
|
198
|
+
if (rest.length !== 1) throw new CliExit(EXIT.INVALID, `Usage: coinpay finances statements ${action} <bank>`);
|
|
199
|
+
if (!chrome) throw new CliExit(EXIT.FAILURE, sf.NO_CHROME);
|
|
200
|
+
const institution = sf.pickInstitution(institutions, rest[0]);
|
|
201
|
+
const state = sf.loadLocal(home);
|
|
202
|
+
const local = (state.institutions[institution.key] ||= {});
|
|
203
|
+
const urls = sf.startUrls(institution, local.start);
|
|
204
|
+
const browser = await sf.openBrowser({ chrome, profile: sf.profileDir(institution.key, home), headless: false });
|
|
205
|
+
if (action === 'login') {
|
|
206
|
+
const url = typeof flags.url === 'string' ? flags.url : urls.login;
|
|
207
|
+
if (!url) throw new CliExit(EXIT.INVALID, `CoinPay has no site for ${institution.name}; pass --url with its sign-in page`);
|
|
208
|
+
out(`Opening ${institution.name}. Sign in, go to the page that lists your statements, then close the window.`);
|
|
209
|
+
const last = await sf.loginWindow(browser, url);
|
|
210
|
+
await browser.close();
|
|
211
|
+
local.loggedInAt = new Date().toISOString();
|
|
212
|
+
if (last) local.start = last;
|
|
213
|
+
sf.saveLocal(state, home);
|
|
214
|
+
emit({ bank: institution.key, start: local.start || urls.fetch }, last ? `Saved. fetch will start at ${last}` : 'Saved. The window closed on a sign-in page, so fetch starts from the bank\'s known page.');
|
|
215
|
+
return EXIT.OK;
|
|
216
|
+
}
|
|
217
|
+
out(`Opening ${institution.name}. Download each statement you want; each is imported as it lands. Close the window when done.`);
|
|
218
|
+
let kept = 0;
|
|
219
|
+
try {
|
|
220
|
+
await sf.assistWindow(browser, {
|
|
221
|
+
start: urls.fetch,
|
|
222
|
+
onFile: async (download) => {
|
|
223
|
+
const result = await sf.keepStatement({ institution, download, state, home, importStatement: bound.importStatement, how: 'assist' });
|
|
224
|
+
if (result.status === 'imported') kept += 1;
|
|
225
|
+
progress(` ${result.status}: ${result.entry ? result.entry.path : result.name}${result.entry && result.entry.importError ? ` (${result.entry.importError})` : ''}`);
|
|
226
|
+
sf.saveLocal(state, home);
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
} finally {
|
|
230
|
+
await browser.close();
|
|
231
|
+
}
|
|
232
|
+
emit({ bank: institution.key, imported: kept }, `${kept} imported`);
|
|
233
|
+
return EXIT.OK;
|
|
234
|
+
}
|
|
235
|
+
// fetch
|
|
236
|
+
const since = typeof flags.since === 'string' ? flags.since : null;
|
|
237
|
+
if (since && !/^\d{4}-(0[1-9]|1[0-2])$/.test(since)) throw new CliExit(EXIT.INVALID, '--since is YYYY-MM');
|
|
238
|
+
const max = flags.max !== undefined ? Number(flags.max) : 24;
|
|
239
|
+
if (!Number.isInteger(max) || max < 1 || max > 500) throw new CliExit(EXIT.INVALID, '--max is a whole number from 1 to 500');
|
|
240
|
+
const render = flags.render !== undefined ? Number(flags.render) : 25;
|
|
241
|
+
if (!Number.isFinite(render) || render < 3 || render > 300) throw new CliExit(EXIT.INVALID, '--render is seconds, 3 to 300');
|
|
242
|
+
const results = await sf.runStatementFetch({ api: bound, banks: rest, since, max, renderMs: render * 1000, headless: flags.headed !== true, chrome, home, log: progress });
|
|
243
|
+
emit({ banks: results }, results.map((r) => `${r.bank}: ${r.status}, ${r.imported} imported, ${r.duplicates} already had, ${r.unmatched} unmatched${r.message ? ` (${r.message})` : ''}`).join('\n'));
|
|
244
|
+
return results.every((r) => r.status === 'ok' && r.failed === 0) ? EXIT.OK : EXIT.STRICT;
|
|
245
|
+
} catch (err) {
|
|
246
|
+
if (err instanceof sf.StatementFetchError) throw new CliExit(EXIT.INVALID, err.message);
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
126
251
|
/**
|
|
127
252
|
* Run one subcommand. `ctx` supplies the client and the output channels.
|
|
128
253
|
* @returns {Promise<number>} an exit code
|
|
@@ -414,7 +539,8 @@ export async function runFinancesCommand(subcommand, args, flags, ctx) {
|
|
|
414
539
|
emit(data, data.note);
|
|
415
540
|
return EXIT.OK;
|
|
416
541
|
}
|
|
417
|
-
|
|
542
|
+
if (STATEMENT_FETCH_ACTIONS.has(action)) return await runStatementFetchAction(action, args.slice(1), flags, { client, out, emit, progress });
|
|
543
|
+
throw new CliExit(EXIT.INVALID, 'Usage: coinpay finances statements [import <file>|list|get <id>|download <id>|reconcile <id>|delete <id>|banks|login <bank>|fetch [bank…]|assist <bank>|coverage|runs|local|retry]');
|
|
418
544
|
}
|
|
419
545
|
|
|
420
546
|
case 'books': {
|
package/src/finances-reports.js
CHANGED
|
@@ -256,6 +256,22 @@ export async function reconcileFinanceStatement(client, statementId, {
|
|
|
256
256
|
return data.reconciliation;
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
/** Report one bank's statement fetch run (counts only). */
|
|
260
|
+
export async function reportStatementFetchRun(client, run) {
|
|
261
|
+
const data = await call(client, '/finances/statements/fetch-runs', { method: 'POST', body: JSON.stringify(run) });
|
|
262
|
+
return data.run;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** `{latest, runs}`: the newest fetch run per bank, and the recent history. */
|
|
266
|
+
export async function listStatementFetchRuns(client, { limit } = {}) {
|
|
267
|
+
return call(client, `/finances/statements/fetch-runs${query({ limit })}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Which of the last `months` months have a statement, per account, plus each bank's latest fetch. */
|
|
271
|
+
export async function getStatementCoverage(client, { months } = {}) {
|
|
272
|
+
return call(client, `/finances/statements/coverage${query({ months })}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
259
275
|
// ── Books ──
|
|
260
276
|
|
|
261
277
|
/** Rows awaiting review (default) plus the category vocabularies. */
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `coinpay mcp`: CoinPay as an MCP server over stdio.
|
|
3
|
+
*
|
|
4
|
+
* Newline-delimited JSON-RPC 2.0, no SDK dependency: initialize, ping,
|
|
5
|
+
* tools/list and tools/call are the whole surface. stdout carries protocol
|
|
6
|
+
* messages only; anything human goes to stderr.
|
|
7
|
+
*
|
|
8
|
+
* It runs on the merchant's machine with their `coinpay login` session, which
|
|
9
|
+
* is what lets `statements_fetch` work at all: the bank sessions behind it
|
|
10
|
+
* live in local Chrome profiles that CoinPay's servers never see.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createInterface } from 'node:readline';
|
|
14
|
+
|
|
15
|
+
import * as reports from './finances-reports.js';
|
|
16
|
+
import { listFinanceAccounts } from './finances.js';
|
|
17
|
+
import * as sf from './statements-fetch.js';
|
|
18
|
+
|
|
19
|
+
export const PROTOCOL_VERSION = '2025-06-18';
|
|
20
|
+
|
|
21
|
+
const text = (value) => ({ content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] });
|
|
22
|
+
|
|
23
|
+
/** Every tool: name, description, JSON schema, and a handler taking (args, ctx). */
|
|
24
|
+
export const TOOLS = [
|
|
25
|
+
{
|
|
26
|
+
name: 'finance_accounts',
|
|
27
|
+
description: 'List the linked bank and card accounts (institution, name, kind, balance, id).',
|
|
28
|
+
inputSchema: { type: 'object', properties: { includeHidden: { type: 'boolean' } }, additionalProperties: false },
|
|
29
|
+
handler: async (args, { client }) => text({ accounts: await listFinanceAccounts(client, { includeHidden: args.includeHidden === true }) }),
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'statements_list',
|
|
33
|
+
description: 'List PDF statements in the CoinPay statement library, optionally for one account or period (2026-08, 2026-Q3).',
|
|
34
|
+
inputSchema: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: { accountId: { type: 'string' }, period: { type: 'string' }, limit: { type: 'integer', minimum: 1, maximum: 500 } },
|
|
37
|
+
additionalProperties: false,
|
|
38
|
+
},
|
|
39
|
+
handler: async (args, { client }) => text({ statements: await reports.listFinanceStatements(client, { accountId: args.accountId, period: args.period, limit: args.limit }) }),
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'statements_coverage',
|
|
43
|
+
description: 'For each account, which of the last N months have a statement (have), which do not (missing), and the current month (open); plus the latest statement fetch per bank.',
|
|
44
|
+
inputSchema: { type: 'object', properties: { months: { type: 'integer', minimum: 1, maximum: 36 } }, additionalProperties: false },
|
|
45
|
+
handler: async (args, { client }) => text(await reports.getStatementCoverage(client, { months: args.months })),
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'statements_fetch_runs',
|
|
49
|
+
description: 'Recent statement fetch runs: per bank, ok / login_needed / no_statements / error, with counts.',
|
|
50
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 1000 } }, additionalProperties: false },
|
|
51
|
+
handler: async (args, { client }) => text(await reports.listStatementFetchRuns(client, { limit: args.limit })),
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'statements_banks',
|
|
55
|
+
description: 'The linked banks and whether each is signed in on this machine for statement downloads. A bank that is not must be signed in by a person: `coinpay finances statements login <bank>`.',
|
|
56
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
57
|
+
handler: async (_args, { client, home }) => {
|
|
58
|
+
const institutions = sf.groupInstitutions(await listFinanceAccounts(client));
|
|
59
|
+
const state = sf.loadLocal(home);
|
|
60
|
+
return text({
|
|
61
|
+
chrome: sf.findChrome(),
|
|
62
|
+
banks: institutions.map((i) => ({ key: i.key, name: i.name, accounts: i.accounts.map((a) => a.name), signedIn: sf.signedIn(i.key, home), ...state.institutions[i.key] })),
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: 'statements_fetch',
|
|
68
|
+
description:
|
|
69
|
+
'Download every new PDF statement from the signed-in banks (or the ones named) in a headless browser on this machine and import each into the CoinPay statement library. Takes minutes. Banks needing a sign-in are reported, never signed in to.',
|
|
70
|
+
inputSchema: {
|
|
71
|
+
type: 'object',
|
|
72
|
+
properties: {
|
|
73
|
+
banks: { type: 'array', items: { type: 'string' }, description: 'Bank keys such as "chase"; all signed-in banks when omitted' },
|
|
74
|
+
since: { type: 'string', pattern: '^\\d{4}-(0[1-9]|1[0-2])$', description: 'Skip statements that closed before this month (YYYY-MM)' },
|
|
75
|
+
max: { type: 'integer', minimum: 1, maximum: 500, description: 'At most this many new statements per bank (default 24)' },
|
|
76
|
+
},
|
|
77
|
+
additionalProperties: false,
|
|
78
|
+
},
|
|
79
|
+
handler: async (args, { client, home, log }) => {
|
|
80
|
+
const results = await sf.runStatementFetch({
|
|
81
|
+
api: await sf.clientApi(client),
|
|
82
|
+
banks: Array.isArray(args.banks) ? args.banks : [],
|
|
83
|
+
since: args.since || null,
|
|
84
|
+
max: args.max || 24,
|
|
85
|
+
home,
|
|
86
|
+
log,
|
|
87
|
+
client: 'coinpay-mcp',
|
|
88
|
+
});
|
|
89
|
+
return { ...text({ banks: results }), isError: false };
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
/** Answer one JSON-RPC message; null for a notification. */
|
|
95
|
+
export async function handleMessage(message, ctx) {
|
|
96
|
+
const { id, method, params = {} } = message || {};
|
|
97
|
+
const reply = (result) => (id === undefined ? null : { jsonrpc: '2.0', id, result });
|
|
98
|
+
const fail = (code, msg) => (id === undefined ? null : { jsonrpc: '2.0', id, error: { code, message: msg } });
|
|
99
|
+
|
|
100
|
+
switch (method) {
|
|
101
|
+
case 'initialize':
|
|
102
|
+
return reply({
|
|
103
|
+
protocolVersion: typeof params.protocolVersion === 'string' ? params.protocolVersion : PROTOCOL_VERSION,
|
|
104
|
+
capabilities: { tools: {} },
|
|
105
|
+
serverInfo: { name: 'coinpay', version: ctx.version || '0.0.0' },
|
|
106
|
+
instructions:
|
|
107
|
+
'CoinPay finances. Statement PDFs come from the banks themselves (SimpleFIN has none): statements_coverage shows what is missing, statements_fetch downloads what is new on this machine. A bank marked login_needed needs a person to run `coinpay finances statements login <bank>`.',
|
|
108
|
+
});
|
|
109
|
+
case 'notifications/initialized':
|
|
110
|
+
case 'notifications/cancelled':
|
|
111
|
+
return null;
|
|
112
|
+
case 'ping':
|
|
113
|
+
return reply({});
|
|
114
|
+
case 'tools/list':
|
|
115
|
+
return reply({ tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
|
|
116
|
+
case 'tools/call': {
|
|
117
|
+
const tool = TOOLS.find((t) => t.name === params.name);
|
|
118
|
+
if (!tool) return fail(-32602, `Unknown tool: ${params.name}`);
|
|
119
|
+
try {
|
|
120
|
+
const client = await ctx.getClient();
|
|
121
|
+
return reply(await tool.handler(params.arguments || {}, { ...ctx, client }));
|
|
122
|
+
} catch (err) {
|
|
123
|
+
return reply({ ...text(`${err && err.message ? err.message : String(err)}${err && err.code ? ` (${err.code})` : ''}`), isError: true });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
default:
|
|
127
|
+
return id === undefined ? null : fail(-32601, `Method not found: ${method}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Serve on stdin/stdout until stdin closes. Calls run concurrently; replies carry their id. */
|
|
132
|
+
export async function serveStdio({ getClient, version, home = sf.statementsHome(), input = process.stdin, output = process.stdout, errOut = (line) => process.stderr.write(`${line}\n`) }) {
|
|
133
|
+
const ctx = { getClient, version, home, log: errOut };
|
|
134
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
135
|
+
const pending = new Set();
|
|
136
|
+
for await (const line of lines) {
|
|
137
|
+
if (!line.trim()) continue;
|
|
138
|
+
let message;
|
|
139
|
+
try {
|
|
140
|
+
message = JSON.parse(line);
|
|
141
|
+
} catch {
|
|
142
|
+
output.write(`${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })}\n`);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const task = handleMessage(message, ctx).then((response) => {
|
|
146
|
+
if (response) output.write(`${JSON.stringify(response)}\n`);
|
|
147
|
+
});
|
|
148
|
+
pending.add(task);
|
|
149
|
+
task.finally(() => pending.delete(task));
|
|
150
|
+
}
|
|
151
|
+
await Promise.all(pending);
|
|
152
|
+
}
|