Skip to main content
Glama
README.md
# dsh-xiaozhi · drive DSH by voice with the Xiaozhi assistant

Connects the **Xiaozhi** (小智) voice assistant to **DeepSeek Harness (DSH) Web**: DSH acts as the
**MCP tool provider**, exposing workspaces, sessions, models, settings and files as tools a voice
assistant can call over JSON-RPC 2.0 on a WebSocket.

**English** · [**中文文档**](./README.zh.md) · [Install & verify](./INSTALL.md) · [Tool reference](./docs/TOOLS.md)

> Connect the Xiaozhi voice assistant to DSH Web: DSH is the MCP tool provider, exposing 35 DSH Web
> endpoints as 16 voice-friendly tools, with a DSH Web settings page.
> 把小智(Xiaozhi)语音助手接入 DSH Web:DSH 作为 MCP 工具提供方,把 35 个接口封装成 16 个语音友好工具,自带设置页。

> The pre-implementation design proposals (architecture ADR, v2 review, settings UX walkthrough) are archived in [docs/design/](./docs/design/) with every divergence from the shipped code listed.

---

## 1. What it solves

DSH's capabilities live behind HTTP REST endpoints; Xiaozhi only speaks **MCP**. This plugin sits
between them:

```
 you say a sentence
        │
        ▼
┌─────────────┐   MCP (JSON-RPC 2.0 / WebSocket)  ┌──────────────────────────┐
│  Xiaozhi     │ ◄──────────────────────────────► │ dsh-xiaozhi (Host half)   │
│ App / device │  initialize / tools/list / call   │ ├ MCP session + registry  │
└─────────────┘                                    │ ├ capability → REST map   │
                                                   │ └ LocalInvoker (in-proc)  │
                                                   └───────────┬──────────────┘
                                                               │ no network hop
                                                               ▼
                                                   ┌──────────────────────────┐
                                                   │ DSH Web REST routes (copy)│
                                                   └──────────────────────────┘
```

Three deliberate decisions:

1. **DSH is always the MCP server / tool provider.** In both transports it answers `initialize`,
   `ping`, `tools/list` and `tools/call`, and never initiates them.
2. **Outbound by default (`endpoint` mode).** DSH dials out to the Xiaozhi MCP access point, so it
   needs no public IP, port forwarding or reverse proxy.
3. **In-process invocation, not loopback HTTP.** Tool calls go straight to the bundled DSH REST
   routes through `LocalInvoker`, so there is no host/port/auth guessing and no dependency on an
   external service.

---

## 2. Quick start (3 steps)

Requirements: DSH Web running (`dsh web`, default `http://127.0.0.1:3080`), a Xiaozhi account, and
its **MCP access point** page open.

1. **Install** from this directory:

   ```bash
   dsh plugin add /Users/tsbj/feyanggit/DHS-test/dsh-xiaozhi
   ```

   Or use "install from a local directory" under Settings → Plugins in DSH Web.

2. **Paste the access point**: DSH Web → Settings → **Xiaozhi** → Connection, put the WebSocket
   address from the Xiaozhi console (like `wss://api.xiaozhi.me/mcp/?token=…`) into
   "Xiaozhi MCP access point", then press **Save and reload**.

3. **Check the status tab**: the connection should read `connected` with a client count. Press
   **Test connection** to perform a real handshake.

   Then say to Xiaozhi: *"ask DSH for my session list"*.

> The access point URL carries a token. When the page reads the config back it shows `token=***`,
> and saving treats that sentinel as "unchanged" rather than writing it over the real secret. See §7.

---

## 3. Two transports

| | `endpoint` (default, recommended) | `server` (self-hosted) |
| --- | --- | --- |
| Who connects | DSH dials out to the Xiaozhi access point | The Xiaozhi server connects to DSH |
| Public reachability | not needed | needed (or a reverse proxy / same LAN) |
| Main settings | `endpointUrl`, `endpointHeaders` | `serverPath`, `serverPort`, `serverToken` |
| Fits | the official Xiaozhi MCP access point | a self-hosted `xiaozhi-esp32-server` |

