dsh-paoding 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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +95 -0
  3. package/README_en.md +95 -0
  4. package/bin/dsh-paoding.mjs +12 -0
  5. package/cordis.patch.yml +6 -0
  6. package/docs/architecture.md +292 -0
  7. package/docs/architecture_en.md +363 -0
  8. package/docs/configuration.md +551 -0
  9. package/docs/configuration_en.md +560 -0
  10. package/docs/images/1.png +0 -0
  11. package/docs/images/2.png +0 -0
  12. package/docs/images/3.png +0 -0
  13. package/docs/images/4.png +0 -0
  14. package/docs/images/5.png +0 -0
  15. package/docs/images/6.png +0 -0
  16. package/docs/installation.md +364 -0
  17. package/docs/installation_en.md +367 -0
  18. package/docs/orchestration.md +254 -0
  19. package/docs/orchestration_en.md +255 -0
  20. package/install.sh +31 -0
  21. package/package.json +29 -0
  22. package/plugins/paoding-config-ui/api-core.mjs +100 -0
  23. package/plugins/paoding-config-ui/index.mjs +327 -0
  24. package/plugins/paoding-config-ui/lib/base.css +615 -0
  25. package/plugins/paoding-config-ui/lib/client.js +1154 -0
  26. package/plugins/paoding-config-ui/package.json +23 -0
  27. package/plugins/paoding-config-ui/runtime-inventory.mjs +212 -0
  28. package/presets/orchestrator/agent.cordis.yml +323 -0
  29. package/presets/orchestrator/preset.yml +6 -0
  30. package/presets/orchestrator/restrict.mjs +191 -0
  31. package/tools/install.mjs +24 -0
  32. package/tools/lib/alloc.mjs +138 -0
  33. package/tools/lib/cli.mjs +522 -0
  34. package/tools/lib/compose.mjs +647 -0
  35. package/tools/lib/config.mjs +297 -0
  36. package/tools/lib/host.mjs +288 -0
  37. package/tools/lib/skills.mjs +146 -0
  38. package/tools/lib/spans.mjs +188 -0
  39. package/tools/lib/state.mjs +328 -0
  40. package/tools/lib/util.mjs +26 -0
  41. package/tools/lib/wizard.mjs +516 -0
  42. package/tools/lib/yaml.mjs +266 -0
