@pothos-wealth/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/dist/client.js ADDED
@@ -0,0 +1,46 @@
1
+ export class PothosApiError extends Error {
2
+ status;
3
+ constructor(status, message) {
4
+ super(message);
5
+ this.status = status;
6
+ this.name = "PothosApiError";
7
+ }
8
+ }
9
+ export async function apiFetch(path, options = {}) {
10
+ const BASE_URL = (process.env.POTHOS_URL ?? "").replace(/\/$/, "");
11
+ const API_KEY = process.env.POTHOS_API_KEY ?? "";
12
+ const url = `${BASE_URL}/api/v1${path}`;
13
+ const res = await fetch(url, {
14
+ ...options,
15
+ headers: {
16
+ "Content-Type": "application/json",
17
+ Authorization: `Bearer ${API_KEY}`,
18
+ ...options.headers,
19
+ },
20
+ });
21
+ if (!res.ok) {
22
+ let message = `Backend returned ${res.status}`;
23
+ try {
24
+ const body = (await res.json());
25
+ if (body?.error)
26
+ message = body.error;
27
+ }
28
+ catch {
29
+ // ignore parse error, use default message
30
+ }
31
+ throw new PothosApiError(res.status, message);
32
+ }
33
+ if (res.status === 204)
34
+ return undefined;
35
+ return res.json();
36
+ }
37
+ export function fmtAmount(minorUnits) {
38
+ return (minorUnits / 100).toFixed(2);
39
+ }
40
+ export function fmtDate(unixTs) {
41
+ return new Date(unixTs * 1000).toLocaleDateString("en-US", {
42
+ year: "numeric",
43
+ month: "short",
44
+ day: "numeric",
45
+ });
46
+ }
package/dist/index.js ADDED
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ import { config } from "dotenv";
3
+ import { fileURLToPath } from "url";
4
+ import { dirname, join } from "path";
5
+ config({ path: join(dirname(fileURLToPath(import.meta.url)), "../.env") });
6
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { registerAccountTools } from "./tools/accounts.js";
9
+ import { registerTransactionTools } from "./tools/transactions.js";
10
+ import { registerCategoryTools } from "./tools/categories.js";
11
+ import { registerBudgetTools } from "./tools/budgets.js";
12
+ import { registerReportTools } from "./tools/reports.js";
13
+ import { registerParseQueueTools } from "./tools/parseQueue.js";
14
+ if (!process.env.POTHOS_URL) {
15
+ process.stderr.write("Error: POTHOS_URL is required in .env\n");
16
+ process.exit(1);
17
+ }
18
+ if (!process.env.POTHOS_API_KEY) {
19
+ process.stderr.write("Error: POTHOS_API_KEY is required in .env\n");
20
+ process.exit(1);
21
+ }
22
+ const server = new McpServer({ name: "pothos", version: "0.1.0" }, {
23
+ instructions: `You are a personal finance assistant with full access to the user's Pothos financial data. Your job is to give clear, actionable financial insight — not just fetch data. Think like a financial advisor: interpret what you see, highlight what matters, and give the user something they can act on.
24
+
25
+ ## Data access
26
+ - Accounts & balances: get_accounts (includes net worth)
27
+ - Transactions: get_transactions (paginated, filterable by account/type/date)
28
+ - Categories: get_categories (resolve IDs and names — always call this before filtering by category or adding a transaction)
29
+ - Budgets: get_budgets (budget vs actual, with committed flag)
30
+ - Reports: get_spending_overview, get_category_breakdown, get_spending_trends
31
+ - Email inbox: get_pending_emails, submit_parsed_email, dismiss_email
32
+
33
+ ## How to answer questions
34
+ Don't just return raw data — synthesize it. When someone asks how they're doing, pull spending + budgets + trends and give a real answer. Spot what's over budget, what's trending up, where they're doing well. Surface the things they should care about, not everything at once.
35
+
36
+ Combine tools freely. A question like "am I on track this month?" warrants get_spending_overview + get_budgets. A question about a specific category warrants get_category_breakdown + get_transactions to show the actual items.
37
+
38
+ Be specific. Instead of "you spent a lot on food", say "you're at 87% of your Food budget with 12 days left in the month."
39
+
40
+ ## Key facts
41
+ - Committed budgets (marked [COMMITTED]) are hard obligations — rent, loan repayments, subscriptions. Even if the transaction hasn't posted yet, treat the full budgeted amount as a guaranteed upcoming expense. When assessing how much money the user has available, subtract any committed budget that hasn't been fully spent yet — that money is already spoken for.
42
+ - Non-committed budgets are flexible targets. The user can underspend them. Treat remaining balance there as genuinely available.
43
+ - Transfers are excluded from budgets and spending reports — they only move money between accounts.
44
+ - Account balances are always current (derived from all transactions).
45
+ - Category IDs are nanoids. Resolve them with get_categories whenever you need to match a name to an ID, or explain a category in a response.
46
+
47
+ ## Common flows
48
+ - "How am I doing?" → get_spending_overview + get_budgets for current month → give a summary with highlights (over budget, on track, noteworthy trends)
49
+ - Spending deep-dive → get_category_breakdown + get_transactions (filtered by category) → show what's driving spend
50
+ - Trend question → get_spending_trends → note direction, compare to prior months
51
+ - Log a transaction → get_accounts + get_categories (if needed) → add_transaction
52
+ - Process inbox → get_pending_emails → parse each email yourself → submit_parsed_email for transactions, dismiss_email for non-transactions. Nothing is created automatically — submitted items go to the user's inbox for approval.`,
53
+ });
54
+ registerAccountTools(server);
55
+ registerTransactionTools(server);
56
+ registerCategoryTools(server);
57
+ registerBudgetTools(server);
58
+ registerReportTools(server);
59
+ registerParseQueueTools(server);
60
+ const transport = new StdioServerTransport();
61
+ await server.connect(transport);
@@ -0,0 +1,22 @@
1
+ import { apiFetch, fmtAmount, PothosApiError } from "../client.js";
2
+ export function registerAccountTools(server) {
3
+ server.registerTool("get_accounts", {
4
+ description: "Get all accounts with their current balances and a net worth total. " +
5
+ "Closed accounts are included and marked [closed].",
6
+ }, async () => {
7
+ try {
8
+ const accounts = await apiFetch("/accounts?includeInactive=true");
9
+ const netWorth = accounts.reduce((sum, a) => sum + a.balance, 0);
10
+ const lines = accounts.map((a) => `- ${a.name} (${a.type}): ${fmtAmount(a.balance)}${a.isActive ? "" : " [closed]"} [id: ${a.id}]`);
11
+ lines.push(`\nNet worth: ${fmtAmount(netWorth)}`);
12
+ lines.push(`(${accounts.length} account${accounts.length !== 1 ? "s" : ""} total)`);
13
+ return { content: [{ type: "text", text: lines.join("\n") }] };
14
+ }
15
+ catch (err) {
16
+ const msg = err instanceof PothosApiError ? err.message : String(err);
17
+ return {
18
+ content: [{ type: "text", text: `Failed to fetch accounts: ${msg}` }],
19
+ };
20
+ }
21
+ });
22
+ }
@@ -0,0 +1,94 @@
1
+ import { z } from "zod";
2
+ import { apiFetch, fmtAmount, PothosApiError } from "../client.js";
3
+ const MONTHS = [
4
+ "January",
5
+ "February",
6
+ "March",
7
+ "April",
8
+ "May",
9
+ "June",
10
+ "July",
11
+ "August",
12
+ "September",
13
+ "October",
14
+ "November",
15
+ "December",
16
+ ];
17
+ export function registerBudgetTools(server) {
18
+ server.registerTool("get_budgets", {
19
+ description: "Get budget vs actual spending for a given month. " +
20
+ "Shows how much is budgeted, spent, and remaining per category. " +
21
+ "Defaults to the current month if not specified.",
22
+ inputSchema: {
23
+ month: z
24
+ .number()
25
+ .int()
26
+ .min(1)
27
+ .max(12)
28
+ .optional()
29
+ .describe("Month (1-12). Defaults to current month."),
30
+ year: z
31
+ .number()
32
+ .int()
33
+ .optional()
34
+ .describe("Year (e.g. 2026). Defaults to current year."),
35
+ },
36
+ }, async ({ month, year }) => {
37
+ try {
38
+ const now = new Date();
39
+ const m = month ?? now.getMonth() + 1;
40
+ const y = year ?? now.getFullYear();
41
+ const budgets = await apiFetch(`/budgets?month=${m}&year=${y}`);
42
+ if (budgets.length === 0) {
43
+ return {
44
+ content: [
45
+ {
46
+ type: "text",
47
+ text: `No budgets set for ${MONTHS[m - 1]} ${y}.`,
48
+ },
49
+ ],
50
+ };
51
+ }
52
+ const lines = [`Budgets for ${MONTHS[m - 1]} ${y}:`];
53
+ for (const b of budgets) {
54
+ const pct = b.amount > 0 ? Math.round((b.spent / b.amount) * 100) : 0;
55
+ const flags = [];
56
+ if (b.isCommitted)
57
+ flags.push("COMMITTED");
58
+ if (b.remaining < 0)
59
+ flags.push("OVER BUDGET");
60
+ else if (b.remaining === 0)
61
+ flags.push("AT LIMIT");
62
+ const status = flags.length ? ` [${flags.join(", ")}]` : "";
63
+ lines.push(`- ${b.categoryId}: spent ${fmtAmount(b.spent)} / budget ${fmtAmount(b.amount)} (${pct}%) — ${b.remaining >= 0 ? fmtAmount(b.remaining) + " remaining" : fmtAmount(Math.abs(b.remaining)) + " over budget"}${status}`);
64
+ }
65
+ const committedUnpaid = budgets
66
+ .filter((b) => b.isCommitted && b.spent < b.amount)
67
+ .map((b) => ({
68
+ categoryId: b.categoryId,
69
+ unpaid: fmtAmount(b.amount - b.spent),
70
+ }));
71
+ const totalCommittedUnpaid = budgets
72
+ .filter((b) => b.isCommitted && b.spent < b.amount)
73
+ .reduce((s, b) => s + (b.amount - b.spent), 0);
74
+ const totalFlexibleRemaining = budgets
75
+ .filter((b) => !b.isCommitted && b.remaining > 0)
76
+ .reduce((s, b) => s + b.remaining, 0);
77
+ return {
78
+ content: [{ type: "text", text: lines.join("\n") }],
79
+ structuredContent: {
80
+ committedUnpaid,
81
+ totalCommittedUnpaid: fmtAmount(totalCommittedUnpaid),
82
+ totalFlexibleRemaining: fmtAmount(totalFlexibleRemaining),
83
+ note: "totalCommittedUnpaid is money that will definitely leave the account this month. Subtract it from available balance when assessing financial health.",
84
+ },
85
+ };
86
+ }
87
+ catch (err) {
88
+ const msg = err instanceof PothosApiError ? err.message : String(err);
89
+ return {
90
+ content: [{ type: "text", text: `Failed to fetch budgets: ${msg}` }],
91
+ };
92
+ }
93
+ });
94
+ }
@@ -0,0 +1,37 @@
1
+ import { apiFetch, PothosApiError } from "../client.js";
2
+ export function registerCategoryTools(server) {
3
+ server.registerTool("get_categories", {
4
+ description: "List all available categories (global defaults and your custom ones), grouped by type. " +
5
+ "Use this to find valid category names when adding transactions.",
6
+ }, async () => {
7
+ try {
8
+ const categories = await apiFetch("/categories");
9
+ const grouped = { expense: [], income: [], neutral: [] };
10
+ for (const c of categories) {
11
+ grouped[c.type].push(c.icon ? `${c.name} ${c.icon} (id: ${c.id})` : `${c.name} (id: ${c.id})`);
12
+ }
13
+ const lines = [];
14
+ if (grouped.expense.length) {
15
+ lines.push("Expense categories:");
16
+ grouped.expense.forEach((c) => lines.push(` ${c}`));
17
+ }
18
+ if (grouped.income.length) {
19
+ lines.push("Income categories:");
20
+ grouped.income.forEach((c) => lines.push(` ${c}`));
21
+ }
22
+ if (grouped.neutral.length) {
23
+ lines.push("Neutral categories:");
24
+ grouped.neutral.forEach((c) => lines.push(` ${c}`));
25
+ }
26
+ return { content: [{ type: "text", text: lines.join("\n") }] };
27
+ }
28
+ catch (err) {
29
+ const msg = err instanceof PothosApiError ? err.message : String(err);
30
+ return {
31
+ content: [
32
+ { type: "text", text: `Failed to fetch categories: ${msg}` },
33
+ ],
34
+ };
35
+ }
36
+ });
37
+ }
@@ -0,0 +1,135 @@
1
+ import { z } from "zod";
2
+ import { apiFetch, fmtAmount, fmtDate, PothosApiError } from "../client.js";
3
+ export function registerParseQueueTools(server) {
4
+ server.registerTool("get_pending_emails", {
5
+ description: "Fetch raw emails waiting to be parsed into transactions. " +
6
+ "Returns the email content for you to interpret and extract transaction details from. " +
7
+ "After parsing, call submit_parsed_email with the result for each email.",
8
+ inputSchema: {
9
+ limit: z
10
+ .number()
11
+ .int()
12
+ .min(1)
13
+ .max(50)
14
+ .optional()
15
+ .default(10)
16
+ .describe("Max number of emails to return (default 10)"),
17
+ },
18
+ }, async ({ limit }) => {
19
+ try {
20
+ const pending = await apiFetch("/parse-queue?status=pending");
21
+ if (pending.length === 0) {
22
+ return {
23
+ content: [{ type: "text", text: "No pending emails." }],
24
+ };
25
+ }
26
+ const batch = pending.slice(0, limit ?? 10);
27
+ const remaining = pending.length - batch.length;
28
+ const lines = [
29
+ `${pending.length} pending email${pending.length !== 1 ? "s" : ""} (showing ${batch.length}):`,
30
+ ];
31
+ for (const msg of batch) {
32
+ lines.push(`\n--- Email ID: ${msg.id} ---`);
33
+ lines.push(`Subject: ${msg.subject ?? "(no subject)"}`);
34
+ lines.push(`Received: ${fmtDate(msg.createdAt)}`);
35
+ lines.push(`Body:\n${msg.rawContent}`);
36
+ }
37
+ if (remaining > 0) {
38
+ lines.push(`\n${remaining} more email${remaining !== 1 ? "s" : ""} not shown. Call again to get the next batch.`);
39
+ }
40
+ return { content: [{ type: "text", text: lines.join("\n") }] };
41
+ }
42
+ catch (err) {
43
+ const msg = err instanceof PothosApiError ? err.message : String(err);
44
+ return {
45
+ content: [
46
+ { type: "text", text: `Failed to fetch pending emails: ${msg}` },
47
+ ],
48
+ };
49
+ }
50
+ });
51
+ server.registerTool("submit_parsed_email", {
52
+ description: "Submit your parsed interpretation of a pending email. " +
53
+ "The result goes to your Pothos inbox for review before any transaction is created. " +
54
+ "Call get_accounts and get_categories first so you can supply accountId and categoryId. " +
55
+ "Use dismiss_email if the email is not a financial transaction.",
56
+ inputSchema: {
57
+ messageId: z.string().describe("ID of the pending email (from get_pending_emails)"),
58
+ type: z.enum(["income", "expense"]).describe("Transaction type"),
59
+ amount: z
60
+ .number()
61
+ .positive()
62
+ .describe("Transaction amount as a positive decimal (e.g. 45.50)"),
63
+ date: z.string().describe("Transaction date (YYYY-MM-DD)"),
64
+ description: z
65
+ .string()
66
+ .min(1)
67
+ .max(60)
68
+ .describe("Merchant name or transaction reference"),
69
+ accountId: z
70
+ .string()
71
+ .optional()
72
+ .describe("Account ID to associate with the transaction (from get_accounts)"),
73
+ categoryId: z
74
+ .string()
75
+ .optional()
76
+ .describe("Category ID to associate with the transaction (from get_categories)"),
77
+ notes: z.string().optional().describe("Optional notes"),
78
+ },
79
+ }, async ({ messageId, type, amount, date, description, accountId, categoryId, notes }) => {
80
+ try {
81
+ const minorUnits = Math.round(amount * 100);
82
+ const unixTs = Math.floor(new Date(date).getTime() / 1000);
83
+ await apiFetch(`/parse-queue/${messageId}/submit`, {
84
+ method: "POST",
85
+ body: JSON.stringify({
86
+ type,
87
+ amount: minorUnits,
88
+ date: unixTs,
89
+ description,
90
+ accountId: accountId ?? null,
91
+ categoryId: categoryId ?? null,
92
+ notes: notes ?? null,
93
+ bypassReview: false,
94
+ }),
95
+ });
96
+ return {
97
+ content: [
98
+ {
99
+ type: "text",
100
+ text: `Submitted: ${type} of ${fmtAmount(minorUnits)} on ${fmtDate(unixTs)} — "${description}".\n` +
101
+ `Review and approve it in your Pothos inbox.`,
102
+ },
103
+ ],
104
+ };
105
+ }
106
+ catch (err) {
107
+ const msg = err instanceof PothosApiError ? err.message : String(err);
108
+ return {
109
+ content: [
110
+ { type: "text", text: `Failed to submit parsed email: ${msg}` },
111
+ ],
112
+ };
113
+ }
114
+ });
115
+ server.registerTool("dismiss_email", {
116
+ description: "Dismiss a pending email that is not a financial transaction (e.g. newsletters, promotions). " +
117
+ "It will no longer appear in the pending queue.",
118
+ inputSchema: {
119
+ messageId: z.string().describe("ID of the pending email to dismiss"),
120
+ },
121
+ }, async ({ messageId }) => {
122
+ try {
123
+ await apiFetch(`/parse-queue/${messageId}/dismiss`, { method: "POST" });
124
+ return {
125
+ content: [{ type: "text", text: "Email dismissed." }],
126
+ };
127
+ }
128
+ catch (err) {
129
+ const msg = err instanceof PothosApiError ? err.message : String(err);
130
+ return {
131
+ content: [{ type: "text", text: `Failed to dismiss email: ${msg}` }],
132
+ };
133
+ }
134
+ });
135
+ }
@@ -0,0 +1,118 @@
1
+ import { z } from "zod";
2
+ import { apiFetch, fmtAmount, PothosApiError } from "../client.js";
3
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
4
+ export function registerReportTools(server) {
5
+ server.registerTool("get_spending_overview", {
6
+ description: "Get total income, expenses, net, and committed budget for a given month. " +
7
+ "Committed = unspent budget remaining (money set aside but not yet spent). " +
8
+ "Use this to assess true available funds. Defaults to the current month.",
9
+ inputSchema: {
10
+ month: z.number().int().min(1).max(12).optional().describe("Month (1-12)"),
11
+ year: z.number().int().optional().describe("Year (e.g. 2026)"),
12
+ },
13
+ }, async ({ month, year }) => {
14
+ try {
15
+ const now = new Date();
16
+ const m = month ?? now.getMonth() + 1;
17
+ const y = year ?? now.getFullYear();
18
+ const overview = await apiFetch(`/reports/overview?month=${m}&year=${y}`);
19
+ const monthName = new Date(y, m - 1).toLocaleDateString("en-US", {
20
+ month: "long",
21
+ year: "numeric",
22
+ });
23
+ const netSign = overview.net >= 0 ? "+" : "";
24
+ const text = `${monthName} Overview:\n` +
25
+ ` Income: ${fmtAmount(overview.income)}\n` +
26
+ ` Expenses: ${fmtAmount(Math.abs(overview.expenses))}\n` +
27
+ ` Net: ${netSign}${fmtAmount(overview.net)}\n` +
28
+ ` Committed: ${fmtAmount(overview.committed)} (budgeted but not yet spent this month)`;
29
+ return { content: [{ type: "text", text }] };
30
+ }
31
+ catch (err) {
32
+ const msg = err instanceof PothosApiError ? err.message : String(err);
33
+ return {
34
+ content: [{ type: "text", text: `Failed to fetch overview: ${msg}` }],
35
+ };
36
+ }
37
+ });
38
+ server.registerTool("get_category_breakdown", {
39
+ description: "Get expense breakdown by category for a given month, with percentages. " +
40
+ "Defaults to the current month.",
41
+ inputSchema: {
42
+ month: z.number().int().min(1).max(12).optional().describe("Month (1-12)"),
43
+ year: z.number().int().optional().describe("Year (e.g. 2026)"),
44
+ },
45
+ }, async ({ month, year }) => {
46
+ try {
47
+ const now = new Date();
48
+ const m = month ?? now.getMonth() + 1;
49
+ const y = year ?? now.getFullYear();
50
+ const breakdown = await apiFetch(`/reports/categories?month=${m}&year=${y}`);
51
+ const monthName = new Date(y, m - 1).toLocaleDateString("en-US", {
52
+ month: "long",
53
+ year: "numeric",
54
+ });
55
+ if (breakdown.length === 0) {
56
+ return {
57
+ content: [
58
+ {
59
+ type: "text",
60
+ text: `No expense data for ${monthName}.`,
61
+ },
62
+ ],
63
+ };
64
+ }
65
+ const totalExpenses = breakdown.reduce((sum, c) => sum + Math.abs(c.total), 0);
66
+ const lines = [`${monthName} — Expenses by Category:`];
67
+ for (const c of breakdown) {
68
+ const icon = c.categoryIcon ? ` ${c.categoryIcon}` : "";
69
+ const abs = Math.abs(c.total);
70
+ const pct = totalExpenses > 0 ? ((abs / totalExpenses) * 100).toFixed(1) : "0.0";
71
+ lines.push(` ${c.categoryName}${icon}: ${fmtAmount(abs)} (${pct}%)`);
72
+ }
73
+ lines.push(` Total expenses: ${fmtAmount(totalExpenses)}`);
74
+ return { content: [{ type: "text", text: lines.join("\n") }] };
75
+ }
76
+ catch (err) {
77
+ const msg = err instanceof PothosApiError ? err.message : String(err);
78
+ return {
79
+ content: [
80
+ {
81
+ type: "text",
82
+ text: `Failed to fetch category breakdown: ${msg}`,
83
+ },
84
+ ],
85
+ };
86
+ }
87
+ });
88
+ server.registerTool("get_spending_trends", {
89
+ description: "Get monthly income, expenses, and net over the last N months. Useful for spotting trends.",
90
+ inputSchema: {
91
+ months: z
92
+ .number()
93
+ .int()
94
+ .min(1)
95
+ .max(24)
96
+ .optional()
97
+ .default(6)
98
+ .describe("Number of months to look back (1-24, default 6)"),
99
+ },
100
+ }, async ({ months }) => {
101
+ try {
102
+ const n = months ?? 6;
103
+ const trends = await apiFetch(`/reports/trends?months=${n}`);
104
+ const lines = [`Spending trends (last ${n} months):`];
105
+ for (const t of trends) {
106
+ const netSign = t.net >= 0 ? "+" : "";
107
+ lines.push(` ${MONTHS[t.month - 1]} ${t.year}: income ${fmtAmount(t.income)} | expenses ${fmtAmount(Math.abs(t.expenses))} | net ${netSign}${fmtAmount(t.net)}`);
108
+ }
109
+ return { content: [{ type: "text", text: lines.join("\n") }] };
110
+ }
111
+ catch (err) {
112
+ const msg = err instanceof PothosApiError ? err.message : String(err);
113
+ return {
114
+ content: [{ type: "text", text: `Failed to fetch trends: ${msg}` }],
115
+ };
116
+ }
117
+ });
118
+ }
@@ -0,0 +1,162 @@
1
+ import { z } from "zod";
2
+ import { apiFetch, fmtAmount, fmtDate, PothosApiError } from "../client.js";
3
+ export function registerTransactionTools(server) {
4
+ server.registerTool("get_transactions", {
5
+ description: "List transactions with optional filters. Returns paginated results with amounts, dates, and categories.",
6
+ inputSchema: {
7
+ accountId: z.string().optional().describe("Filter by account ID"),
8
+ type: z
9
+ .enum(["income", "expense", "transfer"])
10
+ .optional()
11
+ .describe("Filter by transaction type"),
12
+ startDate: z.string().optional().describe("Start date filter (YYYY-MM-DD)"),
13
+ endDate: z.string().optional().describe("End date filter (YYYY-MM-DD)"),
14
+ limit: z
15
+ .number()
16
+ .int()
17
+ .min(1)
18
+ .max(100)
19
+ .optional()
20
+ .default(20)
21
+ .describe("Number of results to return (max 100)"),
22
+ page: z.number().int().min(1).optional().default(1).describe("Page number"),
23
+ },
24
+ }, async ({ accountId, type, startDate, endDate, limit, page }) => {
25
+ try {
26
+ const params = new URLSearchParams();
27
+ if (accountId)
28
+ params.set("accountId", accountId);
29
+ if (type)
30
+ params.set("type", type);
31
+ if (startDate)
32
+ params.set("startDate", String(Math.floor(new Date(startDate).getTime() / 1000)));
33
+ if (endDate)
34
+ params.set("endDate", String(Math.floor(new Date(endDate).getTime() / 1000)));
35
+ params.set("limit", String(limit ?? 20));
36
+ params.set("page", String(page ?? 1));
37
+ const [result, accounts] = await Promise.all([
38
+ apiFetch(`/transactions?${params}`),
39
+ apiFetch("/accounts?includeInactive=true"),
40
+ ]);
41
+ const accountMap = Object.fromEntries(accounts.map((a) => [a.id, a.name]));
42
+ if (result.data.length === 0) {
43
+ return { content: [{ type: "text", text: "No transactions found." }] };
44
+ }
45
+ const lines = [
46
+ `Found ${result.total} transaction${result.total !== 1 ? "s" : ""} (page ${result.page} of ${result.totalPages}):`,
47
+ ];
48
+ for (const t of result.data) {
49
+ const account = accountMap[t.accountId] ?? t.accountId;
50
+ const sign = t.amount > 0 ? "+" : "";
51
+ const parts = [
52
+ fmtDate(t.date),
53
+ t.description,
54
+ `${t.type} ${sign}${fmtAmount(t.amount)}`,
55
+ `[${account}]`,
56
+ ];
57
+ if (t.categoryId)
58
+ parts.push(`[category: ${t.categoryId}]`);
59
+ lines.push(`- ${parts.join(" | ")}`);
60
+ }
61
+ return { content: [{ type: "text", text: lines.join("\n") }] };
62
+ }
63
+ catch (err) {
64
+ const msg = err instanceof PothosApiError ? err.message : String(err);
65
+ return {
66
+ content: [
67
+ { type: "text", text: `Failed to fetch transactions: ${msg}` },
68
+ ],
69
+ };
70
+ }
71
+ });
72
+ server.registerTool("add_transaction", {
73
+ description: "Create a new income or expense transaction. " +
74
+ "Use get_accounts to find account IDs and get_categories to find category IDs.",
75
+ inputSchema: {
76
+ accountId: z.string().describe("ID of the account"),
77
+ type: z.enum(["income", "expense"]).describe("Transaction type"),
78
+ amount: z.number().positive().describe("Amount as a positive decimal (e.g. 45.50)"),
79
+ date: z.string().describe("Transaction date (YYYY-MM-DD)"),
80
+ description: z.string().min(1).describe("Merchant name or description"),
81
+ categoryId: z.string().optional().describe("Category ID (optional)"),
82
+ notes: z.string().optional().describe("Additional notes (optional)"),
83
+ },
84
+ }, async ({ accountId, type, amount, date, description, categoryId, notes }) => {
85
+ try {
86
+ const minorUnits = Math.round(amount * 100);
87
+ const unixTs = Math.floor(new Date(date).getTime() / 1000);
88
+ const tx = await apiFetch("/transactions", {
89
+ method: "POST",
90
+ body: JSON.stringify({
91
+ accountId,
92
+ type,
93
+ amount: minorUnits,
94
+ date: unixTs,
95
+ description,
96
+ categoryId: categoryId ?? null,
97
+ notes: notes ?? null,
98
+ }),
99
+ });
100
+ return {
101
+ content: [
102
+ {
103
+ type: "text",
104
+ text: `Transaction created: "${tx.description}" — ${tx.type} — ${fmtAmount(Math.abs(tx.amount))} on ${fmtDate(tx.date)}.\n` +
105
+ `Transaction ID: ${tx.id}`,
106
+ },
107
+ ],
108
+ };
109
+ }
110
+ catch (err) {
111
+ const msg = err instanceof PothosApiError ? err.message : String(err);
112
+ return {
113
+ content: [
114
+ { type: "text", text: `Failed to create transaction: ${msg}` },
115
+ ],
116
+ };
117
+ }
118
+ });
119
+ server.registerTool("add_transfer", {
120
+ description: "Transfer money between two accounts. Creates a linked pair of transactions. " +
121
+ "Use get_accounts to find account IDs.",
122
+ inputSchema: {
123
+ fromAccountId: z.string().describe("ID of the source account"),
124
+ toAccountId: z.string().describe("ID of the destination account"),
125
+ amount: z
126
+ .number()
127
+ .positive()
128
+ .describe("Amount to transfer as a positive decimal (e.g. 500.00)"),
129
+ date: z.string().describe("Transfer date (YYYY-MM-DD)"),
130
+ description: z.string().min(1).describe("Description or note for the transfer"),
131
+ },
132
+ }, async ({ fromAccountId, toAccountId, amount, date, description }) => {
133
+ try {
134
+ const minorUnits = Math.round(amount * 100);
135
+ const unixTs = Math.floor(new Date(date).getTime() / 1000);
136
+ await apiFetch("/transactions/transfer", {
137
+ method: "POST",
138
+ body: JSON.stringify({
139
+ fromAccountId,
140
+ toAccountId,
141
+ amount: minorUnits,
142
+ date: unixTs,
143
+ description,
144
+ }),
145
+ });
146
+ return {
147
+ content: [
148
+ {
149
+ type: "text",
150
+ text: `Transfer of ${fmtAmount(minorUnits)} on ${new Date(date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} created successfully.`,
151
+ },
152
+ ],
153
+ };
154
+ }
155
+ catch (err) {
156
+ const msg = err instanceof PothosApiError ? err.message : String(err);
157
+ return {
158
+ content: [{ type: "text", text: `Failed to create transfer: ${msg}` }],
159
+ };
160
+ }
161
+ });
162
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@pothos-wealth/mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for Pothos - natural language access to your budgets, transactions, and email parsing",
5
+ "type": "module",
6
+ "license": "AGPL-3.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/pothos-wealth/pothos.git",
10
+ "directory": "mcp"
11
+ },
12
+ "homepage": "https://github.com/pothos-wealth/pothos#mcp-server",
13
+ "keywords": [
14
+ "mcp",
15
+ "finance",
16
+ "budget",
17
+ "personal-finance",
18
+ "pothos"
19
+ ],
20
+ "bin": {
21
+ "pothos-mcp": "dist/index.js"
22
+ },
23
+ "files": [
24
+ "dist/client.js",
25
+ "dist/index.js",
26
+ "dist/tools"
27
+ ],
28
+ "scripts": {
29
+ "dev": "tsx watch src/index.ts",
30
+ "build": "tsc",
31
+ "start": "node dist/index.js",
32
+ "format": "npx prettier --write \"src/**/*.ts\""
33
+ },
34
+ "dependencies": {
35
+ "@modelcontextprotocol/sdk": "^1.0.0",
36
+ "dotenv": "^16.4.5",
37
+ "zod": "^3.23.8"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^22.0.0",
41
+ "typescript-eslint": "^8.0.0",
42
+ "eslint": "^9.0.0",
43
+ "tsx": "^4.19.1",
44
+ "typescript": "^5.6.0"
45
+ }
46
+ }