finhay-mcp-server 1.0.2
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 +118 -0
- package/dist/client/AccountContext.d.ts +25 -0
- package/dist/client/AccountContext.js +64 -0
- package/dist/client/FinhayClient.d.ts +23 -0
- package/dist/client/FinhayClient.js +85 -0
- package/dist/client/HmacSigner.d.ts +13 -0
- package/dist/client/HmacSigner.js +36 -0
- package/dist/config/environment.d.ts +8 -0
- package/dist/config/environment.js +34 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +25 -0
- package/dist/install.d.ts +2 -0
- package/dist/install.js +167 -0
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.js +6 -0
- package/dist/tools/market.d.ts +3 -0
- package/dist/tools/market.js +117 -0
- package/dist/tools/portfolio.d.ts +4 -0
- package/dist/tools/portfolio.js +121 -0
- package/dist/utils/formatter.d.ts +3 -0
- package/dist/utils/formatter.js +14 -0
- package/dist/utils/safeTool.d.ts +13 -0
- package/dist/utils/safeTool.js +29 -0
- package/package.json +48 -0
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# finhay-mcp-server
|
|
2
|
+
|
|
3
|
+
MCP Server cho Finhay Securities — xem gia co phieu, danh muc dau tu, vang, crypto qua Claude AI.
|
|
4
|
+
|
|
5
|
+
## Cai dat
|
|
6
|
+
|
|
7
|
+
### Buoc 1: Tao API Key
|
|
8
|
+
|
|
9
|
+
Vao [https://www.finhay.com.vn/finhay-skills](https://www.finhay.com.vn/finhay-skills) → Dang nhap → Tao API Key.
|
|
10
|
+
|
|
11
|
+
Ban se nhan duoc:
|
|
12
|
+
- **API Key**: `ak_live_xxx`
|
|
13
|
+
- **API Secret**: `sk_live_yyy`
|
|
14
|
+
|
|
15
|
+
### Buoc 2: Ket noi voi Claude
|
|
16
|
+
|
|
17
|
+
Chon **mot trong ba** cach sau:
|
|
18
|
+
|
|
19
|
+
#### Cach 1: Cai dat tu dong (Khuyen dung)
|
|
20
|
+
|
|
21
|
+
Chay lenh sau va nhap API Key/Secret theo huong dan:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx -y finhay-mcp-server --install
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Sau khi hoan tat, khoi dong lai Claude Desktop la xong.
|
|
28
|
+
|
|
29
|
+
#### Cach 2: Claude Code CLI
|
|
30
|
+
|
|
31
|
+
Neu ban da cai [Claude Code](https://docs.anthropic.com/en/docs/claude-code), chay:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
claude mcp add finhay -- npx -y finhay-mcp-server
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Config se tu dong dong bo sang Claude Desktop.
|
|
38
|
+
|
|
39
|
+
#### Cach 3: Cau hinh thu cong
|
|
40
|
+
|
|
41
|
+
**Buoc 3a.** Tao file credentials tai `~/.finhay/credentials/.env`:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
FINHAY_API_KEY=ak_live_xxx
|
|
45
|
+
FINHAY_API_SECRET=sk_live_yyy
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**Buoc 3b.** Them vao file config Claude Desktop:
|
|
49
|
+
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
50
|
+
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"mcpServers": {
|
|
55
|
+
"finhay": {
|
|
56
|
+
"command": "npx",
|
|
57
|
+
"args": ["-y", "@finhay/mcp-server"]
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
> API Key/Secret **khong nam** trong file config Claude — duoc luu rieng tai `~/.finhay/credentials/.env` (dung chung voi Finhay Skills).
|
|
64
|
+
|
|
65
|
+
### Buoc 3: Su dung
|
|
66
|
+
|
|
67
|
+
Mo Claude, hoi:
|
|
68
|
+
|
|
69
|
+
- "Gia co phieu VNM hom nay?"
|
|
70
|
+
- "Xem danh muc dau tu cua toi"
|
|
71
|
+
- "So sanh FPT va VNM"
|
|
72
|
+
- "Gia vang SJC hom nay bao nhieu?"
|
|
73
|
+
- "Lai suat tiet kiem ngan hang nao cao nhat?"
|
|
74
|
+
- "Chi so CPI Viet Nam gan day?"
|
|
75
|
+
|
|
76
|
+
## Tools
|
|
77
|
+
|
|
78
|
+
### Thi truong (18 tools)
|
|
79
|
+
|
|
80
|
+
| Tool | Mo ta |
|
|
81
|
+
|------|-------|
|
|
82
|
+
| `get_stock_realtime` | Gia co phieu realtime (1 ma, nhieu ma, hoac theo san) |
|
|
83
|
+
| `get_price_history_chart` | Lich su gia OHLCV |
|
|
84
|
+
| `get_recommendation_reports` | Bao cao phan tich tu chuyen gia |
|
|
85
|
+
| `get_funds` | Danh sach quy dau tu |
|
|
86
|
+
| `get_fund_portfolio` | Danh muc cua quy |
|
|
87
|
+
| `get_fund_months` | Cac thang co du lieu quy |
|
|
88
|
+
| `get_gold_prices` | Gia vang (SJC, DOJI, PNJ, BTMC) |
|
|
89
|
+
| `get_gold_chart` | Bieu do gia vang |
|
|
90
|
+
| `get_gold_providers` | Gia vang theo nha cung cap |
|
|
91
|
+
| `get_silver_prices` | Gia bac |
|
|
92
|
+
| `get_silver_chart` | Bieu do gia bac |
|
|
93
|
+
| `get_all_financial_data` | Tong hop: vang, bac, crypto, lai suat, ty gia |
|
|
94
|
+
| `get_bank_interest_rates` | Lai suat tiet kiem ngan hang |
|
|
95
|
+
| `get_crypto_top_trending` | Crypto xu huong |
|
|
96
|
+
| `get_macro_data` | Chi so vi mo (CPI, PMI, IIP, FED rate...) |
|
|
97
|
+
| `get_market_session` | Trang thai phien giao dich |
|
|
98
|
+
|
|
99
|
+
### Tai khoan (11 tools)
|
|
100
|
+
|
|
101
|
+
| Tool | Mo ta |
|
|
102
|
+
|------|-------|
|
|
103
|
+
| `get_owner_info` | Thong tin chu tai khoan |
|
|
104
|
+
| `get_account_summary` | So du: tien mat, chung khoan, ky quy |
|
|
105
|
+
| `get_asset_summary` | Tong tai san |
|
|
106
|
+
| `get_portfolio` | Danh muc co phieu voi lai/lo |
|
|
107
|
+
| `get_pnl_today` | Lai/lo hom nay |
|
|
108
|
+
| `get_order_history` | Lich su lenh |
|
|
109
|
+
| `get_order_book` | So lenh trong ngay |
|
|
110
|
+
| `get_order_detail` | Chi tiet 1 lenh |
|
|
111
|
+
| `get_user_rights` | Quyen co dong: co tuc, quyen mua... |
|
|
112
|
+
| `get_trade_info` | Suc mua / so luong ban duoc |
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
## Yeu cau
|
|
116
|
+
|
|
117
|
+
- Node.js >= 18
|
|
118
|
+
- Tai khoan Finhay Securities voi API Key
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { FinhayClient } from './FinhayClient.js';
|
|
2
|
+
export interface AccountInfo {
|
|
3
|
+
userId: string;
|
|
4
|
+
defaultSubAccountId: string;
|
|
5
|
+
subAccountIds: string[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Fetches and caches account info (userId, subAccountIds) on startup.
|
|
9
|
+
* Tools use this as default when user does not provide subAccountId.
|
|
10
|
+
*/
|
|
11
|
+
export declare class AccountContext {
|
|
12
|
+
private readonly client;
|
|
13
|
+
private info;
|
|
14
|
+
constructor(client: FinhayClient);
|
|
15
|
+
init(): Promise<void>;
|
|
16
|
+
getUserId(): string | undefined;
|
|
17
|
+
getDefaultSubAccountId(): string | undefined;
|
|
18
|
+
getSubAccountIds(): string[];
|
|
19
|
+
/**
|
|
20
|
+
* Returns the given subAccountId if provided, otherwise falls back to default.
|
|
21
|
+
* Throws if neither is available.
|
|
22
|
+
*/
|
|
23
|
+
resolveSubAccountId(subAccountId?: string): string;
|
|
24
|
+
resolveUserId(userId?: string): string;
|
|
25
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetches and caches account info (userId, subAccountIds) on startup.
|
|
3
|
+
* Tools use this as default when user does not provide subAccountId.
|
|
4
|
+
*/
|
|
5
|
+
export class AccountContext {
|
|
6
|
+
client;
|
|
7
|
+
info = null;
|
|
8
|
+
constructor(client) {
|
|
9
|
+
this.client = client;
|
|
10
|
+
}
|
|
11
|
+
async init() {
|
|
12
|
+
try {
|
|
13
|
+
// Step 1: get userId
|
|
14
|
+
const meData = await this.client.get('/users/v1/users/me');
|
|
15
|
+
const meResult = meData.result ?? meData.data;
|
|
16
|
+
const userId = meResult?.user_id ?? meResult?.userId ?? meResult?.id ?? '';
|
|
17
|
+
if (!userId) {
|
|
18
|
+
throw new Error('Could not extract userId from /users/v1/users/me');
|
|
19
|
+
}
|
|
20
|
+
// Step 2: get sub-accounts
|
|
21
|
+
const subData = await this.client.get(`/users/v1/users/${userId}/sub-accounts`);
|
|
22
|
+
const subResult = subData.result ?? subData.data ?? [];
|
|
23
|
+
const subAccounts = Array.isArray(subResult) ? subResult : subResult?.subAccounts ?? subResult?.sub_accounts ?? [];
|
|
24
|
+
const subAccountIds = subAccounts.map((sa) => sa.subAccountId ?? sa.sub_account_id ?? sa.id ?? '').filter(Boolean);
|
|
25
|
+
this.info = {
|
|
26
|
+
userId: String(userId),
|
|
27
|
+
defaultSubAccountId: subAccountIds[0] ?? '',
|
|
28
|
+
subAccountIds,
|
|
29
|
+
};
|
|
30
|
+
console.error(`[finhay-mcp] Account loaded: userId=${this.info.userId}, subAccounts=[${subAccountIds.join(', ')}]`);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
console.error(`[finhay-mcp] Warning: could not fetch account info: ${err.message}`);
|
|
34
|
+
console.error('[finhay-mcp] Tools requiring subAccountId will need it as a parameter.');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
getUserId() {
|
|
38
|
+
return this.info?.userId || undefined;
|
|
39
|
+
}
|
|
40
|
+
getDefaultSubAccountId() {
|
|
41
|
+
return this.info?.defaultSubAccountId || undefined;
|
|
42
|
+
}
|
|
43
|
+
getSubAccountIds() {
|
|
44
|
+
return this.info?.subAccountIds ?? [];
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Returns the given subAccountId if provided, otherwise falls back to default.
|
|
48
|
+
* Throws if neither is available.
|
|
49
|
+
*/
|
|
50
|
+
resolveSubAccountId(subAccountId) {
|
|
51
|
+
const resolved = subAccountId || this.info?.defaultSubAccountId;
|
|
52
|
+
if (!resolved) {
|
|
53
|
+
throw new Error('subAccountId is required. Could not auto-detect — provide it explicitly or check your API credentials.');
|
|
54
|
+
}
|
|
55
|
+
return resolved;
|
|
56
|
+
}
|
|
57
|
+
resolveUserId(userId) {
|
|
58
|
+
const resolved = userId || this.info?.userId;
|
|
59
|
+
if (!resolved) {
|
|
60
|
+
throw new Error('userId is required. Could not auto-detect — provide it explicitly or check your API credentials.');
|
|
61
|
+
}
|
|
62
|
+
return resolved;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare class FinhayClient {
|
|
2
|
+
private readonly http;
|
|
3
|
+
private readonly signer;
|
|
4
|
+
private readonly apiKey;
|
|
5
|
+
constructor(baseUrl: string, apiKey: string, apiSecret: string);
|
|
6
|
+
/**
|
|
7
|
+
* GET request with HMAC signing.
|
|
8
|
+
*/
|
|
9
|
+
get(path: string, query?: Record<string, string>): Promise<any>;
|
|
10
|
+
/**
|
|
11
|
+
* POST request with HMAC signing + body hash.
|
|
12
|
+
*/
|
|
13
|
+
post(path: string, body: Record<string, any>): Promise<any>;
|
|
14
|
+
/**
|
|
15
|
+
* PUT request with HMAC signing + body hash.
|
|
16
|
+
*/
|
|
17
|
+
put(path: string, body: Record<string, any>): Promise<any>;
|
|
18
|
+
/**
|
|
19
|
+
* DELETE request with HMAC signing + body hash.
|
|
20
|
+
*/
|
|
21
|
+
delete(path: string, body: Record<string, any>): Promise<any>;
|
|
22
|
+
private writeRequest;
|
|
23
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import { HmacSigner } from './HmacSigner.js';
|
|
4
|
+
export class FinhayClient {
|
|
5
|
+
http;
|
|
6
|
+
signer;
|
|
7
|
+
apiKey;
|
|
8
|
+
constructor(baseUrl, apiKey, apiSecret) {
|
|
9
|
+
this.apiKey = apiKey;
|
|
10
|
+
this.signer = new HmacSigner(apiSecret);
|
|
11
|
+
this.http = axios.create({
|
|
12
|
+
baseURL: baseUrl,
|
|
13
|
+
timeout: 15000,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* GET request with HMAC signing.
|
|
18
|
+
*/
|
|
19
|
+
async get(path, query) {
|
|
20
|
+
const queryString = query
|
|
21
|
+
? Object.entries(query).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')
|
|
22
|
+
: null;
|
|
23
|
+
const fullPath = queryString ? `${path}?${queryString}` : path;
|
|
24
|
+
const timestamp = String(Date.now());
|
|
25
|
+
const nonce = randomUUID();
|
|
26
|
+
const bodyHash = '';
|
|
27
|
+
const payload = this.signer.buildPayload(timestamp, 'GET', path, queryString, null);
|
|
28
|
+
const signature = this.signer.sign(payload);
|
|
29
|
+
const headers = {
|
|
30
|
+
'X-FH-APIKEY': this.apiKey,
|
|
31
|
+
'X-FH-TIMESTAMP': timestamp,
|
|
32
|
+
'X-FH-NONCE': nonce,
|
|
33
|
+
'X-FH-SIGNATURE': signature,
|
|
34
|
+
'X-FH-BODYHASH': bodyHash,
|
|
35
|
+
'X-Origin-Method': 'GET',
|
|
36
|
+
'X-Origin-Path': path,
|
|
37
|
+
...(queryString ? { 'X-Origin-Query': queryString } : {}),
|
|
38
|
+
};
|
|
39
|
+
const response = await this.http.get(fullPath, { headers });
|
|
40
|
+
return response.data;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* POST request with HMAC signing + body hash.
|
|
44
|
+
*/
|
|
45
|
+
async post(path, body) {
|
|
46
|
+
return this.writeRequest('POST', path, body);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* PUT request with HMAC signing + body hash.
|
|
50
|
+
*/
|
|
51
|
+
async put(path, body) {
|
|
52
|
+
return this.writeRequest('PUT', path, body);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* DELETE request with HMAC signing + body hash.
|
|
56
|
+
*/
|
|
57
|
+
async delete(path, body) {
|
|
58
|
+
return this.writeRequest('DELETE', path, body);
|
|
59
|
+
}
|
|
60
|
+
async writeRequest(method, path, body) {
|
|
61
|
+
const bodyStr = JSON.stringify(body);
|
|
62
|
+
const bodyHash = HmacSigner.sha256(bodyStr);
|
|
63
|
+
const timestamp = String(Date.now());
|
|
64
|
+
const nonce = randomUUID();
|
|
65
|
+
const payload = this.signer.buildPayload(timestamp, method, path, null, bodyHash);
|
|
66
|
+
const signature = this.signer.sign(payload);
|
|
67
|
+
const headers = {
|
|
68
|
+
'X-FH-APIKEY': this.apiKey,
|
|
69
|
+
'X-FH-TIMESTAMP': timestamp,
|
|
70
|
+
'X-FH-NONCE': nonce,
|
|
71
|
+
'X-FH-SIGNATURE': signature,
|
|
72
|
+
'X-FH-BODYHASH': bodyHash,
|
|
73
|
+
'X-Origin-Method': method.toUpperCase(),
|
|
74
|
+
'X-Origin-Path': path,
|
|
75
|
+
'Content-Type': 'application/json',
|
|
76
|
+
};
|
|
77
|
+
const response = await this.http.request({
|
|
78
|
+
method,
|
|
79
|
+
url: path,
|
|
80
|
+
data: body,
|
|
81
|
+
headers,
|
|
82
|
+
});
|
|
83
|
+
return response.data;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare class HmacSigner {
|
|
2
|
+
private readonly secret;
|
|
3
|
+
constructor(secret: string);
|
|
4
|
+
/**
|
|
5
|
+
* Build HMAC payload.
|
|
6
|
+
* Format: {timestamp}\n{METHOD}\n{path}{?query}\n{bodyHash}
|
|
7
|
+
*/
|
|
8
|
+
buildPayload(timestamp: string, method: string, path: string, queryString: string | null, bodyHash: string | null): string;
|
|
9
|
+
/** HMAC-SHA256 sign */
|
|
10
|
+
sign(payload: string): string;
|
|
11
|
+
/** SHA256 hash (for body) */
|
|
12
|
+
static sha256(input: string): string;
|
|
13
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
export class HmacSigner {
|
|
3
|
+
secret;
|
|
4
|
+
constructor(secret) {
|
|
5
|
+
this.secret = secret;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Build HMAC payload.
|
|
9
|
+
* Format: {timestamp}\n{METHOD}\n{path}{?query}\n{bodyHash}
|
|
10
|
+
*/
|
|
11
|
+
buildPayload(timestamp, method, path, queryString, bodyHash) {
|
|
12
|
+
let payload = `${timestamp}\n${method.toUpperCase()}\n${path}`;
|
|
13
|
+
if (queryString) {
|
|
14
|
+
payload += `?${queryString}`;
|
|
15
|
+
}
|
|
16
|
+
payload += '\n';
|
|
17
|
+
if (bodyHash) {
|
|
18
|
+
payload += bodyHash;
|
|
19
|
+
}
|
|
20
|
+
return payload;
|
|
21
|
+
}
|
|
22
|
+
/** HMAC-SHA256 sign */
|
|
23
|
+
sign(payload) {
|
|
24
|
+
return crypto
|
|
25
|
+
.createHmac('sha256', this.secret)
|
|
26
|
+
.update(payload, 'utf8')
|
|
27
|
+
.digest('hex');
|
|
28
|
+
}
|
|
29
|
+
/** SHA256 hash (for body) */
|
|
30
|
+
static sha256(input) {
|
|
31
|
+
return crypto
|
|
32
|
+
.createHash('sha256')
|
|
33
|
+
.update(input, 'utf8')
|
|
34
|
+
.digest('hex');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
/** Shared credential path — same as finhay-skills-hub */
|
|
5
|
+
export const CREDENTIALS_PATH = path.join(os.homedir(), '.finhay', 'credentials', '.env');
|
|
6
|
+
/**
|
|
7
|
+
* Load credentials from ~/.finhay/credentials/.env into process.env
|
|
8
|
+
* (only sets vars that are not already defined).
|
|
9
|
+
*/
|
|
10
|
+
function loadCredentialsFile() {
|
|
11
|
+
if (!fs.existsSync(CREDENTIALS_PATH))
|
|
12
|
+
return;
|
|
13
|
+
const content = fs.readFileSync(CREDENTIALS_PATH, 'utf-8');
|
|
14
|
+
for (const line of content.split('\n')) {
|
|
15
|
+
const match = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.+?)\s*$/);
|
|
16
|
+
if (match && !process.env[match[1]]) {
|
|
17
|
+
process.env[match[1]] = match[2];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function getConfig() {
|
|
22
|
+
// Load from shared credentials file first, then env vars can override
|
|
23
|
+
loadCredentialsFile();
|
|
24
|
+
const apiKey = process.env.FINHAY_API_KEY;
|
|
25
|
+
const apiSecret = process.env.FINHAY_API_SECRET;
|
|
26
|
+
const baseUrl = process.env.FINHAY_BASE_URL || 'https://open-api.fhsc.com.vn';
|
|
27
|
+
if (!apiKey || !apiSecret) {
|
|
28
|
+
console.error(`Error: FINHAY_API_KEY and FINHAY_API_SECRET are required.`);
|
|
29
|
+
console.error(`Run "npx @finhay/mcp-server --install" to set up credentials.`);
|
|
30
|
+
console.error(`Or create ${CREDENTIALS_PATH} manually.`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
return { apiKey, apiSecret, baseUrl };
|
|
34
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Route to installer if --install flag is passed
|
|
3
|
+
if (process.argv.includes('--install')) {
|
|
4
|
+
const { run } = await import('./install.js');
|
|
5
|
+
await run;
|
|
6
|
+
process.exit(0);
|
|
7
|
+
}
|
|
8
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
9
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
10
|
+
import { getConfig } from './config/environment.js';
|
|
11
|
+
import { FinhayClient } from './client/FinhayClient.js';
|
|
12
|
+
import { AccountContext } from './client/AccountContext.js';
|
|
13
|
+
import { registerAllTools } from './tools/index.js';
|
|
14
|
+
const config = getConfig();
|
|
15
|
+
const client = new FinhayClient(config.baseUrl, config.apiKey, config.apiSecret);
|
|
16
|
+
const account = new AccountContext(client);
|
|
17
|
+
await account.init();
|
|
18
|
+
const server = new McpServer({
|
|
19
|
+
name: 'finhay-mcp-server',
|
|
20
|
+
version: '1.0.0',
|
|
21
|
+
});
|
|
22
|
+
registerAllTools(server, client, account);
|
|
23
|
+
const transport = new StdioServerTransport();
|
|
24
|
+
await server.connect(transport);
|
|
25
|
+
console.error('[finhay-mcp] Server started, connected via stdio');
|
package/dist/install.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
const CREDENTIALS_PATH = path.join(os.homedir(), '.finhay', 'credentials', '.env');
|
|
6
|
+
function ask(question) {
|
|
7
|
+
process.stdout.write(question);
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
let buf = '';
|
|
10
|
+
process.stdin.setEncoding('utf8');
|
|
11
|
+
process.stdin.resume();
|
|
12
|
+
const onData = (chunk) => {
|
|
13
|
+
buf += chunk;
|
|
14
|
+
if (buf.includes('\n')) {
|
|
15
|
+
process.stdin.pause();
|
|
16
|
+
process.stdin.removeListener('data', onData);
|
|
17
|
+
resolve(buf.replace('\n', '').trim());
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
process.stdin.on('data', onData);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function askMasked(question) {
|
|
24
|
+
process.stdout.write(question);
|
|
25
|
+
if (!process.stdin.isTTY)
|
|
26
|
+
return ask('');
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
process.stdin.setRawMode(true);
|
|
29
|
+
process.stdin.setEncoding('utf8');
|
|
30
|
+
process.stdin.resume();
|
|
31
|
+
let input = '';
|
|
32
|
+
const onData = (ch) => {
|
|
33
|
+
if (ch === '\r' || ch === '\n') {
|
|
34
|
+
process.stdin.setRawMode(false);
|
|
35
|
+
process.stdin.pause();
|
|
36
|
+
process.stdin.removeListener('data', onData);
|
|
37
|
+
process.stdout.write('\n');
|
|
38
|
+
resolve(input.trim());
|
|
39
|
+
}
|
|
40
|
+
else if (ch === '\u007F' || ch === '\b') {
|
|
41
|
+
if (input.length > 0) {
|
|
42
|
+
input = input.slice(0, -1);
|
|
43
|
+
process.stdout.write('\b \b');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else if (ch === '\u0003') {
|
|
47
|
+
process.stdout.write('\n');
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
input += ch;
|
|
52
|
+
process.stdout.write('*');
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
process.stdin.on('data', onData);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function getClaudeConfigPath() {
|
|
59
|
+
const platform = os.platform();
|
|
60
|
+
if (platform === 'darwin') {
|
|
61
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
|
|
62
|
+
}
|
|
63
|
+
else if (platform === 'win32') {
|
|
64
|
+
return path.join(process.env.APPDATA || '', 'Claude', 'claude_desktop_config.json');
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
return path.join(os.homedir(), '.config', 'Claude', 'claude_desktop_config.json');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function upsertEnvFile(filePath, vars) {
|
|
71
|
+
const dir = path.dirname(filePath);
|
|
72
|
+
if (!fs.existsSync(dir)) {
|
|
73
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
74
|
+
}
|
|
75
|
+
let lines = [];
|
|
76
|
+
if (fs.existsSync(filePath)) {
|
|
77
|
+
lines = fs.readFileSync(filePath, 'utf-8').split('\n');
|
|
78
|
+
}
|
|
79
|
+
const written = new Set();
|
|
80
|
+
const updatedLines = lines.map((line) => {
|
|
81
|
+
const match = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=/);
|
|
82
|
+
if (match && match[1] in vars) {
|
|
83
|
+
written.add(match[1]);
|
|
84
|
+
return `${match[1]}=${vars[match[1]]}`;
|
|
85
|
+
}
|
|
86
|
+
return line;
|
|
87
|
+
});
|
|
88
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
89
|
+
if (!written.has(key)) {
|
|
90
|
+
updatedLines.push(`${key}=${value}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const content = updatedLines.filter((l) => l !== '').join('\n') + '\n';
|
|
94
|
+
fs.writeFileSync(filePath, content, { mode: 0o600 });
|
|
95
|
+
}
|
|
96
|
+
function updateClaudeConfig() {
|
|
97
|
+
const configPath = getClaudeConfigPath();
|
|
98
|
+
const configDir = path.dirname(configPath);
|
|
99
|
+
if (!fs.existsSync(configDir)) {
|
|
100
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
101
|
+
}
|
|
102
|
+
let config = {};
|
|
103
|
+
if (fs.existsSync(configPath)) {
|
|
104
|
+
try {
|
|
105
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
console.log(` Canh bao: Khong doc duoc ${configPath}, se tao file moi.`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (!config.mcpServers) {
|
|
112
|
+
config.mcpServers = {};
|
|
113
|
+
}
|
|
114
|
+
config.mcpServers.finhay = {
|
|
115
|
+
command: 'npx',
|
|
116
|
+
args: ['-y', 'finhay-mcp-server'],
|
|
117
|
+
};
|
|
118
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
119
|
+
console.log(` Claude Desktop config: ${configPath}`);
|
|
120
|
+
}
|
|
121
|
+
async function main() {
|
|
122
|
+
console.log('\n Finhay MCP Server — Cai dat cho Claude Desktop\n');
|
|
123
|
+
console.log(' Tao API Key tai: https://www.finhay.com.vn/finhay-skills\n');
|
|
124
|
+
let apiKey = '';
|
|
125
|
+
let apiSecret = '';
|
|
126
|
+
if (fs.existsSync(CREDENTIALS_PATH)) {
|
|
127
|
+
const content = fs.readFileSync(CREDENTIALS_PATH, 'utf-8');
|
|
128
|
+
const keyMatch = content.match(/FINHAY_API_KEY=(.+)/);
|
|
129
|
+
const secretMatch = content.match(/FINHAY_API_SECRET=(.+)/);
|
|
130
|
+
if (keyMatch && secretMatch) {
|
|
131
|
+
const maskedKey = keyMatch[1].trim().slice(0, 8) + '***';
|
|
132
|
+
console.log(` Tim thay credentials tai ${CREDENTIALS_PATH}`);
|
|
133
|
+
console.log(` API Key: ${maskedKey}\n`);
|
|
134
|
+
const reuse = await ask(' Su dung credentials nay? (Y/n): ');
|
|
135
|
+
if (reuse.toLowerCase() !== 'n') {
|
|
136
|
+
apiKey = keyMatch[1].trim();
|
|
137
|
+
apiSecret = secretMatch[1].trim();
|
|
138
|
+
}
|
|
139
|
+
console.log();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!apiKey) {
|
|
143
|
+
apiKey = await ask(' API Key: ');
|
|
144
|
+
if (!apiKey) {
|
|
145
|
+
console.error('\n Loi: API Key khong duoc de trong.\n');
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
apiSecret = await askMasked(' API Secret: ');
|
|
149
|
+
if (!apiSecret) {
|
|
150
|
+
console.error('\n Loi: API Secret khong duoc de trong.\n');
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
upsertEnvFile(CREDENTIALS_PATH, {
|
|
154
|
+
FINHAY_API_KEY: apiKey,
|
|
155
|
+
FINHAY_API_SECRET: apiSecret,
|
|
156
|
+
FINHAY_BASE_URL: 'https://open-api.fhsc.com.vn',
|
|
157
|
+
});
|
|
158
|
+
console.log(`\n Credentials: ${CREDENTIALS_PATH} (permission: 600)\n`);
|
|
159
|
+
}
|
|
160
|
+
updateClaudeConfig();
|
|
161
|
+
console.log('\n Da cai dat thanh cong!');
|
|
162
|
+
console.log(' Hay khoi dong lai Claude Desktop de su dung.\n');
|
|
163
|
+
}
|
|
164
|
+
export const run = main().catch((err) => {
|
|
165
|
+
console.error('Loi:', err.message);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { FinhayClient } from '../client/FinhayClient.js';
|
|
3
|
+
import { AccountContext } from '../client/AccountContext.js';
|
|
4
|
+
export declare function registerAllTools(server: McpServer, client: FinhayClient, account: AccountContext): void;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { registerMarketTools } from './market.js';
|
|
2
|
+
import { registerPortfolioTools } from './portfolio.js';
|
|
3
|
+
export function registerAllTools(server, client, account) {
|
|
4
|
+
registerMarketTools(server, client); // 18 tools: stock, funds, gold, silver, crypto, macro, etc.
|
|
5
|
+
registerPortfolioTools(server, client, account); // 11 tools: account, portfolio, orders, pnl, rights, etc.
|
|
6
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { safeHandler } from '../utils/safeTool.js';
|
|
3
|
+
export function registerMarketTools(server, client) {
|
|
4
|
+
// --- Stock realtime ---
|
|
5
|
+
server.tool('get_stock_realtime', 'Get realtime stock price. Use ONE of: symbol (single), symbols (multiple, comma-separated), or exchange (HOSE/HNX/UPCOM)', {
|
|
6
|
+
symbol: z.string().optional().describe('Single stock symbol (e.g., VNM)'),
|
|
7
|
+
symbols: z.string().optional().describe('Comma-separated symbols (e.g., VNM,FPT,VIC)'),
|
|
8
|
+
exchange: z.string().optional().describe('Exchange code: HOSE, HNX, or UPCOM'),
|
|
9
|
+
}, safeHandler(async ({ symbol, symbols, exchange }) => {
|
|
10
|
+
const query = {};
|
|
11
|
+
if (symbol)
|
|
12
|
+
query.symbol = symbol.toUpperCase();
|
|
13
|
+
else if (symbols)
|
|
14
|
+
query.symbols = symbols.toUpperCase();
|
|
15
|
+
else if (exchange)
|
|
16
|
+
query.exchange = exchange.toUpperCase();
|
|
17
|
+
const data = await client.get('/market/stock-realtime', query);
|
|
18
|
+
return JSON.stringify(data.result, null, 2);
|
|
19
|
+
}));
|
|
20
|
+
// --- Price history chart ---
|
|
21
|
+
server.tool('get_price_history_chart', 'Get OHLCV price history chart for a stock. Timestamps must be Unix seconds (not milliseconds).', {
|
|
22
|
+
symbol: z.string().describe('Stock symbol (e.g., FPT)'),
|
|
23
|
+
resolution: z.string().optional().describe('Resolution, only "1D" supported').default('1D'),
|
|
24
|
+
from: z.number().describe('Start timestamp in Unix SECONDS'),
|
|
25
|
+
to: z.number().describe('End timestamp in Unix SECONDS'),
|
|
26
|
+
}, safeHandler(async ({ symbol, resolution, from, to }) => {
|
|
27
|
+
const data = await client.get('/market/price-histories-chart', {
|
|
28
|
+
symbol: symbol.toUpperCase(),
|
|
29
|
+
resolution: resolution || '1D',
|
|
30
|
+
from: String(from),
|
|
31
|
+
to: String(to),
|
|
32
|
+
});
|
|
33
|
+
return JSON.stringify(data.data, null, 2);
|
|
34
|
+
}));
|
|
35
|
+
// --- Recommendation reports ---
|
|
36
|
+
server.tool('get_recommendation_reports', 'Get analyst recommendation reports for a stock symbol', { symbol: z.string().describe('Stock symbol (e.g., VNM)') }, safeHandler(async ({ symbol }) => {
|
|
37
|
+
const data = await client.get(`/market/recommendation-reports/${symbol.toUpperCase()}`);
|
|
38
|
+
return JSON.stringify(data.data, null, 2);
|
|
39
|
+
}));
|
|
40
|
+
// --- Funds ---
|
|
41
|
+
server.tool('get_funds', 'Get list of all available investment funds with performance data', {}, safeHandler(async () => {
|
|
42
|
+
const data = await client.get('/market/funds');
|
|
43
|
+
return JSON.stringify(data.data, null, 2);
|
|
44
|
+
}));
|
|
45
|
+
server.tool('get_fund_portfolio', 'Get portfolio composition of a specific fund', {
|
|
46
|
+
fund: z.string().describe('Fund code (e.g., DCDS)'),
|
|
47
|
+
month: z.string().optional().describe('Month in YYYY-MM format (defaults to latest)'),
|
|
48
|
+
}, safeHandler(async ({ fund, month }) => {
|
|
49
|
+
const query = {};
|
|
50
|
+
if (month)
|
|
51
|
+
query.month = month;
|
|
52
|
+
const data = await client.get(`/market/funds/${fund}/portfolio`, query);
|
|
53
|
+
return JSON.stringify(data.data, null, 2);
|
|
54
|
+
}));
|
|
55
|
+
server.tool('get_fund_months', 'Get available months for a fund portfolio', { fund: z.string().describe('Fund code (e.g., DCDS)') }, safeHandler(async ({ fund }) => {
|
|
56
|
+
const data = await client.get(`/market/funds/${fund}/months`);
|
|
57
|
+
return JSON.stringify(data.data, null, 2);
|
|
58
|
+
}));
|
|
59
|
+
// --- Gold ---
|
|
60
|
+
server.tool('get_gold_prices', 'Get current gold prices from Vietnamese providers (SJC, DOJI, PNJ, BTMC)', {}, safeHandler(async () => {
|
|
61
|
+
const data = await client.get('/market/financial-data/gold');
|
|
62
|
+
return JSON.stringify(data.data, null, 2);
|
|
63
|
+
}));
|
|
64
|
+
server.tool('get_gold_chart', 'Get gold price chart data over a number of days', { days: z.number().optional().describe('Number of days (default: 30)').default(30) }, safeHandler(async ({ days }) => {
|
|
65
|
+
const data = await client.get('/market/financial-data/gold-chart', { days: String(days) });
|
|
66
|
+
return JSON.stringify(data.data, null, 2);
|
|
67
|
+
}));
|
|
68
|
+
server.tool('get_gold_providers', 'Get gold prices grouped by provider (PNJ, DOJI, BTMC, SJC)', {}, safeHandler(async () => {
|
|
69
|
+
const data = await client.get('/market/financial-data/gold-providers');
|
|
70
|
+
return JSON.stringify(data.data, null, 2);
|
|
71
|
+
}));
|
|
72
|
+
// --- Silver ---
|
|
73
|
+
server.tool('get_silver_prices', 'Get current silver prices', {}, safeHandler(async () => {
|
|
74
|
+
const data = await client.get('/market/financial-data/silver');
|
|
75
|
+
return JSON.stringify(data.data, null, 2);
|
|
76
|
+
}));
|
|
77
|
+
server.tool('get_silver_chart', 'Get silver price chart data over a number of days', { days: z.number().optional().describe('Number of days (default: 30)').default(30) }, safeHandler(async ({ days }) => {
|
|
78
|
+
const data = await client.get('/market/financial-data/silver-chart', { days: String(days) });
|
|
79
|
+
return JSON.stringify(data.data, null, 2);
|
|
80
|
+
}));
|
|
81
|
+
// --- Financial data ---
|
|
82
|
+
server.tool('get_all_financial_data', 'Get all financial data: gold, silver, crypto, bank rates, USD exchange rate', {}, safeHandler(async () => {
|
|
83
|
+
const data = await client.get('/market/financial-data');
|
|
84
|
+
return JSON.stringify(data.data, null, 2);
|
|
85
|
+
}));
|
|
86
|
+
server.tool('get_bank_interest_rates', 'Get bank deposit interest rates from Vietnamese banks', {}, safeHandler(async () => {
|
|
87
|
+
const data = await client.get('/market/financial-data/bank-interest-rates');
|
|
88
|
+
return JSON.stringify(data.data, null, 2);
|
|
89
|
+
}));
|
|
90
|
+
server.tool('get_crypto_top_trending', 'Get top trending cryptocurrencies with price, market cap, and 30-day chart', {}, safeHandler(async () => {
|
|
91
|
+
const data = await client.get('/market/financial-data/cryptos/top-trending');
|
|
92
|
+
return JSON.stringify(data.data, null, 2);
|
|
93
|
+
}));
|
|
94
|
+
// --- Macro ---
|
|
95
|
+
server.tool('get_macro_data', 'Get macroeconomic indicators for Vietnam or US (CPI, PMI, IIP, FED rate, etc.)', {
|
|
96
|
+
type: z.enum([
|
|
97
|
+
'IIP', 'CPI', 'PMI', 'PCE', 'CORE_PCE', 'NFP', 'GOODS_RETAIL', 'SERVICE_RETAIL',
|
|
98
|
+
'TOTAL_EXPORT', 'FDI_EXPORT', 'DOMESTIC_EXPORT', 'FED_FUNDS_RATE', 'INTERBANK_RATE',
|
|
99
|
+
'GOVERNMENT_10Y_BOND_YIELD', 'UNEMPLOYMENT_RATE',
|
|
100
|
+
]).describe('Macro indicator type'),
|
|
101
|
+
country: z.enum(['VN', 'US']).describe('Country code'),
|
|
102
|
+
period: z.enum(['ONE_MONTH', 'ONE_YEAR', 'YTD']).optional().describe('Time period filter'),
|
|
103
|
+
}, safeHandler(async ({ type, country, period }) => {
|
|
104
|
+
const query = { type, country };
|
|
105
|
+
if (period)
|
|
106
|
+
query.period = period;
|
|
107
|
+
const data = await client.get('/market/financial-data/macro', query);
|
|
108
|
+
return JSON.stringify(data.data, null, 2);
|
|
109
|
+
}));
|
|
110
|
+
// --- Market session ---
|
|
111
|
+
server.tool('get_market_session', 'Get current market session status and available order types for an exchange', {
|
|
112
|
+
exchange: z.enum(['HOSE', 'HNX', 'UPCOM', 'HCX']).describe('Exchange code'),
|
|
113
|
+
}, safeHandler(async ({ exchange }) => {
|
|
114
|
+
const data = await client.get('/trading/market/session', { exchange });
|
|
115
|
+
return JSON.stringify(data.result, null, 2);
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { FinhayClient } from '../client/FinhayClient.js';
|
|
3
|
+
import { AccountContext } from '../client/AccountContext.js';
|
|
4
|
+
export declare function registerPortfolioTools(server: McpServer, client: FinhayClient, account: AccountContext): void;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { safeHandler } from '../utils/safeTool.js';
|
|
3
|
+
export function registerPortfolioTools(server, client, account) {
|
|
4
|
+
// --- Owner ---
|
|
5
|
+
server.tool('get_owner_info', 'Get owner identity info (name, accounts, sub-account IDs, etc.)', {}, safeHandler(async () => {
|
|
6
|
+
const data = await client.get('/users/v1/users/me');
|
|
7
|
+
return JSON.stringify(data.result, null, 2);
|
|
8
|
+
}));
|
|
9
|
+
// --- Account summary ---
|
|
10
|
+
server.tool('get_account_summary', 'Get account balance summary: cash, securities value, margin, net asset value', {
|
|
11
|
+
subAccountId: z.string().optional().describe('Sub-account ID (auto-detected if omitted)'),
|
|
12
|
+
}, safeHandler(async ({ subAccountId }) => {
|
|
13
|
+
const id = account.resolveSubAccountId(subAccountId);
|
|
14
|
+
const data = await client.get(`/trading/accounts/${id}/summary`);
|
|
15
|
+
return JSON.stringify(data.result, null, 2);
|
|
16
|
+
}));
|
|
17
|
+
// --- Asset summary ---
|
|
18
|
+
server.tool('get_asset_summary', 'Get asset summary with total valuation', {
|
|
19
|
+
subAccountId: z.string().optional().describe('Sub-account ID (auto-detected if omitted)'),
|
|
20
|
+
}, safeHandler(async ({ subAccountId }) => {
|
|
21
|
+
const id = account.resolveSubAccountId(subAccountId);
|
|
22
|
+
const data = await client.get(`/trading/sub-accounts/${id}/asset-summary`);
|
|
23
|
+
return JSON.stringify(data.data, null, 2);
|
|
24
|
+
}));
|
|
25
|
+
// --- Portfolio ---
|
|
26
|
+
server.tool('get_portfolio', 'Get portfolio positions with P/L, cost basis, available quantity per stock', {
|
|
27
|
+
subAccountId: z.string().optional().describe('Sub-account ID (auto-detected if omitted)'),
|
|
28
|
+
}, safeHandler(async ({ subAccountId }) => {
|
|
29
|
+
const id = account.resolveSubAccountId(subAccountId);
|
|
30
|
+
const data = await client.get(`/trading/v2/sub-accounts/${id}/portfolio`);
|
|
31
|
+
return JSON.stringify(data.data, null, 2);
|
|
32
|
+
}));
|
|
33
|
+
// --- PnL today ---
|
|
34
|
+
server.tool('get_pnl_today', 'Get today profit/loss amount and rate', {
|
|
35
|
+
userId: z.string().optional().describe('User ID (auto-detected if omitted)'),
|
|
36
|
+
subAccountId: z.string().optional().describe('Sub-account ID (default: ALL)'),
|
|
37
|
+
}, safeHandler(async ({ userId, subAccountId }) => {
|
|
38
|
+
const uid = account.resolveUserId(userId);
|
|
39
|
+
const query = {};
|
|
40
|
+
if (subAccountId)
|
|
41
|
+
query['sub-account-id'] = subAccountId;
|
|
42
|
+
const data = await client.get(`/trading/pnl-today/${uid}`, query);
|
|
43
|
+
return JSON.stringify(data.data, null, 2);
|
|
44
|
+
}));
|
|
45
|
+
// --- Order history ---
|
|
46
|
+
server.tool('get_order_history', 'Get order history for a sub-account within a date range', {
|
|
47
|
+
subAccountId: z.string().optional().describe('Sub-account ID (auto-detected if omitted)'),
|
|
48
|
+
fromDate: z.string().describe('Start date (YYYY-MM-DD)'),
|
|
49
|
+
toDate: z.string().describe('End date (YYYY-MM-DD)'),
|
|
50
|
+
page: z.number().optional().describe('Page number (default: 1)'),
|
|
51
|
+
orderStatus: z.string().optional().describe('Filter by status (default: ALL)'),
|
|
52
|
+
symbol: z.string().optional().describe('Filter by symbol (default: ALL)'),
|
|
53
|
+
}, safeHandler(async ({ subAccountId, fromDate, toDate, page, orderStatus, symbol }) => {
|
|
54
|
+
const id = account.resolveSubAccountId(subAccountId);
|
|
55
|
+
const query = { fromDate, toDate };
|
|
56
|
+
if (page)
|
|
57
|
+
query.page = String(page);
|
|
58
|
+
if (orderStatus)
|
|
59
|
+
query.orderStatus = orderStatus;
|
|
60
|
+
if (symbol)
|
|
61
|
+
query.symbol = symbol.toUpperCase();
|
|
62
|
+
const data = await client.get(`/trading/sub-accounts/${id}/orders`, query);
|
|
63
|
+
return JSON.stringify(data.result, null, 2);
|
|
64
|
+
}));
|
|
65
|
+
// --- Order book (intraday) ---
|
|
66
|
+
server.tool('get_order_book', 'Get intraday order book (today pending/matched orders)', {
|
|
67
|
+
subAccountId: z.string().optional().describe('Sub-account ID (auto-detected if omitted)'),
|
|
68
|
+
}, safeHandler(async ({ subAccountId }) => {
|
|
69
|
+
const id = account.resolveSubAccountId(subAccountId);
|
|
70
|
+
const data = await client.get(`/trading/v1/accounts/${id}/order-book`);
|
|
71
|
+
return JSON.stringify(data.result, null, 2);
|
|
72
|
+
}));
|
|
73
|
+
// --- Order detail ---
|
|
74
|
+
server.tool('get_order_detail', 'Get detail of a specific order by ID', {
|
|
75
|
+
subAccountId: z.string().optional().describe('Sub-account ID (auto-detected if omitted)'),
|
|
76
|
+
orderId: z.string().describe('Order ID'),
|
|
77
|
+
}, safeHandler(async ({ subAccountId, orderId }) => {
|
|
78
|
+
const id = account.resolveSubAccountId(subAccountId);
|
|
79
|
+
const data = await client.get(`/trading/v1/accounts/${id}/order-book/${orderId}`);
|
|
80
|
+
return JSON.stringify(data.data, null, 2);
|
|
81
|
+
}));
|
|
82
|
+
// --- User rights (corporate actions) ---
|
|
83
|
+
server.tool('get_user_rights', 'Get corporate actions: dividends, stock rights, meetings, etc.', {
|
|
84
|
+
subAccountId: z.string().optional().describe('Sub-account ID (auto-detected if omitted)'),
|
|
85
|
+
fromDate: z.string().optional().describe('Start date (YYYY-MM-DD)'),
|
|
86
|
+
toDate: z.string().optional().describe('End date (YYYY-MM-DD)'),
|
|
87
|
+
catType: z.string().optional().describe('Category type, comma-separated or ALL (default: ALL)'),
|
|
88
|
+
symbol: z.string().optional().describe('Filter by symbol (default: ALL)'),
|
|
89
|
+
status: z.string().optional().describe('Status filter, comma-separated or ALL (default: ALL)'),
|
|
90
|
+
}, safeHandler(async ({ subAccountId, fromDate, toDate, catType, symbol, status }) => {
|
|
91
|
+
const id = account.resolveSubAccountId(subAccountId);
|
|
92
|
+
const query = {};
|
|
93
|
+
if (fromDate)
|
|
94
|
+
query.fromDate = fromDate;
|
|
95
|
+
if (toDate)
|
|
96
|
+
query.toDate = toDate;
|
|
97
|
+
if (catType)
|
|
98
|
+
query.catType = catType;
|
|
99
|
+
if (symbol)
|
|
100
|
+
query.symbol = symbol.toUpperCase();
|
|
101
|
+
if (status)
|
|
102
|
+
query.status = status;
|
|
103
|
+
const data = await client.get(`/trading/v5/account/${id}/user-rights`, query);
|
|
104
|
+
return JSON.stringify(data.result, null, 2);
|
|
105
|
+
}));
|
|
106
|
+
// --- Trade info (pre-order check) ---
|
|
107
|
+
server.tool('get_trade_info', 'Get buying power (BUY) or available quantity (SELL) before placing an order', {
|
|
108
|
+
subAccountId: z.string().optional().describe('Sub-account ID (auto-detected if omitted)'),
|
|
109
|
+
symbol: z.string().describe('Stock symbol'),
|
|
110
|
+
side: z.enum(['BUY', 'SELL']).describe('Order side'),
|
|
111
|
+
quotePrice: z.number().describe('Quote price in VND'),
|
|
112
|
+
}, safeHandler(async ({ subAccountId, symbol, side, quotePrice }) => {
|
|
113
|
+
const id = account.resolveSubAccountId(subAccountId);
|
|
114
|
+
const data = await client.get(`/trading/sub-accounts/${id}/trade-info`, {
|
|
115
|
+
symbol: symbol.toUpperCase(),
|
|
116
|
+
side,
|
|
117
|
+
quote_price: String(quotePrice),
|
|
118
|
+
});
|
|
119
|
+
return JSON.stringify(data.result, null, 2);
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const vndFmt = new Intl.NumberFormat('vi-VN');
|
|
2
|
+
export function formatVND(amount) {
|
|
3
|
+
return vndFmt.format(amount) + 'đ';
|
|
4
|
+
}
|
|
5
|
+
export function formatVolume(vol) {
|
|
6
|
+
if (vol >= 1_000_000)
|
|
7
|
+
return (vol / 1_000_000).toFixed(1) + 'M';
|
|
8
|
+
if (vol >= 1_000)
|
|
9
|
+
return (vol / 1_000).toFixed(0) + 'K';
|
|
10
|
+
return String(vol);
|
|
11
|
+
}
|
|
12
|
+
export function formatPercent(pct) {
|
|
13
|
+
return (pct > 0 ? '+' : '') + pct.toFixed(2) + '%';
|
|
14
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
type ToolResult = {
|
|
2
|
+
content: {
|
|
3
|
+
type: 'text';
|
|
4
|
+
text: string;
|
|
5
|
+
}[];
|
|
6
|
+
isError?: true;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Wraps an async handler so that errors are caught and returned
|
|
10
|
+
* as MCP error responses instead of crashing the server.
|
|
11
|
+
*/
|
|
12
|
+
export declare function safeHandler<T>(fn: (args: T) => Promise<string>): (args: T) => Promise<ToolResult>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
/**
|
|
3
|
+
* Wraps an async handler so that errors are caught and returned
|
|
4
|
+
* as MCP error responses instead of crashing the server.
|
|
5
|
+
*/
|
|
6
|
+
export function safeHandler(fn) {
|
|
7
|
+
return async (args) => {
|
|
8
|
+
try {
|
|
9
|
+
const text = await fn(args);
|
|
10
|
+
return { content: [{ type: 'text', text }] };
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
const message = formatError(err);
|
|
14
|
+
return { content: [{ type: 'text', text: message }], isError: true };
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function formatError(err) {
|
|
19
|
+
if (axios.isAxiosError(err)) {
|
|
20
|
+
const status = err.response?.status;
|
|
21
|
+
const body = err.response?.data;
|
|
22
|
+
const detail = typeof body === 'object' ? JSON.stringify(body, null, 2) : String(body ?? '');
|
|
23
|
+
return `API error ${status ?? 'NETWORK'}: ${err.message}${detail ? `\n${detail}` : ''}`;
|
|
24
|
+
}
|
|
25
|
+
if (err instanceof Error) {
|
|
26
|
+
return `Error: ${err.message}`;
|
|
27
|
+
}
|
|
28
|
+
return `Error: ${String(err)}`;
|
|
29
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "finhay-mcp-server",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Finhay MCP Server — xem gia co phieu, danh muc dau tu qua Claude AI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/finhay/mcp-server.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"mcp",
|
|
13
|
+
"finhay",
|
|
14
|
+
"stock",
|
|
15
|
+
"vietnam",
|
|
16
|
+
"claude",
|
|
17
|
+
"ai"
|
|
18
|
+
],
|
|
19
|
+
"bin": {
|
|
20
|
+
"finhay-mcp-server": "dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"main": "dist/index.js",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18.0.0"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc",
|
|
28
|
+
"start": "node dist/index.js",
|
|
29
|
+
"dev": "tsc && node dist/index.js",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"prepublishOnly": "npm run build && npm test"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
35
|
+
"axios": "^1.7.0",
|
|
36
|
+
"zod": "^3.22.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^18.19.130",
|
|
40
|
+
"typescript": "^5.5.0",
|
|
41
|
+
"vitest": "^1.6.1"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"dist",
|
|
45
|
+
"!dist/__tests__",
|
|
46
|
+
"!dist/install.js.map"
|
|
47
|
+
]
|
|
48
|
+
}
|