borgez/dsh-project-mcp

dsh-project-mcp

按项目挂载 MCP 服务器:项目在自己的文档里声明服务器(默认 `.dsh/mcp.json`,本地与全局文件列表均为配置项),只对在该项目中工作的会话挂载——会话首个 turn 时挂载,空闲后释放。右侧栏标签页与设置页列出每个有活动会话的项目、各服务器状态以及持有挂载的会话,并提供 `Sync` / `Retry` / `Release`、`enabled` 开关和对某个既有条目的编辑器;按项目的工具策略(`disclosure` / `direct` / `off`)配合固定项与 `mcp_search_tools` 决定模型可见范围;`${VAR}` 引用依次从项目 `.env` 文件与凭据文件解析;按服务器统计的调用次数不随会话消失。

待分类

dsh-project-mcp

Per-project MCP servers for DeepSeek Harness.

DSH normally configures MCP once per profile (cordis.patch.yml), so every session of the process sees the same servers. This plugin adds the missing plane: a project declares its MCP servers in its own files, and those servers are mounted only for the sessions working in that project.

Which files a project is read from is a setting: localFiles lists documents relative to the project root, globalFiles lists the ones read before them. The shipped default reads one document — /.dsh/mcp.json — and nothing global, so a project mounts exactly what it declares. A Kimi Code project (or any other tool's file) is one config line away, never an implicit second source.

// <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}" }
    }
  }
}

Table of contents

  • Install
  • Sources and priority — entry mapping, variables
  • Configuration — profileWins
  • Runtime status
  • Usage counters
  • Tool policy: mode and pins
  • Tool activation and mcp_search_tools
  • Project guidance in the system prompt
  • Lazy mounting and idle release
  • Sidebar panel — settings page, writing entries
  • Diagnostics
  • Resource behaviour
  • Runtimes
  • Verifying
  • Releasing
  • Limitations
  • Dogfooding

Implementation notes: how it works · bundle format · layout

