让每个项目声明自己的 MCP 服务器,仅对该项目会话生效
29.9/100
综合分
0
GitHub 星标
—
npm 周下载
—/5
用户评分
数据截至 2026/10/6 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add borgez/dsh-project-mcp该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)
状态速览
判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态信任档位:需留意
图例:✓ 通过! 需注意— 未判定 / 无数据· 「未判定」= 本站尚无数据,不代表插件有问题
档位由哪些信号合成
本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。
依赖的 DSH / Cordis 模块
@deepseek-ai/schemastery@deepseek-ai/cordis@deepseek-ai/dsh-mcp-client@deepseek-ai/dsh-scope@deepseek-ai/dsh-tools给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个
功能亮点
- 按项目声明MCP服务器
- 仅对项目会话生效
为 DeepSeek Harness 提供按项目划分的 MCP 服务器——项目声明的 MCP 挂载,只作用于在该项目里工作的会话
README
由 DeepSeek 最新模型翻译生成dsh-project-mcp
为 DeepSeek Harness 提供按项目挂载的 MCP 服务器。
DSH 通常按配置档配置 MCP(cordis.patch.yml),所以进程里每个会话看到的服务器都一样。这个插件补上了缺失的那一层:项目在自己的文件里声明 MCP 服务器,这些服务器只挂载给在该项目里工作的会话。
从哪些文件读取项目配置是一个设置项:localFiles 列出相对于项目根目录的文档,globalFiles 列出在它们之前读取的文档。默认只读一个文档 —— <project>/.dsh/mcp.json —— 不读任何全局文档,所以项目挂载的就是它声明的那些。要读 Kimi Code 项目(或别的工具的文件),改一行配置就行,永远不会变成隐式的第二个来源。
展开完整文档(README 剩余 1 节)
dsh-project-mcp
为 DeepSeek Harness 提供按项目挂载的 MCP 服务器。
DSH 通常按配置档配置 MCP(cordis.patch.yml),所以进程里每个会话看到的服务器都一样。这个插件补上了缺失的那一层:项目在自己的文件里声明 MCP 服务器,这些服务器只挂载给在该项目里工作的会话。
从哪些文件读取项目配置是一个设置项:localFiles 列出相对于项目根目录的文档,globalFiles 列出在它们之前读取的文档。默认只读一个文档 —— <project>/.dsh/mcp.json —— 不读任何全局文档,所以项目挂载的就是它声明的那些。要读 Kimi Code 项目(或别的工具的文件),改一行配置就行,永远不会变成隐式的第二个来源。
// <project>/.dsh/mcp.json
{
"mcpServers": {
"rider": {
"type": "http",
"url": "http://127.0.0.1:64343/stream",
"headers": { "IJ_MCP_SERVER_PROJECT_PATH": "/abs/path/to/project/src/App" }
},
"playwright": { "command": "npx", "args": ["-y", "@playwright/mcp@latest"] },
"tracker": {
"command": "uvx",
"args": ["yandex-tracker-mcp@latest"],
"env": { "TRACKER_TOKEN": "${YANDEX_TOKEN}" }
}
}
}
目录
- 安装
- 来源与优先级 — 条目映射、变量
- 配置 — profileWins
- 运行时状态
- 用量计数器
- 工具策略:模式与固定
- 工具激活与 mcp_search_tools
- 系统提示词里的项目指引
- 懒挂载与空闲释放
- 侧边栏面板 — 设置页、写入条目
- 诊断
- 资源行为
- 运行时
- 验证
- 发布
- 限制
- 自用实践
实现说明:工作原理 · 打包格式 · 目录结构
安装
pnpm install && pnpm check # typecheck + build + tests under the coverage gate + resource audit
pnpm coverage # the same suite with the coverage report only
pnpm pack # -> dsh-project-mcp-<version>.tgz (builds first, via prepack)
mv dsh-project-mcp-<version>.tgz ~/.dsh/packages/
dsh plugin --profile web add file:$HOME/.dsh/packages/dsh-project-mcp-<version>.tgz
pnpm check 会带 --coverage 跑整套测试,vitest.config.ts 对 src/ 的四项读数都卡在 80%:新加的接口没配测试,跑 pnpm check 就会失败,不用等到后面去看报告才发现。
Bun 也能当包管理器和运行器用:
bun install && bun run build
bun test tests # same suite through the Bun test runner
dsh plugin add 会把 bundle 追加到 dsh.profile.bundles,并同步 ~/.dsh/profiles/web/package.json;bundle 自带的 cordis.patch.yml 负责挂载这一行。之后重启 web GUI。想免去反复打包,可以用 link:/abs/path/to/dsh-project-mcp 安装,然后重新构建(pnpm build)。
来源与优先级
每个会话按项目读取,优先级从低到高——后面的文档会覆盖前面同名 serverName 的条目。顺序就是先 globalFiles 列表,再 localFiles 列表:
| # | 文档 | 设置 |
| 1 … | 本次部署指定的全局文档 | globalFiles,默认 []——不读取任何文档 |
| … n | 项目自己的文档,按列表顺序 | localFiles,默认 ['.dsh/mcp.json'] |
读取 spec 的方式和 shell 一样:~/… 就是 $HOME/…,$DSH_HOME/… 是插件自己的 home,绝对路径就是它本身,其他路径相对于所属的基准目录——localFiles 的基准是项目根目录,globalFiles 的基准是 $HOME。所以 localFiles: ['.kimi-code/mcp.json', '.dsh/mcp.json'] 会先读 Kimi Code 项目的文件,再让 DSH 的文件覆盖它;而 globalFiles: ['~/.dsh/mcp.json'] 会加一份机器级文档,排在两者下面。
文档可以是 { "mcpServers": { ... } }、{ "servers": { ... } },或者直接是 name → entry 映射。未知字段忽略;某一行出错不会阻塞文档其余部分——它会以 error 行出现。
条目映射
| 输入 | 结果 |
| command / args / env / cwd | transport: 'stdio',cwd 默认是项目根目录 |
| url / headers | transport: 'streamable-http' |
| type: 'stdio' \| 'http' \| 'streamable-http' \| 'sse' | 显式指定 transport;否则从 url 推断 |
| enabled: false / disabled: true | 该行仍以 disabled 显示,但永不挂载 |
| serverName | mcpServers 的键;必须匹配 [A-Za-z0-9_-]{1,32},否则会被 slug 化并给出警告 |
| "${VAR}" | 项目自己的 .env 文件 → process.env.VAR → 凭据文件,都没有则条目报错 |
| "${input:NAME}" | 插件 inputs.NAME → 与 ${VAR} 相同的查找链 |
| 无效 JSON / 坏行 | 带原因的 error 行;同级条目仍会挂载 |
${...} 引用会在 command、args、env、cwd、url 和 headers 中展开。密钥不要写进项目文档:它们放在 process.env、项目自己的 .env 文件,或 ~/.dsh/.credentials.yaml 里。
变量
引用需要的值按固定顺序查找,项目本地优先:
| # | 来源 | 开关 |
| 1 | <project>/.dsh/.env | envFiles(默认 true) |
| 2 | <project>/.env | envFiles(默认 true) |
| 3 | process.env | — |
| 4 | 凭据文件中的 KEY | credentialsFile(默认 $DSH_HOME/.credentials.yaml) |
插件 inputs 的优先级高于以上四项,作用于 ${input:NAME}。项目本地文件是普通 dotenv 文档——每行一个 KEY=value,可选 export ,单引号或双引号,# 注释可独占一行或跟在空白之后——空值视为未设置,所以空条目无法遮蔽真实值。
两种文档写法都支持:${VAR}(手写 mcp.json 通常用这种)和 ${input:VAR}(VS Code 的写法)。每个文档只在其 mtime/size 变化时重新读取,四个来源中任意一个变化都会重新展开引用并重新挂载受影响的服务器,无需改动 mcp.json。这些来源中任意一个已应答的键,编辑器都不会写回:它在面板中保持掩码,文件中声明的 ${...} 文本原样保留。envFiles: false 会把链路收窄回 process.env + 凭据。把 dotenv 文档排除在版本控制之外——插件只读取它们,但提交的 .env 就是提交的密钥。
配置
插件配置是 cordis.patch.yml 中的一行(显示默认值):
- id: project-mcp
config:
enabled: true # master switch
localFiles: ['.dsh/mcp.json'] # project documents, lowest priority first
globalFiles: [] # documents read before a project's own; [] reads none
envFiles: true # read <project>/.env and <project>/.dsh/.env for ${...}
inputs: {} # explicit ${input:NAME} values, e.g. COVERAGE_MCP_ALLOWED_ROOT: /repo
toolCallTimeoutMs: 60000
failOnStartupError: false # true = a dead server fails its mount instead of reconnecting
connectTimeoutMs: 60000 # a mount with no tool after this window becomes an error row; 0 disables
lazy: true # mount a session's project on its first turn, not at session creation
idleTimeoutMs: 300000 # release a session's mounts after this much inactivity; 0 disables
activationWaitMs: 2000 # how long the first step of a turn may wait for a pending mount; 0 never waits
profileWins: true # a profile-level serverName wins; the project row becomes conflict
watch: true # watch project documents for edits
debounceMs: 300
rescanIntervalMs: 10000
credentialsFile: '' # default: $DSH_HOME/.credentials.yaml
allowGlobalWrite: false # let the editor write the documents of globalFiles
activationEnabled: true # offer MCP tools through mcp_search_tools + activation
activationSeeded: 8 # counter-seeded tools that are always offered
activationMinCalls: 5 # calls one tool needs before it is seeded
toolIdleMs: 1800000 # drop a session-activated tool after this long without a call; 0 disables
guidanceEnabled: true # one short project-MCP guidance section in the session system prompt
activationAutoLimit: 12 # 任务上下文层级一次最多提供几个名称;0 表示禁用该层级 activationAutoStickySteps: 2 # 上下文提议额外保持提供的用户消息条数 activationToolBudgetChars: 40000 # 序列化后的 MCP 接口超过此长度就延迟工具;0 表示始终延迟
projectMarkers: ['.git', '.dsh', '.kimi-code', 'package.json']
fileMarkers: ['.sln', '.slnx', '.csproj']
实时编辑
在 DSH ≥ 0.1.7 上,下面这些键是易变的:宿主把它们作为配置表单提供,编辑后直接提交到运行中的插件,无需重启,运行时会在加载器的 loader/volatile-update 事件里读取新值。其余所有键 —— enabled、localFiles、globalFiles、projectMarkers、fileMarkers、inputs、credentialsFile、watch、rescanIntervalMs、failOnStartupError —— 都是结构性的,仍需重新挂载(重启或重载配置)才能生效。旧版宿主只会交出普通值,启动路径没有任何变化。
易变的键:activationEnabled、activationSeeded、activationMinCalls、toolIdleMs、guidanceEnabled、activationAutoLimit、activationAutoStickySteps、activationToolBudgetChars、allowGlobalWrite、envFiles、lazy、localPrefix、profileWins、connectTimeoutMs、toolCallTimeoutMs、idleTimeoutMs、debounceMs、activationWaitMs。
profileWins
- true(默认)—— 如果某个项目条目的 serverName 已经在配置档里生效,就不挂载它;该行显示 conflict,并提示移除配置档里的对应条目。安全:不会产生重复进程,也不会发生遮蔽。
- false —— 项目实例也会挂载。更近的作用域会为该会话遮蔽配置档的工具,因此项目自己的配置(例如项目专用的 IJ_MCP_SERVER_PROJECT_PATH)胜出。代价是:只要该项目的某个会话持有它,每个服务器就多一个服务器进程/连接。
运行时状态
插件发布 projectMcp 服务(snapshot() 读取,save() 写回一个条目,setPin() / setPolicy() 写入项目的工具策略):
ctx.get('projectMcp').snapshot()
// { ready, watchedFiles,
// projects: [{ projectRoot, policy, sessionIds, rows, issues, sessions, usage }] }
// row.status: 'active' | 'connecting' | 'idle' | 'disabled' | 'conflict' | 'error'
// row.entry: the parsed body the editor starts from; row.writeScope / documentRevision
// sessions: [{ id, rows, issues }] — the same rows split per session // policy: { mode, pins } — the project's tool policy, defaulted until it is changed // usage: { [serverName]: ServerUsage } — absent until something was counted
ctx.get('projectMcp').syncNow(root?) // reconcile and await it; root scopes it to one project
ctx.get('projectMcp').syncSoon(root?) // same pass, answered at once — the panel's Sync
ctx.get('projectMcp').retry(root?) // drop the failed mounts, then re-mount them in the background
ctx.get('projectMcp').subscribe(change => …) // push:先立刻推当前 SnapshotChange,之后每次变更推一次
// SnapshotChange: { revision, snapshot } —— 每一帧都带完整快照
ctx.get('projectMcp').save({ projectRoot, server, document, revision, entry })
// { ok: true, snapshot } | { ok: false, code: 'invalid' | 'blocked' | 'conflict' | 'not-found' | 'failed' }
ctx.get('projectMcp').setPin({ projectRoot, tool, pinned })
ctx.get('projectMcp').setPolicy({ projectRoot, mode })
// { ok: true, snapshot } | { ok: false, code: 'invalid' | 'not-found' | 'failed' }
projects[].rows 是该项目的各个会话合并后的结果,按文档顺序排列。会话各自独立挂载,所以当它们对同一个 server 的判断不一致时,以最可操作的状态为准 —— error > active > connecting > conflict > idle > disabled —— 一个正在运行或已经报错的 server,绝不会因为先注册的会话还没轮到就被报成 idle。projects[].sessions 携带同样的数据,但按会话拆分,正是它说明了哪个会话持有挂载。
用量计数器
宿主侧会统计本插件挂载的 server 所收到的每一次工具调用,按项目根目录和 serverName 归类,所以项目的数字比产生它们的会话活得更久:
ctx.get('projectMcp').snapshot().projects[0].usage
// { alpha: { calls: 12, errors: 1, tools: { search: 9, fetch: 3 },
// lastUsedAt: '2024-05-06T07:08:09.000Z' } }
- calls 统计每一次派发的调用,errors 统计结果带错误的那些,lastUsedAt(ISO)只在成功时更新。tools 的键是 server 自己声明的工具名,不带 mcp__<serverName>__ 前缀。
- 归属判定绝不盲目解析前缀:只统计属于本插件为调用方智能体所在项目挂载的 server 的工具名,所以配置档级别的 mcp__ server 永远不会落进项目的计数器,而一个被截断到函数名预算的注册名,仍能解析回它自己的 server。
- 计数器存放在 $DSH_HOME/dsh-project-mcp-usage.json,是一份带版本号的文档({ version: 1, projects: … }),通过防抖写入,写入是原子的(临时文件 + rename)—— 绝不在工具调用路径上写。文档缺失、无法解析或版本较旧时,从干净状态开始;写入失败只记日志然后丢弃:计数从来不是工具调用或插件失败的理由。
- 还没有统计到调用的项目,usage 字段不存在。
工具策略:模式与固定
面板的 Tools 界面按项目覆盖出厂呈现,这个决定属于插件状态,不是项目配置:它存放在 $DSH_HOME/dsh-project-mcp-policy.json,与计数器是同一种文档(带版本号,通过临时文件 + rename 原子写入,防抖写入,文件缺失、损坏或版本较旧时从默认值开始,而不是报错):
json
{ "version": 1, "projects": { "<root>": { "mode": "disclosure", "pins": ["mcp__tglider__workspace"] } } }
- 模式(ProjectSnapshot.policy.mode):
- disclosure(默认)—— 下面「工具激活」讲的全部内容:预算、计数器基线、按需激活、上下文层级和 mcp_search_tools。
- direct —— 什么都不延后:请求携带整个已挂载目录,deferring 为 false,也不对外公布发现工具。
- off —— 不提供项目的任何工具:装配监听器什么都不加。在会话作用域注册 mcp_search_tools 无法取消(这是 DSH 的限制,不是选择),所以它保持注册状态,但同样不对外公布。
- 每次装配都会读取模式,所以改动从下一个模型步骤起生效,无需重新铸造会话作用域。
- 模式决定 SessionTools 的全部三个字段,因为这一行是本插件对请求的贡献,而不是模型最终看到的内容:
| 模式 | baseline | deferred | deferring |
| disclosure | pins ∪ hot names | mounted minus offered | surface over budget |
| direct | pins ∪ hot names | empty | false |
| off | empty | every mounted name | false |
deferring 只关乎预算,与模式无关:direct 和 off 都不按大小延后,它们要么不过滤,要么不提供。面板必须先读 policy.mode,再看数字——在 off 下它说的是「这个项目的工具已关闭」,而不是「隐藏了 N 个」,因为当没有其他展示插件过滤装配时,模型仍然能看到它们。
- 固定项(ProjectSnapshot.policy.pins)—— 用户坚持要的名字:
- 固定的名字从每个请求的第一步起就提供,无论计数器与预算怎么说。它加入 SessionTools.baseline,所以面板把它算作已提供,它永远不会落进 deferred —— baseline 是固定项加上计数器加热的名字,deferred.length 才是被隐藏的数量。
- 固定项是项目状态,不是会话状态:它和计数器基线一样,能挺过空闲清理和 compaction/end。
- 项目不再挂载的名字仍保持固定 —— 声明可能会回来,而固定项悄悄消失会让人意外。这份列表与 baseline 的其余部分分开投递,因为它是用户手动固定的,而不是计数器播种的。
- off 不会抹掉固定项,只是不提供它们 —— 该行的 baseline 为空,每个已挂载名字都在 deferred 里 —— 所以切回 disclosure 时又能找到它们。
两处改动都走面板路由,与保存用同一个信封,且无需重新扫描 —— 策略不属于任何声明文档,所以快照就地重建: ts POST /project-mcp/pin { projectRoot, tool, pinned } // PinRequest POST /project-mcp/policy { projectRoot, mode } // PolicyRequest // 200 { ok: true, value: <fresh snapshot> } // 400 unknown project (not-found) or unknown mode (invalid)
工具激活与 mcp_search_tools
项目可以挂载大量 MCP 工具,而每个请求都带上这些工具,开销很大。宿主侧按渐进式工具部署的思路来呈现它们,同时不动注册表(这就是上文工具策略里的 disclosure 模式;项目也可以切换成 direct 或 off):
- 热工具直接提供。 项目计数器达到 activationMinCalls(默认 5)的工具,会被放进一个基线,从第一步起就出现在请求里;activationSeeded(默认 8)限制计数器最多能种入多少个名字。计数器就是上面那些,所以基线能跨会话、跨重启记住。
- 匹配的工具从任务上下文里提供。 宿主用 BM25 把项目挂载的工具与最新的任务文本做排序——排序依据是工具名和描述,按大小写边界、分隔符和标点切词,k1 = 1.2,b = 0.75,名字匹配的权重高于描述匹配——然后提供最匹配的那些。activationAutoLimit(默认 12)限制一个任务最多能提供多少个名字;分数低于下限的匹配会被丢掉,没匹配上的工具仍然可以通过 mcp_search_tools 找到。
- 只有大工具面才会被延迟。 会话挂载的 schema 按请求实际携带的样子来计量——名字、描述和序列化后的参数。如果总量小于等于 activationToolBudgetChars(默认 40000,约 10K token),每个工具都按 harness 其余部分组装好的样子原样提供。设为 0 会关掉这道闸,延迟所有工具面。两种情况下计数器都继续累加,所以这个决定会跟着项目一起成长。
- 提供的集合是粘性的,所以很少变化。 一个名字会为提供它的那段任务文本,再加上之后 activationAutoStickySteps(默认 2)条消息保持提供,每次重算只输入一项,这样多个步骤共用一份工具列表,而不是跟着排名的每次起伏走。由于内部监听器每一步都会重建注册表列表,新增项每次都根据这些监听器实际返回的内容重新应用;当那份列表已经包含全部内容时,监听器会交回同一个组装对象。工具列表一变就会重写提示词前缀,让模型的缓存失效,所以插件只在有意为之、且很少发生的时候才改它。
- 其余的工具靠查找,不靠列出。 插件在会话自己的作用域里注册 mcp_search_tools,也从自己的组装监听器里提供它——否则一个把列表裁剪到允许清单的呈现插件,会把唯一能让延迟层可达的工具给丢掉。这个发现工具固定保留:无论预算怎么定,它都会出现在每个 disclosure 装配里,这样项目不断长大,模型也总能发现工具。一次调用会搜索本项目的 MCP 工具——绝不会搜到配置档级别的工具,也不会搜到别的项目的服务器——然后激活最多 limit 个匹配项(默认 8),并返回它们的名称和描述。查询不区分大小写,而且逐词匹配:整个查询作为名称或描述里的一个子串时排名最高,随后它的每个词各自计分(词出现在名称里,比出现在描述里权重更高;少于两个字符的词忽略,只统计前八个不同的词),所以 issue get create update 能找到 …-issue_get、…-issue_create 和 …-issue_update,哪怕没有任何工具逐字包含这个短语。被激活的工具从下一个模型步骤起加入候选列表;在那之前,只有已经列出的工具才可靠。
- 一切又会消失。 会话激活的工具如果在 toolIdleMs(默认 30 分钟)内没有被调用,就会在下次 agent/status 状态切换时被丢弃;而一次 compaction/end 会直接清空会话的激活项和上下文候选。计数器基线不受这两者影响——它来自持久计数器,而不是来自会话——固定保留项同样如此(见上文工具策略)。
任务文本从哪来。 上下文层读取会话自己的 session/event 流:最新提交的 user/message 提供文本,tool/call 事件提供最近调用的名称(被调用的工具只有在插件为该项目挂载了它时才会进入候选)。只有来源为 user 的消息才算数:同一事件类型也承载所有插件写入的上下文注入——运行时上下文快照、智能体指令、技能目录、记忆召回、cron 通知——拿这些而不是任务去给工具候选排序,还不如什么都不排。时机跟随 harness:一轮的消息在开启这一轮的装配之后才进入日志,所以一轮的第一步是按上一条消息排序的,当前这条从下一步才生效。空闲清理、计数器基线和 mcp_search_tools 都不受这次读取影响。
这是呈现,不是权限。模型看不见的工具,只要模型已经知道名字,依然可以调用,模型也可以直接调用它;变的只是列出的内容。任何东西都不会被注销:每个请求拿到的是注册表自己的工具列表加上激活的 schema,按名称去重;activationEnabled: false 会关掉整套机制(每个已挂载的工具都像以前一样列出,也不注册任何搜索工具)。
实现说明——呈现搭的是智能体自己的 system-prompt/assemble 瀑布,注册时前置,这样它跑在最外层,看到的是其他呈现插件处理之后的列表(例如 dsh-progressive-tools)把它过滤掉了。这个监听器是自包含的:如果这个插件自身的工作失败,harness 其余部分产出的装配结果会原样返回,所以一个坏掉的探针永远不会弄坏模型请求。还剩一个顺序上的风险:某个展示类插件把列表裁剪成自己的允许清单,而它最终落在这个监听器外面(它稍后注册了自己的前置监听器),于是这个插件加进去的 schema 被丢掉,只剩下那个插件允许的工具。正是这种暴露出来的样子,促使发现工具由这个监听器提供,而不是交给注册表——但在那种嵌套下它仍会被裁掉。如果某次部署想要保留发现路径,可以在那个插件自己的允许清单里写上 mcp_search_tools(在 dsh-progressive-tools 里就是 eagerTools),这样无论哪种嵌套,这个工具都能存活;MCP schema 本身没法这样钉住,因为它们是项目数据,不是配置。
系统提示词里的项目指引
mcp-client 发布的按服务器划分的 mcp:<server> 小节说的是某个服务器是什么,而上面的激活面让它的许多工具不可见——但没有任何东西告诉模型:它看不见的工具仍然可以被请求。host 那一半加了一个简短小节(guidanceEnabled: true,默认开启),说的正是这件事,就放在它描述的挂载点旁边: text
Project MCP servers
Project project1 mounts 2 MCP server(s) in this session.
- memory (stdio, active)
- tglider (stdio, active) — code intelligence
Most used in this project's sessions, offered from the first step on: recall,
find_symbol — call them directly, no search needed.
3 of 14 MCP tool(s) are offered directly; the other 11 are offered on demand — call mcp_search_tools with a keyword or a tool name (several words are matched one by one), and a tool it activates becomes callable from the next step. Search once with the best keywords you have; when the search reports no match, this project mounts no such tool — report the gap instead of rephrasing the search or guessing a name.
On demand right now: batch_rename, changed_symbols, diagnostics, find_callers
+7 more — every one of them runs when called by its exact full name mcp__<server>__<tool>; search a name with mcp_search_tools first to see its description and parameters.
- 确定且有界。 服务器按代码单元名称顺序排列,延迟面是一行扁平的名字——排好序、去重、最多十二个名字,超出部分写成 +N more。整个小节上限 1200 个字符,所以极端项目会先丢掉末尾的服务器条目(折叠成 … and N more server(s)),之后才会丢掉按需调用的说明和名字行。
- 用名字行代替 schema。 描述和参数才是开销大的部分,所以这一节只列裸名字:模型挑出自己需要的工具,跑一次有针对性的 mcp_search_tools 查询,而不是盲搜。这一行还写明了直接调用的约定——延迟只会裁剪组装后的请求列表,绝不会注销工具,所以用完整名称 mcp__<server>__<tool> 发起的调用依然会执行;先搜索只是让模型看到描述和参数,并不是一道门槛。
- 来自持久计数器的最常用行。 当种子基线非空时,这一节会列出项目的热门工具(按调用次数从多到少,最多四个名称,超出部分显示 +N more),并说明它们从第一步起就可用——这样模型会直接调用它们,而不是去搜索自己已经有的东西。设置 activationEnabled: false 时,这一行会被去掉,和激活机制的其他部分一样。
- 防循环提示。 按需段落以搜索礼仪收尾:用手头最好的描述搜一次,如果无匹配,就说明项目没有挂载这样的工具——报告这个缺口,而不是换个说法再搜,或者编一个名字。
- 与 MCP 各节放在一起。 这一节通过 ctx.inject(['systemPrompt'], …) 在 MCP_SERVERS 顺序下注册,名称(mcp-project-guidance)的代码单元顺序排在每个 mcp:<server> 指令块之前——mcp-resource-servers 用的也是同一套约定——所以这段引导会先于它所介绍的服务商被读到。
- 实时生效,随作用域释放。 text 是回调:每次组装时都读取会话的挂载和注册表,所以一次重新扫描挂载或卸载了服务商,文本就会变化,无需重新注册任何东西。注册归智能体作用域所有,并在挂载所在的位置精确释放(projectMcp.release()、空闲超时、智能体销毁)。
- 不含本地标识符。 项目用 ~ 折叠后的目录标签命名,绝不用绝对根路径,这一节也不带端点、不带参数、不带文档路径。构建失败的一节返回 '',而不是让请求组装的模型请求失败。
guidanceEnabled: false 完全不注册任何节。设置 activationEnabled: false 时,这一节仍会列出挂载,但会去掉按需段落、名称行和最常用行,因为没有任何东西被延迟,也没有任何东西被播种。
惰性挂载与空闲释放
一个包含二十个项目文件夹的配置档,绝不能启动二十个项目那么多的服务商。会话的项目在第一轮挂载,挂载的作用域限定在发起请求的那个会话:
session created → nothing runs; rows read idle
one session's turn → that session's project starts, nothing else
idle for idleTimeoutMs → scope disposed; rows read idle again
next turn → mounted again from scratch
隔离规则是一个纯函数,已导出——src/runtime.ts 里的 mountTrigger。只有当那个会话发起请求时,一轮才会启动它的服务商:它自己的轮次、点名它的操作员请求、lazy: false,或者一个会话已经持有挂载点,并且正在被调和。会话只是活着——被恢复、被列出、空闲,或是别的会话的邻居——就永远不会启动任何东西,所以一个二十个项目的配置档会一直保持零个子进程,直到它自己的某个会话在那里工作。
有两个智能体平面事件驱动它:agent/status(idle/running)标记活动,而循环会等待 agent/pre-step,所以插件可以等待
for a pending mount (activationWaitMs, default 2 s) and the step's request
already sees the project's tools. The hook always continues the waterfall — a project MCP server is never a reason to block an agent step.
A mount is released on the first path that exists; only the quiet session needs
a clock:
- a session the host no longer lists releases its mounts on the next pass — no timer, no status event, and it works with idleTimeoutMs: 0;
- agent/disposed, an explicit projectMcp.release(), and a move to another
project dispose the session's scope where it lives;
- the idle sweep releases a quiet session after idleTimeoutMs. It reads the
host's own agent.status where the composition publishes one, so a session
whose idle transition never reaches the plugin cannot pin its mounts behind
a stale flag.
- lazy: false restores eager mounting at session creation; idle release still applies.
- activationWaitMs: 0 never waits: the first request of a reactivated session
may run without the project's tools, which appear from the next step on.
- An operator call — projectMcp.syncNow(), projectMcp.syncSoon(),
projectMcp.retry() — requests every listed session, or only one project's
when it names a root, so it mounts a lazy declaration without a turn. That is
the one path that starts a server for a session which is not working, and it
is deliberate: the POST sync|retry routes and scripted reconciliation call
it. syncSoon() and retry() answer as soon as the request is accepted — the pass is background work, and the panel reads its outcome off the status channel instead of holding a response open for connectTimeoutMs.
- Every created process writes exactly one info line naming the session, the
project root its working directory resolved to, and the trigger (turn,
mounted, eager, operator). It is written when the mount is created, never
by a rescan pass that re-derives the same mount.
Sidebar panel
The package has a browser half (./client) that registers a tab type with DSH's
own right sidebar — ctx.sidebarRightTabs for the type, the keyed sidebar.right.pane.tab seat for the body (see src/client/sidebar-tab.ts) — which the sidebar's guide page lists. A tab belongs to one conversation, so it shows only that session's project — never another one: the servers it declares and their merged live status, then the same project broken down per session — which session holds a mount, which has not turned yet, which one is this tab's — with Sync / Release actions. Release gives one session's hold back: the shared instance keeps running while another session of the project holds it, and stops when it was the last holder.
Both operator actions are scoped to the project the surface shows: the tab
and the settings page name that root in the request, the host touches only its
servers and answers with its slice of the snapshot (the settings page asks for
the whole snapshot back, since it lists every project), and the pass is queued
rather than awaited — the answer arrives immediately, not after the servers have settled.
Four view modes, switched in the toolbar and remembered in localStorage:
Servers (everything, the default), Tools (what the model sees of this
project — pins, disclosed and hidden tools, the budget line), Problems (only error / conflict / connecting, grouped in that order, with Retry failed alongside Sync) and Logs (the plugin's own event ring, below). Every state that has nothing to list names what is missing instead of showing an empty box — no session, no project marker above the session cwd, no declared server, or nothing that needs attention.
The tab chip's actions menu carries one row of this plugin's own — Panel settings… — and the popup it opens is where the whole picture lives: every project with a live session, not just this tab's, plus the panel's poll interval, persisted in browser-local storage (dsh-project-mcp:servers:refreshMs). The row dismisses the menu and the popup renders in DSH's shell.overlay floating layer, so it outlives the menu it was opened from.
The browser half runs in its own process, so the panel does not call
the runtime in process: the host half registers HTTP routes on the DSH web server
(/project-mcp/snapshot|events|logs|sync|retry|release|save|pin|policy, see
src/ui.ts) and the panel fetches them. Responses are { ok: true, value } or
{ ok: false, error }.
GET /project-mcp/events is the status channel: Server-Sent Events, one frame
per change (event: change, data: { revision, snapshot }), and a hello frame
carrying the current picture the moment a panel subscribes. DSH's web server
hands a route the raw response and exempts text/event-stream from its gzip
filter, so the stream is not buffered; a comment frame every 15 s keeps an idle
connection warm. The panel stops polling while the stream is open and falls back
to its poll — DEFAULT_REFRESH_MS, or the interval from the settings popup — whenever it is not (an older host half, a browser without EventSource, a dropped connection, which EventSource reconnects on its own).
Logs is the tab's fourth mode and reads the plugin's own event ring
(src/logs.ts): up to 200 events per project, the snapshot carrying the latest
50 and GET /project-mcp/logs paging the rest (before cursor, limit). One
event is info / up / warn / error, carries the project root, optionally a
session and a server, and one line of message. The runtime publishes each of its
five lifecycle events once, through the notification service
(src/notifications.ts) — mounting, is up, a stall, a failed mount, unmounting — and both readers subscribe to that publication: the ring, and the line the host log gets. The tab and the log therefore carry the same event, with no second write path to keep in step. Filters (this session / all sessions, all levels / errors) are client-side and remembered per browser; the level opens on errors and an empty list under that filter says the ring holds other events instead of claiming there are none. Clear is a client mark in localStorage (logsClearedAt) — there is no clear route. A page that fails to arrive is a line under the list, not a broken tab.
Toasts are the same picture on the frame: a server that starts or stops while a
panel is subscribed floats a banner in DSH's shell.overlay floating layer
(src/client/toasts.ts) instead of only changing a row in a tab nobody is
looking at. They are fed by the same status channel — serverTransitions() diffs what the change frames of GET /project-mcp/events say, and the baseline hello frame only resets it, so a page load or a reconnect never reads as twenty servers starting at once.
browser: ctx.inject(['slots', 'sidebarRightTabs'], s => s.sidebarRightTabs.register({ id, kind, … }))
s.slots.register({ name: 'sidebar.right.pane.tab', key: id }, Panel)
fetch('/project-mcp/snapshot')
host: ctx.webServer.register({ kind: 'prefix', path: '/project-mcp', … })
Requirements: react in the composition. If the composition has DSH's right
sidebar (slots + sidebarRightTabs), the tab type is registered; otherwise the
host half keeps mounting servers and the settings page, the configuration card and
the toasts still work. Client changes hot-reload (hard-refresh the browser);
host changes need a dsh web restart.
Settings page
The browser half also contributes a page to DSH's own Settings
(settings.section, kind list, scope root, declared by the settings shell), a
Plugins settings section). That is the one surface where every project with a
live session is visible: a project picker plus two remembered views — Table (a dense overview of server, transport, status, source and the enabled switch) and By files (one card per declaring document, in priority order, each card showing the servers it declares and the overrides <name> from <file> line a later document caused). The view is remembered in browser-local storage (dsh-project-mcp:settings-view); the page has no pluginSettings blob of its own.
Alongside the two server views the same section carries the project's Tools
page, and every figure on it is the host's own: the mode and the pin list come
from the project policy (setPin / setPolicy write them back), the per-server
counters are read from the session's visible and deferred tool names by the
mcp__<server>__ prefix, and the request preview is the visible list plus the
serialized sizes (visibleChars / deferredChars, tokens = characters / 4).
There is no slot figure and no prefix switch: the budget is measured in
characters, and the mcp__<server>__ prefix is applied by
@deepseek-ai/dsh-mcp-client, not configured here.
Selecting a server opens the split editor: the project's servers on the left, the
parsed entry as a form in the middle, and a JSON pane on the right showing the
difference between what the document declares and the edit. The host half serves
the write path behind that form (POST /project-mcp/save, see Writing entries
below and docs/design/contracts/write-path.md); creating and deleting servers is a
separate step and is not part of it.
Registration is two-step and deliberately partial:
ts
ctx.inject(['slots', 'locale'], (scope) => { // optional client services
scope.locale.addLanguage({ id: 'ru', label: 'Русский', fallback: 'en' })
scope.effect(() => scope.locale.register(NS, { zh, en, ru })) // + a second register for projectMcp.host
// The slot is declared by the Plugins section at runtime, and
// slots.register throws while it is undeclared — so wait for it.
scope.slots.inject('settings.section', () => scope.slots.register({ … }, SettingsTab))
})
The module-level inject is empty, and neither slots, nor sidebarRightTabs,
nor locale is listed there: an entry that waits on an optional service stays
pending, and DSH's web boot audit refuses to start while any entry is pending.
All three are read without gating the entry instead — the tab type and its two seats park behind ctx.inject(['slots', 'sidebarRightTabs'], …) (so they register whenever the composition has a right sidebar), and the tab's translate seat comes from ctx.get('locale'). A property read of a service the fiber did not declare throws cannot get property "…" without inject and fails the entry, which is what took the whole GUI down in 0.1.9. The host half and every slot-based surface continue without either service. The bundle requests
@deepseek-ai/dsh-client-ui-slots (a baseline platform module) and
package.json declares @deepseek-ai/dsh-client-locale and
@deepseek-ai/dsh-client-ui-settings under dsh.client.inject — package-name materialization edges, not Cordis services.
Writing entries
POST /project-mcp/save writes one existing entry back to the document that
declares it (ServerRow.source — even when a higher-priority document overrides it, because that is where the entry lives). The request carries the entry body, the declaring document and the documentRevision the editor was built from.
Tiers (ServerRow.writeScope):
| Tier | Documents | Condition |
| project | the resolved localFiles of the project | form confirmation |
| global | the resolved globalFiles of the deployment | consent: true and allowGlobalWrite: true |
| readonly | profile, <DSH_HOME>, bundled | never; writeBlockedReason says why |
Order of the checks, all before a byte is written: the row exists (not-found);
the document belongs to a writable tier and, for global, both gates are open
(blocked); the request's revision equals the document's (conflict,
documentRevision is a short SHA-256 prefix of the bytes); the edited entry
passes the same parse path as mounting (invalid). Only then is the document
replaced: <document>.bak first, then a temporary file in the same directory,
then rename over the original — a failure at any step leaves the document exactly as it was, and a write failure answers failed. Formatting is
normalized (two spaces, trailing newline) while every other key of the document
and every key the editor does not present are preserved. After the write the
project is rescanned and the answer carries the fresh snapshot
({ ok: true, value: <snapshot> }).
Secrets: env/headers values are handed to the editor only when the document
holds them literally. A masked key submitted without a value keeps what the
document declares, a key with a value writes that value, and a key the
submission omits is removed — so an editor that cannot see a secret can neither erase nor replace it (value: '' is an empty value, not an omission). A key answered outside the document — by the project's .env or by the credentials file — is marked fromCredentials and its resolved value is never written back: the declared text (usually a ${input:NAME} reference) stays as it is.
Errors keep the plugin's envelope and code: 400 for invalid, blocked,
not-found; 409 for conflict; 500 for failed.
Diagnostics
A server that never comes up is the one failure a project-scoped mount cannot
recover from on its own, so it is reported instead of being retried forever.
- connecting — mounted, no tool yet. Normal while a command boots.
- error after connectTimeoutMs — the mount produced no mcp__<name>__ tool inside the window. The row (and a matching issues[] entry) then carries a facts an operator needs: the server name and how long it has been waiting, its endpoint, and the document that declared it.
gateway: no tool appeared in 4m 27s
endpoint: stdio docker
declared in: /repo/.dsh/mcp.json
Facts only, on purpose: the row already carries the status, the transport chip
and a Retry button, and a stdio server's own reason is on its stderr — in the DSH log.
The watchdog is read-only: it never disposes a slow server, and a mount that
connects later goes back to active with the detail cleared.
- error from activation — with failOnStartupError: true the failing attempt itself rejects, and the row carries the underlying error plus the same context; such a mount is never retried implicitly.
- No respawn storms — a failed or stalled mount is not re-mounted on later passes. Fix the cause, then call projectMcp.retry() (or edit the declaration, which re-mounts it too).
Error text is redacted on purpose: it names the command but never its arguments,
and an HTTP endpoint keeps its path but drops the query string, because both can
carry credentials.
Resource behaviour
Measured by scripts/audit.mjs against the real mcp-client and real child
processes (with a 400-tool catalog stub) on Node 24.13 and Bun 1.4.2 — every
check passes on both:
| scenario | result |
| idle project, 4 s | 0 passes, 0 catalog scans, ~1 ms CPU (Node) |
| 600 unrelated writes (node_modules, dist, .git) | 3 watch events → 1 pass, 0 catalog scans, 0 remounts |
| mount → dispose | MCP child process exits, its pid file is removed |
| server that cannot start | one detailed error row, no respawn storm, no leaked child |
| 10 × session create/dispose | 0 surviving children, file descriptors flat, heap +0.3 MB (settled: read after a collection; a runtime exposing no collector reads raw under the same 8 MB budget) |
| two sessions, one project | 1 mount attempt, 1 child, 1 creation line — the second session's turn reuses it |
| a release while another session holds | child stays up for the remaining holder, goes down with the last one |
| teardown | watchers closed, descriptors released, 0 passes and ~1 ms CPU afterwards |
What keeps it that way: only the project's own document directories are watched
recursively (a flat watch on the project root notices a config directory
appearing), watcher events are debounced, a rescan compares config digests and
returns without doing anything when nothing changed, and the rescan timer,
watchers and every scope the plugin minted — per session and per project — are released by the plugin fiber.
Runtimes
Verified with Node 24.13 and Bun 1.4.2 — both the plugin and its checks:
| | Node | Bun |
| test | pnpm test (vitest) | bun test tests |
| resource audit | pnpm audit | bun scripts/audit.mjs |
| live smoke | node scripts/live-smoke.mjs | bun scripts/live-smoke.mjs |
| build | pnpm build | bun run build |
| host runtime | dsh (Node) | bun $(which dsh) |
The plugin imports only node:fs, node:os and node:path, reads the ambient
process.env, and spawns MCP servers through @deepseek-ai/dsh-mcp-client, so it
behaves the same under either runtime. Watches prefer fs.watch({ recursive: true })
and fall back to flat watches on the project root and each config directory when a
runtime or platform cannot watch a tree recursively — the rescan timer covers the rest either way.
Verifying
bash
real Cordis host + real @deepseek-ai/dsh-mcp-client + a fixture stdio server
pnpm build && node scripts/live-smoke.mjs # prints the registered tool + snapshot
bun scripts/live-smoke.mjs # same check under Bun
mount a real project's own documents and report what the servers publish
pnpm check:config . # -> tools: mcp__tglider__
CHECK_CONFIG_TOOL=mcp__tglider__server_status pnpm check:config . # call one tool
the sidebar tab against the frozen surface contract, out of a live profile
(needs a profile that links this checkout: dsh plugin --profile test-web add link:<workspace>)
pnpm design:parity # PASS / ACCEPTED / SKIP / FAIL per element
design:parity boots the test-web profile's own dsh web (which must
link:<workspace> this checkout), opens the tab and compares the computed
styles of what the panel draws, row by row, with the expected values frozen as
literals inside scripts/design-parity.mjs — in the live shell's own theme. The rationale for each rule lives in docs/design/contracts/surfaces.md §9 and docs/design/contracts/single-surface.md: the doc explains, the script decides, and a surface change updates both in the same pass. The gate no longer diffs against a picture: the design stand (docs/design/mockups/harness.html) is a proposal allowed to run ahead of the product. The panel is fed a payload of the host's own shape, because a profile started only for this measurement has no live agent until a session runs a turn, and the plugin publishes a project only for a live agent: the fixture puts every state the contract names on screen at once. It prints the profile, the session and the bundle (path, size, build time, sha256) it used. ACCEPTED is a documented exception with its authority named in the contract, and it fails as soon as that authority moves under it; SKIP is a row the run could not observe, and is never counted as a pass. --dump prints both raw captures, --watch the panel's own reading once a second.
Manual acceptance on a running GUI:
1. A session whose cwd is inside a project with .dsh/mcp.json (or whichever
document localFiles names) sees that project's mcp__<name>__ tools (check
the session tool catalog), and a second live session of the same project
resolves the same instance rather than a second process.
2. A session in a project without such a file sees no project tools.
3. Two sessions in two projects see different sets; the same serverName in
both projects resolves to different servers.
4. Editing the file adds/removes tools after the debounce (~300 ms), without a
restart and without disturbing the profile-level servers.
Releasing
package.json's version is the published version; a v<version> tag only selects
the commit, and the two are checked against each other before every publish.
Every push to main releases, when the commits since the last tag ask for one
(Conventional Commits: feat → minor, fix/perf/revert → patch, ! or a
BREAKING CHANGE: paragraph → major; docs, chore, ci, test and refactor
release nothing on their own). .github/workflows/release.yml runs the full
pnpm check gate, decides the version with scripts/next-version.mjs, writes that one
line of package.json, commits it as release: dsh-project-mcp <version>, tags it,
creates the GitHub Release, and calls npm-publish.yml to publish the tag to npm — called rather than triggered, because a Release created with the workflow's own GITHUB_TOKEN starts no other run. The decision reads both package.json and the highest v tag and takes the newer of the two, so a tag that outran its manifest cannot publish the wrong number twice; before tagging, the job refuses to reuse a tag or an already-published version rather than failing later inside npm publish.
To see what the next push would release:
bash
node scripts/next-version.mjs # the decision, as JSON (nothing is written)
node scripts/next-version.mjs --bump patch # force a level over a docs-only range
The Release workflow also has a manual entry point (Run workflow: bump, dry_run)
for re-driving a run that died between its tag and npm. To publish by hand, create a
GitHub Release for a tag whose package.json already carries that version; the publish
job fails loudly when the two disagree. pnpm pack builds by itself (prepack), so a
hand-made tarball cannot ship the package without its lib/ — the shape the published 0.2.0 had.
The publish credential is an OIDC token, not a repository secret: the job asks for
id-token: write and exchanges it with npm's trusted publishing (registered for this
repository and npm-publish.yml on npmjs.com), so nothing has to be rotated and a
stale NODE_AUTH_TOKEN must not be set — it would take precedence over the exchange. That job pins Node 24, because the exchange needs npm ≥ 11.5.1 and is performed by the
npm CLI: the pnpm 10 line has no trusted-publishing support, so pnpm publish there
would send the PUT unauthenticated and the registry would mask that as a 404.
Limitations
- Per-project cost: one MCP instance per project per server, shared by every
session working there (a subagent in the same project does not start a second
one). HTTP servers are cheap; npx/uvx/dnx stdio servers spawn a process
each, so a wide multi-project session graph still costs one child per project.
Use enabled: false, or keep a server in the profile when every project needs
it.
- Writes edit existing entries only: the editor replaces
mcpServers[<server>] in the document that declares it. Creating and deleting
servers, and writing a document that does not use an mcpServers object, are
not supported (the latter answers blocked).
- A config edit that keeps JSON.stringify of the generated config identical is
a no-op by design (no remount, no dropped connections).
- profileWins cannot un-mount a profile entry; remove it from
cordis.patch.yml to hand a server over to the project plane.
Dogfooding
This repository declares its own MCP server in .dsh/mcp.json — tglider (TypeScript/JavaScript code intelligence) with --workspace ., which the plugin resolves against the project root it launches in. pnpm check:config . mounts that file through the plugin and lists the published mcp__tglider__ tools.
How it works
1. Project discovery — for every live agent (session), the plugin walks up from agent.session.header.cwd to the first directory carrying a project
marker (.git, .dsh, .kimi-code, package.json, .sln/.slnx/.csproj).
2. Scope minting — the project's servers are mounted once per project inside a Cordis scope minted for it (createScope(hostCtx, projectKey)), the same pattern the in-box browser-use-runtime uses for MCP servers. Every session of that project gets its own scope, parented to the project's
(bindScopeParent(agent, projectKey)): the tools registry resolves a
session's catalog through that chain, so one instance serves every session
working in the project — subagents and Agent-Teams members included — and a session of another project never resolves it.
dsh-scope binds a key's parent once, and a session composed from an
agent preset — every session of the Web GUI — has its key parented to that preset's standing scope by the harness before the session is published. Such a session cannot join the project's layer at all, so the plugin mounts the same declarations under the session's own scope instead: an exact-key layer always resolves for its own key, with no parent link to lose. That is one instance per session rather than one per project, and the info line says which of the two shapes a session got.
Reading the project's servers only after that link, or reporting a mount the
session cannot resolve as connected, is what the 60-second no tool appeared
row used to be: a healthy child process paired with an empty catalogue.
3. Lifecycle — agent/created mounts, agent/disposed (and the agent's own effect) tears down, a debounced fs.watch plus a rescan timer pick up config edits, and a config change disposes and re-mounts only the affected server. The shared instance follows the project's last holder: a session that goes idle, releases, is disposed, or moves to another project gives its hold back, and the servers stop exactly when the last of them lets go.
Because @deepseek-ai/dsh-mcp-client reserves a serverName per registration
scope, two different projects may declare the same name (rider, gitea, …)
without colliding with each other or with the profile.
profile scope : gitea, context7, ... → every session
agent scope A : .dsh/mcp.json of project A → only sessions in A
agent scope B : .dsh/mcp.json of project B → only sessions in B
Bundle format (do not "fix" this)
DSH does not import a plugin bundle. The web client concatenates several
plugins' client.js into one classic script and materializes each through a
Lazy-CJS module table:
js
// lib/client.js — generated by scripts/wrap-client.mjs
window.__ModuleLoader__.load({
id: 'dsh-project-mcp',
factory: (require) => { / CJS body; require('react') resolves against the platform table */ },
})
Three consequences, each guarded by a check:
- the client target builds as CJS (tsdown format: ['cjs']) and is wrapped
by scripts/wrap-client.mjs, which runs as part of pnpm build;
- the bundle must contain no statement-level import/export — it would be a syntax error that aborts the whole batch, taking unrelated plugins down;
- every require(...) must be a baseline module (react, react/jsx-runtime,
…). Anything else throws at materialization, because the require handed to a factory is synchronous and cannot await a fetch.
tests/bundle.spec.ts replays that exact load path against the built bundle and
pnpm audit re-checks the shell, the ESM-free body and the module requests.
Layout
src/index.ts plugin entry: Config schema, apply, projectMcp service
src/runtime.ts per-agent scope minting, mounting, diffing, watchers
src/discovery.ts project root + document paths + credentials + project .env files
src/parse.ts mcpServers -> mcp-client config, ${VAR}/${input:...}, dotenv/credentials, merge
src/write.ts editor write path: revision, declaration, atomic replace
src/ui.ts host HTTP routes: snapshot, events (SSE), sync, retry, release, save, pin, policy
src/usage.ts durable per-project tool-call counters
src/policy.ts durable per-project tool policy: mode (disclosure/direct/off), pins
src/guidance.ts project-MCP guidance section: deterministic text + wiring
src/types.ts snapshot/row vocabulary
src/client/ browser half: the right-sidebar tab and its settings popup,
the native Settings page (settings.section), shared view
helpers
tests/ unit tests (parse, discovery, runtime, guidance, both views),
plus a load-path guard for the wrapped client bundle
scripts/ MCP fixture server, live smoke test, resource audit,
project-config checker, bundle wrapper, panel screenshot and
the surface-parity check预览分享卡片长什么样 ↓
分享 · DeepSeek Harness Hub
borgez/dsh-project-mcp
让每个项目声明自己的 MCP 服务器,仅对该项目会话生效
相关插件 · UI / 主题
破甲增强版JeffKeeping/dsh-purge-plus
dsh-purge 的非官方分支fork:适配 DeepSeek Harness 0.2.1-rc.2、破甲内核…
★ 387已汉化DeepSeek 客户端
71.9
无代码业务系统平台nocobase/nocobase
NocoBase 是什么
★ 24,804已汉化DSH
71.8
1Panel 服务器面板1Panel-dev/1Panel
全球 250 万+ 自托管用户共同喜爱。
★ 37,362已汉化DSH
71.4
终端编码代理esengine/DeepSeek-Reasonix
· 指南 · ACP · 扩展开发 · 规格 · 官方网站 · Discord · QQ
★ 36,076已汉化DSH
71.2
工作状态行动态ccch1mneyyy/working-activity
作者:chimney@ccch1mneyyy。社区项目,非 DeepSeek 官方项目。
★ 664已汉化DSH
71.2
多模型配置管家farion1231/cc-switch
跨平台桌面一体化助手,支持 Claude Code、Codex、OpenCode、DeepSeek Harness 等工具,唯一官网 ccswitch.io
★ 151,814已汉化DSH
71.1
这个插件是你维护的吗?
作者本人可认领并核对本条目;认领通过后,详情页会显示「作者已认领」标记。