@@ -0,0 +1,297 @@
1
+ /**
2
+ * dsh-paoding.config.yml 键规范化与读写:main_agent_name / roles.<toolName>.name /
3
+ * roles.<toolName>.model / roles.<toolName>.provider(角色专用模型,生成层映射到
4
+ * dsh-tool-subagent 的 agentOptions)/ roles_remove 解析(normalizeRoleName(兼容
5
+ * 别名 normalizeMainAgentName)/ normalizeModelRef / parseRolesRemove / yamlScalar)、
6
+ * 文件加载与规范化(loadConfig / normalizeConfig)、序列化与落盘(serializeConfig /
7
+ * saveConfig)。
8
+ * 依赖 util(ROLES / warn)与 yaml(preprocessPatchText / parseYamlSubset);
9
+ * 被 compose / wizard / state / cli 引用。
10
+ */
11
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
12
+ import path from 'node:path'
13
+ import { ROLES, warn } from './util.mjs'
14
+ import { parseYamlSubset, preprocessPatchText } from './yaml.mjs'
15
+
16
+ // ── config keys: main_agent_name / roles.<toolName>.name / roles_remove ─────
17
+
18
+ /**
19
+ * 校验并规范化一个显示名(配置键 main_agent_name 或 roles.<toolName>.name,
20
+ * label 形参用于报错文案,缺省 'main_agent_name' 时行为与文案和原实现逐字
21
+ * 一致)。null/undefined/非 string/空串 → null(= 维持默认,不改名);否则存
22
+ * trim 后值,trim 后 1-60 字符且原值不得含 \r \n(违规直接 throw —— 配置错了
23
+ * 宁可报错,也不要静默失效)。
24
+ * main_agent_name:persona 身份行与 preset.yml 的 name: 行两处共用同一个值;
25
+ * roles.<toolName>.name:仅作用于该内置角色 persona 走默认时的首行身份句主语。
26
+ */
27
+ export function normalizeRoleName(value, label = 'main_agent_name') {
28
+ if (value === null || value === undefined || typeof value !== 'string') return null
29
+ const trimmed = value.trim()
30
+ if (trimmed === '') return null
31
+ if (/[\r\n]/.test(value)) {
32
+ throw new Error(`${label} 不得包含换行符(\\r / \\n)`)
33
+ }
34
+ if (trimmed.length > 60) {
35
+ throw new Error(`${label} 过长(${trimmed.length} > 60 字符)`)
36
+ }
37
+ return trimmed
38
+ }
39
+
40
+ /** 兼容别名:主 agent 显示名校验(main_agent_name),行为与文案逐字不变。 */
41
+ export function normalizeMainAgentName(value) {
42
+ return normalizeRoleName(value)
43
+ }
44
+
45
+ /**
46
+ * 校验并规范化一个模型/路由引用(配置键 roles.<toolName>.model 或 .provider,
47
+ * label 形参用于报错文案)。行为镜像 normalizeRoleName,仅长度上限不同(120):
48
+ * null/undefined/非 string/trim 后空串 → null(= 缺省,生成层不注入 agentOptions,
49
+ * 子 agent 继承主 agent 当前模型);否则存 trim 后值,trim 后 1-120 字符且原值
50
+ * 不得含 \r \n(违规直接 throw —— 配置错了宁可报错,也不要静默失效)。
51
+ * provider 只有与 model 同时配置才生效,该组合规则由生成层(compose)判定。
52
+ */
53
+ export function normalizeModelRef(value, label = 'model') {
54
+ if (value === null || value === undefined || typeof value !== 'string') return null
55
+ const trimmed = value.trim()
56
+ if (trimmed === '') return null
57
+ if (/[\r\n]/.test(value)) {
58
+ throw new Error(`${label} 不得包含换行符(\\r / \\n)`)
59
+ }
60
+ if (trimmed.length > 120) {
61
+ throw new Error(`${label} 过长(${trimmed.length} > 120 字符)`)
62
+ }
63
+ return trimmed
64
+ }
65
+
66
+ /**
67
+ * 解析配置键 roles_remove:string[],只收 ROLES 内置角色名(search_external /
68
+ * design / implement);含其他名字 warn 并忽略;去重保序。非数组 → []。
69
+ * 删除语义由生成层承担(委派行、主 persona 引用、restrict allow 三处)。
70
+ */
71
+ export function parseRolesRemove(raw) {
72
+ const out = []
73
+ if (!Array.isArray(raw)) return out
74
+ for (const entry of raw) {
75
+ if (typeof entry !== 'string' || entry === '') {
76
+ warn(`roles_remove: 忽略非字符串项(${JSON.stringify(entry)})`)
77
+ continue
78
+ }
79
+ if (!ROLES.includes(entry)) {
80
+ warn(`roles_remove: '${entry}' 不是内置角色(可选: ${ROLES.join(', ')}),已忽略`)
81
+ continue
82
+ }
83
+ if (!out.includes(entry)) out.push(entry)
84
+ }
85
+ return out
86
+ }
87
+
88
+ /** 简单 YAML 标量渲染:含空格/CJK 无需引号;含 ':' / '#'、首尾空白、纯数字等
89
+ * 歧义形态时用单引号包裹并转义内部单引号。 */
90
+ export function yamlScalar(value) {
91
+ const s = String(value)
92
+ const needsQuote =
93
+ s === '' ||
94
+ /^[\s]|[\s]$/.test(s) || // 首/尾空白
95
+ /:(\s|$)/.test(s) || // ':'(键值分隔风险)
96
+ /(^|\s)#/.test(s) || // '#'(行内注释风险)
97
+ /^[-+]?(\d+\.?\d*|\.\d+)$/.test(s) || // 纯数字形态
98
+ /^(true|false|null|~)$/i.test(s) // bool/null 字面量形态
99
+ return needsQuote ? `'${s.replace(/'/g, "''")}'` : s
100
+ }
101
+
102
+ // ── config file (dsh-paoding.config.yml) ────────────────────────────────────
103
+
104
+ /**
105
+ * Load the config file when present. Returns null when the file does not
106
+ * exist; throws when it exists but cannot be parsed. Shape:
107
+ * { profile, roles: { <toolName>: { name?, model?, provider?, persona?, tools[] } },
108
+ * roles_remove[] (内置角色删除), main_agent_extra[], main_agent_remove[],
109
+ * main_agent_skills[], main_agent_skills_inline[],
110
+ * main_agent_persona_extra? (string|null), main_agent_name? (string|null),
111
+ * skills: { <skill>: [<toolName>...] } }
112
+ */
113
+ export function loadConfig(file, yamlMod) {
114
+ let text
115
+ try {
116
+ text = readFileSync(file, 'utf8')
117
+ } catch {
118
+ return null
119
+ }
120
+ const processed = preprocessPatchText(text)
121
+ let data = null
122
+ if (yamlMod) {
123
+ try {
124
+ data = yamlMod.parse(processed)
125
+ } catch (yamlErr) {
126
+ warn(`${file}: yaml parse failed (${yamlErr.message}) — trying the built-in fallback parser`)
127
+ }
128
+ }
129
+ if (data === null) {
130
+ try {
131
+ data = parseYamlSubset(processed)
132
+ } catch (err) {
133
+ throw new Error(`${file}: could not be parsed (${err.message})`)
134
+ }
135
+ }
136
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
137
+ throw new Error(`${file}: expected a mapping at the top level`)
138
+ }
139
+ return normalizeConfig(data)
140
+ }
141
+
142
+ export function normalizeConfig(data) {
143
+ const roles = {}
144
+ const rawRoles = data.roles && typeof data.roles === 'object' && !Array.isArray(data.roles) ? data.roles : {}
145
+ for (const [toolName, raw] of Object.entries(rawRoles)) {
146
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
147
+ const tools = Array.isArray(raw.tools) ? raw.tools.filter((t) => typeof t === 'string' && t !== '') : null
148
+ // 角色条目字段顺序 { name?, model?, provider?, persona?, tools? }:name
149
+ // (roles.<toolName>.name,内置角色可选显示名)与 main_agent_name 同约束,
150
+ // 仅字符串收(trim 后空/违规由 normalizeRoleName 处理);缺省/null → null
151
+ // (= 保持该角色默认身份句)。model / provider(roles.<toolName>.model /
152
+ // .provider,角色专用模型)镜像同款收法,违规由 normalizeModelRef 处理;
153
+ // 缺省/null → null(= 不注入 agentOptions,子 agent 继承主 agent 当前模型)。
154
+ roles[toolName] = {
155
+ name: typeof raw.name === 'string' ? normalizeRoleName(raw.name, `roles.${toolName}.name`) : null,
156
+ model: typeof raw.model === 'string' ? normalizeModelRef(raw.model, `roles.${toolName}.model`) : null,
157
+ provider: typeof raw.provider === 'string' ? normalizeModelRef(raw.provider, `roles.${toolName}.provider`) : null,
158
+ persona: typeof raw.persona === 'string' && raw.persona.trim() !== '' ? raw.persona : null,
159
+ tools,
160
+ }
161
+ }
162
+ const extra = Array.isArray(data.main_agent_extra)
163
+ ? data.main_agent_extra.filter((t) => typeof t === 'string' && t !== '')
164
+ : []
165
+ const remove = Array.isArray(data.main_agent_remove)
166
+ ? data.main_agent_remove.filter((t) => typeof t === 'string' && t !== '')
167
+ : []
168
+ const skills = {}
169
+ if (data.skills && typeof data.skills === 'object' && !Array.isArray(data.skills)) {
170
+ for (const [skill, roleList] of Object.entries(data.skills)) {
171
+ if (Array.isArray(roleList)) skills[skill] = roleList.filter((r) => typeof r === 'string' && r !== '')
172
+ }
173
+ }
174
+ const mainAgentSkills = Array.isArray(data.main_agent_skills)
175
+ ? data.main_agent_skills.filter((s) => typeof s === 'string' && s !== '')
176
+ : []
177
+ const mainAgentSkillsInline = Array.isArray(data.main_agent_skills_inline)
178
+ ? data.main_agent_skills_inline.filter((s) => typeof s === 'string' && s !== '')
179
+ : []
180
+ // 主 persona 尾部追加:string 原样收(含 '' = 明确清空,不追加);非 string/缺省 → null
181
+ // (compose 时回落 DEFAULT_MAIN_AGENT_PERSONA_EXTRA)。
182
+ const mainPersonaExtra = typeof data.main_agent_persona_extra === 'string' ? data.main_agent_persona_extra : null
183
+ // 内置角色删除(roles_remove):只收 ROLES 内置名,其他 warn 忽略、去重。
184
+ const rolesRemove = parseRolesRemove(data.roles_remove)
185
+ // 主 agent 名称(main_agent_name):typeof string 且 trim 后非空才收(存 trim 值);
186
+ // 否则 null(= 维持默认)。长度/换行违规由 normalizeMainAgentName throw。
187
+ const mainAgentName = typeof data.main_agent_name === 'string' ? normalizeMainAgentName(data.main_agent_name) : null
188
+ return {
189
+ profile: typeof data.profile === 'string' && data.profile !== '' ? data.profile : null,
190
+ roles,
191
+ roles_remove: rolesRemove,
192
+ main_agent_extra: extra,
193
+ // 内部标记:键 main_agent_extra 是否在文件里显式存在(含显式空列表)。
194
+ // resolveAssignments 据此区分「显式 [] = 尊重空」与「键缺失 = 老配置,回填
195
+ // 智能默认」——serializeConfig 对显式空数组落盘 `main_agent_extra: []`,
196
+ // 该语义才能在 读写 往返中存活。
197
+ has_main_agent_extra: Array.isArray(data.main_agent_extra),
198
+ main_agent_remove: remove,
199
+ main_agent_skills: mainAgentSkills,
200
+ main_agent_skills_inline: mainAgentSkillsInline,
201
+ main_agent_persona_extra: mainPersonaExtra,
202
+ main_agent_name: mainAgentName,
203
+ skills,
204
+ }
205
+ }
206
+
207
+ /** Serialize the config back to YAML (fixed structure, hand-rolled writer). */
208
+ export function serializeConfig(config) {
209
+ const lines = ['# dsh-paoding(庖丁)安装配置 —— ./install.sh 交互向导生成;可手编;改后重跑 ./install.sh --auto 应用']
210
+ lines.push(`profile: ${config.profile ?? 'web'}`)
211
+ // main_agent_name:仅非 null 写出(null/缺省 = 维持默认名,不改名)。
212
+ if (config.main_agent_name !== null && config.main_agent_name !== undefined) {
213
+ const name = normalizeMainAgentName(config.main_agent_name)
214
+ if (name !== null) lines.push(`main_agent_name: ${yamlScalar(name)}`)
215
+ }
216
+ lines.push('roles:')
217
+ for (const [toolName, role] of Object.entries(config.roles)) {
218
+ lines.push(` ${toolName}:`)
219
+ // roles.<toolName>.name:仅非 null 写出(null/缺省 = 保持该角色默认身份句);
220
+ // 写在该角色 persona / tools 之前,缩进 4。
221
+ if (role.name !== null && role.name !== undefined) {
222
+ const name = normalizeRoleName(role.name, `roles.${toolName}.name`)
223
+ if (name !== null) lines.push(` name: ${yamlScalar(name)}`)
224
+ }
225
+ // roles.<toolName>.model / .provider:仅非 null 写出(null/缺省 = 不注入
226
+ // agentOptions,子 agent 继承主 agent 当前模型);写前过一次 normalizeModelRef
227
+ // (与上方 name 写法一致,防手工构造的 config 绕过规范化),字段序 name →
228
+ // model → provider → persona → tools 固定。
229
+ if (role.model !== null && role.model !== undefined) {
230
+ const model = normalizeModelRef(role.model, `roles.${toolName}.model`)
231
+ if (model !== null) lines.push(` model: ${yamlScalar(model)}`)
232
+ }
233
+ if (role.provider !== null && role.provider !== undefined) {
234
+ const provider = normalizeModelRef(role.provider, `roles.${toolName}.provider`)
235
+ if (provider !== null) lines.push(` provider: ${yamlScalar(provider)}`)
236
+ }
237
+ if (role.persona !== null && role.persona !== undefined) {
238
+ lines.push(' persona: |-')
239
+ for (const line of String(role.persona).split('\n')) lines.push(` ${line}`)
240
+ }
241
+ if (role.tools !== null && role.tools !== undefined) {
242
+ lines.push(' tools:')
243
+ for (const tool of role.tools) lines.push(` - ${tool}`)
244
+ }
245
+ }
246
+ if (config.roles_remove && config.roles_remove.length > 0) {
247
+ lines.push('roles_remove:')
248
+ for (const role of config.roles_remove) lines.push(` - ${role}`)
249
+ }
250
+ // main_agent_extra:「数组存在即写」——length>0 走多行块;显式空数组写
251
+ // `main_agent_extra: []`。显式空必须落盘成键:否则下次加载时键缺失,
252
+ // resolveAssignments 会把空列表当老配置用智能默认回填,用户「明确不要 host
253
+ // 工具」的意愿就丢了。非数组(缺省)仍整键省略。
254
+ if (Array.isArray(config.main_agent_extra)) {
255
+ if (config.main_agent_extra.length > 0) {
256
+ lines.push('main_agent_extra:')
257
+ for (const tool of config.main_agent_extra) lines.push(` - ${tool}`)
258
+ } else {
259
+ lines.push('main_agent_extra: []')
260
+ }
261
+ }
262
+ if (config.main_agent_remove && config.main_agent_remove.length > 0) {
263
+ lines.push('main_agent_remove:')
264
+ for (const tool of config.main_agent_remove) lines.push(` - ${tool}`)
265
+ }
266
+ if (config.main_agent_skills && config.main_agent_skills.length > 0) {
267
+ lines.push('main_agent_skills:')
268
+ for (const skill of config.main_agent_skills) lines.push(` - ${skill}`)
269
+ }
270
+ if (config.main_agent_skills_inline && config.main_agent_skills_inline.length > 0) {
271
+ lines.push('main_agent_skills_inline:')
272
+ for (const skill of config.main_agent_skills_inline) lines.push(` - ${skill}`)
273
+ }
274
+ // main_agent_persona_extra:只在非 null 时写出 —— '' 也写(= 明确清空,不追加任何
275
+ // 内容),区别于缺省(= compose 回落默认常量)。
276
+ if (config.main_agent_persona_extra !== null && config.main_agent_persona_extra !== undefined) {
277
+ const value = String(config.main_agent_persona_extra)
278
+ if (value === '') {
279
+ lines.push("main_agent_persona_extra: ''")
280
+ } else {
281
+ lines.push('main_agent_persona_extra: |-')
282
+ for (const line of value.split('\n')) lines.push(` ${line}`)
283
+ }
284
+ }
285
+ if (Object.keys(config.skills).length > 0) {
286
+ lines.push('skills:')
287
+ for (const [skill, roleList] of Object.entries(config.skills)) {
288
+ lines.push(` ${skill}: [${roleList.join(', ')}]`)
289
+ }
290
+ }
291
+ return lines.join('\n') + '\n'
292
+ }
293
+
294
+ export function saveConfig(file, config) {
295
+ mkdirSync(path.dirname(file), { recursive: true })
296
+ writeFileSync(file, serializeConfig(config), { mode: 0o600 })
297
+ }
@@ -0,0 +1,288 @@
1
+ /**
2
+ * 主机层(host)工具检测与 MCP 工具名解析:KNOWN_MCP_TOOLS / KNOWN_HOST_PLUGINS
3
+ * 静态表、JSON-RPC initialize + tools/list 握手(stdio / streamable-http /
4
+ * postMcp / parseHttpMessage)、host 相关判定与文案(isHostDependent /
5
+ * pluginLabel / removedReason)与错误收尾(sanitizeError / truncate)。
6
+ * 仅依赖 node:child_process(spawn);fetch / AbortSignal 为 Node 全局。
7
+ * 被 state / compose / wizard / cli 引用。
8
+ */
9
+ import { spawn } from 'node:child_process'
10
+
11
+ /**
12
+ * Exact tool names of well-known MCP servers (restrict() does not support
13
+ * globs, so the names must be spelled out). Servers listed here are resolved
14
+ * without a live handshake; unknown servers go through one.
15
+ */
16
+ export const KNOWN_MCP_TOOLS = {
17
+ tavily: [
18
+ 'tavily_search',
19
+ 'tavily_crawl',
20
+ 'tavily_extract',
21
+ 'tavily_map',
22
+ 'tavily_research',
23
+ ],
24
+ codegraph: ['codegraph_explore'],
25
+ }
26
+
27
+ /**
28
+ * Local tool plugins whose tools are added to the host layer. `match` tests
29
+ * the entry name; `tools` lists the exact tool names the plugin registers.
30
+ * Extend this table when adding more plugins.
31
+ */
32
+ export const KNOWN_HOST_PLUGINS = [{ match: /magic-memory/, tools: ['memory_search'] }]
33
+
34
+ export const HANDSHAKE_TIMEOUT_MS = 15000
35
+
36
+ export const INIT_REQUEST = JSON.stringify({
37
+ jsonrpc: '2.0',
38
+ id: 1,
39
+ method: 'initialize',
40
+ params: {
41
+ protocolVersion: '2024-11-05',
42
+ capabilities: {},
43
+ clientInfo: { name: 'dsh-paoding-installer', version: '1.0.0' },
44
+ },
45
+ })
46
+
47
+ export const LIST_REQUEST = JSON.stringify({
48
+ jsonrpc: '2.0',
49
+ id: 2,
50
+ method: 'tools/list',
51
+ params: {},
52
+ })
53
+
54
+ // ── host tool detection ─────────────────────────────────────────────────────
55
+
56
+ export function isMcpEntry(entry) {
57
+ if (!entry || typeof entry !== 'object') return false
58
+ const name = typeof entry.name === 'string' ? entry.name : ''
59
+ const cfg = entry.config && typeof entry.config === 'object' ? entry.config : {}
60
+ const serverName = typeof cfg.serverName === 'string' ? cfg.serverName : ''
61
+ const transport = typeof cfg.transport === 'string' ? cfg.transport : ''
62
+ const hasCommand = typeof cfg.command === 'string' && cfg.command.length > 0
63
+ if (name.endsWith('dsh-mcp-client')) return true
64
+ return serverName !== '' && (['stdio', 'streamable-http', 'sse'].includes(transport) || hasCommand)
65
+ }
66
+
67
+ /** Entry-level enablement (spec rule); plugins additionally check config.enabled. */
68
+ export function isEntryEnabled(entry) {
69
+ return entry?.enabled !== false && !entry?.disabled
70
+ }
71
+
72
+ /** Extract the MCP connection details, or null when the config is unusable. */
73
+ export function extractMcp(entry) {
74
+ const cfg = entry.config && typeof entry.config === 'object' && !Array.isArray(entry.config) ? entry.config : null
75
+ const serverName = cfg && typeof cfg.serverName === 'string' && cfg.serverName !== '' ? cfg.serverName : ''
76
+ if (!cfg || serverName === '') return null
77
+ return {
78
+ serverName,
79
+ transport: typeof cfg.transport === 'string' ? cfg.transport : undefined,
80
+ command: typeof cfg.command === 'string' ? cfg.command : undefined,
81
+ args: Array.isArray(cfg.args) ? cfg.args.filter((a) => typeof a === 'string') : [],
82
+ url: typeof cfg.url === 'string' ? cfg.url : undefined,
83
+ }
84
+ }
85
+
86
+ // ── MCP tool-name resolution ────────────────────────────────────────────────
87
+
88
+ /** Never echo real URLs (e.g. a tavily API key) in warnings/reports. */
89
+ export function sanitizeError(message) {
90
+ return String(message).replace(/https?:\/\/\S+/g, '<url>').slice(0, 300)
91
+ }
92
+
93
+ export function truncate(text, max) {
94
+ const s = String(text)
95
+ return s.length > max ? `${s.slice(0, max)}…` : s
96
+ }
97
+
98
+ /**
99
+ * Resolve the exact tool names of one MCP server. Static table first, then a
100
+ * live JSON-RPC handshake. Never throws: failures return { tools: [], error }.
101
+ */
102
+ export async function resolveMCP(mcp) {
103
+ const known = KNOWN_MCP_TOOLS[mcp.serverName]
104
+ if (known) return { tools: [...known], source: 'static' }
105
+ try {
106
+ if (mcp.transport === 'stdio') {
107
+ if (!mcp.command) throw new Error('stdio transport requires config.command')
108
+ return { tools: await handshakeStdio(mcp), source: 'handshake' }
109
+ }
110
+ if (mcp.transport === 'streamable-http') {
111
+ if (!mcp.url) throw new Error('streamable-http transport requires config.url')
112
+ return { tools: await handshakeHttp(mcp), source: 'handshake' }
113
+ }
114
+ if (mcp.transport === 'sse') {
115
+ throw new Error('sse transport is not supported for live tool discovery')
116
+ }
117
+ throw new Error(`unsupported transport: ${mcp.transport ?? '(none)'}`)
118
+ } catch (err) {
119
+ return { tools: [], source: 'failed', error: sanitizeError(err?.message ?? err) }
120
+ }
121
+ }
122
+
123
+ /** JSON-RPC handshake over stdio: initialize (id 1) then tools/list (id 2). */
124
+ export function handshakeStdio(mcp) {
125
+ return new Promise((resolve, reject) => {
126
+ const child = spawn(mcp.command, Array.isArray(mcp.args) ? mcp.args : [], {
127
+ stdio: ['pipe', 'pipe', 'pipe'],
128
+ })
129
+ let stdoutBuf = ''
130
+ let stderrBuf = ''
131
+ let settled = false
132
+ const timer = setTimeout(
133
+ () => fail(`handshake timed out after ${HANDSHAKE_TIMEOUT_MS / 1000}s`),
134
+ HANDSHAKE_TIMEOUT_MS,
135
+ )
136
+
137
+ function fail(message) {
138
+ if (settled) return
139
+ settled = true
140
+ clearTimeout(timer)
141
+ try {
142
+ child.kill('SIGTERM')
143
+ } catch {
144
+ /* already gone */
145
+ }
146
+ reject(new Error(sanitizeError(message)))
147
+ }
148
+ function finish(tools) {
149
+ if (settled) return
150
+ settled = true
151
+ clearTimeout(timer)
152
+ try {
153
+ child.kill('SIGTERM')
154
+ } catch {
155
+ /* already gone */
156
+ }
157
+ resolve(tools)
158
+ }
159
+ function send(payload) {
160
+ try {
161
+ child.stdin.write(`${payload}\n`)
162
+ } catch {
163
+ /* the exit handler will surface the failure */
164
+ }
165
+ }
166
+
167
+ child.stdout.on('data', (chunk) => {
168
+ stdoutBuf += chunk.toString()
169
+ let newline
170
+ while ((newline = stdoutBuf.indexOf('\n')) !== -1) {
171
+ const line = stdoutBuf.slice(0, newline).trim()
172
+ stdoutBuf = stdoutBuf.slice(newline + 1)
173
+ if (line === '') continue
174
+ let msg
175
+ try {
176
+ msg = JSON.parse(line)
177
+ } catch {
178
+ continue
179
+ }
180
+ if (msg.id === 1) {
181
+ send(LIST_REQUEST)
182
+ } else if (msg.id === 2) {
183
+ if (msg.error) {
184
+ fail(`tools/list error: ${JSON.stringify(msg.error).slice(0, 300)}`)
185
+ return
186
+ }
187
+ const tools = Array.isArray(msg.result?.tools)
188
+ ? msg.result.tools.map((t) => t?.name).filter((n) => typeof n === 'string' && n !== '')
189
+ : []
190
+ finish(tools)
191
+ }
192
+ }
193
+ })
194
+ child.stderr.on('data', (chunk) => {
195
+ stderrBuf = (stderrBuf + chunk.toString()).slice(-2000)
196
+ })
197
+ child.on('error', (err) => fail(`spawn failed: ${err.message}`))
198
+ child.on('exit', (code, signal) => {
199
+ if (settled) return
200
+ const tail = stderrBuf.trim() ? `: ${truncate(stderrBuf.trim(), 200)}` : ''
201
+ fail(`process exited early (code=${code}, signal=${signal ?? 'none'})${tail}`)
202
+ })
203
+
204
+ send(INIT_REQUEST)
205
+ })
206
+ }
207
+
208
+ /** JSON-RPC handshake over streamable-http (plain JSON or SSE `data:` lines). */
209
+ export async function handshakeHttp(mcp) {
210
+ const headers = {
211
+ Accept: 'application/json, text/event-stream',
212
+ 'Content-Type': 'application/json',
213
+ }
214
+ const initRes = await postMcp(mcp.url, INIT_REQUEST, headers)
215
+ const initMsg = parseHttpMessage(await initRes.text())
216
+ if (initMsg.id !== 1) throw new Error(`unexpected initialize response id: ${initMsg?.id ?? 'none'}`)
217
+ const listRes = await postMcp(mcp.url, LIST_REQUEST, headers)
218
+ const listMsg = parseHttpMessage(await listRes.text())
219
+ if (listMsg.id !== 2) throw new Error(`unexpected tools/list response id: ${listMsg?.id ?? 'none'}`)
220
+ if (listMsg.error) throw new Error(`tools/list error: ${JSON.stringify(listMsg.error).slice(0, 300)}`)
221
+ const tools = Array.isArray(listMsg.result?.tools)
222
+ ? listMsg.result.tools.map((t) => t?.name).filter((n) => typeof n === 'string' && n !== '')
223
+ : []
224
+ return tools
225
+ }
226
+
227
+ export async function postMcp(url, body, headers) {
228
+ let res
229
+ try {
230
+ res = await fetch(url, {
231
+ method: 'POST',
232
+ headers,
233
+ body,
234
+ signal: AbortSignal.timeout(HANDSHAKE_TIMEOUT_MS),
235
+ })
236
+ } catch (err) {
237
+ throw new Error(`http request failed: ${sanitizeError(err.message)}`)
238
+ }
239
+ if (!res.ok) throw new Error(`http ${res.status} ${res.statusText ?? ''}`.trim())
240
+ return res
241
+ }
242
+
243
+ /** Parse a streamable-http response: plain JSON object or SSE `data:` events. */
244
+ export function parseHttpMessage(text) {
245
+ const trimmed = String(text).trim()
246
+ if (trimmed.startsWith('{')) return JSON.parse(trimmed)
247
+ for (const line of trimmed.split(/\r?\n/)) {
248
+ const match = line.match(/^data:\s?(.*)$/)
249
+ if (!match) continue
250
+ const payload = match[1].trim()
251
+ if (payload === '') continue
252
+ try {
253
+ const obj = JSON.parse(payload)
254
+ if (obj && typeof obj === 'object' && obj.id !== undefined) return obj
255
+ } catch {
256
+ /* try the next data line */
257
+ }
258
+ }
259
+ throw new Error('no JSON-RPC message found in HTTP response')
260
+ }
261
+
262
+ /**
263
+ * Whether `name` can only be provided by the host patch layer. Only these
264
+ * names are filtered against the detection inventory; everything else is
265
+ * guaranteed by the standard composition and stays untouched.
266
+ */
267
+ export function isHostDependent(name) {
268
+ if (name.startsWith('mcp__')) return true
269
+ return KNOWN_HOST_PLUGINS.some((p) => p.tools.includes(name))
270
+ }
271
+
272
+ export function pluginLabel(plugin) {
273
+ return String(plugin.match).replace(/^\//, '').replace(/\/$/, '')
274
+ }
275
+
276
+ /** Human reason for removing `name` from an allow list. */
277
+ export function removedReason(name, mcpReports) {
278
+ if (name.startsWith('mcp__')) {
279
+ const [, serverName, tool] = name.split('__')
280
+ const report = mcpReports.find((r) => r.serverName === serverName)
281
+ if (!report) return `${serverName} MCP 未启用`
282
+ if (report.source === 'failed') return `${serverName} MCP 握手失败: ${report.error}`
283
+ return `${serverName} MCP 未提供工具 ${tool}`
284
+ }
285
+ const plugin = KNOWN_HOST_PLUGINS.find((p) => p.tools.includes(name))
286
+ if (plugin) return `${pluginLabel(plugin)} 已停用`
287
+ return 'host 工具未启用'
288
+ }