Skip to content

dsh-mcp-lazyload

Verified

dsh-mcp-lazyload · v0.4.5 · MIT · Web UI

把 MCP 工具从全量常驻改为渐进式披露:全部工具名与一句话常驻(L0 目录),完整参数 schema 按需取(mcp_find detail),执行走 mcp_use,另在左侧栏提供只读状态面板。

Install

dsh plugin add dsh-mcp-lazyload

Confirm the layer applied with dsh --profile default --dump-config — see the install guide.

Source

Tags

Creators

Readme

dsh-mcp-lazyload

把 MCP 工具从「全量常驻上下文」改成「按需加载」,并提供一个左侧栏状态面板。

要解决的问题

DSH 的 MCP 工具是全量常驻的。dsh-mcp-client 把每个 MCP 工具注册进全局工具表后, tools.schemas() 无条件把全部工具的 JSON Schema 投影给模型,每一轮请求都带着。

本机实测(aarch64 Android,207 个 REA 工具):

项目 字节 折算 token 常驻时机
MCP 工具 schema 820,278 ≈240k 每轮
Skill 前置元数据 437 可忽略 每轮
Skill 正文 74,537 ≈20k 仅触发时

上下文窗口 1,048,576 token,光工具定义就占 23%。

同一个产品里 Skill 有真正的渐进披露(437B 常驻 / 74KB 按需),MCP 没有。 本插件就是把 MCP 也改成渐进披露。

测量口径:原始 tools/list 响应是 3,423,371 字节,其中 outputSchema 占 74.8%, 而 DSH 从不外发这个字段。真实成本是 820KB,不要拿 3.4MB 说事。

装完什么变了:四层渐进披露

不是把 MCP 工具"藏起来",而是分层:默认只常驻便宜的那一层, 越贵的细节越往后放,由模型自己决定要不要付这笔钱。

层 内容 何时付 实测体积
L0 常驻目录 每个工具一行:server/tool: 一句话 每轮 251 工具 29.3KB(实测)
L1 mcp_find 命中工具的名字 + 压平截断的描述 调用时 1~3KB
L2 mcp_find detail 单个工具的完整参数 schema 真要填参数时 单条 2KB 级
L3 mcp_use 转发执行结果 执行时 —

L0 是关键,也是唯一必须存在的层。没有它就不叫披露,叫藏起来让人猜: 模型不知道有哪些工具,只能先盲探一轮,而且它不知道自己不知道什么。 DSH 自己的 Skill 就是这样分层的——目录常驻、正文按需。

所以 L0 的取舍不是"列不列",而是每条留多少字。见下方 「catalogDescriptionMax 为什么默认 120」。

L2 有一条硬规矩:detail 一次只披露一个工具的 schema。命中多条时 返回收窄提示而不是把几十份 schema 一次倒出来,否则刚省下的常驻成本当场还回去。

目录随工具面实时变化(text 是函数,不是启动快照),服务器连上、重连、 新增工具都会反映到下一轮提示词;折叠撤销后目录块整个消失, 不会继续声称"这些工具已折叠"而实际全量可见。

两个入口工具

工具 参数 返回
mcp_find query / server / detail / limit 工具名 + 一行描述;detail 收窄到单条时附完整参数
mcp_use server / tool / args 转发执行结果

早期版本还有第三个 mcp_servers(服务器概览)。已删除:L0 目录本来就按 server/tool 列全,工具数在目录首尾也有,它提供的 folded / sample 目录里都能看出来,纯属同一份信息占两处额度。要看服务器粒度用左侧栏面板。

mcp_use 收的是原始名字(server + tool),内部拼 mcp__server__tool。

左侧栏面板

安装后左侧栏出现一行「MCP 懒加载」,点进去是只读状态面板:降幅百分比、 折叠/直连工具数、每个服务器的工具数与样例、以及两个折叠后工具的用法。

面板只是一个观测窗口。开关和逃生口仍然在配置里改,面板不提供编辑, 这样配置只有一份真源,不会出现「面板显示 A、实际跑 B」。

安装

dsh plugin --profile <profile> add dsh-mcp-lazyload

也可以钉版本:add dsh-mcp-lazyload@<version>。

包已发布到 npm,不需要本地路径。装了之后用 dsh plugin --profile <profile> list 可以确认它在列表里。

