dsh-armory 2.3.0 → 2.3.2
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/README.en.md +21 -15
- package/README.md +24 -14
- package/docs/installation.md +0 -2
- package/lib/client.js +2490 -736
- package/lib/community-meta.js +222 -0
- package/lib/expert-market.js +119 -23
- package/lib/index.js +261 -95
- package/lib/loadout.js +178 -0
- package/lib/mcp-config.js +0 -171
- package/lib/mcp-oauth.js +131 -26
- package/lib/npm-downloads.js +262 -0
- package/lib/suites-registry.js +10 -4
- package/lib/typert.js +13 -10
- package/lib/wb-awesome-market.js +706 -214
- package/lib/wb-install-state.js +10 -4
- package/lib/wb-market.js +164 -41
- package/lib/wb-registry.js +32 -31
- package/lib/whats-new-en.js +27 -0
- package/lib/whats-new.js +33 -0
- package/package.json +2 -2
- package/docs/mcp-market-design.md +0 -617
- package/lib/mcp-catalog-en.js +0 -244
- package/lib/mcp-catalog.js +0 -806
- package/lib/wb-plugin-market.js +0 -291
package/lib/loadout.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// 装备清单(loadout):把「这台机器装了什么」变成一份可分享、可对比的清单。
|
|
2
|
+
//
|
|
3
|
+
// 边界(决定了这里为什么只做投影,不做安装):
|
|
4
|
+
// - 只记录名称 / 类型 / 来源 / 版本这类**身份信息**,绝不写入 env、header、
|
|
5
|
+
// token、authorization 或任何配置值;MCP 最多保留 env/header 的**键名**。
|
|
6
|
+
// - 导入只做解析与差异计算,不写盘、不安装、不调用任何市场写链。
|
|
7
|
+
// - 清单是用户主动导出、主动分享的文件;没有后端、没有账号、没有遥测。
|
|
8
|
+
//
|
|
9
|
+
// 数据层保持纯函数:宿主只负责收集本机条目,校验与 diff 都在这里,可独立测试。
|
|
10
|
+
export const LOADOUT_SCHEMA = "dsh-armory/loadout@1";
|
|
11
|
+
export const LOADOUT_MAX_ITEMS = 200;
|
|
12
|
+
export const LOADOUT_MAX_BYTES = 256 * 1024;
|
|
13
|
+
export const LOADOUT_DEFAULT_NAME = "我的 DSH 装备清单";
|
|
14
|
+
|
|
15
|
+
const KINDS = new Set(["mcp", "skill", "expert", "plugin"]);
|
|
16
|
+
const TRANSPORTS = new Set(["http", "stdio", "local", "?"]);
|
|
17
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]+/g;
|
|
18
|
+
|
|
19
|
+
function text(value, limit) {
|
|
20
|
+
return String(value ?? "").replace(CONTROL_CHARS, " ").trim().slice(0, limit);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function keyOf(item) {
|
|
24
|
+
return `${item.kind}\u0000${item.name}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 单条投影:只放行白名单字段,其余(含任何看起来像密钥的键)一律丢弃。 */
|
|
28
|
+
function normalizeItem(raw) {
|
|
29
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
30
|
+
const kind = text(raw.kind, 20).toLowerCase();
|
|
31
|
+
if (!KINDS.has(kind)) return null;
|
|
32
|
+
const name = text(raw.name, 120);
|
|
33
|
+
if (!name) return null;
|
|
34
|
+
|
|
35
|
+
const item = { kind, name };
|
|
36
|
+
const source = text(raw.source, 200);
|
|
37
|
+
if (source) item.source = source;
|
|
38
|
+
const version = text(raw.version, 100);
|
|
39
|
+
if (version) item.version = version;
|
|
40
|
+
const updatedAt = text(raw.updatedAt, 60);
|
|
41
|
+
if (updatedAt) item.updatedAt = updatedAt;
|
|
42
|
+
|
|
43
|
+
if (kind === "mcp") {
|
|
44
|
+
const transport = text(raw.transport, 12).toLowerCase();
|
|
45
|
+
if (TRANSPORTS.has(transport)) item.transport = transport;
|
|
46
|
+
const endpointHost = text(raw.endpointHost, 200);
|
|
47
|
+
if (endpointHost) item.endpointHost = endpointHost;
|
|
48
|
+
if (Array.isArray(raw.configKeys)) {
|
|
49
|
+
const keys = [];
|
|
50
|
+
for (const value of raw.configKeys) {
|
|
51
|
+
const key = text(value, 80);
|
|
52
|
+
if (!key || keys.includes(key)) continue;
|
|
53
|
+
keys.push(key);
|
|
54
|
+
if (keys.length >= 20) break;
|
|
55
|
+
}
|
|
56
|
+
if (keys.length) item.configKeys = keys;
|
|
57
|
+
}
|
|
58
|
+
if (typeof raw.enabled === "boolean") item.enabled = raw.enabled;
|
|
59
|
+
} else if (kind === "expert") {
|
|
60
|
+
const plugin = text(raw.plugin, 160);
|
|
61
|
+
if (plugin) item.plugin = plugin;
|
|
62
|
+
} else if (kind === "plugin") {
|
|
63
|
+
const key = text(raw.key, 160);
|
|
64
|
+
if (key) item.key = key;
|
|
65
|
+
}
|
|
66
|
+
return item;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function countItems(items) {
|
|
70
|
+
const counts = { total: items.length, mcp: 0, skill: 0, expert: 0, plugin: 0 };
|
|
71
|
+
for (const item of items) counts[item.kind] += 1;
|
|
72
|
+
return counts;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function countStatuses(rows) {
|
|
76
|
+
const counts = { total: rows.length, installed: 0, update: 0, missing: 0 };
|
|
77
|
+
for (const row of rows) counts[row.status] += 1;
|
|
78
|
+
return counts;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 解析并校验一份清单(对象或 JSON 文本)。
|
|
83
|
+
* 非法结构直接抛错;单条非法 / 重复静默丢弃,避免一个坏行毁掉整份清单。
|
|
84
|
+
*/
|
|
85
|
+
export function normalizeLoadout(input) {
|
|
86
|
+
let raw = input;
|
|
87
|
+
if (typeof input === "string") {
|
|
88
|
+
if (input.length > LOADOUT_MAX_BYTES) throw new Error("装备清单文件过大(上限 256KB)");
|
|
89
|
+
try {
|
|
90
|
+
raw = JSON.parse(input);
|
|
91
|
+
} catch {
|
|
92
|
+
throw new Error("不是有效的 JSON");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("装备清单必须是一个对象");
|
|
96
|
+
if (raw.schema !== LOADOUT_SCHEMA) throw new Error("不是 dsh-armory 装备清单(schema 不匹配)");
|
|
97
|
+
if (!Array.isArray(raw.items)) throw new Error("装备清单缺少 items 数组");
|
|
98
|
+
|
|
99
|
+
const items = [];
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
for (const row of raw.items) {
|
|
102
|
+
const item = normalizeItem(row);
|
|
103
|
+
if (!item) continue;
|
|
104
|
+
const key = keyOf(item);
|
|
105
|
+
if (seen.has(key)) continue;
|
|
106
|
+
seen.add(key);
|
|
107
|
+
items.push(item);
|
|
108
|
+
if (items.length >= LOADOUT_MAX_ITEMS) break;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const loadout = {
|
|
112
|
+
schema: LOADOUT_SCHEMA,
|
|
113
|
+
name: text(raw.name, 120) || LOADOUT_DEFAULT_NAME,
|
|
114
|
+
generatedAt: text(raw.generatedAt, 60),
|
|
115
|
+
items,
|
|
116
|
+
counts: countItems(items),
|
|
117
|
+
};
|
|
118
|
+
if (raw.app && typeof raw.app === "object") {
|
|
119
|
+
const name = text(raw.app.name, 80);
|
|
120
|
+
const version = text(raw.app.version, 40);
|
|
121
|
+
if (name) loadout.app = { name, ...(version ? { version } : {}) };
|
|
122
|
+
}
|
|
123
|
+
if (raw.truncated === true) loadout.truncated = true;
|
|
124
|
+
return loadout;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 由本机收集到的条目构建一份可导出的清单。 */
|
|
128
|
+
export function buildLoadout({ name, items, generatedAt, appVersion } = {}) {
|
|
129
|
+
const sourceItems = Array.isArray(items) ? items : [];
|
|
130
|
+
const loadout = normalizeLoadout({
|
|
131
|
+
schema: LOADOUT_SCHEMA,
|
|
132
|
+
name: text(name, 120) || LOADOUT_DEFAULT_NAME,
|
|
133
|
+
generatedAt: text(generatedAt, 60) || new Date().toISOString(),
|
|
134
|
+
app: { name: "dsh-armory", version: text(appVersion, 40) },
|
|
135
|
+
items: sourceItems,
|
|
136
|
+
});
|
|
137
|
+
if (sourceItems.length > LOADOUT_MAX_ITEMS) loadout.truncated = true;
|
|
138
|
+
return loadout;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 与清单做本机差异对比。
|
|
143
|
+
* 只按 kind + name 匹配;双方都有版本且不同才算「可更新」,避免把缺版本的条目误判。
|
|
144
|
+
*/
|
|
145
|
+
export function diffLoadout(remoteInput, localItems) {
|
|
146
|
+
const remote = normalizeLoadout(remoteInput);
|
|
147
|
+
const local = new Map();
|
|
148
|
+
for (const row of Array.isArray(localItems) ? localItems : []) {
|
|
149
|
+
const item = normalizeItem(row);
|
|
150
|
+
if (item && !local.has(keyOf(item))) local.set(keyOf(item), item);
|
|
151
|
+
}
|
|
152
|
+
const rows = remote.items.map((item) => {
|
|
153
|
+
const hit = local.get(keyOf(item));
|
|
154
|
+
const status = !hit ? "missing" : item.version && hit.version && item.version !== hit.version ? "update" : "installed";
|
|
155
|
+
return { ...item, status, localVersion: hit?.version || "" };
|
|
156
|
+
});
|
|
157
|
+
return {
|
|
158
|
+
loadout: {
|
|
159
|
+
schema: remote.schema,
|
|
160
|
+
name: remote.name,
|
|
161
|
+
generatedAt: remote.generatedAt,
|
|
162
|
+
counts: remote.counts,
|
|
163
|
+
},
|
|
164
|
+
rows,
|
|
165
|
+
counts: countStatuses(rows),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 稳定、人类可读的导出文本。 */
|
|
170
|
+
export function stringifyLoadout(input) {
|
|
171
|
+
return JSON.stringify(normalizeLoadout(input), null, 2);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 下载文件名:保留中文,只清理 Windows / URL 不接受的字符。 */
|
|
175
|
+
export function loadoutFileName(name) {
|
|
176
|
+
const base = text(name, 80).replace(/[\\/:*?"<>|\s]+/g, "-").replace(/^-+|-+$/g, "");
|
|
177
|
+
return `${base || "dsh-loadout"}.json`;
|
|
178
|
+
}
|
package/lib/mcp-config.js
CHANGED
|
@@ -1,79 +1,14 @@
|
|
|
1
1
|
import { parseDocument, Scalar, YAMLMap, YAMLSeq, isMap, isSeq } from 'yaml'
|
|
2
2
|
import { isSafeExpression, makeEnvExpression, makeExpression } from './env-expression.js'
|
|
3
|
-
import { MCP_CATALOG, CATEGORY_ORDER, CATEGORY_LABELS, catalogById } from './mcp-catalog.js'
|
|
4
|
-
import { MCP_CATALOG_EN, CATEGORY_LABELS_EN, CATALOG_LABELS_EN } from './mcp-catalog-en.js'
|
|
5
3
|
|
|
6
4
|
export const MCP_PLUGIN_NAME = '@deepseek-ai/dsh-mcp-client'
|
|
7
5
|
const JS_TAG = 'tag:yaml.org,2002:js'
|
|
8
6
|
const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/
|
|
9
7
|
const MAX_TIMER_DELAY_MS = 2147483647
|
|
10
8
|
const MANAGED_CONFIG_FIELDS = ['transport', 'command', 'args', 'env', 'cwd', 'url', 'headers', 'toolCallTimeoutMs', 'failOnStartupError', 'reconnect']
|
|
11
|
-
// 分类取值域与标签都来自生成的目录文件:那里是唯一真相源,顺序由生成器的 CATEGORY_ORDER
|
|
12
|
-
// 决定(生成器会按它重排 MCP_CATALOG,面板的 chips 因此取到的首次出现序就是它)。
|
|
13
|
-
// 再抄一份到这里就会漂移(加了分类忘了改第二处,市场的 chips 会缺一个分组)。
|
|
14
|
-
export const MCP_CATEGORIES = CATEGORY_ORDER
|
|
15
|
-
export const MCP_CATEGORY_LABELS = CATEGORY_LABELS
|
|
16
9
|
const INTERPOLATION = /\$\{(?:env:)?([A-Za-z_][A-Za-z0-9_]*)(?::-(.*?))?\}/g
|
|
17
10
|
const UNSUPPORTED_TEMPLATE = '不支持的变量表达式(只允许 ${VAR}、${VAR:-fallback} 与 ${env:VAR})'
|
|
18
11
|
|
|
19
|
-
// 内置目录 = 市场目录的投影。以前这里是硬编码的 5 条,现在是 `lib/mcp-catalog.js`
|
|
20
|
-
// 的派生视图:目录加一条,内置清单与身份识别表同时跟上,不需要在两处各改一遍。
|
|
21
|
-
//
|
|
22
|
-
// `name`(写进配置的 serverName)取 id,是刻意的:id 一旦发布就不能改,而 serverName
|
|
23
|
-
// 决定工具名 `mcp__<serverName>__<tool>`,让两者同源就不会出现「目录改名导致老配置变孤儿」。
|
|
24
|
-
export const BUILTIN_MCP_SERVERS = MCP_CATALOG.map((entry) => {
|
|
25
|
-
// P1:英文覆盖与生成目录分开维护(lib/mcp-catalog-en.js)。生成器没有随本仓库
|
|
26
|
-
// 分发,直接改 mcp-catalog.js 会在下次生成时被冲掉;覆盖表缺失时逐字段回落中文。
|
|
27
|
-
const en = MCP_CATALOG_EN[entry.id] || {}
|
|
28
|
-
const server = {
|
|
29
|
-
id: entry.id,
|
|
30
|
-
label: entry.label,
|
|
31
|
-
labelEn: CATALOG_LABELS_EN[entry.id] || entry.label,
|
|
32
|
-
summary: entry.summary,
|
|
33
|
-
summaryEn: en.summary || entry.summary,
|
|
34
|
-
access: entry.access,
|
|
35
|
-
accessEn: en.access || entry.access,
|
|
36
|
-
// OPT-24:`auth` 是给面板判断「当前宿主装不装得上」的展示元数据,不是配置字段——
|
|
37
|
-
// `builtinSpec()` 只挑 MANAGED_CONFIG_FIELDS、`makeEntry()` 只挑固定字段表,两者都
|
|
38
|
-
// 不会带它,所以它和 authNote 落不进用户配置文件,mcp-client 收不到不认识的键。
|
|
39
|
-
auth: entry.auth,
|
|
40
|
-
// authNoteEn 与 authNote 同生同灭:`none` 条目没有说明,补空串会让面板分不清
|
|
41
|
-
// 「没有说明」和「说明是空的」。
|
|
42
|
-
...(entry.authNote !== undefined ? { authNoteEn: en.authNote || entry.authNote } : {}),
|
|
43
|
-
name: entry.id,
|
|
44
|
-
transport: entry.transport,
|
|
45
|
-
}
|
|
46
|
-
for (const field of ['url', 'headers', 'command', 'args', 'authNote']) {
|
|
47
|
-
if (entry[field] !== undefined) server[field] = structuredClone(entry[field])
|
|
48
|
-
}
|
|
49
|
-
return server
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* 「这个已配置的 server 是不是目录里的某一条」——按 endpoint / 包名认,不只看名字。
|
|
54
|
-
*
|
|
55
|
-
* 从目录派生而不是手工维护:以前这张表只有 5 条、和上面的硬编码清单两处同步,加一条就得
|
|
56
|
-
* 记得改两个地方。现在 hosts 取自 url、packages 取自目录的 `packages`(stdio 型可从 args
|
|
57
|
-
* 推导),新增条目自动生效。
|
|
58
|
-
*
|
|
59
|
-
* 为什么需要它:用户可能把 Exa 配成了 `my-exa`。安装时靠它识别「已经装过」而跳过,
|
|
60
|
-
* 而不是再插入一条重复的。
|
|
61
|
-
*/
|
|
62
|
-
const BUILTIN_MCP_IDENTITIES = Object.fromEntries(MCP_CATALOG.map((entry) => {
|
|
63
|
-
const hosts = []
|
|
64
|
-
if (typeof entry.url === 'string') {
|
|
65
|
-
try {
|
|
66
|
-
hosts.push(new URL(entry.url).hostname.toLowerCase())
|
|
67
|
-
} catch { /* url 不是合法 URL 时没有可认领的 host,交给 packages 兜底 */ }
|
|
68
|
-
}
|
|
69
|
-
// 包名优先取目录里显式写的(HTTP 型也可能有对应的本地 npx 包,那层知识推不出来),
|
|
70
|
-
// 没有才从 args 推导:只认非开关的参数,`-y`、`--flag` 这类不是包名。
|
|
71
|
-
const packages = entry.packages ?? (entry.args || []).filter((arg) => typeof arg === 'string'
|
|
72
|
-
&& !arg.startsWith('-')
|
|
73
|
-
&& /^[@a-z0-9]/i.test(arg))
|
|
74
|
-
return [entry.id, { hosts, packages }]
|
|
75
|
-
}))
|
|
76
|
-
|
|
77
12
|
function isObject(value) {
|
|
78
13
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
79
14
|
}
|
|
@@ -517,109 +452,3 @@ export function updateManagedMcpPatch(content, specs, { replace = false, removeN
|
|
|
517
452
|
}
|
|
518
453
|
return String(doc)
|
|
519
454
|
}
|
|
520
|
-
|
|
521
|
-
function packageTokenMatches(value, packageName) {
|
|
522
|
-
if (typeof value !== 'string') return false
|
|
523
|
-
return value.split(/\s+/).some((token) => token === packageName || token.startsWith(`${packageName}@`))
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
function matchesBuiltinIdentity(server, builtin) {
|
|
527
|
-
const name = server?.name ?? server?.serverName
|
|
528
|
-
if (name === builtin.name) return true
|
|
529
|
-
const identity = BUILTIN_MCP_IDENTITIES[builtin.id]
|
|
530
|
-
const invocation = [server?.url, server?.command, ...(Array.isArray(server?.args) ? server.args : [])].filter((value) => typeof value === 'string')
|
|
531
|
-
if (identity.hosts?.some((host) => invocation.some((value) => {
|
|
532
|
-
try {
|
|
533
|
-
return new URL(value).hostname.toLowerCase() === host
|
|
534
|
-
} catch {
|
|
535
|
-
return value.includes(`://${host}/`) || value.includes(`://${host}?`)
|
|
536
|
-
}
|
|
537
|
-
}))) return true
|
|
538
|
-
return identity.packages?.some((packageName) => invocation.some((value) => packageTokenMatches(value, packageName))) === true
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
function builtinSpec(builtin) {
|
|
542
|
-
const spec = { name: builtin.name }
|
|
543
|
-
for (const field of MANAGED_CONFIG_FIELDS) {
|
|
544
|
-
if (builtin[field] !== undefined) spec[field] = structuredClone(builtin[field])
|
|
545
|
-
}
|
|
546
|
-
return spec
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
/**
|
|
550
|
-
* 目录 → 面板要的形状。
|
|
551
|
-
*
|
|
552
|
-
* 浏览器那半是预构建 bundle,**拿不到本模块的常量**(不 import Host 侧代码),所以市场的
|
|
553
|
-
* 展示字段(分类、提供方、主页、需要哪些密钥)必须随这次 RPC 一起过去。少带一个字段的
|
|
554
|
-
* 后果是面板侧只能显示 undefined,而那是 jsdom 看不出来的。
|
|
555
|
-
*
|
|
556
|
-
* @param {ReadonlyArray<object>} effectiveServers - 当前生效的 MCP 配置(用来判「已配置」)。
|
|
557
|
-
* @returns {object[]} 面板用的目录条目。
|
|
558
|
-
*/
|
|
559
|
-
export function builtinMcpCatalog(effectiveServers) {
|
|
560
|
-
return BUILTIN_MCP_SERVERS.map((builtin) => {
|
|
561
|
-
const matches = effectiveServers.filter((server) => matchesBuiltinIdentity(server, builtin))
|
|
562
|
-
const installedAs = [...new Set(matches.map((server) => server?.name ?? server?.serverName).filter(Boolean))]
|
|
563
|
-
const source = catalogById().get(builtin.id) || {}
|
|
564
|
-
return {
|
|
565
|
-
id: builtin.id,
|
|
566
|
-
label: builtin.label,
|
|
567
|
-
labelEn: builtin.labelEn || builtin.label,
|
|
568
|
-
summary: builtin.summary,
|
|
569
|
-
summaryEn: builtin.summaryEn || builtin.summary,
|
|
570
|
-
access: builtin.access,
|
|
571
|
-
accessEn: builtin.accessEn || builtin.access,
|
|
572
|
-
// OPT-24:`auth` 走 builtin(BUILTIN_MCP_SERVERS 已投影),面板据此降级提示
|
|
573
|
-
// oauth 条目;`authNote` 只在存在时挂键——`none` 条目根本没有这个字段,补成空串
|
|
574
|
-
// 会让面板分不清「没有说明」和「说明是空的」。
|
|
575
|
-
auth: builtin.auth,
|
|
576
|
-
...(builtin.authNote !== undefined ? { authNote: builtin.authNote } : {}),
|
|
577
|
-
...(builtin.authNoteEn !== undefined ? { authNoteEn: builtin.authNoteEn } : {}),
|
|
578
|
-
name: builtin.name,
|
|
579
|
-
transport: builtin.transport,
|
|
580
|
-
configuration: builtinSpec(builtin),
|
|
581
|
-
installed: installedAs.length > 0,
|
|
582
|
-
installedAs,
|
|
583
|
-
// —— 市场展示字段(bundle 侧读不到常量,只能这里带过去)——
|
|
584
|
-
category: source.category,
|
|
585
|
-
categoryLabel: MCP_CATEGORY_LABELS[source.category],
|
|
586
|
-
categoryLabelEn: CATEGORY_LABELS_EN[source.category] || MCP_CATEGORY_LABELS[source.category],
|
|
587
|
-
vendor: source.vendor,
|
|
588
|
-
homepage: source.homepage,
|
|
589
|
-
envKeys: source.envKeys || [],
|
|
590
|
-
// 连接参数(HOST/PORT/USER)与凭据分开:市场对前者显示「需填连接信息」而不是
|
|
591
|
-
// 「需密钥」——Redis / ClickHouse 是本机服务,说成要申请 Key 是误导。
|
|
592
|
-
configKeys: source.configKeys || [],
|
|
593
|
-
// 含 `<占位符>` 的条目(如 filesystem 的 `<允许访问的目录>`)装完**不能直接用**:
|
|
594
|
-
// 拿占位符当真实参数启动会失败。市场要据此提示用户先编辑,而不是显示「安装成功」。
|
|
595
|
-
placeholders: source.placeholders || [],
|
|
596
|
-
}
|
|
597
|
-
})
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
function selectedBuiltins(ids) {
|
|
601
|
-
if (!Array.isArray(ids) || !ids.length || !ids.every((id) => typeof id === 'string')) {
|
|
602
|
-
throw new Error('内置 MCP ids 必须是非空字符串数组')
|
|
603
|
-
}
|
|
604
|
-
const selectedIds = new Set(ids)
|
|
605
|
-
const knownIds = new Set(BUILTIN_MCP_SERVERS.map((builtin) => builtin.id))
|
|
606
|
-
const unknown = [...selectedIds].filter((id) => !knownIds.has(id))
|
|
607
|
-
if (unknown.length) throw new Error(`未知的内置 MCP:${unknown.join(', ')}`)
|
|
608
|
-
return BUILTIN_MCP_SERVERS.filter((builtin) => selectedIds.has(builtin.id))
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
export function appendSelectedBuiltinMcpServers(content, effectiveServers, ids, { reservedIds = [] } = {}) {
|
|
612
|
-
const selected = selectedBuiltins(ids)
|
|
613
|
-
const skipped = selected.filter((builtin) => effectiveServers.some((server) => matchesBuiltinIdentity(server, builtin)))
|
|
614
|
-
const skippedIds = new Set(skipped.map((builtin) => builtin.id))
|
|
615
|
-
const additions = selected.filter((builtin) => !skippedIds.has(builtin.id))
|
|
616
|
-
const updated = additions.length
|
|
617
|
-
? updateManagedMcpPatch(content, additions.map(builtinSpec), { replace: false, reservedIds })
|
|
618
|
-
: content
|
|
619
|
-
|
|
620
|
-
return {
|
|
621
|
-
content: updated,
|
|
622
|
-
added: additions.map((builtin) => builtin.id),
|
|
623
|
-
skipped: skipped.map((builtin) => builtin.id),
|
|
624
|
-
}
|
|
625
|
-
}
|
package/lib/mcp-oauth.js
CHANGED
|
@@ -29,7 +29,7 @@ import { homedir } from "node:os";
|
|
|
29
29
|
import { join } from "node:path";
|
|
30
30
|
|
|
31
31
|
/** 插件版本:DCR 的 clientInfo 与 UA 共用一处,避免两边各自漂移。 */
|
|
32
|
-
const PLUGIN_VERSION = "2.3.
|
|
32
|
+
const PLUGIN_VERSION = "2.3.2";
|
|
33
33
|
const USER_AGENT = `dsh-armory/${PLUGIN_VERSION} (+mcp-oauth)`;
|
|
34
34
|
const NET_TIMEOUT_MS = 15_000;
|
|
35
35
|
/** 授权流程整体存活时间:超过就判过期,避免会话表无限增长。 */
|
|
@@ -59,6 +59,69 @@ function sha256hex(text) {
|
|
|
59
59
|
return createHash("sha256").update(String(text)).digest("hex");
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// ── 凭据形状 ────────────────────────────────────────────────────────────
|
|
63
|
+
// 这一层存在的理由是一条真实事故:`ensureAccessToken()` 返回的是对象
|
|
64
|
+
// `{ok, accessToken, ...}`,调用方写成 `String(await ensureAccessToken(url))` 就得到
|
|
65
|
+
// `"[object Object]"` —— 真值、非空、于是被当成正经令牌注进 `authorization` 头并落盘。
|
|
66
|
+
// 服务器收到后回的不是 401「empty access token」而是 400「token parse failed」,
|
|
67
|
+
// 装前探测只认 401/403,于是「需要授权」被彻底漏判:配置写进去了、授权页永远不弹、
|
|
68
|
+
// 连接永远失败。所以「什么才算一个能用的令牌」必须只写一次、放在这里。
|
|
69
|
+
|
|
70
|
+
/** Bearer 令牌长度上限:正常签发物不会这么长,超长基本是把整坨响应粘回来了。 */
|
|
71
|
+
const BEARER_TOKEN_MAX = 8192;
|
|
72
|
+
|
|
73
|
+
/** 去掉可选的 `Bearer ` 前缀与首尾空白(光杆一个 "Bearer" 等于没有令牌)。 */
|
|
74
|
+
function stripBearerPrefix(value) {
|
|
75
|
+
return String(value ?? "").replace(/^\s*Bearer\b[ \t]*/i, "").trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 一眼假:序列化残骸(`[object Object]` / `undefined` / `NaN`)、空值、光杆 `Bearer`。
|
|
80
|
+
* 未替换的 `${TOKEN}` 占位符**不算**残骸——那是 R7 令牌引导的合法中间态,
|
|
81
|
+
* 由 `missingSlots` 单独回报面板,不能被这里悄悄抹掉。
|
|
82
|
+
*/
|
|
83
|
+
export function isSerializedGarbageCredential(value) {
|
|
84
|
+
if (value === null || value === undefined) return true;
|
|
85
|
+
if (typeof value !== "string") return true;
|
|
86
|
+
const text = value.trim();
|
|
87
|
+
if (!text) return true;
|
|
88
|
+
if (/^\[object\s+\w+\]$/i.test(text) || /\[object\s+\w+\]/i.test(text)) return true;
|
|
89
|
+
if (/^(?:undefined|null|NaN)$/i.test(stripBearerPrefix(text))) return true;
|
|
90
|
+
return !stripBearerPrefix(text);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 是不是一个可以原样发给服务器的 access token。 */
|
|
94
|
+
export function isUsableBearerToken(value) {
|
|
95
|
+
if (isSerializedGarbageCredential(value)) return false;
|
|
96
|
+
const token = stripBearerPrefix(value);
|
|
97
|
+
if (token.length > BEARER_TOKEN_MAX) return false;
|
|
98
|
+
if (/\$\{[^}]*\}/.test(token)) return false;
|
|
99
|
+
// 控制符/换行:HTTP 头里出现就是注入面(fetch 也会拒),一律视为不可用。
|
|
100
|
+
return !/[\u0000-\u001f\u007f\u0080-\u009f]/.test(token);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── 「这是鉴权失败吗」判定 ───────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 各家网关的鉴权失败措辞(JSON-RPC error.message 里常见)。刻意只列鉴权语义,
|
|
107
|
+
* 不收 "not found" / "invalid params" 这类——把服务器故障判成「要授权」会把用户
|
|
108
|
+
* 引到一条根本走不通的授权路上。
|
|
109
|
+
*/
|
|
110
|
+
const AUTH_BODY_RE =
|
|
111
|
+
/(empty access token|access token (?:empty|missing|invalid)|token parse|invalid[ _-]?token|missing[ _-]?token|no (?:access )?token|token (?:expired|invalid)|unauthorized|unauthenticated|(?:authentication|authorization) (?:failed|error|required)|(?:需要|请)(?:登录|授权|鉴权))/i;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 一次 MCP 握手失败是不是「缺凭据」。
|
|
115
|
+
* 401/403 无条件算;其它 4xx 只有在响应体明确说鉴权时才算(腾讯 ima 就是 400 +
|
|
116
|
+
* `msg:token parse failed`,且不带 `WWW-Authenticate`);2xx/3xx/5xx 一律不算。
|
|
117
|
+
*/
|
|
118
|
+
export function mcpAuthFailure(status, bodyText = "") {
|
|
119
|
+
const code = Number(status) || 0;
|
|
120
|
+
if (code === 401 || code === 403) return true;
|
|
121
|
+
if (code < 400 || code >= 500) return false;
|
|
122
|
+
return AUTH_BODY_RE.test(String(bodyText || ""));
|
|
123
|
+
}
|
|
124
|
+
|
|
62
125
|
/** PKCE 授权码流程的 verifier/challenge(S256)。 */
|
|
63
126
|
export function createPkce() {
|
|
64
127
|
const verifier = b64url(randomBytes(48));
|
|
@@ -114,8 +177,8 @@ async function fetchJson(url, init = {}) {
|
|
|
114
177
|
// ── 探测 ────────────────────────────────────────────────────────────────
|
|
115
178
|
|
|
116
179
|
/**
|
|
117
|
-
* 打一次 MCP initialize
|
|
118
|
-
* 只用于确认「这颗 server
|
|
180
|
+
* 打一次 MCP initialize,拿回状态码、`WWW-Authenticate` 与响应体摘要。
|
|
181
|
+
* 只用于确认「这颗 server 是不是要凭据」,不做其它判断。
|
|
119
182
|
*/
|
|
120
183
|
export async function probeServer(serverUrl, headers = {}) {
|
|
121
184
|
const body = JSON.stringify({
|
|
@@ -142,16 +205,18 @@ export async function probeServer(serverUrl, headers = {}) {
|
|
|
142
205
|
},
|
|
143
206
|
body
|
|
144
207
|
});
|
|
145
|
-
// 读掉 body,避免 socket
|
|
146
|
-
await response.text().catch(() => "");
|
|
208
|
+
// 读掉 body,避免 socket 悬挂;正文还要留给鉴权判定(不少网关 400 也带鉴权语义)。
|
|
209
|
+
const text = await response.text().catch(() => "");
|
|
147
210
|
return {
|
|
148
211
|
ok: true,
|
|
149
212
|
status: response.status,
|
|
150
213
|
wwwAuthenticate: response.headers.get("www-authenticate") || "",
|
|
214
|
+
body: trimText(text, 400),
|
|
215
|
+
authRequired: mcpAuthFailure(response.status, text),
|
|
151
216
|
error: ""
|
|
152
217
|
};
|
|
153
218
|
} catch (error) {
|
|
154
|
-
return { ok: false, status: 0, wwwAuthenticate: "", error: String(error?.message || error) };
|
|
219
|
+
return { ok: false, status: 0, wwwAuthenticate: "", body: "", authRequired: false, error: String(error?.message || error) };
|
|
155
220
|
}
|
|
156
221
|
}
|
|
157
222
|
|
|
@@ -439,7 +504,8 @@ export async function clearTokenRecord(serverUrl) {
|
|
|
439
504
|
|
|
440
505
|
/** 记录里是否有一个「还没过期」的 access token(留 60s 余量)。 */
|
|
441
506
|
export function tokenIsFresh(record) {
|
|
442
|
-
|
|
507
|
+
// 形状先于时效:`[object Object]` 这种残骸哪怕带着未来的 expiresAt 也不是令牌。
|
|
508
|
+
if (!record || !isUsableBearerToken(record.accessToken)) return false;
|
|
443
509
|
if (!record.expiresAt) return true; // 服务器没给 expires_in:视为长期有效,等 401 再说
|
|
444
510
|
// 落盘时写的是 ISO 串(见 exchangeCode),但令牌库是纯 JSON、可能被人手改或由
|
|
445
511
|
// 旧版本写成数字毫秒,两种都认:数字直接当时间戳,字符串才走 Date.parse。
|
|
@@ -478,7 +544,7 @@ async function refreshAccessToken(record) {
|
|
|
478
544
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
479
545
|
body: body.toString()
|
|
480
546
|
});
|
|
481
|
-
if (!result.ok || !
|
|
547
|
+
if (!result.ok || !isUsableBearerToken(result.json?.access_token)) {
|
|
482
548
|
return { ok: false, accessToken: "", error: `刷新 token 失败(HTTP ${result.status || "网络错误"}):${trimText(result.text)}` };
|
|
483
549
|
}
|
|
484
550
|
await writeTokenRecord(record.serverUrl, tokenRecordPatch(result.json));
|
|
@@ -512,20 +578,35 @@ function tokenRecordPatch(tokenResponse) {
|
|
|
512
578
|
export async function beginAuthorization({ serverName, serverUrl, headers = {}, clientName = "DeepSeek Harness" } = {}) {
|
|
513
579
|
if (!serverUrl) return fail("", "", "", "缺少 server url");
|
|
514
580
|
const probe = await probeServer(serverUrl, headers);
|
|
515
|
-
if (!probe.ok) return fail("", "", "", `无法连接 ${serverUrl}:${probe.error}
|
|
516
|
-
if (probe.status
|
|
517
|
-
//
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
}
|
|
581
|
+
if (!probe.ok) return fail("", "", "", `无法连接 ${serverUrl}:${probe.error}`, { serverUrl });
|
|
582
|
+
if (probe.status >= 400 && !probe.authRequired) {
|
|
583
|
+
// 明确的非鉴权失败(404 路径错、500 服务器挂了…):继续往下走只会把用户
|
|
584
|
+
// 引到一条根本不存在的授权路上,直接说清楚更省时间。
|
|
585
|
+
return fail("", "", "", `服务器返回 HTTP ${probe.status},不是预期的鉴权错误:${probe.body || "(无响应体)"}`, { serverUrl });
|
|
521
586
|
}
|
|
587
|
+
// 到这里要么服务器点名要凭据(401/403/4xx 鉴权体),要么匿名握手就过了
|
|
588
|
+
// (有些实现把鉴权推迟到 tools/call)——两种都继续试一次发现流程。
|
|
522
589
|
|
|
523
590
|
const resource = await resolveProtectedResource({ serverUrl, wwwAuthenticate: probe.wwwAuthenticate });
|
|
524
|
-
if (!resource.ok) return fail("", "", "", resource.error);
|
|
591
|
+
if (!resource.ok) return fail("", "", "", resource.error, { serverUrl, manualTokenOnly: true });
|
|
525
592
|
const as = await resolveAuthorizationServer(resource.authorizationServers[0]);
|
|
526
|
-
if (!as.ok)
|
|
593
|
+
if (!as.ok) {
|
|
594
|
+
// 现实里很常见:服务器要 Bearer token,但压根不实现 RFC 9728/8414 的 well-known
|
|
595
|
+
// 发现(腾讯 ima 就是这样)。这时没有任何 authorizeUrl 可开,硬报错等于把用户
|
|
596
|
+
// 关在门外——回 manualTokenOnly,让面板把手填令牌那条兜底路摊开给他。
|
|
597
|
+
return fail("", "", "", `${as.error}\n该服务器没有提供标准 OAuth 授权发现,无法自动打开授权页;如果你已有访问令牌,可以直接粘贴安装。`, {
|
|
598
|
+
serverUrl,
|
|
599
|
+
manualTokenOnly: true
|
|
600
|
+
});
|
|
601
|
+
}
|
|
527
602
|
if (!as.tokenEndpointAuthMethods.includes("none")) {
|
|
528
|
-
return fail(
|
|
603
|
+
return fail(
|
|
604
|
+
"",
|
|
605
|
+
"",
|
|
606
|
+
"",
|
|
607
|
+
`该授权服务器要求客户端认证(${as.tokenEndpointAuthMethods.join("/")}),本插件只支持公共客户端(none);如果你已有访问令牌,可以直接粘贴安装。`,
|
|
608
|
+
{ serverUrl, manualTokenOnly: true }
|
|
609
|
+
);
|
|
529
610
|
}
|
|
530
611
|
|
|
531
612
|
const scope = as.scopesSupported.length ? as.scopesSupported.join(" ") : "";
|
|
@@ -543,13 +624,22 @@ export async function beginAuthorization({ serverName, serverUrl, headers = {},
|
|
|
543
624
|
const template = extractExpectedRedirectUri(registration.raw || registration.error);
|
|
544
625
|
if (!template) {
|
|
545
626
|
loopback.close();
|
|
546
|
-
return fail("", "", "", registration.error
|
|
627
|
+
return fail("", "", "", `${registration.error}\n动态注册走不通,无法生成授权页;如果你已有访问令牌,可以直接粘贴安装。`, {
|
|
628
|
+
serverUrl,
|
|
629
|
+
manualTokenOnly: true
|
|
630
|
+
});
|
|
547
631
|
}
|
|
548
632
|
const expected = substituteRedirectPlaceholder(template, serverName || clientName);
|
|
549
633
|
const retry = await registerClient(as, { redirectUri: expected, clientName, scope });
|
|
550
634
|
if (!retry.ok) {
|
|
551
635
|
loopback.close();
|
|
552
|
-
return fail(
|
|
636
|
+
return fail(
|
|
637
|
+
"",
|
|
638
|
+
"",
|
|
639
|
+
"",
|
|
640
|
+
`${registration.error}\n改用服务器指定的回调地址 ${expected} 重试仍失败:${retry.error}\n如果你已有访问令牌,可以直接粘贴安装。`,
|
|
641
|
+
{ serverUrl, manualTokenOnly: true }
|
|
642
|
+
);
|
|
553
643
|
}
|
|
554
644
|
registration = retry;
|
|
555
645
|
redirectUri = expected;
|
|
@@ -619,8 +709,21 @@ function buildAuthorizeUrl({ authorizationEndpoint, clientId, redirectUri, scope
|
|
|
619
709
|
return url.href;
|
|
620
710
|
}
|
|
621
711
|
|
|
622
|
-
function fail(sessionId, authorizeUrl, mode, error) {
|
|
623
|
-
return {
|
|
712
|
+
function fail(sessionId, authorizeUrl, mode, error, extra = {}) {
|
|
713
|
+
return {
|
|
714
|
+
ok: false,
|
|
715
|
+
sessionId,
|
|
716
|
+
authorizeUrl,
|
|
717
|
+
mode,
|
|
718
|
+
redirectUri: "",
|
|
719
|
+
hint: "",
|
|
720
|
+
// manualTokenOnly:授权页这条路走不通,但「手填访问令牌」仍然可用。面板据此
|
|
721
|
+
// 也要把弹层开出来(而不是只甩一行红字),否则用户看到的是死路。
|
|
722
|
+
manualTokenOnly: false,
|
|
723
|
+
serverUrl: "",
|
|
724
|
+
error,
|
|
725
|
+
...extra
|
|
726
|
+
};
|
|
624
727
|
}
|
|
625
728
|
|
|
626
729
|
function pruneSessions() {
|
|
@@ -667,7 +770,7 @@ async function exchangeCode(session, code) {
|
|
|
667
770
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
668
771
|
body: body.toString()
|
|
669
772
|
});
|
|
670
|
-
if (!result.ok || !
|
|
773
|
+
if (!result.ok || !isUsableBearerToken(result.json?.access_token)) {
|
|
671
774
|
session.status = "failed";
|
|
672
775
|
session.error = `换取 token 失败(HTTP ${result.status || "网络错误"}):${trimText(result.text)}`;
|
|
673
776
|
return;
|
|
@@ -775,8 +878,12 @@ export async function saveManualToken({ serverUrl, serverName = "", token, token
|
|
|
775
878
|
const raw = String(token || "").trim();
|
|
776
879
|
if (!serverUrl) return { ok: false, error: "缺少 server url" };
|
|
777
880
|
if (!raw) return { ok: false, error: "token 不能为空" };
|
|
778
|
-
const accessToken = raw
|
|
881
|
+
const accessToken = stripBearerPrefix(raw);
|
|
779
882
|
if (!accessToken) return { ok: false, error: "token 不能为空" };
|
|
883
|
+
// 手填这条路最容易粘进残骸(`[object Object]`、没替换的 ${TOKEN}、带换行的整段 curl)。
|
|
884
|
+
if (!isUsableBearerToken(accessToken)) {
|
|
885
|
+
return { ok: false, error: "这不像是个可用的访问令牌(空值、未替换的 ${...} 占位符或序列化残骸),请重新从服务方复制。" };
|
|
886
|
+
}
|
|
780
887
|
await writeTokenRecord(serverUrl, {
|
|
781
888
|
accessToken,
|
|
782
889
|
tokenType: "Bearer",
|
|
@@ -801,6 +908,4 @@ export async function authorizationOverview(serverUrl) {
|
|
|
801
908
|
refreshable: Boolean(str(record.refreshToken)),
|
|
802
909
|
savedAt: str(record.savedAt)
|
|
803
910
|
};
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
export const __testing = { sessions, recordPathFor, buildAuthorizeUrl, tokenRecordPatch };
|
|
911
|
+
}
|