dsh-skill-mcp 0.2.0 → 0.4.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 (3) hide show
  1. package/lib/client.js +883 -122
  2. package/lib/index.js +412 -1
  3. package/package.json +2 -2
package/lib/index.js CHANGED
@@ -24,6 +24,15 @@
24
24
  * ~/.dsh/project-mcp-state.json
25
25
  * mcp/reload 强制重读配置并 reconcile
26
26
  *
27
+ * MCP 管理(v0.3 新增):
28
+ * mcp/save { name, scope, transport, … } — 添加/编辑(upsert)到
29
+ * 全局 ~/.dsh/mcp.json 或项目 .dsh/mcp.json(Claude 格式)
30
+ * mcp/get { name } — 取原始条目(user → .mcp.json → .dsh/mcp.json 顺序)
31
+ * mcp/remove { name } — 从所在配置文件删除并立即 reconcile
32
+ * mcp/import/scan 扫描其他 agent(Claude Code / Cursor / Codex / Gemini CLI)
33
+ * 的本机 MCP 配置,返回 { servers, existing }
34
+ * mcp/import/apply { items, scope } — 勾选导入;已存在的逐条跳过
35
+ *
27
36
  * codegraph 场景:command "codegraph"、args ["serve","--mcp"]、cwd=项目根
28
37
  * (靠项目根下的 .codegraph/ 定位索引)。
29
38
  */
@@ -509,6 +518,16 @@ function plainStringMap(raw) {
509
518
  return out
510
519
  }
511
520
 
521
+ /** 扁平字符串记录(headers/env 的 Claude 格式:{ "X-Key": "value" })。 */
522
+ function flatStringRecord(raw) {
523
+ const out = {}
524
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return out
525
+ for (const [k, v] of Object.entries(raw)) {
526
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') out[k] = String(v)
527
+ }
528
+ return out
529
+ }
530
+
512
531
  function numOrDefault(raw, dflt) {
513
532
  return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : dflt
514
533
  }