Both can run at once: `mode` picks the primary channel, and `serverPort > 0` additionally listens on
`0.0.0.0`.

**Reconnect** in endpoint mode uses exponential backoff (`reconnectMinMs` → `reconnectMaxMs`, ±20%
jitter) plus a `heartbeatMs` ping. The Status tab and the log show every attempt.

---

## 4. Tool exposure: grouped (default) or flat

Xiaozhi sanitises tool names to `[A-Za-z0-9_\-CJK]`. Every name this plugin exposes is a **fixed
point** of that rule (e.g. `dsh_session_history`), so no platform-side renaming occurs.

| Mode | Tools | Notes |
| --- | --- | --- |
| `grouped` (default) | **16** (fewer with groups disabled) | merged by capability area, an `action` argument picks the operation |
| `flat` | **35** | one tool per endpoint, named after it |

Grouped is the default because a voice model picks the right tool far more reliably from 16 options
than from 35; the settings page warns past 24 tools. Full mapping: [docs/TOOLS.md](./docs/TOOLS.md).

**Groups** can be disabled per area (e.g. `docs`, `files`). **`allowWriteTools = false`** refuses
create/update/delete/send operations with a speakable message while **keeping read operations
usable**, even inside a grouped tool that mixes both.

---

## 5. Capability coverage

**All 35 endpoints are reachable, and both tool modes cover 35/35:**

| Area | # | Endpoints |
| --- | --- | --- |
| System | 1 | `GET /system/status` |
| Workspaces | 6 | `/workspaces`, `/workspaces/:id`, `/workspaces/:id/sessions` |
| Sessions | 13 | `/sessions`, `/sessions/:id`, `history`, `stats`, `todos`, `skills`, `questions`, `answers`, `cancel`, `events` |
| Files | 3 | `/sessions/:id/files`, `/files/download` |
| Conversation | 3 | `/sessions/:id/prompt`, `/prompt-stream`, `/chat/completions` |
| Models | 5 | `/models`, `/models/default`, `/providers`, `/presets` |
| Settings | 2 | `/settings`, `/settings/:namespace` |
| Docs | 2 | `/docs`, `/openapi.json` |

Four of them are **degraded** under MCP semantics. Read the next section before relying on them.

---

## 6. MCP semantic degradations (please read)

`tools/call` is strictly request/response with no incremental channel, while several source
endpoints stream. This plugin keeps as much semantics as possible and says so, instead of pretending:

| Capability | Native form | Over MCP | What it means for you |
| --- | --- | --- | --- |
| `conversation.promptStream` (`dsh_say`) | `text/event-stream`, incremental | DSH **collects the whole stream** and returns the result text once | The voice side is not incremental; `promptTimeoutMs` bounds the wait, and a timeout answers "submitted, still running" instead of an error |
| `sessions.events` (`dsh_session_watch`) | long-lived SSE | collects events for a **bounded window** (1–30 s) then returns | A peek at recent activity, not a live subscription; poll `sessions.stats` to follow progress |
| `files.download` | binary stream | text files return their body (clipped to `maxVoiceChars`); **binaries return a summary** (size, type, path) | Reading binary bytes aloud is meaningless; fetch the real file from the DSH Web UI or the bundled REST layer |
| `docs.openapi` | full OpenAPI JSON | a **structure summary** (`openapi`, `title`, path count, up to 100 paths, bytes, URL) | Open `apiBase/openapi.json` for the full document |

Also:

* `dsh_say(wait=false)` hands a sentence to a session without waiting: it submits `prompt-stream`
  with a ~1.5 s budget and, on timeout, quietly reports "submitted" plus the session status.
* Every tool result is clipped to `maxVoiceChars` and delivered as a **single text block** so
  speech stays short.

---

## 7. Security model

