The MCP plugin that ships with DSH has no OAuth support, and the expensive part of this task was not code — it was three layers of silent failure in the plugin loader. I put a stdio proxy in front of an internal MCP server, ended up with 33 registered tools, and hit ten distinct traps; each one was only located by reading logs and dumping the merged config tree.

Environment: DeepSeek Harness 0.2.2-rc.2 (Electron 44.7.0 / bundled Node 24.21.0), host Node v22.12.0, the app's bundled pnpm 11.28.5, mcp-remote@0.16.6, MCP SDK @modelcontextprotocol/client@2.2.0.

The goal was to let an agent query internal log and trace services, with sign-in going through standard dynamic OAuth (PKCE plus dynamic client registration) instead of a hand-pasted long-lived token.

The short version

# Symptom Root cause Fix
1 No OAuth or auth field anywhere in the MCP config createTransport builds only stdio and streamable-http transports, with no authProvider hook Put mcp-remote in front as a stdio proxy and let it own OAuth
2 Direct HTTPS calls fail with UNABLE_TO_VERIFY_LEAF_SIGNATURE The internal CA is not in Node's trust chain On a link that is already internal, use http:// plus --allow-http
3 Consent completes but no connection is established The callback uses localhost (which can resolve to ::1) while the local listener binds 127.0.0.1 --host 127.0.0.1 --bind-host 127.0.0.1
4 command: npx in the config never starts The app process PATH holds only system directories — no node, no npx Use absolute paths for node and proxy.js
5 The built-in MCP plugin package cannot be resolved from the profile An entry's name resolves in the profile scope; packages inside app.asar are not in it Install the plugin and its peer closure into the profile with the app's pnpm
6 A newly added plugin entry "does nothing" and logs stay empty A top-level - id: only overrides an existing entry; an unknown id is dropped whole New plugins must go inside a - insert: [...] list
7 The module is imported but apply never runs One indent level short: the keys under config: became siblings, so the merged entry has config: null Fix the indentation — keys two spaces deeper than config:
8 Startup fails and the UI says plugins are waiting for services An absolute-path hook file referenced by the patch was deleted by an external tool → ERR_MODULE_NOT_FOUND Copy the file back from the package template
9 After an app upgrade, rc.1 plugins "disappear" from the profile The loader audits versions strictly, skips an incompatible bundle whole, and prints one line to stderr Align every profile dependency to the same version
10 Latency comparison numbers were not trustworthy The test script talked to MCP directly, bypassing the native DSH tools Delete the scratch script; confirm mcp__ops__* really appears in the tool list

Capability boundary: why the official plugin cannot just do OAuth

The compiled output is enough (@deepseek-ai/dsh-mcp-client/lib/index.js):