@@ -524,11 +543,17 @@ function normalizeMcpEntry(rawName, raw, projectRoot) {
524
543
  const type = String(raw.type ?? '').toLowerCase()
525
544
  const url = typeof raw.url === 'string' && raw.url !== '' ? raw.url : null
526
545
  if (type === 'http' || type === 'sse' || type === 'streamable-http' || (!raw.command && url)) {
546
+ // headers 优先按扁平字符串记录读(Claude http 格式);旧嵌套写法兜底。
547
+ const flatHeaders = flatStringRecord(raw.headers)
548
+ const nested = plainStringMap(raw.headers)
549
+ const headers = Object.keys(flatHeaders).length > 0
550
+ ? flatHeaders
551
+ : (nested[Object.keys(nested)[0] ?? ''] ?? {})
527
552
  return {
528
553
  transport: 'streamable-http',
529
554
  serverName,
530
555
  url,
531
- headers: plainStringMap(raw.headers)[Object.keys(plainStringMap(raw.headers))[0] ?? ''] ?? {},
556
+ headers,
532
557
  toolCallTimeoutMs: numOrDefault(raw.toolCallTimeoutMs, 60_000),
533
558
  failOnStartupError: false,
534
559
  }
@@ -595,6 +620,242 @@ async function pathExists(p) {
595
620
  try { await lstat(p); return true } catch { return false }
596
621
  }
597
622
 
623
+ // ---------------------------------------------------------------------------
624
+ // MCP CRUD & import: raw config documents
625
+ // ---------------------------------------------------------------------------
626
+
627
+ /** 配置文档里持有 server 映射的键(mcpServers 优先,兼容 dsh 原生 servers)。 */
628
+ function serverMapOf(doc) {
629
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) return null
630
+ if (doc.mcpServers !== null && typeof doc.mcpServers === 'object' && !Array.isArray(doc.mcpServers)) return doc.mcpServers
631
+ if (doc.servers !== null && typeof doc.servers === 'object' && !Array.isArray(doc.servers)) return doc.servers
632
+ return null
633
+ }
634
+
635
+ /** 读原始 JSON 文档:缺失 → {doc:null};损坏 → {error}。 */
636
+ async function readMcpDoc(filePath) {
637
+ let text
638
+ try {
639
+ text = await readFile(filePath, 'utf8')
640
+ } catch (e) {
641
+ if (e.code === 'ENOENT') return { doc: null }
642
+ return { error: e2str(e) }
643
+ }
644
+ try {
645
+ const doc = JSON.parse(text.replace(/^\uFEFF/, ''))
646
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) return { error: 'top level must be a JSON object' }
647
+ return { doc }
648
+ } catch (e) {
649
+ return { error: `invalid JSON — ${e2str(e)}` }
650
+ }
651
+ }
652
+
653
+ async function writeMcpDoc(filePath, doc) {
654
+ await mkdir(path.dirname(filePath), { recursive: true })
655
+ await atomicWrite(filePath, JSON.stringify(doc, null, 2) + '\n')
656
+ }
657
+
658
+ // ---------------------------------------------------------------------------
659
+ // MCP import: foreign agent config readers (Claude Code / Codex / Cursor / Gemini)
660
+ // ---------------------------------------------------------------------------
661
+
662
+ /**
663
+ * 零依赖 TOML 子集解析器:足够覆盖 ~/.codex/config.toml 的
664
+ * [mcp_servers.<name>] 表(command/args/env/url,多行数组、内联表、注释)。
665
+ */
666
+ function parseTomlSubset(text) {
667
+ const root = {}
668
+ let target = root
669
+ const unquote = (s) => {
670
+ const t = s.trim()
671
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) return t.slice(1, -1)
672
+ return t
673
+ }
674
+ const parseValue = (raw) => {
675
+ const s = raw.trim()
676
+ if (s.startsWith('"') && s.endsWith('"') && s.length >= 2) return s.slice(1, -1).replace(/\\(["\\nt])/g, (_, c) => ({ '"': '"', '\\': '\\', n: '\n', t: '\t' })[c])
677
+ if (s.startsWith("'") && s.endsWith("'") && s.length >= 2) return s.slice(1, -1)
678
+ if (s === 'true') return true
679
+ if (s === 'false') return false
680
+ if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s)
681
+ if (s.startsWith('[') || s.startsWith('{')) {
682
+ // 多行数组/内联表已由调用方配平括号后合并成单行。
683
+ if (s.startsWith('[')) {
684
+ const inner = s.slice(1, -1)
685
+ const items = []
686
+ for (const part of splitTopLevel(inner, ',')) {
687
+ const piece = part.trim()
688
+ if (piece !== '') items.push(parseValue(piece))
689
+ }
690
+ return items
691
+ }
692
+ const table = {}
693
+ for (const part of splitTopLevel(s.slice(1, -1), ',')) {
694
+ const m = part.match(/^([^=]+)=(.*)$/)
695
+ if (m) table[unquote(m[1])] = parseValue(m[2])
696
+ }
697
+ return table
698
+ }
699
+ return s
700
+ }
701
+ /** 按分隔符切分,忽略字符串字面量内与嵌套 [] {} 中的分隔符。 */
702
+ const splitTopLevel = (text, sep) => {
703
+ const parts = []
704
+ let depth = 0
705
+ let quote = null
706
+ let current = ''
707
+ for (let i = 0; i < text.length; i++) {
708
+ const ch = text[i]
709
+ if (quote !== null) {
710
+ if (ch === quote && text[i - 1] !== '\\') quote = null
711
+ current += ch
712
+ continue
713
+ }
714
+ if (ch === '"' || ch === "'") { quote = ch; current += ch; continue }
715
+ if (ch === '[' || ch === '{') depth++
716
+ if (ch === ']' || ch === '}') depth--
717
+ if (ch === sep && depth === 0) { parts.push(current); current = ''; continue }
718
+ current += ch
719
+ }
720
+ if (current.trim() !== '') parts.push(current)
721
+ return parts
722
+ }
723
+ const lines = String(text).split(/\r?\n/)
724
+ for (let i = 0; i < lines.length; i++) {
725
+ const line = lines[i].trim()
726
+ if (line === '' || line.startsWith('#')) continue
727
+ const section = line.match(/^\[\s*([^\]]+?)\s*\]$/)
728
+ if (section) {
729
+ target = root
730
+ for (const part of section[1].split('.')) {
731
+ const key = unquote(part)
732
+ if (target[key] === null || typeof target[key] !== 'object' || Array.isArray(target[key])) target[key] = {}
733
+ target = target[key]
734
+ }
735
+ continue
736
+ }
737
+ const kv = line.match(/^([^=]+?)\s*=\s*(.+)$/)
738
+ if (!kv) continue
739
+ let valueText = kv[2].trim()
740
+ // 多行数组 / 内联表:向后累积直到括号配平。
741
+ const opener = valueText[0]
742
+ if (opener === '[' || opener === '{') {
743
+ const closer = opener === '[' ? ']' : '}'
744
+ let depth = 0
745
+ let quote = null
746
+ for (const ch of valueText) {
747
+ if (quote !== null) { if (ch === quote) quote = null; continue }
748
+ if (ch === '"' || ch === "'") { quote = ch; continue }
749
+ if (ch === opener) depth++
750
+ if (ch === closer) depth--
751
+ }
752
+ let j = i
753
+ while (depth > 0 && j + 1 < lines.length) {
754
+ j++
755
+ const next = lines[j].trim()
756
+ valueText += ' ' + next
757
+ for (const ch of next) {
758
+ if (quote !== null) { if (ch === quote) quote = null; continue }
759
+ if (ch === '"' || ch === "'") { quote = ch; continue }
760
+ if (ch === opener) depth++
761
+ if (ch === closer) depth--
762
+ }
763
+ }
764
+ i = j
765
+ }
766
+ target[unquote(kv[1])] = parseValue(valueText)
767
+ }
768
+ return root
769
+ }
770
+
771
+ /** 一条外部 agent 条目 → 可导入的描述;SSE 等不支持的形状返回 null。 */
772
+ function mapForeignEntry(agent, name, entry) {
773
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return null
774
+ const type = typeof entry.type === 'string' ? entry.type.toLowerCase() : ''
775
+ if (type === 'sse') return null
776
+ const url = typeof entry.url === 'string' ? entry.url
777
+ : (typeof entry.httpUrl === 'string' ? entry.httpUrl : '')
778
+ const http = type === 'http' || type === 'streamable-http' || (!entry.command && url !== '')
779
+ if (http) {
780
+ if (!/^https?:\/\//.test(url)) return null
781
+ const headers = flatStringRecord(entry.headers)
782
+ return {
783
+ agent, name, transport: 'http', url,
784
+ ...(Object.keys(headers).length > 0 ? { headers } : {}),
785
+ }
786
+ }
787
+ if (typeof entry.command !== 'string' || entry.command === '') return null
788
+ const args = Array.isArray(entry.args)
789
+ ? entry.args.filter((a) => typeof a === 'string' || typeof a === 'number').map(String)
790
+ : []
791
+ const env = flatStringRecord(entry.env)
792
+ return {
793
+ agent, name, transport: 'stdio', command: entry.command,
794
+ ...(args.length > 0 ? { args } : {}),
795
+ ...(Object.keys(env).length > 0 ? { env } : {}),
796
+ }
797
+ }
798
+
799
+ /** 逐个读 JSON 配置文件的 mcpServers 映射;缺失/损坏跳过。 */
800
+ async function readForeignJsonMap(filePaths) {
801
+ const merged = {}
802
+ for (const file of filePaths) {
803
+ const read = await readMcpDoc(file)
804
+ if (read.error || read.doc === null) continue
805
+ const map = serverMapOf(read.doc)
806
+ if (map !== null) Object.assign(merged, map)
807
+ }
808
+ return merged
809
+ }
810
+
811
+ /**
812
+ * 扫描本机其他 agent 的 MCP 配置。纯读取已知路径;
813
+ * 缺失或损坏的文件静默跳过。返回 [{agent, name, transport, …}]。
814
+ */
815
+ async function scanForeignMcp() {
816
+ const home = homedir()
817
+ const out = []
818
+ const seen = new Set()
819
+ const collect = (agent, rawMap) => {
820
+ for (const [name, entry] of Object.entries(rawMap)) {
821
+ const key = `${agent}/${name}`
822
+ if (seen.has(key)) continue
823
+ const mapped = mapForeignEntry(agent, name, entry)
824
+ if (mapped !== null) { seen.add(key); out.push(mapped) }
825
+ }
826
+ }
827
+ // Claude Code:settings.json 在前,.claude.json 覆盖同名。
828
+ collect('claude-code', await readForeignJsonMap([path.join(home, '.claude', 'settings.json'), path.join(home, '.claude.json')]))
829
+ // Cursor:~/.cursor/mcp.json(Claude 同形)。
830
+ collect('cursor', await readForeignJsonMap([path.join(home, '.cursor', 'mcp.json')]))
831
+ // Gemini CLI:~/.gemini/settings.json(httpUrl → http)。
832
+ collect('gemini', await readForeignJsonMap([path.join(home, '.gemini', 'settings.json')]))
833
+ // Codex:~/.codex/config.toml 的 [mcp_servers.*]。
834
+ try {
835
+ const text = await readFile(path.join(home, '.codex', 'config.toml'), 'utf8')
836
+ const root = parseTomlSubset(text)
837
+ const table = root.mcp_servers
838
+ collect('codex', table !== null && typeof table === 'object' && !Array.isArray(table) ? table : {})
839
+ } catch { /* missing or unreadable → skip */ }
840
+ return out
841
+ }
842
+
843
+ /** 可导入条目 → 写入 dsh 配置的 Claude 格式原始条目。 */
844
+ function foreignToRawEntry(server) {
845
+ if (server.transport === 'http') {
846
+ return {
847
+ type: 'http',
848
+ url: server.url,
849
+ ...(server.headers && Object.keys(server.headers).length > 0 ? { headers: server.headers } : {}),
850
+ }
851
+ }
852
+ return {
853
+ command: server.command,
854
+ ...(Array.isArray(server.args) && server.args.length > 0 ? { args: server.args } : {}),
855
+ ...(server.env && Object.keys(server.env).length > 0 ? { env: server.env } : {}),
856
+ }
857
+ }
858
+
598
859
  /** 启停状态文件:{ disabled: [name, …] }。损坏时按空处理。 */
599
860
  function mcpStatePath() {
600
861
  return path.join(dshHome(), 'project-mcp-state.json')
@@ -794,6 +1055,146 @@ export function apply(ctx) {
794
1055
  return ok(enabled ? `MCP server '${serverName}' enabled.` : `MCP server '${serverName}' disabled.`)
795
1056
  }
796
1057
 
1058
+ // ---- MCP CRUD(添加/编辑/删除)与导入 ----
1059
+
1060
+ /**
1061
+ * 写入作用域 → 目标文件路径。'user' 全局;'project-dsh' 当前项目 .dsh/mcp.json;
1062
+ * 'project-mcp' 当前项目 .mcp.json(仅编辑该来源条目时用)。
1063
+ * 项目作用域需要已同步的工作区。
1064
+ */
1065
+ function scopeFilePath(scope) {
1066
+ if (scope === 'user') return userMcpPath()
1067
+ if (scope === 'project-dsh') return lastCwd === null ? null : projectDshPath(lastCwd)
1068
+ if (scope === 'project-mcp') return lastCwd === null ? null : projectClaudePath(lastCwd)
1069
+ return null
1070
+ }
1071
+
1072
+ /** 写入后立即重读配置并 reconcile(不依赖 0.5s 的文件监听)。 */
1073
+ async function resync() {
1074
+ if (lastCwd !== null) await sync(lastCwd, true)
1075
+ }
1076
+
1077
+ /** 添加/编辑(upsert)一条 MCP 服务到指定作用域的配置文件。 */
1078
+ async function saveMcpServer(payload) {
1079
+ const scope = String(payload.scope || 'user')
1080
+ const filePath = scopeFilePath(scope)
1081
+ if (filePath === null) return fail(`Scope '${scope}' 需要已同步的工作区 —— 请先打开一个项目。`)
1082
+ const name = sanitizeServerName(payload.name)
1083
+ if (name === '') return fail("Invalid server name: use 1-32 chars of A-Z a-z 0-9 _ -")
1084
+ const wantsHttp = payload.transport === 'http' || payload.transport === 'streamable-http'
1085
+ || payload.transport === 'sse' || payload.transport === 'streamable_http'
1086
+ let entry
1087
+ if (wantsHttp) {
1088
+ const url = typeof payload.url === 'string' ? payload.url.trim() : ''
1089
+ if (!/^https?:\/\//.test(url)) return fail('HTTP 传输需要一个 http(s) URL。')
1090
+ const headers = flatStringRecord(payload.headers)
1091
+ entry = { type: 'http', url, ...(Object.keys(headers).length > 0 ? { headers } : {}) }
1092
+ } else {
1093
+ const command = typeof payload.command === 'string' ? payload.command.trim() : ''
1094
+ if (command === '') return fail('stdio 传输需要 command。')
1095
+ const args = Array.isArray(payload.args) ? payload.args.map(String) : []
1096
+ const env = flatStringRecord(payload.env)
1097
+ entry = {
1098
+ command,
1099
+ ...(args.length > 0 ? { args } : {}),
1100
+ ...(Object.keys(env).length > 0 ? { env } : {}),
1101
+ ...(typeof payload.cwd === 'string' && payload.cwd.trim() !== '' ? { cwd: payload.cwd.trim() } : {}),
1102
+ }
1103
+ }
1104
+ const read = await readMcpDoc(filePath)
1105
+ if (read.error) return fail(`${filePath}: ${read.error}`)
1106
+ const doc = read.doc ?? {}
1107
+ const map = serverMapOf(doc) ?? (doc.mcpServers = {})
1108
+ const replaced = Object.prototype.hasOwnProperty.call(map, name)
1109
+ map[name] = entry
1110
+ await writeMcpDoc(filePath, doc)
1111
+ await resync()
1112
+ return ok(
1113
+ replaced ? `MCP 服务 '${name}' 已更新(${filePath})。` : `MCP 服务 '${name}' 已添加到 ${filePath}。`,
1114
+ { name, scope, path: filePath, replaced },
1115
+ )
1116
+ }
1117
+
1118
+ /** 配置层查找顺序(get/remove 共用)。 */
1119
+ function configLayers() {
1120
+ return [
1121
+ ['user', userMcpPath()],
1122
+ ['project-mcp-json', lastCwd === null ? null : projectClaudePath(lastCwd)],
1123
+ ['project-dsh-json', lastCwd === null ? null : projectDshPath(lastCwd)],
1124
+ ]
1125
+ }
1126
+
1127
+ /** 取一条 MCP 服务的原始配置(编辑表单 / JSON 编辑器填充用)。 */
1128
+ async function getMcpServer(name) {
1129
+ const key = String(name ?? '')
1130
+ for (const [source, filePath] of configLayers()) {
1131
+ if (filePath === null) continue
1132
+ const read = await readMcpDoc(filePath)
1133
+ if (read.error || read.doc === null) continue
1134
+ const map = serverMapOf(read.doc)
1135
+ if (map !== null && Object.prototype.hasOwnProperty.call(map, key)) {
1136
+ return ok(`found in ${filePath}`, { name: key, source, path: filePath, raw: map[key] })
1137
+ }
1138
+ }
1139
+ return fail(`MCP 服务 '${key}' 不在任何配置文件中。`)
1140
+ }
1141
+
1142
+ /** 从所在配置文件删除一条 MCP 服务,写回并立即 reconcile。 */
1143
+ async function removeMcpServer(name) {
1144
+ const key = String(name ?? '')
1145
+ for (const [source, filePath] of configLayers()) {
1146
+ if (filePath === null) continue
1147
+ const read = await readMcpDoc(filePath)
1148
+ if (read.error || read.doc === null) continue
1149
+ const map = serverMapOf(read.doc)
1150
+ if (map === null || !Object.prototype.hasOwnProperty.call(map, key)) continue
1151
+ delete map[key]
1152
+ await writeMcpDoc(filePath, read.doc)
1153
+ await resync()
1154
+ return ok(`MCP 服务 '${key}' 已从 ${filePath} 删除。`, { name: key, source, path: filePath })
1155
+ }
1156
+ return fail(`MCP 服务 '${key}' 不在任何配置文件中。`)
1157
+ }
1158
+
1159
+ /** 把选中的外部 agent 条目写入目标作用域;已存在的逐条跳过。 */
1160
+ async function applyImport(payload) {
1161
+ const scope = String(payload.scope || 'user')
1162
+ const filePath = scopeFilePath(scope)
1163
+ if (filePath === null) return fail(`Scope '${scope}' 需要已同步的工作区 —— 请先打开一个项目。`)
1164
+ const wanted = new Set(
1165
+ (Array.isArray(payload.items) ? payload.items : [])
1166
+ .filter((it) => it !== null && typeof it === 'object')
1167
+ .map((it) => `${String(it.agent)}/${String(it.name)}`),
1168
+ )
1169
+ if (wanted.size === 0) return fail('没有选择要导入的服务。')
1170
+ const scanned = await scanForeignMcp()
1171
+ const read = await readMcpDoc(filePath)
1172
+ if (read.error) return fail(`${filePath}: ${read.error}`)
1173
+ const doc = read.doc ?? {}
1174
+ const map = serverMapOf(doc) ?? (doc.mcpServers = {})
1175
+ const results = []
1176
+ for (const server of scanned) {
1177
+ if (!wanted.has(`${server.agent}/${server.name}`)) continue
1178
+ const name = sanitizeServerName(server.name)
1179
+ if (name === '') {
1180
+ results.push({ name: server.name, ok: false, error: '名称不含合法字符(A-Z a-z 0-9 _ -)' })
1181
+ continue
1182
+ }
1183
+ if (Object.prototype.hasOwnProperty.call(map, name)) {
1184
+ results.push({ name, ok: false, error: '已存在于目标配置' })
1185
+ continue
1186
+ }
1187
+ map[name] = foreignToRawEntry(server)
1188
+ results.push({ name, ok: true })
1189
+ }
1190
+ if (results.some((r) => r.ok)) {
1191
+ await writeMcpDoc(filePath, doc)
1192
+ await resync()
1193
+ }
1194
+ const imported = results.filter((r) => r.ok).length
1195
+ return ok(`导入完成:${imported} 成功,${results.length - imported} 跳过。`, { results, scope, path: filePath })
1196
+ }
1197
+
797
1198
  // ---- connection RPC surface(官方推荐的 Client→Host 私有通道) ----
798
1199
 
799
1200
  const endpoints = {
@@ -806,6 +1207,16 @@ export function apply(ctx) {
806
1207
  'mcp/sync': { run: async (p) => sync(p.cwd, p.force === true) },
807
1208
  'mcp/status': { run: async () => mcpStatus() },
808
1209
  'mcp/setEnabled': { run: async (p) => setMcpEnabled(p.name, p.enabled === true) },
1210
+ 'mcp/save': { run: async (p) => saveMcpServer(p ?? {}) },
1211
+ 'mcp/get': { run: async (p) => getMcpServer(p?.name) },
1212
+ 'mcp/remove': { run: async (p) => removeMcpServer(p?.name) },
1213
+ 'mcp/import/scan': {
1214
+ run: async () => {
1215
+ const servers = await scanForeignMcp()
1216
+ return ok(`${servers.length} importable server(s)`, { servers, existing: [...desired.keys()] })
1217
+ },
1218
+ },
1219
+ 'mcp/import/apply': { run: async (p) => applyImport(p ?? {}) },
809
1220
  'mcp/reload': {
810
1221
  run: async () => {
811
1222
  if (lastCwd === null) return ok('No project synced yet — nothing to reload.')
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-skill-mcp",
3
- "version": "0.2.0",
4
- "description": "Management center: link skill directories into the user skills root (enable/disable/unlink/delete) and auto-load each project's own MCP servers via the loader",
3
+ "version": "0.4.0",
4
+ "description": "Management center: link skill directories into the user skills root (enable/disable/unlink/delete) and manage MCP servers — add/edit/delete, import from other agents (Claude Code/Codex/Cursor/Gemini CLI), auto-load each project's own MCP servers via the loader",
5
5
  "license": "BSD-3-Clause",
6
6
  "homepage": "https://github.com/LvanLa/deepseek-harness-ex/tree/master/plugins/dsh-skill-mcp",
7
7
  "repository": {