dsh-armory 2.2.0 → 2.3.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 +16 -7
- package/README.md +19 -10
- package/lib/client.js +311 -89
- package/lib/index.js +62 -2
- package/lib/mcp-catalog-en.js +244 -0
- package/lib/mcp-config.js +15 -0
- package/lib/mcp-oauth.js +1 -1
- package/lib/tool-ledger.js +93 -0
- package/lib/whats-new-en.js +416 -0
- package/lib/whats-new.js +29 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { allMcpEntries, entriesForName, projectToolSchema, revisionOf, toolInven
|
|
|
15
15
|
// Skill 看板的卸载落盘点:来源白名单 + 根目录包含性 + 符号链接/根目录校验(lib/skill-remove.js)。
|
|
16
16
|
import { removeSkillFromDisk } from "./skill-remove.js";
|
|
17
17
|
import { WHATS_NEW } from "./whats-new.js";
|
|
18
|
+
import { attachWhatsNewEnglish } from "./whats-new-en.js";
|
|
18
19
|
import { probeMcpServersAuthorization, wbConnectorCatalog, wbConnectorCachedCount, wbConnectorPreview, wbSkillCatalog, wbSkillDetail, wbSkillInstall, wbSkillInstalled, wbSkillUninstall } from "./wb-market.js";
|
|
19
20
|
// T03:多市场注册表(R4)与 codebuddy 插件市场(R5)的宿主侧数据层。
|
|
20
21
|
import { readRegistry, upsertMarketplace } from "./wb-registry.js";
|
|
@@ -961,7 +962,64 @@ async function buildToolTailorReport(ctx, payload) {
|
|
|
961
962
|
}
|
|
962
963
|
}
|
|
963
964
|
const schemas = [...ctx.tools.schemas()].map(projectToolSchema);
|
|
964
|
-
|
|
965
|
+
const report = await scanSessionLedger({ days, mcpServerNames, schemas, disabledServers });
|
|
966
|
+
return attachRegisteredSkills(ctx, report);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* §6.4 技能账本的第二半:把「注册表里存在」与「日志里真的被调用过」合起来。
|
|
971
|
+
*
|
|
972
|
+
* 只看日志能回答「谁被用了」,但回答不了最要紧的那一问——**注册了却一次没被用过的是哪些**。
|
|
973
|
+
* 那一问的答案正是「该不该留」的依据(本机实测 32 个技能里 21 个一次没调用过),而这批名字
|
|
974
|
+
* 只能来自 `ctx.skills` 注册表。scope 解析复用看板那条 `withSkillScope`(默认预设代言),
|
|
975
|
+
* 免得这里另写一套、漏掉预设层的发现范围。
|
|
976
|
+
*
|
|
977
|
+
* 注册表缺席时(老宿主 / 预设层没挂 skill-filesystem)**不伪造 0**:把
|
|
978
|
+
* `skillsRegistryAvailable` 置 false、`skillNeverUsedCount` 置 null,界面据此只说
|
|
979
|
+
* 「日志里出现过的技能」,而不是暗示「其余技能都没被用过」——后者会是一句假话。
|
|
980
|
+
*/
|
|
981
|
+
async function attachRegisteredSkills(ctx, report) {
|
|
982
|
+
const observed = Array.isArray(report?.skills) ? report.skills : [];
|
|
983
|
+
let registered = [];
|
|
984
|
+
try {
|
|
985
|
+
const listed = await withSkillScope(ctx, {}, async (registry, options) => {
|
|
986
|
+
if (typeof registry.list !== "function") return undefined;
|
|
987
|
+
return registry.list(options);
|
|
988
|
+
});
|
|
989
|
+
if (Array.isArray(listed)) {
|
|
990
|
+
registered = listed.filter((skill) => typeof skill?.name === "string" && skill.name !== "");
|
|
991
|
+
}
|
|
992
|
+
} catch {
|
|
993
|
+
// 注册表读不了不挡工具账本:技能那一节退化为「只有日志里出现过的」。
|
|
994
|
+
}
|
|
995
|
+
const available = registered.length > 0;
|
|
996
|
+
const registeredNames = new Set(registered.map((skill) => skill.name));
|
|
997
|
+
const observedNames = new Set(observed.map((skill) => skill.name));
|
|
998
|
+
const merged = available
|
|
999
|
+
? [
|
|
1000
|
+
...observed.map((skill) => ({ ...skill, registered: registeredNames.has(skill.name) })),
|
|
1001
|
+
...registered
|
|
1002
|
+
.filter((skill) => !observedNames.has(skill.name))
|
|
1003
|
+
.map((skill) => ({
|
|
1004
|
+
name: skill.name,
|
|
1005
|
+
calls: 0,
|
|
1006
|
+
errors: 0,
|
|
1007
|
+
errorRate: 0,
|
|
1008
|
+
firstUsedMs: null,
|
|
1009
|
+
lastUsedMs: null,
|
|
1010
|
+
registered: true,
|
|
1011
|
+
})),
|
|
1012
|
+
].sort((a, b) => (b.calls - a.calls) || String(a.name).localeCompare(String(b.name)))
|
|
1013
|
+
: observed.map((skill) => ({ ...skill, registered: null }));
|
|
1014
|
+
return {
|
|
1015
|
+
...report,
|
|
1016
|
+
skills: merged,
|
|
1017
|
+
skillsRegistryAvailable: available,
|
|
1018
|
+
skillRegisteredCount: available ? registered.length : null,
|
|
1019
|
+
// 「一次没被调用」只在注册表可用时才有意义(否则分母本身是未知的)。
|
|
1020
|
+
skillNeverUsedCount: available ? merged.filter((skill) => skill.calls === 0).length : null,
|
|
1021
|
+
skillCount: merged.length,
|
|
1022
|
+
};
|
|
965
1023
|
}
|
|
966
1024
|
|
|
967
1025
|
// 全局 MCP 列表核心(list / getWorkspaceView 共用)。
|
|
@@ -1565,7 +1623,9 @@ let McpManagerGateway = (() => {
|
|
|
1565
1623
|
async whatsNew() {
|
|
1566
1624
|
// 只回数据,不在宿主判定「该不该弹」:「上次看到的版本」是每台浏览器自己的事实
|
|
1567
1625
|
// (localStorage),宿主没有这个视角;判定在客户端(whatsNewFor)。
|
|
1568
|
-
|
|
1626
|
+
// P1:中英字段一起送(text + textEn / where + whereEn),客户端按 locale 选择。
|
|
1627
|
+
// 宿主不读 locale,因为它也可能是老版本、没有 locale 服务;选择只发生在浏览器侧。
|
|
1628
|
+
return { current: pluginVersion(), entries: attachWhatsNewEnglish(WHATS_NEW) };
|
|
1569
1629
|
}
|
|
1570
1630
|
// ── Skill 看板 ────────────────────────────────────────────────────
|
|
1571
1631
|
// 数据真源是 @deepseek-ai/dsh-skill 的 ctx.skills 注册表,本插件只做投影与只读定位,
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// en-US overlay for the generated built-in MCP catalog (lib/mcp-catalog.js).
|
|
2
|
+
//
|
|
3
|
+
// Why this file exists: `mcp-catalog.js` is generated from the upstream verified
|
|
4
|
+
// list (see its header) and is not a UI string table, so its Chinese `summary`,
|
|
5
|
+
// `access`, `authNote`, and category labels cannot go through client `t()`
|
|
6
|
+
// (the i18n dictionary is checked bidirectionally against client.js literals and
|
|
7
|
+
// would report every catalog key as an orphan). The host instead ships both
|
|
8
|
+
// languages with the `builtins` RPC, and the client selects by locale.
|
|
9
|
+
//
|
|
10
|
+
// Maintenance: when a catalog entry is added/removed upstream, the parity test
|
|
11
|
+
// (`test/mcp-catalog-i18n.mjs`) fails until this overlay is updated. Keep the
|
|
12
|
+
// English fields free of CJK so an English panel never leaks Chinese here.
|
|
13
|
+
// Proper nouns (Exa, GitHub, ClickHouse, ...) stay as-is.
|
|
14
|
+
export const CATEGORY_LABELS_EN = {
|
|
15
|
+
search: 'Search',
|
|
16
|
+
dev: 'Developer Tools',
|
|
17
|
+
security: 'Security & Reverse Engineering',
|
|
18
|
+
data: 'Databases & Data',
|
|
19
|
+
ai: 'AI & Knowledge',
|
|
20
|
+
browser: 'Browser Automation',
|
|
21
|
+
cloud: 'Cloud & Infrastructure',
|
|
22
|
+
productivity: 'Productivity',
|
|
23
|
+
files: 'Files & Local',
|
|
24
|
+
comms: 'Communication',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Only display labels containing Chinese need an override; ASCII brand names are
|
|
28
|
+
// already language-neutral. `localizeBuiltinItem` falls back to `label`.
|
|
29
|
+
export const CATALOG_LABELS_EN = {
|
|
30
|
+
'deepwiki-fetch': 'DeepWiki (local fetch)',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const MCP_CATALOG_EN = {
|
|
34
|
+
exa: {
|
|
35
|
+
summary: 'Web search and content reading',
|
|
36
|
+
access: 'Usable without a key (rate-limited); add x-api-key to headers to raise the quota',
|
|
37
|
+
authNote: 'The keyless signal is an empty x-api-key header (rate limit measured in testing); set EXA_API_KEY for a higher quota',
|
|
38
|
+
},
|
|
39
|
+
tavily: {
|
|
40
|
+
summary: 'AI-optimized search and web extraction',
|
|
41
|
+
access: 'Free keyless mode has rate limits (responses are identical with a key); the official free key gives 1,000 credits/month, no credit card required',
|
|
42
|
+
},
|
|
43
|
+
firecrawl: {
|
|
44
|
+
summary: 'Web scraping, search, and document parsing',
|
|
45
|
+
access: 'Usable without a key (daily limit); set an API key for a higher quota. For sites requiring browser login, use /v2/mcp-oauth',
|
|
46
|
+
authNote: 'Sends an empty Bearer token in keyless mode; set FIRECRAWL_API_KEY for keyed mode',
|
|
47
|
+
},
|
|
48
|
+
'brave-search': {
|
|
49
|
+
summary: 'Independent web and local search index',
|
|
50
|
+
access: 'Requires your own Brave Search API key (free tier available; paid beyond it)',
|
|
51
|
+
authNote: 'BRAVE_API_KEY is required; there is no keyless path',
|
|
52
|
+
},
|
|
53
|
+
perplexity: {
|
|
54
|
+
summary: 'Real-time web search and deep research',
|
|
55
|
+
access: 'Requires a paid Perplexity API key (Bearer); no free quota',
|
|
56
|
+
authNote: 'PERPLEXITY_API_KEY is required (Bearer); no free quota',
|
|
57
|
+
},
|
|
58
|
+
'google-maps': {
|
|
59
|
+
summary: 'Google Maps platform developer documentation search',
|
|
60
|
+
access: 'Free during the experimental phase, no authentication required (testing returned 2 documentation-search tools from tools/list)',
|
|
61
|
+
},
|
|
62
|
+
fetch: {
|
|
63
|
+
summary: 'Fetch web pages and convert them to LLM-friendly text',
|
|
64
|
+
access: 'Open source and free; no key required',
|
|
65
|
+
},
|
|
66
|
+
github: {
|
|
67
|
+
summary: 'Repositories, issues, pull requests, and code search',
|
|
68
|
+
access: 'Requires a GitHub account with OAuth authorization; a PAT also works. Available features depend on your plan',
|
|
69
|
+
authNote: 'Skip OAuth: configure GITHUB_PERSONAL_ACCESS_TOKEN (a PAT) and connect directly with Bearer',
|
|
70
|
+
},
|
|
71
|
+
gitlab: {
|
|
72
|
+
summary: 'Projects, issues, and merge request access',
|
|
73
|
+
access: 'Available on Free and higher plans; OAuth dynamic registration, including self-hosted instances | WARNING: static token auth is not officially confirmed (official MCP docs only describe OAuth/DCR; issue 586184 requests PAT support and was answered as not on the near-term roadmap), so authorization may fail in DSH',
|
|
74
|
+
authNote: 'Only OAuth dynamic registration is supported; static tokens are not officially supported, so this host will stall during authorization and stay disconnected after installation',
|
|
75
|
+
},
|
|
76
|
+
sentry: {
|
|
77
|
+
summary: 'Error search, performance analysis, and issue triage',
|
|
78
|
+
access: 'Requires Sentry account OAuth authorization (free-tier accounts work)',
|
|
79
|
+
authNote: 'Official docs only mention OAuth; if you issue SENTRY_AUTH_TOKEN in Sentry settings, Bearer may work (not guaranteed by the docs)',
|
|
80
|
+
},
|
|
81
|
+
git: {
|
|
82
|
+
summary: 'Read and write local Git repositories',
|
|
83
|
+
access: 'Open source and free; operates only on local repositories',
|
|
84
|
+
},
|
|
85
|
+
serena: {
|
|
86
|
+
summary: 'Symbol-level code search and refactoring',
|
|
87
|
+
access: 'Open source and free (MIT), runs locally; install first with uv tool install -p 3.13 serena-agent',
|
|
88
|
+
},
|
|
89
|
+
everything: {
|
|
90
|
+
summary: 'Official reference implementation for connectivity checks',
|
|
91
|
+
access: 'Open source and free; intended for demos and testing',
|
|
92
|
+
},
|
|
93
|
+
'fast-context': {
|
|
94
|
+
summary: 'Windsurf-powered semantic code search',
|
|
95
|
+
access: 'Open source and free; if Windsurf is installed its key is picked up automatically, otherwise set WINDSURF_API_KEY',
|
|
96
|
+
authNote: 'Key is picked up automatically when Windsurf is installed; otherwise set WINDSURF_API_KEY',
|
|
97
|
+
},
|
|
98
|
+
jshook: {
|
|
99
|
+
summary: 'JS analysis and security research: hooks, CDP debugging, deobfuscation',
|
|
100
|
+
access: 'Open source and free (AGPL-3.0), runs locally; requires Node 22.12+. Tool profiles are controlled by MCP_TOOL_PROFILE (search ~3K tokens / workflow / full ~40K+); the default profile is sufficient',
|
|
101
|
+
},
|
|
102
|
+
'ida-pro': {
|
|
103
|
+
summary: 'Drive IDA Pro for reverse engineering',
|
|
104
|
+
access: 'Requires IDA Pro installed locally; the service is fetched on demand, no prior pip install needed',
|
|
105
|
+
},
|
|
106
|
+
'frida-mcp': {
|
|
107
|
+
summary: 'Frida dynamic instrumentation: hook functions and trace calls',
|
|
108
|
+
access: 'Open source and free (MIT), runs locally; requires Frida and the target on this machine. Tested with uvx startup and a successful initialize/tools/list handshake; enumerate_processes returned local processes',
|
|
109
|
+
},
|
|
110
|
+
dbx: {
|
|
111
|
+
summary: 'Multi-database queries (Postgres / MySQL / SQLite, etc.)',
|
|
112
|
+
access: 'Open source and free; native local binary. Data sources are configured in the DBX app',
|
|
113
|
+
},
|
|
114
|
+
postgres: {
|
|
115
|
+
summary: 'Postgres queries, indexes, and health analysis',
|
|
116
|
+
access: 'Open source and free; requires your own database connection string (DATABASE_URI)',
|
|
117
|
+
authNote: 'Set DATABASE_URI (the database connection string, including the password)',
|
|
118
|
+
},
|
|
119
|
+
sqlite: {
|
|
120
|
+
summary: 'Local SQLite queries and data analysis',
|
|
121
|
+
access: 'Open source and free; operates only on local .db files. Note: the official project is archived and receives maintenance fixes only',
|
|
122
|
+
},
|
|
123
|
+
redis: {
|
|
124
|
+
summary: 'Redis key-value, vector, and stream operations',
|
|
125
|
+
access: 'Open source and free (MIT); requires your own Redis instance',
|
|
126
|
+
authNote: 'Set REDIS_HOST / REDIS_PORT; include the password if the instance requires one',
|
|
127
|
+
},
|
|
128
|
+
clickhouse: {
|
|
129
|
+
summary: 'ClickHouse queries and schema exploration',
|
|
130
|
+
access: 'Open source and free, read-only by default; requires your own ClickHouse connection details',
|
|
131
|
+
authNote: 'Set CLICKHOUSE_HOST / PORT / USER and CLICKHOUSE_PASSWORD',
|
|
132
|
+
},
|
|
133
|
+
mongodb: {
|
|
134
|
+
summary: 'MongoDB and Atlas data access',
|
|
135
|
+
access: 'Open source and free (Apache-2.0); requires your own connection string',
|
|
136
|
+
authNote: 'Set MDB_MCP_CONNECTION_STRING (a connection string including the password)',
|
|
137
|
+
},
|
|
138
|
+
supabase: {
|
|
139
|
+
summary: 'Supabase project and database management',
|
|
140
|
+
access: 'Requires Supabase account OAuth authorization; parameters such as ?read_only=true can restrict permissions',
|
|
141
|
+
authNote: 'Official docs specify OAuth; if you have SUPABASE_ACCESS_TOKEN, Bearer may work (not guaranteed by the docs)',
|
|
142
|
+
},
|
|
143
|
+
neon: {
|
|
144
|
+
summary: 'Neon project management and queries',
|
|
145
|
+
access: 'Requires Neon account OAuth or an API key; optional read-only mode and single-project scoping',
|
|
146
|
+
authNote: 'Official recommendation is OAuth; set NEON_API_KEY as a Bearer token to connect directly',
|
|
147
|
+
},
|
|
148
|
+
'deepwiki-fetch': {
|
|
149
|
+
summary: 'Fetch DeepWiki pages and convert them to Markdown',
|
|
150
|
+
access: 'Completely free, no authentication; fetches locally and overlaps with the official remote version, so pick one as needed',
|
|
151
|
+
},
|
|
152
|
+
memory: {
|
|
153
|
+
summary: 'Persistent memory backed by a knowledge graph',
|
|
154
|
+
access: 'Open source and free; data is stored in local files',
|
|
155
|
+
},
|
|
156
|
+
'sequential-thinking': {
|
|
157
|
+
summary: 'Step-by-step reasoning and dynamic problem decomposition',
|
|
158
|
+
access: 'Open source and free; no key required',
|
|
159
|
+
},
|
|
160
|
+
context7: {
|
|
161
|
+
summary: 'Real-time documentation for the latest library versions',
|
|
162
|
+
access: 'Usable without a key; a free API key is available on the website to raise the rate limit',
|
|
163
|
+
},
|
|
164
|
+
deepwiki: {
|
|
165
|
+
summary: 'Repository documentation and Q&A',
|
|
166
|
+
access: 'Completely free, no authentication; accesses public repositories only',
|
|
167
|
+
},
|
|
168
|
+
huggingface: {
|
|
169
|
+
summary: 'Model, dataset, and Spaces search',
|
|
170
|
+
access: 'Basic tools work without a key; sign in to use your configured personal Apps and tools',
|
|
171
|
+
},
|
|
172
|
+
arxiv: {
|
|
173
|
+
summary: 'Paper search and full-text access',
|
|
174
|
+
access: 'Open source and free; no key required',
|
|
175
|
+
},
|
|
176
|
+
'chrome-devtools': {
|
|
177
|
+
summary: 'Drive a real Chrome instance for debugging and performance analysis',
|
|
178
|
+
access: 'Open source and free, runs locally; requires Chrome installed',
|
|
179
|
+
},
|
|
180
|
+
playwright: {
|
|
181
|
+
summary: 'Browser automation based on accessibility snapshots',
|
|
182
|
+
access: 'Open source and free (Apache-2.0), runs locally; requires Node.js 18+',
|
|
183
|
+
},
|
|
184
|
+
'cloudflare-api': {
|
|
185
|
+
summary: 'Call the full Cloudflare API',
|
|
186
|
+
access: 'Requires Cloudflare account OAuth authorization; an API token can also be used as a Bearer token',
|
|
187
|
+
authNote: 'Official recommendation is OAuth; set CLOUDFLARE_API_TOKEN as a Bearer token to connect directly',
|
|
188
|
+
},
|
|
189
|
+
'cloudflare-docs': {
|
|
190
|
+
summary: 'Semantic search over official Cloudflare documentation',
|
|
191
|
+
access: 'Usable without a key; no account required',
|
|
192
|
+
},
|
|
193
|
+
aws: {
|
|
194
|
+
summary: 'Access AWS services through mcp-proxy-for-aws',
|
|
195
|
+
access: 'Open source and free; credentials come from the local AWS configuration (~/.aws) and Bedrock access is required',
|
|
196
|
+
authNote: 'Credentials come from local ~/.aws, so no key is needed here; the account must have Bedrock access',
|
|
197
|
+
},
|
|
198
|
+
kubernetes: {
|
|
199
|
+
summary: 'Cluster resources, pods, and Helm management',
|
|
200
|
+
access: 'Open source and free (Apache-2.0); uses the local kubeconfig',
|
|
201
|
+
authNote: 'Uses local kubeconfig credentials, so no key is needed here; the cluster must be reachable',
|
|
202
|
+
},
|
|
203
|
+
docker: {
|
|
204
|
+
summary: 'Run MCP servers uniformly in isolated containers',
|
|
205
|
+
access: 'Open source and free (MIT); requires Docker Desktop or the docker CLI plugin',
|
|
206
|
+
},
|
|
207
|
+
netlify: {
|
|
208
|
+
summary: 'Site builds, deployments, and the Netlify CLI',
|
|
209
|
+
access: 'Requires Netlify account OAuth authorization | WARNING: static token auth is not officially confirmed (NETLIFY_PERSONAL_ACCESS_TOKEN in the official docs is an environment variable for the local stdio package; the remote endpoint only documents OAuth), so authorization may fail in DSH',
|
|
210
|
+
authNote: 'The remote endpoint only documents OAuth; static tokens are not officially confirmed, so this host will stall during authorization and stay disconnected after installation',
|
|
211
|
+
},
|
|
212
|
+
linear: {
|
|
213
|
+
summary: 'Issue, project, and comment management',
|
|
214
|
+
access: 'Requires Linear account OAuth; read-only access is available at https://mcp.linear.app/mcp/readonly',
|
|
215
|
+
authNote: 'Official docs specify OAuth; LINEAR_API_KEY as a Bearer token is not documented, so it will most likely fail to connect after installation',
|
|
216
|
+
},
|
|
217
|
+
atlassian: {
|
|
218
|
+
summary: 'Jira and Confluence read/write',
|
|
219
|
+
access: 'Free tier available (no credit card); requires Atlassian account OAuth authorization',
|
|
220
|
+
authNote: 'Official docs specify OAuth; ATLASSIAN_API_TOKEN as a Bearer token is not documented, so it will most likely fail to connect after installation',
|
|
221
|
+
},
|
|
222
|
+
airtable: {
|
|
223
|
+
summary: 'Read and write Airtable bases and records',
|
|
224
|
+
access: 'Available on all plans (including free); requires Airtable OAuth authorization',
|
|
225
|
+
authNote: 'Official docs specify OAuth; AIRTABLE_TOKEN as a Bearer token is not documented, so it will most likely fail to connect after installation',
|
|
226
|
+
},
|
|
227
|
+
'microsoft-learn': {
|
|
228
|
+
summary: 'Search official Microsoft documentation and code samples',
|
|
229
|
+
access: 'Completely free; no authentication or account required',
|
|
230
|
+
},
|
|
231
|
+
time: {
|
|
232
|
+
summary: 'Time queries and timezone conversion',
|
|
233
|
+
access: 'Open source and free; purely local computation',
|
|
234
|
+
},
|
|
235
|
+
filesystem: {
|
|
236
|
+
summary: 'File read/write within allowed directories',
|
|
237
|
+
access: 'Open source and free; can access only directories explicitly allowed on the command line',
|
|
238
|
+
},
|
|
239
|
+
slack: {
|
|
240
|
+
summary: 'Search messages, send messages, and manage canvases',
|
|
241
|
+
access: 'Create your own Slack app and complete one OAuth flow to obtain an xoxp- user token, then configure it here; the token can be reused long-term',
|
|
242
|
+
authNote: 'Create a Slack app and complete one OAuth flow to obtain an xoxp- user token; set SLACK_USER_TOKEN to reuse it long-term',
|
|
243
|
+
},
|
|
244
|
+
};
|
package/lib/mcp-config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parseDocument, Scalar, YAMLMap, YAMLSeq, isMap, isSeq } from 'yaml'
|
|
2
2
|
import { isSafeExpression, makeEnvExpression, makeExpression } from './env-expression.js'
|
|
3
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'
|
|
4
5
|
|
|
5
6
|
export const MCP_PLUGIN_NAME = '@deepseek-ai/dsh-mcp-client'
|
|
6
7
|
const JS_TAG = 'tag:yaml.org,2002:js'
|
|
@@ -21,15 +22,24 @@ const UNSUPPORTED_TEMPLATE = '不支持的变量表达式(只允许 ${VAR}、$
|
|
|
21
22
|
// `name`(写进配置的 serverName)取 id,是刻意的:id 一旦发布就不能改,而 serverName
|
|
22
23
|
// 决定工具名 `mcp__<serverName>__<tool>`,让两者同源就不会出现「目录改名导致老配置变孤儿」。
|
|
23
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] || {}
|
|
24
28
|
const server = {
|
|
25
29
|
id: entry.id,
|
|
26
30
|
label: entry.label,
|
|
31
|
+
labelEn: CATALOG_LABELS_EN[entry.id] || entry.label,
|
|
27
32
|
summary: entry.summary,
|
|
33
|
+
summaryEn: en.summary || entry.summary,
|
|
28
34
|
access: entry.access,
|
|
35
|
+
accessEn: en.access || entry.access,
|
|
29
36
|
// OPT-24:`auth` 是给面板判断「当前宿主装不装得上」的展示元数据,不是配置字段——
|
|
30
37
|
// `builtinSpec()` 只挑 MANAGED_CONFIG_FIELDS、`makeEntry()` 只挑固定字段表,两者都
|
|
31
38
|
// 不会带它,所以它和 authNote 落不进用户配置文件,mcp-client 收不到不认识的键。
|
|
32
39
|
auth: entry.auth,
|
|
40
|
+
// authNoteEn 与 authNote 同生同灭:`none` 条目没有说明,补空串会让面板分不清
|
|
41
|
+
// 「没有说明」和「说明是空的」。
|
|
42
|
+
...(entry.authNote !== undefined ? { authNoteEn: en.authNote || entry.authNote } : {}),
|
|
33
43
|
name: entry.id,
|
|
34
44
|
transport: entry.transport,
|
|
35
45
|
}
|
|
@@ -554,13 +564,17 @@ export function builtinMcpCatalog(effectiveServers) {
|
|
|
554
564
|
return {
|
|
555
565
|
id: builtin.id,
|
|
556
566
|
label: builtin.label,
|
|
567
|
+
labelEn: builtin.labelEn || builtin.label,
|
|
557
568
|
summary: builtin.summary,
|
|
569
|
+
summaryEn: builtin.summaryEn || builtin.summary,
|
|
558
570
|
access: builtin.access,
|
|
571
|
+
accessEn: builtin.accessEn || builtin.access,
|
|
559
572
|
// OPT-24:`auth` 走 builtin(BUILTIN_MCP_SERVERS 已投影),面板据此降级提示
|
|
560
573
|
// oauth 条目;`authNote` 只在存在时挂键——`none` 条目根本没有这个字段,补成空串
|
|
561
574
|
// 会让面板分不清「没有说明」和「说明是空的」。
|
|
562
575
|
auth: builtin.auth,
|
|
563
576
|
...(builtin.authNote !== undefined ? { authNote: builtin.authNote } : {}),
|
|
577
|
+
...(builtin.authNoteEn !== undefined ? { authNoteEn: builtin.authNoteEn } : {}),
|
|
564
578
|
name: builtin.name,
|
|
565
579
|
transport: builtin.transport,
|
|
566
580
|
configuration: builtinSpec(builtin),
|
|
@@ -569,6 +583,7 @@ export function builtinMcpCatalog(effectiveServers) {
|
|
|
569
583
|
// —— 市场展示字段(bundle 侧读不到常量,只能这里带过去)——
|
|
570
584
|
category: source.category,
|
|
571
585
|
categoryLabel: MCP_CATEGORY_LABELS[source.category],
|
|
586
|
+
categoryLabelEn: CATEGORY_LABELS_EN[source.category] || MCP_CATEGORY_LABELS[source.category],
|
|
572
587
|
vendor: source.vendor,
|
|
573
588
|
homepage: source.homepage,
|
|
574
589
|
envKeys: source.envKeys || [],
|
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.
|
|
32
|
+
const PLUGIN_VERSION = "2.3.0";
|
|
33
33
|
const USER_AGENT = `dsh-armory/${PLUGIN_VERSION} (+mcp-oauth)`;
|
|
34
34
|
const NET_TIMEOUT_MS = 15_000;
|
|
35
35
|
/** 授权流程整体存活时间:超过就判过期,避免会话表无限增长。 */
|
package/lib/tool-ledger.js
CHANGED
|
@@ -29,6 +29,8 @@ import { setImmediate } from "node:timers";
|
|
|
29
29
|
|
|
30
30
|
const SESSIONS_DIR_SEGMENTS = [".dsh", "sessions"];
|
|
31
31
|
const MCP_PREFIX = "mcp__";
|
|
32
|
+
/** §6.4 宿主里「参数带名字」的内置工具:技能调用都从这里走。 */
|
|
33
|
+
const SKILL_TOOL_NAME = "skill";
|
|
32
34
|
/** 每处理这么多文件让出一次事件循环,避免大库扫描把宿主卡住。 */
|
|
33
35
|
const YIELD_EVERY = 16;
|
|
34
36
|
|
|
@@ -60,6 +62,14 @@ const TEXT_CACHE_MAX_ENTRIES = 32;
|
|
|
60
62
|
*/
|
|
61
63
|
const WIRE_TAX_MAX_KEYS = 64;
|
|
62
64
|
|
|
65
|
+
/**
|
|
66
|
+
* §6.4 `callId → 技能名` 的映射上限。它只在「等结果回来把错误算到技能头上」这一件事上用,
|
|
67
|
+
* 而工具账本本来就为每个工具记着 `seenCallIds`,这里只是同一件事的技能版本。上限的作用与
|
|
68
|
+
* WIRE_TAX_MAX_KEYS 同理:日志长度不可控时不能让它无界增长;超出后停止登记新映射,
|
|
69
|
+
* 已登记的照常配对,未配对的错误数最多偏少(不会偏多)。
|
|
70
|
+
*/
|
|
71
|
+
const SKILL_CALL_MAP_MAX = 20_000;
|
|
72
|
+
|
|
63
73
|
|
|
64
74
|
/** 把字节数格式化成 MB 文案,只用于诊断信息。 */
|
|
65
75
|
function formatMB(bytes) {
|
|
@@ -235,6 +245,33 @@ function emptyToolRecord() {
|
|
|
235
245
|
return { calls: 0, results: 0, errors: 0, seenCallIds: new Set(), firstUsedMs: null, lastUsedMs: null };
|
|
236
246
|
}
|
|
237
247
|
|
|
248
|
+
/** §6.4 技能维度的调用账目:`skill` 工具每调一次,参数里就写着一个技能名。 */
|
|
249
|
+
function emptySkillRecord() {
|
|
250
|
+
return { calls: 0, errors: 0, seenResultIds: new Set(), firstUsedMs: null, lastUsedMs: null };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 从 `skill` 工具的调用参数里取技能名。
|
|
255
|
+
* 参数是**字符串化的 JSON**(`{"name":"grill"}`),解不出来就不是一次可归属的调用:
|
|
256
|
+
* 这类情况必须单独计数(`skillCallsUnparsed`)而不是静默丢弃,否则界面上会表现为
|
|
257
|
+
* 「skill 有 N 次调用、但按技能的表是空的」这种自相矛盾的读数。
|
|
258
|
+
* @param {unknown} rawArguments
|
|
259
|
+
* @returns {string|null}
|
|
260
|
+
*/
|
|
261
|
+
function skillNameOf(rawArguments) {
|
|
262
|
+
if (typeof rawArguments !== "string") return null;
|
|
263
|
+
let parsed;
|
|
264
|
+
try {
|
|
265
|
+
parsed = JSON.parse(rawArguments);
|
|
266
|
+
} catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
const name = parsed?.name;
|
|
270
|
+
if (typeof name !== "string") return null;
|
|
271
|
+
const trimmed = name.trim();
|
|
272
|
+
return trimmed === "" ? null : trimmed;
|
|
273
|
+
}
|
|
274
|
+
|
|
238
275
|
/**
|
|
239
276
|
* 按前缀归属把工具名分类到 MCP 服务器。与 mcp-registry.js 的 toolInventory 同一判定:
|
|
240
277
|
* `mcp__<server>__<tool>`;服务器名自身可能含下划线,取**最长**匹配的归属,匹配到多个
|
|
@@ -295,6 +332,13 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
|
|
|
295
332
|
// 桶只存「窗口内的相对天序号」(0 = 最早一天,windowDays-1 = 今天),
|
|
296
333
|
// 由界面负责渲染;返回绝对日期反而让前端多一次时区换算的机会出错。
|
|
297
334
|
const dayBuckets = new Array(windowDays).fill(0);
|
|
335
|
+
// §6.4 技能维度:`skill` 工具是宿主里唯一「参数里带名字」的内置工具,所以同一个 callId
|
|
336
|
+
// 既属于工具 `skill`,也属于某个具体技能。两本账各记各的,互不覆盖——工具表看「skill 被
|
|
337
|
+
// 调了 59 次」,技能表看「grill 12 次、剩下 20 个一次没有」,两者回答的是不同的问题。
|
|
338
|
+
const skillUsage = new Map(); // skillName -> record
|
|
339
|
+
const skillCallOwner = new Map(); // callId -> skillName(只为把错误算到技能头上)
|
|
340
|
+
let totalSkillCalls = 0;
|
|
341
|
+
let skillCallsUnparsed = 0;
|
|
298
342
|
// §6.1 真实下发税的按天采样。`dayTaxKeys` 每个日桶只存**当天最后一次**请求的指纹(数字与
|
|
299
343
|
// 短串,不持有工具数组引用 —— 365 个日桶各留一份 234 项清单会常驻几十 MB,宿主主进程里不
|
|
300
344
|
// 可接受);`wireTaxByKey` 存每个不同清单的税,只在指纹首次出现时算一次,上限 WIRE_TAX_MAX_KEYS
|
|
@@ -416,10 +460,42 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
|
|
|
416
460
|
if (record.firstUsedMs === null || time < record.firstUsedMs) record.firstUsedMs = time;
|
|
417
461
|
if (record.lastUsedMs === null || time > record.lastUsedMs) record.lastUsedMs = time;
|
|
418
462
|
}
|
|
463
|
+
// §6.4 同一件事的技能版本:`skill` 的参数里带着技能名,按名字再记一笔。
|
|
464
|
+
// 解不出名字的调用单独计数,界面据此说明「有一部分调用无法归属」,
|
|
465
|
+
// 而不是让技能表的合计悄悄小于工具表里的 skill 调用数。
|
|
466
|
+
if (name === SKILL_TOOL_NAME) {
|
|
467
|
+
const skillName = skillNameOf(event.data?.arguments);
|
|
468
|
+
if (skillName === null) {
|
|
469
|
+
skillCallsUnparsed += 1;
|
|
470
|
+
} else {
|
|
471
|
+
let skillRecord = skillUsage.get(skillName);
|
|
472
|
+
if (!skillRecord) {
|
|
473
|
+
skillRecord = emptySkillRecord();
|
|
474
|
+
skillUsage.set(skillName, skillRecord);
|
|
475
|
+
}
|
|
476
|
+
skillRecord.calls += 1;
|
|
477
|
+
totalSkillCalls += 1;
|
|
478
|
+
if (callId && skillCallOwner.size < SKILL_CALL_MAP_MAX) skillCallOwner.set(callId, skillName);
|
|
479
|
+
if (time !== null) {
|
|
480
|
+
if (skillRecord.firstUsedMs === null || time < skillRecord.firstUsedMs) skillRecord.firstUsedMs = time;
|
|
481
|
+
if (skillRecord.lastUsedMs === null || time > skillRecord.lastUsedMs) skillRecord.lastUsedMs = time;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
419
485
|
} else if (event.type === "tool/result") {
|
|
420
486
|
const message = event.data?.message;
|
|
421
487
|
const callId = typeof message?.toolCallId === "string" ? message.toolCallId : null;
|
|
422
488
|
if (!callId) continue;
|
|
489
|
+
// §6.4 技能错误先归账:结果事件只带 callId,工具那本账要靠遍历
|
|
490
|
+
// usage 反查,技能这本账直接查映射,成本更低且不受「调用在窗口外」影响。
|
|
491
|
+
const skillOwner = skillCallOwner.get(callId);
|
|
492
|
+
if (skillOwner !== undefined) {
|
|
493
|
+
const skillRecord = skillUsage.get(skillOwner);
|
|
494
|
+
if (skillRecord && !skillRecord.seenResultIds.has(callId)) {
|
|
495
|
+
skillRecord.seenResultIds.add(callId);
|
|
496
|
+
if (message.isError === true) skillRecord.errors += 1;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
423
499
|
// 与 call 配对:同一 callId 的后续 result(重试/续写)不再重复计。
|
|
424
500
|
let owner = null;
|
|
425
501
|
for (const record of usage.values()) {
|
|
@@ -604,6 +680,23 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
|
|
|
604
680
|
// 没有真实下发记录时为 null —— 界面必须把「不知道」和「差 0」分开呈现。
|
|
605
681
|
wireTaxDivergenceTokens: wireTaxLatest === null ? null : taxTotalTokens - wireTaxLatest.tokens,
|
|
606
682
|
tools,
|
|
683
|
+
// §6.4 技能维度账目。工具表回答「skill 这个工具被调了几次」,这里回答「哪些技能真的
|
|
684
|
+
// 被用过、各用了几次」——两者的合计**不要求相等**:参数解不出名字的调用单独进
|
|
685
|
+
// skillCallsUnparsed,让差异自己显形,而不是被悄悄吞掉。
|
|
686
|
+
// 排序与工具表同口径:调用多的在前,同调用数按名字排,保证两次扫描的先后一致。
|
|
687
|
+
skills: [...skillUsage]
|
|
688
|
+
.map(([name, record]) => ({
|
|
689
|
+
name,
|
|
690
|
+
calls: record.calls,
|
|
691
|
+
errors: record.errors,
|
|
692
|
+
errorRate: record.calls > 0 ? record.errors / record.calls : 0,
|
|
693
|
+
firstUsedMs: record.firstUsedMs,
|
|
694
|
+
lastUsedMs: record.lastUsedMs,
|
|
695
|
+
}))
|
|
696
|
+
.sort((a, b) => (b.calls - a.calls) || String(a.name).localeCompare(String(b.name))),
|
|
697
|
+
skillCalls: totalSkillCalls,
|
|
698
|
+
skillCallsUnparsed,
|
|
699
|
+
skillCount: skillUsage.size,
|
|
607
700
|
};
|
|
608
701
|
}
|
|
609
702
|
|