whooing-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/.env.example ADDED
@@ -0,0 +1,8 @@
1
+ # Whooing API credentials
2
+ # Get these from https://whooing.com → Settings → API
3
+ WHOOING_APP_ID=3
4
+ WHOOING_TOKEN=your_token_here
5
+ WHOOING_SIGNATURE=your_signature_here
6
+
7
+ # Default section (가계부) ID
8
+ WHOOING_SECTION_ID=your_section_id_here
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 JM Jeong
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,139 @@
1
+ # whooing-mcp
2
+
3
+ MCP server for [Whooing (후잉)](https://whooing.com) personal finance — read-only queries for spending, transactions, balance sheets, and accounts.
4
+
5
+ ## Setup
6
+
7
+ ### 1. Get API Credentials
8
+
9
+ 1. Go to [Whooing App Settings](https://whooing.com/#main/setting/app)
10
+ 2. Note your `app_id`, `token`, and `signature`
11
+ 3. Find your `section_id` from the API or URL
12
+
13
+ ### 2. Install
14
+
15
+ ```bash
16
+ git clone https://github.com/jmjeong/whooing-mcp.git
17
+ cd whooing-mcp
18
+ npm install
19
+ npm run build
20
+ ```
21
+
22
+ ### 3. Configure Environment
23
+
24
+ ```bash
25
+ export WHOOING_APP_ID=3
26
+ export WHOOING_TOKEN=your_token
27
+ export WHOOING_SIGNATURE=your_signature
28
+ export WHOOING_SECTION_ID=your_section_id
29
+ ```
30
+
31
+ Or create a `.env` file (see `.env.example`).
32
+
33
+ ## Usage
34
+
35
+ ### stdio mode (Claude Code, Claude Desktop)
36
+
37
+ ```bash
38
+ node dist/cli.js
39
+ ```
40
+
41
+ ### HTTP mode (daemon)
42
+
43
+ ```bash
44
+ node dist/cli.js --http --port 8182
45
+ ```
46
+
47
+ ### Claude Code config (`~/.mcp.json`)
48
+
49
+ ```json
50
+ {
51
+ "mcpServers": {
52
+ "whooing": {
53
+ "command": "node",
54
+ "args": ["/path/to/whooing-mcp/dist/cli.js"],
55
+ "env": {
56
+ "WHOOING_APP_ID": "3",
57
+ "WHOOING_TOKEN": "...",
58
+ "WHOOING_SIGNATURE": "...",
59
+ "WHOOING_SECTION_ID": "..."
60
+ }
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ ### Claude Desktop config
67
+
68
+ ```json
69
+ {
70
+ "mcpServers": {
71
+ "whooing": {
72
+ "command": "node",
73
+ "args": ["/path/to/whooing-mcp/dist/cli.js"],
74
+ "env": {
75
+ "WHOOING_APP_ID": "3",
76
+ "WHOOING_TOKEN": "...",
77
+ "WHOOING_SIGNATURE": "...",
78
+ "WHOOING_SECTION_ID": "..."
79
+ }
80
+ }
81
+ }
82
+ }
83
+ ```
84
+
85
+ ## Tools
86
+
87
+ | Tool | Description | Parameters |
88
+ |------|-------------|------------|
89
+ | `whooing_pl` | Profit & loss (spending/income by category) | `start_date?`, `end_date?`, `section_id?` |
90
+ | `whooing_entries` | Transaction list with account names | `start_date?`, `end_date?`, `limit?`, `section_id?` |
91
+ | `whooing_balance` | Balance sheet (assets, liabilities, capital) | `start_date?`, `end_date?`, `section_id?` |
92
+ | `whooing_accounts` | Full account list | `section_id?` |
93
+ | `whooing_sections` | List all sections (가계부) | (none) |
94
+
95
+ - Dates use `YYYYMMDD` format. Default: current month (1st to today).
96
+ - `section_id` defaults to `WHOOING_SECTION_ID` env var.
97
+ - All tools are read-only.
98
+
99
+ ## Running as a daemon (macOS launchd)
100
+
101
+ Create `~/Library/LaunchAgents/com.whooing.mcp.plist`:
102
+
103
+ ```xml
104
+ <?xml version="1.0" encoding="UTF-8"?>
105
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
106
+ <plist version="1.0">
107
+ <dict>
108
+ <key>Label</key><string>com.whooing.mcp</string>
109
+ <key>ProgramArguments</key>
110
+ <array>
111
+ <string>/opt/homebrew/bin/node</string>
112
+ <string>/path/to/whooing-mcp/dist/cli.js</string>
113
+ <string>--http</string>
114
+ <string>--port</string>
115
+ <string>8182</string>
116
+ </array>
117
+ <key>EnvironmentVariables</key>
118
+ <dict>
119
+ <key>WHOOING_APP_ID</key><string>3</string>
120
+ <key>WHOOING_TOKEN</key><string>YOUR_TOKEN</string>
121
+ <key>WHOOING_SIGNATURE</key><string>YOUR_SIGNATURE</string>
122
+ <key>WHOOING_SECTION_ID</key><string>YOUR_SECTION_ID</string>
123
+ </dict>
124
+ <key>KeepAlive</key><true/>
125
+ <key>RunAtLoad</key><true/>
126
+ <key>StandardOutPath</key><string>/tmp/whooing-mcp.log</string>
127
+ <key>StandardErrorPath</key><string>/tmp/whooing-mcp.err</string>
128
+ </dict>
129
+ </plist>
130
+ ```
131
+
132
+ ```bash
133
+ chmod 600 ~/Library/LaunchAgents/com.whooing.mcp.plist
134
+ launchctl load ~/Library/LaunchAgents/com.whooing.mcp.plist
135
+ ```
136
+
137
+ ## License
138
+
139
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+ import dotenv from "dotenv";
3
+ import { WhooingClient } from "./whooing-client.js";
4
+ import { createWhooingMcpServer } from "./server.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
+ import { createServer } from "node:http";
8
+ dotenv.config();
9
+ function getRequiredEnv(name) {
10
+ const value = process.env[name];
11
+ if (!value) {
12
+ console.error(`Error: Missing required environment variable: ${name}`);
13
+ process.exit(1);
14
+ }
15
+ return value;
16
+ }
17
+ function parseArgs() {
18
+ const args = process.argv.slice(2);
19
+ let http = false;
20
+ let port = 8182;
21
+ for (let i = 0; i < args.length; i++) {
22
+ if (args[i] === "--http") {
23
+ http = true;
24
+ }
25
+ else if (args[i] === "--port" && i + 1 < args.length) {
26
+ port = parseInt(args[i + 1], 10);
27
+ if (isNaN(port)) {
28
+ console.error(`Error: Invalid port number: ${args[i + 1]}`);
29
+ process.exit(1);
30
+ }
31
+ i++;
32
+ }
33
+ }
34
+ return { http, port };
35
+ }
36
+ function getConfig() {
37
+ return {
38
+ appId: getRequiredEnv("WHOOING_APP_ID"),
39
+ token: getRequiredEnv("WHOOING_TOKEN"),
40
+ signature: getRequiredEnv("WHOOING_SIGNATURE"),
41
+ defaultSectionId: process.env.WHOOING_SECTION_ID ?? "",
42
+ };
43
+ }
44
+ async function main() {
45
+ const config = getConfig();
46
+ const { http, port } = parseArgs();
47
+ if (http) {
48
+ await startHttpServer(config, port);
49
+ }
50
+ else {
51
+ const client = new WhooingClient(config);
52
+ if (config.defaultSectionId) {
53
+ try {
54
+ await client.loadAccounts(config.defaultSectionId);
55
+ }
56
+ catch (e) {
57
+ console.error("Warning: Failed to pre-load accounts:", e);
58
+ }
59
+ }
60
+ const server = createWhooingMcpServer(client);
61
+ const transport = new StdioServerTransport();
62
+ await server.connect(transport);
63
+ }
64
+ }
65
+ async function startHttpServer(config, port) {
66
+ const sessions = new Map();
67
+ function createSession() {
68
+ const client = new WhooingClient(config);
69
+ const server = createWhooingMcpServer(client);
70
+ return { server, client };
71
+ }
72
+ const httpServer = createServer(async (req, res) => {
73
+ const url = new URL(req.url ?? "/", `http://localhost:${port}`);
74
+ // Health check
75
+ if (url.pathname === "/health") {
76
+ res.writeHead(200, { "Content-Type": "application/json" });
77
+ res.end(JSON.stringify({ status: "ok" }));
78
+ return;
79
+ }
80
+ if (url.pathname === "/mcp") {
81
+ const sessionId = req.headers["mcp-session-id"];
82
+ try {
83
+ if (req.method === "POST") {
84
+ const body = await collectBody(req);
85
+ const parsed = JSON.parse(body);
86
+ if (sessionId && sessions.has(sessionId)) {
87
+ const entry = sessions.get(sessionId);
88
+ await entry.transport.handleRequest(req, res, parsed);
89
+ }
90
+ else {
91
+ // New session — create fresh server + transport
92
+ const { server, client } = createSession();
93
+ const transport = new StreamableHTTPServerTransport({
94
+ sessionIdGenerator: () => crypto.randomUUID(),
95
+ });
96
+ transport.onclose = () => {
97
+ if (transport.sessionId) {
98
+ sessions.delete(transport.sessionId);
99
+ }
100
+ };
101
+ await server.connect(transport);
102
+ // Pre-load accounts in background (don't block the response)
103
+ if (config.defaultSectionId) {
104
+ client.loadAccounts(config.defaultSectionId).catch((e) => {
105
+ console.error("Warning: Failed to pre-load accounts:", e);
106
+ });
107
+ }
108
+ await transport.handleRequest(req, res, parsed);
109
+ if (transport.sessionId) {
110
+ sessions.set(transport.sessionId, { transport, server });
111
+ }
112
+ }
113
+ return;
114
+ }
115
+ if (req.method === "GET") {
116
+ if (sessionId && sessions.has(sessionId)) {
117
+ const entry = sessions.get(sessionId);
118
+ await entry.transport.handleRequest(req, res);
119
+ }
120
+ else {
121
+ res.writeHead(400, { "Content-Type": "application/json" });
122
+ res.end(JSON.stringify({ error: "No valid session" }));
123
+ }
124
+ return;
125
+ }
126
+ if (req.method === "DELETE") {
127
+ if (sessionId && sessions.has(sessionId)) {
128
+ const entry = sessions.get(sessionId);
129
+ await entry.transport.handleRequest(req, res);
130
+ sessions.delete(sessionId);
131
+ }
132
+ else {
133
+ res.writeHead(400, { "Content-Type": "application/json" });
134
+ res.end(JSON.stringify({ error: "No valid session" }));
135
+ }
136
+ return;
137
+ }
138
+ }
139
+ catch (err) {
140
+ console.error("Request error:", err);
141
+ if (!res.headersSent) {
142
+ res.writeHead(500, { "Content-Type": "application/json" });
143
+ res.end(JSON.stringify({ error: "Internal server error" }));
144
+ }
145
+ return;
146
+ }
147
+ }
148
+ res.writeHead(404, { "Content-Type": "application/json" });
149
+ res.end(JSON.stringify({ error: "Not found" }));
150
+ });
151
+ httpServer.listen(port, "0.0.0.0", () => {
152
+ console.error(`whooing-mcp HTTP server listening on http://0.0.0.0:${port}/mcp`);
153
+ console.error(`Health check: http://localhost:${port}/health`);
154
+ });
155
+ }
156
+ function collectBody(req) {
157
+ return new Promise((resolve, reject) => {
158
+ const chunks = [];
159
+ req.on("data", (chunk) => chunks.push(chunk));
160
+ req.on("end", () => resolve(Buffer.concat(chunks).toString()));
161
+ req.on("error", reject);
162
+ });
163
+ }
164
+ main().catch((err) => {
165
+ console.error("Fatal error:", err);
166
+ process.exit(1);
167
+ });
@@ -0,0 +1,67 @@
1
+ import type { AccountInfo } from "./whooing-client.js";
2
+ interface AccountEntry {
3
+ account_id: string;
4
+ money: number;
5
+ }
6
+ interface CategoryGroup {
7
+ total: number;
8
+ accounts: AccountEntry[];
9
+ }
10
+ interface PLResults {
11
+ expenses?: CategoryGroup;
12
+ income?: CategoryGroup;
13
+ net_income?: {
14
+ total: number;
15
+ };
16
+ }
17
+ export declare function formatPL(results: PLResults, accounts: Map<string, AccountInfo>, startDate: string, endDate: string): string;
18
+ interface EntryItem {
19
+ entry_id: number;
20
+ entry_date: string;
21
+ l_account: string;
22
+ l_account_id: string;
23
+ r_account: string;
24
+ r_account_id: string;
25
+ item: string;
26
+ money: number;
27
+ memo: string;
28
+ }
29
+ interface EntryResults {
30
+ rows?: EntryItem[];
31
+ }
32
+ export declare function formatEntries(results: EntryResults, accounts: Map<string, AccountInfo>): string;
33
+ interface BSResults {
34
+ assets?: CategoryGroup;
35
+ liabilities?: CategoryGroup;
36
+ capital?: CategoryGroup;
37
+ }
38
+ export declare function formatBalance(results: BSResults, accounts: Map<string, AccountInfo>, startDate: string, endDate: string): string;
39
+ interface BudgetItem {
40
+ account_id: string;
41
+ budget: number;
42
+ money: number;
43
+ }
44
+ type BudgetResults = {
45
+ expenses?: {
46
+ accounts: BudgetItem[];
47
+ };
48
+ } | BudgetItem[];
49
+ export declare function formatBudget(results: BudgetResults, accounts: Map<string, AccountInfo>, startDate: string, endDate: string): string;
50
+ interface AccountItem {
51
+ account_id: string;
52
+ title: string;
53
+ type: string;
54
+ memo?: string;
55
+ open_date?: string;
56
+ close_date?: string;
57
+ category?: string;
58
+ }
59
+ export declare function formatAccounts(results: Record<string, AccountItem[]>): string;
60
+ interface SectionItem {
61
+ section_id: string;
62
+ title: string;
63
+ memo?: string;
64
+ currency?: string;
65
+ }
66
+ export declare function formatSections(results: SectionItem[]): string;
67
+ export {};
@@ -0,0 +1,154 @@
1
+ function formatAmount(amount) {
2
+ return amount.toLocaleString("ko-KR") + "원";
3
+ }
4
+ export function formatPL(results, accounts, startDate, endDate) {
5
+ const lines = [];
6
+ lines.push(`## 손익 (${startDate} ~ ${endDate})`);
7
+ lines.push("");
8
+ // Expenses
9
+ const expenseAccounts = (results.expenses?.accounts ?? [])
10
+ .filter((item) => item.money > 0)
11
+ .sort((a, b) => b.money - a.money);
12
+ if (expenseAccounts.length > 0) {
13
+ lines.push(`### 지출: ${formatAmount(results.expenses?.total ?? 0)}`);
14
+ for (const item of expenseAccounts) {
15
+ const name = accounts.get(item.account_id)?.name ?? item.account_id;
16
+ lines.push(`- ${name}: ${formatAmount(item.money)}`);
17
+ }
18
+ lines.push("");
19
+ }
20
+ // Income
21
+ const incomeAccounts = (results.income?.accounts ?? [])
22
+ .filter((item) => item.money > 0)
23
+ .sort((a, b) => b.money - a.money);
24
+ if (incomeAccounts.length > 0) {
25
+ lines.push(`### 수입: ${formatAmount(results.income?.total ?? 0)}`);
26
+ for (const item of incomeAccounts) {
27
+ const name = accounts.get(item.account_id)?.name ?? item.account_id;
28
+ lines.push(`- ${name}: ${formatAmount(item.money)}`);
29
+ }
30
+ lines.push("");
31
+ }
32
+ if (results.net_income) {
33
+ lines.push(`### 순이익: ${formatAmount(results.net_income.total)}`);
34
+ }
35
+ if (expenseAccounts.length === 0 && incomeAccounts.length === 0) {
36
+ lines.push("해당 기간에 데이터가 없습니다.");
37
+ }
38
+ return lines.join("\n");
39
+ }
40
+ export function formatEntries(results, accounts) {
41
+ const rows = results.rows ?? [];
42
+ if (rows.length === 0) {
43
+ return "해당 기간에 거래 내역이 없습니다.";
44
+ }
45
+ const lines = [];
46
+ lines.push(`## 거래 내역 (${rows.length}건)`);
47
+ lines.push("");
48
+ for (const row of rows) {
49
+ const date = formatDate(row.entry_date);
50
+ const lName = accounts.get(row.l_account_id)?.name ?? row.l_account_id;
51
+ const rName = accounts.get(row.r_account_id)?.name ?? row.r_account_id;
52
+ const item = row.item || "(항목 없음)";
53
+ const memo = row.memo ? ` — ${row.memo}` : "";
54
+ lines.push(`- **${date}** ${item} ${formatAmount(row.money)} [${lName} ← ${rName}]${memo}`);
55
+ }
56
+ return lines.join("\n");
57
+ }
58
+ function formatDate(dateStr) {
59
+ // Handle YYYYMMDD or YYYYMMDD.NNNN format
60
+ const base = dateStr.split(".")[0];
61
+ if (base.length === 8) {
62
+ return `${base.slice(0, 4)}-${base.slice(4, 6)}-${base.slice(6, 8)}`;
63
+ }
64
+ return dateStr;
65
+ }
66
+ export function formatBalance(results, accounts, startDate, endDate) {
67
+ const lines = [];
68
+ lines.push(`## 자산/부채 현황 (${startDate} ~ ${endDate})`);
69
+ lines.push("");
70
+ const sections = [
71
+ ["자산", results.assets],
72
+ ["부채", results.liabilities],
73
+ ["자본", results.capital],
74
+ ];
75
+ for (const [title, group] of sections) {
76
+ const filtered = (group?.accounts ?? [])
77
+ .filter((item) => item.money !== 0)
78
+ .sort((a, b) => Math.abs(b.money) - Math.abs(a.money));
79
+ if (filtered.length > 0) {
80
+ lines.push(`### ${title}: ${formatAmount(group?.total ?? 0)}`);
81
+ for (const item of filtered) {
82
+ const name = accounts.get(item.account_id)?.name ?? item.account_id;
83
+ lines.push(`- ${name}: ${formatAmount(item.money)}`);
84
+ }
85
+ lines.push("");
86
+ }
87
+ }
88
+ return lines.join("\n");
89
+ }
90
+ export function formatBudget(results, accounts, startDate, endDate) {
91
+ const lines = [];
92
+ lines.push(`## 예산 현황 (${startDate} ~ ${endDate})`);
93
+ lines.push("");
94
+ let budgetItems = [];
95
+ if (Array.isArray(results)) {
96
+ // Empty array — no budgets
97
+ }
98
+ else if (results.expenses?.accounts) {
99
+ budgetItems = results.expenses.accounts;
100
+ }
101
+ const items = budgetItems
102
+ .filter((item) => item.budget > 0 || item.money > 0)
103
+ .sort((a, b) => b.money - a.money);
104
+ if (items.length === 0) {
105
+ lines.push("설정된 예산이 없습니다.");
106
+ return lines.join("\n");
107
+ }
108
+ for (const item of items) {
109
+ const name = accounts.get(item.account_id)?.name ?? item.account_id;
110
+ const pct = item.budget > 0 ? Math.round((item.money / item.budget) * 100) : 0;
111
+ const status = pct > 100 ? " (초과!)" : "";
112
+ lines.push(`- ${name}: ${formatAmount(item.money)} / ${formatAmount(item.budget)} (${pct}%)${status}`);
113
+ }
114
+ return lines.join("\n");
115
+ }
116
+ export function formatAccounts(results) {
117
+ const lines = [];
118
+ lines.push("## 계정 목록");
119
+ lines.push("");
120
+ const typeNames = {
121
+ assets: "자산",
122
+ liabilities: "부채",
123
+ capital: "자본",
124
+ income: "수입",
125
+ expenses: "지출",
126
+ };
127
+ for (const [type, accounts] of Object.entries(results)) {
128
+ if (!Array.isArray(accounts) || accounts.length === 0)
129
+ continue;
130
+ const typeName = typeNames[type] ?? type;
131
+ lines.push(`### ${typeName}`);
132
+ for (const acc of accounts) {
133
+ const memo = acc.memo ? ` (${acc.memo})` : "";
134
+ lines.push(`- ${acc.account_id}: ${acc.title}${memo}`);
135
+ }
136
+ lines.push("");
137
+ }
138
+ return lines.join("\n");
139
+ }
140
+ export function formatSections(results) {
141
+ const lines = [];
142
+ lines.push("## 가계부 (Section) 목록");
143
+ lines.push("");
144
+ if (!Array.isArray(results) || results.length === 0) {
145
+ lines.push("가계부가 없습니다.");
146
+ return lines.join("\n");
147
+ }
148
+ for (const section of results) {
149
+ const currency = section.currency ? ` [${section.currency}]` : "";
150
+ const memo = section.memo ? ` — ${section.memo}` : "";
151
+ lines.push(`- ${section.section_id}: ${section.title}${currency}${memo}`);
152
+ }
153
+ return lines.join("\n");
154
+ }
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { WhooingClient } from "./whooing-client.js";
3
+ export declare function createWhooingMcpServer(client: WhooingClient): McpServer;
package/dist/server.js ADDED
@@ -0,0 +1,141 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { formatPL, formatEntries, formatBalance, formatAccounts, formatSections, } from "./formatters.js";
4
+ function getDateDefaults() {
5
+ const now = new Date();
6
+ const y = now.getFullYear();
7
+ const m = String(now.getMonth() + 1).padStart(2, "0");
8
+ const d = String(now.getDate()).padStart(2, "0");
9
+ return {
10
+ startDate: `${y}${m}01`,
11
+ endDate: `${y}${m}${d}`,
12
+ };
13
+ }
14
+ const dateRangeSchema = {
15
+ start_date: z
16
+ .string()
17
+ .regex(/^\d{8}$/)
18
+ .optional()
19
+ .describe("Start date (YYYYMMDD). Defaults to 1st of current month."),
20
+ end_date: z
21
+ .string()
22
+ .regex(/^\d{8}$/)
23
+ .optional()
24
+ .describe("End date (YYYYMMDD). Defaults to today."),
25
+ section_id: z
26
+ .string()
27
+ .optional()
28
+ .describe("Section ID. Defaults to WHOOING_SECTION_ID env var."),
29
+ };
30
+ export function createWhooingMcpServer(client) {
31
+ const server = new McpServer({
32
+ name: "whooing-mcp",
33
+ version: "0.1.0",
34
+ }, {
35
+ instructions: "Whooing (후잉) is a Korean personal finance tracking service. " +
36
+ "This server provides read-only access to financial data: " +
37
+ "spending/income summaries (P&L), transaction lists, balance sheets, " +
38
+ "and account listings. " +
39
+ "Dates use YYYYMMDD format. All amounts are in KRW (원). " +
40
+ "If no dates are specified, the current month is used.",
41
+ });
42
+ // whooing_pl — Profit & Loss
43
+ server.registerTool("whooing_pl", {
44
+ description: "Get profit & loss summary (spending and income by category) for a date range",
45
+ inputSchema: dateRangeSchema,
46
+ annotations: { readOnlyHint: true },
47
+ }, async (args) => {
48
+ const defaults = getDateDefaults();
49
+ const startDate = args.start_date ?? defaults.startDate;
50
+ const endDate = args.end_date ?? defaults.endDate;
51
+ const sectionId = args.section_id ?? client.defaultSectionId;
52
+ await client.loadAccounts(sectionId);
53
+ const results = await client.apiGet("pl.json", {
54
+ section_id: sectionId,
55
+ start_date: startDate,
56
+ end_date: endDate,
57
+ });
58
+ const text = formatPL(results, client.getAccountCache(), startDate, endDate);
59
+ return { content: [{ type: "text", text }] };
60
+ });
61
+ // whooing_entries — Transaction list
62
+ server.registerTool("whooing_entries", {
63
+ description: "Get transaction entries (individual transactions with account names) for a date range",
64
+ inputSchema: {
65
+ ...dateRangeSchema,
66
+ limit: z
67
+ .number()
68
+ .int()
69
+ .min(1)
70
+ .max(500)
71
+ .optional()
72
+ .describe("Max number of entries to return. Defaults to 20."),
73
+ },
74
+ annotations: { readOnlyHint: true },
75
+ }, async (args) => {
76
+ const defaults = getDateDefaults();
77
+ const startDate = args.start_date ?? defaults.startDate;
78
+ const endDate = args.end_date ?? defaults.endDate;
79
+ const sectionId = args.section_id ?? client.defaultSectionId;
80
+ const limit = args.limit ?? 20;
81
+ await client.loadAccounts(sectionId);
82
+ const results = await client.apiGet("entries.json", {
83
+ section_id: sectionId,
84
+ start_date: startDate,
85
+ end_date: endDate,
86
+ limit: String(limit),
87
+ });
88
+ const text = formatEntries(results, client.getAccountCache());
89
+ return { content: [{ type: "text", text }] };
90
+ });
91
+ // whooing_balance — Balance sheet
92
+ server.registerTool("whooing_balance", {
93
+ description: "Get balance sheet (assets, liabilities, capital) as of a date range",
94
+ inputSchema: dateRangeSchema,
95
+ annotations: { readOnlyHint: true },
96
+ }, async (args) => {
97
+ const defaults = getDateDefaults();
98
+ const startDate = args.start_date ?? defaults.startDate;
99
+ const endDate = args.end_date ?? defaults.endDate;
100
+ const sectionId = args.section_id ?? client.defaultSectionId;
101
+ await client.loadAccounts(sectionId);
102
+ const results = await client.apiGet("bs.json", {
103
+ section_id: sectionId,
104
+ start_date: startDate,
105
+ end_date: endDate,
106
+ });
107
+ const text = formatBalance(results, client.getAccountCache(), startDate, endDate);
108
+ return { content: [{ type: "text", text }] };
109
+ });
110
+ // whooing_accounts — Account list
111
+ server.registerTool("whooing_accounts", {
112
+ description: "Get the full list of accounts (assets, liabilities, income, expenses, capital)",
113
+ inputSchema: {
114
+ section_id: z
115
+ .string()
116
+ .optional()
117
+ .describe("Section ID. Defaults to WHOOING_SECTION_ID env var."),
118
+ },
119
+ annotations: { readOnlyHint: true },
120
+ }, async (args) => {
121
+ const sectionId = args.section_id ?? client.defaultSectionId;
122
+ const results = await client.loadAccounts(sectionId);
123
+ // Re-fetch raw data for formatting
124
+ const raw = await client.apiGet("accounts.json", {
125
+ section_id: sectionId,
126
+ });
127
+ const text = formatAccounts(raw);
128
+ return { content: [{ type: "text", text }] };
129
+ });
130
+ // whooing_sections — List sections
131
+ server.registerTool("whooing_sections", {
132
+ description: "List all sections (가계부) in the Whooing account",
133
+ inputSchema: {},
134
+ annotations: { readOnlyHint: true },
135
+ }, async () => {
136
+ const results = await client.apiGet("sections.json");
137
+ const text = formatSections(results);
138
+ return { content: [{ type: "text", text }] };
139
+ });
140
+ return server;
141
+ }
@@ -0,0 +1,22 @@
1
+ export interface WhooingConfig {
2
+ appId: string;
3
+ token: string;
4
+ signature: string;
5
+ defaultSectionId: string;
6
+ }
7
+ export interface AccountInfo {
8
+ name: string;
9
+ type: string;
10
+ }
11
+ export declare class WhooingClient {
12
+ private config;
13
+ private accountCache;
14
+ constructor(config: WhooingConfig);
15
+ get defaultSectionId(): string;
16
+ private getApiKey;
17
+ apiGet(endpoint: string, params?: Record<string, string>): Promise<unknown>;
18
+ loadAccounts(sectionId?: string): Promise<Map<string, AccountInfo>>;
19
+ getAccountName(accountId: string): string;
20
+ getAccountInfo(accountId: string): AccountInfo | undefined;
21
+ getAccountCache(): Map<string, AccountInfo>;
22
+ }
@@ -0,0 +1,65 @@
1
+ import crypto from "node:crypto";
2
+ export class WhooingClient {
3
+ config;
4
+ accountCache = new Map();
5
+ constructor(config) {
6
+ this.config = config;
7
+ }
8
+ get defaultSectionId() {
9
+ return this.config.defaultSectionId;
10
+ }
11
+ getApiKey() {
12
+ const nounce = crypto.randomBytes(20).toString("hex");
13
+ const timestamp = Math.floor(Date.now() / 1000);
14
+ return `app_id=${this.config.appId},token=${this.config.token},signiture=${this.config.signature},nounce=${nounce},timestamp=${timestamp}`;
15
+ }
16
+ async apiGet(endpoint, params = {}) {
17
+ const qs = new URLSearchParams(params).toString();
18
+ const url = qs
19
+ ? `https://whooing.com/api/${endpoint}?${qs}`
20
+ : `https://whooing.com/api/${endpoint}`;
21
+ const res = await fetch(url, {
22
+ method: "GET",
23
+ headers: {
24
+ "X-API-KEY": this.getApiKey(),
25
+ },
26
+ });
27
+ if (!res.ok) {
28
+ const text = await res.text();
29
+ throw new Error(`Whooing API error ${res.status}: ${text}`);
30
+ }
31
+ const json = (await res.json());
32
+ if (json.code !== 200) {
33
+ throw new Error(`Whooing API error ${json.code}: ${json.message}`);
34
+ }
35
+ return json.results;
36
+ }
37
+ async loadAccounts(sectionId) {
38
+ const sid = sectionId || this.config.defaultSectionId;
39
+ const results = (await this.apiGet("accounts.json", {
40
+ section_id: sid,
41
+ }));
42
+ const cache = new Map();
43
+ for (const [type, accounts] of Object.entries(results)) {
44
+ if (!Array.isArray(accounts))
45
+ continue;
46
+ for (const acc of accounts) {
47
+ cache.set(acc.account_id, { name: acc.title, type });
48
+ }
49
+ }
50
+ // Merge into main cache
51
+ for (const [id, info] of cache) {
52
+ this.accountCache.set(id, info);
53
+ }
54
+ return cache;
55
+ }
56
+ getAccountName(accountId) {
57
+ return this.accountCache.get(accountId)?.name ?? accountId;
58
+ }
59
+ getAccountInfo(accountId) {
60
+ return this.accountCache.get(accountId);
61
+ }
62
+ getAccountCache() {
63
+ return this.accountCache;
64
+ }
65
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "whooing-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for Whooing (후잉) personal finance — read-only queries for spending, transactions, budgets, and balance sheets",
5
+ "type": "module",
6
+ "main": "./dist/server.js",
7
+ "scripts": {
8
+ "build": "tsc"
9
+ },
10
+ "keywords": [
11
+ "mcp",
12
+ "whooing",
13
+ "후잉",
14
+ "가계부",
15
+ "finance",
16
+ "model-context-protocol"
17
+ ],
18
+ "license": "MIT",
19
+ "engines": {
20
+ "node": ">=22.0.0"
21
+ },
22
+ "dependencies": {
23
+ "@modelcontextprotocol/sdk": "^1.27.1",
24
+ "zod": "^3.23.0",
25
+ "dotenv": "^16.4.0"
26
+ },
27
+ "devDependencies": {
28
+ "typescript": "^5.9.0",
29
+ "@types/node": "^22.0.0"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/jmjeong/whooing-mcp.git"
34
+ },
35
+ "bugs": {
36
+ "url": "https://github.com/jmjeong/whooing-mcp/issues"
37
+ },
38
+ "homepage": "https://github.com/jmjeong/whooing-mcp#readme",
39
+ "bin": {
40
+ "whooing-mcp": "dist/cli.js"
41
+ },
42
+ "files": [
43
+ "dist",
44
+ "README.md",
45
+ "LICENSE",
46
+ ".env.example"
47
+ ]
48
+ }