Bundle
dsh-mcp-client-plus
DeepSeek Harness MCP client bridge with OAuth 2.1, connection/discovery timeouts, environment-indirected secrets, optional indefinite reconnection, and a mcp_status diagnostic tool
- Source
- anthonyyu-verkada
- License
- MIT
- Updated
- Updated 17 hours ago
Readme
# dsh-mcp-client-plus
An MCP client bridge for [DeepSeek Harness](https://github.com/deepseek-ai/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
```sh
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
```yaml
- 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
```yaml
- 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
```yaml
- 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:
```diff
- 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 on **`0.1.5-rc.2`**.
- Node `^22.19.0 || >=24.0.0`.
- Declares no npm `dependencies` or `peerDependencies`, matching the convention used by other `dsh` plugins: the harness's own `@deepseek-ai/*` packages and `@modelcontextprotocol/sdk` resolve from the profile's `node_modules`.
- Upstream `master` (`0.1.6-alpha.2`) moved to `@modelcontextprotocol/client` v2 and requires `@deepseek-ai/dsh-mcp-resources`; this fork is based on `dsh-v0.1.5-rc.2`. See `PROVENANCE.md`.
## Relationship to `dsh-mcp-manager`
[`hyqhyq3/dsh-mcp-manager`](https://github.com/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
```sh
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:
```sh
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`, and `tools/call`. Plus an assertion that an unresponsive server fails inside `connectTimeoutMs` rather than the 60-second SDK default, and that `bearer` and `headerEnv` credentials reach the wire.
- **Harness integration** — `apply()` mounted on a real Cordis context against a real `ctx.tools` registry: tool registration and public naming, `mcp_status` reporting and rendering, disposal unregistering everything, duplicate `serverName` rejection, and `failOnStartupError` rejecting activation.
## Known limitations
- **Tools only.** MCP `resources` and `prompts` are 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 `0600` JSON file. Static bearer tokens are read from the environment and never persisted.
- **`connectTimeoutMs` bounds `initialize`**, not the whole TCP/TLS establishment; a hung DNS lookup may still take longer before the request timer applies.
- **`maxAttempts` defaults to `10`, unchanged from upstream.** Set `0` for 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`.
Install
dsh plugin --profile web add github:anthonyyu-verkada/dsh-mcp-client-plus
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-mcp-client-plus from the hub
- This package builds from source on install. pnpm will ask you to allow its build script — that is permission to run the package’s code on your machine, outside the agent sandbox. Only allow sources you trust.
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.