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.
- package/lib/client.js +883 -122
- package/lib/index.js +412 -1
- 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
|
|
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.
|
|
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": {
|