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 +8 -0
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +167 -0
- package/dist/formatters.d.ts +67 -0
- package/dist/formatters.js +154 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +141 -0
- package/dist/whooing-client.d.ts +22 -0
- package/dist/whooing-client.js +65 -0
- package/package.json +48 -0
package/.env.example
ADDED
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
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
|
+
}
|
package/dist/server.d.ts
ADDED
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
|
+
}
|