Files

1048 lines
38 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// wind-mcp-skill CLI: thin JSON-envelope wrapper around Wind MCP servers
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, dirname, basename, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { spawn } from 'node:child_process';
// #region 静态版本、7 个 MCP 地址、路径、HTTP 状态码映射。只含常量,不发网络。
const SKILL_VERSION = '2.0.4';
// 本地 registry: 工具选择可在任何网络调用前失败
const SERVERS = {
stock_data: {
endpoint: 'https://mcp.wind.com.cn/vserver_stock_data/mcp/',
label: 'Wind 股票(选股筛选 + 档案/财务/股本/事件/技术/风险 + 行情/K线/分钟)',
},
fund_data: {
endpoint: 'https://mcp.wind.com.cn/vserver_fund_data/mcp/',
label: 'Wind 基金(基金筛选 + 档案/财务/持仓/业绩/持有人/公司 + 行情/K线/分钟)',
},
index_data: {
endpoint: 'https://mcp.wind.com.cn/vserver_index_data/mcp/',
label: 'Wind 指数/板块(档案/基本面/技术 + 行情/K线/分钟)',
},
bond_data: {
endpoint: 'https://mcp.wind.com.cn/vserver_bond_data/mcp/',
label: 'Wind 债券(基本档案/发债主体/行情估值/主体财务)',
},
financial_docs: {
endpoint: 'https://mcp.wind.com.cn/vserver_financial_docs/mcp/',
label: 'Wind 金融文档 RAG公告 / 新闻)',
},
economic_data: {
endpoint: 'https://mcp.wind.com.cn/vserver_economic_data/mcp/',
label: 'Wind EDB 宏观/行业经济指标',
},
analytics_data: {
endpoint: 'https://mcp.wind.com.cn/vserver_analytics_data/mcp/',
label: 'Wind 通用分析数据NL → Wind 数据)',
},
};
const PORTAL_URL = 'https://aifinmarket.wind.com.cn/#/user/overview';
const SKILL_DIR = dirname(dirname(fileURLToPath(
import.meta.url)));
const UPDATE_CHECK_PATH = join(SKILL_DIR, 'scripts', 'update-check.mjs');
const TOOL_MANIFEST_PATH = join(SKILL_DIR, 'scripts', 'tool-manifest.json');
const CALL_RULES_PATH = join(SKILL_DIR, 'scripts', 'call-rules.json');
const SKILL_NAME = basename(SKILL_DIR);
const CALL_EXAMPLES = [
`cli.mjs call stock_data search_stocks '{"question":"筛选沪深市场市值超500亿且连续5日上涨的股票"}'`,
`cli.mjs call stock_data search_stocks '{"question":"筛选港股中市值超1000亿港元的科技股"}'`,
`cli.mjs call fund_data search_funds '{"question":"筛选股票型基金中近一年收益率超20%的产品"}'`,
`cli.mjs call stock_data get_stock_basicinfo '{"question":"600519.SH公司基本档案"}'`,
`cli.mjs call stock_data get_stock_price_indicators '{"windcode":"600519.SH","indexes":"中文简称,最新成交价,涨跌幅"}'`,
`cli.mjs call fund_data get_fund_kline '{"windcode":"588200.SH","begin_date":"2026-04-01","end_date":"2026-04-30"}'`,
`cli.mjs call stock_data get_stock_quote '{"windcode":"AAPL.O","begin":"2026-08-05","end":"2026-08-05","count":-30}'`,
`cli.mjs call index_data get_index_kline '{"windcode":"000300.SH","begin_date":"2026-04-01","end_date":"2026-04-30"}'`,
`cli.mjs call financial_docs get_financial_news '{"query":"美联储利率政策","top_k":3}'`,
`cli.mjs call economic_data search_economic_indicator '{"question":"中国GDP相关指标有哪些"}'`,
`cli.mjs call economic_data query_economic_indicator_data '{"question":"中国GDP","observation":"10"}'`,
`cli.mjs call analytics_data get_financial_data '{"question":"查询中国A股市场过去一年的平均成交量"}'`,
];
const PRICE_INDICATOR_TOOLS = new Set(['get_stock_price_indicators', 'get_fund_price_indicators', 'get_index_price_indicators']);
const QUOTE_TOOLS = new Set(['get_stock_quote', 'get_fund_quote', 'get_index_quote']);
const HTTP_ERROR_MAP = {
401: 'AUTH_ERROR',
429: 'RATE_LIMIT_ERROR',
500: 'NETWORK_ERROR',
502: 'NETWORK_ERROR',
503: 'NETWORK_ERROR',
504: 'NETWORK_ERROR',
};
// #endregion 静态
// #region 自动更新:仅 call 成功后触发今天已成功则跳过detached 跑 update-check.mjs不阻塞取数。
function todayKey() {
return new Date().toISOString().slice(0, 10);
}
function normalizePath(value) {
const normalized = resolve(value).replace(/\\/g, '/');
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
function updateScope() {
const globalRoot = normalizePath(join(homedir(), '.agents', 'skills'));
const skillDir = normalizePath(SKILL_DIR);
return skillDir.startsWith(globalRoot + '/') ? 'global' : 'project';
}
function updateStateFile() {
return join(SKILL_DIR, 'scripts', 'update-state.json');
}
function readUpdateState() {
try {
const stateFile = updateStateFile();
if (!existsSync(stateFile)) return null;
return JSON.parse(readFileSync(stateFile, 'utf8'));
} catch {
return null;
}
}
function writeUpdateStatePatch(patch) {
const stateFile = updateStateFile();
mkdirSync(dirname(stateFile), { recursive: true });
const state = { ...(readUpdateState() || {}), ...patch };
writeFileSync(stateFile, JSON.stringify(state, null, 2) + '\n');
}
function alreadyUpdatedToday() {
try {
const state = readUpdateState();
return state && state.date === todayKey() && state.status === 'success';
} catch {
return false;
}
}
function markSkillUsed() {
writeUpdateStatePatch({
lastUsedAt: new Date().toISOString(),
lastUsedPid: process.pid,
});
}
function triggerUpdateCheck() {
try {
if (!existsSync(UPDATE_CHECK_PATH)) return;
if (alreadyUpdatedToday()) return;
markSkillUsed();
const tmpDir = join(homedir(), '.cache', 'wind-aifinmarket');
mkdirSync(tmpDir, { recursive: true });
const runnerPath = join(tmpDir, `update-check-${SKILL_NAME}-${process.pid}.mjs`);
copyFileSync(UPDATE_CHECK_PATH, runnerPath);
const child = spawn('node', [runnerPath, SKILL_DIR], { detached: true, stdio: 'ignore', windowsHide: true });
child.on('error', () => { /* ignore spawn failures; update must not block CLI */ });
child.unref();
} catch { }
}
// #endregion 自动更新
// #region 信封:成功写 MCP result + cli_meta失败统一写 {ok:false,code,message}。Agent 只读 stdout。
function normalizeSuccessPayload(value, path = '$', state = { warnings: [], tables: [], invalidPaths: [] }, dataCell = false) {
if (dataCell && value === 'INVALID') {
state.invalidPaths.push(path);
return null;
}
if (Array.isArray(value)) {
return value.map((item, index) => normalizeSuccessPayload(item, `${path}[${index}]`, state, dataCell));
}
if (!value || typeof value !== 'object') return value;
const normalized = {};
for (const [key, item] of Object.entries(value)) {
const isStructuredDataArray = Array.isArray(item) && (key === 'rows' || key === 'value');
normalized[key] = normalizeSuccessPayload(item, `${path}.${key}`, state, dataCell || isStructuredDataArray);
}
if (Array.isArray(value.rows)) {
state.tables.push({ path, actual_row_count: value.rows.length });
}
if (Object.hasOwn(value, 'excelTotalCount')) {
state.warnings.push({
code: 'UNRELIABLE_DECLARED_COUNT',
path: `${path}.excelTotalCount`,
message: 'excelTotalCount 仅保留为后端原始字段,不得据此判断结果总数或完整性。',
});
}
return normalized;
}
// 保留 MCP result 外层兼容性;只清洗可解析的 JSON 文本并附加机器可读安全元数据。
function normalizeCallSuccess(result, context = {}) {
const output = result && typeof result === 'object' ? structuredClone(result) : result;
const state = { warnings: [], tables: [], invalidPaths: [] };
if (output && Array.isArray(output.content)) {
for (const item of output.content) {
if (item?.type !== 'text' || typeof item.text !== 'string') continue;
try {
const parsed = JSON.parse(item.text);
item.text = JSON.stringify(normalizeSuccessPayload(parsed, '$', state));
} catch {
// 非 JSON 文本按后端原文透传。
}
}
}
if (state.invalidPaths.length) {
state.warnings.push({
code: 'BACKEND_INVALID_AS_NULL',
count: state.invalidPaths.length,
paths: state.invalidPaths.slice(0, 100),
truncated: state.invalidPaths.length > 100,
message: '结构化数据区中的后端字符串 INVALID 已转换为 null表示缺失或不适用禁止按 0 参与计算。',
});
}
if (output && typeof output === 'object') {
output.cli_meta = {
schema_version: '1.0',
server_type: context.server_type || null,
tool_name: context.tool_name || null,
completeness: state.warnings.some(warning => warning.code === 'UNRELIABLE_DECLARED_COUNT') ? 'unknown' : 'not_asserted',
tables: state.tables,
warnings: state.warnings,
};
}
return output;
}
function writeRawCallSuccess(result, context = {}) {
process.stdout.write(JSON.stringify(normalizeCallSuccess(result, context), null, 2) + '\n');
}
function writePlainSuccess(data) {
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
}
const DEFAULT_ERROR_MESSAGES = Object.freeze({
AUTH_ERROR: '认证失败,请检查 API Key',
PARAM_TYPE_ERROR: '参数类型错误,请检查字段类型',
USAGE_ERROR: '命令用法错误,请检查输入参数',
PARAMS_FILE_ERROR: '参数文件读取失败,请检查文件路径和内容',
INVALID_PARAMS_JSON: '参数格式错误params 必须是 JSON 对象',
ROUTE_ERROR: '工具路由失败,请检查 server_type 和 tool_name',
PARAM_VALIDATION_ERROR: '参数校验失败,请检查字段名和取值',
PARAM_CONFLICT_ERROR: '参数存在冲突,请检查输入组合',
RATE_LIMIT_ERROR: '请求过于频繁,请稍后重试',
NETWORK_ERROR: '服务暂时不可用,请稍后重试',
TOOL_RUNTIME_ERROR: '响应解析失败,请稍后重试',
SETUP_ERROR: '本地配置缺失或无效,请检查技能配置',
UNKNOWN: '调用失败,请稍后重试',
});
const MAPPED_ERROR_MESSAGE_CODES = new Set(
Object.keys(DEFAULT_ERROR_MESSAGES).filter((code) => code !== 'UNKNOWN'),
);
function normalizeErrorMessage(code, detail, metadata = {}) {
if (!MAPPED_ERROR_MESSAGE_CODES.has(code)) {
if (typeof metadata.error_message === 'string' && metadata.error_message.trim()) {
return metadata.error_message.trim();
}
if (typeof detail === 'string' && detail.trim()) {
return detail.trim().slice(0, 2000);
}
return DEFAULT_ERROR_MESSAGES.UNKNOWN;
}
if (typeof detail === 'string' && detail.trim()) {
return detail.trim().slice(0, 500);
}
return DEFAULT_ERROR_MESSAGES[code] || DEFAULT_ERROR_MESSAGES.UNKNOWN;
}
function writeErrorEnvelope(code, detail, metadata = {}) {
const envelope = {
ok: false,
code,
message: normalizeErrorMessage(code, detail, metadata),
};
process.stdout.write(JSON.stringify(envelope, null, 2) + '\n');
}
function die(code, detail = null, exitCode = 1, metadata = {}) {
writeErrorEnvelope(code, detail, metadata);
process.exit(exitCode);
}
function exitWithUsage(usage, exitCode = 0) {
die('USAGE_ERROR', `USAGE:\n${usage}`, exitCode);
}
// #endregion 信封
// #region 认证Key 顺序为 ~/.wind-aifinmarket/config > skill config.json > 环境变量 WIND_API_KEY。
function maskKey(key) {
if (!key || key.length < 8) return '***';
return key.slice(0, 4) + '***' + key.slice(-4);
}
// dotenv 解析: 兼容注释 / 引号 / export 前缀
function parseDotenv(content) {
const env = {};
for (const rawLine of content.split('\n')) {
let line = rawLine.replace(/^\uFEFF/, '').trim();
if (!line || line.startsWith('#')) continue;
if (line.startsWith('export ')) line = line.slice(7).trim();
const eq = line.indexOf('=');
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
let val = line.slice(eq + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
} else {
const hashIdx = val.indexOf(' #');
if (hashIdx >= 0) val = val.slice(0, hashIdx).trim();
}
env[key] = val;
}
return env;
}
function getApiKey() {
const globalConfig = join(homedir(), '.wind-aifinmarket', 'config');
if (existsSync(globalConfig)) {
try {
const env = parseDotenv(readFileSync(globalConfig, 'utf8'));
const key = env.WIND_API_KEY?.trim();
if (key) return key;
} catch { }
}
const localConfig = join(SKILL_DIR, 'config.json');
if (existsSync(localConfig)) {
try {
const cfg = JSON.parse(readFileSync(localConfig, 'utf8'));
const key = typeof cfg.wind_api_key === 'string' ? cfg.wind_api_key.trim() : '';
if (key) return key;
} catch { }
}
const envKey = process.env.WIND_API_KEY?.trim();
if (envKey) return envKey;
die('AUTH_ERROR', 'WIND_API_KEY 未配置CLI 已完整检查:用户全局配置 > Skill 本地配置 > 环境变量)');
}
// #endregion 认证
// #region 路由:校验 server_type、tool_name 是否在 SERVERS 与 tool-manifest.json。非法则 ROUTE_ERROR。
function getServer(server_type) {
const server = SERVERS[server_type];
if (!server) {
die('ROUTE_ERROR', `未知 server_type: ${server_type}. 可用: ${Object.keys(SERVERS).join(' / ')}`);
}
return server;
}
function loadToolManifest() {
try {
// tool-manifest.json is the authority for legal server_type + tool_name combinations.
const manifest = JSON.parse(readFileSync(TOOL_MANIFEST_PATH, 'utf8'));
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
throw new Error('manifest 顶层必须是对象');
}
for (const [serverType, tools] of Object.entries(manifest)) {
if (!SERVERS[serverType]) {
throw new Error(`manifest 包含未知 server_type: ${serverType}`);
}
if (!Array.isArray(tools) || tools.some(tool => typeof tool !== 'string' || !tool)) {
throw new Error(`manifest 中 ${serverType} 的工具清单必须是非空字符串数组`);
}
}
for (const serverType of Object.keys(SERVERS)) {
if (!Array.isArray(manifest[serverType])) {
throw new Error(`manifest 缺少 server_type: ${serverType}`);
}
}
return manifest;
} catch (err) {
die('UNKNOWN', `工具清单读取失败: ${err.message}`);
}
}
function validateToolSelection(server_type, toolName) {
getServer(server_type);
const manifest = loadToolManifest();
const tools = manifest[server_type];
if (!tools.includes(toolName)) {
die('ROUTE_ERROR', `工具名 "${toolName}" 不属于 server_type "${server_type}"。`);
}
}
// #endregion 路由
// #region 规则加载:读 call-rules.json得到 K 线周期映射、按域改写工具名、参数校验规则。
function readCallRules() {
try {
return JSON.parse(readFileSync(CALL_RULES_PATH, 'utf8'));
} catch (err) {
die('UNKNOWN', `调用规则读取失败: ${err.message}`);
}
}
function prepareNormalizationRules(rules) {
return {
klinePeriodMap: new Map(Object.entries(rules.kline_period_map || {})),
toolByDomain: rules.tool_by_domain || {},
};
}
const CALL_RULES = readCallRules();
const NORMALIZATION_RULES = prepareNormalizationRules(CALL_RULES);
const KLINE_PERIOD_MAP = NORMALIZATION_RULES.klinePeriodMap;
const PUBLIC_KLINE_PERIODS = new Set(KLINE_PERIOD_MAP.keys());
const KLINE_PERIODS = new Set(KLINE_PERIOD_MAP.values());
const TOOL_BY_DOMAIN = NORMALIZATION_RULES.toolByDomain;
const TOOL_VALIDATION_RULES = {
basic: CALL_RULES.basic || {},
toolRules: Array.isArray(CALL_RULES.tool_rules) ? CALL_RULES.tool_rules : [],
};
const KLINE_TOOLS = new Set(TOOL_VALIDATION_RULES.toolRules.find(rule => rule.name === 'kline')?.tools || []);
// #endregion 规则加载
// #region 规范化:整理 windcode/indexes/period。不给中文名称猜交易所后缀。
function normalizeIndexes(indexes) {
if (typeof indexes !== 'string') return indexes;
return indexes.split(',').map((item) => item.trim()).filter(Boolean).join(',');
}
function normalizeWindcode(windcode) {
if (typeof windcode !== 'string') return windcode;
const raw = windcode.trim();
const upper = raw.toUpperCase();
// Keep natural-language names untouched. Wind's backend NER is responsible
// for resolving names/aliases; the CLI must not guess exchange suffixes.
if (/[\u4e00-\u9fff]/.test(raw)) return raw;
if (/^0\d{4}\.HK$/.test(upper)) return upper.slice(1);
if (/^\d{4}\.HK$/.test(upper)) return upper;
if (/^\d{6}\.(SH|SZ|BJ|OF)$/.test(upper)) return upper;
if (/^[A-Z]{1,5}\.(O|N|A|HK|SH|SZ|BJ)$/.test(upper)) return upper;
return raw;
}
function toolFamily(toolName) {
if (PRICE_INDICATOR_TOOLS.has(toolName)) return 'price';
if (KLINE_TOOLS.has(toolName)) return 'kline';
if (QUOTE_TOOLS.has(toolName)) return 'quote';
return null;
}
function normalizeCall(server_type, toolName, args) {
const family = toolFamily(toolName);
if (family) toolName = TOOL_BY_DOMAIN[family]?.[server_type] || toolName;
const normalizedArgs = { ...args };
const normalizationErrors = [];
if (typeof normalizedArgs.indexes === 'string') normalizedArgs.indexes = normalizeIndexes(normalizedArgs.indexes);
if (typeof normalizedArgs.windcode === 'string') normalizedArgs.windcode = normalizeWindcode(normalizedArgs.windcode);
// count 是整型字段:把整数字符串收敛成 number非整数原样留给 patterns 校验拦截。
if (typeof normalizedArgs.count === 'string' && /^-?\d+$/.test(normalizedArgs.count.trim())) {
normalizedArgs.count = Number(normalizedArgs.count.trim());
}
if (KLINE_TOOLS.has(toolName) && normalizedArgs.period === undefined) normalizedArgs.period = '1d';
if (typeof normalizedArgs.period === 'string') {
const key = normalizedArgs.period.trim();
const backendPeriod = KLINE_PERIOD_MAP.get(key);
normalizedArgs.period = backendPeriod || key;
if (!backendPeriod && KLINE_PERIODS.has(key)) {
normalizationErrors.push({
message: `字段 'period' 只能是 ${Array.from(PUBLIC_KLINE_PERIODS).join('/')},日 K 请传 '1d'`,
field: 'period',
issue: 'invalid_enum',
actual: key,
allowed_values: Array.from(PUBLIC_KLINE_PERIODS),
});
}
}
return { server_type, toolName, args: normalizedArgs, normalizationErrors };
}
// #endregion 规范化
// #region 校验:按 call-rules 查必填、枚举、成对/互斥字段、日期顺序。发网络前拦住非法参数。
function validateBasicParams(params) {
const errors = [];
if (!params || typeof params !== 'object' || Array.isArray(params)) {
return [{
code: 'PARAM_TYPE_ERROR',
message: 'params 必须是 JSON object',
field: 'params',
issue: 'invalid_type',
expected_type: 'object',
actual_type: Array.isArray(params) ? 'array' : typeof params,
}];
}
const basic = TOOL_VALIDATION_RULES.basic;
for (const key of basic.string_keys || []) {
if (!(key in params)) continue;
if (typeof params[key] !== 'string') {
errors.push({ message: `字段 '${key}' 必须是字符串`, field: key, issue: 'invalid_type', expected_type: 'string', actual_type: Array.isArray(params[key]) ? 'array' : typeof params[key] });
} else if (params[key].trim().length === 0) {
errors.push({ message: `字段 '${key}' 不能为空或全空白`, field: key, issue: 'empty_value', expected: 'non-empty string' });
}
}
return errors;
}
function hasParamValue(params, key) {
return params[key] !== undefined && params[key] !== null && params[key] !== '';
}
function resolveValidationValues(fieldRule) {
if (Array.isArray(fieldRule.values)) return fieldRule.values.map(String);
if (fieldRule.values_from === 'kline_period_map') return Array.from(KLINE_PERIODS).map(String);
return [];
}
function resolveValidationDisplayValues(fieldRule) {
if (fieldRule.values_from === 'kline_period_map') return Array.from(PUBLIC_KLINE_PERIODS).map(String);
return resolveValidationValues(fieldRule);
}
function renderValidationMessage(template, values) {
return String(template || '').replace('${values}', values.join('/'));
}
function validationErrorMessage(error) {
return typeof error === 'string' ? error : error.message;
}
function validationErrorCode(error) {
return typeof error === 'object' && error?.code ? error.code : null;
}
function validateToolParams(toolName, params) {
const errors = [];
const rules = TOOL_VALIDATION_RULES.toolRules.filter(rule => Array.isArray(rule.tools) && rule.tools.includes(toolName));
for (const rule of rules) {
const ruleLabel = rule.label || rule.name || toolName;
if (Array.isArray(rule.allowed)) {
const allowedKeys = new Set(rule.allowed);
for (const key of Object.keys(params)) {
if (!allowedKeys.has(key)) errors.push({ message: `${ruleLabel} 工具不支持字段 '${key}'`, field: key, issue: 'unknown_field', allowed_fields: [...allowedKeys] });
}
}
for (const key of rule.required || []) {
if (!hasParamValue(params, key)) errors.push({ message: `${ruleLabel} 工具缺少必填字段 '${key}'`, field: key, issue: 'missing_required', required_fields: rule.required || [] });
}
for (const [field, fieldRule] of Object.entries(rule.enum_fields || {})) {
if (!(field in params)) continue;
const values = resolveValidationValues(fieldRule);
if (!values.includes(String(params[field]))) {
const displayValues = resolveValidationDisplayValues(fieldRule);
errors.push({ message: renderValidationMessage(fieldRule.message, displayValues), field, issue: 'invalid_enum', actual: params[field], allowed_values: displayValues });
}
}
for (const fields of rule.paired || []) {
const present = fields.filter(key => hasParamValue(params, key));
if (present.length > 0 && present.length < fields.length) {
errors.push({ message: `字段 '${fields.join("' 和 '")}' 应成对填写`, fields, issue: 'incomplete_pair', expected_fields: fields });
}
}
for (const fields of rule.mutually_exclusive || []) {
const present = fields.filter(key => hasParamValue(params, key));
if (present.length > 1) {
errors.push({ message: `字段 '${fields.join('/')}' 互斥,不应同时填写`, fields, issue: 'mutually_exclusive' });
}
}
for (const [startKey, endKey] of rule.ordered_dates || []) {
if (params[startKey] && params[endKey] && params[startKey] > params[endKey]) {
errors.push({ message: `字段 '${startKey}' 不能晚于 '${endKey}'`, fields: [startKey, endKey], issue: 'invalid_order', expected: `${startKey} <= ${endKey}` });
}
}
for (const [field, patternRule] of Object.entries(rule.patterns || {})) {
if (!(field in params)) continue;
const pattern = new RegExp(patternRule.pattern);
if (!pattern.test(String(params[field]))) {
errors.push({ message: patternRule.message || `字段 '${field}' 格式不合法`, field, issue: 'invalid_format', actual: params[field], expected_pattern: patternRule.pattern });
}
}
for (const conditional of rule.required_one_of_when || []) {
if (!conditional.values?.map(String).includes(String(params[conditional.field]))) continue;
const satisfied = conditional.one_of?.some(group => group.every(key => hasParamValue(params, key)));
if (!satisfied) errors.push({ message: conditional.message || `字段 '${conditional.field}' 当前取值缺少配套参数`, field: conditional.field, issue: 'missing_conditional_fields', one_of: conditional.one_of });
}
for (const requirement of rule.required_one_of || []) {
const satisfied = requirement.one_of?.some(group => group.every(key => hasParamValue(params, key)));
if (!satisfied) errors.push({ message: requirement.message || `${ruleLabel} 工具缺少一组必填字段`, issue: 'missing_one_of', one_of: requirement.one_of });
}
}
return errors;
}
// #endregion 校验
// #region MCP裸 HTTP JSON-RPC + SSE。先 initialize 再 tools/call。本地/网络错误由 CLI 收口,接口错误统一 backend_error。
function parseSSE(text) {
const trimmed = text.trim();
// 后端正常 SSE, 部分错误场景纯 JSON
if (trimmed.startsWith('{')) {
try {
return JSON.parse(trimmed);
} catch { }
}
const lines = text.split(/\r?\n/);
let last = null;
for (const line of lines) {
if (line.startsWith('data: ')) last = line.slice(6);
}
if (last) {
try {
return JSON.parse(last);
} catch (e) {
throw new Error(`SSE data 行 JSON 解析失败:${e.message}。原文前 200 字符:${text.slice(0, 200)}`);
}
}
throw new Error(`响应格式无法识别(既非 SSE 也非纯 JSON。原文前 200 字符:${text.slice(0, 200)}`);
}
async function fetchWithRetry(fetchFn, url, optionsOrFactory, {
attempts = 3,
delaysMs = [300, 1000],
onAttemptError = null,
} = {}) {
let lastError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const options = typeof optionsOrFactory === 'function'
? optionsOrFactory(attempt)
: optionsOrFactory;
return await fetchFn(url, options);
} catch (err) {
lastError = err;
onAttemptError?.(err, attempt, attempts);
const delayMs = delaysMs[Math.min(attempt - 1, delaysMs.length - 1)] || 0;
if (attempt < attempts && delayMs > 0) {
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
}
throw lastError;
}
async function mcpRequest(server_type, method, params, {
timeoutMs = 60_000,
} = {}) {
const server = getServer(server_type);
const apiKey = getApiKey();
const headers = {
Authorization: `Bearer ${apiKey}`,
Accept: 'application/json, text/event-stream',
'Content-Type': 'application/json',
};
const body = JSON.stringify({
jsonrpc: '2.0',
id: Date.now(),
method,
params
});
const dieInterfaceError = (message) => {
die('backend_error', null, 1, {
error_message: String(message ?? '').slice(0, 2000),
});
};
let resp;
try {
resp = await fetchWithRetry(
fetch,
server.endpoint,
() => ({
method: 'POST',
headers,
body,
signal: AbortSignal.timeout(timeoutMs),
}),
{
attempts: 3,
delaysMs: [300, 1000],
onAttemptError: process.env.WIND_DEBUG === '1'
? (err, attempt, total) => {
const causeCode = err?.cause?.code || err?.code || 'UNKNOWN_CAUSE';
process.stderr.write(`[wind-mcp fetch retry ${attempt}/${total}] ${causeCode}: ${err?.message || err}\n`);
}
: null,
},
);
} catch {
die('NETWORK_ERROR');
}
if (!resp.ok) {
await resp.text().catch(() => '');
die(HTTP_ERROR_MAP[resp.status] || 'NETWORK_ERROR');
}
const text = await resp.text();
let payload;
try {
payload = parseSSE(text);
} catch (err) {
die('TOOL_RUNTIME_ERROR', `${err.message} (server=${server_type})`);
}
if (payload.error) {
const msg = typeof payload.error === 'string'
? payload.error
: (payload.error.message || JSON.stringify(payload.error));
dieInterfaceError(msg);
}
if (payload.result?.isError) {
const msg = payload.result.content?.[0]?.text || JSON.stringify(payload.result);
dieInterfaceError(msg);
}
// 部分工具把业务错误包在 content[0].text 的 JSON 字符串里, 必须二次解析
const innerText = payload.result?.content?.[0]?.text;
if (typeof innerText === 'string') {
let inner;
try {
inner = JSON.parse(innerText);
} catch {
inner = null;
}
if (inner) {
if (typeof inner.mcp_tool_error_code === 'number' && inner.mcp_tool_error_code !== 0) {
const msg = inner.mcp_tool_error_msg || JSON.stringify(inner);
dieInterfaceError(msg);
}
if (inner.error && (inner.error.code || inner.error.message)) {
const errorMessage = inner.error.message || JSON.stringify(inner.error);
dieInterfaceError(errorMessage);
}
if (inner?.data && typeof inner.data === 'object') {
const numericCode = typeof inner.data.code === 'number'
? inner.data.code
: (typeof inner.data.code === 'string' && /^\d+$/.test(inner.data.code.trim()) ? Number(inner.data.code) : null);
const isSuccessCode = numericCode === 0
|| (numericCode !== null && numericCode >= 200 && numericCode < 300);
if (numericCode !== null && !isSuccessCode) {
dieInterfaceError(typeof inner.data.message === 'string' ? inner.data.message : JSON.stringify(inner.data));
}
}
}
}
return payload.result;
}
async function mcpInitializeAndCall(server_type, method, params) {
await mcpRequest(server_type, 'initialize', {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: {
name: SKILL_NAME,
version: SKILL_VERSION
},
}, {
timeoutMs: 30_000
});
return mcpRequest(server_type, method, params, {
timeoutMs: 600_000,
});
}
// #endregion MCP
// #region 命令call 取数list-tools 拉 schemasetup-key / open-portal 配 Keydiagnose 看更新状态。
function loadParamsInput(paramsInput) {
if (!paramsInput.startsWith('@')) {
return { jsonText: paramsInput, source: 'inline' };
}
const fileArg = paramsInput.slice(1);
if (!fileArg) {
const error = new Error('@file 缺少文件路径');
error.code = 'PARAMS_FILE_ERROR';
error.file = fileArg;
throw error;
}
const filePath = resolve(process.cwd(), fileArg);
try {
const jsonText = readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
return { jsonText, source: 'file', filePath };
} catch (cause) {
const error = new Error(`无法读取 params 文件:${filePath} (${cause.code || cause.message})`);
error.code = 'PARAMS_FILE_ERROR';
error.file = filePath;
error.cause = cause;
throw error;
}
}
async function cmdCall(server_type, toolName, paramsInput) {
if (!server_type || !toolName || !paramsInput) {
exitWithUsage(
`用法call <server_type> <tool_name> '<params_json>|@params_file'\n` +
`可用 server_type: ${Object.keys(SERVERS).join(' / ')}\n` +
`典型:\n ${CALL_EXAMPLES.join('\n ')}`,
1,
);
}
let paramsSource;
try {
paramsSource = loadParamsInput(paramsInput);
} catch (e) {
die('PARAMS_FILE_ERROR', e.message);
}
let args;
try {
args = JSON.parse(paramsSource.jsonText);
} catch (e) {
const sourceDetail = paramsSource.source === 'file'
? `文件:${paramsSource.filePath}`
: `原文:${paramsSource.jsonText.slice(0, 200)}`;
die('INVALID_PARAMS_JSON', `params JSON 解析失败:${e.message} | ${sourceDetail}`);
}
if (!args || typeof args !== 'object' || Array.isArray(args)) {
die('PARAM_TYPE_ERROR', 'params 必须是 JSON object');
}
let normalizationErrors;
({ server_type, toolName, args, normalizationErrors } = normalizeCall(server_type, toolName, args));
validateToolSelection(server_type, toolName);
const validationErrors = [...normalizationErrors, ...validateBasicParams(args)];
const paramsShapeInvalid = validationErrors.some(error => validationErrorCode(error) === 'PARAM_TYPE_ERROR' && error.field === 'params');
if (!paramsShapeInvalid) validationErrors.push(...validateToolParams(toolName, args));
if (validationErrors.length > 0) {
const explicitCode = validationErrors.map(validationErrorCode).find(Boolean);
const messages = validationErrors.map(validationErrorMessage);
const hasTypeError = validationErrors.some(error => typeof error === 'object' && error?.issue === 'invalid_type');
die(explicitCode || (hasTypeError ? 'PARAM_TYPE_ERROR' : 'PARAM_VALIDATION_ERROR'), messages.join(''));
}
const result = await mcpInitializeAndCall(server_type, 'tools/call', {
name: toolName,
arguments: args,
_meta: { clientVersion: SKILL_VERSION },
});
return {
server_type,
tool: toolName,
result,
};
}
async function cmdListTools(server_type) {
if (!server_type) {
exitWithUsage(
`用法list-tools <server_type>\n` +
`可用 server_type: ${Object.keys(SERVERS).join(' / ')}`,
1,
);
}
getServer(server_type);
const result = await mcpInitializeAndCall(server_type, 'tools/list', {});
return { server_type, ...result };
}
async function cmdSetupKey(...rawArgs) {
const key = rawArgs[0];
if (!key || key.startsWith('--')) {
exitWithUsage(
`用法cli.mjs setup-key <KEY> --scope <global|skill>\n\n` +
`scope: global=全局共享skill=仅当前 skill。调用前先让用户选择。`,
1,
);
}
let scope = null;
for (let i = 1; i < rawArgs.length; i++) {
const a = rawArgs[i];
if (a === '--scope' && rawArgs[i + 1]) {
scope = rawArgs[i + 1];
break;
}
if (a.startsWith('--scope=')) {
scope = a.slice(8);
break;
}
}
if (!scope) {
exitWithUsage(
`setup-key 缺 --scope 参数。\n\n` +
`先让用户选择 global 或 skill再重试cli.mjs setup-key ${maskKey(key)} --scope <global|skill>`,
1,
);
}
if (!['global', 'skill'].includes(scope)) {
die('SETUP_ERROR', `setup-key 未知 scope: ${scope} (可选: global / skill)`);
}
let file;
try {
if (scope === 'global') {
const dir = join(homedir(), '.wind-aifinmarket');
if (!existsSync(dir)) mkdirSync(dir, {
recursive: true
});
file = join(dir, 'config');
let lines = [];
if (existsSync(file)) {
lines = readFileSync(file, 'utf8').split('\n')
.filter(l => l.length > 0 && !/^\s*(export\s+)?WIND_API_KEY\s*=/.test(l));
}
lines.push(`WIND_API_KEY=${key}`);
writeFileSync(file, lines.join('\n') + '\n', {
mode: 0o600
});
} else {
file = join(SKILL_DIR, 'config.json');
writeFileSync(file, JSON.stringify({ wind_api_key: key }, null, 2) + '\n', { mode: 0o600 });
}
} catch (err) {
die('SETUP_ERROR', `配置写入失败 (scope=${scope}, path=${file || 'n/a'}): ${err.message}`);
}
return {
scope,
path: file,
key_masked: maskKey(key),
next: '现在可以重试原 Wind 调用',
};
}
async function cmdOpenPortal() {
const platform = process.platform;
let bin, args;
if (platform === 'darwin') {
bin = 'open';
args = [PORTAL_URL];
} else if (platform === 'win32') {
bin = 'cmd';
args = ['/c', 'start', '', PORTAL_URL];
} else {
bin = 'xdg-open';
args = [PORTAL_URL];
}
let spawnError = null;
try {
const child = spawn(bin, args, {
stdio: 'ignore',
detached: true,
windowsHide: true
});
child.unref();
spawnError = await new Promise((resolve) => {
child.once('error', resolve);
setTimeout(() => resolve(null), 300);
});
} catch (err) {
spawnError = err;
}
const data = {
url: PORTAL_URL,
platform,
spawn_command: `${bin} ${args.join(' ')}`,
flow_note: '未登录时会自动跳转到登录页(/#/login登录完成后回到 overview 页面即可获取 API Key。',
fallback_message: `如果浏览器没有自动弹出,请手动访问:${PORTAL_URL}`,
};
if (spawnError) {
die('SETUP_ERROR', `本地无法启动浏览器: ${spawnError.message} | 用户应手动打开 ${data.url}`);
}
return data;
}
// 诊断: 输出自动更新状态
async function cmdDiagnose() {
let updateState = null;
try {
const stateFile = updateStateFile();
if (existsSync(stateFile)) {
updateState = JSON.parse(readFileSync(stateFile, 'utf8'));
}
} catch {
updateState = { status: 'unreadable' };
}
return {
platform: process.platform,
node_pid: process.pid,
update_scope: updateScope(),
update_state_file: updateStateFile(),
update_state: updateState,
next_update_needed: !alreadyUpdatedToday(),
};
}
// #endregion 命令
// #region 主入口IS_MAIN 避免测试 import 时跑副作用。无参打 USAGE仅 call 成功才触发更新检查。
const IS_MAIN = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (IS_MAIN) runMain();
function runMain() {
const [cmd, ...args] = process.argv.slice(2);
const USAGE =
`wind-mcp-skill\n` +
`访问万得 Wind 金融数据(按数据域分类调用)\n\n` +
`用法:\n` +
` cli.mjs call <server_type> <tool_name> '<params_json>|@params_file'\n` +
` cli.mjs list-tools <server_type> # 获取后端官方工具描述和 inputSchema\n` +
` cli.mjs open-portal # 打开万得开发者中心拿 API Key\n` +
` cli.mjs setup-key <KEY> --scope <global|skill> # 配置 API Key先问用户存放位置\n\n` +
`可用 server_type:\n` +
Object.entries(SERVERS).map(([k, v]) => ` ${k.padEnd(20)}${v.label}`).join('\n') + '\n\n' +
`典型:\n` +
` ${CALL_EXAMPLES.join('\n ')}`;
const commands = {
call: () => cmdCall(args[0], args[1], args[2]),
'list-tools': () => cmdListTools(args[0]),
'open-portal': () => cmdOpenPortal(),
'setup-key': () => cmdSetupKey(...args),
diagnose: () => cmdDiagnose(),
};
if (!cmd) {
// help: 直接输出 USAGE 纯文本
process.stdout.write(USAGE + '\n');
process.exit(0);
}
if (!commands[cmd]) {
die('USAGE_ERROR', `未知命令: ${cmd}\nUSAGE:\n${USAGE}`);
}
commands[cmd]()
.then((data) => {
if (cmd === 'call') {
// call: 透传 result 内容 (parse JSON if applicable, else raw text)
writeRawCallSuccess(data?.result, { server_type: data?.server_type, tool_name: data?.tool });
setTimeout(triggerUpdateCheck, 0);
} else {
// open-portal / setup-key: 直接输出结构化数据 (无 envelope 包裹)
writePlainSuccess(data);
}
})
.catch((err) => {
die('UNKNOWN', `执行失败: ${err.message || err}${err.stack ? ' | stack: ' + err.stack.slice(0, 300) : ''}`);
});
}
// #endregion 主入口