@sheetlink/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/README.md +79 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +246 -0
- package/package.json +25 -0
- package/src/index.ts +333 -0
- package/tsconfig.json +14 -0
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# SheetLink MCP Server
|
|
2
|
+
|
|
3
|
+
Connect Claude to your bank transactions via SheetLink.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
Exposes three tools to Claude:
|
|
8
|
+
|
|
9
|
+
- **`list_accounts`** — lists your connected bank accounts
|
|
10
|
+
- **`list_transactions`** — fetches transactions with optional date, account, and category filters
|
|
11
|
+
- **`get_spending_summary`** — aggregates spending by category or merchant for a date range
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
- SheetLink MAX tier (for API key access)
|
|
16
|
+
- Node.js 18+
|
|
17
|
+
|
|
18
|
+
## Setup
|
|
19
|
+
|
|
20
|
+
### 1. Get your API key
|
|
21
|
+
|
|
22
|
+
Log in to [sheetlink.app/dashboard/api-keys](https://sheetlink.app/dashboard/api-keys) and create an API key.
|
|
23
|
+
|
|
24
|
+
### 2. Configure Claude Desktop
|
|
25
|
+
|
|
26
|
+
Add to your `claude_desktop_config.json`:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"mcpServers": {
|
|
31
|
+
"sheetlink": {
|
|
32
|
+
"command": "npx",
|
|
33
|
+
"args": ["-y", "@sheetlink/mcp"],
|
|
34
|
+
"env": {
|
|
35
|
+
"SHEETLINK_API_KEY": "sl_your_api_key_here"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### 3. Restart Claude Desktop
|
|
43
|
+
|
|
44
|
+
That's it. Ask Claude things like:
|
|
45
|
+
|
|
46
|
+
- *"What did I spend on restaurants last month?"*
|
|
47
|
+
- *"Show me my top spending categories for Q1"*
|
|
48
|
+
- *"List all transactions over $100 from Chase this week"*
|
|
49
|
+
- *"Build me a P&L for March using my bank data"*
|
|
50
|
+
|
|
51
|
+
## Tools
|
|
52
|
+
|
|
53
|
+
### `list_accounts`
|
|
54
|
+
Lists all connected bank accounts with institution name and last sync time.
|
|
55
|
+
|
|
56
|
+
### `list_transactions`
|
|
57
|
+
| Parameter | Type | Description |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| `item_id` | string | Filter to a specific bank (from `list_accounts`) |
|
|
60
|
+
| `start_date` | string | YYYY-MM-DD |
|
|
61
|
+
| `end_date` | string | YYYY-MM-DD |
|
|
62
|
+
| `category` | string | Partial match on Plaid category (e.g. `FOOD_AND_DRINK`) |
|
|
63
|
+
| `limit` | number | Max results (default 100) |
|
|
64
|
+
|
|
65
|
+
### `get_spending_summary`
|
|
66
|
+
| Parameter | Type | Description |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `item_id` | string | Limit to one bank |
|
|
69
|
+
| `start_date` | string | YYYY-MM-DD |
|
|
70
|
+
| `end_date` | string | YYYY-MM-DD |
|
|
71
|
+
| `group_by` | `category` \| `merchant` | Default: `category` |
|
|
72
|
+
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npm install
|
|
77
|
+
npm run build
|
|
78
|
+
SHEETLINK_API_KEY=sl_your_key node dist/index.js
|
|
79
|
+
```
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
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
|
+
const API_BASE = "https://api.sheetlink.app";
|
|
6
|
+
const API_KEY = process.env.SHEETLINK_API_KEY;
|
|
7
|
+
if (!API_KEY) {
|
|
8
|
+
console.error("Error: SHEETLINK_API_KEY environment variable is required");
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
async function apiFetch(path, options = {}) {
|
|
12
|
+
const res = await fetch(`${API_BASE}${path}`, {
|
|
13
|
+
...options,
|
|
14
|
+
headers: {
|
|
15
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
16
|
+
"Content-Type": "application/json",
|
|
17
|
+
...options.headers,
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
if (!res.ok) {
|
|
21
|
+
const text = await res.text();
|
|
22
|
+
throw new Error(`SheetLink API error ${res.status}: ${text}`);
|
|
23
|
+
}
|
|
24
|
+
return res.json();
|
|
25
|
+
}
|
|
26
|
+
// ── Tool handlers ────────────────────────────────────────────────────────────
|
|
27
|
+
async function listAccounts() {
|
|
28
|
+
const data = await apiFetch("/api/items");
|
|
29
|
+
const items = data.items;
|
|
30
|
+
if (!items.length) {
|
|
31
|
+
return "No bank accounts connected. Visit sheetlink.app to connect a bank.";
|
|
32
|
+
}
|
|
33
|
+
return items
|
|
34
|
+
.map((item) => `• ${item.institution_name} (item_id: ${item.item_id}, last synced: ${item.last_synced_at ?? "never"})`)
|
|
35
|
+
.join("\n");
|
|
36
|
+
}
|
|
37
|
+
async function listTransactions(args) {
|
|
38
|
+
// Get items to sync
|
|
39
|
+
const itemsData = await apiFetch("/api/items");
|
|
40
|
+
const items = itemsData.items;
|
|
41
|
+
if (!items.length) {
|
|
42
|
+
return "No bank accounts connected.";
|
|
43
|
+
}
|
|
44
|
+
const targetItems = args.item_id
|
|
45
|
+
? items.filter((i) => i.item_id === args.item_id)
|
|
46
|
+
: items;
|
|
47
|
+
if (!targetItems.length) {
|
|
48
|
+
return `No item found with item_id: ${args.item_id}`;
|
|
49
|
+
}
|
|
50
|
+
// Sync each item and collect transactions
|
|
51
|
+
let allTransactions = [];
|
|
52
|
+
for (const item of targetItems) {
|
|
53
|
+
const syncData = await apiFetch("/api/sync", {
|
|
54
|
+
method: "POST",
|
|
55
|
+
body: JSON.stringify({ item_id: item.item_id }),
|
|
56
|
+
});
|
|
57
|
+
allTransactions.push(...(syncData.transactions ?? []));
|
|
58
|
+
}
|
|
59
|
+
// Filter by date range
|
|
60
|
+
if (args.start_date) {
|
|
61
|
+
allTransactions = allTransactions.filter((t) => t.date >= args.start_date);
|
|
62
|
+
}
|
|
63
|
+
if (args.end_date) {
|
|
64
|
+
allTransactions = allTransactions.filter((t) => t.date <= args.end_date);
|
|
65
|
+
}
|
|
66
|
+
// Filter by category
|
|
67
|
+
if (args.category) {
|
|
68
|
+
const cat = args.category.toUpperCase();
|
|
69
|
+
allTransactions = allTransactions.filter((t) => (t.category_primary ?? "").toUpperCase().includes(cat) ||
|
|
70
|
+
(t.category_detailed ?? "").toUpperCase().includes(cat));
|
|
71
|
+
}
|
|
72
|
+
// Sort newest first
|
|
73
|
+
allTransactions.sort((a, b) => b.date.localeCompare(a.date));
|
|
74
|
+
// Apply limit
|
|
75
|
+
const limit = args.limit ?? 100;
|
|
76
|
+
const truncated = allTransactions.length > limit;
|
|
77
|
+
const transactions = allTransactions.slice(0, limit);
|
|
78
|
+
if (!transactions.length) {
|
|
79
|
+
return "No transactions found matching the given filters.";
|
|
80
|
+
}
|
|
81
|
+
const lines = transactions.map((t) => {
|
|
82
|
+
const amount = typeof t.amount === "number" ? t.amount.toFixed(2) : t.amount;
|
|
83
|
+
const name = t.merchant_name ?? t.description ?? t.description_raw ?? "Unknown";
|
|
84
|
+
const cat = t.category_primary ?? "";
|
|
85
|
+
return `${t.date} ${String(name).padEnd(35)} $${String(amount).padStart(8)} ${cat}`;
|
|
86
|
+
});
|
|
87
|
+
const header = `${"Date".padEnd(10)} ${"Merchant".padEnd(35)} ${"Amount".padStart(9)} Category`;
|
|
88
|
+
const separator = "─".repeat(header.length);
|
|
89
|
+
const result = [header, separator, ...lines].join("\n");
|
|
90
|
+
return truncated
|
|
91
|
+
? `${result}\n\n(Showing first ${limit} of ${allTransactions.length} transactions. Use a narrower date range or increase limit.)`
|
|
92
|
+
: result;
|
|
93
|
+
}
|
|
94
|
+
async function getSpendingSummary(args) {
|
|
95
|
+
const itemsData = await apiFetch("/api/items");
|
|
96
|
+
const items = itemsData.items;
|
|
97
|
+
if (!items.length)
|
|
98
|
+
return "No bank accounts connected.";
|
|
99
|
+
const targetItems = args.item_id
|
|
100
|
+
? items.filter((i) => i.item_id === args.item_id)
|
|
101
|
+
: items;
|
|
102
|
+
let allTransactions = [];
|
|
103
|
+
for (const item of targetItems) {
|
|
104
|
+
const syncData = await apiFetch("/api/sync", {
|
|
105
|
+
method: "POST",
|
|
106
|
+
body: JSON.stringify({ item_id: item.item_id }),
|
|
107
|
+
});
|
|
108
|
+
allTransactions.push(...(syncData.transactions ?? []));
|
|
109
|
+
}
|
|
110
|
+
// Filter by date range
|
|
111
|
+
if (args.start_date) {
|
|
112
|
+
allTransactions = allTransactions.filter((t) => t.date >= args.start_date);
|
|
113
|
+
}
|
|
114
|
+
if (args.end_date) {
|
|
115
|
+
allTransactions = allTransactions.filter((t) => t.date <= args.end_date);
|
|
116
|
+
}
|
|
117
|
+
if (!allTransactions.length) {
|
|
118
|
+
return "No transactions found in the given date range.";
|
|
119
|
+
}
|
|
120
|
+
// Group and sum
|
|
121
|
+
const groupBy = args.group_by ?? "category";
|
|
122
|
+
const totals = {};
|
|
123
|
+
for (const t of allTransactions) {
|
|
124
|
+
const key = groupBy === "merchant"
|
|
125
|
+
? (t.merchant_name ?? t.description ?? "Unknown")
|
|
126
|
+
: (t.category_primary ?? "Uncategorized");
|
|
127
|
+
totals[key] = (totals[key] ?? 0) + t.amount;
|
|
128
|
+
}
|
|
129
|
+
// Sort by spend descending
|
|
130
|
+
const sorted = Object.entries(totals).sort((a, b) => b[1] - a[1]);
|
|
131
|
+
const totalSpend = sorted.reduce((sum, [, v]) => sum + v, 0);
|
|
132
|
+
const dateRange = args.start_date && args.end_date
|
|
133
|
+
? `${args.start_date} to ${args.end_date}`
|
|
134
|
+
: args.start_date
|
|
135
|
+
? `from ${args.start_date}`
|
|
136
|
+
: args.end_date
|
|
137
|
+
? `through ${args.end_date}`
|
|
138
|
+
: "all time";
|
|
139
|
+
const lines = sorted.map(([key, amt]) => `${key.padEnd(40)} $${amt.toFixed(2).padStart(10)}`);
|
|
140
|
+
return [
|
|
141
|
+
`Spending summary by ${groupBy} (${dateRange})`,
|
|
142
|
+
`Total: $${totalSpend.toFixed(2)}`,
|
|
143
|
+
"─".repeat(55),
|
|
144
|
+
...lines,
|
|
145
|
+
].join("\n");
|
|
146
|
+
}
|
|
147
|
+
// ── MCP Server ───────────────────────────────────────────────────────────────
|
|
148
|
+
const server = new Server({ name: "sheetlink", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
149
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
150
|
+
tools: [
|
|
151
|
+
{
|
|
152
|
+
name: "list_accounts",
|
|
153
|
+
description: "List all bank accounts connected to SheetLink, including institution names and last sync time.",
|
|
154
|
+
inputSchema: {
|
|
155
|
+
type: "object",
|
|
156
|
+
properties: {},
|
|
157
|
+
required: [],
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: "list_transactions",
|
|
162
|
+
description: "Fetch bank transactions from SheetLink. Optionally filter by account, date range, or spending category. Returns date, merchant, amount, and category for each transaction.",
|
|
163
|
+
inputSchema: {
|
|
164
|
+
type: "object",
|
|
165
|
+
properties: {
|
|
166
|
+
item_id: {
|
|
167
|
+
type: "string",
|
|
168
|
+
description: "Filter to a specific bank account (item_id from list_accounts). Omit to fetch all accounts.",
|
|
169
|
+
},
|
|
170
|
+
start_date: {
|
|
171
|
+
type: "string",
|
|
172
|
+
description: "Start date filter in YYYY-MM-DD format (inclusive).",
|
|
173
|
+
},
|
|
174
|
+
end_date: {
|
|
175
|
+
type: "string",
|
|
176
|
+
description: "End date filter in YYYY-MM-DD format (inclusive).",
|
|
177
|
+
},
|
|
178
|
+
category: {
|
|
179
|
+
type: "string",
|
|
180
|
+
description: "Filter by spending category (e.g. FOOD_AND_DRINK, TRANSPORTATION, SHOPPING). Partial match, case-insensitive.",
|
|
181
|
+
},
|
|
182
|
+
limit: {
|
|
183
|
+
type: "number",
|
|
184
|
+
description: "Maximum number of transactions to return (default: 100).",
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
required: [],
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: "get_spending_summary",
|
|
192
|
+
description: "Get a spending summary aggregated by category or merchant for a given date range. Useful for answering questions like 'how much did I spend on food last month?' or 'what are my top spending categories?'",
|
|
193
|
+
inputSchema: {
|
|
194
|
+
type: "object",
|
|
195
|
+
properties: {
|
|
196
|
+
item_id: {
|
|
197
|
+
type: "string",
|
|
198
|
+
description: "Limit summary to a specific bank account. Omit for all accounts.",
|
|
199
|
+
},
|
|
200
|
+
start_date: {
|
|
201
|
+
type: "string",
|
|
202
|
+
description: "Start date in YYYY-MM-DD format.",
|
|
203
|
+
},
|
|
204
|
+
end_date: {
|
|
205
|
+
type: "string",
|
|
206
|
+
description: "End date in YYYY-MM-DD format.",
|
|
207
|
+
},
|
|
208
|
+
group_by: {
|
|
209
|
+
type: "string",
|
|
210
|
+
enum: ["category", "merchant"],
|
|
211
|
+
description: "Group spending by category (default) or merchant.",
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
required: [],
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
}));
|
|
219
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
220
|
+
const { name, arguments: args = {} } = request.params;
|
|
221
|
+
try {
|
|
222
|
+
let result;
|
|
223
|
+
if (name === "list_accounts") {
|
|
224
|
+
result = await listAccounts();
|
|
225
|
+
}
|
|
226
|
+
else if (name === "list_transactions") {
|
|
227
|
+
result = await listTransactions(args);
|
|
228
|
+
}
|
|
229
|
+
else if (name === "get_spending_summary") {
|
|
230
|
+
result = await getSpendingSummary(args);
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
234
|
+
}
|
|
235
|
+
return { content: [{ type: "text", text: result }] };
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
239
|
+
return {
|
|
240
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
241
|
+
isError: true,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
const transport = new StdioServerTransport();
|
|
246
|
+
await server.connect(transport);
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sheetlink/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for SheetLink — connect Claude to your bank transactions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"sheetlink-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"dev": "tsc --watch",
|
|
13
|
+
"start": "node dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"typescript": "^5.3.0",
|
|
20
|
+
"@types/node": "^20.0.0"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
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 {
|
|
5
|
+
CallToolRequestSchema,
|
|
6
|
+
ListToolsRequestSchema,
|
|
7
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
8
|
+
|
|
9
|
+
const API_BASE = "https://api.sheetlink.app";
|
|
10
|
+
const API_KEY = process.env.SHEETLINK_API_KEY;
|
|
11
|
+
|
|
12
|
+
if (!API_KEY) {
|
|
13
|
+
console.error("Error: SHEETLINK_API_KEY environment variable is required");
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function apiFetch(path: string, options: RequestInit = {}) {
|
|
18
|
+
const res = await fetch(`${API_BASE}${path}`, {
|
|
19
|
+
...options,
|
|
20
|
+
headers: {
|
|
21
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
22
|
+
"Content-Type": "application/json",
|
|
23
|
+
...options.headers,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (!res.ok) {
|
|
28
|
+
const text = await res.text();
|
|
29
|
+
throw new Error(`SheetLink API error ${res.status}: ${text}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return res.json();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Tool handlers ────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
async function listAccounts() {
|
|
38
|
+
const data = await apiFetch("/api/items");
|
|
39
|
+
const items = data.items as Array<{
|
|
40
|
+
item_id: string;
|
|
41
|
+
institution_name: string;
|
|
42
|
+
last_synced_at: string | null;
|
|
43
|
+
}>;
|
|
44
|
+
|
|
45
|
+
if (!items.length) {
|
|
46
|
+
return "No bank accounts connected. Visit sheetlink.app to connect a bank.";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return items
|
|
50
|
+
.map(
|
|
51
|
+
(item) =>
|
|
52
|
+
`• ${item.institution_name} (item_id: ${item.item_id}, last synced: ${item.last_synced_at ?? "never"})`
|
|
53
|
+
)
|
|
54
|
+
.join("\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function listTransactions(args: {
|
|
58
|
+
item_id?: string;
|
|
59
|
+
start_date?: string;
|
|
60
|
+
end_date?: string;
|
|
61
|
+
category?: string;
|
|
62
|
+
limit?: number;
|
|
63
|
+
}) {
|
|
64
|
+
// Get items to sync
|
|
65
|
+
const itemsData = await apiFetch("/api/items");
|
|
66
|
+
const items = itemsData.items as Array<{ item_id: string; institution_name: string }>;
|
|
67
|
+
|
|
68
|
+
if (!items.length) {
|
|
69
|
+
return "No bank accounts connected.";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const targetItems = args.item_id
|
|
73
|
+
? items.filter((i) => i.item_id === args.item_id)
|
|
74
|
+
: items;
|
|
75
|
+
|
|
76
|
+
if (!targetItems.length) {
|
|
77
|
+
return `No item found with item_id: ${args.item_id}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Sync each item and collect transactions
|
|
81
|
+
let allTransactions: Array<Record<string, unknown>> = [];
|
|
82
|
+
|
|
83
|
+
for (const item of targetItems) {
|
|
84
|
+
const syncData = await apiFetch("/api/sync", {
|
|
85
|
+
method: "POST",
|
|
86
|
+
body: JSON.stringify({ item_id: item.item_id }),
|
|
87
|
+
});
|
|
88
|
+
allTransactions.push(...(syncData.transactions ?? []));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Filter by date range
|
|
92
|
+
if (args.start_date) {
|
|
93
|
+
allTransactions = allTransactions.filter(
|
|
94
|
+
(t) => (t.date as string) >= args.start_date!
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (args.end_date) {
|
|
98
|
+
allTransactions = allTransactions.filter(
|
|
99
|
+
(t) => (t.date as string) <= args.end_date!
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Filter by category
|
|
104
|
+
if (args.category) {
|
|
105
|
+
const cat = args.category.toUpperCase();
|
|
106
|
+
allTransactions = allTransactions.filter(
|
|
107
|
+
(t) =>
|
|
108
|
+
((t.category_primary as string) ?? "").toUpperCase().includes(cat) ||
|
|
109
|
+
((t.category_detailed as string) ?? "").toUpperCase().includes(cat)
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Sort newest first
|
|
114
|
+
allTransactions.sort((a, b) =>
|
|
115
|
+
(b.date as string).localeCompare(a.date as string)
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// Apply limit
|
|
119
|
+
const limit = args.limit ?? 100;
|
|
120
|
+
const truncated = allTransactions.length > limit;
|
|
121
|
+
const transactions = allTransactions.slice(0, limit);
|
|
122
|
+
|
|
123
|
+
if (!transactions.length) {
|
|
124
|
+
return "No transactions found matching the given filters.";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const lines = transactions.map((t) => {
|
|
128
|
+
const amount = typeof t.amount === "number" ? t.amount.toFixed(2) : t.amount;
|
|
129
|
+
const name = t.merchant_name ?? t.description ?? t.description_raw ?? "Unknown";
|
|
130
|
+
const cat = t.category_primary ?? "";
|
|
131
|
+
return `${t.date} ${String(name).padEnd(35)} $${String(amount).padStart(8)} ${cat}`;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const header = `${"Date".padEnd(10)} ${"Merchant".padEnd(35)} ${"Amount".padStart(9)} Category`;
|
|
135
|
+
const separator = "─".repeat(header.length);
|
|
136
|
+
const result = [header, separator, ...lines].join("\n");
|
|
137
|
+
|
|
138
|
+
return truncated
|
|
139
|
+
? `${result}\n\n(Showing first ${limit} of ${allTransactions.length} transactions. Use a narrower date range or increase limit.)`
|
|
140
|
+
: result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function getSpendingSummary(args: {
|
|
144
|
+
item_id?: string;
|
|
145
|
+
start_date?: string;
|
|
146
|
+
end_date?: string;
|
|
147
|
+
group_by?: "category" | "merchant";
|
|
148
|
+
}) {
|
|
149
|
+
const itemsData = await apiFetch("/api/items");
|
|
150
|
+
const items = itemsData.items as Array<{ item_id: string }>;
|
|
151
|
+
|
|
152
|
+
if (!items.length) return "No bank accounts connected.";
|
|
153
|
+
|
|
154
|
+
const targetItems = args.item_id
|
|
155
|
+
? items.filter((i) => i.item_id === args.item_id)
|
|
156
|
+
: items;
|
|
157
|
+
|
|
158
|
+
let allTransactions: Array<Record<string, unknown>> = [];
|
|
159
|
+
|
|
160
|
+
for (const item of targetItems) {
|
|
161
|
+
const syncData = await apiFetch("/api/sync", {
|
|
162
|
+
method: "POST",
|
|
163
|
+
body: JSON.stringify({ item_id: item.item_id }),
|
|
164
|
+
});
|
|
165
|
+
allTransactions.push(...(syncData.transactions ?? []));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Filter by date range
|
|
169
|
+
if (args.start_date) {
|
|
170
|
+
allTransactions = allTransactions.filter(
|
|
171
|
+
(t) => (t.date as string) >= args.start_date!
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (args.end_date) {
|
|
175
|
+
allTransactions = allTransactions.filter(
|
|
176
|
+
(t) => (t.date as string) <= args.end_date!
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!allTransactions.length) {
|
|
181
|
+
return "No transactions found in the given date range.";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Group and sum
|
|
185
|
+
const groupBy = args.group_by ?? "category";
|
|
186
|
+
const totals: Record<string, number> = {};
|
|
187
|
+
|
|
188
|
+
for (const t of allTransactions) {
|
|
189
|
+
const key =
|
|
190
|
+
groupBy === "merchant"
|
|
191
|
+
? ((t.merchant_name ?? t.description ?? "Unknown") as string)
|
|
192
|
+
: ((t.category_primary ?? "Uncategorized") as string);
|
|
193
|
+
totals[key] = (totals[key] ?? 0) + (t.amount as number);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Sort by spend descending
|
|
197
|
+
const sorted = Object.entries(totals).sort((a, b) => b[1] - a[1]);
|
|
198
|
+
|
|
199
|
+
const totalSpend = sorted.reduce((sum, [, v]) => sum + v, 0);
|
|
200
|
+
const dateRange =
|
|
201
|
+
args.start_date && args.end_date
|
|
202
|
+
? `${args.start_date} to ${args.end_date}`
|
|
203
|
+
: args.start_date
|
|
204
|
+
? `from ${args.start_date}`
|
|
205
|
+
: args.end_date
|
|
206
|
+
? `through ${args.end_date}`
|
|
207
|
+
: "all time";
|
|
208
|
+
|
|
209
|
+
const lines = sorted.map(
|
|
210
|
+
([key, amt]) =>
|
|
211
|
+
`${key.padEnd(40)} $${amt.toFixed(2).padStart(10)}`
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
return [
|
|
215
|
+
`Spending summary by ${groupBy} (${dateRange})`,
|
|
216
|
+
`Total: $${totalSpend.toFixed(2)}`,
|
|
217
|
+
"─".repeat(55),
|
|
218
|
+
...lines,
|
|
219
|
+
].join("\n");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── MCP Server ───────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
const server = new Server(
|
|
225
|
+
{ name: "sheetlink", version: "0.1.0" },
|
|
226
|
+
{ capabilities: { tools: {} } }
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
230
|
+
tools: [
|
|
231
|
+
{
|
|
232
|
+
name: "list_accounts",
|
|
233
|
+
description:
|
|
234
|
+
"List all bank accounts connected to SheetLink, including institution names and last sync time.",
|
|
235
|
+
inputSchema: {
|
|
236
|
+
type: "object",
|
|
237
|
+
properties: {},
|
|
238
|
+
required: [],
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
name: "list_transactions",
|
|
243
|
+
description:
|
|
244
|
+
"Fetch bank transactions from SheetLink. Optionally filter by account, date range, or spending category. Returns date, merchant, amount, and category for each transaction.",
|
|
245
|
+
inputSchema: {
|
|
246
|
+
type: "object",
|
|
247
|
+
properties: {
|
|
248
|
+
item_id: {
|
|
249
|
+
type: "string",
|
|
250
|
+
description:
|
|
251
|
+
"Filter to a specific bank account (item_id from list_accounts). Omit to fetch all accounts.",
|
|
252
|
+
},
|
|
253
|
+
start_date: {
|
|
254
|
+
type: "string",
|
|
255
|
+
description: "Start date filter in YYYY-MM-DD format (inclusive).",
|
|
256
|
+
},
|
|
257
|
+
end_date: {
|
|
258
|
+
type: "string",
|
|
259
|
+
description: "End date filter in YYYY-MM-DD format (inclusive).",
|
|
260
|
+
},
|
|
261
|
+
category: {
|
|
262
|
+
type: "string",
|
|
263
|
+
description:
|
|
264
|
+
"Filter by spending category (e.g. FOOD_AND_DRINK, TRANSPORTATION, SHOPPING). Partial match, case-insensitive.",
|
|
265
|
+
},
|
|
266
|
+
limit: {
|
|
267
|
+
type: "number",
|
|
268
|
+
description: "Maximum number of transactions to return (default: 100).",
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
required: [],
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
name: "get_spending_summary",
|
|
276
|
+
description:
|
|
277
|
+
"Get a spending summary aggregated by category or merchant for a given date range. Useful for answering questions like 'how much did I spend on food last month?' or 'what are my top spending categories?'",
|
|
278
|
+
inputSchema: {
|
|
279
|
+
type: "object",
|
|
280
|
+
properties: {
|
|
281
|
+
item_id: {
|
|
282
|
+
type: "string",
|
|
283
|
+
description:
|
|
284
|
+
"Limit summary to a specific bank account. Omit for all accounts.",
|
|
285
|
+
},
|
|
286
|
+
start_date: {
|
|
287
|
+
type: "string",
|
|
288
|
+
description: "Start date in YYYY-MM-DD format.",
|
|
289
|
+
},
|
|
290
|
+
end_date: {
|
|
291
|
+
type: "string",
|
|
292
|
+
description: "End date in YYYY-MM-DD format.",
|
|
293
|
+
},
|
|
294
|
+
group_by: {
|
|
295
|
+
type: "string",
|
|
296
|
+
enum: ["category", "merchant"],
|
|
297
|
+
description: "Group spending by category (default) or merchant.",
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
required: [],
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
],
|
|
304
|
+
}));
|
|
305
|
+
|
|
306
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
307
|
+
const { name, arguments: args = {} } = request.params;
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
let result: string;
|
|
311
|
+
|
|
312
|
+
if (name === "list_accounts") {
|
|
313
|
+
result = await listAccounts();
|
|
314
|
+
} else if (name === "list_transactions") {
|
|
315
|
+
result = await listTransactions(args as Parameters<typeof listTransactions>[0]);
|
|
316
|
+
} else if (name === "get_spending_summary") {
|
|
317
|
+
result = await getSpendingSummary(args as Parameters<typeof getSpendingSummary>[0]);
|
|
318
|
+
} else {
|
|
319
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return { content: [{ type: "text", text: result }] };
|
|
323
|
+
} catch (err) {
|
|
324
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
325
|
+
return {
|
|
326
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
327
|
+
isError: true,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const transport = new StdioServerTransport();
|
|
333
|
+
await server.connect(transport);
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"declaration": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"]
|
|
14
|
+
}
|