装完如果 client-hmr 是关的,需要刷新一次浏览器让新的客户端 bundle 生效。 宿主侧的折叠与会话无关,不需要重启。

配置

在 profile 的 cordis.patch.yml 里写。注意 patch 会整体替换目标行的 config, 所以想保留的键要一起重述:

- id: mcp-lazy
  name: 'dsh-mcp-lazyload'
  config:
    enabled: true
    alwaysVisible: []        # 这些服务器的工具保持直连,不折叠
    allowDetail: true        # 是否允许 mcp_find 返回完整参数 schema
    maxFindResults: 40       # mcp_find 单次最多返回条数
    catalog: true              # L0 常驻目录(关掉就退回"全隐藏 + 靠盲探")
    catalogDescriptionMax: 120 # 目录里每条摘要的字符上限;0 = 只列名字
    catalogMaxTools: 600       # 超过这个数就压缩摘要;超过三倍才只留名字
    catalogBudgetPct: 5        # 目录最多占上下文窗口百分之几;0 = 关闭这条约束
    contextWindow: 200000      # 换算上面那个百分比用的窗口大小(token)

alwaysVisible 是必要的逃生口:某个 MCP 只有 3 个工具、且每轮都高频用, 折叠反而多一次往返,就把它加进来。

enabled: false 时行为与未安装完全一致(仍然注册两个入口工具,但不摘任何东西, 目录也不产出)。

relevanceFloor:按相对分数截断,而不是固定条数

默认 0.3:只保留分数不低于「最高分 × 0.3」的结果,0 表示关闭。

为什么不用固定条数,实测数据见下面「相关性下限」一节——简单说, 固定条数对宽查询不够收(33 条里 20 条之后全是噪声)、对窄查询是空切 (只有 15 条时第 16 条本来就不存在)。按比例切则宽窄自适应,实测目标工具零丢失。

catalogBudgetPct:为什么还需要一个百分比

catalogDescriptionMax 是绝对字符数,而"目录会不会挤爆上下文"是相对的。 同样的 29.3KB 目录:

上下文窗口 目录占上下文 是否合规(MCP 规范建议 1%~5%)
200K 4.47% 合规
128K 6.90% 超了

所以配置里多了一层:按 catalogBudgetPct 与 contextWindow 反算每条摘要可用多少字, 再与 catalogDescriptionMax 取最小值(绝对上限仍然兜底)。

实测(251 工具):

200K 窗口 → 平均每行 119 字符,目录占 4.47%(不降档)
128K 窗口 → 平均每行  83 字符,目录占 5.03%(自动降档,工具名一个不丢)

默认 5%,取 MCP 官方 client-best-practices 建议区间的上沿 (Claude Code 默认阈值 10%, Hermes 默认 5%)。

contextWindow 为什么不自动探测:DSH 的 agent 对象不向插件暴露当前模型路由; llm.resolveModelInfo(provider, model) 需要 provider/model,而 systemPrompt 组装时 (assemble(context))只拿得到 scope。与其写一个"看起来智能、实际恒回退默认值"的 探测,不如让它显式可配——换模型时按需改这个值。面板会显示实际生效的上限 (catalog.effectiveDescriptionMax),不是复述你配的 120。

catalogDescriptionMax 为什么默认 120

旧默认是 32,为了把降幅压进 95% 验收线。那个取舍是错的:它切的是成本, 不是信息密度。这批工具的描述中位数 289 字符,硬切 32 只能留下 11%,切出来是这样:

- rea/address_name: Resolve the primary analyzed na…      ← 32 字
- rea/address_name: Resolve the primary analyzed name at a code or data address.   ← 120 字

名词都没说完,模型照样认不出这工具干什么,于是仍需 mcp_find 盲探一次—— L0 存在的意义(免去盲探)根本没实现,省下来的钱没买到任何东西。

现在改成按句子边界截断(brief():优先在 .!?。!? 处收,退不到句子 边界才按词界切,并保护 {{...}} 占位不被切半)。同样的字符预算能保住首句语义。

本机真实 251 个 MCP 工具(rea 207 / github 26 / filesystem 14 / graphflow 4), 全量 schema 782KB:

摘要上限 目录体积 降幅
0(只列名字) 6.7KB 99.2%
32(旧默认) 13.8KB 98.2%
120(现默认) 29.3KB 96.2%
160 42.3KB 95.0%

