mingdao-harness 0.1.54
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/LICENSE +21 -0
- package/README.md +246 -0
- package/assets/tokenizer-data.json.gz +0 -0
- package/docs/ARCHITECTURE.md +108 -0
- package/docs/CONFIG.md +257 -0
- package/docs/DESKTOP-EVALUATION.md +48 -0
- package/docs/PROVIDERS.md +98 -0
- package/docs/QA-REPORT.md +333 -0
- package/install.bat +8 -0
- package/install.ps1 +57 -0
- package/install.sh +154 -0
- package/package.json +61 -0
- package/skills/api-design/SKILL.md +21 -0
- package/skills/code-review/SKILL.md +31 -0
- package/skills/debugging/SKILL.md +21 -0
- package/skills/docker/SKILL.md +27 -0
- package/skills/docx/SKILL.md +28 -0
- package/skills/frontend-design/SKILL.md +31 -0
- package/skills/git-commit/SKILL.md +32 -0
- package/skills/pdf/SKILL.md +30 -0
- package/skills/pptx/SKILL.md +24 -0
- package/skills/refactoring/SKILL.md +24 -0
- package/skills/release-checklist/SKILL.md +20 -0
- package/skills/testing/SKILL.md +30 -0
- package/skills/webapp-testing/SKILL.md +27 -0
- package/skills/xlsx/SKILL.md +28 -0
- package/src/agent.js +472 -0
- package/src/audit.js +68 -0
- package/src/autostart.js +74 -0
- package/src/batch.js +182 -0
- package/src/cachestats.js +215 -0
- package/src/cli.js +1099 -0
- package/src/commands/key.js +75 -0
- package/src/commands/schedule.js +176 -0
- package/src/commands/skill.js +168 -0
- package/src/commands/sync.js +222 -0
- package/src/commands/update.js +157 -0
- package/src/commands/workspace.js +109 -0
- package/src/compact.js +112 -0
- package/src/config.js +134 -0
- package/src/context.js +86 -0
- package/src/cost-guard.js +58 -0
- package/src/credentials.js +69 -0
- package/src/hooks.js +123 -0
- package/src/index.js +42 -0
- package/src/mcp-presets.js +78 -0
- package/src/mcp.js +284 -0
- package/src/memory.js +242 -0
- package/src/model-discovery.js +153 -0
- package/src/models.js +186 -0
- package/src/notify.js +38 -0
- package/src/permissions.js +83 -0
- package/src/pricing.js +156 -0
- package/src/prompts.js +58 -0
- package/src/providers/index.js +135 -0
- package/src/providers/openai-compatible.js +171 -0
- package/src/routing.js +126 -0
- package/src/schedule.js +434 -0
- package/src/session-index.js +122 -0
- package/src/session.js +131 -0
- package/src/skill-lib.js +350 -0
- package/src/skill-registry.js +165 -0
- package/src/skills.js +135 -0
- package/src/sync-server.js +564 -0
- package/src/sync.js +479 -0
- package/src/tasks.js +126 -0
- package/src/titles.js +75 -0
- package/src/tokenizer.js +287 -0
- package/src/tools/bash.js +165 -0
- package/src/tools/fs-tools.js +346 -0
- package/src/tools/index.js +287 -0
- package/src/ui.js +643 -0
- package/src/update.js +222 -0
- package/src/web/attachments.js +49 -0
- package/src/web/index.html +993 -0
- package/src/web/server.js +1173 -0
- package/src/web/web-io.js +107 -0
- package/src/workspace.js +157 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// 凭证管理模块:模型 API Key 的独立存储与解析。
|
|
2
|
+
//
|
|
3
|
+
// 设计原则(借鉴 gh CLI 等成熟工具):
|
|
4
|
+
// · Key 绝不进入项目仓库、绝不写入 config.json(config 可分享、可提交);
|
|
5
|
+
// · 默认存储在 <mingdao-home>/credentials.json,文件权限 600(仅本人可读);
|
|
6
|
+
// · 解析优先级:环境变量 > 本地凭证库 > config.json 显式字段(兼容旧版本);
|
|
7
|
+
// · 提供独立的命令行管理:mingdao key status / set / remove / import。
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { mingdaoHome, ensureHome } from './config.js';
|
|
12
|
+
|
|
13
|
+
export function credentialsPath() {
|
|
14
|
+
return path.join(mingdaoHome(), 'credentials.json');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function loadCredentials() {
|
|
18
|
+
try {
|
|
19
|
+
const data = JSON.parse(fs.readFileSync(credentialsPath(), 'utf8'));
|
|
20
|
+
return data && typeof data === 'object' ? data : {};
|
|
21
|
+
} catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function saveCredentials(creds) {
|
|
27
|
+
ensureHome();
|
|
28
|
+
const p = credentialsPath();
|
|
29
|
+
fs.writeFileSync(p, JSON.stringify(creds, null, 2) + '\n', { mode: 0o600 });
|
|
30
|
+
// mode 只在创建时生效:对已存在但权限过宽的文件强制收权
|
|
31
|
+
try {
|
|
32
|
+
fs.chmodSync(p, 0o600);
|
|
33
|
+
} catch {}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getStoredKey(providerName) {
|
|
37
|
+
return loadCredentials()[providerName] || null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function setStoredKey(providerName, key) {
|
|
41
|
+
const creds = loadCredentials();
|
|
42
|
+
if (key) creds[providerName] = String(key);
|
|
43
|
+
else delete creds[providerName];
|
|
44
|
+
saveCredentials(creds);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function removeStoredKey(providerName) {
|
|
48
|
+
setStoredKey(providerName, null);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 脱敏展示:只显示首 6 位与末 4 位,永不输出完整 Key。
|
|
52
|
+
export function maskKey(key) {
|
|
53
|
+
if (!key) return null;
|
|
54
|
+
const s = String(key);
|
|
55
|
+
if (s.length <= 10) return '******';
|
|
56
|
+
return s.slice(0, 6) + '…' + s.slice(-4);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 统一的 Key 解析链:环境变量 → 本地凭证库 → config.json 显式字段
|
|
60
|
+
export function resolveApiKey(cfg, providerName, envKeyHint) {
|
|
61
|
+
const envKeys = [envKeyHint, 'MINGDAO_API_KEY'].filter((k, i, a) => k && a.indexOf(k) === i);
|
|
62
|
+
for (const k of envKeys) {
|
|
63
|
+
const v = process.env[k];
|
|
64
|
+
if (v) return v;
|
|
65
|
+
}
|
|
66
|
+
const stored = getStoredKey(providerName);
|
|
67
|
+
if (stored) return stored;
|
|
68
|
+
return cfg?.apiKey || '';
|
|
69
|
+
}
|
package/src/hooks.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Hooks 生命周期钩子(借鉴 Claude Code PreToolUse/PostToolUse 设计):
|
|
2
|
+
// config.hooks = {
|
|
3
|
+
// "PreToolUse": [ { "matcher": "write|edit|bash", "cmd": "node ~/hooks/pre.js" } ],
|
|
4
|
+
// "PostToolUse": [ { "matcher": "*", "cmd": "curl -X POST http://localhost:9000/audit" } ]
|
|
5
|
+
// }
|
|
6
|
+
// - PreToolUse:向子进程 stdin 写入 JSON {hook_event_name, tool_name, tool_input};
|
|
7
|
+
// 子进程 stdout 输出 JSON {decision:"approve"|"block", reason} 可阻止工具执行。
|
|
8
|
+
// - PostToolUse:写入 {hook_event_name, tool_name, tool_input, tool_response},结果仅记录不阻塞。
|
|
9
|
+
// - matcher 支持精确工具名、逗号分隔多个名、'*' 通配。
|
|
10
|
+
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
function normalize(list) {
|
|
14
|
+
if (!Array.isArray(list)) return [];
|
|
15
|
+
return list.filter((h) => h && typeof h.cmd === 'string' && h.cmd.trim());
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function match(hook, toolName) {
|
|
19
|
+
const m = String(hook.matcher || '*').trim();
|
|
20
|
+
if (!m || m === '*') return true;
|
|
21
|
+
return m.split(',').some((part) => {
|
|
22
|
+
const p = part.trim();
|
|
23
|
+
if (p.endsWith('*')) return toolName.startsWith(p.slice(0, -1));
|
|
24
|
+
return p === toolName;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createHooks(hooksCfg = {}, workingDir) {
|
|
29
|
+
const pre = normalize(hooksCfg?.PreToolUse);
|
|
30
|
+
const post = normalize(hooksCfg?.PostToolUse);
|
|
31
|
+
|
|
32
|
+
function run(hook, payload) {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const child = spawn(hook.cmd, {
|
|
35
|
+
shell: true,
|
|
36
|
+
cwd: workingDir,
|
|
37
|
+
env: process.env,
|
|
38
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
39
|
+
});
|
|
40
|
+
let out = '';
|
|
41
|
+
let err = '';
|
|
42
|
+
const MAX_HOOK_OUT = 64 * 1024;
|
|
43
|
+
const cap = (acc, d) => {
|
|
44
|
+
const t = acc + d;
|
|
45
|
+
return t.length > MAX_HOOK_OUT ? t.slice(-MAX_HOOK_OUT) : t;
|
|
46
|
+
};
|
|
47
|
+
let settled = false;
|
|
48
|
+
const finish = (result) => {
|
|
49
|
+
if (settled) return;
|
|
50
|
+
settled = true;
|
|
51
|
+
resolve(result);
|
|
52
|
+
};
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
// 审计质量项:超时杀整组(shell:true 的孙进程不成孤儿)
|
|
55
|
+
try {
|
|
56
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
57
|
+
} catch {
|
|
58
|
+
child.kill('SIGKILL');
|
|
59
|
+
}
|
|
60
|
+
finish({ ok: false, error: 'hook 执行超时(10s)' });
|
|
61
|
+
}, 10000);
|
|
62
|
+
child.stdout.on('data', (d) => (out = cap(out, d)));
|
|
63
|
+
child.stderr.on('data', (d) => (err = cap(err, d)));
|
|
64
|
+
child.on('error', (e) => {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
finish({ ok: false, error: `hook 启动失败:${e.message}` });
|
|
67
|
+
});
|
|
68
|
+
child.on('close', (code) => {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
finish({ ok: true, exitCode: code, output: out, stderr: err });
|
|
71
|
+
});
|
|
72
|
+
try {
|
|
73
|
+
child.stdin.write(JSON.stringify(payload));
|
|
74
|
+
child.stdin.end();
|
|
75
|
+
} catch {}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
async pre(toolName, args) {
|
|
81
|
+
let decision = 'approve';
|
|
82
|
+
let reason = '';
|
|
83
|
+
for (const h of pre) {
|
|
84
|
+
if (!match(h, toolName)) continue;
|
|
85
|
+
const r = await run(h, { hook_event_name: 'PreToolUse', tool_name: toolName, tool_input: args });
|
|
86
|
+
if (!r.ok) {
|
|
87
|
+
decision = 'block';
|
|
88
|
+
reason = r.error;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
const out = String(r.output || '').trim();
|
|
92
|
+
if (!out) continue; // 空输出 = 放行
|
|
93
|
+
try {
|
|
94
|
+
const j = JSON.parse(out);
|
|
95
|
+
if (j && j.decision === 'block') {
|
|
96
|
+
decision = 'block';
|
|
97
|
+
reason = j.reason || '被 PreToolUse 钩子阻止';
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// fail-closed:输出不是合法 JSON(日志/报错混入)时按阻止处理,避免策略被静默绕过
|
|
102
|
+
decision = 'block';
|
|
103
|
+
reason = `PreToolUse 钩子输出无法解析(前 80 字):${out.slice(0, 80)}`;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { decision, reason };
|
|
108
|
+
},
|
|
109
|
+
async post(toolName, args, result) {
|
|
110
|
+
for (const h of post) {
|
|
111
|
+
if (!match(h, toolName)) continue;
|
|
112
|
+
try {
|
|
113
|
+
await run(h, {
|
|
114
|
+
hook_event_name: 'PostToolUse',
|
|
115
|
+
tool_name: toolName,
|
|
116
|
+
tool_input: args,
|
|
117
|
+
tool_response: result,
|
|
118
|
+
});
|
|
119
|
+
} catch {}
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// MingDao-Harness 公共 API:供第三方程序/插件以库形式复用核心能力。
|
|
2
|
+
|
|
3
|
+
export { createAgent } from './agent.js';
|
|
4
|
+
export { createProvider, resolveProviderConfig } from './providers/index.js';
|
|
5
|
+
export { toolSchemas, dispatch } from './tools/index.js';
|
|
6
|
+
export { trimMessages, approxTokens, clampText, TOOL_RESULT_LIMIT } from './context.js';
|
|
7
|
+
export { compactConversation, summarizeConversation } from './compact.js';
|
|
8
|
+
export { createPermission } from './permissions.js';
|
|
9
|
+
export { MODELS, PROVIDERS, modelPreset, providerPreset } from './models.js';
|
|
10
|
+
export { createIO, style, C } from './ui.js';
|
|
11
|
+
export {
|
|
12
|
+
mingdaoHome,
|
|
13
|
+
ensureHome,
|
|
14
|
+
loadConfig,
|
|
15
|
+
saveConfig,
|
|
16
|
+
runWizard,
|
|
17
|
+
effectiveApiKey,
|
|
18
|
+
} from './config.js';
|
|
19
|
+
export {
|
|
20
|
+
credentialsPath,
|
|
21
|
+
loadCredentials,
|
|
22
|
+
saveCredentials,
|
|
23
|
+
getStoredKey,
|
|
24
|
+
setStoredKey,
|
|
25
|
+
removeStoredKey,
|
|
26
|
+
maskKey,
|
|
27
|
+
resolveApiKey,
|
|
28
|
+
} from './credentials.js';
|
|
29
|
+
export { estimateCost, estimateCostLabel, isPeakHour, PRICE_DATA_AS_OF } from './pricing.js';
|
|
30
|
+
export { countTokens, heuristicTokens, makeTokenCounter, isTokenizable } from './tokenizer.js';
|
|
31
|
+
export { updateCheck, mingdaoUpdate, mingdaoRollback, findRepoRoot } from './update.js';
|
|
32
|
+
export { writeAudit, listAudit, redactSecrets, auditFile } from './audit.js';
|
|
33
|
+
export { trustSkill, skillDirHash, readSourceMeta } from './skill-lib.js';
|
|
34
|
+
export { tamperedSkillNames } from './skills.js';
|
|
35
|
+
export { McpClient, startMcpServers } from './mcp.js';
|
|
36
|
+
export {
|
|
37
|
+
createSession,
|
|
38
|
+
latestSession,
|
|
39
|
+
listSessions,
|
|
40
|
+
appendMessages,
|
|
41
|
+
loadSession,
|
|
42
|
+
} from './session.js';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// MCP 生态预设包:官方常用 MCP 服务器的一键接入目录。
|
|
2
|
+
// 命令:mingdao mcp preset list / add <名称> [参数]
|
|
3
|
+
// 预设以 npx 运行(无需预装),add 时合并进 config.json 的 mcpServers(重启 WebUI 生效)。
|
|
4
|
+
|
|
5
|
+
export const MCP_PRESETS = {
|
|
6
|
+
filesystem: {
|
|
7
|
+
label: '文件系统(读写指定目录)',
|
|
8
|
+
command: 'npx',
|
|
9
|
+
args: ['-y', '@modelcontextprotocol/server-filesystem', '{dir}'],
|
|
10
|
+
argLabel: '目录(默认当前工作目录)',
|
|
11
|
+
},
|
|
12
|
+
fetch: {
|
|
13
|
+
label: '网页抓取(把网页内容转 Markdown)',
|
|
14
|
+
command: 'npx',
|
|
15
|
+
args: ['-y', 'mcp-server-fetch'],
|
|
16
|
+
},
|
|
17
|
+
everything: {
|
|
18
|
+
label: 'Everything 测试服务器(echo/环境/资源演示)',
|
|
19
|
+
command: 'npx',
|
|
20
|
+
args: ['-y', '@modelcontextprotocol/server-everything'],
|
|
21
|
+
},
|
|
22
|
+
git: {
|
|
23
|
+
label: 'Git 操作(状态/提交/日志/分支)',
|
|
24
|
+
command: 'npx',
|
|
25
|
+
args: ['-y', 'mcp-server-git', '--repository', '{dir}'],
|
|
26
|
+
argLabel: '仓库目录(默认当前工作目录)',
|
|
27
|
+
},
|
|
28
|
+
memory: {
|
|
29
|
+
label: '知识图谱记忆(持久化实体关系记忆)',
|
|
30
|
+
command: 'npx',
|
|
31
|
+
args: ['-y', '@modelcontextprotocol/server-memory'],
|
|
32
|
+
},
|
|
33
|
+
'sequential-thinking': {
|
|
34
|
+
label: '结构化分步思考(复杂推理辅助)',
|
|
35
|
+
command: 'npx',
|
|
36
|
+
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
|
|
37
|
+
},
|
|
38
|
+
playwright: {
|
|
39
|
+
label: '浏览器自动化(页面操作/截图/测试)',
|
|
40
|
+
command: 'npx',
|
|
41
|
+
args: ['-y', '@playwright/mcp@latest'],
|
|
42
|
+
},
|
|
43
|
+
sqlite: {
|
|
44
|
+
label: 'SQLite 数据库查询',
|
|
45
|
+
command: 'npx',
|
|
46
|
+
args: ['-y', 'mcp-server-sqlite', '--db-path', '{dir}'],
|
|
47
|
+
argLabel: '数据库文件路径(必填)',
|
|
48
|
+
},
|
|
49
|
+
time: {
|
|
50
|
+
label: '时间与时区查询',
|
|
51
|
+
command: 'npx',
|
|
52
|
+
args: ['-y', '@modelcontextprotocol/server-time'],
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export function presetList() {
|
|
57
|
+
return Object.entries(MCP_PRESETS).map(([name, p]) => ({
|
|
58
|
+
name,
|
|
59
|
+
label: p.label,
|
|
60
|
+
argLabel: p.argLabel || null,
|
|
61
|
+
args: p.args,
|
|
62
|
+
command: p.command,
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function buildPreset(name, arg, cwd) {
|
|
67
|
+
const p = MCP_PRESETS[name];
|
|
68
|
+
if (!p) return { error: `未知预设 ${name}(mingdao mcp preset list 查看)` };
|
|
69
|
+
if (p.argLabel && !arg) {
|
|
70
|
+
if (p.args.includes('{dir}')) {
|
|
71
|
+
arg = cwd; // 目录类参数默认当前工作目录
|
|
72
|
+
} else {
|
|
73
|
+
return { error: `该预设需要参数:${p.argLabel}` };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const args = p.args.map((a) => (a === '{dir}' ? arg : a));
|
|
77
|
+
return { config: { command: p.command, args } };
|
|
78
|
+
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// MCP (Model Context Protocol) 客户端 —— 零依赖实现 stdio 传输 + JSON-RPC 2.0。
|
|
2
|
+
// 配置(config.json):
|
|
3
|
+
// "mcpServers": {
|
|
4
|
+
// "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] },
|
|
5
|
+
// "fetch": { "command": "npx", "args": ["-y", "mcp-server-fetch"] }
|
|
6
|
+
// }
|
|
7
|
+
// 工具命名:mcp__<服务器>__<工具>,与内置工具合并后交给模型;/mcp 查看状态。
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
|
|
12
|
+
// 客户端版本读 package.json(评估 P3-9:此前硬编码 '0.6.0' 与真实版本脱节)
|
|
13
|
+
const CLIENT_VERSION = (() => {
|
|
14
|
+
try {
|
|
15
|
+
const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
16
|
+
return pkg.version || '0.0.0';
|
|
17
|
+
} catch {
|
|
18
|
+
return '0.0.0';
|
|
19
|
+
}
|
|
20
|
+
})();
|
|
21
|
+
|
|
22
|
+
const REQUEST_TIMEOUT_MS = 60000;
|
|
23
|
+
const HANDSHAKE_TIMEOUT_MS = 20000;
|
|
24
|
+
|
|
25
|
+
let nextId = 1;
|
|
26
|
+
|
|
27
|
+
export class McpClient {
|
|
28
|
+
constructor(name, { command, args = [], env = {} }, workingDir) {
|
|
29
|
+
this.name = name;
|
|
30
|
+
this.command = command;
|
|
31
|
+
this.args = args;
|
|
32
|
+
this.env = env;
|
|
33
|
+
this.workingDir = workingDir;
|
|
34
|
+
this.tools = [];
|
|
35
|
+
this.child = null;
|
|
36
|
+
this.pending = new Map();
|
|
37
|
+
this.buf = '';
|
|
38
|
+
this.ready = false;
|
|
39
|
+
this.error = null;
|
|
40
|
+
this.stderrTail = '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async start() {
|
|
44
|
+
if (this.child) return this;
|
|
45
|
+
this.child = spawn(this.command, this.args, {
|
|
46
|
+
cwd: this.workingDir,
|
|
47
|
+
env: { ...process.env, ...this.env },
|
|
48
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
49
|
+
detached: true, // 自成进程组:stop 时整组清理(npx 孙进程不成孤儿)
|
|
50
|
+
});
|
|
51
|
+
this.child.stdout.on('data', (d) => this._onData(d));
|
|
52
|
+
this.child.stderr.on('data', (d) => {
|
|
53
|
+
this.stderrTail = (this.stderrTail + d.toString()).slice(-2000);
|
|
54
|
+
});
|
|
55
|
+
this.child.on('error', (err) => {
|
|
56
|
+
this.error = `启动失败:${err.message}`;
|
|
57
|
+
this._failAll(this.error);
|
|
58
|
+
});
|
|
59
|
+
this.child.on('close', (code) => {
|
|
60
|
+
if (!this.stopped) {
|
|
61
|
+
this.error = this.error || `进程退出(code ${code})${this.stderrTail ? ':' + this.stderrTail.slice(-300) : ''}`;
|
|
62
|
+
this._failAll(this.error);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
try {
|
|
66
|
+
await this._handshake();
|
|
67
|
+
await this._listTools();
|
|
68
|
+
} catch (err) {
|
|
69
|
+
this.error = String(err?.message || err);
|
|
70
|
+
this.stop(); // 审计 P1-3:握手/列工具失败时清理 detached 子进程树,防孤儿
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
return this;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_onData(d) {
|
|
77
|
+
this.buf += d.toString('utf8');
|
|
78
|
+
if (this.buf.length > 20 * 1024 * 1024) {
|
|
79
|
+
// 恶意/异常服务器灌入超量数据:放弃解析,避免内存膨胀
|
|
80
|
+
this.error = this.error || 'MCP 服务器输出超限';
|
|
81
|
+
this.buf = '';
|
|
82
|
+
this.stop();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
let nl;
|
|
86
|
+
while ((nl = this.buf.indexOf('\n')) >= 0) {
|
|
87
|
+
const line = this.buf.slice(0, nl).trim();
|
|
88
|
+
this.buf = this.buf.slice(nl + 1);
|
|
89
|
+
if (!line) continue;
|
|
90
|
+
let msg;
|
|
91
|
+
try {
|
|
92
|
+
msg = JSON.parse(line);
|
|
93
|
+
} catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (msg.id != null && this.pending.has(msg.id)) {
|
|
97
|
+
const p = this.pending.get(msg.id);
|
|
98
|
+
clearTimeout(p.timer);
|
|
99
|
+
this.pending.delete(msg.id);
|
|
100
|
+
if (msg.error) p.reject(new Error(`MCP [${this.name}] ${msg.error.message || JSON.stringify(msg.error)}`));
|
|
101
|
+
else p.resolve(msg.result);
|
|
102
|
+
}
|
|
103
|
+
// 通知类消息(如 notifications/*)忽略
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
_failAll(reason) {
|
|
108
|
+
for (const [, p] of this.pending) {
|
|
109
|
+
clearTimeout(p.timer);
|
|
110
|
+
p.reject(new Error(reason));
|
|
111
|
+
}
|
|
112
|
+
this.pending.clear();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
request(method, params = {}) {
|
|
116
|
+
if (!this.child || this.child.killed) {
|
|
117
|
+
return Promise.reject(new Error(`MCP 服务器 ${this.name} 未运行`));
|
|
118
|
+
}
|
|
119
|
+
const id = nextId++;
|
|
120
|
+
return new Promise((resolve, reject) => {
|
|
121
|
+
const timer = setTimeout(() => {
|
|
122
|
+
this.pending.delete(id);
|
|
123
|
+
reject(new Error(`MCP [${this.name}] 请求超时:${method}`));
|
|
124
|
+
}, REQUEST_TIMEOUT_MS);
|
|
125
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
126
|
+
try {
|
|
127
|
+
this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
|
|
128
|
+
} catch (err) {
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
this.pending.delete(id);
|
|
131
|
+
reject(new Error(`MCP [${this.name}] 写入失败:${err.message}`));
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
notify(method, params = {}) {
|
|
137
|
+
try {
|
|
138
|
+
this.child?.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
|
|
139
|
+
} catch {}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async _handshake() {
|
|
143
|
+
await new Promise((resolve, reject) => {
|
|
144
|
+
const timer = setTimeout(() => reject(new Error(`MCP [${this.name}] 握手超时`)), HANDSHAKE_TIMEOUT_MS);
|
|
145
|
+
this.request('initialize', {
|
|
146
|
+
protocolVersion: '2025-03-26',
|
|
147
|
+
capabilities: {},
|
|
148
|
+
clientInfo: { name: 'mingdao', version: CLIENT_VERSION },
|
|
149
|
+
})
|
|
150
|
+
.then(() => {
|
|
151
|
+
this.notify('notifications/initialized');
|
|
152
|
+
this.ready = true;
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
resolve();
|
|
155
|
+
})
|
|
156
|
+
.catch((err) => {
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
reject(err);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async _listTools() {
|
|
164
|
+
const res = await this.request('tools/list', {});
|
|
165
|
+
this.tools = Array.isArray(res?.tools) ? res.tools : [];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
prefixedName(toolName) {
|
|
169
|
+
return `mcp__${this.name}__${toolName}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
toolSchemas() {
|
|
173
|
+
return this.tools
|
|
174
|
+
.filter((t) => t && t.name)
|
|
175
|
+
.map((t) => ({
|
|
176
|
+
type: 'function',
|
|
177
|
+
function: {
|
|
178
|
+
name: this.prefixedName(t.name),
|
|
179
|
+
description: `[MCP:${this.name}] ${t.description || t.name}`,
|
|
180
|
+
parameters: t.inputSchema || { type: 'object', properties: {} },
|
|
181
|
+
},
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
isReadonly(toolName) {
|
|
186
|
+
const t = this.tools.find((x) => x.name === toolName);
|
|
187
|
+
return Boolean(t?.annotations?.readOnlyHint);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async callTool(toolName, args) {
|
|
191
|
+
const res = await this.request('tools/call', { name: toolName, arguments: args });
|
|
192
|
+
if (res?.isError) {
|
|
193
|
+
const text = (res.content || []).map((c) => c.text || '').join('\n');
|
|
194
|
+
return { ok: false, error: text || 'MCP 工具返回错误' };
|
|
195
|
+
}
|
|
196
|
+
let parts = (res?.content || []).map((c) => (c.type === 'text' ? c.text : `[${c.type}] ${JSON.stringify(c)}`));
|
|
197
|
+
let text = parts.join('\n') || '(无输出)';
|
|
198
|
+
if (text.length > 100 * 1024) text = text.slice(0, 100 * 1024) + `\n…[MCP 输出过长已截断,原文共 ${text.length} 字符]`;
|
|
199
|
+
return { ok: true, output: text };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
stop() {
|
|
203
|
+
this.stopped = true;
|
|
204
|
+
try {
|
|
205
|
+
// 整组清理:npx 等命令拉起的孙进程不成孤儿
|
|
206
|
+
if (this.child?.pid) process.kill(-this.child.pid, 'SIGKILL');
|
|
207
|
+
} catch {
|
|
208
|
+
try {
|
|
209
|
+
this.child?.kill('SIGKILL');
|
|
210
|
+
} catch {}
|
|
211
|
+
}
|
|
212
|
+
this._failAll('MCP 服务器已停止');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 多服务器管理器:部分服务器启动失败不影响其余
|
|
217
|
+
export async function startMcpServers(mcpCfg, workingDir) {
|
|
218
|
+
const clients = new Map();
|
|
219
|
+
const entries = Object.entries(mcpCfg || {});
|
|
220
|
+
await Promise.all(
|
|
221
|
+
entries.map(async ([name, cfg]) => {
|
|
222
|
+
if (!cfg || typeof cfg.command !== 'string' || !cfg.command.trim()) return;
|
|
223
|
+
const client = new McpClient(name, cfg, workingDir);
|
|
224
|
+
clients.set(name, client);
|
|
225
|
+
try {
|
|
226
|
+
await client.start();
|
|
227
|
+
} catch (err) {
|
|
228
|
+
client.error = String(err?.message || err);
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
);
|
|
232
|
+
// 精确工具名映射:名字含 __ 的服务器/工具也不会被贪婪正则切错
|
|
233
|
+
const managerExact = new Map();
|
|
234
|
+
for (const [serverName, c] of clients) {
|
|
235
|
+
if (c.error) continue;
|
|
236
|
+
for (const t of c.tools || []) {
|
|
237
|
+
if (t?.name) managerExact.set(`mcp__${serverName}__${t.name}`, { server: serverName, tool: t.name });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const manager = {
|
|
241
|
+
clients,
|
|
242
|
+
toolSchemas() {
|
|
243
|
+
const out = [];
|
|
244
|
+
for (const c of clients.values()) {
|
|
245
|
+
if (!c.error) out.push(...c.toolSchemas());
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
},
|
|
249
|
+
lookup(prefixedName) {
|
|
250
|
+
const exact = managerExact.get(prefixedName);
|
|
251
|
+
if (exact) {
|
|
252
|
+
const c = clients.get(exact.server);
|
|
253
|
+
if (!c || c.error) return null;
|
|
254
|
+
return { client: c, toolName: exact.tool };
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
257
|
+
},
|
|
258
|
+
async call(prefixedName, args) {
|
|
259
|
+
const found = this.lookup(prefixedName);
|
|
260
|
+
if (!found) return { ok: false, error: `MCP 工具不可用:${prefixedName}` };
|
|
261
|
+
return found.client.callTool(found.toolName, args);
|
|
262
|
+
},
|
|
263
|
+
isReadonly(prefixedName) {
|
|
264
|
+
const found = this.lookup(prefixedName);
|
|
265
|
+
return found ? found.client.isReadonly(found.toolName) : false;
|
|
266
|
+
},
|
|
267
|
+
status() {
|
|
268
|
+
const rows = [];
|
|
269
|
+
for (const [name, c] of clients) {
|
|
270
|
+
rows.push({
|
|
271
|
+
name,
|
|
272
|
+
ok: !c.error,
|
|
273
|
+
tools: c.tools.length,
|
|
274
|
+
error: c.error || '',
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return rows;
|
|
278
|
+
},
|
|
279
|
+
stop() {
|
|
280
|
+
for (const c of clients.values()) c.stop();
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
return manager;
|
|
284
|
+
}
|