| Surface | Default | Protection |
| --- | --- | --- |
| Settings API `/dsh-xiaozhi/admin` | loopback only (DSH binds `127.0.0.1`) | ① cross-site `Origin` refused ② `sec-fetch-site: cross-site` refused ③ **every** request (reads included) must carry `x-dsh-xiaozhi-admin: 1`; cross-site forms/images cannot set a custom header and a cross-origin `fetch` preflights, which this `cors: false` router never approves ④ when the Host exposes a `connection` service, it judges the request first (browser cookie + Host/Origin → 401/403) |
| Bundled DSH REST layer `/dsh-xiaozhi/api/v1` | on | Set `apiKey` to require `Authorization: Bearer …` or `X-API-Key`; **a warning is shown while it is unset** |
| MCP tools | on, writes allowed | `allowWriteTools=false` blocks all writes; `disabledGroups` shrinks the surface |
| `server` mode extra port | off (`serverPort=0`) | A port number listens on `0.0.0.0`, so `serverToken` becomes mandatory; the page warns when it is empty |

**Secret masking**: reading the config masks `apiKey`, `serverToken`, the `token=` value inside the
access point URL, and every `endpointHeaders` **value** (`••••••` / `***`) while keeping header
**names**. Saving treats those sentinels as "unchanged" and drops them, so a sentinel can never
overwrite a real secret.

**`endpointHeaders` can be added or overwritten from the page but not deleted** (the write is a
merge). Edit `settings.json` by hand to remove a header.

---

## 8. Configuration

Precedence, lowest first:

1. code defaults (`DEFAULTS` in `src/config.ts`)
2. the plugin row's `config` (the profile's `cordis.patch.yml`)
3. overrides saved by the settings page (`<homeDir>/settings.json`)

