dsh-as-mcp 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The dsh-as-mcp Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,621 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <img src="icon.svg" width="96" alt="dsh-as-mcp">
3
+ </p>
2
4
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
5
+ <h1 align="center">dsh-as-mcp</h1>
6
+
7
+ <p align="center">English | <a href="README.zh.md">中文</a></p>
8
+
9
+ <p align="center">
10
+ <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>
11
+ <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>
12
+ <a href="LICENSE"><img src="https://img.shields.io/github/license/xiseliuli/dsh-as-mcp?color=3fb950" alt="License: MIT"></a>
13
+ <a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.9-3178C6?logo=typescript&logoColor=white" alt="TypeScript"></a>
14
+ <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>
15
+ <a href="https://modelcontextprotocol.io/"><img src="https://img.shields.io/badge/MCP-server-8B5CF6" alt="MCP server"></a>
16
+ <a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/DSH-plugin-4B8BBE" alt="DSH plugin"></a>
17
+ </p>
18
+
19
+ Expose a running **DeepSeek Harness** as an **MCP server**, so any other agent — Claude
20
+ Code, Codex, another DSH, a CI job, your own script — can drive it: create workspaces,
21
+ start sessions, hand the DSH agent a coding task, read and write files, run commands.
22
+
23
+ The endpoint runs *inside* the DSH host process, so a session created over MCP is a real
24
+ DSH session. It appears live in the DSH UI, runs inside the DSH sandbox, and is subject to
25
+ the same permission policy as anything you type yourself. Nothing about the agent is
26
+ reimplemented.
27
+
28
+ ## How it works
29
+
30
+ ```mermaid
31
+ flowchart LR
32
+ subgraph clients["Any MCP client"]
33
+ direction TB
34
+ c1["Claude Code"]
35
+ c2["Codex"]
36
+ c3["another DSH"]
37
+ c4["a CI job"]
38
+ c5["your own script"]
39
+ end
40
+
41
+ subgraph host["DSH host process"]
42
+ direction TB
43
+ plugin["dsh-as-mcp<br/>(this plugin)"]
44
+ tools["workspace_* · session_*<br/>file_* · shell_* · dsh_tool_*"]
45
+ services["the harness's own services<br/>workspace · session · fs · shell"]
46
+ plugin --> tools --> services
47
+ end
48
+
49
+ clients -- "Streamable HTTP or stdio<br/>bearer token" --> plugin
50
+ ```
51
+
52
+ ---
53
+
54
+ ## Install
55
+
56
+ Three channels install this bundle into a profile; pick whichever matches where you got the
57
+ package from.
58
+
59
+ **From npm:**
60
+
61
+ ```bash
62
+ dsh plugin --profile <name> add dsh-as-mcp
63
+ ```
64
+
65
+ From a source checkout, the equivalent is `pnpm dsh plugin --profile <name> add dsh-as-mcp`.
66
+
67
+ **From GitHub:**
68
+
69
+ ```bash
70
+ dsh plugin --profile <name> add github:xiseliuli/dsh-as-mcp
71
+ ```
72
+
73
+ A git install fetches source, not the built `lib/`, so pnpm must run this package's `prepare`
74
+ script (`tsdown && node scripts/build-client.mjs`) to produce it. pnpm ≥10 refuses to run that
75
+ script — and the `esbuild` postinstall the build depends on — until the profile allows them, so
76
+ the first `add` fails with `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`.
77
+
78
+ A bare package name in `allowBuilds` works for `esbuild` (a registry dependency) but never for
79
+ `dsh-as-mcp` here: pnpm only approves a git-hosted build by its exact resolved key, never by
80
+ name alone ([pnpm docs](https://pnpm.io/settings/build#allowbuilds)). The failed `add` prints
81
+ that exact key in its error; copy it verbatim into the profile's `pnpm-workspace.yaml`
82
+ (`~/.dsh/profiles/<name>/pnpm-workspace.yaml`) and re-run the `add`. Don't rely on pnpm having
83
+ written a placeholder entry for you — on this failure path it does not. The result looks like:
84
+
85
+ ```yaml
86
+ allowBuilds:
87
+ '<the exact git spec pnpm wrote, including its commit hash>': true
88
+ esbuild: true
89
+ ```
90
+
91
+ Treat that allowance as permission to run this package's code on your machine at install time;
92
+ pin a commit (`github:xiseliuli/dsh-as-mcp#<sha>`) so a later push cannot silently change what runs.
93
+ `dsh plugin add` runs the pnpm version DSH pins (v11.7.0 as of DSH 0.1.7-rc.2 — its output ends
94
+ with `using pnpm v…`), so the following applies only once DSH ships a newer pnpm. If the
95
+ profile's pnpm is ≥11.19.0 (≥11.11.0 for a cloned, non-`github:` git dependency), you can instead
96
+ approve the repository itself — `'dsh-as-mcp@git+https://github.com/xiseliuli/dsh-as-mcp.git': true`, with
97
+ no `#<sha>` — so a later commit on the same repo keeps building without re-approval; on an older
98
+ pnpm the exact-commit key is your only option and needs re-approving after every update
99
+ ([pnpm 11.11 release notes](https://pnpm.io/blog/releases/11.11-11.14),
100
+ [pnpm/pnpm#12367](https://github.com/pnpm/pnpm/issues/12367)).
101
+
102
+ **From a tarball:**
103
+
104
+ ```bash
105
+ pnpm pack
106
+ dsh plugin --profile <name> add /abs/path/to/dsh-as-mcp-<version>.tgz
107
+ ```
108
+
109
+ Reinstalling from the *same* tarball path silently keeps the previous build — see the warning
110
+ under "Installing into DSH Desktop" below for why, and give each rebuild a unique filename.
111
+
112
+ ---
113
+
114
+ `dsh plugin add` records the package in the profile's `dsh.profile.bundles`, and the
115
+ bundle's `cordis.patch.yml` supplies the plugin row and its defaults. **Restart DSH
116
+ afterwards**: bundle patches are read at boot, not hot-reloaded.
117
+
118
+ Confirm the layer composed:
119
+
120
+ ```bash
121
+ dsh --profile <name> --dump-config | grep -A 30 '# == dsh-as-mcp'
122
+ ```
123
+
124
+ **For publishers:** tag the GitHub repository with the topic `dsh-plugin` — that is what plugin
125
+ discovery keys on — and keep npm's `latest` dist-tag pointed at an exact, stable version (no
126
+ prerelease, no range), since that is what `dsh plugin add dsh-as-mcp` resolves.
127
+
128
+ ## Connect a client
129
+
130
+ Every request needs a bearer token. It is resolved in this order:
131
+
132
+ 1. `auth.token` in the plugin config;
133
+ 2. `$DSH_HOME/dsh-as-mcp/token`;
134
+ 3. otherwise generated and persisted there on first run, with mode `0600`.
135
+
136
+ The default endpoint is `http://127.0.0.1:8790/mcp`.
137
+
138
+ A client that cannot set a header may pass `?token=<token>` instead. Prefer the header: a
139
+ credential in a URL is one your shell history, a reverse proxy's access log, or a browser's
140
+ history may keep. DSH itself does not log query strings (`webserver/src/index.ts:224`) and the
141
+ plugin does not log the token, so this is a habit worth keeping rather than a leak this code
142
+ introduces.
143
+
144
+ **HTTP-capable client** (Streamable HTTP):
145
+
146
+ ```json
147
+ {
148
+ "mcpServers": {
149
+ "dsh": {
150
+ "type": "http",
151
+ "url": "http://127.0.0.1:8790/mcp",
152
+ "headers": { "Authorization": "Bearer <token>" }
153
+ }
154
+ }
155
+ }
156
+ ```
157
+
158
+ **stdio-only client** — use the bundled bridge, which forwards each JSON-RPC message to the
159
+ endpoint over HTTP:
160
+
161
+ ```json
162
+ {
163
+ "mcpServers": {
164
+ "dsh": {
165
+ "command": "npx",
166
+ "args": ["-y", "dsh-as-mcp"],
167
+ "env": { "DSH_AS_MCP_TOKEN": "<token>" }
168
+ }
169
+ }
170
+ }
171
+ ```
172
+
173
+ `dsh-as-mcp --help` prints the endpoint and token state it will use. The bridge sends a token
174
+ read from the token file to loopback endpoints only; pointing `DSH_AS_MCP_URL` off-host requires
175
+ setting `DSH_AS_MCP_TOKEN` explicitly, so a copied client config cannot quietly exfiltrate the
176
+ machine-local credential. "Loopback" means an exact match — `localhost`, `::1`, or a literal
177
+ `127.x.x.x` address — so a name such as `127.0.0.1.example.com` is treated as the remote host it
178
+ is. A scheme is optional: `127.0.0.1:8790/mcp` is accepted and understood.
179
+
180
+ Start with **`dsh_info`**. It reports the endpoint, where its token comes from, which tool groups are enabled,
181
+ and — importantly — which harness services the current profile actually provides, so a client
182
+ can tell "this profile has no shell" from "the command failed".
183
+
184
+ ## Tools
185
+
186
+ | Tool | What it does |
187
+ | --- | --- |
188
+ | `dsh_info` | Endpoint, token source, enabled groups, available harness services. |
189
+ | `workspace_create` | Register a directory as a DSH workspace (creating it if needed). |
190
+ | `workspace_list` | Every registered workspace with id, path, title, session count, directory status. |
191
+ | `session_create` | Start a DSH agent session bound to a workspace or bare directory. |
192
+ | `session_list` | Live session summaries, most recently updated first. |
193
+ | `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. |
194
+ | `session_messages` | Read a session's user/assistant transcript. |
195
+ | `session_cancel` | Ask the agent to stop its current turn. |
196
+ | `file_read` | Read a UTF-8 file through DSH's filesystem service. |
197
+ | `file_write` | Create or replace a file through DSH's filesystem service. |
198
+ | `file_list` | List a directory through DSH's filesystem service. |
199
+ | `shell_run` | Run one command through DSH's shell service. |
200
+ | `dsh_tool_list` | The agent tools this endpoint permits, with the schema DSH's own agent sees. |
201
+ | `dsh_tool_call` | Run one of them directly, through the same pipeline the agent uses. |
202
+
203
+ The typical coding flow:
204
+
205
+ ```
206
+ workspace_create { path: "/Users/me/project" }
207
+ -> session_create { workspaceId: "..." }
208
+ -> session_prompt { sessionId: "...", prompt: "Add a --verbose flag to the CLI and a test for it." }
209
+ ```
210
+
211
+ `session_prompt` is how you get work done *by* DSH: the DSH agent plans, edits files, runs
212
+ its own tools, and can spawn subagents. `file_*` and `shell_run` are for when you want to do
213
+ something yourself without involving the agent.
214
+
215
+ `dsh_tool_call` is the third option: it runs one of DSH's *own* tools without spending a model
216
+ turn to decide to call it. Call `dsh_tool_list` first — it reports exactly what is permitted, and
217
+ a name it does not report is refused. Both require a `sessionId`, because that session's agent
218
+ *is* the policy: it is what makes the harness apply a sandbox, run guards, and file the call under
219
+ a transcript.
220
+
221
+ There is deliberately no session-less form. DSH registers a tool into the scope of the context
222
+ that registers it, and every tool package ships inside an agent preset, so the global layer is
223
+ empty — an unscoped listing really does return zero tools, and an unscoped call answers
224
+ `unknown tool`. A `dsh_tool_list` without a session therefore **fails loudly** rather than
225
+ reporting an empty toolbox, which would read as "nothing is permitted". For the agentless path
226
+ use `file_read` / `file_write` / `file_list` / `shell_run`, which resolve the deployment policy
227
+ directly and need no session.
228
+
229
+ The permitted set is an **allow-list**, and it is deliberately narrow. Everything absent is
230
+ refused *and* hidden from `dsh_tool_list`. The omissions that matter:
231
+
232
+ - **`run_code`** is DSH's programmatic-tool-calling entry point: one call runs a program that
233
+ can invoke *any other tool by name*. Exposing it would void the list entirely.
234
+ - **`cordis_run`, `cordis_define`, …** execute arbitrary plugin code. No shipped bundle mounts
235
+ them, so a deny-list written against today's profile would be blind in exactly the profile
236
+ that adds them — which is why this is an allow-list.
237
+ - **`ask_user_question` and `present`** reach the human at the keyboard.
238
+ - **`workflow`, `ralph`, `send_message`, `spawn_teammate`, `schedule_create`** start work that
239
+ outlives the call; **`create_goal` and `update_goal`** sustain unattended execution.
240
+
241
+ An operator can widen this deliberately with the `agentTools.allow` setting, or subtract from
242
+ the default with `agentTools.deny`.
243
+
244
+ ## Security
245
+
246
+ This endpoint is remote control of a coding agent that has shell access. Treat the token like
247
+ an SSH key — and note that the plugin does too: no tool returns its value. `dsh_info` reports
248
+ where the token comes from (a file path or the composition), never the literal, so a calling
249
+ agent's transcript never accumulates the credential. A pinned token shorter than 16 characters
250
+ draws a warning at boot, and a generated one is 43 characters of CSPRNG output.
251
+
252
+ - The listener binds to `127.0.0.1` and every request is bearer-checked, including requests on
253
+ a route mounted on DSH's own web server. That check is the *only* gate there: an exact route
254
+ registered on the web server is matched **before** DSH's authorization fence — anything
255
+ unmatched is handed to that fence as a fallback (`webserver/src/index.ts:222-227`) — so with
256
+ `http.mountOnWebServer: true` the endpoint does not inherit your browser session's
257
+ protection, it enforces its own. One caveat on DSH Desktop: when ordinary browser access is
258
+ disabled there, the web server additionally refuses requests that do not carry the renderer
259
+ header, so the mounted path is unreachable for a plain MCP client — use the plugin-owned
260
+ listener. Setting `http.host: 0.0.0.0` exposes that same power to your network; the plugin
261
+ warns at bind time, and you should do it only behind your own gateway.
262
+ - **The token is the entire security boundary, and that boundary is your user account.** There
263
+ is no path sandbox. Once a caller holds the token, `file_read`/`file_write`/`file_list` reach
264
+ anything the DSH process can reach, and `shell_run` runs arbitrary commands as you — because
265
+ that is exactly what the harness's own `fs` and `shell` services do for DSH's own agent. Both
266
+ properties were verified against a live instance: `file_read` returned `/etc/passwd`,
267
+ `~/.dsh/settings.yaml`, and this plugin's own token file; `file_write` created a file outside
268
+ every registered workspace. A workspace sets where a *session's* agent starts; it does not
269
+ fence these tools.
270
+ - **The bridge is instance-wide, not per-caller.** `session_list` enumerates every session in
271
+ this DSH instance and `session_messages` reads any of their transcripts — verified reading a
272
+ session this plugin did not create. Those are the sessions you have been talking to DSH in, so
273
+ a token holder sees your conversation history, and a calling agent's context accumulates it.
274
+ This is not an escalation (the same bytes are in `$DSH_HOME/sessions`, reachable through
275
+ `shell_run`), but it is a privacy consequence worth knowing before you hand out a token.
276
+ - Consequently the `tools` toggles **narrow what a client can discover and call; they are not
277
+ containment.** `tools.shell: false` removes the shell tool, but `file_write` still writes your
278
+ shell startup files and `file_read` still reads your credentials, so a profile with the shell
279
+ tool switched off is not safe to hand to a caller you would not give a login to. For real
280
+ containment, run the whole DSH instance inside an OS-level sandbox or as a dedicated
281
+ unprivileged account, and treat the token as that account's password.
282
+ - `approval.policy` decides what happens when the DSH agent wants to run something the harness
283
+ would normally ask a human about:
284
+ - `inherit` (default) — the plugin does not answer. With no browser attached, an
285
+ approval-requiring tool resolves `unavailable` and the agent's action fails closed.
286
+ - `allow` — the plugin approves every request raised by a session **it created**, and stays
287
+ out of the waterfall for every other session, so your own interactive sessions keep their
288
+ policy. This is what makes unattended agent runs work; it is a real widening of what the
289
+ agent may execute.
290
+ - `file_write` and `shell_run` are the *caller's* actions, not the agent's. They run through
291
+ the harness's own filesystem and shell services, so the sandbox and policy configured for
292
+ this DSH instance still apply.
293
+
294
+ ## Installing into DSH Desktop (restart required)
295
+
296
+ Electron reserves the `desktop` profile, so `dsh plugin --profile desktop add` is refused from a
297
+ plain terminal and the install is manual. DSH Desktop must then be **restarted**.
298
+
299
+ ### Why a restart is required
300
+
301
+ `patchReload: live` in the profile manifest only governs the **CLI launcher**. The no-restart
302
+ recompose is implemented by `watchUserPatches()`, which lives in the CLI's `runProfile()`
303
+ (`apps/cli/src/profile-boot.ts`). DSH Desktop takes a different path: it calls `boot()` from
304
+ `@deepseek-ai/dsh-app-boot` with the patch list computed once at startup, and the shipping app
305
+ contains no such watcher at all:
306
+
307
+ ```bash
308
+ grep -rn watchUserPatches <dsh-desktop>/dsh-plugin-desktop/src/ # no matches
309
+ ```
310
+
311
+ Measured behaviour agrees: after editing the profile's `cordis.patch.yml`, the app log gained not
312
+ one line and the port never opened.
313
+
314
+ So: **a CLI-launched profile (`dsh --profile xxx`) applies patch edits immediately; DSH Desktop
315
+ must be restarted.** On either path the watcher only recomposes config rather than replacing
316
+ loaded modules, so editing `lib/` needs a restart regardless.
317
+
318
+ ### If the app will not start afterwards
319
+
320
+ Reset `~/.dsh/profiles/desktop/cordis.patch.yml` to `[]` and the plugin takes no part in startup.
321
+ To remove it entirely: `cd ~/.dsh/profiles/desktop && pnpm remove dsh-as-mcp`.
322
+
323
+ ### 1. Pack it and install into the desktop profile
324
+
325
+ ```bash
326
+ cd /path/to/dsh-as-mcp && pnpm pack --pack-destination /tmp
327
+
328
+ # raw pnpm, bypassing the CLI's reserved-profile guard
329
+ cd ~/.dsh/profiles/desktop && pnpm add /tmp/dsh-as-mcp-0.1.0.tgz
330
+ ```
331
+
332
+ > **Reinstalling over an existing install: change the tarball path.**
333
+ > `pnpm` keys a `file:` dependency on the *path*, and a reinstall from the same path
334
+ > reports `added 0` while leaving the previous content linked — a silently stale
335
+ > build that still boots and still runs the old code. Give each build a unique
336
+ > filename and the spec string changes with it:
337
+ >
338
+ > ```bash
339
+ > TARBALL=/tmp/dsh-as-mcp-$(date +%s).tgz
340
+ > cd /path/to/dsh-as-mcp && pnpm pack --pack-destination "$(dirname $TARBALL)"
341
+ > mv /tmp/dsh-as-mcp-0.1.0.tgz "$TARBALL"
342
+ > cd ~/.dsh/profiles/desktop && pnpm remove dsh-as-mcp && pnpm add "$TARBALL"
343
+ > ```
344
+ >
345
+ > Then confirm the bytes actually match, or you are testing the old build:
346
+ >
347
+ > ```bash
348
+ > wc -c ~/.dsh/profiles/desktop/node_modules/dsh-as-mcp/lib/index.js \
349
+ > /path/to/dsh-as-mcp/lib/index.js
350
+ > ```
351
+
352
+ ### 2. Put the plugin row in the profile's own patch layer
353
+
354
+ Edit `~/.dsh/profiles/desktop/cordis.patch.yml` (replace the `[]`):
355
+
356
+ ```yaml
357
+ - insert:
358
+ - id: dsh-as-mcp
359
+ name: dsh-as-mcp
360
+ config:
361
+ http:
362
+ enabled: true
363
+ host: 127.0.0.1
364
+ port: 8790
365
+ path: /mcp
366
+ tools:
367
+ workspace: true
368
+ session: true
369
+ files: true
370
+ shell: true
371
+ ```
372
+
373
+ Then **restart DSH Desktop** to mount the plugin.
374
+
375
+ > ⚠️ **Do not also add `dsh-as-mcp` to `dsh.profile.bundles`.** The bundle contributes its own
376
+ > row, so the composed result carries **two rows sharing one id** (`dsh --profile <name>
377
+ > --dump-config` shows both). Use the patch layer or the bundle list, never both. Switch to the
378
+ > bundle route via `dsh plugin add` when you want it to persist across restarts.
379
+
380
+ ### 3. Smoke-test it
381
+
382
+ ```bash
383
+ node ~/.dsh/profiles/desktop/node_modules/dsh-as-mcp/scripts/smoke.mjs
384
+ ```
385
+
386
+ It reads `<DSH_HOME>/dsh-as-mcp/token`, shakes hands, lists tools, calls `dsh_info` to report which
387
+ harness services this profile actually mounted, then calls `workspace_list`. The last line is `OK`
388
+ on success and the exit status is non-zero on failure.
389
+
390
+ For one real round trip (temp workspace → session → hand the DSH agent a task → print the reply and
391
+ tool calls):
392
+
393
+ ```bash
394
+ node ~/.dsh/profiles/desktop/node_modules/dsh-as-mcp/scripts/smoke.mjs \
395
+ --prompt "In the current workspace create hello.txt containing hi, then read it back to confirm"
396
+ ```
397
+
398
+ ### 4. Uninstall
399
+
400
+ Reset `~/.dsh/profiles/desktop/cordis.patch.yml` to `[]` and restart, and the plugin no longer
401
+ mounts. To remove it entirely: `cd ~/.dsh/profiles/desktop && pnpm remove dsh-as-mcp`.
402
+
403
+ ## Configuration
404
+
405
+ Defaults ship in `cordis.patch.yml`. A profile's own `cordis.patch.yml` overrides the row by
406
+ `id`, and an override **replaces the row's whole `config` object** rather than deep-merging
407
+ it — restate every key you still want. Unknown keys are rejected at boot with the offending
408
+ key named, because a silently ignored typo means your override is not being applied.
409
+
410
+ ```yaml
411
+ - id: dsh-as-mcp
412
+ name: dsh-as-mcp
413
+ config:
414
+ http:
415
+ enabled: true # plugin-owned listener
416
+ host: 127.0.0.1
417
+ port: 8790 # 0 asks the OS for a free port
418
+ path: /mcp
419
+ mountOnWebServer: false # also serve at http://<dsh host>:<dsh web port>/mcp
420
+ auth:
421
+ token: '' # empty: read/generate $DSH_HOME/dsh-as-mcp/token
422
+ tools:
423
+ workspace: true
424
+ session: true
425
+ files: true
426
+ shell: true
427
+ session:
428
+ agentPreset: '' # empty: harness default
429
+ provider: '' # must be paired with model
430
+ model: ''
431
+ promptTimeoutMs: 900000
432
+ limits:
433
+ maxReadBytes: 1048576
434
+ shellTimeoutMs: 120000
435
+ agentToolTimeoutMs: 120000
436
+ approval:
437
+ policy: inherit # inherit | allow
438
+ ```
439
+
440
+ ## Settings panel
441
+
442
+ Where the host mounts a settings service — DSH Desktop and `dsh web` both do — the plugin
443
+ contributes an **MCP server** section to the settings panel. It edits the same `dsh-as-mcp`
444
+ namespace the configuration above fills, so the panel and the file are two views of one
445
+ value, not two copies.
446
+
447
+ The panel covers every setting including the `agentTools.allow` and `agentTools.deny` lists,
448
+ which are edited as comma-separated text. Both are ordinary `string[]` values, so an entry with a
449
+ comma in it cannot be expressed here; use the configuration file for that.
450
+
451
+ Every change applies **live**:
452
+
453
+ | Change | Effect |
454
+ | --- | --- |
455
+ | 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 |
456
+ | a limit, or a session default | read at the start of the next call |
457
+ | the token | the new value is required by the next request; the old one stops working immediately |
458
+ | `enabled`, `host`, `port`, `path`, `mountOnWebServer` | the listener is moved |
459
+
460
+ Changing the port is the case worth stating plainly: the plugin stops the old listener and
461
+ starts the new one, so the endpoint really does move. If the new port is taken, the bind error
462
+ is shown in the panel and in `dsh_info` rather than being swallowed.
463
+
464
+ The token is rendered **write-only**. Its literal never leaves the host process, so the panel
465
+ shows only whether one is set, and a "clear" button. The copyable client configuration uses a
466
+ `<token>` placeholder and points at the token file. Live endpoint state (listening, bind error,
467
+ enabled tool groups) is served by a read-only route on DSH's connection layer — inside that
468
+ layer's Host/Origin and browser-cookie fence, so it inherits DSH's own authorization and is
469
+ never exposed on a bare port.
470
+
471
+ The panel needs `@deepseek-ai/schemastery` to register a settings namespace; there is no
472
+ schemastery-free path in DSH. It is declared as an **optional** peer and loaded by dynamic
473
+ import, so a host that cannot supply it loses the section and nothing else — the endpoint keeps
474
+ running from the configuration file. `dsh_info` and the smoke script both report which of the
475
+ two you have.
476
+
477
+ ## Compatibility
478
+
479
+ - **Harness** ≥ `0.1.5-rc.1` (developed and verified against `dsh-v0.1.5-rc.1`, the version
480
+ bundled with DSH Desktop 2.0.9), declared as `peerDependencies["@deepseek-ai/dsh"]:
481
+ ">=0.1.5-rc.1"` with no upper bound. DSH's plugin loader reads that range straight from the
482
+ manifest and semver-checks it against the single running runtime version (prereleases
483
+ included) *before* the plugin is imported; an incompatible install is refused unless the host
484
+ grants an exact-version exemption (`dsh plugin allow-version`).
485
+ - **Node** `^22.19.0 || >=24.0.0`.
486
+ - The `@deepseek-ai/dsh` peer is declared **optional**, purely so `dsh plugin add` does not pull
487
+ a full harness install into every profile just to satisfy it. Optional only changes
488
+ installation — DSH's compatibility gate reads `peerDependencies` regardless of
489
+ `peerDependenciesMeta`, so the version check above still applies in full.
490
+ - **No other hard `@deepseek-ai/*` dependency.** This package resolves every other capability
491
+ structurally through `ctx.get(name)`, and its config schema is a hand-written
492
+ [Standard Schema](https://standardschema.dev) rather than a schemastery schema — which is all
493
+ Cordis's `resolveConfig` actually consumes. The one exception is `@deepseek-ai/schemastery`,
494
+ also declared as an **optional** peer: DSH offers no schemastery-free path to registering a
495
+ settings namespace, so wanting the panel means declaring it — but optional means a host
496
+ without it loses only the panel. This is the same stance the installed third-party
497
+ `dsh-tokenledger` takes.
498
+
499
+ ## Design notes
500
+
501
+ - **Capability detection, not `inject`.** The plugin loads in a bare CLI profile that has no
502
+ web server, no session service, and no filesystem seam, and each tool then reports exactly
503
+ which service is missing and which bundle provides it. Declaring `inject` would make the
504
+ loader refuse to mount the plugin instead, which is a worse answer for a capability bridge.
505
+ - **Waiting for a turn.** The harness exposes no per-message "await this turn" call —
506
+ `sessionController.prompt()` returns as soon as the message is queued. So `session_prompt` polls
507
+ the durable session log for a `user/message` whose `source.rpcId` equals the `requestId` it
508
+ passed to `prompt()` — the same correlation the session controller's own idempotency check uses
509
+ — and then waits for *that* turn to close. Attribution is what makes this correct rather than
510
+ plausible: a queued prompt's log entry sits inside the span of the turn that was already
511
+ running, so a `turn/end` appearing after our message is usually someone else's turn, not ours.
512
+ Turns on one session are additionally serialized, so two concurrent callers cannot interleave
513
+ prompts and then disagree about which reply is theirs. The serialization covers the whole wait,
514
+ not just the submission: a second `session_prompt` to a session whose first wait is still open
515
+ does not even submit its message until that wait settles (up to its timeout), and a `steer` sent
516
+ while another MCP wait is in flight is deferred until it, which turns it into a queued prompt.
517
+ Callers that only want to queue a message should prefer `session_messages` polling over holding
518
+ a long wait open.
519
+ - **Filesystem.** Only `workspace_create` touches `node:fs` `mkdir` — the directory it creates
520
+ *is* the new sandbox root, so by definition it sits outside every root that exists before it,
521
+ which is why the call cannot go through the filesystem seam and is instead gated on the
522
+ read-only policy. The harness filesystem service deliberately exposes no `mkdir`; `file_write`
523
+ relies on the seam's atomic write, which creates parents inside the fence. Every read and
524
+ write goes through `ctx.fs`, so the same path rules and sandboxing the DSH agent lives
525
+ under apply to the caller.
526
+ - **No session deletion.** The harness offers `archiveSession`/`unarchiveSession` but no
527
+ delete, so neither does this plugin.
528
+
529
+ ## Development
530
+
531
+ ```bash
532
+ pnpm install
533
+ pnpm build # tsdown -> lib/
534
+ pnpm typecheck # tsc --noEmit
535
+ pnpm test # vitest
536
+ ```
537
+
538
+ The test suite runs real HTTP against a real listener, spawns the real `bin/mcp-stdio.mjs`,
539
+ and loads the plugin into a real `@deepseek-ai/cordis` context — with `resolveConfig` doing the
540
+ config validation — so the wire protocol, the bridge, and the loader contract are all
541
+ exercised rather than mocked.
542
+
543
+ Two scripts run against a **live** endpoint, which is a different thing. Both resolve it the
544
+ same way — the `--url`/`--token` flag wins, then `DSH_AS_MCP_URL`/`DSH_AS_MCP_TOKEN`, then the
545
+ loopback default — and both print the endpoint they resolved and where it came from (`arg` /
546
+ `env` / `default`) to stderr before sending anything, so a stray run cannot silently land on
547
+ whatever DSH instance happens to be listening on the default port:
548
+
549
+ ```bash
550
+ node scripts/smoke.mjs # handshake, tools/list, dsh_info — is the wire up?
551
+ node scripts/exercise.mjs # ~40 checks: create a workspace, start a session, have the
552
+ # DSH agent write code, read it back off disk, run it, check
553
+ # the transcript, and confirm the failure paths are legible
554
+ ```
555
+
556
+ `smoke.mjs` answers "does this speak MCP". `exercise.mjs` answers "can an outside agent
557
+ actually drive a DSH instance through it" — and to answer that it hands a real prompt to a real
558
+ DSH agent, making real LLM calls and costing real money against whatever endpoint it resolved
559
+ to, so read the endpoint line it prints before letting it run. It is the only check that catches
560
+ a whole class of bug a green unit suite misses — a test stub more forgiving than the harness
561
+ service it stands in for. That class produced four real failures here, three in a primary use
562
+ case, so run it after any change to the driver. `docs/STUB-FIDELITY-AUDIT.md` is the audit that
563
+ enumerated the class; it is worth reading before writing a double for a harness service.
564
+
565
+ Two rules this codebase learned the hard way:
566
+
567
+ - **A double must enforce the service's real preconditions.** A stub that returns `undefined`
568
+ where the real call *rejects*, or that accepts both arguments where the real one rejects the
569
+ pair, converts a production failure into a green test. Where a double cannot model a service,
570
+ the missing double is itself the finding — the filesystem and shell seams had none, and the
571
+ worst bug lived there.
572
+ - **A test can lock a bug in.** One asserted that a `turn/start` *before* our message meant the
573
+ turn was not ours; a real session log showed that is exactly the ordinary shape, and the
574
+ assertion kept every waiting `session_prompt` timing out on turns that had completed.
575
+
576
+ ## Publishing (maintainers)
577
+
578
+ **One-time setup**, once the GitHub repo exists:
579
+
580
+ 1. Replace every `xiseliuli/dsh-as-mcp` placeholder in this repo (package.json's `repository`,
581
+ `homepage`, and `bugs`, plus both READMEs) with the real `xiseliuli/dsh-as-mcp` — a single global
582
+ find-and-replace works, since every occurrence uses the identical spelling.
583
+ 2. Tag the repo with the topic plugin discovery keys on:
584
+ `gh repo edit xiseliuli/dsh-as-mcp --add-topic dsh-plugin`.
585
+ 3. Trusted Publishing cannot perform a package's *first* publish — npm requires the package to
586
+ already exist on the registry before a Trusted Publisher can be attached to it (the
587
+ [`npm trust` docs](https://docs.npmjs.com/cli/v11/commands/npm-trust/) state the prerequisite
588
+ outright: "Package must exist: The package you're configuring must already exist on the npm
589
+ registry."). So the first release has to go out by hand: `npm publish --access public` from a
590
+ checkout (or `npm login --auth-type=web` first, if you'd rather not touch a long-lived
591
+ credential at all). Only after that publish succeeds does the package have a Settings page to
592
+ configure.
593
+ 4. On npmjs.com, open the package → **Settings** → **Trusted Publisher**, and add a GitHub
594
+ Actions publisher with this repo's owner, repo name, and the exact workflow filename
595
+ `release.yml`. Every release after this one goes out through
596
+ `.github/workflows/release.yml`'s OIDC flow — no `NPM_TOKEN` involved.
597
+
598
+ **Every release:**
599
+
600
+ ```bash
601
+ npm version patch # or minor / major
602
+ git push --follow-tags
603
+ ```
604
+
605
+ The pushed tag triggers `release.yml`, which verifies the tag matches `package.json`'s version,
606
+ builds, tests, and publishes — under the `next` dist-tag instead of `latest` if the version is a
607
+ prerelease.
608
+
609
+ **After a release, verify it actually installs:**
610
+
611
+ ```bash
612
+ dsh plugin --profile <name> add dsh-as-mcp
613
+ dsh --profile <name> --dump-config | grep -A 30 '# == dsh-as-mcp'
614
+ ```
615
+
616
+ That is the same install/verify pair from "Install" above, run against the version that just
617
+ shipped.
618
+
619
+ ## License
620
+
621
+ MIT