@profullstack/coinpay 0.11.1 → 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/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
+ }