investdesk-quotes 0.1.0 → 0.3.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 +42 -4
- package/dist/cli/quote.js +38 -0
- package/dist/config.js +68 -0
- package/dist/feishu/backend.js +8 -0
- package/dist/feishu/base.js +88 -0
- package/dist/feishu/openapi.js +119 -0
- package/dist/mcp/server.js +201 -0
- package/{src/mcp/stdio.ts → dist/mcp/stdio.js} +2 -2
- package/dist/mcp/tools.js +165 -0
- package/dist/quotes/eastmoney.js +46 -0
- package/dist/quotes/index.js +134 -0
- package/dist/quotes/tencent.js +71 -0
- package/dist/quotes/types.js +8 -0
- package/dist/sync.js +32 -0
- package/package.json +18 -12
- package/src/cli/quote.ts +0 -45
- package/src/config.ts +0 -62
- package/src/feishu/base.ts +0 -188
- package/src/mcp/server.ts +0 -230
- package/src/mcp/tools.ts +0 -201
- package/src/quotes/eastmoney.ts +0 -62
- package/src/quotes/index.ts +0 -160
- package/src/quotes/tencent.ts +0 -80
- package/src/quotes/types.ts +0 -55
- package/src/sync.ts +0 -53
- package/tsconfig.json +0 -16
package/README.md
CHANGED
|
@@ -45,10 +45,14 @@ A 股与场外公募基金行情查询 + 飞书多维表格持仓读写的 MCP
|
|
|
45
45
|
两层,都不经过 MCP 参数:
|
|
46
46
|
|
|
47
47
|
1. **客户端 → MCP**:无认证,绑定 `127.0.0.1`,仅供本机客户端使用
|
|
48
|
-
2. **MCP →
|
|
48
|
+
2. **MCP → 飞书**:飞书**自建应用**的 `tenant_access_token`,用 `FEISHU_APP_ID` / `FEISHU_APP_SECRET` 换取
|
|
49
49
|
|
|
50
50
|
行情接口免认证。
|
|
51
51
|
|
|
52
|
+
> 0.3.0 起移除了 lark-cli 后端。此前迁移期同时支持「本机用户令牌」和「应用令牌」两套,
|
|
53
|
+
> 收敛成单后端是因为两条路会带来行为差异——OpenAPI 与 lark-cli 的记录形状完全不同,
|
|
54
|
+
> 曾导致字段全部解析为空。现在本机、云端、CI 走的是同一条路径。
|
|
55
|
+
|
|
52
56
|
## 配置
|
|
53
57
|
|
|
54
58
|
工作目录默认 `~/.investdesk`,可用 `INVESTDESK_HOME` 覆盖。**代码在 Store 检出目录,状态在工作目录**,
|
|
@@ -63,7 +67,7 @@ INVESTDESK_PLATFORMS_TABLE=... # 平台表 table id
|
|
|
63
67
|
MCP_PORT=8787 # 可选
|
|
64
68
|
```
|
|
65
69
|
|
|
66
|
-
`.cache/` 与 `inbox/`
|
|
70
|
+
`.cache/` 与 `inbox/` 会自动创建,用于存放运行日志和邮件附件。
|
|
67
71
|
|
|
68
72
|
## 运行
|
|
69
73
|
|
|
@@ -74,7 +78,41 @@ npm run quote -- 510300 600028 # 手动查场内
|
|
|
74
78
|
npm run quote -- f:008284 # 手动查场外(f: 前缀)
|
|
75
79
|
```
|
|
76
80
|
|
|
77
|
-
|
|
81
|
+
**源码是 TypeScript,但发布到 npm 的是编译后的 JS(`dist/`)。** 这不是洁癖:
|
|
82
|
+
|
|
83
|
+
> Node 拒绝为 `node_modules` 下的文件做类型擦除
|
|
84
|
+
> (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`)。
|
|
85
|
+
> 直接发布 `.ts` 在本地源码目录能跑,`npm install` 之后却起不来——
|
|
86
|
+
> 而 `npm pack --dry-run` 不会暴露这个问题,只有真装一次才会。
|
|
87
|
+
|
|
88
|
+
所以 `prepare` 会在发布前自动 `tsc`,`bin` 指向 `dist/`。运行时零依赖,构建期只需要 `typescript`。
|
|
89
|
+
本地开发仍然可以直接 `node src/mcp/server.ts`(不在 `node_modules` 下,类型擦除可用)。
|
|
90
|
+
|
|
91
|
+
## 认证配置(跑起来的前置条件)
|
|
92
|
+
|
|
93
|
+
用飞书**自建应用**的 `tenant_access_token` 访问 Base,不依赖任何本机凭据,
|
|
94
|
+
所以本机、服务器、CI 的配置完全一样:
|
|
95
|
+
|
|
96
|
+
1. 在[飞书开发者后台](https://open.feishu.cn/app)创建企业自建应用,记下 `app_id` / `app_secret`
|
|
97
|
+
2. 「权限管理」开通多维表格权限:`bitable:app`(包含记录读写)
|
|
98
|
+
3. 在该 Base 的「协作者」里把应用加为**可编辑**——只开通 scope 不加协作者,调用会报权限错误
|
|
99
|
+
4. 配置环境变量:
|
|
100
|
+
|
|
101
|
+
```dotenv
|
|
102
|
+
FEISHU_APP_ID=cli_xxxxxxxxxxxx
|
|
103
|
+
FEISHU_APP_SECRET=xxxxxxxxxxxxxxxx
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
验证配置是否生效:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
node -e "import('./dist/feishu/base.js').then(async m=>{console.log('backend:',m.backendName());console.log('records:',(await m.listHoldings()).length)})"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
期望输出 `backend: openapi` 与 `records: <你的持仓数>`。
|
|
113
|
+
|
|
114
|
+
> `tenant_access_token` 是应用身份,看到的数据范围由「应用被授权访问哪些资源」决定,
|
|
115
|
+
> 与某个人的可见范围无关。换 Base 或换表时,协作者授权要同步更新。
|
|
78
116
|
|
|
79
117
|
## 在豆包工作中接入
|
|
80
118
|
|
|
@@ -83,6 +121,6 @@ URL 填 `http://127.0.0.1:8787/mcp` → 保存并打开开关。
|
|
|
83
121
|
|
|
84
122
|
## 限制
|
|
85
123
|
|
|
86
|
-
- `status: limited
|
|
124
|
+
- `status: limited`:需要先创建飞书自建应用、开通 `bitable:app` 并加为 Base 协作者,不是装上即用
|
|
87
125
|
- 只覆盖公开行情,**没有** Level-2、资金流、公告正文等付费数据
|
|
88
126
|
- 不做投资建议;输出只有数据口径,不产出买卖结论
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 手动查价 CLI。
|
|
3
|
+
*
|
|
4
|
+
* node src/cli/quote.ts 510300 600028 512670 # 场内
|
|
5
|
+
* node src/cli/quote.ts f:008284 f:110017 # 场外(f: 前缀强制走净值)
|
|
6
|
+
*
|
|
7
|
+
* 加 f: 前缀是必要的:同一串 6 位代码可能既是股票又是场外基金
|
|
8
|
+
* (000866 既是已退市的扬子石化,也是华宝制造股票),不能靠规则猜。
|
|
9
|
+
*/
|
|
10
|
+
import { fetchQuotes } from '../quotes/index.js';
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
if (args.length === 0) {
|
|
13
|
+
console.error('用法: node src/cli/quote.ts <代码...> (场外请加 f: 前缀,如 f:008284)');
|
|
14
|
+
process.exit(2);
|
|
15
|
+
}
|
|
16
|
+
const targets = args.map((raw) => {
|
|
17
|
+
const isFund = raw.toLowerCase().startsWith('f:');
|
|
18
|
+
const code = isFund ? raw.slice(2) : raw;
|
|
19
|
+
const route = isFund ? '场外净值' : '场内行情';
|
|
20
|
+
return { recordId: code, code, name: '', assetType: '', route };
|
|
21
|
+
});
|
|
22
|
+
const { byRecord, failures } = await fetchQuotes(targets);
|
|
23
|
+
const rows = [...byRecord.values()].map((quote) => ({
|
|
24
|
+
代码: quote.code,
|
|
25
|
+
名称: quote.name,
|
|
26
|
+
价格: quote.price,
|
|
27
|
+
涨跌幅: quote.changePct === null ? '-' : `${quote.changePct}%`,
|
|
28
|
+
时间: quote.tradeTime,
|
|
29
|
+
来源: quote.source,
|
|
30
|
+
}));
|
|
31
|
+
if (rows.length > 0)
|
|
32
|
+
console.table(rows);
|
|
33
|
+
if (failures.length > 0) {
|
|
34
|
+
console.log(`\n未取到 ${failures.length} 条:`);
|
|
35
|
+
for (const failure of failures)
|
|
36
|
+
console.log(` ✗ ${failure.code} — ${failure.reason}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 运行时配置。
|
|
3
|
+
*
|
|
4
|
+
* 遵循 Store 里 launchd 类自动化的既有约定:**代码在 Store 检出目录,
|
|
5
|
+
* 状态(.env / 缓存 / 对账单)在工作目录**,两者独立。
|
|
6
|
+
*
|
|
7
|
+
* 工作目录默认 ~/.investdesk,可用 INVESTDESK_HOME 覆盖。
|
|
8
|
+
* 这样代码可以随 Store 更新,而个人凭证与数据留在本机工作目录里。
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
/** 工作目录:存放 .env、.cache、inbox,不随代码仓库移动 */
|
|
14
|
+
export const WORK_DIR = process.env.INVESTDESK_HOME ?? path.join(os.homedir(), '.investdesk');
|
|
15
|
+
export const CACHE_DIR = path.join(WORK_DIR, '.cache');
|
|
16
|
+
export const INBOX_DIR = path.join(WORK_DIR, 'inbox');
|
|
17
|
+
/** 极简 .env 解析:不做变量展开,够用即可 */
|
|
18
|
+
function loadDotEnv() {
|
|
19
|
+
const file = path.join(WORK_DIR, '.env');
|
|
20
|
+
if (!existsSync(file))
|
|
21
|
+
return;
|
|
22
|
+
for (const rawLine of readFileSync(file, 'utf8').split('\n')) {
|
|
23
|
+
const line = rawLine.trim();
|
|
24
|
+
if (!line || line.startsWith('#') || !line.includes('='))
|
|
25
|
+
continue;
|
|
26
|
+
const index = line.indexOf('=');
|
|
27
|
+
const key = line.slice(0, index).trim();
|
|
28
|
+
if (!key || process.env[key] !== undefined)
|
|
29
|
+
continue;
|
|
30
|
+
let value = line.slice(index + 1).trim();
|
|
31
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
32
|
+
value = value.slice(1, -1);
|
|
33
|
+
}
|
|
34
|
+
process.env[key] = value;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
loadDotEnv();
|
|
38
|
+
mkdirSync(WORK_DIR, { recursive: true });
|
|
39
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
40
|
+
/** 飞书多维表格坐标。换库时用环境变量覆盖。 */
|
|
41
|
+
export const BASE_TOKEN = process.env.INVESTDESK_BASE_TOKEN ?? '';
|
|
42
|
+
export const HOLDINGS_TABLE = process.env.INVESTDESK_HOLDINGS_TABLE ?? '';
|
|
43
|
+
export const PLATFORMS_TABLE = process.env.INVESTDESK_PLATFORMS_TABLE ?? '';
|
|
44
|
+
/** MCP 服务端口 */
|
|
45
|
+
export const MCP_PORT = Number(process.env.MCP_PORT ?? 8787);
|
|
46
|
+
/**
|
|
47
|
+
* 飞书自建应用凭据。
|
|
48
|
+
*
|
|
49
|
+
* 0.3.0 起这是**唯一**的认证方式:用 tenant_access_token 调 OpenAPI,
|
|
50
|
+
* 不依赖任何本机凭据,本机与云端行为一致。
|
|
51
|
+
*/
|
|
52
|
+
export const FEISHU_APP_ID = process.env.FEISHU_APP_ID ?? '';
|
|
53
|
+
export const FEISHU_APP_SECRET = process.env.FEISHU_APP_SECRET ?? '';
|
|
54
|
+
export const FEISHU_BASE_URL = process.env.FEISHU_BASE_URL ?? 'https://open.feishu.cn';
|
|
55
|
+
export function assertFeishuConfigured() {
|
|
56
|
+
const missing = [];
|
|
57
|
+
if (!FEISHU_APP_ID)
|
|
58
|
+
missing.push('FEISHU_APP_ID');
|
|
59
|
+
if (!FEISHU_APP_SECRET)
|
|
60
|
+
missing.push('FEISHU_APP_SECRET');
|
|
61
|
+
if (!BASE_TOKEN)
|
|
62
|
+
missing.push('INVESTDESK_BASE_TOKEN');
|
|
63
|
+
if (!HOLDINGS_TABLE)
|
|
64
|
+
missing.push('INVESTDESK_HOLDINGS_TABLE');
|
|
65
|
+
if (missing.length > 0) {
|
|
66
|
+
throw new Error(`缺少飞书配置:${missing.join(', ')}。请在 ${path.join(WORK_DIR, '.env')} 中补齐。`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 飞书多维表格访问层。
|
|
3
|
+
*
|
|
4
|
+
* **只使用飞书 OpenAPI**(自建应用 tenant_access_token):
|
|
5
|
+
* 不依赖任何本机凭据,所以本机、云端、CI 走的是同一条路径,
|
|
6
|
+
* 不存在「两套后端行为不一致」的问题。
|
|
7
|
+
*
|
|
8
|
+
* 历史:0.2.x 曾保留 lark-cli(本机用户令牌)作为迁移期后备,
|
|
9
|
+
* 0.3.0 起移除。
|
|
10
|
+
*/
|
|
11
|
+
import { BASE_TOKEN, FEISHU_APP_ID, FEISHU_APP_SECRET, FEISHU_BASE_URL, HOLDINGS_TABLE, PLATFORMS_TABLE, assertFeishuConfigured, } from '../config.js';
|
|
12
|
+
import { OpenApiBackend } from './openapi.js';
|
|
13
|
+
let cachedBackend = null;
|
|
14
|
+
/** 当前后端(只有一种,结果缓存) */
|
|
15
|
+
export function getBackend() {
|
|
16
|
+
if (cachedBackend)
|
|
17
|
+
return cachedBackend;
|
|
18
|
+
assertFeishuConfigured();
|
|
19
|
+
cachedBackend = new OpenApiBackend({
|
|
20
|
+
appId: FEISHU_APP_ID,
|
|
21
|
+
appSecret: FEISHU_APP_SECRET,
|
|
22
|
+
appToken: BASE_TOKEN,
|
|
23
|
+
baseUrl: FEISHU_BASE_URL,
|
|
24
|
+
});
|
|
25
|
+
return cachedBackend;
|
|
26
|
+
}
|
|
27
|
+
/** 后端名,用于日志(固定值,保留是为了让日志继续可读) */
|
|
28
|
+
export function backendName() {
|
|
29
|
+
return 'openapi';
|
|
30
|
+
}
|
|
31
|
+
/** 单选字段:OpenAPI 返回字符串,兼容数组形态 */
|
|
32
|
+
function firstOf(value) {
|
|
33
|
+
if (Array.isArray(value))
|
|
34
|
+
return String(value[0] ?? '');
|
|
35
|
+
if (typeof value === 'string')
|
|
36
|
+
return value;
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
function toNumber(value) {
|
|
40
|
+
if (value === null || value === undefined || value === '')
|
|
41
|
+
return null;
|
|
42
|
+
const parsed = Number(String(value).replace(/,/g, ''));
|
|
43
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
44
|
+
}
|
|
45
|
+
/** 读取「最新持仓」全部记录 */
|
|
46
|
+
export async function listHoldings() {
|
|
47
|
+
const records = await getBackend().listRecords(HOLDINGS_TABLE, 2000);
|
|
48
|
+
return records.map((record) => ({
|
|
49
|
+
recordId: record.record_id,
|
|
50
|
+
code: (record.代码 ?? '').trim(),
|
|
51
|
+
name: (record.名称 ?? '').trim(),
|
|
52
|
+
platformId: Array.isArray(record.投资平台) && record.投资平台[0]
|
|
53
|
+
? record.投资平台[0].id
|
|
54
|
+
: '',
|
|
55
|
+
assetType: firstOf(record.资产种类),
|
|
56
|
+
route: firstOf(record.定价方式),
|
|
57
|
+
quantity: toNumber(record.持有数量),
|
|
58
|
+
unitCost: toNumber(record.单位成本),
|
|
59
|
+
price: toNumber(record.最新价格),
|
|
60
|
+
marketValue: toNumber(record.最新市值),
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
/** 读取「最新持仓」并转换为取价目标 */
|
|
64
|
+
export async function listHoldingTargets() {
|
|
65
|
+
const holdings = await listHoldings();
|
|
66
|
+
return holdings.map((holding) => ({
|
|
67
|
+
recordId: holding.recordId,
|
|
68
|
+
code: holding.code,
|
|
69
|
+
name: holding.name,
|
|
70
|
+
assetType: holding.assetType,
|
|
71
|
+
route: holding.route,
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
/** 读取「持仓平台」表,返回 record_id → 平台名 的映射 */
|
|
75
|
+
export async function listPlatformNames() {
|
|
76
|
+
const records = await getBackend().listRecords(PLATFORMS_TABLE, 200);
|
|
77
|
+
const map = new Map();
|
|
78
|
+
for (const record of records) {
|
|
79
|
+
map.set(record.record_id, record.平台 ?? '未命名');
|
|
80
|
+
}
|
|
81
|
+
return map;
|
|
82
|
+
}
|
|
83
|
+
/** 把价格写回「最新持仓」的「最新价格」字段(写入后「最新市值」公式自动重算) */
|
|
84
|
+
export async function updateLatestPrices(updates) {
|
|
85
|
+
if (updates.length === 0)
|
|
86
|
+
return 0;
|
|
87
|
+
return getBackend().batchUpdateRecords(HOLDINGS_TABLE, updates.map((update) => ({ recordId: update.recordId, fields: { 最新价格: update.price } })));
|
|
88
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 飞书 OpenAPI 后端 —— 用自建应用的 tenant_access_token 直接调接口。
|
|
3
|
+
*
|
|
4
|
+
* 用自建应用的 tenant_access_token,不依赖本机 keychain,
|
|
5
|
+
* 因此本机 / 云端 / CI / 定时函数用的是同一条路径。
|
|
6
|
+
* 代价是需要一次性配置:
|
|
7
|
+
* 1. 在飞书开发者后台创建自建应用,拿到 app_id / app_secret
|
|
8
|
+
* 2. 开通多维表格权限(bitable:app 或对应的记录读写 scope)
|
|
9
|
+
* 3. 把该应用添加为这个 Base 的协作者(可编辑)
|
|
10
|
+
*
|
|
11
|
+
* 权限没配好时,飞书会返回 91403 / 1254302 之类的错误,提示里会说明缺什么。
|
|
12
|
+
*/
|
|
13
|
+
const DEFAULT_BASE_URL = 'https://open.feishu.cn';
|
|
14
|
+
export class OpenApiBackend {
|
|
15
|
+
name = 'openapi';
|
|
16
|
+
options;
|
|
17
|
+
tokenCache = null;
|
|
18
|
+
constructor(options) {
|
|
19
|
+
this.options = options;
|
|
20
|
+
}
|
|
21
|
+
get baseUrl() {
|
|
22
|
+
return this.options.baseUrl ?? DEFAULT_BASE_URL;
|
|
23
|
+
}
|
|
24
|
+
/** 换取 tenant_access_token,带内存缓存(提前 5 分钟过期) */
|
|
25
|
+
async getToken() {
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
if (this.tokenCache && this.tokenCache.expiresAt > now)
|
|
28
|
+
return this.tokenCache.token;
|
|
29
|
+
const response = await fetch(`${this.baseUrl}/open-apis/auth/v3/tenant_access_token/internal`, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
32
|
+
body: JSON.stringify({ app_id: this.options.appId, app_secret: this.options.appSecret }),
|
|
33
|
+
});
|
|
34
|
+
const payload = (await response.json());
|
|
35
|
+
if (payload.code !== 0 || !payload.tenant_access_token) {
|
|
36
|
+
throw new Error(`获取 tenant_access_token 失败(code=${payload.code}):${payload.msg ?? 'unknown'}。` +
|
|
37
|
+
'请确认 app_id / app_secret 正确,且应用已开通权限。');
|
|
38
|
+
}
|
|
39
|
+
const ttlMs = Math.max(60, (payload.expire ?? 7200) - 300) * 1000;
|
|
40
|
+
this.tokenCache = { token: payload.tenant_access_token, expiresAt: now + ttlMs };
|
|
41
|
+
return this.tokenCache.token;
|
|
42
|
+
}
|
|
43
|
+
async request(path, init = {}) {
|
|
44
|
+
const token = await this.getToken();
|
|
45
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
46
|
+
...init,
|
|
47
|
+
headers: {
|
|
48
|
+
Authorization: `Bearer ${token}`,
|
|
49
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
50
|
+
...(init.headers ?? {}),
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
const payload = (await response.json());
|
|
54
|
+
if (payload.code !== 0) {
|
|
55
|
+
throw new Error(`飞书接口失败 ${path}(code=${payload.code}):${payload.msg ?? 'unknown'}`);
|
|
56
|
+
}
|
|
57
|
+
return payload.data;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* OpenAPI 返回的原始记录不能直接给上层用:
|
|
61
|
+
* - 字段值包在 `fields` 里
|
|
62
|
+
* - 单选返回字符串(不是数组)
|
|
63
|
+
* - 数字返回字符串
|
|
64
|
+
* - 关联字段是 `[{record_ids: [...], text}]`(不是 `[{id}]`)
|
|
65
|
+
* 这里归一化成扁平、稳定的形状,让 base.ts 不用关心平台细节。
|
|
66
|
+
*/
|
|
67
|
+
static normalize(record) {
|
|
68
|
+
const rawFields = record.fields;
|
|
69
|
+
const fields = rawFields && typeof rawFields === 'object'
|
|
70
|
+
? { ...rawFields }
|
|
71
|
+
: { ...record };
|
|
72
|
+
// 关联字段 → [{ id }]
|
|
73
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
74
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
75
|
+
continue;
|
|
76
|
+
const first = value[0];
|
|
77
|
+
if (first && Array.isArray(first.record_ids)) {
|
|
78
|
+
fields[key] = value.map((item) => ({
|
|
79
|
+
id: Array.isArray(item.record_ids) ? (item.record_ids[0] ?? '') : '',
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const recordId = (record.record_id ?? record.id ?? '');
|
|
84
|
+
return { ...fields, record_id: recordId };
|
|
85
|
+
}
|
|
86
|
+
async listRecords(tableId, limit) {
|
|
87
|
+
const records = [];
|
|
88
|
+
let pageToken = '';
|
|
89
|
+
for (;;) {
|
|
90
|
+
const query = new URLSearchParams({ page_size: '500' });
|
|
91
|
+
if (pageToken)
|
|
92
|
+
query.set('page_token', pageToken);
|
|
93
|
+
const data = await this.request(`/open-apis/bitable/v1/apps/${this.options.appToken}/tables/${tableId}/records?${query}`);
|
|
94
|
+
for (const item of data.items ?? [])
|
|
95
|
+
records.push(OpenApiBackend.normalize(item));
|
|
96
|
+
if (records.length >= limit || !data.has_more || !data.page_token)
|
|
97
|
+
break;
|
|
98
|
+
pageToken = data.page_token;
|
|
99
|
+
}
|
|
100
|
+
return records.slice(0, limit);
|
|
101
|
+
}
|
|
102
|
+
async batchUpdateRecords(tableId, updates) {
|
|
103
|
+
if (updates.length === 0)
|
|
104
|
+
return 0;
|
|
105
|
+
const CHUNK = 200;
|
|
106
|
+
let written = 0;
|
|
107
|
+
for (let i = 0; i < updates.length; i += CHUNK) {
|
|
108
|
+
const chunk = updates.slice(i, i + CHUNK);
|
|
109
|
+
await this.request(`/open-apis/bitable/v1/apps/${this.options.appToken}/tables/${tableId}/records/batch_update`, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
body: JSON.stringify({
|
|
112
|
+
records: chunk.map((update) => ({ record_id: update.recordId, fields: update.fields })),
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
115
|
+
written += chunk.length;
|
|
116
|
+
}
|
|
117
|
+
return written;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* InvestDesk 行情 MCP 服务。
|
|
4
|
+
*
|
|
5
|
+
* 两种传输方式:
|
|
6
|
+
* node src/mcp/server.ts # Streamable HTTP,默认 http://127.0.0.1:8787/mcp
|
|
7
|
+
* node src/mcp/server.ts --stdio # 标准输入输出,供本地 MCP 客户端直连
|
|
8
|
+
*
|
|
9
|
+
* 零依赖手写实现(JSON-RPC 2.0 + MCP tools 协议),不引入 @modelcontextprotocol/sdk。
|
|
10
|
+
* 在豆包工作里作为「自定义连接器」接入时,传输类型选 HTTP,URL 填 http://127.0.0.1:8787/mcp。
|
|
11
|
+
*/
|
|
12
|
+
import { createServer } from 'node:http';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { createInterface } from 'node:readline';
|
|
15
|
+
import { MCP_PORT } from '../config.js';
|
|
16
|
+
import { TOOL_DEFINITIONS, callTool } from './tools.js';
|
|
17
|
+
// stdio 模式下 stdout 是纯协议通道,任何 console.log 都会污染 JSON-RPC,
|
|
18
|
+
// 表现为宿主「握手失败」但进程还活着,现场极难复现。先把 log 拦到 stderr。
|
|
19
|
+
if (process.argv.includes('--stdio')) {
|
|
20
|
+
console.log = (...args) => console.error(...args);
|
|
21
|
+
}
|
|
22
|
+
const SERVER_NAME = 'investdesk-quotes';
|
|
23
|
+
// 版本号从 package.json 读,避免发版时忘了改这里导致握手信息与实际版本不一致
|
|
24
|
+
const SERVER_VERSION = createRequire(import.meta.url)('../../package.json').version;
|
|
25
|
+
const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
|
|
26
|
+
const MCP_PATH = process.env.MCP_PATH ?? '/mcp';
|
|
27
|
+
function success(id, result) {
|
|
28
|
+
return { jsonrpc: '2.0', id, result };
|
|
29
|
+
}
|
|
30
|
+
function failure(id, code, message) {
|
|
31
|
+
return { jsonrpc: '2.0', id, error: { code, message } };
|
|
32
|
+
}
|
|
33
|
+
function negotiateVersion(requested) {
|
|
34
|
+
const value = typeof requested === 'string' ? requested : '';
|
|
35
|
+
return PROTOCOL_VERSIONS.includes(value) ? value : PROTOCOL_VERSIONS[0];
|
|
36
|
+
}
|
|
37
|
+
async function handleMessage(message) {
|
|
38
|
+
const { id = null, method, params = {} } = message;
|
|
39
|
+
switch (method) {
|
|
40
|
+
case 'initialize':
|
|
41
|
+
return success(id, {
|
|
42
|
+
protocolVersion: negotiateVersion(params.protocolVersion),
|
|
43
|
+
capabilities: { tools: { listChanged: false } },
|
|
44
|
+
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
|
|
45
|
+
instructions: 'InvestDesk 行情与持仓服务。lookup_quotes 查价格,portfolio_overview 看持仓汇总,sync_prices 刷新价格。',
|
|
46
|
+
});
|
|
47
|
+
case 'notifications/initialized':
|
|
48
|
+
return null;
|
|
49
|
+
case 'ping':
|
|
50
|
+
return success(id, {});
|
|
51
|
+
case 'tools/list':
|
|
52
|
+
return success(id, { tools: TOOL_DEFINITIONS });
|
|
53
|
+
case 'tools/call': {
|
|
54
|
+
const name = typeof params.name === 'string' ? params.name : '';
|
|
55
|
+
const args = params.arguments ?? {};
|
|
56
|
+
const result = await callTool(name, args);
|
|
57
|
+
return success(id, result);
|
|
58
|
+
}
|
|
59
|
+
default:
|
|
60
|
+
return failure(id, -32601, `Method not found: ${method}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function dispatch(payload) {
|
|
64
|
+
if (Array.isArray(payload)) {
|
|
65
|
+
const results = await Promise.all(payload.map((item) => handleMessage(item).catch((error) => failure((item.id ?? null), -32603, error instanceof Error ? error.message : String(error)))));
|
|
66
|
+
return results.filter((r) => r !== null);
|
|
67
|
+
}
|
|
68
|
+
return handleMessage(payload);
|
|
69
|
+
}
|
|
70
|
+
function startHttpServer() {
|
|
71
|
+
const server = createServer((req, res) => {
|
|
72
|
+
const cors = {
|
|
73
|
+
'Access-Control-Allow-Origin': '*',
|
|
74
|
+
'Access-Control-Allow-Headers': 'Content-Type, Accept, Mcp-Session-Id, MCP-Protocol-Version',
|
|
75
|
+
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS, DELETE',
|
|
76
|
+
};
|
|
77
|
+
if (req.method === 'OPTIONS') {
|
|
78
|
+
res.writeHead(204, cors);
|
|
79
|
+
res.end();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (req.url?.split('?')[0] === '/health') {
|
|
83
|
+
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
|
84
|
+
res.end(JSON.stringify({ ok: true, server: SERVER_NAME, version: SERVER_VERSION }));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (req.url?.split('?')[0] !== MCP_PATH) {
|
|
88
|
+
res.writeHead(404, { ...cors, 'Content-Type': 'application/json' });
|
|
89
|
+
res.end(JSON.stringify({ error: 'not found', hint: `MCP endpoint is ${MCP_PATH}` }));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (req.method === 'GET' || req.method === 'DELETE') {
|
|
93
|
+
// 本实现不提供服务端推送(SSE),按规范返回 405
|
|
94
|
+
res.writeHead(405, { ...cors, Allow: 'POST' });
|
|
95
|
+
res.end();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (req.method !== 'POST') {
|
|
99
|
+
res.writeHead(405, { ...cors, Allow: 'POST' });
|
|
100
|
+
res.end();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const chunks = [];
|
|
104
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
105
|
+
req.on('end', () => {
|
|
106
|
+
let payload;
|
|
107
|
+
try {
|
|
108
|
+
payload = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
res.writeHead(400, { ...cors, 'Content-Type': 'application/json' });
|
|
112
|
+
res.end(JSON.stringify(failure(null, -32700, 'Parse error')));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
dispatch(payload)
|
|
116
|
+
.then((result) => {
|
|
117
|
+
if (result === null || (Array.isArray(result) && result.length === 0)) {
|
|
118
|
+
res.writeHead(202, cors);
|
|
119
|
+
res.end();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
// 无状态服务端:不签发 Mcp-Session-Id。规范允许无状态实现,
|
|
123
|
+
// 少一类状态就少一类 bug(参考 teaming-bot 的 /api/mcp 做法)。
|
|
124
|
+
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
|
125
|
+
res.end(JSON.stringify(result));
|
|
126
|
+
})
|
|
127
|
+
.catch((error) => {
|
|
128
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
129
|
+
console.error('[mcp] 处理失败:', message);
|
|
130
|
+
res.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
|
|
131
|
+
res.end(JSON.stringify(failure(null, -32603, message)));
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
server.listen(MCP_PORT, '127.0.0.1', () => {
|
|
136
|
+
console.error(`[mcp] ${SERVER_NAME} v${SERVER_VERSION} 已启动: http://127.0.0.1:${MCP_PORT}${MCP_PATH}`);
|
|
137
|
+
console.error('[mcp] 健康检查: http://127.0.0.1:' + MCP_PORT + '/health');
|
|
138
|
+
});
|
|
139
|
+
const shutdown = () => {
|
|
140
|
+
console.error('[mcp] 正在关闭…');
|
|
141
|
+
server.close(() => process.exit(0));
|
|
142
|
+
};
|
|
143
|
+
process.on('SIGINT', shutdown);
|
|
144
|
+
process.on('SIGTERM', shutdown);
|
|
145
|
+
}
|
|
146
|
+
function startStdioServer() {
|
|
147
|
+
console.error(`[mcp] ${SERVER_NAME} v${SERVER_VERSION} 以 stdio 模式启动`);
|
|
148
|
+
const rl = createInterface({ input: process.stdin });
|
|
149
|
+
let pending = 0;
|
|
150
|
+
let closed = false;
|
|
151
|
+
/** 只有「stdin 已关闭」且「没有在途请求」时才退出 */
|
|
152
|
+
const exitIfDrained = () => {
|
|
153
|
+
if (closed && pending === 0)
|
|
154
|
+
process.exit(0);
|
|
155
|
+
};
|
|
156
|
+
rl.on('line', (line) => {
|
|
157
|
+
const trimmed = line.trim();
|
|
158
|
+
if (!trimmed)
|
|
159
|
+
return;
|
|
160
|
+
let payload;
|
|
161
|
+
try {
|
|
162
|
+
payload = JSON.parse(trimmed);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
process.stdout.write(JSON.stringify(failure(null, -32700, 'Parse error')) + '\n');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
pending += 1;
|
|
169
|
+
dispatch(payload)
|
|
170
|
+
.then((result) => {
|
|
171
|
+
if (result === null)
|
|
172
|
+
return;
|
|
173
|
+
if (Array.isArray(result) && result.length === 0)
|
|
174
|
+
return;
|
|
175
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
176
|
+
})
|
|
177
|
+
.catch((error) => {
|
|
178
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
179
|
+
process.stdout.write(JSON.stringify(failure(null, -32603, message)) + '\n');
|
|
180
|
+
})
|
|
181
|
+
.finally(() => {
|
|
182
|
+
pending -= 1;
|
|
183
|
+
exitIfDrained();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
// 不能用 `rl.on('close', () => process.exit(0))`:
|
|
187
|
+
// 管道用法下 stdin 会在请求返回前关闭,直接退出会把在途的取价请求杀掉。
|
|
188
|
+
// 这里改为等待在途请求完成;事件循环清空后进程自然退出。
|
|
189
|
+
rl.on('close', () => {
|
|
190
|
+
closed = true;
|
|
191
|
+
exitIfDrained();
|
|
192
|
+
// 兜底:万一有请求永远不返回,20 秒后强制退出
|
|
193
|
+
setTimeout(() => process.exit(0), 20_000).unref();
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (process.argv.includes('--stdio')) {
|
|
197
|
+
startStdioServer();
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
startHttpServer();
|
|
201
|
+
}
|