多花约 15KB,换每个工具都能直接读出用途。仍高于 95% 线。 实测首句长度均值 93 字符(中位 98),所以 120 字档基本完整保住首句, 长尾才牺牲参数细节——那些本来就该 mcp_find detail 现场取。

摘要上限为 0 时目录会自动切换成紧凑格式:按服务器分组、工具名逗号连接, 同样信息量比逐行 - server/tool 省掉一半以上骨架开销。

mcp_find 的检索:BM25

早期用 haystack.includes(query) 整串子串匹配。251 个工具下这几乎不可用—— "create pull request" 要求整串连续出现,而描述写成 Create a new pull request…, 多个 a 就零命中。实测 8 个典型查询里 6 个零命中。

现在换 BM25(k1=1.2,b=0.75),并对工具名给 2 倍词频权重(查 analyze_function 时名字命中的可信度远高于描述里顺带提到 analyze)。

实测同一批真实工具(251 个):

查询 旧 includes BM25 BM25 首条
create pull request 0 42 github/create_pull_request
analyze function 0 19 rea/analyze_function
search bytes 0 29 rea/search_bytes
decompile procedure 0 25 rea/batch_decompile
抓包 0 21 rea/inspect_pcap_capture
字节签名 0 39 rea/search_bytes

中文查询怎么办

本机 251 个工具的描述 100% 是英文(实测 0 条含汉字),所以中文查询靠分词 永远切不出交集:安装应用 切出的是 安装/装应/应用,与英文描述交集为空, BM25 零命中,子串回退搜的也是英文全文,同样零命中——中文用户拿到的就是空结果。

解法是 ZH_HINTS 词典:把中文意图映射成英文关键词,追加进查询词元。 第一版只有 34 条(抓包、字节、签名…),漏了「安装」「应用」这类最常用的词, 实测 安装应用 返回 0 条。现在扩到约 130 条,按真实语料覆盖到:

类别 例子
系统与设备 安装/卸载/应用/包名/设备/截图/点击/日志/进程
文件与包 文件/目录/拉取/推送/导出/打包/解包
逆向与二进制 反编译/反汇编/伪代码/函数/符号/地址/字节/加固/脱壳
调用与数据流 调用/调用链/调用者/交叉引用/追踪
运行时与调试 调试/断点/附加/会话/动态/模拟
网络与网页 抓包/请求/响应/接口/浏览器/证书
安卓专项 安装包/清单/资源/布局/控件/界面
安全与漏洞 漏洞/越权/绕过/壳检测

实测(251 个真实工具、12 个中文查询):前 5 命中率 11/12,安装应用 首条就是 install_adb_package。唯一没进前 5 的「列出设备」,首条是 list_adb_features——它其实是对的,是我的期望值写错了。

右侧英文词必须能在工具名或描述里真实命中,否则加了等于没加;测试会校验 每个右侧词在语料中确实存在。

仍然没有接翻译 API:多一次网络往返、把工具检索变成可用性依赖, 而词表法已经覆盖了这几类工程的绝大多数意图。长尾场景(比如中英混写、 口语化描述)仍要模型自己把意图转成英文关键词,这点写在 mcp_find 的描述里。

相关性下限:不再固定返回 N 条

固定条数切法对宽窄两类查询都不合适。实测 251 个工具上:

  • create pull request 全量 33 条,第 20 名之后全是只蹭到 create 一个词 的无关工具(pull_adb_file、push_adb_file);
  • hook android class 全量只有 15 条,第 16 条本来就不存在。

固定 40 对前者不够收、对后者是空切。现在按相对分数截断:只保留 score >= 最高分 × relevanceFloor(默认 0.3)的结果。

查询 全量 保留下限后 目标工具排名
create pull request 33 14 1
抓包 28 11 1
字节签名 39 27 1
hook android class 15 11 1
analyze function 19 15 1

实测 23 个查询:平均返回条数 28.2 → 13.8(削掉 51%), 目标工具 23/23 全部保留,前 5 名命中率不变。

宽查询自动收窄、窄查询原样不动,不需要模型自己判断"结果碎不碎"。 被砍掉时返回值里带一句说明(另有 N 条低于相关性下限…), 因为不告诉它的话,模型会把"只返回 14 条"误解成"库里只有 14 条相关工具"。 想要冷门工具时把 limit 调大或换更准的关键词。

