# dsh-mcp — MCP management UI + tool search: stable tool list, cache hits, no context bloat [![dshfind](https://dshfind.com/api/badge/ArvinQi/dsh-mcp?lang=en)](https://dshfind.com/en/plugins/ArvinQi/dsh-mcp?ref=badge) > **Supported DSH version**: `dsh 0.1.6-alpha.2` — developed and verified on `0.1.6-alpha.2`, declared in `package.json` → `dsh.supported`. When the DSH and plugin versions do not match, the Settings page shows a diagnosis (check the registration row → restart `dsh web` → hard-refresh → upgrade both sides). ![Settings preview](static/snapshot.en.webp) ## Why dsh-mcp? **Problems it solves:** - **Full tool injection burns tokens**: with multiple MCP servers, the tool count can reach hundreds, and injecting all of them every request is expensive. The `search` mode lets the model hot-inject only the tools it needs via `mcp_tool_search`, saving tokens at scale. - **Re-syncs churn the tool list and break caches**: `tools/list_changed` notifications dispose and re-register same-named tools, jittering the system-prompt tool list and constantly invalidating the prompt cache. Tool-list stabilization keeps unchanged tools registered, maximizing cache hits. - **No visual management entry**: server config, enable/disable, and tool toggles used to require editing files by hand. Settings → MCP brings everything into one UI. **Highlights:** - **Visual management**: server list / create / edit / delete / test connection / enable-disable / refresh, all in the UI - **Process-level environment variables**: a global key-value list (expanded by default, batch-add supported); header values can reference a variable by bare name or `${NAME}` and are substituted at connect time (e.g. `Authorization: Bearer ${TOKEN}`) - **Whole-list JSON config**: the "JSON config editor" panel views/edits every server as one JSON array; applying saves immediately (create/update/delete) - **Fine-grained tool control**: expand each server to see its tools, all checked by default; uncheck to load only what you need - **Image result passthrough**: images returned by MCP tools (screenshots/charts) are projected through the attachment service into model image context, with strict preflight and bounded fallbacks (PR #4) - **Two injection modes**: `search` (on-demand, token-saving) and `full` (inject everything) - **Zero npm dependencies**: plugs into DeepSeek Harness internals, install and go - **OAuth authentication**: for `streamable-http` servers using MCP OAuth (authorization-code + PKCE), the browser opens automatically for authorization on connect; tokens and OAuth client info are persisted and refreshed automatically by the SDK (auto-renewed while active within 24h), with automatic re-authorization after expiry - **Three install paths**: npm / GitHub git source / local link; bilingual UI and docs Migrated and merged from uncommitted MCP work in the `deepseek-harness` repository: | Original package | Migrated to | |---|---| | `packages/mcp/mcp-manager` (host registry) | `lib/index.js` (host half) | | `packages/client/ui-settings-mcp` (settings UI) | `src/client/*` → `lib/client.js` (browser half) | | `packages/bundle/web-mcp` (bundle assembly) | single row registered via `cordis.patch.yml` | | `packages/mcp/mcp-client/src/probe.ts` + `transport.ts` | `lib/probe.js` + `lib/transport.js` (vendored; no in-box changes) | ## Features - **Managed MCP server registry** (host): persistent definitions (storage-domain `mcp_servers`), per-server `@deepseek-ai/dsh-mcp-client` mounts, environment variable injection (plain values in the definition, secrets via credentials), connection probe (`test`). - **Web settings page** (client): Settings → MCP — list / edit / delete / test servers. - **Server-level enable/disable**: disabling a server unmounts it and unregisters its tools immediately. - **Per-server refresh** button: re-pulls server status and tool list. - **Tool control**: - Injection mode: `search` (on-demand, default — the model hot-injects tools via `mcp_tool_search`) and `full` (inject every enabled tool each request). - Expandable per-server tool list, all checked by default; unchecking a tool keeps it out of injection. Changes take effect immediately. - **OAuth authentication** (host, `lib/oauth.js`): on a 401 + OAuth challenge from a `streamable-http` server, runs the authorization-code + PKCE flow automatically — opens the browser, receives the callback on a loopback server, persists tokens, and refreshes them on demand; test connection and mounts share the same token. - **Remote self-mount**: the client half mounts the `mcpManager` Remote namespace itself via `ctx.remote.$mount()` in `apply()`, so no in-box package modification is required. - **Declared servers are read too** (host, `lib/cordis-servers.js`): `@deepseek-ai/dsh-mcp-client` rows declared in the patch layers are listed read-only on the Settings page (see below). - Zero npm runtime dependencies (`@deepseek-ai/*` resolve from the DSH profiles module fallback). ### Declared servers (`cordis.patch.yml`) and precedence DSH can declare MCP servers directly in the composition: **one row per server**, `name: '@deepseek-ai/dsh-mcp-client'`, in the profile layer `$DSH_HOME/profiles//cordis.patch.yml` or the machine-wide layer `$DSH_HOME/cordis.patch.yml` (machine-wide applies to every profile and overrides the profile layer per row id). ```yaml - insert: - id: mcp-github name: '@deepseek-ai/dsh-mcp-client' config: transport: stdio # or streamable-http serverName: github command: npx args: ['-y', '@modelcontextprotocol/server-github'] ``` Since 1.11.0 those declarations appear in Settings → MCP, badged "cordis declaration", read-only, with the file they came from: - **Declarations win**: when a `serverName` is already declared (and not `disabled`), the manager does not mount the same-named stored row — two mounts under one name collide in the tool registry and roll back that server's whole generation; the page explains the conflict. - **Read-only**: declared servers cannot be enabled/disabled or edited here; edit `cordis.patch.yml` instead (`web`/`desktop` reload live; `headless`/`sdk` apply it on the next start). - **Imported into storages as mirrors**: at startup and on every refresh the declarations are imported into the storage domain (id `cordis:`, with `origin/declaredIn/declaredRowId`), **import only** — a row created in the plugin is never overwritten; a mirror whose declaration disappeared is **removed automatically** (a mirror is only a copy of a declaration, so an ownerless one must not pile up); a declaration containing a `!!js` expression is skipped and flagged with the reason (its value only resolves inside the Loader). Mirrors are never mounted — the composition owns the mount. - **Take over / give back**: clicking "Take over" makes the plugin write an id-targeted `disabled: true` into its own **managed block** in `cordis.patch.yml` (a `.dsh-mcp.bak` backup is taken before the first write; atomic, idempotent, reversible, confined to that block). The declaration releases the `serverName` and the plugin mounts its own row instead — enabling **OAuth authorization, managed credentials, `${VAR}` header substitution and connection tests**. A hard mount failure rolls back automatically (the block is removed and the row returns to mirror state) and reports why; "Give back" hands the mount back to the declaration and deletes the managed row. A declaration without an explicit `id`, or one containing a `!!js` expression, cannot be taken over. - **OAuth / placeholder limit (`needsPlugin`)**: a declaration can carry neither an OAuth provider nor `${VAR}` / bare-name placeholder substitution — only the plugin does those. Such a declaration can therefore only work while the plugin owns the mount: the page reports it as **failed with the reason** (not "connecting"), "Give back" makes its tools disappear (the confirmation and the result warning both say so), and taking it over again restores them. - **OAuth is an explicit switch (since 1.11.1)**: an OAuth provider is attached only when the server sets `oauth: true` (the form's "Use OAuth authorization" checkbox, off by default). **Do not tick it for servers that authenticate with static tokens or custom headers** — otherwise a 401 is treated as an OAuth challenge and the plugin keeps opening the browser (with its `127.0.0.1:/callback` loopback listener). The plugin also opens the browser **at most once per server per process**; later automatic attempts return the authorization-link error, and "Test connection" can still start one on demand. Upgrading migrates automatically from existing OAuth credentials, so servers that genuinely need OAuth are unaffected. - **Safe degradation when the hot reload does not commit**: if the native tools have not unregistered within 5 seconds of writing the disable block (that profile's patch hot reload did not commit — a sibling entry failing its re-apply rolls the whole generation back), the plugin **registers the takeover without mounting** (the managed row is marked `pendingTakeover`); the next dsh start mounts it, and "Give back" reports the same need for a restart. A mount is also always skipped when same-named `mcp____` tools already exist without a local mount, so the plugin never fights a still-mounted declaration for one name. - **Tool search covers them**: search/hot injection and per-tool switches work by `mcp__` prefix across the whole tool set, so declared tools need no extra configuration. ## Structure ``` dsh-mcp/ ├── package.json name=dsh-mcp; dsh.client declaration; zero npm dependencies ├── lib/ │ ├── index.js host half (McpManagerService, built from mcp-manager) │ ├── cordis-servers.js reads natively declared MCP servers from the patch layers (1.11.0) │ ├── patch-writer.js managed-block writer: disables a declaration on takeover (backup/atomic/idempotent) │ ├── mcp-client.js vendored MCP client (from @deepseek-ai/dsh-mcp-client, with tool-list stability extension) │ ├── oauth.js MCP OAuth client provider (authorization-code + PKCE, loopback callback, token persistence) │ ├── probe.js vendored connection probe (from mcp-client/src/probe.ts) │ ├── transport.js vendored transport factory (from mcp-client/src/transport.ts) │ └── client.js browser half (esbuild bundle, ModuleLoader wire format) ├── src/client/ browser half source (TSX + CSS Modules + local types + remote-contribution) └── scripts/build.mjs build script (esbuild resolved from a DSH checkout, see below) ``` ## Build ```sh node scripts/build.mjs ``` - esbuild is resolved from a DSH source checkout: `$DSH_SOURCE`, or `~/.dsh/source/current` when unset. - Runtime dependencies (`@deepseek-ai/*`, `zod`, `@modelcontextprotocol/sdk`) are not installed as npm packages; they resolve from `$DSH_HOME/profiles/node_modules` (DSH profiles module fallback, `$DSH_HOME` defaults to `~/.dsh`); the build points `nodePaths` at the same directory. - CSS Modules are handled by an esbuild onLoad plugin: styles are injected into a `