nextora-mcp 1.0.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 +76 -0
- package/dist/client.d.ts +54 -0
- package/dist/client.js +17 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +44 -0
- package/dist/tools/add_transaction.d.ts +22 -0
- package/dist/tools/add_transaction.js +50 -0
- package/dist/tools/get_balance.d.ts +13 -0
- package/dist/tools/get_balance.js +27 -0
- package/dist/tools/list_categories.d.ts +10 -0
- package/dist/tools/list_categories.js +17 -0
- package/dist/tools/list_transactions.d.ts +19 -0
- package/dist/tools/list_transactions.js +41 -0
- package/dist/tools/upload_receipt.d.ts +19 -0
- package/dist/tools/upload_receipt.js +89 -0
- package/package.json +25 -0
- package/readme.how_use_mcp.md +294 -0
- package/readme.mcp_server.md +266 -0
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Nextora MCP Server
|
|
2
|
+
|
|
3
|
+
Connect your AI assistant (Claude Desktop, Cursor, Cline, etc.) to Nextora to record transactions and analyze receipts by chatting.
|
|
4
|
+
|
|
5
|
+
## Tools
|
|
6
|
+
|
|
7
|
+
| Tool | Description |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `list_categories` | List available income/expense categories |
|
|
10
|
+
| `add_transaction` | Record a new income or expense |
|
|
11
|
+
| `upload_receipt` | Analyze a receipt image and auto-record the transaction |
|
|
12
|
+
| `get_balance` | Get income/expense/net summary for a date range |
|
|
13
|
+
| `list_transactions` | List recent transactions with filters |
|
|
14
|
+
|
|
15
|
+
## Setup
|
|
16
|
+
|
|
17
|
+
### 1. Generate an API Key
|
|
18
|
+
|
|
19
|
+
Log in to Nextora → Settings → API Keys → Generate Key.
|
|
20
|
+
Copy the key — it is shown **once only**.
|
|
21
|
+
|
|
22
|
+
### 2. Configure Claude Desktop
|
|
23
|
+
|
|
24
|
+
Edit `~/.claude/settings.json` (or `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"mcpServers": {
|
|
29
|
+
"nextora": {
|
|
30
|
+
"command": "npx",
|
|
31
|
+
"args": ["-y", "nextora-mcp"],
|
|
32
|
+
"env": {
|
|
33
|
+
"NEXTORA_API_URL": "https://your-nextora-domain.com/api",
|
|
34
|
+
"NEXTORA_API_KEY": "nxt_your_key_here"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 3. Or run with Docker
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
docker run --rm -i \
|
|
45
|
+
-e NEXTORA_API_URL="https://your-domain/api" \
|
|
46
|
+
-e NEXTORA_API_KEY="nxt_your_key_here" \
|
|
47
|
+
blacksptech/nextora-mcp:latest
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Config ที่ user ใช้กับ Claude Desktop
|
|
51
|
+
{
|
|
52
|
+
"mcpServers": {
|
|
53
|
+
"nextora": {
|
|
54
|
+
"command": "docker",
|
|
55
|
+
"args": ["run", "--rm", "-i",
|
|
56
|
+
"-e", "NEXTORA_API_URL=https://your-domain.com/api",
|
|
57
|
+
"-e", "NEXTORA_API_KEY=nxt_xxx",
|
|
58
|
+
"blacksptech/nextora-mcp:latest"
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
## Example conversations
|
|
65
|
+
|
|
66
|
+
> "บันทึกค่ากาแฟ 80 บาท วันนี้"
|
|
67
|
+
> "Add a food expense of 80 baht for coffee today"
|
|
68
|
+
|
|
69
|
+
> "ส่งรูปใบเสร็จนี้แล้วบันทึกเลย /home/user/receipt.jpg"
|
|
70
|
+
> "Upload this receipt and record it: /Users/me/Downloads/receipt.png"
|
|
71
|
+
|
|
72
|
+
> "ยอดรายรับรายจ่ายเดือนนี้เป็นยังไง"
|
|
73
|
+
> "What's my income vs expense this month?"
|
|
74
|
+
|
|
75
|
+
> "แสดงรายการ 10 รายการล่าสุด"
|
|
76
|
+
> "Show my last 10 transactions"
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type AxiosInstance } from "axios";
|
|
2
|
+
export declare function createApiClient(): AxiosInstance;
|
|
3
|
+
export type TransactionType = "Income" | "Expense";
|
|
4
|
+
export interface Category {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
type: TransactionType;
|
|
8
|
+
parentId: string | null;
|
|
9
|
+
}
|
|
10
|
+
export interface Transaction {
|
|
11
|
+
id: string;
|
|
12
|
+
categoryId: string;
|
|
13
|
+
categoryName: string;
|
|
14
|
+
type: TransactionType;
|
|
15
|
+
amount: number;
|
|
16
|
+
transactionDate: string;
|
|
17
|
+
description: string | null;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
}
|
|
20
|
+
export interface CreateTransactionRequest {
|
|
21
|
+
categoryId: string;
|
|
22
|
+
type: TransactionType;
|
|
23
|
+
amount: number;
|
|
24
|
+
transactionDate: string;
|
|
25
|
+
description?: string | null;
|
|
26
|
+
assetId?: string | null;
|
|
27
|
+
landProjectId?: string | null;
|
|
28
|
+
}
|
|
29
|
+
export interface TransactionFilter {
|
|
30
|
+
from?: string;
|
|
31
|
+
to?: string;
|
|
32
|
+
categoryId?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface AnalyzeReceiptRequest {
|
|
35
|
+
imageBase64: string;
|
|
36
|
+
mimeType: string;
|
|
37
|
+
}
|
|
38
|
+
export interface AnalyzeReceiptResult {
|
|
39
|
+
amount: number | null;
|
|
40
|
+
type: TransactionType;
|
|
41
|
+
description: string | null;
|
|
42
|
+
transactionDate: string | null;
|
|
43
|
+
categoryHint: string | null;
|
|
44
|
+
vendor: string | null;
|
|
45
|
+
success: boolean;
|
|
46
|
+
errorMessage: string | null;
|
|
47
|
+
}
|
|
48
|
+
export interface ReportNetSummary {
|
|
49
|
+
totalIncome: number;
|
|
50
|
+
totalExpense: number;
|
|
51
|
+
netProfit: number;
|
|
52
|
+
from: string;
|
|
53
|
+
to: string;
|
|
54
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
export function createApiClient() {
|
|
3
|
+
const apiUrl = process.env.NEXTORA_API_URL;
|
|
4
|
+
const apiKey = process.env.NEXTORA_API_KEY;
|
|
5
|
+
if (!apiUrl)
|
|
6
|
+
throw new Error("NEXTORA_API_URL environment variable is required");
|
|
7
|
+
if (!apiKey)
|
|
8
|
+
throw new Error("NEXTORA_API_KEY environment variable is required");
|
|
9
|
+
return axios.create({
|
|
10
|
+
baseURL: apiUrl.replace(/\/$/, ""),
|
|
11
|
+
headers: {
|
|
12
|
+
"X-Api-Key": apiKey,
|
|
13
|
+
"Content-Type": "application/json",
|
|
14
|
+
},
|
|
15
|
+
timeout: 30_000,
|
|
16
|
+
});
|
|
17
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { createApiClient } from "./client.js";
|
|
5
|
+
import { listCategoriesSchema, listCategories } from "./tools/list_categories.js";
|
|
6
|
+
import { addTransactionSchema, addTransaction } from "./tools/add_transaction.js";
|
|
7
|
+
import { uploadReceiptSchema, uploadReceipt } from "./tools/upload_receipt.js";
|
|
8
|
+
import { getBalanceSchema, getBalance } from "./tools/get_balance.js";
|
|
9
|
+
import { listTransactionsSchema, listTransactions } from "./tools/list_transactions.js";
|
|
10
|
+
const server = new McpServer({
|
|
11
|
+
name: "nextora",
|
|
12
|
+
version: "1.0.0",
|
|
13
|
+
});
|
|
14
|
+
const client = createApiClient();
|
|
15
|
+
server.tool("list_categories", "List available transaction categories. Call this before add_transaction if you're unsure which category to use.", listCategoriesSchema.shape, async (args) => {
|
|
16
|
+
const text = await listCategories(client, args);
|
|
17
|
+
return { content: [{ type: "text", text }] };
|
|
18
|
+
});
|
|
19
|
+
server.tool("add_transaction", "Record a new income or expense transaction in Nextora.", addTransactionSchema.shape, async (args) => {
|
|
20
|
+
const text = await addTransaction(client, args);
|
|
21
|
+
return { content: [{ type: "text", text }] };
|
|
22
|
+
});
|
|
23
|
+
server.tool("upload_receipt", "Analyze a receipt image file and automatically record the transaction. Requires an AI provider configured in Nextora Settings.", uploadReceiptSchema.shape, async (args) => {
|
|
24
|
+
const text = await uploadReceipt(client, args);
|
|
25
|
+
return { content: [{ type: "text", text }] };
|
|
26
|
+
});
|
|
27
|
+
server.tool("get_balance", "Get income, expense and net profit summary for a date range. Defaults to the current month.", getBalanceSchema.shape, async (args) => {
|
|
28
|
+
const text = await getBalance(client, args);
|
|
29
|
+
return { content: [{ type: "text", text }] };
|
|
30
|
+
});
|
|
31
|
+
server.tool("list_transactions", "List recent transactions with optional date range and type filters.", listTransactionsSchema.shape, async (args) => {
|
|
32
|
+
const text = await listTransactions(client, args);
|
|
33
|
+
return { content: [{ type: "text", text }] };
|
|
34
|
+
});
|
|
35
|
+
async function main() {
|
|
36
|
+
const transport = new StdioServerTransport();
|
|
37
|
+
await server.connect(transport);
|
|
38
|
+
// Startup message goes to stderr so it doesn't pollute the MCP JSON-RPC stdio stream
|
|
39
|
+
process.stderr.write("Nextora MCP server running (stdio)\n");
|
|
40
|
+
}
|
|
41
|
+
main().catch((err) => {
|
|
42
|
+
process.stderr.write(`Fatal error: ${err}\n`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { AxiosInstance } from "axios";
|
|
3
|
+
export declare const addTransactionSchema: z.ZodObject<{
|
|
4
|
+
type: z.ZodEnum<["Income", "Expense"]>;
|
|
5
|
+
amount: z.ZodNumber;
|
|
6
|
+
categoryName: z.ZodString;
|
|
7
|
+
description: z.ZodOptional<z.ZodString>;
|
|
8
|
+
transactionDate: z.ZodOptional<z.ZodString>;
|
|
9
|
+
}, "strip", z.ZodTypeAny, {
|
|
10
|
+
type: "Income" | "Expense";
|
|
11
|
+
amount: number;
|
|
12
|
+
categoryName: string;
|
|
13
|
+
description?: string | undefined;
|
|
14
|
+
transactionDate?: string | undefined;
|
|
15
|
+
}, {
|
|
16
|
+
type: "Income" | "Expense";
|
|
17
|
+
amount: number;
|
|
18
|
+
categoryName: string;
|
|
19
|
+
description?: string | undefined;
|
|
20
|
+
transactionDate?: string | undefined;
|
|
21
|
+
}>;
|
|
22
|
+
export declare function addTransaction(client: AxiosInstance, args: z.infer<typeof addTransactionSchema>): Promise<string>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const addTransactionSchema = z.object({
|
|
3
|
+
type: z.enum(["Income", "Expense"]).describe("Income or Expense"),
|
|
4
|
+
amount: z.number().positive().describe("Amount in the account's currency (positive number)"),
|
|
5
|
+
categoryName: z.string().describe("Category name (partial match OK). Call list_categories first if unsure."),
|
|
6
|
+
description: z.string().optional().describe("Optional note about this transaction"),
|
|
7
|
+
transactionDate: z.string().optional().describe("Date as YYYY-MM-DD. Defaults to today if omitted."),
|
|
8
|
+
});
|
|
9
|
+
export async function addTransaction(client, args) {
|
|
10
|
+
// 1. Find the best matching category
|
|
11
|
+
const catResponse = await client.get("/transaction-categories");
|
|
12
|
+
const allCategories = catResponse.data.filter((c) => c.type === args.type);
|
|
13
|
+
const nameLower = args.categoryName.toLowerCase();
|
|
14
|
+
let category = allCategories.find((c) => c.name.toLowerCase() === nameLower);
|
|
15
|
+
if (!category) {
|
|
16
|
+
category = allCategories.find((c) => c.name.toLowerCase().includes(nameLower));
|
|
17
|
+
}
|
|
18
|
+
if (!category) {
|
|
19
|
+
// Fallback: use first category of matching type
|
|
20
|
+
category = allCategories[0];
|
|
21
|
+
}
|
|
22
|
+
if (!category) {
|
|
23
|
+
const available = catResponse.data.map((c) => `${c.name} (${c.type})`).join(", ");
|
|
24
|
+
return `No ${args.type} category found matching "${args.categoryName}". Available categories: ${available}. Please call list_categories and retry.`;
|
|
25
|
+
}
|
|
26
|
+
// 2. Determine date
|
|
27
|
+
const transactionDate = args.transactionDate ?? new Date().toISOString().slice(0, 10);
|
|
28
|
+
// 3. Create transaction
|
|
29
|
+
const req = {
|
|
30
|
+
categoryId: category.id,
|
|
31
|
+
type: args.type,
|
|
32
|
+
amount: args.amount,
|
|
33
|
+
transactionDate,
|
|
34
|
+
description: args.description ?? null,
|
|
35
|
+
};
|
|
36
|
+
const response = await client.post("/transactions", req);
|
|
37
|
+
const tx = response.data;
|
|
38
|
+
return [
|
|
39
|
+
`✅ Transaction recorded successfully!`,
|
|
40
|
+
``,
|
|
41
|
+
` ID: ${tx.id}`,
|
|
42
|
+
` Type: ${tx.type}`,
|
|
43
|
+
` Amount: ${tx.amount.toLocaleString()}`,
|
|
44
|
+
` Category: ${tx.categoryName}`,
|
|
45
|
+
` Date: ${tx.transactionDate}`,
|
|
46
|
+
tx.description ? ` Description: ${tx.description}` : null,
|
|
47
|
+
]
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.join("\n");
|
|
50
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { AxiosInstance } from "axios";
|
|
3
|
+
export declare const getBalanceSchema: z.ZodObject<{
|
|
4
|
+
from: z.ZodOptional<z.ZodString>;
|
|
5
|
+
to: z.ZodOptional<z.ZodString>;
|
|
6
|
+
}, "strip", z.ZodTypeAny, {
|
|
7
|
+
from?: string | undefined;
|
|
8
|
+
to?: string | undefined;
|
|
9
|
+
}, {
|
|
10
|
+
from?: string | undefined;
|
|
11
|
+
to?: string | undefined;
|
|
12
|
+
}>;
|
|
13
|
+
export declare function getBalance(client: AxiosInstance, args: z.infer<typeof getBalanceSchema>): Promise<string>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const getBalanceSchema = z.object({
|
|
3
|
+
from: z.string().optional().describe("Start date YYYY-MM-DD (default: first day of current month)"),
|
|
4
|
+
to: z.string().optional().describe("End date YYYY-MM-DD (default: today)"),
|
|
5
|
+
});
|
|
6
|
+
export async function getBalance(client, args) {
|
|
7
|
+
const today = new Date();
|
|
8
|
+
const defaultFrom = new Date(today.getFullYear(), today.getMonth(), 1)
|
|
9
|
+
.toISOString()
|
|
10
|
+
.slice(0, 10);
|
|
11
|
+
const defaultTo = today.toISOString().slice(0, 10);
|
|
12
|
+
const from = args.from ?? defaultFrom;
|
|
13
|
+
const to = args.to ?? defaultTo;
|
|
14
|
+
const response = await client.get("/reports/net-summary", {
|
|
15
|
+
params: { from, to },
|
|
16
|
+
});
|
|
17
|
+
const s = response.data;
|
|
18
|
+
const fmt = (n) => n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
19
|
+
const netLabel = s.netProfit >= 0 ? "Net Profit ✅" : "Net Loss ⚠️";
|
|
20
|
+
return [
|
|
21
|
+
`💰 Financial Summary (${s.from} → ${s.to})`,
|
|
22
|
+
``,
|
|
23
|
+
` Total Income: ${fmt(s.totalIncome)}`,
|
|
24
|
+
` Total Expense: ${fmt(s.totalExpense)}`,
|
|
25
|
+
` ${netLabel}: ${fmt(Math.abs(s.netProfit))}`,
|
|
26
|
+
].join("\n");
|
|
27
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { AxiosInstance } from "axios";
|
|
3
|
+
export declare const listCategoriesSchema: z.ZodObject<{
|
|
4
|
+
type: z.ZodDefault<z.ZodOptional<z.ZodEnum<["Income", "Expense", "All"]>>>;
|
|
5
|
+
}, "strip", z.ZodTypeAny, {
|
|
6
|
+
type: "Income" | "Expense" | "All";
|
|
7
|
+
}, {
|
|
8
|
+
type?: "Income" | "Expense" | "All" | undefined;
|
|
9
|
+
}>;
|
|
10
|
+
export declare function listCategories(client: AxiosInstance, args: z.infer<typeof listCategoriesSchema>): Promise<string>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const listCategoriesSchema = z.object({
|
|
3
|
+
type: z.enum(["Income", "Expense", "All"]).optional().default("All")
|
|
4
|
+
.describe("Filter by transaction type"),
|
|
5
|
+
});
|
|
6
|
+
export async function listCategories(client, args) {
|
|
7
|
+
const response = await client.get("/transaction-categories");
|
|
8
|
+
let categories = response.data;
|
|
9
|
+
if (args.type !== "All") {
|
|
10
|
+
categories = categories.filter((c) => c.type === args.type);
|
|
11
|
+
}
|
|
12
|
+
if (categories.length === 0) {
|
|
13
|
+
return `No ${args.type === "All" ? "" : args.type + " "}categories found.`;
|
|
14
|
+
}
|
|
15
|
+
const lines = categories.map((c) => `• [${c.id}] ${c.name} (${c.type})${c.parentId ? " [sub-category]" : ""}`);
|
|
16
|
+
return `Found ${categories.length} categor${categories.length === 1 ? "y" : "ies"}:\n\n${lines.join("\n")}`;
|
|
17
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { AxiosInstance } from "axios";
|
|
3
|
+
export declare const listTransactionsSchema: z.ZodObject<{
|
|
4
|
+
from: z.ZodOptional<z.ZodString>;
|
|
5
|
+
to: z.ZodOptional<z.ZodString>;
|
|
6
|
+
limit: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
7
|
+
type: z.ZodDefault<z.ZodOptional<z.ZodEnum<["Income", "Expense", "All"]>>>;
|
|
8
|
+
}, "strip", z.ZodTypeAny, {
|
|
9
|
+
type: "Income" | "Expense" | "All";
|
|
10
|
+
limit: number;
|
|
11
|
+
from?: string | undefined;
|
|
12
|
+
to?: string | undefined;
|
|
13
|
+
}, {
|
|
14
|
+
type?: "Income" | "Expense" | "All" | undefined;
|
|
15
|
+
from?: string | undefined;
|
|
16
|
+
to?: string | undefined;
|
|
17
|
+
limit?: number | undefined;
|
|
18
|
+
}>;
|
|
19
|
+
export declare function listTransactions(client: AxiosInstance, args: z.infer<typeof listTransactionsSchema>): Promise<string>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const listTransactionsSchema = z.object({
|
|
3
|
+
from: z.string().optional().describe("Start date YYYY-MM-DD"),
|
|
4
|
+
to: z.string().optional().describe("End date YYYY-MM-DD"),
|
|
5
|
+
limit: z.number().int().min(1).max(50).optional().default(20)
|
|
6
|
+
.describe("Maximum number of results to return (1–50, default 20)"),
|
|
7
|
+
type: z.enum(["Income", "Expense", "All"]).optional().default("All")
|
|
8
|
+
.describe("Filter by transaction type"),
|
|
9
|
+
});
|
|
10
|
+
export async function listTransactions(client, args) {
|
|
11
|
+
const params = {};
|
|
12
|
+
if (args.from)
|
|
13
|
+
params.from = args.from;
|
|
14
|
+
if (args.to)
|
|
15
|
+
params.to = args.to;
|
|
16
|
+
const response = await client.get("/transactions", { params });
|
|
17
|
+
let transactions = response.data;
|
|
18
|
+
if (args.type !== "All") {
|
|
19
|
+
transactions = transactions.filter((t) => t.type === args.type);
|
|
20
|
+
}
|
|
21
|
+
// Take most recent N
|
|
22
|
+
const sliced = transactions.slice(-args.limit).reverse();
|
|
23
|
+
if (sliced.length === 0) {
|
|
24
|
+
return `No transactions found${args.from ? ` from ${args.from}` : ""}${args.to ? ` to ${args.to}` : ""}.`;
|
|
25
|
+
}
|
|
26
|
+
const fmt = (n) => n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
27
|
+
const lines = sliced.map((t) => {
|
|
28
|
+
const sign = t.type === "Income" ? "+" : "-";
|
|
29
|
+
const desc = t.description ? ` — ${t.description}` : "";
|
|
30
|
+
return ` ${t.transactionDate} ${sign}${fmt(t.amount).padStart(12)} ${t.categoryName}${desc}`;
|
|
31
|
+
});
|
|
32
|
+
const totalIncome = sliced.filter((t) => t.type === "Income").reduce((s, t) => s + t.amount, 0);
|
|
33
|
+
const totalExpense = sliced.filter((t) => t.type === "Expense").reduce((s, t) => s + t.amount, 0);
|
|
34
|
+
return [
|
|
35
|
+
`📊 Last ${sliced.length} transaction${sliced.length !== 1 ? "s" : ""}:`,
|
|
36
|
+
``,
|
|
37
|
+
...lines,
|
|
38
|
+
``,
|
|
39
|
+
` Income: +${fmt(totalIncome)} | Expense: -${fmt(totalExpense)} | Net: ${fmt(totalIncome - totalExpense)}`,
|
|
40
|
+
].join("\n");
|
|
41
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { AxiosInstance } from "axios";
|
|
3
|
+
export declare const uploadReceiptSchema: z.ZodObject<{
|
|
4
|
+
imagePath: z.ZodString;
|
|
5
|
+
autoRecord: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
6
|
+
descriptionOverride: z.ZodOptional<z.ZodString>;
|
|
7
|
+
amountOverride: z.ZodOptional<z.ZodNumber>;
|
|
8
|
+
}, "strip", z.ZodTypeAny, {
|
|
9
|
+
imagePath: string;
|
|
10
|
+
autoRecord: boolean;
|
|
11
|
+
descriptionOverride?: string | undefined;
|
|
12
|
+
amountOverride?: number | undefined;
|
|
13
|
+
}, {
|
|
14
|
+
imagePath: string;
|
|
15
|
+
autoRecord?: boolean | undefined;
|
|
16
|
+
descriptionOverride?: string | undefined;
|
|
17
|
+
amountOverride?: number | undefined;
|
|
18
|
+
}>;
|
|
19
|
+
export declare function uploadReceipt(client: AxiosInstance, args: z.infer<typeof uploadReceiptSchema>): Promise<string>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
import { extname } from "path";
|
|
4
|
+
export const uploadReceiptSchema = z.object({
|
|
5
|
+
imagePath: z.string().describe("Absolute path to the receipt image file (JPG, PNG, WEBP, or PDF)"),
|
|
6
|
+
autoRecord: z.boolean().optional().default(true).describe("Automatically create the transaction after analysis. Set to false to preview only."),
|
|
7
|
+
descriptionOverride: z.string().optional().describe("Override the AI-suggested description"),
|
|
8
|
+
amountOverride: z.number().positive().optional().describe("Override the AI-detected amount"),
|
|
9
|
+
});
|
|
10
|
+
const MIME_MAP = {
|
|
11
|
+
".jpg": "image/jpeg",
|
|
12
|
+
".jpeg": "image/jpeg",
|
|
13
|
+
".png": "image/png",
|
|
14
|
+
".webp": "image/webp",
|
|
15
|
+
".gif": "image/gif",
|
|
16
|
+
".pdf": "application/pdf",
|
|
17
|
+
};
|
|
18
|
+
export async function uploadReceipt(client, args) {
|
|
19
|
+
// 1. Read image file
|
|
20
|
+
let imageBuffer;
|
|
21
|
+
try {
|
|
22
|
+
imageBuffer = readFileSync(args.imagePath);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return `❌ Could not read file at "${args.imagePath}". Please check the path exists and is accessible.`;
|
|
26
|
+
}
|
|
27
|
+
const ext = extname(args.imagePath).toLowerCase();
|
|
28
|
+
const mimeType = MIME_MAP[ext] ?? "image/jpeg";
|
|
29
|
+
const imageBase64 = imageBuffer.toString("base64");
|
|
30
|
+
// 2. Analyze via API
|
|
31
|
+
const analyzeReq = { imageBase64, mimeType };
|
|
32
|
+
const analyzeRes = await client.post("/transactions/analyze-image", analyzeReq);
|
|
33
|
+
const result = analyzeRes.data;
|
|
34
|
+
if (!result.success) {
|
|
35
|
+
return `❌ Receipt analysis failed: ${result.errorMessage}\n\nTip: Make sure an AI provider (OpenAI, Claude, or Gemini) is configured in Settings.`;
|
|
36
|
+
}
|
|
37
|
+
const amount = args.amountOverride ?? result.amount;
|
|
38
|
+
const description = args.descriptionOverride ?? (result.description ?? result.vendor ?? null);
|
|
39
|
+
const type = result.type ?? "Expense";
|
|
40
|
+
const dateStr = result.transactionDate ?? new Date().toISOString().slice(0, 10);
|
|
41
|
+
// Build preview
|
|
42
|
+
const preview = [
|
|
43
|
+
`📋 Receipt Analysis Result:`,
|
|
44
|
+
``,
|
|
45
|
+
` Type: ${type}`,
|
|
46
|
+
` Amount: ${amount?.toLocaleString() ?? "(not detected)"}`,
|
|
47
|
+
` Date: ${dateStr}`,
|
|
48
|
+
result.vendor ? ` Vendor: ${result.vendor}` : null,
|
|
49
|
+
description ? ` Description: ${description}` : null,
|
|
50
|
+
result.categoryHint ? ` Category: ${result.categoryHint} (suggested)` : null,
|
|
51
|
+
]
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.join("\n");
|
|
54
|
+
if (!args.autoRecord) {
|
|
55
|
+
return `${preview}\n\n💡 autoRecord is false — call again with autoRecord: true to save this transaction.`;
|
|
56
|
+
}
|
|
57
|
+
if (!amount || amount <= 0) {
|
|
58
|
+
return `${preview}\n\n❌ Could not determine amount from receipt. Please call add_transaction manually with the correct amount.`;
|
|
59
|
+
}
|
|
60
|
+
// 3. Find matching category
|
|
61
|
+
const catResponse = await client.get("/transaction-categories");
|
|
62
|
+
const typeCategories = catResponse.data.filter((c) => c.type === type);
|
|
63
|
+
let category;
|
|
64
|
+
if (result.categoryHint) {
|
|
65
|
+
const hint = result.categoryHint.toLowerCase();
|
|
66
|
+
category = typeCategories.find((c) => c.name.toLowerCase().includes(hint));
|
|
67
|
+
}
|
|
68
|
+
category ??= typeCategories[0];
|
|
69
|
+
if (!category) {
|
|
70
|
+
return `${preview}\n\n❌ No ${type} categories configured. Please create one in Settings first.`;
|
|
71
|
+
}
|
|
72
|
+
// 4. Create transaction
|
|
73
|
+
const req = {
|
|
74
|
+
categoryId: category.id,
|
|
75
|
+
type,
|
|
76
|
+
amount,
|
|
77
|
+
transactionDate: dateStr,
|
|
78
|
+
description,
|
|
79
|
+
};
|
|
80
|
+
const txRes = await client.post("/transactions", req);
|
|
81
|
+
const tx = txRes.data;
|
|
82
|
+
return [
|
|
83
|
+
preview,
|
|
84
|
+
``,
|
|
85
|
+
`✅ Transaction recorded!`,
|
|
86
|
+
` ID: ${tx.id}`,
|
|
87
|
+
` Category: ${tx.categoryName}`,
|
|
88
|
+
].join("\n");
|
|
89
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nextora-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Nextora MCP Server — record transactions and analyze receipts via AI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": ["dist"],
|
|
7
|
+
"bin": {
|
|
8
|
+
"nextora-mcp": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"dev": "tsx src/index.ts",
|
|
13
|
+
"start": "node dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@modelcontextprotocol/sdk": "^1.15.0",
|
|
17
|
+
"axios": "^1.9.0",
|
|
18
|
+
"zod": "^3.24.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^22.0.0",
|
|
22
|
+
"tsx": "^4.19.0",
|
|
23
|
+
"typescript": "^5.8.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# วิธีใช้งาน Nextora MCP Server กับ AI ต่างๆ
|
|
2
|
+
|
|
3
|
+
## ขั้นตอนแรก: สร้าง API Key
|
|
4
|
+
|
|
5
|
+
1. Login เข้า Nextora → **Settings → API Keys**
|
|
6
|
+
2. กด **"สร้าง Key"** → ตั้งชื่อ เช่น `My Claude Desktop`
|
|
7
|
+
3. **Copy key ที่แสดง** — จะแสดงครั้งเดียวเท่านั้น (รูปแบบ: `nxt_xxxxxxxxxx...`)
|
|
8
|
+
4. จดค่า API URL ของ Nextora ไว้ด้วย เช่น `https://your-domain.com/api`
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 1. Claude Desktop (แนะนำ — รองรับ native)
|
|
13
|
+
|
|
14
|
+
Claude Desktop คือแอปบน Mac/Windows ที่รองรับ MCP โดยตรง
|
|
15
|
+
|
|
16
|
+
### ขั้นตอน
|
|
17
|
+
|
|
18
|
+
**Mac:** เปิดไฟล์ `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
19
|
+
**Windows:** เปิดไฟล์ `%APPDATA%\Claude\claude_desktop_config.json`
|
|
20
|
+
|
|
21
|
+
เพิ่ม config นี้:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"mcpServers": {
|
|
26
|
+
"nextora": {
|
|
27
|
+
"command": "npx",
|
|
28
|
+
"args": ["-y", "nextora-mcp"],
|
|
29
|
+
"env": {
|
|
30
|
+
"NEXTORA_API_URL": "https://your-domain.com/api",
|
|
31
|
+
"NEXTORA_API_KEY": "nxt_your_key_here"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
> ถ้าไม่มี `npx` หรือ Node.js → ดาวน์โหลดที่ [nodejs.org](https://nodejs.org) (LTS)
|
|
39
|
+
|
|
40
|
+
**Restart Claude Desktop** แล้วจะเห็น 🔧 icon ด้านล่าง chat — หมายความว่า tools พร้อมใช้
|
|
41
|
+
|
|
42
|
+
### ลองใช้
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
"บันทึกค่ากาแฟ 80 บาท วันนี้"
|
|
46
|
+
"ส่งรูปใบเสร็จนี้แล้วบันทึกด้วย: /Users/me/Downloads/receipt.jpg"
|
|
47
|
+
"ยอดรายรับรายจ่ายเดือนนี้เป็นเท่าไหร่"
|
|
48
|
+
"แสดงรายการล่าสุด 10 รายการ"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## 2. Cursor IDE
|
|
54
|
+
|
|
55
|
+
Cursor เป็น Code Editor ที่รองรับ MCP tools
|
|
56
|
+
|
|
57
|
+
### ขั้นตอน
|
|
58
|
+
|
|
59
|
+
เปิด **Cursor Settings** → ค้นหา **MCP** → เพิ่ม server ใหม่:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"mcpServers": {
|
|
64
|
+
"nextora": {
|
|
65
|
+
"command": "npx",
|
|
66
|
+
"args": ["-y", "nextora-mcp"],
|
|
67
|
+
"env": {
|
|
68
|
+
"NEXTORA_API_URL": "https://your-domain.com/api",
|
|
69
|
+
"NEXTORA_API_KEY": "nxt_your_key_here"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
หรือเพิ่มที่ไฟล์ `.cursor/mcp.json` ใน project root:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"mcpServers": {
|
|
81
|
+
"nextora": {
|
|
82
|
+
"command": "npx",
|
|
83
|
+
"args": ["-y", "nextora-mcp"],
|
|
84
|
+
"env": {
|
|
85
|
+
"NEXTORA_API_URL": "https://your-domain.com/api",
|
|
86
|
+
"NEXTORA_API_KEY": "nxt_your_key_here"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
เปิด Cursor Chat (Cmd/Ctrl+L) → พิมพ์คำสั่งได้เลย
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## 3. Cline (VS Code Extension)
|
|
98
|
+
|
|
99
|
+
Cline คือ VS Code extension ที่ทรงพลัง รองรับ MCP tools ครบ
|
|
100
|
+
|
|
101
|
+
### ขั้นตอน
|
|
102
|
+
|
|
103
|
+
1. ติดตั้ง extension **Cline** จาก VS Code Marketplace
|
|
104
|
+
2. เปิด Cline Panel (icon ด้านซ้าย)
|
|
105
|
+
3. กด **Settings icon** → **MCP Servers** → **Edit Config**
|
|
106
|
+
4. เพิ่ม:
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"mcpServers": {
|
|
111
|
+
"nextora": {
|
|
112
|
+
"command": "npx",
|
|
113
|
+
"args": ["-y", "nextora-mcp"],
|
|
114
|
+
"env": {
|
|
115
|
+
"NEXTORA_API_URL": "https://your-domain.com/api",
|
|
116
|
+
"NEXTORA_API_KEY": "nxt_your_key_here"
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
5. Save → กด **Restart MCP Servers**
|
|
124
|
+
6. เห็น `nextora` ใน MCP Servers list → พร้อมใช้
|
|
125
|
+
|
|
126
|
+
> **Cline รองรับ OpenAI, Claude, Gemini, Ollama ฯลฯ** — ตั้งค่า AI provider ใน Cline Settings ได้อิสระ
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## 4. Continue.dev (VS Code / JetBrains)
|
|
131
|
+
|
|
132
|
+
Continue เป็น open-source AI coding assistant รองรับ MCP
|
|
133
|
+
|
|
134
|
+
### ขั้นตอน
|
|
135
|
+
|
|
136
|
+
เปิดไฟล์ `~/.continue/config.json` แล้วเพิ่ม:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"experimental": {
|
|
141
|
+
"modelContextProtocolServers": [
|
|
142
|
+
{
|
|
143
|
+
"transport": {
|
|
144
|
+
"type": "stdio",
|
|
145
|
+
"command": "npx",
|
|
146
|
+
"args": ["-y", "nextora-mcp"],
|
|
147
|
+
"env": {
|
|
148
|
+
"NEXTORA_API_URL": "https://your-domain.com/api",
|
|
149
|
+
"NEXTORA_API_KEY": "nxt_your_key_here"
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
]
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## 5. ChatGPT / OpenAI (ผ่าน Bridge)
|
|
161
|
+
|
|
162
|
+
OpenAI ยังไม่รองรับ MCP โดยตรงใน ChatGPT web app ณ ขณะนี้ (กรกฎาคม 2026) แต่มีวิธีอ้อม:
|
|
163
|
+
|
|
164
|
+
### Option A: ใช้ผ่าน OpenAI API + MCP-to-OpenAI Bridge
|
|
165
|
+
|
|
166
|
+
ถ้าเขียน code เอง สามารถใช้ library เชื่อม MCP กับ OpenAI Function Calling:
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
import OpenAI from "openai";
|
|
170
|
+
// แปลง MCP tools เป็น OpenAI function definitions แล้วส่ง tool results กลับ
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Option B: ใช้ Cline ที่ตั้งค่า OpenAI เป็น provider
|
|
174
|
+
|
|
175
|
+
Cline รองรับทั้ง MCP tools และ OpenAI API — ตั้งค่า Cline ให้ใช้ OpenAI แล้ว connect Nextora MCP เข้าไป:
|
|
176
|
+
|
|
177
|
+
1. ติดตั้ง Cline
|
|
178
|
+
2. Settings → AI Provider → เลือก **OpenAI**, ใส่ API Key ของ OpenAI
|
|
179
|
+
3. เพิ่ม Nextora MCP server ตามขั้นตอนใน [ส่วนที่ 3]
|
|
180
|
+
|
|
181
|
+
> วิธีนี้ใช้ OpenAI เป็น brain แต่ได้ Nextora tools ครบ
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## 6. Open WebUI (Self-hosted)
|
|
186
|
+
|
|
187
|
+
สำหรับคนที่รัน Open WebUI (Ollama / Local LLM):
|
|
188
|
+
|
|
189
|
+
```json
|
|
190
|
+
{
|
|
191
|
+
"tools": [
|
|
192
|
+
{
|
|
193
|
+
"type": "mcp",
|
|
194
|
+
"server": {
|
|
195
|
+
"type": "stdio",
|
|
196
|
+
"command": "npx",
|
|
197
|
+
"args": ["-y", "nextora-mcp"],
|
|
198
|
+
"env": {
|
|
199
|
+
"NEXTORA_API_URL": "https://your-domain.com/api",
|
|
200
|
+
"NEXTORA_API_KEY": "nxt_your_key_here"
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
]
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## 7. Docker Run (ถ้าไม่ต้องการติดตั้ง Node.js)
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
# ใช้ image จาก Docker Hub
|
|
214
|
+
docker run --rm -i \
|
|
215
|
+
-e NEXTORA_API_URL="https://your-domain.com/api" \
|
|
216
|
+
-e NEXTORA_API_KEY="nxt_your_key_here" \
|
|
217
|
+
blacksptech/nextora-mcp:latest
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
จากนั้นเปลี่ยน command ใน config เป็น:
|
|
221
|
+
|
|
222
|
+
```json
|
|
223
|
+
{
|
|
224
|
+
"mcpServers": {
|
|
225
|
+
"nextora": {
|
|
226
|
+
"command": "docker",
|
|
227
|
+
"args": [
|
|
228
|
+
"run", "--rm", "-i",
|
|
229
|
+
"-e", "NEXTORA_API_URL=https://your-domain.com/api",
|
|
230
|
+
"-e", "NEXTORA_API_KEY=nxt_your_key_here",
|
|
231
|
+
"blacksptech/nextora-mcp:latest"
|
|
232
|
+
]
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## 8. ตัวอย่างบทสนทนา
|
|
241
|
+
|
|
242
|
+
### บันทึกรายจ่าย
|
|
243
|
+
```
|
|
244
|
+
You: บันทึกค่าน้ำมัน 500 บาท เมื่อวานนี้
|
|
245
|
+
AI: ✅ บันทึกแล้ว
|
|
246
|
+
Type: Expense
|
|
247
|
+
Amount: 500
|
|
248
|
+
Category: Transport
|
|
249
|
+
Date: 2026-07-03
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### วิเคราะห์ใบเสร็จ
|
|
253
|
+
```
|
|
254
|
+
You: ส่งรูปใบเสร็จนี้แล้วบันทึกด้วย: /Users/me/receipt.png
|
|
255
|
+
AI: 📋 ผลการวิเคราะห์:
|
|
256
|
+
Type: Expense
|
|
257
|
+
Amount: 1,250
|
|
258
|
+
Vendor: ห้างสรรพสินค้า ABC
|
|
259
|
+
Date: 2026-07-04
|
|
260
|
+
✅ บันทึกแล้ว (Category: Shopping)
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### ดูสรุปการเงิน
|
|
264
|
+
```
|
|
265
|
+
You: ยอดเดือนนี้เป็นยังไง
|
|
266
|
+
AI: 💰 สรุปเดือนกรกฎาคม 2026
|
|
267
|
+
รายรับ: 45,000.00
|
|
268
|
+
รายจ่าย: 32,450.00
|
|
269
|
+
กำไรสุทธิ: 12,550.00 ✅
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### ดูรายการล่าสุด
|
|
273
|
+
```
|
|
274
|
+
You: แสดง 5 รายการล่าสุด
|
|
275
|
+
AI: 📊 5 รายการล่าสุด:
|
|
276
|
+
2026-07-04 -1,250.00 Shopping ห้างฯ ABC
|
|
277
|
+
2026-07-04 -500.00 Transport น้ำมัน
|
|
278
|
+
2026-07-03 -80.00 Food กาแฟ
|
|
279
|
+
2026-07-02 +45,000.00 Salary เงินเดือน
|
|
280
|
+
2026-07-01 -350.00 Utilities ค่าไฟ
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## 9. Troubleshooting
|
|
286
|
+
|
|
287
|
+
| ปัญหา | สาเหตุ | วิธีแก้ |
|
|
288
|
+
|---|---|---|
|
|
289
|
+
| `NEXTORA_API_URL is required` | ไม่ได้ set env var | ตรวจสอบ config ว่า env ครบ |
|
|
290
|
+
| `Invalid API key` | Key ผิดหรือถูก revoke | ไปที่ Settings → API Keys → สร้างใหม่ |
|
|
291
|
+
| `No AI API key configured` | ใช้ upload_receipt แต่ยังไม่ได้ set AI provider | Settings → Receipt scanning → เพิ่ม OpenAI/Claude key |
|
|
292
|
+
| Tool ไม่ขึ้นใน AI client | Restart client หลัง save config | ปิด-เปิด Claude Desktop / VS Code ใหม่ |
|
|
293
|
+
| `npx` ไม่ทำงาน | ไม่มี Node.js | ติดตั้ง Node.js LTS จาก nodejs.org |
|
|
294
|
+
| Timeout | Network ช้าหรือ Nextora ไม่ตอบ | ตรวจ NEXTORA_API_URL ว่าเข้าถึงได้ |
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# Nextora MCP Server — วิธีการเขียนและโครงสร้าง
|
|
2
|
+
|
|
3
|
+
## 1. MCP คืออะไร
|
|
4
|
+
|
|
5
|
+
**Model Context Protocol (MCP)** คือมาตรฐานเปิด (Open Standard) ที่ Anthropic พัฒนาขึ้น เพื่อให้ AI Assistant สื่อสารกับระบบภายนอกได้แบบ standardized ทำหน้าที่คล้าย USB-C — "port" เดียว เชื่อมได้ทุก AI ที่รองรับ
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
AI Client (Claude Desktop / Cursor / Cline)
|
|
9
|
+
│
|
|
10
|
+
│ JSON-RPC 2.0 over stdio / HTTP-SSE
|
|
11
|
+
▼
|
|
12
|
+
MCP Server (nextora-mcp)
|
|
13
|
+
│
|
|
14
|
+
│ HTTP REST + X-Api-Key
|
|
15
|
+
▼
|
|
16
|
+
Nextora API (.NET 10)
|
|
17
|
+
│
|
|
18
|
+
▼
|
|
19
|
+
PostgreSQL
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 2. โครงสร้างโปรเจกต์
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
mcp-server/
|
|
28
|
+
├── src/
|
|
29
|
+
│ ├── index.ts ← Entry point — สร้าง server + register tools + connect transport
|
|
30
|
+
│ ├── client.ts ← Axios HTTP client wrapper + TypeScript types
|
|
31
|
+
│ └── tools/
|
|
32
|
+
│ ├── list_categories.ts ← Tool: list_categories
|
|
33
|
+
│ ├── add_transaction.ts ← Tool: add_transaction
|
|
34
|
+
│ ├── upload_receipt.ts ← Tool: upload_receipt
|
|
35
|
+
│ ├── get_balance.ts ← Tool: get_balance
|
|
36
|
+
│ └── list_transactions.ts ← Tool: list_transactions
|
|
37
|
+
├── package.json
|
|
38
|
+
├── tsconfig.json
|
|
39
|
+
└── Dockerfile
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 3. เทคโนโลยีที่ใช้
|
|
45
|
+
|
|
46
|
+
| Package | เวอร์ชัน | หน้าที่ |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `@modelcontextprotocol/sdk` | ^1.15 | MCP SDK (server, transport, tool registry) |
|
|
49
|
+
| `axios` | ^1.9 | HTTP client ยิง REST API ไปที่ Nextora |
|
|
50
|
+
| `zod` | ^3.24 | Schema validation สำหรับ tool arguments |
|
|
51
|
+
| `typescript` | ^5.8 | Type safety ทั้ง project |
|
|
52
|
+
| `tsx` | ^4.19 | Dev runner (run TypeScript โดยตรง) |
|
|
53
|
+
|
|
54
|
+
**Runtime**: Node.js 22+, ES Module (`"type": "module"`)
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 4. วิธีการเขียน MCP Server
|
|
59
|
+
|
|
60
|
+
### 4.1 สร้าง McpServer instance
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
// src/index.ts
|
|
64
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
65
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
66
|
+
|
|
67
|
+
const server = new McpServer({
|
|
68
|
+
name: "nextora", // ชื่อที่ AI client เห็น
|
|
69
|
+
version: "1.0.0",
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 4.2 Register Tool
|
|
74
|
+
|
|
75
|
+
แต่ละ tool ต้องมี 4 ส่วน:
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
server.tool(
|
|
79
|
+
"tool_name", // 1. ชื่อ tool (snake_case)
|
|
80
|
+
"Description for AI to read", // 2. คำอธิบาย — AI ใช้ตัดสินใจว่าจะเรียก tool ไหน
|
|
81
|
+
schemaObject.shape, // 3. Zod schema ของ arguments
|
|
82
|
+
async (args) => { // 4. Handler function
|
|
83
|
+
const text = await doSomething(args);
|
|
84
|
+
return { content: [{ type: "text", text }] };
|
|
85
|
+
}
|
|
86
|
+
);
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### 4.3 Schema ด้วย Zod
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
// src/tools/add_transaction.ts
|
|
93
|
+
import { z } from "zod";
|
|
94
|
+
|
|
95
|
+
export const addTransactionSchema = z.object({
|
|
96
|
+
type: z.enum(["Income", "Expense"]).describe("Income or Expense"),
|
|
97
|
+
amount: z.number().positive().describe("Amount (positive number)"),
|
|
98
|
+
categoryName: z.string().describe("Category name, partial match OK"),
|
|
99
|
+
description: z.string().optional().describe("Optional note"),
|
|
100
|
+
transactionDate: z.string().optional().describe("YYYY-MM-DD, defaults to today"),
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
> `.describe(...)` คือสิ่งที่ AI อ่านเพื่อเข้าใจว่า field นี้ใส่อะไร — เขียนให้ชัด
|
|
105
|
+
|
|
106
|
+
### 4.4 API Client (Authentication)
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
// src/client.ts
|
|
110
|
+
import axios from "axios";
|
|
111
|
+
|
|
112
|
+
export function createApiClient() {
|
|
113
|
+
return axios.create({
|
|
114
|
+
baseURL: process.env.NEXTORA_API_URL,
|
|
115
|
+
headers: {
|
|
116
|
+
"X-Api-Key": process.env.NEXTORA_API_KEY, // API Key auth
|
|
117
|
+
"Content-Type": "application/json",
|
|
118
|
+
},
|
|
119
|
+
timeout: 30_000,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
API Key ถูกส่งผ่าน header `X-Api-Key` ทุก request (ไม่ใช่ JWT Bearer)
|
|
125
|
+
|
|
126
|
+
### 4.5 Tool Logic ตัวอย่าง: add_transaction
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
export async function addTransaction(client, args) {
|
|
130
|
+
// 1. หา category จากชื่อ (partial match)
|
|
131
|
+
const cats = await client.get("/transaction-categories");
|
|
132
|
+
const category = cats.data.find(c =>
|
|
133
|
+
c.type === args.type &&
|
|
134
|
+
c.name.toLowerCase().includes(args.categoryName.toLowerCase())
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// 2. สร้าง transaction
|
|
138
|
+
const response = await client.post("/transactions", {
|
|
139
|
+
categoryId: category.id,
|
|
140
|
+
type: args.type,
|
|
141
|
+
amount: args.amount,
|
|
142
|
+
transactionDate: args.transactionDate ?? today,
|
|
143
|
+
description: args.description,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// 3. return plain text สำหรับ AI อ่าน
|
|
147
|
+
return `✅ Transaction recorded!\n ID: ${response.data.id}\n Amount: ${args.amount}`;
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### 4.6 Transport: stdio
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
async function main() {
|
|
155
|
+
const transport = new StdioServerTransport();
|
|
156
|
+
await server.connect(transport);
|
|
157
|
+
process.stderr.write("Nextora MCP server running\n");
|
|
158
|
+
// stderr = ข้อความ log, ไม่รบกวน JSON-RPC stream ที่ไปที่ stdout
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**Stdio transport** = AI client launch process นี้ขึ้นมา แล้วคุยกันผ่าน stdin/stdout เป็น JSON-RPC 2.0
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## 5. Tools ที่ implement
|
|
167
|
+
|
|
168
|
+
### 5.1 `list_categories`
|
|
169
|
+
- เรียก `GET /api/transaction-categories`
|
|
170
|
+
- รองรับ filter by type (Income / Expense / All)
|
|
171
|
+
- Return: รายการ category พร้อม ID
|
|
172
|
+
|
|
173
|
+
### 5.2 `add_transaction`
|
|
174
|
+
- รับ: type, amount, categoryName (partial), description?, date?
|
|
175
|
+
- Logic: หา category → สร้าง transaction
|
|
176
|
+
- Smart matching: ชื่อ category ไม่ต้องตรงพอดี
|
|
177
|
+
|
|
178
|
+
### 5.3 `upload_receipt`
|
|
179
|
+
- รับ: imagePath (absolute path ในเครื่อง AI client)
|
|
180
|
+
- อ่านไฟล์ → base64 → ส่งไป `POST /api/transactions/analyze-image`
|
|
181
|
+
- API วิเคราะห์ด้วย Claude / OpenAI / Gemini Vision
|
|
182
|
+
- ถ้า `autoRecord: true` (default) → สร้าง transaction อัตโนมัติ
|
|
183
|
+
|
|
184
|
+
### 5.4 `get_balance`
|
|
185
|
+
- เรียก `GET /api/reports/net-summary?from=&to=`
|
|
186
|
+
- Default: เดือนปัจจุบัน
|
|
187
|
+
- Return: รายรับ / รายจ่าย / กำไร-ขาดทุน
|
|
188
|
+
|
|
189
|
+
### 5.5 `list_transactions`
|
|
190
|
+
- เรียก `GET /api/transactions?from=&to=`
|
|
191
|
+
- รองรับ filter by date, type, limit
|
|
192
|
+
- Return: ตาราง + summary
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## 6. Backend ที่เพิ่มเพื่อรองรับ MCP
|
|
197
|
+
|
|
198
|
+
### 6.1 API Key Authentication
|
|
199
|
+
|
|
200
|
+
เพิ่ม scheme ใหม่ใน ASP.NET Core:
|
|
201
|
+
|
|
202
|
+
```
|
|
203
|
+
Header: X-Api-Key: nxt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`ApiKeyAuthenticationHandler` จะ:
|
|
207
|
+
1. Hash raw key ด้วย SHA-256
|
|
208
|
+
2. ค้นหาใน `api_keys` table
|
|
209
|
+
3. Load user + permissions จาก DB
|
|
210
|
+
4. Build `ClaimsPrincipal` เหมือน JWT (ใช้ policy เดิมได้เลย)
|
|
211
|
+
|
|
212
|
+
### 6.2 Receipt Analysis Endpoint
|
|
213
|
+
|
|
214
|
+
`POST /api/transactions/analyze-image`
|
|
215
|
+
|
|
216
|
+
```json
|
|
217
|
+
{
|
|
218
|
+
"imageBase64": "...",
|
|
219
|
+
"mimeType": "image/jpeg"
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
ส่งต่อไปยัง AI provider ที่ org configure ไว้:
|
|
224
|
+
- **Claude**: `POST https://api.anthropic.com/v1/messages` model `claude-haiku-4-5-20251001`
|
|
225
|
+
- **OpenAI**: `POST https://api.openai.com/v1/chat/completions` model `gpt-4o-mini`
|
|
226
|
+
- **Gemini**: `POST https://generativelanguage.googleapis.com/...gemini-1.5-flash`
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## 7. Build & Run
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
# Install dependencies
|
|
234
|
+
cd mcp-server
|
|
235
|
+
npm install
|
|
236
|
+
|
|
237
|
+
# Dev (run directly)
|
|
238
|
+
NEXTORA_API_URL=http://localhost:8080/api \
|
|
239
|
+
NEXTORA_API_KEY=nxt_xxx \
|
|
240
|
+
npm run dev
|
|
241
|
+
|
|
242
|
+
# Build for production
|
|
243
|
+
npm run build
|
|
244
|
+
node dist/index.js
|
|
245
|
+
|
|
246
|
+
# Docker
|
|
247
|
+
docker build -t nextora-mcp .
|
|
248
|
+
docker run -i \
|
|
249
|
+
-e NEXTORA_API_URL="https://your-domain/api" \
|
|
250
|
+
-e NEXTORA_API_KEY="nxt_xxx" \
|
|
251
|
+
nextora-mcp
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
> `-i` (interactive / stdin open) จำเป็นสำหรับ stdio transport
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## 8. Pattern สำคัญ
|
|
259
|
+
|
|
260
|
+
| Pattern | เหตุผล |
|
|
261
|
+
|---|---|
|
|
262
|
+
| `stderr` สำหรับ log | stdout ถูก JSON-RPC ใช้งานอยู่ — ห้าม `console.log` |
|
|
263
|
+
| Zod `.describe()` ครบทุก field | AI อ่านเพื่อเข้าใจว่าต้องส่งอะไร |
|
|
264
|
+
| Return plain text | AI อ่านแล้วตอบ user ได้ทันที ไม่ต้อง parse JSON |
|
|
265
|
+
| `autoRecord: true` default | UX ดีกว่า — user บอก "ส่งใบเสร็จ" ก็บันทึกเลย |
|
|
266
|
+
| Partial category match | ลด friction — user พิมพ์ "กาแฟ" แทน ID |
|