设 relevanceFloor: 0 可完全关闭,退回旧行为。

每条结果带 matchedTerms

返回给模型的每条结果附带 matchedTerms——本次查询命中了这条的哪些词。

只蹭到 create 一个泛词 vs 同时命中 search+bytes 两个区分性强的词, 可信度完全不同。模型看得到命中词,就能判断这条结果靠不靠得住,不用盲信排序。

实测 抓包 的首条 matchedTerms 是 capture,pcap,packet, search bytes 的是 search,bytes。

调研过但没采用的方案

方案 结论
语义向量检索(embedding) 环境不具备:无 onnxruntime / transformers.js,可用内存 2.3GB。且每次搜索从微秒级变成秒级,与"功耗均衡"的诉求相反
工具名 bigram(pull_request 作为一个概念) 实测无收益:bigram 权重 0→6 时前 8 条顺序完全不变,Top1 一直 22/23。工具名已经是强信号,bigram 只是同比例放大
Porter 词干化 实测有害:Top1 从 19/20 降到 16/20。procedure→procedur 之类会与描述里的其他词撞车
IDF 加权覆盖率硬门槛 实测有害:中文查询被直接砍成 0 条(安装应用 的覆盖率只有 0.19,卡在 0.2 门槛下)
短语邻近加分 实测微负:Top1 从 19 降到 18,batch_decompile 因短语邻近反超了正确的工具

结论:BM25 在这批高结构化的工具名语料上已经接近上限(Top1 22/23, 唯一未命中的 decompile procedure 让 batch_decompile 排第一本身也不算错)。 真正的可改进点在长尾噪声(固定条数)和中文覆盖(词表), 不是排序算法本身——所以这两项已实现,而没有为了"更强算法"引入更重的依赖。

两条必要的短路

精确名字:查询本身是某个工具的全名 / 原始名 / server/tool 时只回那一条。 不做这个的话,mcp__rea__address_name 会被分词成 mcp/rea/address/name, 凡描述含 address 的工具都得分——实测返回 251 条,用户给了精确名字却得到全库。

子串回退:BM25 零命中时退回原文 includes。这不是保险丝,是必需路径: 查询词元全被停用词过滤(the)时 BM25 返回 undefined; OpenClaw 与 Hermes 也都保留同类回退, 理由是防零 IDF 退化。

机制

用官方 API ctx.tools.restrict({ deny }) 摘掉 mcp__*:

restrict → layer.restrictions.append
  → view(scope).visible 过滤(admits() 命中 deny 即跳过)
  → schemas(scope) 只投影 visible
  → 请求体 tools 数组里不再有这些工具

这是机制层真摘除,不是提示词层面的糊弄。

restrict 会影响执行(原「坑 8」结论是错的)

早期文档写过"restrict 只影响可见性、不影响执行,所以转发不需要绕过手段"。 这是错的,而且方向反了,照它写代码会得到必然失败的转发。

真实宿主 dsh-agent-loop/lib/index.js:778:

this.scope = createScope(loopCtx, this)   // ← scope key 就是 agent 对象本身

于是 view(agent) 会应用本插件刚下的 deny,get(name, agent) 得 undefined, dispatchToolBody 里 resolveExecution(name, exec.agent, ...) 抛 ToolNotFoundError → UNKNOWN_TOOL。实测(scope key = agent 对象):

view(agent)          → []
get(name, agent)     → undefined
execute(带 agent)    → isError=true, code=UNKNOWN_TOOL
execute(不带 agent)  → isError=false, 成功

这个结论极易搞反:若 scope key 用 {agentId:"x"} 这种独立对象、另造一个 agent, 则 scopeParents.get(agent) 查不到链 → 不应用 restriction → 看起来"带 agent 成功"。 我们曾在测试里用错 key,导致这条回退分支一次都测不到,还差点把它当死代码删掉。

因此 mcp_use 的转发是:先带 agent 试(权限 guard 照常生效), 仅当结果是 UNKNOWN_TOOL 且该名字在全局视图里确实存在时,才回退到不带 agent 再试。 test/real-runtime.mjs 对这两段都有断言: "裸 execute(带 agent) 按预期失败 UNKNOWN_TOOL" + "mcp_use 回退后仍可执行"。

