linker-luobo-mcp 0.1.0 → 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 (2) hide show
  1. package/dist/index.js +181 -539
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1,607 +1,249 @@
1
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
+ */
2
16
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
17
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
18
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4
19
  import { z } from "zod";
5
- import { publicEncrypt, constants } from "node:crypto";
6
- import { writeFile } from "node:fs/promises";
7
- import { resolve } from "node:path";
8
- // ============================================================
9
- // 配置
10
- // ============================================================
11
- const GW = (process.env.DATAYES_GW ?? "https://gw.datayes.com").replace(/\/$/, "");
12
- const ACCOUNT = process.env.DATAYES_ACCOUNT ?? "";
13
- const PASSWORD = process.env.DATAYES_PASSWORD ?? "";
14
- // \u9650\u6d41/\u9000\u907f\uff08\u9632\u98ce\u63a7\u4fdd\u9669\u4e1d\uff0c\u53ef\u7528 env \u8c03\uff09
15
- const QPS = Number(process.env.DATAYES_QPS ?? "3");
16
- const MIN_GAP_MS = Number(process.env.DATAYES_MIN_GAP_MS ?? "0");
17
- const MAX_RETRY = Number(process.env.DATAYES_MAX_RETRY ?? "3");
18
- if (!ACCOUNT || !PASSWORD) {
19
- process.stderr.write("[linker-luobo-mcp] DATAYES_ACCOUNT / DATAYES_PASSWORD 环境变量必须设置\n");
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_..." }');
20
28
  process.exit(1);
21
29
  }
