dsh-armory 1.10.0 → 2.0.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/README.en.md +57 -13
- package/README.md +70 -21
- package/docs/mcp-market-design.md +62 -0
- package/docs/prd-awesome-dsh-plugins.md +105 -0
- package/docs/prd-workbuddy-parity.md +128 -0
- package/docs/system-design-awesome.md +269 -0
- package/docs/system_design.md +530 -0
- package/docs/workbuddy-market-gap-analysis.md +197 -0
- package/lib/client.js +1631 -136
- package/lib/expert-market.js +13 -2
- package/lib/index.js +356 -7
- package/lib/mcp-oauth.js +806 -0
- package/lib/typert.js +24 -0
- package/lib/wb-auth-rules.js +128 -0
- package/lib/wb-awesome-market.js +453 -0
- package/lib/wb-install-state.js +151 -0
- package/lib/wb-market.js +213 -8
- package/lib/wb-plugin-market.js +291 -0
- package/lib/wb-registry.js +224 -0
- package/lib/whats-new.js +37 -0
- package/package.json +2 -2
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// 本地安装状态 + 来源锁(R8)—— ~/.dsh/armory/install-state.json 的读写层。
|
|
2
|
+
//
|
|
3
|
+
// 职责(设计文档 docs/system_design.md 8.3 / 类图 InstallStateStore):
|
|
4
|
+
// - record(item):按 kind+key 定位,已存在则覆盖并刷新 lastUpdated;
|
|
5
|
+
// 首次写入补 installedAt。connector 不记 filesDigest,plugin/skill 记关键文件摘要。
|
|
6
|
+
// - forget(kind, key):删除一条登记,不存在时为无害空操作。
|
|
7
|
+
// - listInstalled(kind):返回该 kind 全部条目(顺序 = 写入序),供面板做「已安装」
|
|
8
|
+
// **全量本地筛选**(不受分页影响,与技能侧 skillOnlyInstalled 只筛当前页不同)。
|
|
9
|
+
//
|
|
10
|
+
// 三类安装(connector=profile、skill=目录、plugin=宿主 bundle)在各自收尾点调用 record,
|
|
11
|
+
// 把分散的安装事实收敛到这一个文件里——这就是「来源锁」:登记条目带 source
|
|
12
|
+
// {marketplace, url, kind},面板据此区分「本插件装的」与「其它途径来的」。
|
|
13
|
+
//
|
|
14
|
+
// 纯数据层:零网络。存储根目录可注入(options.rootDir / 环境变量 DSH_ARMORY_HOME),
|
|
15
|
+
// 测试绝不碰真实 ~/.dsh。写盘与 lib/wb-registry.js 同用 staging + rename 原子替换。
|
|
16
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
export const INSTALL_STATE_VERSION = 1;
|
|
21
|
+
|
|
22
|
+
const KINDS = new Set(["connector", "skill", "plugin"]);
|
|
23
|
+
|
|
24
|
+
/** 存储根目录:显式 rootDir > 环境变量 > 真实 ~/.dsh/armory(与 wb-registry 同一解析序)。 */
|
|
25
|
+
function armoryRoot(options = {}) {
|
|
26
|
+
return options.rootDir || process.env.DSH_ARMORY_HOME || join(homedir(), ".dsh", "armory");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 状态文件绝对路径。 */
|
|
30
|
+
export function statePath(options = {}) {
|
|
31
|
+
return join(armoryRoot(options), "install-state.json");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function asString(value, fallback = "") {
|
|
35
|
+
return typeof value === "string" ? value : fallback;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function asTimestamp(value, fallback = 0) {
|
|
39
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 把任意原始对象规范成一条完整的 InstallStateItem:
|
|
44
|
+
* 缺失字段防御性补全,畸形来源对象折成空对象而不是抛错。
|
|
45
|
+
*/
|
|
46
|
+
export function normalizeItem(raw) {
|
|
47
|
+
const source = raw && typeof raw.source === "object" && raw.source !== null ? raw.source : {};
|
|
48
|
+
const filesDigest = raw && typeof raw.filesDigest === "object" && raw.filesDigest !== null && !Array.isArray(raw.filesDigest)
|
|
49
|
+
? raw.filesDigest
|
|
50
|
+
: {};
|
|
51
|
+
return {
|
|
52
|
+
kind: KINDS.has(raw?.kind) ? raw.kind : "connector",
|
|
53
|
+
key: asString(raw?.key).trim(),
|
|
54
|
+
name: asString(raw?.name),
|
|
55
|
+
scope: asString(raw?.scope, "managed"),
|
|
56
|
+
installPath: asString(raw?.installPath),
|
|
57
|
+
version: asString(raw?.version),
|
|
58
|
+
dependencies: Array.isArray(raw?.dependencies) ? raw.dependencies.map((dep) => String(dep)) : [],
|
|
59
|
+
source: {
|
|
60
|
+
marketplace: asString(source.marketplace),
|
|
61
|
+
url: asString(source.url),
|
|
62
|
+
kind: asString(source.kind),
|
|
63
|
+
},
|
|
64
|
+
installedAt: asTimestamp(raw?.installedAt, 0),
|
|
65
|
+
lastUpdated: asTimestamp(raw?.lastUpdated, 0),
|
|
66
|
+
filesDigest,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 从磁盘读原始文档;不存在/损坏一律返回 null。 */
|
|
71
|
+
async function readRawDoc(path) {
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 拆出目录部分(路径既有 / 也有 \ 的 Windows 形态)。 */
|
|
80
|
+
function dirnameOf(path) {
|
|
81
|
+
const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
82
|
+
return cut > 0 ? path.slice(0, cut) : path;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** staging + rename 原子写(与 wb-registry.writeAtomic 同一模式)。 */
|
|
86
|
+
async function writeAtomic(path, doc) {
|
|
87
|
+
await mkdir(dirnameOf(path), { recursive: true });
|
|
88
|
+
await writeFile(`${path}.staging`, JSON.stringify(doc, null, 2));
|
|
89
|
+
await rm(path, { force: true });
|
|
90
|
+
await rename(`${path}.staging`, path);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 读全部条目(合并投影:文件缺失/损坏 = 空状态)。永不抛错。 */
|
|
94
|
+
export async function readState(options = {}) {
|
|
95
|
+
const raw = await readRawDoc(statePath(options));
|
|
96
|
+
const items = Array.isArray(raw?.items)
|
|
97
|
+
? raw.items.filter((item) => item && typeof item === "object" && !Array.isArray(item)).map(normalizeItem)
|
|
98
|
+
: [];
|
|
99
|
+
const version = raw && typeof raw.version === "number" ? raw.version : INSTALL_STATE_VERSION;
|
|
100
|
+
return { version, items };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 登记一条安装:同 kind+key 覆盖(刷新 lastUpdated,保留首次的 installedAt),
|
|
105
|
+
* 首次写入补 installedAt。connector 不落 filesDigest(连接器装进 profile,
|
|
106
|
+
* 没有关键文件可记);plugin/skill 原样保留传入的 filesDigest。
|
|
107
|
+
*/
|
|
108
|
+
export async function record(item, options = {}) {
|
|
109
|
+
const normalized = normalizeItem(item);
|
|
110
|
+
if (!normalized.key) throw new Error("安装状态条目缺少 key,无法登记");
|
|
111
|
+
const path = statePath(options);
|
|
112
|
+
const { items } = await readState(options);
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
const index = items.findIndex((entry) => entry.kind === normalized.kind && entry.key === normalized.key);
|
|
115
|
+
if (index >= 0) {
|
|
116
|
+
const existing = items[index];
|
|
117
|
+
items[index] = {
|
|
118
|
+
...normalized,
|
|
119
|
+
installedAt: existing.installedAt || now,
|
|
120
|
+
lastUpdated: now,
|
|
121
|
+
filesDigest: normalized.kind === "connector" ? {} : (Object.keys(normalized.filesDigest).length ? normalized.filesDigest : existing.filesDigest),
|
|
122
|
+
};
|
|
123
|
+
} else {
|
|
124
|
+
items.push({
|
|
125
|
+
...normalized,
|
|
126
|
+
installedAt: now,
|
|
127
|
+
lastUpdated: now,
|
|
128
|
+
filesDigest: normalized.kind === "connector" ? {} : normalized.filesDigest,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
await writeAtomic(path, { version: INSTALL_STATE_VERSION, items });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 删除一条登记(kind+key 定位)。不存在时为无害空操作。 */
|
|
135
|
+
export async function forget(kind, key, options = {}) {
|
|
136
|
+
const wantedKind = String(kind ?? "").trim();
|
|
137
|
+
const wantedKey = String(key ?? "").trim();
|
|
138
|
+
if (!wantedKind || !wantedKey) throw new Error("forget 需要 kind 与 key");
|
|
139
|
+
const path = statePath(options);
|
|
140
|
+
const { items } = await readState(options);
|
|
141
|
+
const kept = items.filter((entry) => !(entry.kind === wantedKind && entry.key === wantedKey));
|
|
142
|
+
if (kept.length === items.length) return; // 没删到东西:不写盘
|
|
143
|
+
await writeAtomic(path, { version: INSTALL_STATE_VERSION, items: kept });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 该 kind 的全部已安装条目(供「已安装」全量筛选)。 */
|
|
147
|
+
export async function listInstalled(kind, options = {}) {
|
|
148
|
+
const wanted = String(kind ?? "").trim();
|
|
149
|
+
const { items } = await readState(options);
|
|
150
|
+
return items.filter((entry) => entry.kind === wanted);
|
|
151
|
+
}
|
package/lib/wb-market.js
CHANGED
|
@@ -23,8 +23,21 @@ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/p
|
|
|
23
23
|
import { homedir } from "node:os";
|
|
24
24
|
import { join, resolve, sep } from "node:path";
|
|
25
25
|
import { ZipReader } from "./wb-zip.js";
|
|
26
|
+
// T02/R1:auth_injection_rules 只解析、只展示(X9 边界)——DSH 不做任何代注入,
|
|
27
|
+
// 这里只借用 T01 纯函数库的解析与按连接器命中能力。
|
|
28
|
+
import { parseInjectionRules, rulesFor, scanTokenSlots } from "./wb-auth-rules.js";
|
|
29
|
+
// T03/R4:连接器源不再硬编码单 URL——从多市场注册表取 sourceFor("connectors"),
|
|
30
|
+
// 读不到/损坏时回落内置默认(wb-registry.builtinMarketplaces 保证)。
|
|
31
|
+
import { sourceFor } from "./wb-registry.js";
|
|
26
32
|
|
|
27
33
|
const CONNECTOR_ZIP_URL = "https://static.workbuddy.cn/connectors-config-v2/connectors-config.zip";
|
|
34
|
+
|
|
35
|
+
/** R4:连接器市场源 URL。注册表第一条 connectors 源优先,缺失/读失败回落内置默认。 */
|
|
36
|
+
export async function connectorSourceUrl(options = {}) {
|
|
37
|
+
const row = await sourceFor("connectors", options);
|
|
38
|
+
const url = row && row.source && typeof row.source.url === "string" ? row.source.url : "";
|
|
39
|
+
return url || CONNECTOR_ZIP_URL;
|
|
40
|
+
}
|
|
28
41
|
const SKILLHUB_ORIGIN = "https://api.skillhub.cn";
|
|
29
42
|
const USER_AGENT = "@2026heshao/dsh-armory (+https://github.com/2026heshao/dsh-armory)";
|
|
30
43
|
// 单文件/单响应上限:connectors.json ≈ 550KB、SKILL.md 最大几十 KB,8MB 已是十倍冗余。
|
|
@@ -67,8 +80,11 @@ async function loadConnectorZip() {
|
|
|
67
80
|
if (cachedZip) return cachedZip;
|
|
68
81
|
await mkdir(cacheDir(), { recursive: true });
|
|
69
82
|
const meta = (await readJson(metaPath())) || {};
|
|
83
|
+
// R4:注册表取源(缺失回落内置默认),ZIP 路径/缓存键不变——
|
|
84
|
+
// 注册表只是改了「从哪下」,没有改「下到哪」。
|
|
85
|
+
const sourceUrl = await connectorSourceUrl();
|
|
70
86
|
const download = async (etag) => {
|
|
71
|
-
const response = await fetch(
|
|
87
|
+
const response = await fetch(sourceUrl, {
|
|
72
88
|
headers: { "user-agent": USER_AGENT, ...(etag ? { "if-none-match": etag } : {}) },
|
|
73
89
|
signal: AbortSignal.timeout(120_000),
|
|
74
90
|
});
|
|
@@ -115,21 +131,115 @@ async function loadConnectorZip() {
|
|
|
115
131
|
function isWorkbuddyOnlyCommand(command) {
|
|
116
132
|
return typeof command === "string" && (command.includes("connectors-marketplace") || command.includes(".workbuddy"));
|
|
117
133
|
}
|
|
118
|
-
|
|
134
|
+
|
|
135
|
+
// ── T02 投影函数(R1/R2/R3)───────────────────────────────────────────
|
|
136
|
+
// 三个都是纯函数:输入 connectors.json 的原始形状,输出面板行需要的展示字段。
|
|
137
|
+
// 放在这里而不是 client.js,是因为它们处理的是「远端数据的形状防御」,与
|
|
138
|
+
// serverInstallability 同层;UI 只消费结果,不重复判形状。
|
|
139
|
+
|
|
140
|
+
/** R1:某条连接器的 token 注入规则标注。只数条数、不给注入物(X9:只展示不注入)。 */
|
|
141
|
+
export function injectionRuleNote(indexJson, connectorId) {
|
|
142
|
+
const rules = rulesFor(parseInjectionRules(indexJson), connectorId);
|
|
143
|
+
if (!rules.length) return { count: 0, note: "" };
|
|
144
|
+
return {
|
|
145
|
+
count: rules.length,
|
|
146
|
+
note: `存在 ${rules.length} 条 token 注入规则(依赖 WB 账号体系,DSH 不代注入)`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* R2:visible_in 透传 + 标签筛选。实测为 key 数组(如 ["workbuddy","cline"]);
|
|
152
|
+
* 畸形(缺失/非数组/元素非字符串)一律归一成 [],调用方不需要再判形状。
|
|
153
|
+
* 空数组 ≠ 「不可见」:上游没标就当作全部可见,筛选函数里不拦截。
|
|
154
|
+
*/
|
|
155
|
+
export function visibleInOf(entry) {
|
|
156
|
+
const raw = entry?.visible_in;
|
|
157
|
+
if (!Array.isArray(raw)) return [];
|
|
158
|
+
return raw.map((tag) => (typeof tag === "string" ? tag.trim() : "")).filter(Boolean);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** R2:本地筛选。visibleTags 为空(上游未标)= 不受该维度过滤;非空才要求包含。 */
|
|
162
|
+
export function matchesVisibleIn(rowVisibleTags, selectedTags) {
|
|
163
|
+
if (!Array.isArray(selectedTags) || !selectedTags.length) return true;
|
|
164
|
+
if (!Array.isArray(rowVisibleTags) || !rowVisibleTags.length) return true;
|
|
165
|
+
const owned = new Set(rowVisibleTags.map((tag) => String(tag).toLowerCase()));
|
|
166
|
+
return selectedTags.every((tag) => owned.has(String(tag).toLowerCase()));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* R3:examples 优先的展示文案。规则(设计文档 R3):
|
|
171
|
+
* 有 examples 且非空 → 正文用 examples(按语言 zh/en 选取);
|
|
172
|
+
* 否则回落 description;两者都无 → 空串(UI 自行回落到 name)。
|
|
173
|
+
* 入参都是 connectors.json 条目的原样字段,畸形一律防御性回落。
|
|
174
|
+
*/
|
|
175
|
+
export function displayCopyOf(entry) {
|
|
176
|
+
const pick = (list) => (Array.isArray(list) ? list.filter((item) => typeof item === "string" && item.trim()).filter(Boolean) : []);
|
|
177
|
+
const zhExamples = pick(entry?.examples_zh);
|
|
178
|
+
const enExamples = pick(entry?.examples_en);
|
|
179
|
+
const examples = zhExamples.length ? zhExamples : enExamples;
|
|
180
|
+
if (examples.length) {
|
|
181
|
+
return { body: examples.join("\n"), from: "examples", examples };
|
|
182
|
+
}
|
|
183
|
+
const zh = typeof entry?.description_zh === "string" ? entry.description_zh : "";
|
|
184
|
+
const fallbackZh = typeof entry?.description === "string" ? entry.description : "";
|
|
185
|
+
const en = typeof entry?.description_en === "string" ? entry.description_en : "";
|
|
186
|
+
const body = zh || fallbackZh || en;
|
|
187
|
+
return { body, from: body ? "description" : "", examples: [] };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function serverInstallability(serverName, rawServer, authMode = "") {
|
|
119
191
|
const reasons = [];
|
|
192
|
+
// R8:oauth / oneid-token 类不再拒装,而是标记「需走浏览器授权」,
|
|
193
|
+
// 由 lib/mcp-oauth.js 在安装时驱动 RFC 9728/8414/7591/7636 全链路。
|
|
194
|
+
let needsOAuth = false;
|
|
120
195
|
const nested = rawServer && typeof rawServer.transport === "object" ? rawServer.transport : rawServer;
|
|
121
|
-
|
|
122
|
-
|
|
196
|
+
// mcp.json 里的 stdio 条目本来就只有 command、不写 type(官方示例与各家 README 皆如此)。
|
|
197
|
+
// 早前这里只认 nested.type:command 型条目 transport 落成空串,被文件末尾的
|
|
198
|
+
// 「未知传输类型」判为拒装——凡是标准 stdio 连接器都装不上。缺 type 但有 command
|
|
199
|
+
// 时按 stdio 处理,与下面返回处的 `transport || "stdio"` 兜底保持同一判断。
|
|
200
|
+
const declaredType = String(nested?.type ?? "").toLowerCase();
|
|
201
|
+
const transport = declaredType || (typeof nested?.command === "string" && nested.command.trim() ? "stdio" : "");
|
|
202
|
+
// R7:先扫占位符槽位(env/headers 里的 ${...})。这一扫描必须发生在 transport 的
|
|
203
|
+
// 提前 return 之前——否则 streamable-http 型连接器的 header 占位符会被整段跳过,
|
|
204
|
+
// 而 80+ 条 auth_mode=token 的连接器正是这个传输形态,正是「装不了」的成因之一。
|
|
205
|
+
const tokenSlots = scanTokenSlots({ [serverName]: rawServer });
|
|
206
|
+
const hasReplaceableSlots = tokenSlots.length > 0;
|
|
207
|
+
// 索引 auth_mode 是比 mcp.json 更权威的鉴权声明:mcp.json 可能漏标(如 tec-do 裸 URL),
|
|
208
|
+
// 但索引里标了鉴权的连接器 DSH 无法自动注入凭据,装上必 401,必须在列表层就拦下。
|
|
209
|
+
const mode = String(authMode || "").trim().toLowerCase();
|
|
210
|
+
if (mode === "token") {
|
|
211
|
+
// R7:token 型由「一律拒装」改为「可装但需引导填 token」。
|
|
212
|
+
// 有可替换占位符 → 放行(UI 弹令牌框);无占位符 → 用户无从填起,仍然拒装。
|
|
213
|
+
if (!hasReplaceableSlots) {
|
|
214
|
+
reasons.push("该连接器需要 token 凭据,但 mcp.json 未声明可替换的 ${...} 槽位,DSH 无法代填");
|
|
215
|
+
}
|
|
216
|
+
} else if (mode === "oauth" || mode === "oneid-token") {
|
|
217
|
+
// R8:凭据改由插件侧 OAuth 授权流程获取(浏览器授权 → token 落盘 → 注入
|
|
218
|
+
// authorization header),所以这里放行并打上需授权标记。
|
|
219
|
+
needsOAuth = true;
|
|
220
|
+
} else if (mode === "server-side" || mode === "mcp") {
|
|
221
|
+
reasons.push(`该连接器需要 ${mode} 凭据,DSH 无法自动注入`);
|
|
222
|
+
} else if (mode) {
|
|
223
|
+
// 未知 auth_mode 值保守处理:宁可少装不错装(与本文件「能装的标准从严」同一纪律)。
|
|
224
|
+
reasons.push(`该连接器需要未知类型的鉴权(auth_mode=${mode}),DSH 无法自动注入`);
|
|
225
|
+
}
|
|
226
|
+
const needsTokens = hasReplaceableSlots;
|
|
227
|
+
if (transport === "streamablehttp" || transport === "streamable-http" || transport === "http") {
|
|
228
|
+
return { ok: reasons.length === 0, transport: "streamable-http", reasons, tokenSlots, needsTokens, needsOAuth };
|
|
229
|
+
}
|
|
123
230
|
if (transport === "sse" || transport === "ws") reasons.push(`传输 ${transport} 不受 DSH 支持`);
|
|
124
231
|
if (rawServer?.preAuth) reasons.push("需要 WorkBuddy 的 preAuth 鉴权链路");
|
|
125
232
|
if (typeof nested?.command === "string" && isWorkbuddyOnlyCommand(nested.command)) reasons.push("command 依赖 WorkBuddy 本机连接器目录");
|
|
126
233
|
const envKeys = Object.keys(nested?.env || {});
|
|
127
234
|
const headerKeys = Object.keys(nested?.headers || {});
|
|
128
|
-
|
|
235
|
+
// R7:已有可替换占位符的,交给 needsTokens 引导用户填,不再重复算作拒装理由;
|
|
236
|
+
// 反之(键名像凭据但值里没有 ${...} 槽位)无从填起,仍然拒装。
|
|
237
|
+
if (!hasReplaceableSlots
|
|
238
|
+
&& (envKeys.some((key) => /token|secret|key/i.test(key)) || headerKeys.some((key) => /authorization|token/i.test(key)))) {
|
|
129
239
|
reasons.push("需要 WorkBuddy 的 token 注入,DSH 无法代填");
|
|
130
240
|
}
|
|
131
241
|
if (!reasons.length && transport !== "stdio") reasons.push(`未知传输类型 ${transport || "(缺失)"}`);
|
|
132
|
-
return { ok: reasons.length === 0, transport: transport || "stdio", reasons };
|
|
242
|
+
return { ok: reasons.length === 0, transport: transport || "stdio", reasons, tokenSlots, needsTokens, needsOAuth };
|
|
133
243
|
}
|
|
134
244
|
|
|
135
245
|
/** connectors.json 索引 + 每条的可装性标注。UI 列表唯一数据源。 */
|
|
@@ -155,17 +265,32 @@ export async function wbConnectorCatalog() {
|
|
|
155
265
|
}
|
|
156
266
|
// mcp.json 可能一条目录带多个 mcpServers;逐个判可装性,全部可装才算可装(安装时逐条写入)。
|
|
157
267
|
const servers = mcpJson?.mcpServers && typeof mcpJson.mcpServers === "object" ? Object.entries(mcpJson.mcpServers) : [];
|
|
158
|
-
const checks = servers.map(([serverName, rawServer]) => ({ serverName, rawServer, ...serverInstallability(serverName, rawServer) }));
|
|
268
|
+
const checks = servers.map(([serverName, rawServer]) => ({ serverName, rawServer, ...serverInstallability(serverName, rawServer, entry.auth_mode) }));
|
|
159
269
|
const blocked = checks.filter((check) => !check.ok);
|
|
270
|
+
// R3:examples 优先(zh → en 回落),description 只作兜底正文。
|
|
271
|
+
const copy = displayCopyOf(entry);
|
|
160
272
|
rows.push({
|
|
161
273
|
id,
|
|
162
274
|
name: entry.name || id,
|
|
163
275
|
nameEn: entry.name_en || "",
|
|
164
276
|
description: entry.description_zh || entry.description || entry.description_en || "",
|
|
165
277
|
descriptionEn: entry.description_en || "",
|
|
278
|
+
// R3 投影:UI 优先用 displayBody(examples 语义),from 让 UI 知道在展示什么。
|
|
279
|
+
displayBody: copy.body,
|
|
280
|
+
displayFrom: copy.from,
|
|
281
|
+
examples: copy.examples,
|
|
282
|
+
// R2:visible_in 标签(上游未标 = 空数组 = 不受筛选)。
|
|
283
|
+
visibleIn: visibleInOf(entry),
|
|
284
|
+
// R1:token 注入规则条数标注(只展示;DSH 不代注入)。
|
|
285
|
+
injectionRuleCount: injectionRuleNote(index, id).count,
|
|
286
|
+
// R7:可装性拆分投影——needsTokens 为 true 时 UI 应弹令牌引导框。
|
|
287
|
+
needsTokens: checks.some((check) => check.needsTokens),
|
|
288
|
+
// R8:needsOAuth 为 true 时 UI 应走「浏览器授权」引导(而不是拒装/令牌框)。
|
|
289
|
+
needsOAuth: checks.some((check) => check.needsOAuth),
|
|
290
|
+
tokenSlots: checks.flatMap((check) => check.tokenSlots || []),
|
|
166
291
|
authMode: entry.auth_mode || "",
|
|
292
|
+
version: entry.version || "",
|
|
167
293
|
type: entry.type || "",
|
|
168
|
-
examples: entry.examples_zh || entry.examples_en || [],
|
|
169
294
|
skillCount,
|
|
170
295
|
serverNames: checks.map((check) => check.serverName),
|
|
171
296
|
transport: checks[0]?.transport || "",
|
|
@@ -190,6 +315,86 @@ export async function wbConnectorPreview(connectorId) {
|
|
|
190
315
|
return { connectorId: id, mcpServers: servers };
|
|
191
316
|
}
|
|
192
317
|
|
|
318
|
+
/**
|
|
319
|
+
* 装前 401 探测:对 streamable-http 型 server 发一次 MCP initialize JSON-RPC,
|
|
320
|
+
* 服务器明确回 401/403 就中止安装。索引 auth_mode 会漏标(tec-do 的 mcp.json 是裸 URL、
|
|
321
|
+
* 索引 auth_mode 也为空,装完连接必 401 "Version negotiation failed")——这是漏标鉴权的
|
|
322
|
+
* 最后防线。判定刻意保守:只有 401/403 拦,超时/5xx/网络错误/其他状态一律放行——
|
|
323
|
+
* 探测挂了不能误伤正常连接器,宁可让个别服务器在连接时报错。
|
|
324
|
+
* 返回 { authorized: true }(放行)或 throw(要求授权,中止整个安装)。
|
|
325
|
+
*/
|
|
326
|
+
export async function probeServerAuthorization(serverName, rawServer) {
|
|
327
|
+
const nested = rawServer && typeof rawServer.transport === "object" ? rawServer.transport : rawServer;
|
|
328
|
+
const transport = String(nested?.type ?? "").toLowerCase();
|
|
329
|
+
// 只探测 HTTP 型:stdio 没有可探测的 URL,sse/ws 本来就被准入闸拦下。
|
|
330
|
+
if (transport !== "streamablehttp" && transport !== "streamable-http" && transport !== "http") return { authorized: true, skipped: true };
|
|
331
|
+
const url = String(nested?.url || "").trim();
|
|
332
|
+
if (!url) return { authorized: true, skipped: true };
|
|
333
|
+
const body = JSON.stringify({
|
|
334
|
+
jsonrpc: "2.0",
|
|
335
|
+
id: 1,
|
|
336
|
+
method: "initialize",
|
|
337
|
+
params: {
|
|
338
|
+
protocolVersion: "2025-03-26",
|
|
339
|
+
capabilities: {},
|
|
340
|
+
clientInfo: { name: "dsh-armory", version: "1.0.0" },
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
let response;
|
|
344
|
+
try {
|
|
345
|
+
response = await fetch(url, {
|
|
346
|
+
method: "POST",
|
|
347
|
+
headers: {
|
|
348
|
+
"content-type": "application/json",
|
|
349
|
+
accept: "application/json, text/event-stream",
|
|
350
|
+
// 透传 mcp.json 里该 server 已声明的 headers(例如部分连接器带公共 UA/token 模板)。
|
|
351
|
+
...Object.fromEntries(Object.entries(nested?.headers || {}).map(([key, value]) => [String(key).toLowerCase(), String(value)])),
|
|
352
|
+
"user-agent": USER_AGENT,
|
|
353
|
+
},
|
|
354
|
+
body,
|
|
355
|
+
signal: AbortSignal.timeout(8_000),
|
|
356
|
+
});
|
|
357
|
+
} catch {
|
|
358
|
+
// 超时 / DNS 失败 / 连接拒绝:探测不了就放行,安装后由用户实测。
|
|
359
|
+
return { authorized: true, error: "probe-unreachable" };
|
|
360
|
+
}
|
|
361
|
+
if (response.status === 401 || response.status === 403) {
|
|
362
|
+
// R8:不再把 401 当致命错误。改回传结构化「需授权」,由上层触发浏览器授权流程:
|
|
363
|
+
// 解析 WWW-Authenticate 里的 resource_metadata,交给 lib/mcp-oauth.js 继续走
|
|
364
|
+
// RFC 9728(资源元数据)→ RFC 8414(AS 元数据)→ RFC 7591(动态注册)→ RFC 7636(PKCE)。
|
|
365
|
+
const wwwAuthenticate = String(response.headers.get("www-authenticate") || "");
|
|
366
|
+
return {
|
|
367
|
+
authorized: false,
|
|
368
|
+
needsAuth: true,
|
|
369
|
+
status: response.status,
|
|
370
|
+
url,
|
|
371
|
+
serverName,
|
|
372
|
+
wwwAuthenticate,
|
|
373
|
+
resourceMetadata: extractResourceMetadata(wwwAuthenticate),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
// 200/202/4xx 其他/5xx:都视为「没有明确拒绝匿名握手」,放行。
|
|
377
|
+
return { authorized: true, status: response.status };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** 从 WWW-Authenticate 里取 resource_metadata="..."(RFC 9728 的入口指针)。 */
|
|
381
|
+
function extractResourceMetadata(header) {
|
|
382
|
+
const match = String(header || "").match(/resource_metadata\s*=\s*"([^"]+)"/i)
|
|
383
|
+
|| String(header || "").match(/resource_metadata\s*=\s*([^\s,]+)/i);
|
|
384
|
+
return match ? match[1].trim() : "";
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** 安装前对整份 mcpServers 逐个探测;把「需授权」的 server 聚合成清单回报,绝不中止安装。 */
|
|
388
|
+
export async function probeMcpServersAuthorization(mcpServers) {
|
|
389
|
+
const servers = mcpServers && typeof mcpServers === "object" ? Object.entries(mcpServers) : [];
|
|
390
|
+
const needsAuth = [];
|
|
391
|
+
for (const [serverName, rawServer] of servers) {
|
|
392
|
+
const probe = await probeServerAuthorization(serverName, rawServer);
|
|
393
|
+
if (probe?.needsAuth) needsAuth.push(probe);
|
|
394
|
+
}
|
|
395
|
+
return { ok: true, checked: servers.length, needsAuth };
|
|
396
|
+
}
|
|
397
|
+
|
|
193
398
|
/**
|
|
194
399
|
* 只读磁盘缓存的市场条目计数(供顶栏 chip 常驻显示,绝不联网)。
|
|
195
400
|
* 磁盘上有 connectors-config.zip 就直接解索引计数;没有就返回 total:null,
|