| Option | Default | Meaning |
| --- | --- | --- |
| `enabled` | `true` | while off, no tool can run |
| `mode` | `endpoint` | `endpoint` / `server` |
| `endpointUrl` | `''` | Xiaozhi MCP access point (`ws://`/`wss://`, must contain `/mcp/`) |
| `endpointHeaders` | `{}` | extra request headers (merged on write) |
| `serverPath` | `/mcp/xiaozhi` | server-mode path (must contain `/mcp/`) |
| `serverPort` | `0` | `0` reuses the DSH web server; `>0` also listens on `0.0.0.0` |
| `serverToken` | `''` | strongly recommended whenever `serverPort > 0` |
| `toolMode` | `grouped` | `grouped` / `flat` |
| `disabledGroups` | `[]` | disabled capability areas |
| `allowWriteTools` | `true` | allow write operations |
| `promptTimeoutMs` | `120000` | voice wait limit (must stay below the REST layer's 180000) |
| `maxVoiceChars` | `700` | reply clipping length |
| `listLimit` | `10` | list page size |
| `heartbeatMs` | `30000` | ping interval |
| `reconnectMinMs` / `reconnectMaxMs` | `1000` / `30000` | reconnect backoff bounds |
| `apiPathPrefix` | `/dsh-xiaozhi/api` | **bundled REST layer** prefix (the settings API is fixed at `/dsh-xiaozhi/admin`) |
| `exposeDshApi` | `true` | mount the bundled DSH REST layer |
| `apiKey` | `''` | auth key for the bundled layer |
| `cors` | `false` | allow cross-origin calls to the bundled layer |
| `defaultCwd` | `''` | default directory for created sessions |
| `maxUploadBytes` | `104857600` | upload limit |
| `homeDir` | `''` | **row config only** (see below) |
| `logToolCalls` | `true` | log every tool call |
| `sendInitializedNotification` | `true` | send `notifications/initialized` after the handshake |
| `serverName` | `DSH` | announced service name |

> **Why is `homeDir` not on the settings page?** It decides where the override file lives, so
> honouring it *from* that file is circular — the page would show a new directory while overrides
> kept being written to the old one. `homeDir` therefore comes only from the plugin row `config`
> (or the `DSH_XIAOZHI_HOME` environment variable) and the page shows it read-only.

---

## 9. Settings page

DSH Web → Settings → **Xiaozhi**, five tabs:

* **Status** — connection badge, transport, masked access point, client count, reconnects, last
  error, warnings, public addresses, tool/capability counts, per-group state; with **Test
  connection**, **Reconnect now** and **Refresh**.
* **Connection** — basics, tool-group switches, and a collapsed advanced form. **Save and reload**
  writes the override file and restarts the runtime; **Restore defaults** clears every override.
* **Tools** — the tools actually exposed, their read/write nature and capability counts.
* **Capabilities** — all 35 capabilities by area, with method and path.
* **Logs** — the plugin ring log (300 lines) with an optional 5-second auto refresh.

The page styles itself with DSH theme tokens (`--dsw-alias-*`) only, imports **no**
`dsh-client-ui-primitives`, and therefore follows the host in light and dark without clashing.

---

## 10. Development

```bash
cd dsh-xiaozhi
bash scripts/build.sh                     # needs a DSH source checkout for tsc (auto-probed)
node --test --test-timeout=30000 "test/*.test.mjs"
```

**114 test cases** across:

| File | Covers |
| --- | --- |
| `test/protocol.test.mjs` | MCP messages, tool-name sanitiser fixed points, envelope parsing |
| `test/ws.test.mjs` | RFC 6455 framing, mask direction, fragmentation, closing handshake |
| `test/config.test.mjs` | three-layer merge, secret masking, `homeDir` not overridable |
| `test/coverage.test.mjs` | **all 35 endpoints pinned verbatim**; both weavings cover everything; names are sanitiser fixed points |
| `test/dispatcher.test.mjs` | in-process invocation: JSON, query strings, request bodies, streaming, 404, 504 timeout |
| `test/mcp-session.test.mjs` | handshake → `tools/list` → `tools/call` over a real socket, with concurrency and protocol errors |
| `test/routes.test.mjs` | every capability resolves on the **real** route table; grouped tools end to end |
| `test/client.test.mjs` | browser-half constant parity, bilingual dictionary completeness, helpers, `react-dom/server` renders |
| `test/admin.test.mjs` | settings API: every route the page calls is reachable with the right method; the three guards; masked-secret stripping |
| `test/docs.test.mjs` | doc/code consistency: names, counts and routes cannot drift |

`src/dshapi/` is a **verbatim copy** of `@dsh-external/dsh-web-service` v0.1.11 (BSD-3-Clause); the
only new file is `src/dshapi/service.ts`, which assembles it into one router, so an upstream update
stays a clean three-way diff. See [NOTICE](./NOTICE).

---

## 11. Troubleshooting

| Symptom | Cause and fix |
| --- | --- |
| Status stays `disconnected` | The access point is empty or malformed (must be `ws://`/`wss://`, contain `/mcp/`, and avoid the substrings `key`/`call`). Check the first error in the Logs tab |
| Xiaozhi sees the tools but calls fail | Check `allowWriteTools`; a blocked write returns an explicit message |
| Xiaozhi sees no tools at all | `enabled=false`, or every tool group is disabled |
| Ids are hard to say aloud | Grouped tools shorten ids (like `sess-123`); you can also address things by name |
| A LAN self-hosted Xiaozhi cannot connect | In `server` mode with `serverPort=0` only the DSH server listens (loopback by default); set a port and a `serverToken` |
| Changing `apiPathPrefix` did not move the settings page | Expected: the settings API is fixed at `/dsh-xiaozhi/admin`; `apiPathPrefix` only shapes the bundled REST layer |

---

## 12. License

BSD-3-Clause. Derived from `@dsh-external/dsh-web-service` v0.1.11 (Copyright © 2026 toddpan 潘祖继)
under the same license. See [LICENSE](./LICENSE) and [NOTICE](./NOTICE).