22
- const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
23
- // 萝卜投研登录 RSA 公钥(前端硬编码,SPKI DER base64)
24
- const RSA_PUBKEY_DER_B64 = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC1l2C9UZK68IvwAshoHGCl3hU1QScNUiZiO0lBAbRq0xJonNkbOD1uUFYr0G19rlZbvlOAUWlKFmvYhnleD25yPvZzJNLQOWIusL4TIigkGTnt7JWg77aGFBO2l9D8c6WOry8fo1h1ijavxEXl7b4tSsB/jiVHsbnsWHIQdictOQIDAQAB";
25
- function pemPublicKey(derB64) {
26
- const wrapped = derB64.replace(/(.{64})/g, "$1\n");
27
- return `-----BEGIN PUBLIC KEY-----\n${wrapped}\n-----END PUBLIC KEY-----`;
30
+ try {
31
+ new URL(BASE);
28
32
  }
29
- function rsaEncrypt(plain) {
30
- const buf = publicEncrypt({ key: pemPublicKey(RSA_PUBKEY_DER_B64), padding: constants.RSA_PKCS1_PADDING }, Buffer.from(plain, "utf8"));
31
- return buf.toString("base64");
32
- }
33
- // ============================================================
34
- // 会话(Cookie-jar)管理 + 自动登录/重登
35
- // ============================================================
36
- let ssoToken = null;
37
- function cookieHeader() {
38
- return ssoToken ? `cloud-sso-token=${ssoToken}` : "";
39
- }
40
- function baseHeaders(extra = {}) {
41
- const h = {
42
- "User-Agent": UA,
43
- Origin: "https://r.datayes.com",
44
- Referer: "https://r.datayes.com/",
45
- ...extra,
46
- };
47
- const c = cookieHeader();
48
- if (c)
49
- h.Cookie = c;
50
- return h;
51
- }
52
- async function login() {
53
- const form = new URLSearchParams({
54
- createDefaultAccount: "false",
55
- isSlide: "false",
56
- rememberMe: "true",
57
- username: rsaEncrypt(ACCOUNT),
58
- password: rsaEncrypt(PASSWORD),
59
- });
60
- const res = await fetch(`${GW}/usermaster/authenticate/web.json`, {
61
- method: "POST",
62
- headers: baseHeaders({ "Content-Type": "application/x-www-form-urlencoded" }),
63
- body: form.toString(),
64
- });
65
- const data = (await res.json());
66
- if (data?.code !== 0 || data?.content?.result !== "SUCCESS") {
67
- throw new Error(`登录失败: ${data?.message ?? JSON.stringify(data).slice(0, 200)}`);
68
- }
69
- // 优先从 Set-Cookie 取,回退用 content.token.tokenString
70
- const setCookie = res.headers.get("set-cookie") ?? "";
71
- const m = setCookie.match(/cloud-sso-token=([^;]+)/);
72
- ssoToken = m ? m[1] : (data.content?.token?.tokenString ?? null);
73
- if (!ssoToken)
74
- throw new Error("登录成功但未取到 cloud-sso-token");
75
- console.error(`[linker-luobo-mcp] 登录成功 userId=${data.content.userId}`);
33
+ catch {
34
+ console.error(`[error] LUOBO_API_BASE 格式无效: "${BASE}",必须是合法 http/https URL`);
35
+ process.exit(1);
76
36
  }
77
- // ============================================================
78
- // 令牌桶限流 + 并发安全登录(防风控保险丝)
79
- // ============================================================
80
- let _bucketTs = Date.now();
81
- let _bucketCount = 0;
82
- let _lastReqTs = 0;
83
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
84
- // 串行化的限流闸门:QPS 令牌桶 + 最小间隔,所有 datayes 请求过这里
85
- let _gate = Promise.resolve();
86
- async function throttle() {
87
- _gate = _gate.then(async () => {
88
- const now = Date.now();
89
- if (now - _bucketTs >= 1000) {
90
- _bucketTs = now;
91
- _bucketCount = 0;
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));
92
45
  }
93
- _bucketCount++;
94
- if (_bucketCount > QPS) {
95
- const wait = 1000 - (now - _bucketTs);
96
- if (wait > 0)
97
- await sleep(wait);
98
- _bucketTs = Date.now();
99
- _bucketCount = 1;
100
- }
101
- if (MIN_GAP_MS > 0) {
102
- const gap = Date.now() - _lastReqTs;
103
- if (gap < MIN_GAP_MS)
104
- await sleep(MIN_GAP_MS - gap);
105
- }
106
- _lastReqTs = Date.now();
107
- });
108
- return _gate;
109
- }
110
- // 并发安全登录:多个请求同时发现会话失效时,只登录一次
111
- let _loginInflight = null;
112
- function loginOnce() {
113
- if (_loginInflight)
114
- return _loginInflight;
115
- _loginInflight = login().finally(() => {
116
- _loginInflight = null;
117
- });
118
- return _loginInflight;
119
- }
120
- // 带会话的请求;-403/Need login 时自动重登一次
121
- async function apiFetch(path, init = {}, retry = true) {
122
- if (!ssoToken)
123
- await loginOnce();
124
- const { json, ...rest } = init;
125
- const headers = baseHeaders(rest.headers ??
126
- (json !== undefined ? { "Content-Type": "application/json" } : {}));
127
- const method = rest.method ?? (json !== undefined ? "POST" : "GET");
128
- await throttle();
129
- const res = await fetch(`${GW}${path}`, {
130
- ...rest,
131
- method,
132
- headers,
133
- body: json !== undefined ? JSON.stringify(json) : rest.body,
134
- });
135
- const text = await res.text();
136
- let data;
137
- try {
138
- data = JSON.parse(text);
139
- }
140
- catch {
141
- data = { raw: text };
142
- }
143
- // 会话失效:usermaster 用 -403,部分网关用 message "Need login"
144
- const needLogin = data?.code === -403 || /need login/i.test(data?.message ?? "") || res.status === 401;
145
- if (needLogin && retry) {
146
- console.error("[linker-luobo-mcp] 会话失效,重新登录…");
147
- ssoToken = null;
148
- await loginOnce();
149
- return apiFetch(path, init, false);
150
- }
151
- return data;
152
- }
153
- // ============================================================
154
- // 业务封装
155
- // ============================================================
156
- const stripTags = (s) => (s ?? "").replace(/<[^>]+>/g, "");
157
- const toDate = (iso) => (iso ? String(iso).slice(0, 10) : "");
158
- // 报告类型(权威映射,来自 getReportClassification)
159
- const REPORT_TYPE_MAP = {
160
- "\u5b8f\u89c2\u7814\u7a76": "MACRO",
161
- "\u6295\u8d44\u7b56\u7565": "INVESTMENT",
162
- "\u884c\u4e1a\u7814\u7a76": "INDUSTRY",
163
- "\u516c\u53f8\u7814\u7a76": "COMPANY",
164
- "\u6668\u4f1a\u65e9\u62a5": "MORNING",
165
- "\u503a\u5238\u7814\u7a76": "BOND",
166
- "\u57fa\u91d1\u7814\u7a76": "FUND",
167
- "\u671f\u8d27\u7814\u7a76": "FUTURES",
168
- "\u671f\u6743\u7814\u7a76": "OPTIONS",
169
- "\u5916\u6c47\u7814\u7a76": "CURRENCY",
170
- "\u65b0\u4e09\u677f\u7814\u7a76": "BOARD",
171
- "\u91d1\u878d\u5de5\u7a0b": "FINANCIAL",
172
- "\u5176\u4ed6\u7814\u7a76": "OTHER",
173
- };
174
- // 已是英文 code 则原样透传
175
- const ALL_REPORT_TYPE_CODES = new Set(Object.values(REPORT_TYPE_MAP));
176
- const toReportTypeCode = (t) => REPORT_TYPE_MAP[t] ?? t;
177
- // 市场 → exchangeCode
178
- const MARKET_MAP = {
179
- "A\u80a1": "XSHG,XBEI,XSHE",
180
- "\u6e2f\u80a1": "XHKG",
181
- "\u7f8e\u80a1": "AMXO,XNAS,XNYS",
182
- };
183
- // 内外资 → orgType
184
- const ORG_TYPE_MAP = {
185
- "\u5185\u8d44": "1,3",
186
- "\u5916\u8d44": "2",
187
- };
188
- const ymd = (d) => (d ? d.replace(/-/g, "") : "");
189
- async function searchReports(a) {
190
- const qs = new URLSearchParams({
191
- searchField: "titleSeg",
192
- type: "EXTERNAL_REPORT",
193
- isNewFortune: a.new_fortune_only ? "true" : "false",
194
- isOptional: "false",
195
- ifMultipleQuery: "false",
196
- showTarPrice: "true",
197
- sortOrder: a.sort_order ?? "desc",
198
- pageNow: String(a.page ?? 1),
199
- pageSize: String(a.page_size ?? 20),
200
- pubTimeStart: ymd(a.start_date) || "20000101",
201
- pubTimeEnd: ymd(a.end_date) || "20991231",
202
- });
203
- if (a.min_pages && a.min_pages > 0)
204
- qs.set("minPageCount", String(a.min_pages));
205
- const body = { dataType: "0", searchType: "cn" };
206
- if (a.query) {
207
- // \u9632\u5fa1\u6027\u5256\u9664\u5468\u671f\u8bcd\uff1atitleSeg \u5206\u8bcd\u4f1a\u628a\u300c\u65e5\u62a5/\u65e9\u62a5/\u5468\u62a5/\u6708\u62a5\u300d\u5207\u51fa\u6765\u5339\u914d\u6240\u6709\u54c1\u79cd\u7684\u540c\u7c7b\u62a5\u544a\uff0c\u9020\u6210\u5927\u91cf\u566a\u58f0\u3002
208
- // \u5468\u671f\u8bcd\u5e94\u901a\u8fc7\u65e5\u671f\u8303\u56f4/\u62a5\u544a\u7c7b\u578b\u8868\u8fbe\uff0c\u4e0d\u8fdb query\u3002
209
- const cleaned = a.query.replace(/(\u65e5\u62a5|\u65e9\u62a5|\u665a\u62a5|\u5468\u62a5|\u6708\u62a5|\u5b63\u62a5|\u5e74\u62a5|\u534a\u5e74\u62a5)/g, "").trim();
210
- body.query = cleaned || a.query;
211
- }
212
- if (a.org_name)
213
- body.orgName = a.org_name;
214
- if (a.industry)
215
- body.industry = a.industry;
216
- if (a.report_types?.length) {
217
- body.reportType = a.report_types.map(toReportTypeCode).join(",");
218
- }
219
- if (a.report_sub_types?.length)
220
- body.reportSubType = a.report_sub_types.join(",");
221
- if (a.market)
222
- body.exchangeCode = MARKET_MAP[a.market] ?? a.market;
223
- if (a.org_type)
224
- body.orgType = ORG_TYPE_MAP[a.org_type] ?? a.org_type;
225
- const data = await apiFetch(`/aladdin_adventure/web/search?${qs}`, { json: body });
226
- if (data?.code !== 1)
227
- throw new Error(data?.message ?? "\u641c\u7d22\u5931\u8d25");
228
- const d = data.data ?? {};
229
- const items = (d.list ?? []).map((it) => {
230
- const x = it.data ?? {};
231
- return {
232
- report_id: x.id,
233
- title: stripTags(x.highlightTitle || x.title),
234
- org: x.orgName ?? "",
235
- industry: x.industryName ?? "",
236
- report_type: x.reportType ?? "",
237
- pub_date: toDate(x.publishTime),
238
- pages: x.pageNum ?? 0,
239
- abstract: stripTags(x.abstractText ?? "").slice(0, 160),
240
- };
241
- });
242
- return {
243
- total: Math.min(d.total ?? 0, d.actualTotal ?? d.total ?? 0),
244
- page: d.pageNow ?? a.page ?? 1,
245
- page_count: d.pageCount ?? 0,
246
- items,
247
- };
248
- }
249
- async function getReportDetail(reportId) {
250
- const info = await apiFetch(`/aladdin_adventure/web/externalReport/${reportId}/info`);
251
- const core = await apiFetch(`/aladdin_adventure/web/externalReport/${reportId}/coreIndicators`).catch(() => ({ data: [] }));
252
- const d = info?.data ?? {};
253
- return {
254
- report_id: reportId,
255
- author: d.author ?? "",
256
- pages: d.pageNum ?? 0,
257
- abstract: stripTags(d.textAbstract ?? ""),
258
- core_indicators: (core?.data ?? []).map((c) => ({
259
- indicator: c.indicator,
260
- value: c.data,
261
- date: c.date,
262
- page: c.page,
263
- })),
264
- };
265
- }
266
- async function getReportPdfUrl(reportId) {
267
- const data = await apiFetch(`/aladdin_adventure/web/externalReport/${reportId}/downloadUrl`);
268
- if (data?.code !== 1 || !data?.data)
269
- throw new Error(data?.message ?? "\u83b7\u53d6\u5916\u94fe\u5931\u8d25");
270
- return data.data;
271
- }
272
- // \u7528\u5185\u7f6e\u4f1a\u8bdd\u76f4\u63a5\u628a PDF \u53d6\u4e0b\u6765\uff08\u5916\u94fe\u4f1a\u8bdd\u7ed1\u5b9a\uff0c\u5fc5\u987b\u5e26 cloud-sso-token\uff09
273
- async function fetchReportPdf(reportId) {
274
- if (!ssoToken)
275
- await loginOnce();
276
- let pdf_url = await getReportPdfUrl(reportId);
277
- await throttle();
278
- let res = await fetch(pdf_url, { headers: { Cookie: cookieHeader(), "User-Agent": UA } });
279
- if (res.status === 403) {
280
- // \u4f1a\u8bdd\u53ef\u80fd\u5931\u6548\uff0c\u91cd\u767b\u540e\u91cd\u62ff\u4e00\u6b21\u65b0\u5916\u94fe
281
- ssoToken = null;
282
- await loginOnce();
283
- pdf_url = await getReportPdfUrl(reportId);
284
- await throttle();
285
- res = await fetch(pdf_url, { headers: { Cookie: cookieHeader(), "User-Agent": UA } });
286
- }
287
- if (!res.ok)
288
- throw new Error(`\u4e0b\u8f7d\u5931\u8d25 HTTP ${res.status}`);
289
- const ct = res.headers.get("content-type") ?? "";
290
- const buf = Buffer.from(await res.arrayBuffer());
291
- if (!ct.includes("pdf") && buf.subarray(0, 4).toString() !== "%PDF") {
292
- throw new Error(`\u8fd4\u56de\u975e PDF (content-type=${ct})`);
293
46
  }
294
- return { url: pdf_url, bytes: buf };
295
- }
296
- // \u9000\u907f\u91cd\u8bd5\u5305\u88c5\uff08\u6307\u6570 backoff + \u6296\u52a8\uff09
297
- async function withRetry(fn, label) {
298
- let lastErr;
299
- for (let attempt = 0; attempt <= MAX_RETRY; attempt++) {
300
- try {
301
- return await fn();
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);
302
55
  }
303
- catch (e) {
304
- lastErr = e;
305
- if (attempt === MAX_RETRY)
306
- break;
307
- const base = 500 * Math.pow(2, attempt);
308
- const jitter = Math.floor(Math.random() * 400);
309
- console.error(`[linker-luobo-mcp] ${label} \u7b2c${attempt + 1}\u6b21\u5931\u8d25\uff0c${base + jitter}ms\u540e\u91cd\u8bd5`);
310
- await sleep(base + jitter);
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) : ""}`);
311
64
  }
312
- }
313
- throw lastErr;
314
- }
315
- // \u89e3\u6790\u4e0b\u8f7d\u4fe1\u606f\uff1a\u8fd4\u56de\u53d7\u4f1a\u8bdd\u4fdd\u62a4\u7684 OSS \u5916\u94fe\u5143\u4fe1\u606f\uff08\u5bf9\u9f50\u672a\u6765\u4e0b\u8f7d\u4ee3\u7406\u6a21\u5f0f\uff09
316
- async function resolveDownload(reportId) {
317
- const pdf_url = await getReportPdfUrl(reportId);
318
- return {
319
- report_id: reportId,
320
- pdf_url,
321
- expires_in_sec: 86400,
322
- access: "session-bound",
323
- note: "\u8be5 OSS \u5916\u94fe\u53d7 cloud-sso-token \u4f1a\u8bdd\u7ed1\u5b9a\uff0c\u88f8\u8bbf\u95ee 403\uff1b\u751f\u4ea7\u73af\u5883\u9700\u7ecf\u4e0b\u8f7d\u4ee3\u7406\uff08\u670d\u52a1\u7aef\u6301\u4f1a\u8bdd\u6d41\u5f0f\u8f6c\u53d1\uff09\u3002\u72ec\u7acb\u8c03\u8bd5\u53ef\u4f20 save_path/save_dir \u8ba9 MCP \u7528\u5185\u7f6e\u4f1a\u8bdd\u843d\u76d8\u3002",
324
- };
325
- }
326
- // \u6279\u91cf\u89e3\u6790\u4e0b\u8f7d\u4fe1\u606f\uff1b\u4f20 saveDir \u5219\u540c\u65f6\u843d\u76d8\uff08\u4e32\u884c+\u6296\u52a8+\u9000\u907f\uff0c\u72ec\u7acb/\u8c03\u8bd5\u7528\uff09
327
- async function downloadReports(reportIds, saveDir) {
328
- const results = [];
329
- let okN = 0;
330
- let failN = 0;
331
- let dir = "";
332
- if (saveDir) {
333
- const { mkdir } = await import("node:fs/promises");
334
- dir = resolve(saveDir);
335
- await mkdir(dir, { recursive: true });
336
- }
337
- for (const rid of reportIds) {
338
- try {
339
- if (saveDir) {
340
- const { bytes } = await withRetry(() => fetchReportPdf(rid), `download ${rid}`);
341
- const file = resolve(dir, `${rid}.pdf`);
342
- await writeFile(file, bytes);
343
- results.push({ report_id: rid, ok: true, saved_path: file, size_kb: Math.round(bytes.length / 1024) });
344
- }
345
- else {
346
- const info = await withRetry(() => resolveDownload(rid), `resolve ${rid}`);
347
- results.push({ ...info, ok: true });
348
- }
349
- okN++;
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}`));
350
69
  }
