{"id":"57cfed13-e843-4d11-b36c-f9dfafff98b7","slug":"dsh-as-mcp","kind":"mcp","name":"dsh-as-mcp","summary":"dsh-as-mcp","owner":"xiseliuli","repoUrl":"https://github.com/xiseliuli/dsh-as-mcp","license":"MIT","licenseTier":"permissive","trust":"community","stars":6,"match":null,"fewStars":false,"format":"mcp","createdAt":"2026-09-30T07:24:55.000Z","addedAt":"2026-10-21T18:11:55.978Z","logoUrl":"https://avatars.githubusercontent.com/u/51782608?u=48c751d68d76450fb9c65e09d14d7fb101610a23&v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["utilities"],"tags":["dsh-plugin"],"runtime":"node","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-25T20:05:10.020Z","sample":false,"url":"https://conduits.dev/tools/dsh-as-mcp","language":"TypeScript","transports":["stdio"],"install":{"npm":"dsh-as-mcp"},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"<p align=\"center\">\n  <!-- Absolute URLs on purpose: npm renders the README from the tarball and does\n       not resolve relative image paths, so a relative src shows up broken there.\n       Two variants because the wordmark is dark navy, which vanishes on GitHub's\n       dark theme. -->\n  <picture>\n    <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://raw.githubusercontent.com/xiseliuli/dsh-as-mcp/main/assets/logo-dark.png\">\n    <img src=\"https://raw.githubusercontent.com/xiseliuli/dsh-as-mcp/main/assets/logo-light.png\" width=\"380\" alt=\"dsh-as-mcp\">\n  </picture>\n</p>\n\n<p align=\"center\">English | <a href=\"README.zh.md\">中文</a></p>\n\n<p align=\"center\">\n  <a href=\"https://github.com/xiseliuli/dsh-as-mcp/actions/workflows/ci.yml\"><img src=\"https://github.com/xiseliuli/dsh-as-mcp/actions/workflows/ci.yml/badge.svg\" alt=\"CI\"></a>\n  <a href=\"https://github.com/xiseliuli/dsh-as-mcp/releases\"><img src=\"https://img.shields.io/github/v/release/xiseliuli/dsh-as-mcp?label=release&color=2f81f7\" alt=\"Release\"></a>\n  <a href=\"LICENSE\"><img src=\"https://img.shields.io/github/license/xiseliuli/dsh-as-mcp?color=3fb950\" alt=\"License: MIT\"></a>\n  <a href=\"https://www.typescriptlang.org/\"><img src=\"https://img.shields.io/badge/TypeScript-5.9-3178C6?logo=typescript&logoColor=white\" alt=\"TypeScript\"></a>\n  <a href=\"https://nodejs.org/\"><img src=\"https://img.shields.io/badge/node-%5E22.19.0%20%7C%7C%20%3E%3D24-339933?logo=nodedotjs&logoColor=white\" alt=\"Node\"></a>\n  <a href=\"https://modelcontextprotocol.io/\"><img src=\"https://img.shields.io/badge/MCP-server-8B5CF6\" alt=\"MCP server\"></a>\n  <a href=\"https://github.com/topics/dsh-plugin\"><img src=\"https://img.shields.io/badge/DSH-plugin-4B8BBE\" alt=\"DSH plugin\"></a>\n</p>\n\nExpose a running **DeepSeek Harness** as an **MCP server**, so any other agent — Claude\nCode, Codex, another DSH, a CI job, your own script — can drive it: create workspaces,\nstart sessions, hand the DSH agent a coding task, read and write files, run commands.\n\nThe endpoint runs *inside* the DSH host process, so a session created over MCP is a real\nDSH session. It appears live in the DSH UI, runs inside the DSH sandbox, and is subject to\nthe same permission policy as anything you type yourself. Nothing about the agent is\nreimplemented.\n\n## How it works\n\n```mermaid\nflowchart LR\n  subgraph clients[\"Any MCP client\"]\n    direction TB\n    c1[\"Claude Code\"]\n    c2[\"Codex\"]\n    c3[\"another DSH\"]\n    c4[\"a CI job\"]\n    c5[\"your own script\"]\n  end\n\n  subgraph host[\"DSH host process\"]\n    direction TB\n    plugin[\"dsh-as-mcp<br/>(this plugin)\"]\n    tools[\"workspace_* · session_*<br/>file_* · shell_* · dsh_tool_*\"]\n    services[\"the harness's own services<br/>workspace · session · fs · shell\"]\n    plugin --> tools --> services\n  end\n\n  clients -- \"Streamable HTTP or stdio<br/>bearer token\" --> plugin\n```\n\n---\n\n## Install\n\nThree channels install this bundle into a profile; pick whichever matches where you got the\npackage from.\n\n**From npm:**\n\n```bash\ndsh plugin --profile <name> add dsh-as-mcp\n```\n\nFrom a source checkout, the equivalent is `pnpm dsh plugin --profile <name> add dsh-as-mcp`.\n\n**From GitHub:**\n\n```bash\ndsh plugin --profile <name> add github:xiseliuli/dsh-as-mcp\n```\n\nA git install fetches source, not the built `lib/`, so pnpm must run this package's `prepare`\nscript (`tsdown && node scripts/build-client.mjs`) to produce it. pnpm ≥10 refuses to run that\nscript — and the `esbuild` postinstall the build depends on — until the profile allows them, so\nthe first `add` fails with `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`.\n\nA bare package name in `allowBuilds` works for `esbuild` (a registry dependency) but never for\n`dsh-as-mcp` here: pnpm only approves a git-hosted build by its exact resolved key, never by\nname alone ([pnpm docs](https://pnpm.io/settings/build#allowbuilds)). The failed `add` prints\nthat exact key in its error; copy it verbatim into the profile's `pnpm-workspace.yaml`\n(`~/.dsh/profiles/<name>/pnpm-workspace.yaml`) and re-run the `add`. Don't rely on pnpm having\nwritten a placeholder entry for you — on this failure path it does not. The result looks like:\n\n```yaml\nallowBuilds:\n  '<the exact git spec pnpm wrote, including its commit hash>': true\n  esbuild: true\n```\n\nTreat that allowance as permission to run this package's code on your machine at install time;\npin a commit (`github:xiseliuli/dsh-as-mcp#<sha>`) so a later push cannot silently change what runs.\n`dsh plugin add` runs the pnpm version DSH pins (v11.7.0 as of DSH 0.1.7-rc.2 — its output ends\nwith `using pnpm v…`), so the following applies only once DSH ships a newer pnpm. If the\nprofile's pnpm is ≥11.19.0 (≥11.11.0 for a cloned, non-`github:` git dependency), you can instead\napprove the repository itself — `'dsh-as-mcp@git+https://github.com/xiseliuli/dsh-as-mcp.git': true`, with\nno `#<sha>` — so a later commit on the same repo keeps building without re-approval; on an older\npnpm the exact-commit key is your only option and needs re-approving after every update\n([pnpm 11.11 release notes](https://pnpm.io/blog/releases/11.11-11.14),\n[pnpm/pnpm#12367](https://github.com/pnpm/pnpm/issues/12367)).\n\n**From a tarball:**\n\n```bash\npnpm pack\ndsh plugin --profile <name> add /abs/path/to/dsh-as-mcp-<version>.tgz\n```\n\nReinstalling from the *same* tarball path silently keeps the previous build — see the warning\nunder \"Installing into DSH Desktop\" below for why, and give each rebuild a unique filename.\n\n---\n\n`dsh plugin add` records the package in the profile's `dsh.profile.bundles`, and the\nbundle's `cordis.patch.yml` supplies the plugin row and its defaults. **Restart DSH\nafterwards**: bundle patches are read at boot, not hot-reloaded.\n\nConfirm the layer composed:\n\n```bash\ndsh --profile <name> --dump-config | grep -A 30 '# == dsh-as-mcp'\n```\n\n**For publishers:** tag the GitHub repository with the topic `dsh-plugin` — that is what plugin\ndiscovery keys on — and keep npm's `latest` dist-tag pointed at an exact, stable version (no\nprerelease, no range), since that is what `dsh plugin add dsh-as-mcp` resolves.\n\n## Connect a client\n\nEvery request needs a bearer token. It is resolved in this order:\n\n1. `auth.token` in the plugin config;\n2. `$DSH_HOME/dsh-as-mcp/token`;\n3. otherwise generated and persisted there on first run, with mode `0600`.\n\nThe default endpoint is `http://127.0.0.1:8790/mcp`.\n\nA client that cannot set a header may pass `?token=<token>` instead. Prefer the header: a\ncredential in a URL is one your shell history, a reverse proxy's access log, or a browser's\nhistory may keep. DSH itself does not log query strings (`webserver/src/index.ts:224`) and the\nplugin does not log the token, so this is a habit worth keeping rather than a leak this code\nintroduces.\n\n**HTTP-capable client** (Streamable HTTP):\n\n```json\n{\n  \"mcpServers\": {\n    \"dsh\": {\n      \"type\": \"http\",\n      \"url\": \"http://127.0.0.1:8790/mcp\",\n      \"headers\": { \"Authorization\": \"Bearer <token>\" }\n    }\n  }\n}\n```\n\nThe settings panel builds exactly this snippet with the token already filled in, so prefer its\n**Copy** button over editing `<token>` by hand.\n\n**stdio-only client** — use the bundled bridge, which forwards each JSON-RPC message to the\nendpoint over HTTP:\n\n```json\n{\n  \"mcpServers\": {\n    \"dsh\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"dsh-as-mcp\"],\n      \"env\": { \"DSH_AS_MCP_TOKEN\": \"<token>\" }\n    }\n  }\n}\n```\n\n`dsh-as-mcp --help` prints the endpoint and token state it will use. The bridge sends a token\nread from the token file to loopback endpoints only; pointing `DSH_AS_MCP_URL` off-host requires\nsetting `DSH_AS_MCP_TOKEN` explicitly, so a copied client config cannot quietly exfiltrate the\nmachine-local credential. \"Loopback\" means an exact match — `localhost`, `::1`, or a literal\n`127.x.x.x` address — so a name such as `127.0.0.1.example.com` is treated as the remote host it\nis. A scheme is optional: `127.0.0.1:8790/mcp` is accepted and understood.\n\nStart with **`dsh_info`**. It reports the endpoint, where its token comes from, which tool groups are enabled,\nand — importantly — which harness services the current profile actually provides, so a client\ncan tell \"this profile has no shell\" from \"the command failed\".\n\n## Tools\n\n| Tool | What it does |\n| --- | --- |\n| `dsh_info` | Endpoint, token source, enabled groups, available harness services. |\n| `workspace_create` | Register a directory as a DSH workspace (creating it if needed). |\n| `workspace_list` | Every registered workspace with id, path, title, session count, directory status. |\n| `session_create` | Start a DSH agent session bound to a workspace or bare directory. |\n| `session_list` | Live session summaries, most recently updated first. |\n| `session_prompt` | Send one task to a DSH session; by default waits for the turn and returns the reply plus every tool call the agent made. |\n| `session_messages` | Read a session's user/assistant transcript. |\n| `session_cancel` | Ask the agent to stop its current turn. |\n| `file_read` | Read a UTF-8 file through DSH's filesystem service. |\n| `file_write` | Create or replace a file through DSH's filesystem service. |\n| `file_list` | List a directory through DSH's filesystem service. |\n| `shell_run` | Run one command through DSH's shell service. |\n| `dsh_tool_list` | The agent tools this endpoint permits, with the schema DSH's own agent sees. |\n| `dsh_tool_call` | Run one of them directly, through the same pipeline the agent uses. |\n\nThe typical coding flow:\n\n```\nworkspace_create { path: \"/Users/me/project\" }\n  -> session_create { workspaceId: \"...\" }\n  -> session_prompt { sessionId: \"...\", prompt: \"Add a --verbose flag to the CLI and a test for it.\" }\n```\n\n`session_prompt` is how you get work done *by* DSH: the DSH agent plans, edits files, runs\nits own tools, and can spawn subagents. `file_*` and `shell_run` are for when you want to do\nsomething yourself without involving the agent.\n\n`dsh_tool_call` is the third option: it runs one of DSH's *own* tools without spending a model\nturn to decide to call it. Call `dsh_tool_list` first — it reports exactly what is permitted, and\na name it does not report is refused. Both require a `sessionId`, because that session's agent\n*is* the policy: it is what makes the harness apply a sandbox, run guards, and file the call under\na transcript.\n\nThere is deliberately no session-less form. DSH registers a tool into the scope of the context\nthat registers it, and every tool package ships inside an agent preset, so the global layer is\nempty — an unscoped listing really does return zero tools, and an unscoped call answers\n`unknown tool`. A `dsh_tool_list` without a session therefore **fails loudly** rather than\nreporting an empty toolbox, which would read as \"nothing is permitted\". For the agentless path\nuse `file_read` / `file_write` / `file_list` / `shell_run`, which resolve the deployment policy\ndirectly and need no session.\n\nThe permitted set is an **allow-list**, and it is deliberately narrow. Everything absent is\nrefused *and* hidden from `dsh_tool_list`. The omissions that matter:\n\n- **`run_code`** is DSH's programmatic-tool-calling entry point: one call runs a program that\n  can invoke *any other tool by name*. Exposing it would void the list entirely.\n- **`cordis_run`, `cordis_define`, …** execute arbitrary plugin code. No shipped bundle mounts\n  them, so a deny-list written against today's profile would be blind in exactly the profile\n  that adds them — which is why this is an allow-list.\n- **`ask_user_question` and `present`** reach the human at the keyboard.\n- **`workflow`, `ralph`, `send_message`, `spawn_teammate`, `schedule_create`** start work that\n  outlives the call; **`create_goal` and `update_goal`** sustain unattended execution.\n\nAn operator can widen this deliberately with the `agentTools.allow` setting, or subtract from\nthe default with `agentTools.deny`.\n\n## Security\n\nThis endpoint is remote control of a coding agent that has shell access. Treat the token like\nan SSH key — and note that the plugin does too: no tool returns its value. `dsh_info` reports\nwhere the token comes from (a file path or the composition), never the literal, so a calling\nagent's transcript never accumulates the credential. A pinned token shorter than 16 characters\ndraws a warning at boot, and a generated one is 43 characters of CSPRNG output.\n\n- The listener binds to `127.0.0.1` and every request is bearer-checked, including requests on\n  a route mounted on DSH's own web server. That check is the *only* gate there: an exact route\n  registered on the web server is matched **before** DSH's authorization fence — anything\n  unmatched is handed to that fence as a fallback (`webserver/src/index.ts:222-227`) — so with\n  `http.mountOnWebServer: true` the endpoint does not inherit your browser session's\n  protection, it enforces its own. One caveat on DSH Desktop: when ordinary browser access is\n  disabled there, the web server additionally refuses requests that do not carry the renderer\n  header, so the mounted path is unreachable for a plain MCP client — use the plugin-owned\n  listener. Setting `http.host: 0.0.0.0` exposes that same power to your network; the plugin\n  warns at bind time, and you should do it only behind your own gateway.\n- **The token is the entire security boundary, and that boundary is your user account.** There\n  is no path sandbox. Once a caller holds the token, `file_read`/`file_write`/`file_list` reach\n  anything the DSH process can reach, and `shell_run` runs arbitrary commands as you — because\n  that is exactly what the harness's own `fs` and `shell` services do for DSH's own agent. Both\n  properties were verified against a live instance: `file_read` returned `/etc/passwd`,\n  `~/.dsh/settings.yaml`, and this plugin's own token file; `file_write` created a file outside\n  every registered workspace. A workspace sets where a *session's* agent starts; it does not\n  fence these tools.\n- **The bridge is instance-wide, not per-caller.** `session_list` enumerates every session in\n  this DSH instance and `session_messages` reads any of their transcripts — verified reading a\n  session this plugin did not create. Those are the sessions you have been talking to DSH in, so\n  a token holder sees your conversation history, and a calling agent's context accumulates it.\n  This is not an escalation (the same bytes are in `$DSH_HOME/sessions`, reachable through\n  `shell_run`), but it is a privacy consequence worth knowing before you hand out a token.\n- Consequently the `tools` toggles **narrow what a client can discover and call; they are not\n  containment.** `tools.shell: false` removes the shell tool, but `file_write` still writes your\n  shell startup files and `file_read` still reads your credentials, so a profile with the shell\n  tool switched off is not safe to hand to a caller you would not give a login to. For real\n  containment, run the whole DSH instance inside an OS-level sandbox or as a dedicated\n  unprivileged account, and treat the token as that account's password.\n- `approval.policy` decides what happens when the DSH agent wants to run something the harness\n  would normally ask a human about:\n  - `inherit` (default) — the plugin does not answer. With no browser attached, an\n    approval-requiring tool resolves `unavailable` and the agent's action fails closed.\n  - `allow` — the plugin approves every request raised by a session **it created**, and stays\n    out of the waterfall for every other session, so your own interactive sessions keep their\n    policy. This is what makes unattended agent runs work; it is a real widening of what the\n    agent may execute.\n- `file_write` and `shell_run` are the *caller's* actions, not the agent's. They run through\n  the harness's own filesystem and shell services, so the sandbox and policy configured for\n  this DSH instance still apply.\n\n## Installing into DSH Desktop (restart required)\n\nElectron reserves the `desktop` profile, so `dsh plugin --profile desktop add` is refused from a\nplain terminal and the install is manual. DSH Desktop must then be **restarted**.\n\n### Why a restart is required\n\n`patchReload: live` in the profile manifest only governs the **CLI launcher**. The no-restart\nrecompose is implemented by `watchUserPatches()`, which lives in the CLI's `runProfile()`\n(`apps/cli/src/profile-boot.ts`). DSH Desktop takes a different path: it calls `boot()` from\n`@deepseek-ai/dsh-app-boot` with the patch list computed once at startup, and the shipping app\ncontains no such watcher at all:\n\n```bash\ngrep -rn watchUserPatches <dsh-desktop>/dsh-plugin-desktop/src/    # no matches\n```\n\nMeasured behaviour agrees: after editing the profile's `cordis.patch.yml`, the app log gained not\none line and the port never opened.\n\nSo: **a CLI-launched profile (`dsh --profile xxx`) applies patch edits immediately; DSH Desktop\nmust be restarted.** On either path the watcher only recomposes config rather than replacing\nloaded modules, so editing `lib/` needs a restart regardless.\n\n### If the app will not start afterwards\n\nReset `~/.dsh/profiles/desktop/cordis.patch.yml` to `[]` and the plugin takes no part in startup.\nTo remove it entirely: `cd ~/.dsh/profiles/desktop && pnpm remove dsh-as-mcp`.\n\n### 1. Pack it and install into the desktop profile\n\n```bash\ncd /path/to/dsh-as-mcp && pnpm pack --pack-destination /tmp\n\n# raw pnpm, bypassing the CLI's reserved-profile guard\ncd ~/.dsh/profiles/desktop && pnpm add /tmp/dsh-as-mcp-0.1.0.tgz\n```\n\n> **Reinstalling over an existing install: change the tarball path.**\n> `pnpm` keys a `file:` dependency on the *path*, and a reinstall from the same path\n> reports `added 0` while leaving the previous content linked — a silently stale\n> build that still boots and still runs the old code. Give each build a unique\n> filename and the spec string changes with it:\n>\n> ```bash\n> TARBALL=/tmp/dsh-as-mcp-$(date +%s).tgz\n> cd /path/to/dsh-as-mcp && pnpm pack --pack-destination \"$(dirname $TARBALL)\"\n> mv /tmp/dsh-as-mcp-0.1.0.tgz \"$TARBALL\"\n> cd ~/.dsh/profiles/desktop && pnpm remove dsh-as-mcp && pnpm add \"$TARBALL\"\n> ```\n>\n> Then confirm the bytes actually match, or you are testing the old build:\n>\n> ```bash\n> wc -c ~/.dsh/profiles/desktop/node_modules/dsh-as-mcp/lib/index.js \\\n>       /path/to/dsh-as-mcp/lib/index.js\n> ```\n\n### 2. Put the plugin row in the profile's own patch layer\n\nEdit `~/.dsh/profiles/desktop/cordis.patch.yml` (replace the `[]`):\n\n```yaml\n- insert:\n    - id: dsh-as-mcp\n      name: dsh-as-mcp\n      config:\n        http:\n          enabled: true\n          host: 127.0.0.1\n          port: 8790\n          path: /mcp\n        tools:\n          workspace: true\n          session: true\n          files: true\n          shell: true\n```\n\nThen **restart DSH Desktop** to mount the plugin.\n\n> ⚠️ **Do not also add `dsh-as-mcp` to `dsh.profile.bundles`.** The bundle contributes its own\n> row, so the composed result carries **two rows sharing one id** (`dsh --profile <name>\n> --dump-config` shows both). Use the patch layer or the bundle list, never both. Switch to the\n> bundle route via `dsh plugin add` when you want it to persist across restarts.\n>\n> This is worse than a duplicated row: **every write from the settings panel is refused**, with\n> `Configuration for \"dsh-as-mcp\" is overridden by a home patch or command-line overlay`. DSH's\n> config editor validates an edit against the *first* composed row while the Loader applies the\n> *last*, so with two rows they never agree and nothing is ever persisted. The panel only says\n> `Write failed; the host state was re-read. Please retry.` — which reads like a transient\n> failure, not a composition mistake. If you are on the bundle route, delete the hand-written\n> `- insert:` row; if you are on the patch route, remove `dsh-as-mcp` from\n> `dsh.profile.bundles`.\n\n### 3. Smoke-test it\n\n```bash\nnode ~/.dsh/profiles/desktop/node_modules/dsh-as-mcp/scripts/smoke.mjs\n```\n\nIt reads `<DSH_HOME>/dsh-as-mcp/token`, shakes hands, lists tools, calls `dsh_info` to report which\nharness services this profile actually mounted, then calls `workspace_list`. The last line is `OK`\non success and the exit status is non-zero on failure.\n\nFor one real round trip (temp workspace → session → hand the DSH agent a task → print the reply and\ntool calls):\n\n```bash\nnode ~/.dsh/profiles/desktop/node_modules/dsh-as-mcp/scripts/smoke.mjs \\\n  --prompt \"In the current workspace create hello.txt containing hi, then read it back to confirm\"\n```\n\n### 4. Uninstall\n\nReset `~/.dsh/profiles/desktop/cordis.patch.yml` to `[]` and restart, and the plugin no longer\nmounts. To remove it entirely: `cd ~/.dsh/profiles/desktop && pnpm remove dsh-as-mcp`.\n\n## Configuration\n\nDefaults ship in `cordis.patch.yml`. A profile's own `cordis.patch.yml` overrides the row by\n`id`, and an override **replaces the row's whole `config` object** rather than deep-merging\nit — restate every key you still want. Unknown keys are rejected at boot with the offending\nkey named, because a silently ignored typo means your override is not being applied.\n\n```yaml\n- id: dsh-as-mcp\n  name: dsh-as-mcp\n  config:\n    http:\n      enabled: true          # plugin-owned listener\n      host: 127.0.0.1\n      port: 8790             # 0 asks the OS for a free port\n      path: /mcp\n      mountOnWebServer: false # also serve at http://<dsh host>:<dsh web port>/mcp\n    auth:\n      token: ''              # empty: read/generate $DSH_HOME/dsh-as-mcp/token\n    tools:\n      workspace: true\n      session: true\n      files: true\n      shell: true\n    session:\n      agentPreset: ''        # empty: harness default\n      provider: ''           # must be paired with model\n      model: ''\n      promptTimeoutMs: 900000\n    limits:\n      maxReadBytes: 1048576\n      shellTimeoutMs: 120000\n      agentToolTimeoutMs: 120000\n    approval:\n      policy: inherit        # inherit | allow\n```\n\n## Settings panel\n\nWhere the host mounts a settings service — DSH Desktop and `dsh web` both do — the plugin\ncontributes an **MCP server** section to the settings panel. It edits the same `dsh-as-mcp`\nnamespace the configuration above fills, so the panel and the file are two views of one\nvalue, not two copies.\n\nThe panel covers every setting including the `agentTools.allow` and `agentTools.deny` lists,\nwhich are edited as comma-separated text. Both are ordinary `string[]` values, so an entry with a\ncomma in it cannot be expressed here; use the configuration file for that.\n\nEvery change applies **live**:\n\n| Change | Effect |\n| --- | --- |\n| a tool toggle | the group leaves or joins the very next `tools/list`; a disabled tool is genuinely absent, so calling it reports an unknown tool rather than running and being refused |\n| a limit, or a session default | read at the start of the next call |\n| the token | the new value is required by the next request; the old one stops working immediately |\n| `enabled`, `host`, `port`, `path`, `mountOnWebServer` | the listener is moved |\n\nChanging the port is the case worth stating plainly: the plugin stops the old listener and\nstarts the new one, so the endpoint really does move. If the new port is taken, the bind error\nis shown in the panel and in `dsh_info` rather than being swallowed.\n\nThe token is rendered **write-only in the form**: its literal never reaches the settings form, so\nthe input only writes, and a \"clear\" button removes the override this panel wrote (a token the\ncomposition config supplies is not affected by it and stays in force). Whether one is pinned is\nreported by the host, not guessed by the page: `auth.token` deliberately carries no schema\ndefault, because the settings service answers \"is this secret set?\" with `value !== undefined`\nand a default would make every panel claim a token was pinned from the moment the plugin loaded.\n\nThe panel's **copyable client configuration** is the one place the literal is deliberately handed\nout. The **Copy** button writes the snippet with the real effective token in it, so pasting it\njust works; the text on screen is masked (with a \"Show token\" button) so a screenshot or a shared\nscreen does not leak it. Both come from the same read-only status route, on DSH's connection\nlayer — inside that layer's Host/Origin and browser-cookie fence, so it inherits DSH's own\nauthorization and is never exposed on a bare port. `dsh_info` still reports only the token's\nsource: a caller that authenticated with the token does not need it echoed into its transcript.\n\nThe panel is a `settings.section` page the browser half registers through\n`ctx.configForms.whileServed`, so it appears only while the host actually serves this entry's\nform. That needs `@deepseek-ai/schemastery`: DSH 0.2.0-rc.2 projects a form only for an entry\nwhose `Config` carries a `toJSON()`, and it keeps only fields marked `meta.volatile`. There is\nno schemastery-free path to a panel, so the module is a declared peer and imported directly —\nwithout it the plugin cannot be imported at all, which is why the compatibility range below is\nwhat protects a host that cannot supply it.\n\n## Compatibility\n\n- **Harness** ≥ `0.1.5-rc.1`, declared as `peerDependencies[\"@deepseek-ai/dsh\"]:\n  \">=0.1.5-rc.1\"` with no upper bound. DSH's plugin loader reads that range straight from the\n  manifest and semver-checks it against the single running runtime version (prereleases\n  included) *before* the plugin is imported; an incompatible install is refused unless the host\n  grants an exact-version exemption (`dsh plugin allow-version`).\n- **Node** `^22.19.0 || >=24.0.0`.\n- The `@deepseek-ai/dsh` peer is declared **optional**, purely so `dsh plugin add` does not pull\n  a full harness install into every profile just to satisfy it. Optional only changes\n  installation — DSH's compatibility gate reads `peerDependencies` regardless of\n  `peerDependenciesMeta`, so the version check above still applies in full.\n- **Settings model.** Verified against `dsh 0.2.0-rc.2`, whose settings service replaced the\n  0.1.x API this plugin first shipped against: `settings.installSection` is gone, a namespace is\n  projected from the entry's own `Config` keyed by the Loader row id, and only `meta.volatile`\n  fields are editable — a volatile write is committed into the running reference without a\n  remount. The host half therefore declares no registration at all and reads its configuration\n  through `plainConfig()` on every access, and the only settings call it still makes is\n  `configure({ auto: false }, ctx.fiber)` (it ships its own page). **The panel needs the 0.2\n  settings model**; on a 0.1.x runtime the endpoint still runs from the configuration file, but\n  there is no panel, because that runtime only projects a form for a namespace a plugin\n  registered through the removed call.\n- **No other hard `@deepseek-ai/*` dependency.** This package resolves every other capability\n  structurally through `ctx.get(name)`. The one exception is `@deepseek-ai/schemastery`, a\n  declared peer imported directly: DSH offers no schemastery-free path to a settings form.\n\n## Design notes\n\n- **Capability detection, not `inject`.** The plugin loads in a bare CLI profile that has no\n  web server, no session service, and no filesystem seam, and each tool then reports exactly\n  which service is missing and which bundle provides it. Declaring `inject` would make the\n  loader refuse to mount the plugin instead, which is a worse answer for a capability bridge.\n- **Waiting for a turn.** The harness exposes no per-message \"await this turn\" call —\n  `sessionController.prompt()` returns as soon as the message is queued. So `session_prompt` polls\n  the durable session log for a `user/message` whose `source.rpcId` equals the `requestId` it\n  passed to `prompt()` — the same correlation the session controller's own idempotency check uses\n  — and then waits for *that* turn to close. Attribution is what makes this correct rather than\n  plausible: a queued prompt's log entry sits inside the span of the turn that was already\n  running, so a `turn/end` appearing after our message is usually someone else's turn, not ours.\n  Turns on one session are additionally serialized, so two concurrent callers cannot interleave\n  prompts and then disagree about which reply is theirs. The serialization covers the whole wait,\n  not just the submission: a second `session_prompt` to a session whose first wait is still open\n  does not even submit its message until that wait settles (up to its timeout), and a `steer` sent\n  while another MCP wait is in flight is deferred until it, which turns it into a queued prompt.\n  Callers that only want to queue a message should prefer `session_messages` polling over holding\n  a long wait open.\n- **Filesystem.** Only `workspace_create` touches `node:fs` `mkdir` — the directory it creates\n  *is* the new sandbox root, so by definition it sits outside every root that exists before it,\n  which is why the call cannot go through the filesystem seam and is instead gated on the\n  read-only policy. The harness filesystem service deliberately exposes no `mkdir`; `file_write`\n  relies on the seam's atomic write, which creates parents inside the fence. Every read and\n  write goes through `ctx.fs`, so the same path rules and sandboxing the DSH agent lives\n  under apply to the caller.\n- **No session deletion.** The harness offers `archiveSession`/`unarchiveSession` but no\n  delete, so neither does this plugin.\n\n## Development\n\n```bash\npnpm install\npnpm build       # tsdown -> lib/\npnpm typecheck   # tsc --noEmit\npnpm test        # vitest\n```\n\nThe test suite runs real HTTP against a real listener, spawns the real `bin/mcp-stdio.mjs`,\nand loads the plugin into a real `@deepseek-ai/cordis` context — with `resolveConfig` doing the\nconfig validation — so the wire protocol, the bridge, and the loader contract are all\nexercised rather than mocked.\n\nTwo scripts run against a **live** endpoint, which is a different thing. Both resolve it the\nsame way — the `--url`/`--token` flag wins, then `DSH_AS_MCP_URL`/`DSH_AS_MCP_TOKEN`, then the\nloopback default — and both print the endpoint they resolved and where it came from (`arg` /\n`env` / `default`) to stderr before sending anything, so a stray run cannot silently land on\nwhatever DSH instance happens to be listening on the default port:\n\n```bash\nnode scripts/smoke.mjs       # handshake, tools/list, dsh_info — is the wire up?\nnode scripts/exercise.mjs    # ~40 checks: create a workspace, start a session, have the\n                             # DSH agent write code, read it back off disk, run it, check\n                             # the transcript, and confirm the failure paths are legible\n```\n\n`smoke.mjs` answers \"does this speak MCP\". `exercise.mjs` answers \"can an outside agent\nactually drive a DSH instance through it\" — and to answer that it hands a real prompt to a real\nDSH agent, making real LLM calls and costing real money against whatever endpoint it resolved\nto, so read the endpoint line it prints before letting it run. It is the only check that catches\na whole class of bug a green unit suite misses — a test stub more forgiving than the harness\nservice it stands in for. That class produced four real failures here, three in a primary use\ncase, so run it after any change to the driver. `docs/STUB-FIDELITY-AUDIT.md` is the audit that\nenumerated the class; it is worth reading before writing a double for a harness service.\n\nTwo rules this codebase learned the hard way:\n\n- **A double must enforce the service's real preconditions.** A stub that returns `undefined`\n  where the real call *rejects*, or that accepts both arguments where the real one rejects the\n  pair, converts a production failure into a green test. Where a double cannot model a service,\n  the missing double is itself the finding — the filesystem and shell seams had none, and the\n  worst bug lived there.\n- **A test can lock a bug in.** One asserted that a `turn/start` *before* our message meant the\n  turn was not ours; a real session log showed that is exactly the ordinary shape, and the\n  assertion kept every waiting `session_prompt` timing out on turns that had completed.\n\n## Publishing (maintainers)\n\n**One-time setup**, once the GitHub repo exists:\n\n1. Replace every `xiseliuli/dsh-as-mcp` placeholder in this repo (package.json's `repository`,\n   `homepage`, and `bugs`, plus both READMEs) with the real `xiseliuli/dsh-as-mcp` — a single global\n   find-and-replace works, since every occurrence uses the identical spelling.\n2. Tag the repo with the topic plugin discovery keys on:\n   `gh repo edit xiseliuli/dsh-as-mcp --add-topic dsh-plugin`.\n3. Trusted Publishing cannot perform a package's *first* publish — npm requires the package to\n   already exist on the registry before a Trusted Publisher can be attached to it (the\n   [`npm trust` docs](https://docs.npmjs.com/cli/v11/commands/npm-trust/) state the prerequisite\n   outright: \"Package must exist: The package you're configuring must already exist on the npm\n   registry.\"). So the first release has to go out by hand: `npm publish --access public` from a\n   checkout (or `npm login --auth-type=web` first, if you'd rather not touch a long-lived\n   credential at all). Only after that publish succeeds does the package have a Settings page to\n   configure.\n4. On npmjs.com, open the package → **Settings** → **Trusted Publisher**, and add a GitHub\n   Actions publisher with this repo's owner, repo name, and the exact workflow filename\n   `release.yml`. Every release after this one goes out through\n   `.github/workflows/release.yml`'s OIDC flow — no `NPM_TOKEN` involved.\n\n**Every release:**\n\n```bash\nnpm version patch   # or minor / major\ngit push --follow-tags\n```\n\nThe pushed tag triggers `release.yml`, which verifies the tag matches `package.json`'s version,\nbuilds, tests, and publishes — under the `next` dist-tag instead of `latest` if the version is a\nprerelease.\n\n**After a release, verify it actually installs:**\n\n```bash\ndsh plugin --profile <name> add dsh-as-mcp\ndsh --profile <name> --dump-config | grep -A 30 '# == dsh-as-mcp'\n```\n\nThat is the same install/verify pair from \"Install\" above, run against the version that just\nshipped.\n\n## License\n\nMIT\n"}