function createTransport(config) { switch (config.transport) { case 'stdio': return new StdioClientTransport({ command, args, env, cwd }) case 'streamable-http': return new StreamableHTTPClientTransport(new URL(config.url), { requestInit: { headers: config.headers } // static headers only }) } } 
  • The config schema only exposes url and headers (values may be !!js expressions such as Bearer ${process.env.MCP_TOKEN}). There is no authProvider.
  • The bundled MCP SDK does support OAuth — OAuthClientProvider, UnauthorizedError, and registration_endpoint are all present.
  • The server side was a standard implementation: 401 with WWW-Authenticate: Bearer ... resource_metadata=... → RFC 9728 protected-resource metadata carrying authorization_endpoint, token_endpoint, registration_endpoint (DCR), and code_challenge_methods_supported: ["S256"].

So the gap is purely the client plugin's wiring. Either fork the plugin, or wrap a stdio MCP server that knows how to do OAuth around it. I chose the latter: no code to maintain, and nothing breaks when the app updates. This is the same layering that decides which config wins in config layer precedence; the difference only shows up at runtime.

The approach: let a stdio proxy own OAuth

How DSH reaches an internal MCP server through a stdio proxy

mcp-remote handles the whole OAuth dance: dynamic registration → PKCE → browser consent → token exchange → automatic refresh, with tokens cached at ~/.mcp-auth/mcp-remote-v1/<hash>_tokens.json. DSH sees nothing but an ordinary stdio MCP server.

Trap 1: the certificate chain

Direct HTTPS calls returned:

UNABLE_TO_VERIFY_LEAF_SIGNATURE 

The internal CA is in the system keychain; Node does not consult it. Three options: inject NODE_EXTRA_CA_CERTS, set NODE_TLS_REJECT_UNAUTHORIZED=0 (don't), or use plain http on a link that is already internal. I took the third:

http://mcp-gateway.internal.example/mcp --transport http-only --allow-http 

--allow-http is required; mcp-remote refuses cleartext endpoints otherwise. This only makes sense when the link itself is internal — never do it for a public endpoint. The diagnostic path is the same one described in TLS certificate errors behind a corporate proxy.

Trap 2: localhost is not 127.0.0.1

After clicking "allow", the browser returned to http://localhost:<port>/callback, but the local listener was bound to 127.0.0.1. In some environments localhost resolves to the IPv6 ::1 first, so the callback never lands → the page looks like a success while the connection is never established. Two flags and the whole flow finished in three seconds:

--host 127.0.0.1 --bind-host 127.0.0.1 

--host decides the generated redirect URI; --bind-host decides the actual listen address. You need both.

Trap 3: the host PATH is minimal

The DSH app process gets:

/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin 

No node, no npx. The mcp-remote shim starts with #!/usr/bin/env node, so command: npx always fails. Absolute paths are mandatory:

command: /Users/you/.n/bin/node args: - '/Users/you/.n/lib/node_modules/mcp-remote/dist/proxy.js' 

Three layers of silent failure in the plugin loader

This is where most of the time went, and it is the part worth writing down.

Three layers of silent failure: module scope, patch syntax, YAML indentation

Layer 1: module resolution scope

DSH merges the plugin tree in layers (bundle → patch), and each entry's name resolves in a specific scope:

  • Absolute path: anchored in the profile directory; relative paths anchor next to the patch file.
  • Package name: resolved from the profile's node_modules. Packages inside app.asar are not in that scope.

So the built-in @deepseek-ai/dsh-mcp-client, which the app itself uses every day, cannot be referenced from a patch. Symlinking it into app.asar does not work either (the ESM realpath check rejects it). The correct move is to install it into the profile:

cd ~/.dsh/profiles/desktop ELECTRON_RUN_AS_NODE=1 "/Applications/DeepSeek Harness.app/Contents/MacOS/DeepSeek Harness" \ ".../runtime/primary-runtime/dependencies/pnpm/bin/pnpm.mjs" \ add @deepseek-ai/dsh-mcp-client@0.2.2-rc.2 

Use the pnpm bundled with the app: it requires Node ≥ 22.13 and the host has 22.12.0, so run it with ELECTRON_RUN_AS_NODE=1 to borrow Electron's Node 24. After that it reports each missing peer in turn — dsh-scope → cordis → dsh-http-proxy → dsh-llm — until the peer closure is complete. Installing a plugin package by hand is also covered in installing a local plugin checkout into a profile.

Layer 2: insert versus override

cordis.patch.yml is the patch layer, and an entry has two possible meanings.

A bare - id: only overrides an entry that already exists. An unknown id is dropped whole, with nothing reported — an easy level to get wrong:

- id: mcp-ops-oauth name: '@deepseek-ai/dsh-mcp-client' 

A new plugin must sit inside an insert list:

- insert: - id: mcp-ops-oauth name: '@deepseek-ai/dsh-mcp-client' config: {...} 

The loader's failOnStartupError defaults to false, so the drop is silent. I copied an existing managed block and missed this wrapper.

Layer 3: indentation — imported but never applied

Even inside insert nothing happened. Temporary instrumentation showed:

[mcp] plugin module loaded ← the module really was imported (the apply line never appears) ← but apply never ran 

To separate "entry not mounted" from "waiting for a service", I added a probe plugin with the same shape (also inject: ['tools']). Its apply ran, which proved the entry was mounted and the tools service existed.

Dumping the merged tree made the real problem obvious:

- id: mcp-ops-oauth name: '@deepseek-ai/dsh-mcp-client' config: null # ← empty  serverName: ops # ← every config key landed at entry level  transport: stdio 

config: sat at six spaces and its keys also at six, making them siblings. Schema validation failed, cordis never created the plugin instance, so the module was imported but never applied. One more indent level and it logged ready outcome=ok immediately.

Rule of thumb: after insert, keys under config: must be indented deeper than the - id: line, and args: — a grandchild of config — one level deeper still.

A startup failure is not always the MCP plugin's fault

After the config change, the app would not start and the UI reported several plugins "waiting for services". The crash log (~/Library/Logs/DeepSeek Harness/crash-*.log) held exactly one real error:

agent-hooks → ERR_MODULE_NOT_FOUND ~/.dsh/hooks/agent-stop-hook.mjs 

An external tool had rewritten that hook plugin's managed block that day without writing the hook file back to disk, while the patch still referenced its absolute path → the plugin failed to load → startup stalled, and every other plugin merely looked like it was waiting. For the general ordering of that investigation, see what to check first when a plugin waits for services.

Plugins whose patch references an absolute-path file are soft dependencies: delete the file and startup dies, and the error never points at the patch file itself.

Two operational traps as well:

  • After a failed start, open -a does not relaunch (the main process hangs), and osascript quit returns -128 because the failure dialog blocks it.
  • Send kill -TERM first, fall back to kill -9, then open -a.

An upgrade silently skipped plugins

The app auto-upgraded from 0.2.2-rc.1 to 0.2.2-rc.2 while the profile still held rc.1. The loader audits versions strictly, skips an incompatible bundle whole, and prints one line to stderr:

dsh: skipping profile bundle "@deepseek-ai/dsh-subagent-codex": Error: Plugin ...@0.2.2-rc.1 is incompatible with dsh 0.2.2-rc.2 ... 

A normally started app shows no stderr at all — the symptom is simply "the codex / claude-code subagent features quietly vanished". Upgrading both subagent packages to rc.2 and re-running --dump-config left stderr clean.

Rule: after an app upgrade, align every @deepseek-ai/* dependency in the profile to the same version.

The diagnostic toolbox

--dump-config: read the merged tree

The desktop profile is owned exclusively by the Electron app, so it cannot be dumped directly — but a copy can:

mkdir -p /tmp/dshhome/profiles/test cp ~/.dsh/profiles/desktop/{cordis.yml,cordis.patch.yml,package.json,pnpm-lock.yaml} /tmp/dshhome/profiles/test/ ln -s ~/.dsh/profiles/desktop/node_modules /tmp/dshhome/profiles/test/node_modules DSH_HOME=/tmp/dshhome ELECTRON_RUN_AS_NODE=1 \ "/Applications/DeepSeek Harness.app/Contents/MacOS/DeepSeek Harness" \ ".../app.asar/dsh/node_modules/@deepseek-ai/dsh/lib/bin.js" \ --profile test --dump-config 

It answers three questions at once: did the entry reach the tree, is config nested correctly, and which bundle did the version audit skip (watch stderr).

Reading app.asar with Electron-as-Node

Plain cat cannot read asar, but Electron in Node mode has asar support in fs:

ELECTRON_RUN_AS_NODE=1 "/Applications/DeepSeek Harness.app/Contents/MacOS/DeepSeek Harness" -e ' const fs = require("fs") const f = "/Applications/DeepSeek Harness.app/Contents/Resources/app.asar/dsh/node_modules/@deepseek-ai/dsh-mcp-client/lib/index.js" console.log(fs.readFileSync(f, "utf8").slice(0, 2000)) ' 

Plugin sources, the loader implementation, and package.json versions all came from this.

Tiered probes

  • Probe A (inject: []): does the patch insert mechanism work at all?
  • Probe B (inject: ['tools'], same shape as the failing plugin): is the required service present, and is the entry actually mounted?
  • Instrumentation: one line to /tmp/*.log at module top level, the apply entry, createTransport, connection.ready, and syncTools, plus handlers for uncaughtException and unhandledRejection.

Note that pnpm add overwrites hand-edited files, so instrumentation disappears after an upgrade — that is not "the instrumentation stopped working".

Other

  • pgrep -fl "mcp-remote/dist/proxy.js": did the stdio child process start?
  • ~/Library/Logs/DeepSeek Harness/crash-*.log: the only trustworthy record of a failed start.
  • A healthy app writes almost nothing to stderr; do not expect plugin errors there.

Two methodology traps while verifying

  1. Counting tools: my first two counts (40 and 34) were both wrong — they included serverInfo.name and duplicated SSE events. Count only the deduplicated raw names in tools/list's tools[]; both paths (static token and OAuth) yield 33.
  2. Test contamination: for a few rounds, while no native tool existed, the automation found the token in ~/.mcp-auth and wrote its own direct MCP client. It "could query logs", but through a script rather than a DSH tool, and the latency comparison was meaningless. The only valid proof of a working integration is mcp__ops__* showing up in the session's tool list, not a script that can reach the server.

The final config

New plugins must sit inside an insert list: a bare - id: only overrides an existing entry, and an unknown id is dropped silently (failOnStartupError defaults to false).

- insert: - id: mcp-ops-oauth name: '@deepseek-ai/dsh-mcp-client' config: serverName: ops transport: stdio # absolute paths: the app PATH has no node or npx  command: /Users/you/.n/bin/node args: - '/Users/you/.n/lib/node_modules/mcp-remote/dist/proxy.js' - 'http://mcp-gateway.internal.example/mcp' - '--header' - 'x-mcp-client-version: 1.0.0' - '--transport' - 'http-only' - '--allow-http' - '--host' - '127.0.0.1' toolCallTimeoutMs: 180000 

Instrumented startup log:

[mcp] apply serverName=ops [mcp] createTransport {"transport":"stdio","command":"/Users/you/.n/bin/node",...} [mcp] syncTools registering 33 tools: mcp__ops__ops-global-helper, mcp__ops__ops-log, ... [mcp] ready outcome=ok 

A reusable checklist

When adding an MCP plugin to DSH that has to spawn its own child process, work through this in order:

  1. Does name point at a package inside app.asar? → pnpm add it into the profile first, using the app's pnpm through Electron-as-Node.
  2. Is the entry new? → it must be written as - insert:, never a bare - id:.
  3. Are config: keys indented deeper than - id:? → confirm with --dump-config that config is not null.
  4. Is the child-process command an absolute path? → the app PATH holds only system directories.
  5. Did the app version change? → align every @deepseek-ai/* in the profile, or whole bundles get skipped.
  6. Incomplete certificate chain? → inject NODE_EXTRA_CA_CERTS, or use http plus --allow-http on an internal link.
  7. Startup failing? Read the crash log, not just the "waiting for services" message in the UI.
  8. The acceptance test is mcp__<server>__* in the tool list, not a script that can connect.
  9. Delete probes and instrumentation when done: probe files, patch entries, /tmp logs, debug branches in source.

What these problems share is that every one of them fails silently. A plugin that is never inserted raises nothing, a malformed config raises nothing, an incompatible version prints one stderr line, and a failed start only says "waiting for services". So the transferable lesson is not the ten traps — it is the three ways of getting evidence: --dump-config, Electron-as-Node, and tiered probes.

Originally published at https://dsh-plugin.com/blog/connect-internal-mcp-with-oauth