351
- catch (e) {
352
- results.push({ report_id: rid, ok: false, error: e instanceof Error ? e.message : String(e) });
353
- failN++;
354
- }
355
- await sleep(300 + Math.floor(Math.random() * 600));
70
+ return json.data ?? json;
71
+ }
72
+ finally {
73
+ clearTimeout(timer);
356
74
  }
357
- return { ok: okN, failed: failN, results };
358
- }
359
- // \u7ed3\u6784\u5316\u5185\u5bb9\uff08\u5e73\u53f0 AI \u73b0\u6210\u7ed3\u679c\uff0c\u65e0\u9700\u542f PDF\uff09
360
- async function getReportQa(reportId) {
361
- const d = await apiFetch(`/aladdin_adventure/web/externalReport/${reportId}/qaPairs`);
362
- return (d?.data ?? []).map((q) => ({ question: q.question, answer: q.answer, page: q.page }));
363
- }
364
- async function getReportIndicators(reportId) {
365
- const d = await apiFetch(`/aladdin_adventure/web/externalReport/${reportId}/coreIndicators`);
366
- return (d?.data ?? []).map((c) => ({
367
- indicator: c.indicator,
368
- value: c.data,
369
- date: c.date,
370
- page: c.page,
371
- is_forecast: c.is_pre === 1,
372
- }));
373
75
  }
