dsh-mcp-client-plus
An MCP client bridge for DeepSeek Harness (dsh), forked from the built-in @deepseek-ai/dsh-mcp-client and extended with the capabilities its own documentation lists as deferred.
Everything the upstream bridge does, it still does: one plugin instance per MCP server, tools registered as native mcp__<serverName>__<rawName> tools, serverName namespacing, tool-list-change refresh, and reconnect with exponential backoff.
What it adds:
| Capability | Upstream | |
|---|---|---|
| 1 | OAuth 2.1 — discovery, RFC 7591 dynamic client registration, PKCE, refresh rotation, browser redirect | static headers only |
| 2 | connectTimeoutMs — bounds initialize |
inherits the SDK's 60s default |
| 3 | discoveryTimeoutMs — bounds each tools/list page |
passes no timeout at all |
| 4 | Secrets by environment — tokenEnv, headerEnv |
raw values inlined in cordis.yml |
| 5 | maxAttempts: 0 — reconnect indefinitely |
gives up permanently after 10 |
| 6 | mcp_status tool — live per-server state from inside a session |
log output only |
Why these six
These are not speculative features. Each one maps to a concrete failure of the upstream bridge, and the first five were reproduced against a real harness before this fork was written.
The upstream bridge can give up permanently and never recover. On a startup failure it retries with backoff, then unregisters and logs giving up after 10 consecutive failed reconnect attempts — tools unregistered; reload the plugin or restart the Host to reconnect. Nothing self-heals, and the failure is visible only in host stderr. In the harness this was diagnosed on, five OAuth-protected servers were dead this way — valid credentials, reachable endpoints, zero tools — while the two servers without an Authorization header worked fine.
OAuth is the root cause of most of that. Because upstream accepts only static headers, an OAuth server has to be fed a token by an out-of-band bridge — commonly a config expression that shells out to another agent's credential store on every load. That path is fragile (a Keychain ACL denial is silently swallowed into an empty Bearer header), and it cannot refresh. auth: oauth removes the bridge entirely: the SDK performs discovery, registration, and refresh, and the first connect prints an authorization URL.
Timeouts were unbounded in two places. listToolsUncached passed no RequestOptions at all, so every tools/list page waited the SDK's 60-second default, and connect() did the same for initialize. A single unresponsive server could stall plugin activation for minutes. Both are now explicit and configurable.
A dead server is invisible from a session. mcp_status reports every configured server, its state, registered tool count, retry position, and last error — so "why is this tool missing" is answerable without reading host logs.
Install
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:anthonyyu-verkada/dsh-mcp-client-plus
Then declare your servers in your profile patch (~/.dsh/profiles/web/cordis.patch.yml) and restart dsh.
The bundled cordis.patch.yml is intentionally empty: the bridge is one instance per server, so a packaged patch cannot know your servers. See the comments in that file for copy-pasteable row templates.
Quick start
OAuth-protected remote server
- insert:
- id: mcp-linear
name: 'dsh-mcp-client-plus'
config:
transport: streamable-http
serverName: linear
url: https://mcp.linear.app/mcp
auth: oauth
reconnect:
maxAttempts: 0
On first connect the host logs an authorization URL and opens your browser. After consent the loopback listener captures the code, tokens are stored, and the tools register. Tokens refresh automatically, and rotation is persisted.
Credentials live in $DSH_HOME/.dsh/mcp-client-plus-oauth.json, mode 0600, keyed by resource-server URL. Treat that file as a secret.
Token from the environment
- insert:
- id: mcp-tracecat
name: 'dsh-mcp-client-plus'
config:
transport: streamable-http
serverName: tracecat
url: https://tracecat.example.com/mcp
auth: bearer
tokenEnv: TRACECAT_MCP_TOKEN
The token itself never appears in config. auth: none sends no Authorization header at all.
Local stdio server
- insert:
- id: mcp-terraform
name: 'dsh-mcp-client-plus'
config:
transport: stdio
serverName: terraform
command: /Users/you/go/bin/terraform-mcp-server
args: [stdio]
env:
TFE_ADDRESS: https://app.terraform.io
TFE_TOKEN: ''
Migrating from the built-in client
Replace the name on each existing row and drop any header-expression bridge:
- id: mcp-linear
- name: '@deepseek-ai/dsh-mcp-client'
+ name: 'dsh-mcp-client-plus'
config:
transport: streamable-http
serverName: linear
url: https://mcp.linear.app/mcp
- headers:
- Authorization: !!js >-
- (function () { try { return 'Bearer ' + ...keychain read... } catch (e) { return ''; } })()
+ auth: oauth
Do not run both bridges for the same serverName: each holds its own namespace reservation, and the second registration fails on a duplicate public tool name.
Config reference
Fields common to both transports:
| Field | Default | Meaning |
|---|---|---|
serverName |
required | Namespace for mcp__<serverName>__*; [A-Za-z0-9_-]{1,32}, unique among live instances |
connectTimeoutMs |
15000 |
Bound on one connection attempt |
discoveryTimeoutMs |
30000 |
Bound on one tools/list page |
toolCallTimeoutMs |
60000 |
Bound on one tool call |
failOnStartupError |
false |
Reject plugin activation when the first attempt fails |
reconnect.enabled |
true |
Reconnect after a lost connection |
reconnect.initialDelayMs |
500 |
First backoff delay; doubles per consecutive failure |
reconnect.maxDelayMs |
30000 |
Backoff ceiling, and the uptime that resets the outage budget |
reconnect.maxAttempts |
10 |
Per-outage ceiling. 0 retries indefinitely |
transport: streamable-http only:
| Field | Default | Meaning |
|---|---|---|
url |
required | MCP endpoint |
auth |
none |
none, bearer, or oauth |
headers |
{} |
Literal header values |
headerEnv |
{} |
Header name → environment variable name |
tokenEnv |
— | Environment variable holding the bearer token |
oauth.scopes |
— | Scopes to request |
oauth.callbackPath |
/callback |
Loopback redirect path |
oauth.authorizationTimeoutMs |
300000 |
How long to wait for the human |
oauth.openBrowser |
true |
Launch the platform browser |
oauth.storePath |
$DSH_HOME/.dsh/mcp-client-plus-oauth.json |
Credential store |
For headless or remote hosts, set oauth.openBrowser: false — the authorization URL is always logged, so you can open it wherever your browser lives. The authorization server must accept a http://127.0.0.1:<port>/... loopback redirect, which the MCP specification requires for local clients.
The mcp_status tool
One mcp_status tool is registered per scope, reporting every server:
linear [connected] · 42 tools · auth=oauth · streamable-http
hex [failed] · 0 tools · auth=oauth · streamable-http · last error: giving up after 10 consecutive failed reconnect attempts …
Pass { "server": "hex" } to filter, or call it with no arguments to list all.
Compatibility
- DeepSeek Harness
>=0.1.5-rc.1 <0.2.0. Tested on0.1.5-rc.2. - Node
^22.19.0 || >=24.0.0. - Declares no npm
dependenciesorpeerDependencies, matching the convention used by otherdshplugins: the harness's own@deepseek-ai/*packages and@modelcontextprotocol/sdkresolve from the profile'snode_modules. - Upstream
master(0.1.6-alpha.2) moved to@modelcontextprotocol/clientv2 and requires@deepseek-ai/dsh-mcp-resources; this fork is based ondsh-v0.1.5-rc.2. SeePROVENANCE.md.
Relationship to dsh-mcp-manager
hyqhyq3/dsh-mcp-manager is a fuller-featured alternative and a good choice if you want a GUI: a Settings → MCP page, workspace-scoped servers, and an on-demand tool broker. This fork stays a drop-in replacement for the built-in bridge instead — same one-row-per-server composition model, same tool names, no client half and no GUI.
Development
npm run build # tsc -> lib/ (lib/ is committed: dsh loads it directly)
npm test # build first; runs node --test over tests/**/*.test.js
Because the plugin declares no dependencies, build and test resolve
@deepseek-ai/* and @modelcontextprotocol/sdk from a harness install. Point
node_modules at one before building:
ln -s "$(npm root -g)/@deepseek-ai/dsh/node_modules" node_modules
51 tests across three layers:
- Unit — the config and reconnect policy (including
maxAttempts: 0), header and secret resolution, the OAuth provider's persistence and credential invalidation, and the loopback redirect listener driven over real HTTP. - Transport end-to-end — a real MCP server over stdio through the transport factory: connect,
tools/list, andtools/call. Plus an assertion that an unresponsive server fails insideconnectTimeoutMsrather than the 60-second SDK default, and thatbearerandheaderEnvcredentials reach the wire. - Harness integration —
apply()mounted on a real Cordis context against a realctx.toolsregistry: tool registration and public naming,mcp_statusreporting and rendering, disposal unregistering everything, duplicateserverNamerejection, andfailOnStartupErrorrejecting activation.
Known limitations
- Tools only. MCP
resourcesandpromptsare not bridged, same as upstream. - One authorization per process for a new server. The redirect is received by a loopback listener owned by the plugin instance; if the host is headless, you complete the flow in whatever browser can reach the printed URL.
- OAuth tokens are stored in plaintext in a
0600JSON file. Static bearer tokens are read from the environment and never persisted. connectTimeoutMsboundsinitialize, not the whole TCP/TLS establishment; a hung DNS lookup may still take longer before the request timer applies.maxAttemptsdefaults to10, unchanged from upstream. Set0for the retry-forever behaviour; the default was left alone deliberately so the fork does not silently change an existing deployment's resource profile.
License
MIT. Portions derived from DeepSeek Harness (packages/mcp/mcp-client, MIT) at dsh-v0.1.5-rc.2. See LICENSE and PROVENANCE.md.
No comments yet. Be the first to write one.