实现必须守住的坑

这些是实际运行验证出来的,静态读代码看不出来。

  1. schema 每个 object 都要显式写 additionalProperties,根节点和嵌套都一样,缺一个 defineTool 就抛 JsonSchemaError。
  2. 自由值必须用 type: 'json',写 {} 会被拒。编译后它变成 annotation-only 节点。
  3. tools.execute() 的 signal 是必填。省略会在 prepareExecution 阶段抛 Cannot read properties of undefined (reading 'aborted')。
  4. agents 不能写进 inject 数组。服务缺失时 Cordis 会挂起整个插件,apply 一次都不执行,表现是"装了但没反应",日志里也看不出原因。用 ctx.get('agents') 安全查询。
  5. restrict 只能在 agent 作用域调,插件根 ctx 没有 kScope symbol,直接抛错。且 agent.ctx 也要用 get() 取服务。
  6. restrict 校验名字必须已存在(restrictableNames)。MCP 是异步注册的,必须监听 tools/change 重算。
  7. restrict 是追加语义(restrictions.append)。每次重算前必须先 dispose 上一轮的,否则越叠越多。同时要做签名去重,这既是性能也是打断 tools/change → 重算 → tools/change 递归的关键。
  8. restrict 拒绝空过滤器:{ deny: [] } 会抛 tools.restrict({}) is a no-op。所以没有可折叠工具时直接跳过,不要调。
  9. 插件自己的入口工具名绝不能进 deny,否则会把自己隐藏掉。

已知限制

PTC 模式下折叠不可用。 比早期文档写的更严重——不是"降级失效",是整条链路断。 tools.mode = 'ptc' 时实测:

调用方式 结果
模型直调 mcp_use UNKNOWN_TOOL(PTC 只允许直调 run_code)
模型直调 mcp__x__y UNKNOWN_TOOL
run_code 内调 mcp_use 进得来,但转发时 collapses() 判定 !nested && mode==='ptc' → UNKNOWN_TOOL
run_code 内调 + 不带 agent 能通,但绕过了 agent 作用域的权限 guard

最后一行能"修好",但那等于静默关掉沙箱/审批。本插件不做这种绕过,宁可给出 明确报错:"在 PTC 模式下无法转发……把 tools 的 mode 改回 native"。

DSH 默认模式是 native,不受影响。

验收

三份测试,全部不需要 DSH 在跑:

npm test                                  # 离线 7 份一起跑
npm run test:live                         # 另 2 份需要真 MCP 服务器在运行

node test/contract.test.mjs    # 协议与行为契约(仿真 ToolRuntime)
node test/disclosure.mjs       # L0→L3 分层披露逐层验证
node test/prompt.mjs           # 提示词段落挂载与开关
node test/honest-metrics.mjs   # 面板数字诚实性(专抓"报得好看但没生效")
node test/scope.mjs            # 多 agent 作用域与去重
node test/scale.mjs            # 真实量级下的降幅(210 工具 / 510KB)
node test/real-runtime.mjs     # 真实 dsh-tools ToolRuntime(含 restrict 行为断言)
node test/live-mcp.mjs         # 连真 filesystem MCP,全链路
node test/scale-live.mjs       # 连真 MCP,225 工具 / 379KB
node test/budget-and-search.mjs # 上下文预算降档 + BM25 检索

指标诚实性(test/honest-metrics.mjs)

面板报的数字必须反映此刻的真实状态,不是插件的意图。这条测试专门抓 "报得好看但没生效",它抓出过三个真 bug:

bug 现象 修法
折叠未生效报 100% 降幅 无活跃 agent 时目录为空 → mcpAfter=0 未生效时 mcpAfter = mcpBefore,降幅 0
folded / catalog.tools 按名字数 折叠没生效也报 251 已折叠 按 enabled && foldingActive() 判定
测量缓存跨状态复用 折叠刚生效,面板 5 秒内仍报 0% 缓存键加上 active

判据是四个场景:无 agent(必须全 0)、有 agent(必须真降)、dispose 后 (必须退回 0)、enabled:false(必须与未安装一致)。降幅为 0 不是故障—— 它说明此刻确实没省,这比虚报 100% 有用得多。