374
- async function getReportCharts(reportId) {
375
- const d = await apiFetch(`/aladdin_adventure/researchQuickReport/getGraphTable?reportId=${reportId}`);
376
- return (d?.data ?? []).map((g) => ({
377
- title: g.title,
378
- page: g.pageNum,
379
- image_url: g.imgUrl,
380
- data_xlsx_url: g.dataUrl,
381
- }));
76
+ function ok(data) {
77
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
382
78
  }
383
- let orgCache = null;
384
- async function listOrgs(prefix) {
385
- if (!orgCache) {
386
- const data = await apiFetch(`/aladdin_monitor/web/aladdin/report/org/new?internalOrg=true`);
387
- const groups = data?.data ?? {};
388
- const names = [];
389
- for (const k of Object.keys(groups)) {
390
- for (const o of groups[k] ?? [])
391
- if (o?.orgName)
392
- names.push(o.orgName);
393
- }
394
- orgCache = Array.from(new Set(names));
395
- }
396
- if (prefix)
397
- return orgCache.filter((n) => n.includes(prefix));
398
- return orgCache;
79
+ function fail(e) {
80
+ return { content: [{ type: "text", text: `错误: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
399
81
  }
400
- // ============================================================
401
- // MCP Server + Tools
402
- // ============================================================
403
- const server = new McpServer({ name: "linker-luobo-mcp", version: "0.1.0" });
404
- const ok = (obj) => ({ content: [{ type: "text", text: JSON.stringify(obj) }] });
405
- const fail = (e) => ({
406
- content: [{ type: "text", text: `\u51fa\u9519: ${e instanceof Error ? e.message : String(e)}` }],
407
- isError: true,
408
- });
82
+ // ── MCP Server ──────────────────────────────────────────────────────
83
+ const server = new McpServer({ name: "linker-luobo-mcp", version: "0.2.0" });
84
+ // 1) 检索
409
85
  server.registerTool("search_reports", {
410
- title: "\u641c\u7d22\u7814\u62a5",
411
- description: "\u5728\u841d\u535c\u6295\u7814(DataYes!Pro)\u68c0\u7d22\u5238\u5546/\u671f\u8d27\u7814\u62a5\uff0c\u652f\u6301\u5173\u952e\u8bcd + \u591a\u7ef4\u7b5b\u9009\u3002\n" +
412
- "\u8fd4\u56de\uff1areport_id/title/org/industry/report_type/pub_date/pages/abstract\u3002\n" +
413
- "\u3010\u81ea\u7136\u8bed\u8a00\u62c6\u89e3\u89c4\u5219\uff0c\u5fc5\u987b\u9075\u5b88\u3011\n" +
414
- "1. query \u53ea\u653e\u6807\u7684\u4e3b\u4f53\u8bcd\uff08\u54c1\u79cd/\u516c\u53f8/\u4e3b\u9898\uff09\uff0c\u5982'\u7532\u9187'\u3001'\u5b81\u5fb7\u65f6\u4ee3'\u3001'LPG'\u3002\n" +
415
- " \u7edd\u4e0d\u8981\u628a'\u65e5\u62a5/\u65e9\u62a5/\u5468\u62a5/\u6708\u62a5'\u7b49\u5468\u671f\u8bcd\u653e\u8fdb query\uff08\u4f1a\u88ab\u5206\u8bcd\u6c61\u67d3\uff0c\u5339\u914d\u5230\u6240\u6709\u54c1\u79cd\u7684\u65e5\u62a5\uff0c\u566a\u58f0\u6fc0\u589e\uff09\u3002\n" +
416
- "2. \u65f6\u95f4\u8bcd\uff08\u4eca\u5929/\u672c\u5468/\u8fd1\u4e00\u6708\uff09\u2192 \u7528 start_date/end_date\u3002\u300c\u4eca\u5929\u300d\u5219 start_date=end_date=\u4eca\u5929\u3002\n" +
417
- "3. \u5927\u5b97/\u5316\u5de5/\u80fd\u6e90\u54c1\u79cd\u7684\u65e5\u62a5/\u5468\u62a5\uff08\u7532\u9187/LPG/\u539f\u6cb9/\u805a\u70ef\u70c3/\u7eaf\u78b1\u2026\uff09\u2192 report_types=['\u671f\u8d27\u7814\u7a76']\uff0c\u6392\u6389\u65e0\u5173\u7684\u516c\u53f8/\u884c\u4e1a\u7814\u62a5\u3002\n" +
418
- "\u4f8b\uff1a'\u67e5\u7532\u9187\u4eca\u5929\u7684\u65e5\u62a5' \u2192 query='\u7532\u9187', report_types=['\u671f\u8d27\u7814\u7a76'], start_date=end_date=\u4eca\u5929\u3002\n" +
419
- "\u4f8b\uff1a'\u6c38\u5b89\u671f\u8d27\u7684 LPG \u5468\u62a5' \u2192 query='LPG', report_types=['\u671f\u8d27\u7814\u7a76'], org_name='\u6c38\u5b89\u671f\u8d27'\u3002\n" +
420
- "\u6280\u5de7\uff1aorg_name \u5fc5\u987b\u662f\u673a\u6784\u8bcd\u8868\u7cbe\u786e\u540d\uff0c\u4e0d\u786e\u5b9a\u5148\u8c03 list_report_orgs\u3002",
86
+ title: "检索研报",
87
+ description: "按关键词/类型/机构/行业/市场/页数/日期检索萝卜投研(DataYes)券商研报。" +
88
+ "query 只放主体词(如'甲醇'),周期词(日报/周报/月报)不要进 query,改用 report_types + 日期范围,否则标题分词会污染召回。" +
89
+ "返回 report_id 可用于后续详情/问答/指标/图表/下载。total 上限 9999。",
421
90
  inputSchema: {
422
- query: z.string().optional().describe("\u53ea\u653e\u6807\u7684\u4e3b\u4f53\u8bcd\uff08\u54c1\u79cd/\u516c\u53f8/\u4e3b\u9898\uff09\uff0c\u5982'\u7532\u9187'\u3001'LPG'\u3001'\u5b81\u5fb7\u65f6\u4ee3'\u3002\u52ff\u542b'\u65e5\u62a5/\u5468\u62a5'\u7b49\u5468\u671f\u8bcd"),
423
- report_types: z
424
- .array(z.string())
425
- .optional()
426
- .describe("\u62a5\u544a\u7c7b\u578b\u591a\u9009\uff08\u4e2d\u6587\u6216\u82f1\u6587 code\uff09\uff1a\u5b8f\u89c2\u7814\u7a76MACRO/\u6295\u8d44\u7b56\u7565INVESTMENT/\u884c\u4e1a\u7814\u7a76INDUSTRY/\u516c\u53f8\u7814\u7a76COMPANY/\u6668\u4f1a\u65e9\u62a5MORNING/\u503a\u5238\u7814\u7a76BOND/\u57fa\u91d1\u7814\u7a76FUND/\u671f\u8d27\u7814\u7a76FUTURES/\u671f\u6743\u7814\u7a76OPTIONS/\u5916\u6c47\u7814\u7a76CURRENCY/\u65b0\u4e09\u677f\u7814\u7a76BOARD/\u91d1\u878d\u5de5\u7a0bFINANCIAL/\u5176\u4ed6\u7814\u7a76OTHER\u3002\u5927\u5b97\u5546\u54c1/\u5316\u5de5\u54c1\u79cd\u7528 \u671f\u8d27\u7814\u7a76"),
427
- report_sub_types: z
428
- .array(z.string())
429
- .optional()
430
- .describe("\u7279\u8272\u6807\u7b7e/\u5b50\u7c7b\u591a\u9009\uff0c\u5982 \u884c\u4e1a\u6df1\u5ea6/\u516c\u53f8\u6df1\u5ea6/\u9996\u6b21\u8986\u76d6/\u5546\u54c1\u671f\u8d27/\u91d1\u878d\u671f\u8d27/\u4e2d\u56fd\u7ecf\u6d4e/\u6d77\u5916\u7ecf\u6d4e/\u5e02\u573a\u7b56\u7565/\u91d1\u5de5\u6df1\u5ea6"),
431
- org_name: z.string().optional().describe("\u673a\u6784\u7cbe\u786e\u540d\uff0c\u5982'\u6c38\u5b89\u671f\u8d27'\u3001'\u534e\u6cf0\u8bc1\u5238'"),
432
- org_type: z.enum(["\u5185\u8d44", "\u5916\u8d44"]).optional().describe("\u5185\u8d44\u7814\u62a5 \u6216 \u5916\u8d44\u7814\u62a5"),
433
- industry: z.string().optional().describe("\u884c\u4e1a\u7cbe\u786e\u540d\uff0c\u5982'\u57fa\u7840\u5316\u5de5'\u3001'\u7535\u529b\u8bbe\u5907'"),
434
- market: z.enum(["A\u80a1", "\u6e2f\u80a1", "\u7f8e\u80a1"]).optional().describe("\u6807\u7684\u5e02\u573a"),
435
- min_pages: z.number().int().optional().describe("\u6700\u5c11\u9875\u6570\uff08\u5982 10/20/30\uff0c\u7528\u4e8e\u627e\u6df1\u5ea6\u62a5\u544a\uff09"),
436
- new_fortune_only: z.boolean().optional().describe("\u53ea\u770b\u65b0\u8d22\u5bcc\u5206\u6790\u5e08\uff08\u80a1\u7968\u7814\u62a5\u8d28\u91cf\u4fe1\u53f7\uff09"),
437
- start_date: z.string().optional().describe("\u53d1\u5e03\u8d77\u59cb YYYY-MM-DD\uff1b'\u4eca\u5929'\u5219\u4e0e end_date \u540c\u4e3a\u4eca\u5929"),
438
- end_date: z.string().optional().describe("\u53d1\u5e03\u7ed3\u675f YYYY-MM-DD"),
439
- sort_order: z.enum(["desc", "asc"]).optional().describe("\u6309\u53d1\u5e03\u65f6\u95f4\u6392\u5e8f\uff0c\u9ed8\u8ba4 desc\uff08\u6700\u65b0\u5728\u524d\uff09"),
440
- page: z.number().int().optional().describe("\u9875\u7801\uff0c\u9ed8\u8ba4 1"),
441
- page_size: z.number().int().optional().describe("\u6bcf\u9875\u6761\u6570\uff0c\u9ed8\u8ba4 20"),
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"),
442
105
  },
443
- annotations: { readOnlyHint: true },
444
- }, async (args) => {
106
+ }, async (a) => {
445
107
  try {
446
- const r = await searchReports(args);
447
- if (!r.items.length)
448
- return ok({ total: 0, items: [], note: "\u65e0\u7ed3\u679c" });
449
- return ok(r);
108
+ return ok(await call("POST", "/search", { body: a }));
450
109
  }
451
110
  catch (e) {
452
111
  return fail(e);
453
112
  }
454
113
  });
114
+ // 2) 详情
455
115
  server.registerTool("get_report_detail", {
456
- title: "\u7814\u62a5\u8be6\u60c5",
457
- description: "\u6839\u636e report_id \u83b7\u53d6\u5355\u7bc7\u7814\u62a5\u8be6\u60c5\uff1a\u4f5c\u8005\u3001\u9875\u6570\u3001\u5b8c\u6574\u6458\u8981\u3001\u6838\u5fc3\u6307\u6807\u3002\u6458\u8981\u59cb\u7ec8\u6709\uff1bcore_indicators \u4ec5\u90e8\u5206\uff08\u88ab\u5e73\u53f0 AI \u7ed3\u6784\u5316\u8fc7\u7684\uff09\u7814\u62a5\u6709\uff0c\u65e0\u5219\u4e3a\u7a7a\u6570\u7ec4\u3002",
458
- inputSchema: { report_id: z.number().int().describe("\u7814\u62a5 ID\uff08\u6765\u81ea search_reports\uff09") },
459
- annotations: { readOnlyHint: true },
460
- }, async (args) => {
116
+ title: "研报详情",
117
+ description: "按 report_id 取研报详情:作者、页数、摘要、核心指标。",
118
+ inputSchema: { report_id: z.number().int().describe("研报 ID,来自 search_reports") },
119
+ }, async ({ report_id }) => {
461
120
  try {
462
- return ok(await getReportDetail(args.report_id));
121
+ return ok(await call("GET", "/report/detail", { query: { report_id } }));
463
122
  }
464
123
  catch (e) {
465
124
  return fail(e);
466
125
  }
467
126
  });
468
- server.registerTool("download_report", {
469
- title: "\u83b7\u53d6\u7814\u62a5\u4e0b\u8f7d\u4fe1\u606f",
470
- description: "\u9ed8\u8ba4\u8fd4\u56de\u5355\u7bc7\u7814\u62a5\u7684\u4e0b\u8f7d\u4fe1\u606f\uff1aOSS \u5916\u94fe + \u6709\u6548\u671f + \u8bbf\u95ee\u8bf4\u660e\u3002\n" +
471
- "\u26a0\ufe0f \u8be5\u5916\u94fe\u53d7 cloud-sso-token \u4f1a\u8bdd\u7ed1\u5b9a\uff0c**\u4e0d\u80fd\u76f4\u63a5\u6253\u5f00/\u7ed9\u7528\u6237**\uff08\u88f8\u8bbf\u95ee 403\uff09\uff0c\u751f\u4ea7\u9700\u7ecf\u670d\u52a1\u7aef\u4e0b\u8f7d\u4ee3\u7406\u3002\n" +
472
- "\u8981\u771f\u6b63\u62ff\u5230 PDF \u6587\u4ef6\uff0c\u4f20 save_path\uff08\u7528\u5185\u7f6e\u4f1a\u8bdd\u843d\u76d8\uff0c\u72ec\u7acb/\u8c03\u8bd5\u7528\uff09\u3002",
473
- inputSchema: {
474
- report_id: z.number().int().describe("\u7814\u62a5 ID"),
475
- save_path: z.string().optional().describe("\uff08\u53ef\u9009\uff09\u672c\u5730\u4fdd\u5b58\u8def\u5f84\uff0c\u4f20\u4e86\u5219\u843d\u76d8\u5e76\u8fd4\u56de saved_path"),
476
- },
477
- annotations: { readOnlyHint: false },
478
- }, async (args) => {
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 }) => {
479
133
  try {
480
- if (args.save_path) {
481
- const { bytes } = await fetchReportPdf(args.report_id);
482
- const abs = resolve(args.save_path);
483
- await writeFile(abs, bytes);
484
- return ok({ report_id: args.report_id, saved_path: abs, size_kb: Math.round(bytes.length / 1024) });
485
- }
486
- return ok(await resolveDownload(args.report_id));
134
+ return ok(await call("GET", "/report/qa", { query: { report_id } }));
487
135
  }
488
136
  catch (e) {
489
137
  return fail(e);
490
138
  }
491
139
  });
492
- server.registerTool("download_reports", {
493
- title: "\u6279\u91cf\u83b7\u53d6\u7814\u62a5\u4e0b\u8f7d\u4fe1\u606f",
494
- description: "\u6279\u91cf\u89e3\u6790\u591a\u7bc7\u7814\u62a5\u7684\u4e0b\u8f7d\u4fe1\u606f\uff08\u53d7\u4f1a\u8bdd\u4fdd\u62a4\u7684 OSS \u5916\u94fe\uff09\u3002\u5185\u7f6e\u9650\u6d41+\u6296\u52a8+\u9000\u907f\uff0c\u8fd4\u56de\u6bcf\u7bc7\u6210\u8d25\u3002\n" +
495
- "\u4f20 save_dir \u5219\u7528\u5185\u7f6e\u4f1a\u8bdd\u5c06 PDF \u843d\u76d8\u5230\u8be5\u76ee\u5f55\uff08\u6587\u4ef6\u540d={report_id}.pdf\uff0c\u72ec\u7acb/\u8c03\u8bd5\u7528\uff09\u3002",
496
- inputSchema: {
497
- report_ids: z.array(z.number().int()).describe("\u7814\u62a5 ID \u5217\u8868"),
498
- save_dir: z.string().optional().describe("\uff08\u53ef\u9009\uff09\u4fdd\u5b58\u76ee\u5f55\uff0c\u4f20\u4e86\u5219\u843d\u76d8"),
499
- },
500
- annotations: { readOnlyHint: false },
501
- }, async (args) => {
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 }) => {
502
146
  try {
503
- return ok(await downloadReports(args.report_ids, args.save_dir));
147
+ return ok(await call("GET", "/report/indicators", { query: { report_id } }));
504
148
  }
505
149
  catch (e) {
506
150
  return fail(e);
507
151
  }
508
152
  });
509
- server.registerTool("get_report_qa", {
510
- title: "\u7814\u62a5\u95ee\u7b54",
511
- description: "\u8fd4\u56de\u5e73\u53f0 AI \u751f\u6210\u7684\u7814\u62a5\u95ee\u7b54\u5bf9\uff08\u542b\u9875\u7801\u5b9a\u4f4d\uff09\uff0c\u9002\u5408\u5feb\u901f\u628a\u63e1\u6838\u5fc3\u95ee\u9898\u4e0e\u7ed3\u8bba\u3002\n\u6ce8\uff1a\u5e76\u975e\u6bcf\u7bc7\u7814\u62a5\u90fd\u6709\uff0c\u8fd4\u56de\u7a7a\u6570\u7ec4\u53ea\u8bf4\u660e\u8be5\u7bc7\u672a\u88ab\u5e73\u53f0 AI \u7ed3\u6784\u5316\uff0c\u4e0d\u4ee3\u8868\u7814\u62a5\u672c\u8eab\u65e0\u5185\u5bb9\uff08\u53ef\u8f6c\u800c\u770b\u6458\u8981\u6216\u4e0b\u8f7d\u539f\u6587\uff09\u3002",
512
- inputSchema: { report_id: z.number().int().describe("\u7814\u62a5 ID") },
513
- annotations: { readOnlyHint: true },
514
- }, async (args) => {
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 }) => {
515
159
  try {
516
- return ok({ report_id: args.report_id, qa: await getReportQa(args.report_id) });
160
+ return ok(await call("GET", "/report/charts", { query: { report_id } }));
517
161
  }
518
162
  catch (e) {
519
163
  return fail(e);
520
164
  }
521
165
  });
522
- server.registerTool("get_report_indicators", {
523
- title: "\u7814\u62a5\u6838\u5fc3\u6307\u6807",
524
- description: "\u8fd4\u56de\u7814\u62a5\u6838\u5fc3\u6307\u6807\uff08\u6307\u6807\u540d+\u6570\u503c+\u65e5\u671f+\u9875\u7801\uff0cis_forecast \u6807\u8bc6\u662f\u5426\u9884\u6d4b\u503c\uff09\u3002\n\u6ce8\uff1a\u4ec5\u88ab\u5e73\u53f0 AI \u7ed3\u6784\u5316\u8fc7\u7684\u7814\u62a5\u6709\uff1b\u7a7a\u6570\u7ec4\u4e0d\u4ee3\u8868\u7814\u62a5\u65e0\u6307\u6807\u3002",
525
- inputSchema: { report_id: z.number().int().describe("\u7814\u62a5 ID") },
526
- annotations: { readOnlyHint: true },
527
- }, async (args) => {
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 }) => {
528
174
  try {
529
- return ok({ report_id: args.report_id, indicators: await getReportIndicators(args.report_id) });
175
+ return ok(await call("POST", "/report/download", { body: { report_id } }));
530
176
  }
531
177
  catch (e) {
532
178
  return fail(e);
533
179
  }
534
180
  });
535
- server.registerTool("get_report_charts", {
536
- title: "\u7814\u62a5\u56fe\u8868",
537
- description: "\u8fd4\u56de\u7814\u62a5\u56fe\u8868\u5217\u8868\uff1a\u6807\u9898+\u9875\u7801+\u56fe\u7247\u94fe(image_url)+\u5e95\u5c42\u6570\u636e\u8868\u94fe(data_xlsx_url)\u3002\u9700\u8981\u56fe\u8868\u80cc\u540e\u6570\u636e\u65f6\u7528\u3002\n\u6ce8\uff1a\u4ec5\u88ab\u5e73\u53f0 AI \u7ed3\u6784\u5316\u8fc7\u7684\u7814\u62a5\u6709\uff1b\u7a7a\u6570\u7ec4\u4e0d\u4ee3\u8868\u7814\u62a5\u65e0\u56fe\u8868\u3002",
538
- inputSchema: { report_id: z.number().int().describe("\u7814\u62a5 ID") },
539
- annotations: { readOnlyHint: true },
540
- }, async (args) => {
541
- try {
542
- return ok({ report_id: args.report_id, charts: await getReportCharts(args.report_id) });
543
- }
544
- catch (e) {
545
- return fail(e);
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));
546
202
  }
203
+ return ok({ count: results.length, results });
547
204
  });
205
+ // 8) 机构词表
548
206
  server.registerTool("list_report_orgs", {
549
- title: "\u673a\u6784\u8bcd\u8868",
550
- description: "\u8fd4\u56de\u53ef\u7528\u4e8e search_reports \u7684 org_name \u7684\u673a\u6784\u7cbe\u786e\u540d\u5217\u8868\uff08\u652f\u6301\u5173\u952e\u5b57\u8fc7\u6ee4\uff09\u3002\u586b org_name \u524d\u5148\u7528\u5b83\u6838\u5b9e\u3002\n" +
551
- "\u5e93\u5185\u5171 1400+ \u5bb6\uff1bcount \u662f\u547d\u4e2d\u603b\u6570\uff0corgs \u6700\u591a\u8fd4\u56de 100 \u6761\u3002\u8bf7\u52a1\u5fc5\u4f20 prefix \u7f29\u8303\uff08\u5982'\u6c38\u5b89'/'\u534e\u6cf0'\uff09\uff0c\u522b\u7a7a\u67e5\u3002",
552
- inputSchema: { prefix: z.string().optional().describe("\u673a\u6784\u540d\u5305\u542b\u7684\u5173\u952e\u5b57\uff0c\u5982'\u534e\u6cf0'") },
553
- annotations: { readOnlyHint: true },
554
- }, async (args) => {
207
+ title: "机构名词表",
208
+ description: "查研报机构名词表(用于 search_reports 的 org_name 精确匹配)。" +
209
+ "建议传 prefix 过滤(如'华泰'),否则最多返回 100 条。机构名需与词表完全一致才能命中。",
210
+ inputSchema: { prefix: z.string().optional().describe("机构名关键字,如'华泰'") },
211
+ }, async ({ prefix }) => {
555
212
  try {
556
- const orgs = await listOrgs(args.prefix);
557
- return ok({ count: orgs.length, orgs: orgs.slice(0, 100) });
213
+ return ok(await call("GET", "/orgs", { query: { prefix } }));
558
214
  }
559
215
  catch (e) {
560
216
  return fail(e);
561
217
  }
562
218
  });
563
- // ============================================================
564
- // \u542f\u52a8\uff1astdio\uff08\u9ed8\u8ba4\uff09\u6216 Streamable HTTP\uff08\u670d\u52a1\u5668\u5e38\u9a7b\uff09
565
- // \u7528 MCP_TRANSPORT=http \u5207\u5230 HTTP\uff0c\u7aef\u53e3 MCP_PORT\uff08\u9ed8\u8ba4 8080\uff09
566
- // ============================================================
567
- const TRANSPORT = (process.env.MCP_TRANSPORT ?? "stdio").toLowerCase();
568
- if (TRANSPORT === "http") {
569
- const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js");
570
- const { createServer } = await import("node:http");
571
- const { randomUUID } = await import("node:crypto");
572
- const PORT = Number(process.env.MCP_PORT ?? "8080");
573
- const PATH = process.env.MCP_PATH ?? "/mcp";
574
- // \u65e0\u72b6\u6001\u6a21\u5f0f\uff1a\u6bcf\u4e2a\u8bf7\u6c42\u4e00\u4e2a\u4e00\u6b21\u6027 transport\uff0c\u9002\u5408\u4f60\u670d\u52a1\u5668\u5728\u524d\u9762\u505a\u8c03\u5ea6
575
- const httpServer = createServer(async (req, res) => {
576
- if (!req.url || !req.url.startsWith(PATH)) {
577
- res.writeHead(404).end("Not Found");
578
- return;
579
- }
580
- try {
581
- const transport = new StreamableHTTPServerTransport({
582
- sessionIdGenerator: () => randomUUID(),
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();
583
236
  });
584
- res.on("close", () => transport.close());
585
- await server.connect(transport);
586
- // \u8bfb\u53d6 body
587
- let body = "";
588
- req.on("data", (c) => (body += c));
589
- await new Promise((r) => req.on("end", () => r()));
590
- const parsed = body ? JSON.parse(body) : undefined;
591
- await transport.handleRequest(req, res, parsed);
592
- }
593
- catch (e) {
594
- console.error("[linker-luobo-mcp] HTTP \u5904\u7406\u5f02\u5e38:", e);
595
- if (!res.headersSent)
596
- res.writeHead(500).end("Internal Error");
597
- }
598
- });
599
- httpServer.listen(PORT, () => {
600
- console.error(`linker-luobo-mcp started (HTTP) http://127.0.0.1:${PORT}${PATH}`);
601
- });
602
- }
603
- else {
604
- const transport = new StdioServerTransport();
605
- await server.connect(transport);
606
- console.error("linker-luobo-mcp started (stdio)");
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
+ }
607
245
  }
246
+ main().catch((e) => {
247
+ console.error("[fatal]", e);
248
+ process.exit(1);
249
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "linker-luobo-mcp",
3
- "version": "0.1.0",
4
- "description": "MCP server for 萝卜投研 (DataYes!Pro) research report search / detail / PDF OSS link",
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
5
  "author": "陈定钢",
6
6
  "type": "module",
7
7
  "bin": {