@apocdata-info/mcp-server 0.1.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/CHANGELOG.md +50 -0
- package/LICENSE +191 -0
- package/README.md +230 -0
- package/dist/cache.d.ts +26 -0
- package/dist/cache.js +51 -0
- package/dist/cache.js.map +1 -0
- package/dist/client.js +113 -0
- package/dist/errors.d.ts +37 -0
- package/dist/errors.js +56 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +259 -0
- package/dist/index.js.map +1 -0
- package/dist/resources.js +229 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.js +69 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/factor-registry.d.ts +17 -0
- package/dist/tools/factor-registry.js +36 -0
- package/dist/tools/factor-registry.js.map +1 -0
- package/dist/tools/index.d.ts +21 -0
- package/dist/tools/index.js +45 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/macro-dashboard.d.ts +17 -0
- package/dist/tools/macro-dashboard.js +30 -0
- package/dist/tools/macro-dashboard.js.map +1 -0
- package/dist/tools/macro-indicator.d.ts +17 -0
- package/dist/tools/macro-indicator.js +45 -0
- package/dist/tools/macro-indicator.js.map +1 -0
- package/dist/tools/quotes-snapshot.d.ts +17 -0
- package/dist/tools/quotes-snapshot.js +39 -0
- package/dist/tools/quotes-snapshot.js.map +1 -0
- package/dist/tools/stock-bars-daily.d.ts +17 -0
- package/dist/tools/stock-bars-daily.js +45 -0
- package/dist/tools/stock-bars-daily.js.map +1 -0
- package/dist/tools/stock-detail.d.ts +17 -0
- package/dist/tools/stock-detail.js +38 -0
- package/dist/tools/stock-detail.js.map +1 -0
- package/dist/tools/stock-financials.d.ts +17 -0
- package/dist/tools/stock-financials.js +36 -0
- package/dist/tools/stock-financials.js.map +1 -0
- package/dist/tools/stock-list.d.ts +17 -0
- package/dist/tools/stock-list.js +39 -0
- package/dist/tools/stock-list.js.map +1 -0
- package/dist/tools/stock-news.d.ts +17 -0
- package/dist/tools/stock-news.js +36 -0
- package/dist/tools/stock-news.js.map +1 -0
- package/dist/tools/stock-quote.d.ts +17 -0
- package/dist/tools/stock-quote.js +32 -0
- package/dist/tools/stock-quote.js.map +1 -0
- package/dist/tools/top10-holders.d.ts +17 -0
- package/dist/tools/top10-holders.js +35 -0
- package/dist/tools/top10-holders.js.map +1 -0
- package/dist/tools/trade-days.d.ts +17 -0
- package/dist/tools/trade-days.js +36 -0
- package/dist/tools/trade-days.js.map +1 -0
- package/dist/tools/types.d.ts +29 -0
- package/dist/tools/types.js +16 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/tools.js +540 -0
- package/dist/transport.d.ts +44 -0
- package/dist/transport.js +87 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +54 -0
- package/dist/types.js +16 -0
- package/dist/types.js.map +1 -0
- package/package.json +67 -0
- package/scripts/client-unit-test.mjs +147 -0
- package/scripts/contract-test.mjs +167 -0
- package/scripts/coverage-test.mjs +126 -0
- package/scripts/error-path-test.mjs +110 -0
- package/scripts/freshness-probe.mjs +144 -0
- package/scripts/integration-test.mjs +284 -0
- package/scripts/mcp-e2e-test.mjs +196 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 错误路径测试:构造非法参数,验证业务错误的响应契约。
|
|
4
|
+
*
|
|
5
|
+
* 契约:
|
|
6
|
+
* - 业务错误用 HTTP 200 + R.success=false 表达(不走 4xx)
|
|
7
|
+
* - R.msg 给出可读错误说明
|
|
8
|
+
* - 计划中 R.code 应该是字符串错误码(INVALID_PARAM_VALUE 等),但当前线上为 HTTP 数值
|
|
9
|
+
* —— 这是路线图 §5.1 的待发版功能
|
|
10
|
+
*
|
|
11
|
+
* 用例分两类:
|
|
12
|
+
* - PROD: 线上已部署的校验(RESOURCE_NOT_FOUND、日期格式、跨度上限)
|
|
13
|
+
* - LAG: 源码已实现但线上未部署的校验(多接口非法 enum 当前会被静默接受)
|
|
14
|
+
* LAG 用例只输出"观察到的行为",不参与 pass/fail 判定
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const BASE = process.env.APOCDATA_BASE_URL
|
|
18
|
+
?? 'https://data.tianqis.com/api/blade-dataplatform/open/data';
|
|
19
|
+
|
|
20
|
+
const CASES = [
|
|
21
|
+
// 线上已部署的错误校验
|
|
22
|
+
{ kind: 'PROD', path: 'holders', params: { symbol: '999999' }, desc: 'holders 不存在 symbol' },
|
|
23
|
+
{ kind: 'PROD', path: 'st', params: { symbol: '999999' }, desc: 'st 不存在 symbol' },
|
|
24
|
+
{ kind: 'PROD', path: 'moneyflow', params: { symbol: '999999' }, desc: 'moneyflow 不存在 symbol' },
|
|
25
|
+
{ kind: 'PROD', path: 'announcements', params: { symbol: '999999' }, desc: 'announcements 不存在 symbol' },
|
|
26
|
+
{ kind: 'PROD', path: 'express', params: { symbol: '999999' }, desc: 'express 不存在 symbol' },
|
|
27
|
+
{ kind: 'PROD', path: 'daily', params: { symbol: '600519', start: '20260101', end: 'bad' }, desc: 'daily end 日期格式错' },
|
|
28
|
+
{ kind: 'PROD', path: 'daily', params: { symbol: '600519', start: '20260101' }, desc: 'daily start/end 必须成对' },
|
|
29
|
+
{ kind: 'PROD', path: 'calendar', params: { start: 'bad', end: '20260101' }, desc: 'calendar 起始日格式错' },
|
|
30
|
+
{ kind: 'PROD', path: 'calendar', params: { start: '20250101', end: '20260601' }, desc: 'calendar 跨度超 366 天' },
|
|
31
|
+
{ kind: 'PROD', path: 'limit-list', params: { kind: 'X' }, desc: 'limit-list kind 必须 U/D/Z' },
|
|
32
|
+
|
|
33
|
+
// 源码已写但线上未发版(§2.1)
|
|
34
|
+
{ kind: 'LAG', path: 'ranking', params: { direction: 'foo' }, desc: 'ranking direction 必须 gain/loss' },
|
|
35
|
+
{ kind: 'LAG', path: 'macro', params: { type: 'XYZ' }, desc: 'macro type 必须 GDP/CPI/PPI/PMI' },
|
|
36
|
+
{ kind: 'LAG', path: 'macro/latest', params: { type: 'XYZ' }, desc: 'macro/latest type 限制' },
|
|
37
|
+
{ kind: 'LAG', path: 'macro/definition', params: { type: 'XYZ' }, desc: 'macro/definition type 限制' },
|
|
38
|
+
{ kind: 'LAG', path: 'sector-flow', params: { type: 'foo' }, desc: 'sector-flow type 必须 industry/concept/region' },
|
|
39
|
+
{ kind: 'LAG', path: 'hot-rank', params: { type: '欧股市场' }, desc: 'hot-rank type 限制 4 选 1' },
|
|
40
|
+
{ kind: 'LAG', path: 'margin', params: { exchange: 'NYSE' }, desc: 'margin exchange 必须 SSE/SZSE/BSE' },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
function buildUrl(path, params) {
|
|
44
|
+
const url = new URL(`${BASE.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`);
|
|
45
|
+
for (const [k, v] of Object.entries(params)) {
|
|
46
|
+
if (v === undefined || v === null || v === '') continue;
|
|
47
|
+
url.searchParams.append(k, String(v));
|
|
48
|
+
}
|
|
49
|
+
return url.toString();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const results = [];
|
|
53
|
+
|
|
54
|
+
for (const c of CASES) {
|
|
55
|
+
const url = buildUrl(c.path, c.params);
|
|
56
|
+
const line = { ...c };
|
|
57
|
+
try {
|
|
58
|
+
const res = await fetch(url, {
|
|
59
|
+
headers: { 'User-Agent': 'apocdata-error-test/0.1', Accept: 'application/json' },
|
|
60
|
+
});
|
|
61
|
+
line.http = res.status;
|
|
62
|
+
line.errHeader = res.headers.get('x-tdc-error-code') ?? '';
|
|
63
|
+
const text = await res.text();
|
|
64
|
+
let body;
|
|
65
|
+
try { body = JSON.parse(text); } catch { body = null; }
|
|
66
|
+
if (body && typeof body === 'object') {
|
|
67
|
+
line.success = body.success;
|
|
68
|
+
line.code = body.code;
|
|
69
|
+
line.msg = String(body.msg ?? '').slice(0, 60);
|
|
70
|
+
} else {
|
|
71
|
+
line.msg = String(text).slice(0, 60);
|
|
72
|
+
}
|
|
73
|
+
} catch (err) {
|
|
74
|
+
line.http = 'ERR';
|
|
75
|
+
line.msg = err.message;
|
|
76
|
+
}
|
|
77
|
+
// 判定:PROD 必须 success=false;LAG 只观察
|
|
78
|
+
line.pass = line.kind === 'LAG' ? null : line.success === false;
|
|
79
|
+
results.push(line);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const headers = ['kind', 'path', 'desc', 'http', 'success', 'code', 'errHdr', 'msg', 'verdict'];
|
|
83
|
+
const colWidth = {
|
|
84
|
+
kind: 4,
|
|
85
|
+
path: Math.max(...results.map(r => r.path.length), 4),
|
|
86
|
+
desc: Math.max(...results.map(r => r.desc.length), 4),
|
|
87
|
+
http: 4,
|
|
88
|
+
success: 7,
|
|
89
|
+
code: 6,
|
|
90
|
+
errHdr: 10,
|
|
91
|
+
msg: 45,
|
|
92
|
+
verdict: 7,
|
|
93
|
+
};
|
|
94
|
+
const pad = (s, w) => String(s ?? '').slice(0, w).padEnd(w);
|
|
95
|
+
console.log(headers.map(h => pad(h, colWidth[h])).join(' │ '));
|
|
96
|
+
console.log(headers.map(h => '─'.repeat(colWidth[h])).join('─┼─'));
|
|
97
|
+
for (const r of results) {
|
|
98
|
+
const verdict = r.pass === null ? '— LAG' : (r.pass ? '✓ pass' : '✗ FAIL');
|
|
99
|
+
console.log(headers.map(h => pad(h === 'verdict' ? verdict : h === 'errHdr' ? r.errHeader : r[h], colWidth[h])).join(' │ '));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const prod = results.filter(r => r.kind === 'PROD');
|
|
103
|
+
const prodPass = prod.filter(r => r.pass).length;
|
|
104
|
+
const lag = results.filter(r => r.kind === 'LAG');
|
|
105
|
+
const lagBehavior = lag.filter(r => r.success === false).length;
|
|
106
|
+
|
|
107
|
+
console.log('');
|
|
108
|
+
console.log(`PROD: ${prodPass}/${prod.length} pass`);
|
|
109
|
+
console.log(`LAG : ${lagBehavior}/${lag.length} 当前线上已校验(其余仍待发版)`);
|
|
110
|
+
process.exit(prodPass === prod.length ? 0 : 1);
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 数据新鲜度/正确性探查:对 ALL_TOOLS 每个工具拉真实数据,
|
|
4
|
+
* 提取所有日期类字段(trade_date / ann_date / updated_at / date / ts 等),
|
|
5
|
+
* 打印该接口出现过的最新日期值,用于判断数据是否"最新"。
|
|
6
|
+
*
|
|
7
|
+
* 参考基准(2026-08-03 周一 15:06,A股已收盘):
|
|
8
|
+
* 正常最新交易日应为 2026-08-03(今日)或 2026-07-31(若 T+1)。
|
|
9
|
+
* 任何 max 日期 < 2026-07-31 视为"数据陈旧/延迟"。
|
|
10
|
+
*/
|
|
11
|
+
import { ALL_TOOLS } from '../dist/tools.js';
|
|
12
|
+
|
|
13
|
+
const BASE = process.env.APOCDATA_BASE_URL
|
|
14
|
+
?? 'https://data.tianqis.com/api/blade-dataplatform/open/data';
|
|
15
|
+
|
|
16
|
+
const TEST_INPUTS = {
|
|
17
|
+
quote: { symbol: '000001' },
|
|
18
|
+
quotes: { symbols: '000001,600519' },
|
|
19
|
+
daily: { symbol: '600519', limit: 5 },
|
|
20
|
+
stocks: { q: '茅台', limit: 3 },
|
|
21
|
+
stock: { symbol: '600519' },
|
|
22
|
+
st: { symbol: '600519' },
|
|
23
|
+
ranking: { direction: 'gain', limit: 3 },
|
|
24
|
+
indexes: { q: '沪深300', limit: 3 },
|
|
25
|
+
'index-daily': { tsCode: '000300.SH', limit: 5 },
|
|
26
|
+
'hot-rank': { type: 'A股市场', limit: 3 },
|
|
27
|
+
financial: { symbol: '600519', limit: 2 },
|
|
28
|
+
express: { symbol: '600519', limit: 3 },
|
|
29
|
+
dividend: { symbol: '600519', limit: 3 },
|
|
30
|
+
holders: { symbol: '600519' },
|
|
31
|
+
'holder-number': { symbol: '600519', limit: 3 },
|
|
32
|
+
'share-float': { symbol: '600519', limit: 3 },
|
|
33
|
+
repurchase: { symbol: '600519', limit: 3 },
|
|
34
|
+
'block-trade': { symbol: '600519', limit: 3 },
|
|
35
|
+
moneyflow: { symbol: '600519', limit: 5 },
|
|
36
|
+
hsgt: { limit: 3 },
|
|
37
|
+
'hk-hold': { symbol: '600519', limit: 3 },
|
|
38
|
+
'hk-daily': { tsCode: '00700.HK', limit: 5 },
|
|
39
|
+
margin: { limit: 3 },
|
|
40
|
+
'dragon-tiger': { limit: 3 },
|
|
41
|
+
'hot-money': { limit: 3 },
|
|
42
|
+
'hot-money-detail': { limit: 3 },
|
|
43
|
+
'limit-list': { kind: 'U', limit: 3 },
|
|
44
|
+
'limit-step': { limit: 3 },
|
|
45
|
+
'sector-flow': { type: 'industry', limit: 3 },
|
|
46
|
+
'cyq-perf': { symbol: '600519', limit: 3 },
|
|
47
|
+
announcements: { symbol: '600519', limit: 3 },
|
|
48
|
+
survey: { symbol: '600519', limit: 3 },
|
|
49
|
+
concepts: { limit: 3 },
|
|
50
|
+
'concept-stocks': { themeCode: 'INVALID', limit: 3 },
|
|
51
|
+
'ths-boards': { limit: 3 },
|
|
52
|
+
'ths-board-stocks': { tsCode: 'INVALID', limit: 3 },
|
|
53
|
+
'convertible-bonds': { limit: 3 },
|
|
54
|
+
'cb-price-chg': { tsCode: '127026.SZ', limit: 3 },
|
|
55
|
+
factors: {},
|
|
56
|
+
'tech-factor': { symbol: '600519', limit: 3 },
|
|
57
|
+
macro: { type: 'CPI', limit: 3 },
|
|
58
|
+
'macro-latest': { type: 'CPI' },
|
|
59
|
+
'macro-definition': { type: 'CPI' },
|
|
60
|
+
calendar: { start: '20260801', end: '20260803' },
|
|
61
|
+
'profile-full': { symbol: '600519' },
|
|
62
|
+
'factor-categories': {},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const DATE_KEY_RE = /date|time|updated|_at$|^ts$|datetime|年月/i;
|
|
66
|
+
const DATE_VAL_RE = /^\d{8}$|^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2})?)?$|^\d{4}\/\d{2}\/\d{2}/;
|
|
67
|
+
|
|
68
|
+
function buildUrl(tool, params) {
|
|
69
|
+
const url = new URL(`${BASE.replace(/\/+$/, '')}/${tool.path.replace(/^\/+/, '')}`);
|
|
70
|
+
for (const [k, v] of Object.entries(params)) {
|
|
71
|
+
if (v === undefined || v === null || v === '') continue;
|
|
72
|
+
url.searchParams.append(k, String(v));
|
|
73
|
+
}
|
|
74
|
+
return url.toString();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 归一化日期到 YYYYMMDD 用于比较
|
|
78
|
+
function normDate(v) {
|
|
79
|
+
if (typeof v !== 'string') return null;
|
|
80
|
+
let m = v.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
|
81
|
+
if (m) return m[1] + m[2] + m[3];
|
|
82
|
+
m = v.match(/^(\d{4})(\d{2})(\d{2})/);
|
|
83
|
+
if (m) return m[1] + m[2] + m[3];
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function walk(value, out) {
|
|
88
|
+
if (value === null || value === undefined) return;
|
|
89
|
+
if (Array.isArray(value)) { value.slice(0, 8).forEach(r => walk(r, out)); return; }
|
|
90
|
+
if (typeof value === 'object') {
|
|
91
|
+
for (const [k, v] of Object.entries(value)) {
|
|
92
|
+
if (DATE_KEY_RE.test(k) && DATE_VAL_RE.test(String(v))) {
|
|
93
|
+
const nd = normDate(String(v));
|
|
94
|
+
if (nd) out.push({ key: k, raw: String(v), norm: nd });
|
|
95
|
+
} else if (typeof v === 'object') {
|
|
96
|
+
walk(v, out);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const results = [];
|
|
103
|
+
for (const tool of ALL_TOOLS) {
|
|
104
|
+
const input = TEST_INPUTS[tool.name];
|
|
105
|
+
if (input === undefined) { results.push({ tool: tool.name, note: 'skip(no input)' }); continue; }
|
|
106
|
+
const url = buildUrl(tool, input);
|
|
107
|
+
const line = { tool: tool.name };
|
|
108
|
+
try {
|
|
109
|
+
const res = await fetch(url, { headers: { 'User-Agent': 'apocdata-freshness/0.1', Accept: 'application/json' } });
|
|
110
|
+
line.http = res.status;
|
|
111
|
+
const body = await res.json().catch(() => null);
|
|
112
|
+
if (!body || body.success === false) { line.note = 'API fail: ' + (body?.msg || res.status); results.push(line); continue; }
|
|
113
|
+
const dates = [];
|
|
114
|
+
walk(body.data, dates);
|
|
115
|
+
if (dates.length === 0) {
|
|
116
|
+
line.dates = '(无日期字段)';
|
|
117
|
+
line.max = '-';
|
|
118
|
+
} else {
|
|
119
|
+
const uniq = [...new Set(dates.map(d => `${d.key}=${d.raw}`))];
|
|
120
|
+
line.dates = uniq.slice(0, 6).join(' ');
|
|
121
|
+
line.max = dates.reduce((a, b) => (b.norm > a.norm ? b : a)).norm;
|
|
122
|
+
}
|
|
123
|
+
} catch (e) {
|
|
124
|
+
line.note = 'ERR ' + e.message;
|
|
125
|
+
}
|
|
126
|
+
results.push(line);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const CUTOFF = '20260731';
|
|
130
|
+
console.log('tool'.padEnd(18), '│ http │ max日期 │ 日期字段(样例)');
|
|
131
|
+
console.log('─'.repeat(18), '┼──────┼───────────┼────────────────────────────────────────');
|
|
132
|
+
let stale = 0, ok = 0;
|
|
133
|
+
for (const r of results) {
|
|
134
|
+
if (r.max && r.max !== '-') {
|
|
135
|
+
const flag = r.max < CUTOFF ? ' ⚠陈旧' : ' ✓';
|
|
136
|
+
if (r.max < CUTOFF) stale++; else ok++;
|
|
137
|
+
console.log(r.tool.padEnd(18), '│', String(r.http ?? '').padEnd(4), '│', r.max, '│', (r.dates || '').slice(0, 50), flag);
|
|
138
|
+
} else {
|
|
139
|
+
console.log(r.tool.padEnd(18), '│', String(r.http ?? '').padEnd(4), '│', (r.max || '-'), '│', (r.dates || r.note || '').slice(0, 50));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
console.log('');
|
|
143
|
+
console.log(`时间敏感接口:✓最新(≥7/31) ${ok} 个 │ ⚠陈旧(<7/31) ${stale} 个`);
|
|
144
|
+
console.log(`基准:2026-08-03 周一收盘,正常应到 2026-08-03 或 2026-07-31`);
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 集成测试:
|
|
4
|
+
* 1. 起一个本地 mock HTTP server 编排响应序列
|
|
5
|
+
* 2. 通过子进程启 MCP server,APOCDATA_BASE_URL 指向 mock
|
|
6
|
+
* 3. 验证:
|
|
7
|
+
* - 真实 retries:连续 503 后 200 应在 ~500+1000ms 后成功
|
|
8
|
+
* - timeout:mock 故意延迟,client timeout 应触发 NetworkError
|
|
9
|
+
* - --version:纯 CLI 行为
|
|
10
|
+
* - SIGTERM 优雅退出:发信号后进程在 grace period 内 exit 0
|
|
11
|
+
* - SIGTERM 等 in-flight:信号到来时有正在跑的请求,等完成后再退
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { createServer } from 'node:http';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { dirname, resolve } from 'node:path';
|
|
18
|
+
|
|
19
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const SERVER_JS = resolve(__dirname, '..', 'dist', 'index.js');
|
|
21
|
+
|
|
22
|
+
const checks = [];
|
|
23
|
+
function check(name, cond, detail = '') {
|
|
24
|
+
checks.push({ name, pass: !!cond, detail });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 起一个 mock HTTP server,handler 决定每次请求的响应 */
|
|
28
|
+
function startMockServer(handler) {
|
|
29
|
+
return new Promise((resolveServer) => {
|
|
30
|
+
let callCount = 0;
|
|
31
|
+
const server = createServer((req, res) => {
|
|
32
|
+
callCount++;
|
|
33
|
+
Promise.resolve(handler(callCount, req)).then((spec) => {
|
|
34
|
+
res.statusCode = spec.status ?? 200;
|
|
35
|
+
for (const [k, v] of Object.entries(spec.headers ?? {})) res.setHeader(k, v);
|
|
36
|
+
res.end(spec.body ?? '');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
server.listen(0, '127.0.0.1', () => {
|
|
40
|
+
const port = server.address().port;
|
|
41
|
+
resolveServer({
|
|
42
|
+
url: `http://127.0.0.1:${port}/open/data`,
|
|
43
|
+
get callCount() { return callCount; },
|
|
44
|
+
close: () => new Promise((r) => server.close(r)),
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 用 MCP JSON-RPC 包一次 tools/call,返回结果文本和耗时 */
|
|
51
|
+
class McpHarness {
|
|
52
|
+
constructor(env = {}) {
|
|
53
|
+
this.proc = spawn('node', [SERVER_JS], {
|
|
54
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
55
|
+
env: { ...process.env, ...env },
|
|
56
|
+
});
|
|
57
|
+
this.buf = '';
|
|
58
|
+
this.pending = new Map();
|
|
59
|
+
this.nextId = 1;
|
|
60
|
+
this.exitCode = null;
|
|
61
|
+
this.exitSignal = null;
|
|
62
|
+
this.proc.stdout.on('data', (chunk) => this._onData(chunk));
|
|
63
|
+
this.proc.stderr.on('data', () => {});
|
|
64
|
+
this.proc.on('exit', (code, signal) => {
|
|
65
|
+
this.exitCode = code;
|
|
66
|
+
this.exitSignal = signal;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
_onData(chunk) {
|
|
71
|
+
this.buf += chunk.toString('utf-8');
|
|
72
|
+
let idx;
|
|
73
|
+
while ((idx = this.buf.indexOf('\n')) >= 0) {
|
|
74
|
+
const line = this.buf.slice(0, idx).trim();
|
|
75
|
+
this.buf = this.buf.slice(idx + 1);
|
|
76
|
+
if (!line) continue;
|
|
77
|
+
try {
|
|
78
|
+
const msg = JSON.parse(line);
|
|
79
|
+
if (msg.id !== undefined && this.pending.has(msg.id)) {
|
|
80
|
+
this.pending.get(msg.id).resolve(msg);
|
|
81
|
+
this.pending.delete(msg.id);
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// 忽略
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
request(method, params, timeoutMs = 30000) {
|
|
90
|
+
const id = this.nextId++;
|
|
91
|
+
return new Promise((resolveReq, rejectReq) => {
|
|
92
|
+
this.pending.set(id, { resolve: resolveReq, reject: rejectReq });
|
|
93
|
+
this.proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
|
|
94
|
+
setTimeout(() => {
|
|
95
|
+
if (this.pending.has(id)) {
|
|
96
|
+
this.pending.delete(id);
|
|
97
|
+
rejectReq(new Error(`Timeout ${method}`));
|
|
98
|
+
}
|
|
99
|
+
}, timeoutMs);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
notify(method, params) {
|
|
104
|
+
this.proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async init() {
|
|
108
|
+
await this.request('initialize', {
|
|
109
|
+
protocolVersion: '2024-11-05',
|
|
110
|
+
capabilities: {},
|
|
111
|
+
clientInfo: { name: 'integration', version: '0' },
|
|
112
|
+
});
|
|
113
|
+
this.notify('notifications/initialized');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async waitForExit(timeoutMs = 6000) {
|
|
117
|
+
const deadline = Date.now() + timeoutMs;
|
|
118
|
+
while (this.exitCode === null && Date.now() < deadline) {
|
|
119
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
120
|
+
}
|
|
121
|
+
return { code: this.exitCode, signal: this.exitSignal };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
close() {
|
|
125
|
+
try { this.proc.stdin.end(); } catch {}
|
|
126
|
+
this.proc.kill();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ============= 测试 1:真实 retries =============
|
|
131
|
+
{
|
|
132
|
+
const mock = await startMockServer((n) => {
|
|
133
|
+
if (n < 3) return { status: 503, body: JSON.stringify({ msg: 'unavailable' }) };
|
|
134
|
+
return {
|
|
135
|
+
status: 200,
|
|
136
|
+
headers: { 'content-type': 'application/json' },
|
|
137
|
+
body: JSON.stringify({ code: 200, success: true, data: { symbol: '000001' } }),
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const harness = new McpHarness({
|
|
142
|
+
APOCDATA_BASE_URL: mock.url,
|
|
143
|
+
APOCDATA_MAX_RETRIES: '3',
|
|
144
|
+
});
|
|
145
|
+
await harness.init();
|
|
146
|
+
|
|
147
|
+
const start = Date.now();
|
|
148
|
+
const res = await harness.request('tools/call', {
|
|
149
|
+
name: 'stock',
|
|
150
|
+
arguments: { symbol: '000001' },
|
|
151
|
+
});
|
|
152
|
+
const elapsed = Date.now() - start;
|
|
153
|
+
|
|
154
|
+
check('retry 后 isError=false', res.result?.isError === false);
|
|
155
|
+
check('retry 后 body 含 success', res.result?.content?.[0]?.text?.includes('"success": true'));
|
|
156
|
+
check('mock server 被调 3 次', mock.callCount === 3, `actual=${mock.callCount}`);
|
|
157
|
+
// backoff: 500 + 1000 = 1500ms 最少
|
|
158
|
+
check('retry 耗时 ≥ 1500ms(backoff 真实生效)', elapsed >= 1500, `elapsed=${elapsed}ms`);
|
|
159
|
+
|
|
160
|
+
harness.close();
|
|
161
|
+
await mock.close();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ============= 测试 2:timeout =============
|
|
165
|
+
{
|
|
166
|
+
const mock = await startMockServer(() => new Promise((r) => {
|
|
167
|
+
// 永远不响应
|
|
168
|
+
setTimeout(() => r({ status: 200, body: 'too late' }), 30000);
|
|
169
|
+
}));
|
|
170
|
+
|
|
171
|
+
const harness = new McpHarness({
|
|
172
|
+
APOCDATA_BASE_URL: mock.url,
|
|
173
|
+
APOCDATA_TIMEOUT_MS: '200',
|
|
174
|
+
APOCDATA_MAX_RETRIES: '0',
|
|
175
|
+
});
|
|
176
|
+
await harness.init();
|
|
177
|
+
|
|
178
|
+
const start = Date.now();
|
|
179
|
+
const res = await harness.request('tools/call', {
|
|
180
|
+
name: 'stock',
|
|
181
|
+
arguments: { symbol: '000001' },
|
|
182
|
+
}, 10000);
|
|
183
|
+
const elapsed = Date.now() - start;
|
|
184
|
+
|
|
185
|
+
check('timeout isError=true', res.result?.isError === true);
|
|
186
|
+
check('timeout 错误消息含 "timed out"', res.result?.content?.[0]?.text?.includes('timed out'));
|
|
187
|
+
check('timeout 实际生效(<1500ms)', elapsed < 1500, `elapsed=${elapsed}ms`);
|
|
188
|
+
|
|
189
|
+
harness.close();
|
|
190
|
+
await mock.close();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ============= 测试 3:--version =============
|
|
194
|
+
{
|
|
195
|
+
const proc = spawn('node', [SERVER_JS, '--version'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
196
|
+
let stdout = '';
|
|
197
|
+
proc.stdout.on('data', (c) => { stdout += c; });
|
|
198
|
+
const exitInfo = await new Promise((r) => proc.on('exit', (code) => r({ code, stdout })));
|
|
199
|
+
check('--version exit 0', exitInfo.code === 0);
|
|
200
|
+
check('--version 输出版本号', /apocdata-mcp-server \d+\.\d+\.\d+/.test(exitInfo.stdout));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ============= 测试 4:--help =============
|
|
204
|
+
{
|
|
205
|
+
const proc = spawn('node', [SERVER_JS, '--help'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
206
|
+
let stdout = '';
|
|
207
|
+
proc.stdout.on('data', (c) => { stdout += c; });
|
|
208
|
+
const exitInfo = await new Promise((r) => proc.on('exit', (code) => r({ code, stdout })));
|
|
209
|
+
check('--help exit 0', exitInfo.code === 0);
|
|
210
|
+
check('--help 输出 Usage 段', exitInfo.stdout.includes('Usage:'));
|
|
211
|
+
check('--help 列出 APOCDATA_BASE_URL', exitInfo.stdout.includes('APOCDATA_BASE_URL'));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ============= 测试 5:SIGTERM 空闲时立即退出 =============
|
|
215
|
+
{
|
|
216
|
+
const harness = new McpHarness({});
|
|
217
|
+
await harness.init();
|
|
218
|
+
// 验证 tools/list 工作
|
|
219
|
+
const list = await harness.request('tools/list', {});
|
|
220
|
+
const expectedToolCount = Number(process.env.APOCDATA_EXPECTED_TOOL_COUNT ?? 46);
|
|
221
|
+
check('启动后 tools/list 正常', list.result?.tools?.length === expectedToolCount, `expected=${expectedToolCount} actual=${list.result?.tools?.length}`);
|
|
222
|
+
|
|
223
|
+
const sigStart = Date.now();
|
|
224
|
+
harness.proc.kill('SIGTERM');
|
|
225
|
+
const exitInfo = await harness.waitForExit(3000);
|
|
226
|
+
const sigElapsed = Date.now() - sigStart;
|
|
227
|
+
check('SIGTERM 空闲时 exit 0', exitInfo.code === 0, `code=${exitInfo.code} signal=${exitInfo.signal}`);
|
|
228
|
+
check('SIGTERM 空闲时退出快(<1500ms)', sigElapsed < 1500, `elapsed=${sigElapsed}ms`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ============= 测试 6:SIGTERM 在 in-flight 请求时等待完成 =============
|
|
232
|
+
{
|
|
233
|
+
// mock 响应延迟 800ms
|
|
234
|
+
const mock = await startMockServer(() => new Promise((r) => {
|
|
235
|
+
setTimeout(() => r({
|
|
236
|
+
status: 200,
|
|
237
|
+
headers: { 'content-type': 'application/json' },
|
|
238
|
+
body: JSON.stringify({ code: 200, success: true, data: { ok: 1 } }),
|
|
239
|
+
}), 800);
|
|
240
|
+
}));
|
|
241
|
+
|
|
242
|
+
const harness = new McpHarness({
|
|
243
|
+
APOCDATA_BASE_URL: mock.url,
|
|
244
|
+
APOCDATA_MAX_RETRIES: '0',
|
|
245
|
+
});
|
|
246
|
+
await harness.init();
|
|
247
|
+
|
|
248
|
+
// 发个请求但不等
|
|
249
|
+
const inflightPromise = harness.request('tools/call', {
|
|
250
|
+
name: 'stock',
|
|
251
|
+
arguments: { symbol: '000001' },
|
|
252
|
+
}, 5000);
|
|
253
|
+
|
|
254
|
+
// 给 server 一点时间真的发出 fetch
|
|
255
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
256
|
+
|
|
257
|
+
const sigStart = Date.now();
|
|
258
|
+
harness.proc.kill('SIGTERM');
|
|
259
|
+
|
|
260
|
+
// in-flight 请求应该完成
|
|
261
|
+
let inflightResult = null;
|
|
262
|
+
try { inflightResult = await inflightPromise; } catch (e) { inflightResult = { error: e.message }; }
|
|
263
|
+
|
|
264
|
+
const exitInfo = await harness.waitForExit(3000);
|
|
265
|
+
const sigElapsed = Date.now() - sigStart;
|
|
266
|
+
|
|
267
|
+
check('SIGTERM 等待 in-flight 完成(≥500ms)', sigElapsed >= 500, `elapsed=${sigElapsed}ms`);
|
|
268
|
+
check('SIGTERM 在 grace 内退出(<5500ms)', sigElapsed < 5500, `elapsed=${sigElapsed}ms`);
|
|
269
|
+
check('in-flight 请求得到响应', inflightResult?.result?.isError === false,
|
|
270
|
+
`result=${JSON.stringify(inflightResult).slice(0, 80)}`);
|
|
271
|
+
check('SIGTERM 后 exit 0', exitInfo.code === 0, `code=${exitInfo.code}`);
|
|
272
|
+
|
|
273
|
+
await mock.close();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 报告
|
|
277
|
+
const pass = checks.filter(c => c.pass).length;
|
|
278
|
+
console.log('');
|
|
279
|
+
for (const c of checks) {
|
|
280
|
+
console.log(`${c.pass ? '✓' : '✗'} ${c.name}${c.detail ? ` (${c.detail})` : ''}`);
|
|
281
|
+
}
|
|
282
|
+
console.log('');
|
|
283
|
+
console.log(`${pass}/${checks.length} checks pass`);
|
|
284
|
+
process.exit(pass === checks.length ? 0 : 1);
|