linker-luobo-mcp 0.0.0-stage → 0.2.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.
Files changed (3) hide show
  1. package/README.md +105 -2
  2. package/dist/index.js +249 -0
  3. package/package.json +32 -4
package/README.md CHANGED
@@ -1,3 +1,106 @@
1
- # Temporary Holding Version
1
+ # linker-luobo-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 萝卜投研(DataYes!Pro, `r.datayes.com`)券商研报检索 MCP server。
4
+
5
+ > 📘 并入 linker-szh hub / 多渠道聚合(萝卜 + 自研 + 其他渠道合成一个研报 MCP)的开发蓝图见 [`INTEGRATION.md`](./INTEGRATION.md)。
6
+
7
+ 定位:**纯抓取逻辑层**(登录 / 搜索 / 下载 / 结构化取数)。多用户编排、排队、日志、监控由上游服务器负责;本 MCP 内置限流退避作「第二道防线」。纯 HTTP 实现,无需浏览器。
8
+
9
+ ## 工具
10
+
11
+ | 工具 | 说明 |
12
+ |---|---|
13
+ | `search_reports` | 研报检索:关键词 + 报告类型/特色标签/机构/内外资/行业/市场/页数/新财富/日期/排序/分页 |
14
+ | `get_report_detail` | 单篇详情:作者、页数、完整摘要、核心指标 |
15
+ | `download_report` | 获取单篇下载信息(受会话保护的 OSS 外链 + 有效期);传 save_path 则落盘(调试用) |
16
+ | `download_reports` | **批量**获取下载信息,内置限流+退避;传 save_dir 则落盘 |
17
+ | `get_report_qa` | 平台 AI 生成的研报问答对(带页码) |
18
+ | `get_report_indicators` | 核心指标(数值+日期+页码+是否预测) |
19
+ | `get_report_charts` | 图表(标题+图片链+底层数据 xlsx 链) |
20
+ | `list_report_orgs` | 机构词表(供 `org_name` 填准,支持关键字过滤) |
21
+
22
+ > 分析提炼走 datayes 自带的 AI 结构化结果(qa/指标/图表/摘要),不解析 PDF。注意:并非每篇研报都有结构化数据,无则返回空数组。
23
+
24
+ ## 环境变量
25
+
26
+ | 变量 | 必填 | 默认 | 说明 |
27
+ |---|---|---|---|
28
+ | `DATAYES_ACCOUNT` | 是 | — | 登录账号(手机号/邮箱/账号) |
29
+ | `DATAYES_PASSWORD` | 是 | — | 登录密码 |
30
+ | `DATAYES_GW` | 否 | `https://gw.datayes.com` | 网关地址 |
31
+ | `DATAYES_QPS` | 否 | `3` | 全局每秒最大请求数(令牌桶) |
32
+ | `DATAYES_MIN_GAP_MS` | 否 | `0` | 请求间最小间隔毫秒(更保守可设 200+) |
33
+ | `DATAYES_MAX_RETRY` | 否 | `3` | 403/429/网络错误的退避重试次数 |
34
+ | `MCP_TRANSPORT` | 否 | `stdio` | `stdio` 或 `http` |
35
+ | `MCP_PORT` | 否 | `8080` | HTTP 模式端口 |
36
+ | `MCP_PATH` | 否 | `/mcp` | HTTP 模式路径 |
37
+
38
+ 登录内置:首次调用自动登录并内存缓存 `cloud-sso-token`,失效自动重登;并发登录去重。
39
+
40
+ ## 运行
41
+
42
+ ```bash
43
+ npm install && npm run build
44
+
45
+ # stdio(本地/客户端子进程)
46
+ DATAYES_ACCOUNT=xxx DATAYES_PASSWORD=yyy node dist/index.js
47
+
48
+ # HTTP 常驻(服务器托管,供你的调度层调用)
49
+ MCP_TRANSPORT=http MCP_PORT=8080 DATAYES_ACCOUNT=xxx DATAYES_PASSWORD=yyy node dist/index.js
50
+ ```
51
+
52
+ ## 自测
53
+
54
+ ```bash
55
+ # PowerShell
56
+ $env:DATAYES_ACCOUNT="你的账号"; $env:DATAYES_PASSWORD="你的密码"; node selftest.mjs
57
+ ```
58
+
59
+ 跑完看到 `🎉 工具全部可用`,并在包目录生成下载的 PDF(可直接打开确认内容)。
60
+
61
+ 场景检验(模拟 agent 真实提问的端到端精准度 + 边界):
62
+
63
+ ```bash
64
+ $env:DATAYES_ACCOUNT="..."; $env:DATAYES_PASSWORD="..."; node scenarios.mjs
65
+ ```
66
+
67
+ 覆盖:期货日报精准、机构+品种周期词剖除、机构核实流程、外资港股、页数筛选收窄、结构化闭环、下载闭环、空查与非法 ID 边界(共 18 项)。
68
+
69
+ ## 客户端配置(Claude Code `.mcp.json`,stdio 模式)
70
+
71
+ ```json
72
+ {
73
+ "mcpServers": {
74
+ "luobo-report": {
75
+ "type": "stdio",
76
+ "command": "npx",
77
+ "args": ["-y", "linker-luobo-mcp@latest"],
78
+ "env": {
79
+ "DATAYES_ACCOUNT": "your-account",
80
+ "DATAYES_PASSWORD": "your-password"
81
+ }
82
+ }
83
+ }
84
+ }
85
+ ```
86
+
87
+ ## 典型 agent 闭环
88
+
89
+ 检索 → 看摘要/问答/指标判断 → 单篇或批量下载到本地:
90
+
91
+ 1. `search_reports(query, report_types, org_name, start_date...)` 拿候选
92
+ 2. `get_report_detail` / `get_report_qa` / `get_report_indicators` 判断价值
93
+ 3. `download_report` 或 `download_reports(ids[], save_dir)` 落盘
94
+
95
+ ## 使用要点
96
+
97
+ - `query` **只放主体词**(品种/公司/主题),如 `甲醇`/`LPG`/`宁德时代`。**勿放周期词**(日报/周报/月报):`titleSeg` 分词会把它切出来匹配所有品种的同类报告,噪声爆增。(工具已内置剖除,即使误传也能兑正。)
98
+ - 时间词(今天/本周)→ `start_date`/`end_date`;大宗商品/化工品种的日报/周报 → `report_types=['期货研究']`。
99
+ 例:“查甲醇今天的日报” → `query='甲醇'` + `report_types=['期货研究']` + `start_date=end_date=今天`。
100
+ - `org_name` 必须是机构词表里的精确名(如 `永安期货`/`华泰证券`)。不确定先调 `list_report_orgs`。
101
+ - `report_types` 支持中文或英文 code,可多选。`search_reports` 的 `total` 服务端封顶 9999,深分页无意义,用日期/筛选收窄。
102
+ - OSS 外链是会话绑定的(须带 cloud-sso-token),`download_report`/`download_reports` 默认返回外链元信息(对齐下载代理模式);独立调试需真正落盘时传 `save_path`/`save_dir`。生产嵌入时走服务端下载代理(见 INTEGRATION.md)。
103
+
104
+ 接入 linker-szh 网关与通用研报类 MCP 规范见 [`INTEGRATION.md`](./INTEGRATION.md)。
105
+
106
+ 接口逆向细节见 [`RESEARCH.md`](./RESEARCH.md)。
package/dist/index.js ADDED
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * linker-luobo-mcp — 萝卜投研(DataYes!Pro) 研报 MCP · 网关客户端
4
+ *
5
+ * 本包是「瘦客户端」:不直连 datayes,不持有萝卜账号密码。
6
+ * 所有调用经链接者统一网关转发:
7
+ * LUOBO_API_BASE = 网关入口,正式 https://hub.linker.net/api/v1/mcp/luobo
8
+ * LUOBO_API_KEY = IAM 签发的 lk_mcp_* 凭证,经 X-MCP-Key 出示
9
+ * 网关校验凭证、注入身份后转发到服务端 luobo 域;萝卜账号仅存在于服务端。
10
+ *
11
+ * 工具(对外契约,与直连版同名,保证切换无感):
12
+ * search_reports / get_report_detail / get_report_qa /
13
+ * get_report_indicators / get_report_charts /
14
+ * download_report / download_reports / list_report_orgs
15
+ */
16
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
18
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
19
+ import { z } from "zod";
20
+ import http from "node:http";
21
+ import { randomUUID } from "node:crypto";
22
+ // ── 配置:网关入口 + 凭证 ────────────────────────────────────────────
23
+ const BASE = (process.env.LUOBO_API_BASE ?? "").replace(/\/$/, "");
24
+ const KEY = process.env.LUOBO_API_KEY ?? "";
25
+ if (!BASE || !KEY) {
26
+ console.error("[error] LUOBO_API_BASE 和 LUOBO_API_KEY 必须设置");
27
+ console.error(' 在 MCP 配置里填: "env": { "LUOBO_API_BASE": "https://hub.linker.net/api/v1/mcp/luobo", "LUOBO_API_KEY": "lk_mcp_..." }');
28
+ process.exit(1);
29
+ }
30
+ try {
31
+ new URL(BASE);
32
+ }
33
+ catch {
34
+ console.error(`[error] LUOBO_API_BASE 格式无效: "${BASE}",必须是合法 http/https URL`);
35
+ process.exit(1);
36
+ }
37
+ const TIMEOUT_MS = Number(process.env.LUOBO_TIMEOUT_MS ?? "30000");
38
+ // ── HTTP 封装:带 X-MCP-Key 打网关,返回 body.data ?? body ──────────────
39
+ async function call(method, path, opts = {}) {
40
+ const url = new URL(BASE + path);
41
+ if (opts.query) {
42
+ for (const [k, v] of Object.entries(opts.query)) {
43
+ if (v !== undefined && v !== null && v !== "")
44
+ url.searchParams.set(k, String(v));
45
+ }
46
+ }
47
+ const controller = new AbortController();
48
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
49
+ try {
50
+ const headers = { "X-MCP-Key": KEY };
51
+ const init = { method, headers, signal: controller.signal };
52
+ if (opts.body !== undefined) {
53
+ headers["Content-Type"] = "application/json";
54
+ init.body = JSON.stringify(opts.body);
55
+ }
56
+ const res = await fetch(url.toString(), init);
57
+ if (res.status === 401)
58
+ throw new Error("鉴权失败(X-MCP-Key 无效或缺失)");
59
+ if (res.status === 403)
60
+ throw new Error("凭证无 luobo 域权限(需 mcp:luobo)");
61
+ if (!res.ok) {
62
+ const txt = await res.text().catch(() => "");
63
+ throw new Error(`网关 ${path} 返回 ${res.status}${txt ? ": " + txt.slice(0, 200) : ""}`);
64
+ }
65
+ const json = (await res.json());
66
+ // 后端统一 ApiResponse:{code,message,data}。code 非 0 视为业务失败。
67
+ if (typeof json.code === "number" && json.code !== 0 && json.code !== 200) {
68
+ throw new Error(String(json.message ?? `业务错误 code=${json.code}`));
69
+ }
70
+ return json.data ?? json;
71
+ }
72
+ finally {
73
+ clearTimeout(timer);
74
+ }
75
+ }
76
+ function ok(data) {
77
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
78
+ }
79
+ function fail(e) {
80
+ return { content: [{ type: "text", text: `错误: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
81
+ }
82
+ // ── MCP Server ──────────────────────────────────────────────────────
83
+ const server = new McpServer({ name: "linker-luobo-mcp", version: "0.2.0" });
84
+ // 1) 检索
85
+ server.registerTool("search_reports", {
86
+ title: "检索研报",
87
+ description: "按关键词/类型/机构/行业/市场/页数/日期检索萝卜投研(DataYes)券商研报。" +
88
+ "query 只放主体词(如'甲醇'),周期词(日报/周报/月报)不要进 query,改用 report_types + 日期范围,否则标题分词会污染召回。" +
89
+ "返回 report_id 可用于后续详情/问答/指标/图表/下载。total 上限 9999。",
90
+ inputSchema: {
91
+ query: z.string().optional().describe("主体关键词,如'甲醇'、'宁德时代';勿放周期词"),
92
+ report_types: z.array(z.string()).optional().describe("报告类型,中文或 code:宏观研究/投资策略/行业研究/公司研究/晨会早报/债券研究/基金研究/期货研究/期权研究/外汇研究/新三板研究/金融工程/其他研究"),
93
+ report_sub_types: z.array(z.string()).optional().describe("特色标签/子类,如 公司深度/商品期货"),
94
+ org_name: z.string().optional().describe("机构精确名,先用 list_report_orgs 核实(如'华泰证券')"),
95
+ org_type: z.string().optional().describe("内资 或 外资"),
96
+ industry: z.string().optional().describe("行业精确名,如'基础化工'"),
97
+ market: z.string().optional().describe("A股 / 港股 / 美股"),
98
+ min_pages: z.number().int().optional().describe("最少页数,用于过滤短报告"),
99
+ new_fortune_only: z.boolean().optional().describe("只看新财富分析师"),
100
+ start_date: z.string().optional().describe("发布起始 YYYY-MM-DD"),
101
+ end_date: z.string().optional().describe("发布结束 YYYY-MM-DD"),
102
+ sort_order: z.enum(["desc", "asc"]).optional().describe("按发布时间排序,默认 desc"),
103
+ page: z.number().int().min(1).optional().describe("页码,默认 1"),
104
+ page_size: z.number().int().min(1).max(50).optional().describe("每页条数,默认 20,上限 50"),
105
+ },
106
+ }, async (a) => {
107
+ try {
108
+ return ok(await call("POST", "/search", { body: a }));
109
+ }
110
+ catch (e) {
111
+ return fail(e);
112
+ }
113
+ });
114
+ // 2) 详情
115
+ server.registerTool("get_report_detail", {
116
+ title: "研报详情",
117
+ description: "按 report_id 取研报详情:作者、页数、摘要、核心指标。",
118
+ inputSchema: { report_id: z.number().int().describe("研报 ID,来自 search_reports") },
119
+ }, async ({ report_id }) => {
120
+ try {
121
+ return ok(await call("GET", "/report/detail", { query: { report_id } }));
122
+ }
123
+ catch (e) {
124
+ return fail(e);
125
+ }
126
+ });
127
+ // 3) 问答
128
+ server.registerTool("get_report_qa", {
129
+ title: "研报问答",
130
+ description: "取平台为该研报生成的结构化问答(QA)。返回空数组表示该报告无 QA,不代表接口异常。",
131
+ inputSchema: { report_id: z.number().int().describe("研报 ID") },
132
+ }, async ({ report_id }) => {
133
+ try {
134
+ return ok(await call("GET", "/report/qa", { query: { report_id } }));
135
+ }
136
+ catch (e) {
137
+ return fail(e);
138
+ }
139
+ });
140
+ // 4) 核心指标
141
+ server.registerTool("get_report_indicators", {
142
+ title: "研报核心指标",
143
+ description: "取平台提取的核心指标(指标名/值/日期/页码/是否预测)。返回空数组表示该报告无结构化指标。",
144
+ inputSchema: { report_id: z.number().int().describe("研报 ID") },
145
+ }, async ({ report_id }) => {
146
+ try {
147
+ return ok(await call("GET", "/report/indicators", { query: { report_id } }));
148
+ }
149
+ catch (e) {
150
+ return fail(e);
151
+ }
152
+ });
153
+ // 5) 图表
154
+ server.registerTool("get_report_charts", {
155
+ title: "研报图表",
156
+ description: "取研报内图表清单(标题/页码/图片URL/数据xlsx URL)。返回空数组表示该报告无结构化图表。",
157
+ inputSchema: { report_id: z.number().int().describe("研报 ID") },
158
+ }, async ({ report_id }) => {
159
+ try {
160
+ return ok(await call("GET", "/report/charts", { query: { report_id } }));
161
+ }
162
+ catch (e) {
163
+ return fail(e);
164
+ }
165
+ });
166
+ // 6) 下载(返回网关签名的下载代理 URL)
167
+ server.registerTool("download_report", {
168
+ title: "获取研报下载链接",
169
+ description: "取该研报的下载代理 URL(由网关签名,默认 15 分钟有效)。" +
170
+ "返回的是可直接 GET 的代理链接(浏览器/agent 皆可),不是萝卜裸 OSS 链。" +
171
+ "PDF 为可解析电子版(中文 CID 字体,取全文用 PyMuPDF/pdfplumber)。",
172
+ inputSchema: { report_id: z.number().int().describe("研报 ID") },
173
+ }, async ({ report_id }) => {
174
+ try {
175
+ return ok(await call("POST", "/report/download", { body: { report_id } }));
176
+ }
177
+ catch (e) {
178
+ return fail(e);
179
+ }
180
+ });
181
+ // 7) 批量下载(客户端聚合:逐个调 /report/download,串行带轻微间隔防风控)
182
+ server.registerTool("download_reports", {
183
+ title: "批量获取下载链接",
184
+ description: "批量取多篇研报的下载代理 URL。客户端串行请求(带间隔防风控),逐条返回 {report_id,url,expires_at} 或错误。",
185
+ inputSchema: {
186
+ report_ids: z.array(z.number().int()).min(1).max(50).describe("研报 ID 列表,上限 50"),
187
+ gap_ms: z.number().int().min(0).optional().describe("每次请求间隔毫秒,默认 300"),
188
+ },
189
+ }, async ({ report_ids, gap_ms }) => {
190
+ const gap = gap_ms ?? 300;
191
+ const results = [];
192
+ for (let i = 0; i < report_ids.length; i++) {
193
+ const id = report_ids[i];
194
+ try {
195
+ results.push(await call("POST", "/report/download", { body: { report_id: id } }));
196
+ }
197
+ catch (e) {
198
+ results.push({ report_id: id, error: e instanceof Error ? e.message : String(e) });
199
+ }
200
+ if (gap > 0 && i < report_ids.length - 1)
201
+ await new Promise((r) => setTimeout(r, gap));
202
+ }
203
+ return ok({ count: results.length, results });
204
+ });
205
+ // 8) 机构词表
206
+ server.registerTool("list_report_orgs", {
207
+ title: "机构名词表",
208
+ description: "查研报机构名词表(用于 search_reports 的 org_name 精确匹配)。" +
209
+ "建议传 prefix 过滤(如'华泰'),否则最多返回 100 条。机构名需与词表完全一致才能命中。",
210
+ inputSchema: { prefix: z.string().optional().describe("机构名关键字,如'华泰'") },
211
+ }, async ({ prefix }) => {
212
+ try {
213
+ return ok(await call("GET", "/orgs", { query: { prefix } }));
214
+ }
215
+ catch (e) {
216
+ return fail(e);
217
+ }
218
+ });
219
+ // ── 传输:stdio(默认) / streamable-http ──────────────────────────────
220
+ async function main() {
221
+ const transport = (process.env.MCP_TRANSPORT ?? "stdio").toLowerCase();
222
+ if (transport === "http") {
223
+ const port = Number(process.env.MCP_PORT ?? "8080");
224
+ const path = process.env.MCP_PATH ?? "/mcp";
225
+ const httpTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
226
+ await server.connect(httpTransport);
227
+ const srv = http.createServer((req, res) => {
228
+ if (!req.url || new URL(req.url, "http://x").pathname !== path) {
229
+ res.writeHead(404).end();
230
+ return;
231
+ }
232
+ httpTransport.handleRequest(req, res).catch((e) => {
233
+ console.error("[http] handleRequest error", e);
234
+ if (!res.headersSent)
235
+ res.writeHead(500).end();
236
+ });
237
+ });
238
+ srv.listen(port, () => console.error(`linker-luobo-mcp started (http) on :${port}${path}`));
239
+ }
240
+ else {
241
+ const stdio = new StdioServerTransport();
242
+ await server.connect(stdio);
243
+ console.error("linker-luobo-mcp started (stdio)");
244
+ }
245
+ }
246
+ main().catch((e) => {
247
+ console.error("[fatal]", e);
248
+ process.exit(1);
249
+ });
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "linker-luobo-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "MCP gateway client for 萝卜投研 (DataYes!Pro) research reports — search / detail / qa / indicators / charts / download via linker hub",
5
+ "author": "陈定钢",
6
+ "type": "module",
7
+ "bin": {
8
+ "linker-luobo-mcp": "dist/index.js"
9
+ },
10
+ "files": ["dist"],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/linker/linker_mcp.git",
14
+ "directory": "packages/linker-luobo-mcp"
15
+ },
16
+ "homepage": "https://github.com/linker/linker_mcp/tree/main/packages/linker-luobo-mcp#readme",
17
+ "keywords": ["mcp", "datayes", "萝卜投研", "research-report", "研报"],
18
+ "scripts": {
19
+ "build": "tsc && shx chmod +x dist/*.js",
20
+ "prepare": "npm run build",
21
+ "watch": "tsc --watch"
22
+ },
23
+ "dependencies": {
24
+ "@modelcontextprotocol/sdk": "^1.26.0",
25
+ "zod": "^3.24.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^22",
29
+ "shx": "^0.3.4",
30
+ "typescript": "^5.8.2"
31
+ },
32
+ "engines": { "node": ">=18" },
33
+ "license": "MIT"
34
+ }