Install

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 runs the suite through --coverage, and vitest.config.ts gates all four readings at 80% over src/**: a surface added without its tests fails the command a reviewer runs, not a later reading of a report.

Bun works as the package manager and runner too:

bun install && bun run build
bun test tests                    # same suite through the Bun test runner

dsh plugin add appends the bundle to dsh.profile.bundles and reconciles ~/.dsh/profiles/web/package.json; the bundle's own cordis.patch.yml mounts the row. Restart the web GUI afterwards. To iterate without re-packing, install with link:/abs/path/to/dsh-project-mcp and rebuild (pnpm build).

Sources and priority

Read for the project of each session, lowest priority first — a later document overrides an earlier entry with the same serverName. The order is exactly the globalFiles list followed by the localFiles list:

# · Document · Setting 1 … · the global documents this deployment names · globalFiles, default [] — none read … n · the project's own documents, in list order · localFiles, default ['.dsh/mcp.json']

A spec is read the way a shell reads it: ~/… is $HOME/…, $DSH_HOME/… is the plugin's own home, an absolute path is itself, and anything else is relative to the base it belongs to — the project root for localFiles, $HOME for globalFiles. So localFiles: ['.kimi-code/mcp.json', '.dsh/mcp.json'] reads a Kimi Code project's file first and lets the DSH file override it, and globalFiles: ['~/.dsh/mcp.json'] adds a machine-wide document below both.

A document may be { "mcpServers": { ... } }, { "servers": { ... } }, or a bare name → entry map. Unknown fields are ignored; a broken row never blocks the rest of the document — it surfaces as an error row.

Entry mapping

Input · Result command / args / env / cwd · transport: 'stdio', cwd defaults to the project root url / headers · transport: 'streamable-http' type: 'stdio' \ · 'http' \ · 'streamable-http' \ · 'sse' · explicit transport; otherwise inferred from url enabled: false / disabled: true · row stays visible as disabled, never mounted serverName · the mcpServers key; must match [A-Za-z0-9_-]{1,32}, otherwise it is slugified with a warning "${VAR}" · the project's own .env files → process.env.VAR → the credentials file, else an entry error "${input:NAME}" · plugin inputs.NAME → the same chain as ${VAR} invalid JSON / bad row · error row with the reason; siblings still mount

${...} references are expanded in command, args, env, cwd, url and headers. Secrets stay out of the project document: they live in process.env, in the project's own .env files, or in ~/.dsh/.credentials.yaml.

Variables

A value a reference needs is looked up in one fixed order, project-local first:

# · Source · Gate 1 · /.dsh/.env · envFiles (default true) 2 · /.env · envFiles (default true) 3 · process.env · — 4 · KEY in the credentials file · credentialsFile (default $DSH_HOME/.credentials.yaml)

Plugin inputs sit above all four for ${input:NAME}. The project-local files are ordinary dotenv documents — one KEY=value per line, an optional export , single or double quotes, # comments on their own line or after whitespace — and an empty value counts as unset, so a blank entry cannot shadow a real one.

Both document shapes work: ${VAR} (what a mcp.json written by hand usually carries) and ${input:VAR} (the VS Code spelling). Each document is re-read only when its mtime/size changes, and a change to any of the four sources re-expands the references and re-mounts the affected servers without the mcp.json being touched. A key any of these sources answered is never written back by the editor: it stays masked in the panel and its declared ${...} text stays in the file. envFiles: false narrows the chain back to process.env + credentials. Keep the dotenv documents out of version control — the plugin only ever reads them, but a committed .env is a committed secret.

Configuration

Plugin config is the cordis.patch.yml row (defaults shown):

- 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: 16      # counter-seeded tools that are always offered
    activationMinCalls: 1     # calls one tool needs before it is seeded
    toolIdleMs: 0             # drop a session-activated tool after this long without a call; 0 keeps it for the session
    guidanceEnabled: true     # one short project-MCP guidance section in the session system prompt
    activationAutoLimit: 12   # names the task-context tier may offer at once; 0 disables that tier
    activationAutoStickySteps: 100  # extra user messages a context offer stays offered
    activationToolBudgetChars: 40000  # serialized MCP surface above which tools are deferred; 0 defers always
    projectMarkers: ['.git', '.dsh', '.kimi-code', 'package.json']
    fileMarkers: ['.sln', '.slnx', '.csproj']

Live editing

On DSH ≥ 0.1.7 the keys below are volatile: the host serves them as a config form, an edit commits into the running plugin without a restart, and the runtime picks the new value up on the loader's loader/volatile-update event. Every other key — enabled, localFiles, globalFiles, projectMarkers, fileMarkers, inputs, credentialsFile, watch, rescanIntervalMs, failOnStartupError — is structural and still needs a remount (restart or config reload) to take effect. An older host just hands over plain values, and nothing about the boot path changes.

Volatile keys: activationEnabled, activationSeeded, activationMinCalls, toolIdleMs, guidanceEnabled, activationAutoLimit, activationAutoStickySteps, activationToolBudgetChars, allowGlobalWrite, envFiles, lazy, localPrefix, profileWins, connectTimeoutMs, toolCallTimeoutMs, idleTimeoutMs, debounceMs, activationWaitMs.

profileWins

  • true (default) — a project entry whose serverName is already live in the

profile is not mounted; its row reads conflict with the hint to remove the profile entry. Safe: no duplicate processes, no shadowing.

  • false — the project instance is mounted too. Nearer scopes shadow the

profile's tools for that session, so the project's own configuration (for example a project-specific IJ_MCP_SERVER_PROJECT_PATH) wins. Costs one server process/connection per server for as long as a session of that project holds it.

Runtime status

The plugin publishes a projectMcp service (snapshot() reads, save() writes one entry back, and setPin() / setPolicy() write the project's tool policy):

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: the current SnapshotChange at once, then one per change
// SnapshotChange: { revision, snapshot } — every frame carries the whole 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 is the merge across that project's sessions, in document order. Sessions mount independently, so when they disagree about one server the most actionable status wins — error > active > connecting > conflict > idle > disabled — and a running or failing server is never reported as idle just because a session that has not turned yet was registered first. projects[].sessions carries the same data split per session, which is what says *which* session holds a mount.

Usage counters

The host half counts every tool call whose server this plugin mounted, keyed by project root and serverName, so a project's numbers outlive the sessions that produced them:

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 counts every dispatched call, errors those whose result carried an

error, and lastUsedAt (ISO) moves on success only. tools keys the tool name as the server declares it, without the mcp____ prefix.

  • Attribution never parses the prefix blindly: only tool names belonging to a

server this plugin mounted for the calling agent's project are counted, so a profile-level mcp__* server never lands in a project's counters, and a registry name truncated to the function-name budget still resolves to its own server.

  • The counters live in $DSH_HOME/dsh-project-mcp-usage.json, a versioned

document ({ version: 1, projects: … }) written atomically (temp file + rename) on a debounce — never on the tool-call path. A missing, unparsable or older-version document starts clean, and a failed write is logged and dropped: counting is never a reason for a tool call or the plugin to fail.

  • usage is absent for a project with no counted calls yet.

Tool policy: mode and pins

The panel's Tools surface overrides the shipped presentation per project, and that decision is plugin state, not project configuration: it lives in $DSH_HOME/dsh-project-mcp-policy.json, the same kind of document as the counters (versioned, written atomically through a temp file + rename, written on a debounce, and a missing, corrupt or older-version file starting from the defaults rather than failing):

{ "version": 1, "projects": { "<root>": { "mode": "disclosure", "pins": ["mcp__tglider__workspace"] } } }
  • Mode (ProjectSnapshot.policy.mode):
  • disclosure (default) — everything *Tool activation* below describes: the

budget, the counter baseline, on-demand activations, the context tier and mcp_search_tools.

  • direct — defers nothing: the request carries the whole mounted catalogue,

deferring is false and the discovery tool is not advertised.

  • off — offers none of the project's tools: the assembly listener adds

nothing. Registering mcp_search_tools in the session scope cannot be cancelled (a DSH limitation, not a choice), so it stays registered but is not advertised either.

  • The mode is read on every assembly, so a change applies from the next

model step on, with no re-mint of the session scope.

  • The mode decides all three fields of SessionTools, because that row is

*this plugin's contribution* to the request, not what the model finally sees:

Mode · 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 is about the budget, never about the mode: direct and off defer nothing by size, they either filter nothing or offer nothing. A panel has to read policy.mode before the numbers — in off it says "this project's tools are off" rather than "N hidden", because a model can still see them when no other presentation plugin filters the assembly.

  • Pins (ProjectSnapshot.policy.pins) — the names the user insists on:
  • A pinned name is offered from the first step of every request, whatever the

counters and the budget say. It joins SessionTools.baseline, so the panel counts it as offered and it never lands in deferred — baseline is pins plus the names the counters heated up, and deferred.length is what is hidden.

  • A pin is project state, not session state: it survives idle pruning and

compaction/end exactly like the counter baseline.

  • A name the project no longer mounts stays pinned — the declaration may come

back, and a pin that silently disappeared would be a surprise. The list is delivered separately from the rest of baseline, because it is what the user pinned by hand rather than what the counters seeded.

  • off does not erase pins; it just does not offer them — the row's

baseline is empty and every mounted name is deferred — so switching back to disclosure finds them again.

Both changes go over the panel routes, in the same envelope as a save and without a rescan — the policy is not part of any declaring document, so the snapshot is rebuilt in place:

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)

Tool activation and mcp_search_tools

A project can mount a large MCP surface, and every one of those tools in every request is expensive. The host half presents them the way a progressive-tools deployment wants, without touching the registry (this is the disclosure mode of *Tool policy* above; a project can be switched to direct or off instead):

  • Hot tools are offered directly. A tool whose project counters reached