真实 MCP 服务器实测(test/live-mcp.mjs)

连本机配置里那个真的 filesystem MCP,走完整链路(握手 → 真工具注册 → 折叠 → 真 systemPrompt.assemble()):

14 个真工具,原始常驻 8,281 字节
→ schema 871 + L0 目录 1,647 = 2,518 字节,降幅 69.59%

69.59% 不是缺陷,是固定开销在小工具集上的摊销:入口工具的 schema 是 871 字节,占原始的 10.5%,光这一项就把降幅上限压到 89.5%;14 个工具摊不平它。 边际数字才是关键:

每工具 schema 592 字节 vs 目录 118 字节 → 折叠对每一个工具都是净收益
摊平固定开销所需工具数 ≈ 2 个

这里刻意不设总量百分比阈值。 早先有一条"降幅 >70%",在描述上限 32 字时 刚好贴着过;改成按句截断的 120 字后,目录从 1225 涨到 1647 字节,降幅落到 69.59%,差 34 字节就判失败。算一下就知道这条线在要求什么: 70% 留给目录的预算是 (1-0.7)×8281 - 871 = 1613 字节,摊到 14 个工具是 115 字节/工具,而 120 字描述实测 118 字节/工具——它实际是在要求把描述缩回 32 字。 用一个小分母上没有意义的百分比去倒逼实现牺牲模型识别力,是本末倒置。

所以小工具集看三件事,都不靠百分比: 绝对省下多少(本机 5763 字节)、边际是否划算、固定开销是否喧宾夺主。 总量 95% 的断言属于大工具面,由下面的 scale.mjs(210 工具 / 510KB) 和 scale-live.mjs(225 真工具 / 379KB,实测 96%)承担。

真实量级实测(test/scale.mjs,真实 ToolRuntime)

工具数 209 → 7
常驻 schema 543,274 → 1,494 字节
L0 目录 18,009 字节(206 行)
常驻总量 543,274 → 19,503 字节
降幅 96.41%  (折算 token 约 159,786 → 5,736)

注意这里的 96.41% 已经把 L0 目录算进分母对面的成本。 早先报 99.68% 是因为当时只算 schema 那一半、把目录藏在括号外—— 那是把披露层的成本藏起来,数字好看但不可复核。

报告里本机 207 个 REA 工具是 820,278 字节;这里造的是同等量级(200 个 MCP 工具、 每个 30 个参数)。按同比例折算,820KB 会降到约 2.6KB。

报告第 7 节 8 条标准的落点

# 标准 落在哪份测试 实测
1 mcp__* 在可见集里为 0 real-runtime / scale 0 个
2 非 MCP 工具未受影响 real-runtime todo_write 等全部保留
3 折叠后仍可 mcp_use 调用,结果与直连一致 real-runtime 直连==转发
4 新增/重连后新工具自动折叠 real-runtime 动态注册后即不可见
5 enabled: false 行为与未安装一致 contract restrict 调用 0 次
6 disposer 能正确撤销 real-runtime agent 可见 MCP 0 → 4
7 常驻体积降幅 ≥95% scale / scale-live 96.41% / 96%
8 插件异常不拖垮宿主 real-runtime 篡改 restrict 抛错,apply 不崩、入口工具仍在

contract.test.mjs 的覆盖:

  • 客户端 bundle 按 __ModuleLoader__ 协议执行,注册了 sidebar.panellist 行与同 key 的 main 面板
  • mcp__* 从可见集消失,非 MCP 工具与两个入口工具保留
  • 被 deny 的工具仍可通过 mcp_use 转发执行
  • 新增 MCP 工具自动折叠(tools/change 增量重算)
  • alwaysVisible 逃生口
  • enabled: false 时一次 restrict 都不调
  • 多 agent 各自折叠,签名未变不重复 restrict
  • 两个入口工具的 schema 合规(additionalProperties 齐全、自由值走 json)
  • 体积降幅 ≥95%

需要本地链接 peer 依赖才能跑(dsh-tools / schemastery 等):

D=<dsh 安装目录>/node_modules/@deepseek-ai
mkdir -p node_modules/@deepseek-ai
for p in schemastery dsh-tools cordis dsh-agent; do
  ln -sfn "$D/$p" "node_modules/@deepseek-ai/$p"
done

许可

MIT