Skip to content
dsh.fish
Bundle

dsh-doctrove

面向编码 agent 的版本化库文档检索:检索文档目录、选择版本、按主题提取带相关度排序的文档片段。以 MCP stdio server 形式运行,零运行时依赖,可被 dsh 等插件化 harness 加载

Source
JohnXu22786
stars
1 stars
License
MIT
Updated
Updated 5 days ago

Readme

[简体中文](README.zh.md)

# doctrove — Versioned library documentation retrieval (agent toolset)

`doctrove` is a **versioned documentation retrieval** plugin for coding agents: it maintains a "library documentation catalog index", letting agents fetch **accurate, versioned, traceable** API documentation snippets on demand while writing code — instead of guessing API usage from training memory — and thereby avoiding "APIs that don't exist in the docs", "outdated signatures", and "fabricated parameters".

- **Zero runtime dependencies**: uses only Node.js built-ins (`fetch`, `node:test`), runs without installing any package;
- **Standard MCP stdio server**: any MCP-capable client (dsh, Claude Code, Codex, opencode, etc.) can connect;
- **Built for dsh**: ships a dsh bundle (`cordis.patch.yml` + a self-built bridge plugin), one-step integration via `dsh plugin add`, tools automatically appear in the model's tool list (`mcp__doctrove__*`);
- **Versioned**: every entry carries multiple documentation volumes, supporting "latest stable / exact version / prefix version (`4` → 4.21.x)" selection;
- **Scored, ranked results**: entry retrieval and documentation snippets both carry 0–1 relevance scores and hit signals, so the model can verify "why it ranked first";
- **Offline-capable**: ships with a built-in local demo index (`data/index.json`), runs without networking or remote sources;
- **Self-hostable remote index**: the index is an open JSON format that can be hosted on any static hosting (a zero-dependency hosting script is included);
- **Smart caching**: TTL + LRU in-memory cache, remote index and query results expire automatically per configuration, `--no-cache` disables it in one shot;
- **Graceful degradation**: when the remote index is unreachable, falls back to the local index automatically, results are tagged with `source` so the agent can tell data provenance.

---

## Quick start

### Method A: connect directly from any MCP client

```bash
# Requires Node.js >= 18.17; no arguments means offline mode (built-in index)
node src/entry.js
```

Example config line using the official dsh bridge (also applies to Claude Code / Codex MCP config):

```yaml
# dsh: insert into $DSH_HOME/profiles/<profile>/cordis.patch.yml
- insert:
    - id: mcp-doctrove
      name: '@deepseek-ai/dsh-mcp-client'
      config:
        serverName: doctrove
        transport: stdio
        command: node
        args: ['/absolute/path/src/entry.js']
```

Once connected, the model sees 3 tools: `catalog_lookup`, `catalog_releases`, `doc_extract`
(generic MCP clients see the bare names; in the dsh scenario they carry the `mcp__doctrove__` prefix, see below).

### Method B: install as a dsh plugin bundle (recommended)

This plugin is declared as a dsh bundle (the `dsh.bundle` field in `package.json`). Run the following in the plugin checkout directory:

```bash
dsh plugin --profile web add .
```

- On first use it automatically initializes the `web` profile and adds this package to `dsh.profile.bundles`;
- The `doctrove/bridge` plugin defined in `cordis.patch.yml` spawns this MCP server inside the dsh process,
  and after the handshake registers all tools into `ctx.tools`, **no manual config changes needed**;
- Offline-capable: the built-in index is loaded by default; to use a remote index, configure `args: ['--index-url', ...]` on the bridge line (see below);
- Uninstall: `dsh plugin --profile web remove doctrove`.

### Installing in DSH

```bash
dsh plugin --profile demo add github:JohnXu22786/docs-retriever
```

- `demo` is a dsh profile: it is created automatically on first use, and the package is added to `dsh.profile.bundles`;
- The `cordis.patch.yml` inside the package defines the `doctrove/bridge` plugin, which starts this MCP server within the dsh process and registers all tools into `ctx.tools` after the handshake — no manual configuration needed;
- Offline-capable out of the box: the built-in index is loaded by default; configure `args: ['--index-url', ...]` on the bridge line to use a remote index (see below);
- Removal:

```bash
dsh plugin --profile demo remove doctrove
```

After installing, restart dsh, then in a session you can simply say:

> "Write an Express 5 endpoint with `:id` route params and a JSON response — look up the exact route-param syntax first"

The corresponding tool call chain: `mcp__doctrove__catalog_lookup` (confirm express) →
`mcp__doctrove__doc_extract` (id=express, focus=route parameters).

> Note: dsh enables no MCP servers by default (every server command is trusted code executed outside the sandbox),
> and this plugin's bundle line is the "enable" action itself; only install plugins you trust.

---

## dsh integration notes (how the pluginized harness loads it)

dsh uses the Cordis plugin framework, and the composition unit is a **bundle**: an npm package + a patch layer. The loading chain is:

```
package.json(dsh.bundle.patch → ./cordis.patch.yml)
  └─ one line in cordis.patch.yml: name: 'doctrove/bridge'
       └─ src/bridge/plugin.js(Cordis plugin, inject: ['tools'])
            ├─ spawns src/entry.js with Node itself(MCP server subprocess, stdio)
            ├─ completes the initialize / tools/list handshake
            └─ registers each tool as mcp__doctrove__<toolname> into ctx.tools
```

- **Tool interface**: the model-visible tool names are `mcp__<serverName>__<raw tool name>`, `serverName` defaults to `doctrove`;
- **Events/skills**: this plugin registers no events or skills; it exposes capabilities only through the `ctx.tools` tool interface (read-only tools, no side effects);
- **Lifecycle**: the handshake and registration happen during the plugin's `apply`; on unload the subprocess is killed and all tools are deregistered automatically
  (registered via `ctx.effect` cleanup, no leftovers after hot reload/unload);
- **Two bridge options**: the self-built bridge `doctrove/bridge` bundled with this package (zero-dependency, works out of the box) and the
  official `@deepseek-ai/dsh-mcp-client` config line (see `examples/overlay-for-dsh.yml.example`);
  tool naming and behavior are identical — pick either one, don't enable both;
- **Environment variables**: dsh filters credential-like variables from MCP subprocess environments; the self-built bridge's subprocess inherits the host environment,
  so `DOCTROVE_INDEX_URL` and similar pass through, and can also be set explicitly with `env:` on the bridge line.

### Common dsh issues

| Symptom | Fix |
| --- | --- |
| Tools missing from the list | Check that the `cordis.patch.yml` line took effect (`dsh --profile <name> --dump-config` to inspect layers), confirm no startup log errors |
| Want a remote index | Configure `args: ['--index-url', 'https://your-index-url']` on the bridge line (directory root), or `env: { DOCTROVE_INDEX_URL: '...' }` |
| Want looser caching | Configure `args: ['--cache-ttl', '3600']` on the bridge line; use `--no-cache` for testing/debugging |
| pnpm >=10 rejects git-installed prepare scripts | This plugin is pure JS with no build script, so it is not affected; install from checkout or tarball |

---

## Tool list (3 tools, all read-only)

| Tool | Purpose | Main parameters |
| --- | --- | --- |
| `catalog_lookup` | Search the doc catalog by name/description, return candidates with scores and hit signals | `query` (required), `limit` |
| `catalog_releases` | List available and recommended versions of an entry | `id` (required) |
| `doc_extract` | Extract doc snippets for an entry/version/focus (relevance-ranked) | `id` (required), `version`, `focus`, `maxSections` |

### catalog_lookup

Search the doc catalog. When unsure of a library's canonical id, call this first, then use the returned `id` with `doc_extract`.

```jsonc
// request
{ "query": "express", "limit": 5 }
// response (structuredContent summary)
{
  "results": [{
    "id": "express", "name": "Express", "summary": "Minimal web framework for Node.js",
    "score": 1.0, "matches": ["exact name match"],
    "versions": ["5.1.0", "4.21.2"], "latest": "5.1.0", "source": "local:.../data/index.json"
  }],
  "total": 1, "sources": ["local:.../data/index.json"]
}
```

### catalog_releases

View an entry's version list and recommended version, useful for checking whether a target version is available (`doc_extract` supports the same version syntax).

```jsonc
{ "id": "express" }
// → { "id": "express", "name": "Express", "latest": "5.1.0",
//     "versions": ["5.1.0", "4.21.2"], "source": "local:..." }
```

### doc_extract

Extract documentation. `focus` describes one concept at a time (e.g. "route parameters"); split cross-concept questions into multiple calls
to avoid diluted results; `version` defaults to the latest stable release.

```jsonc
{ "id": "express", "version": "5", "focus": "wildcard" }
// → {
//     "id": "express", "name": "Express", "version": "5.1.0",
//     "releaseKind": "prefix", "releaseNote": "prefix match 5.x → latest 5.x release",
//     "sections": [{ "heading": "Wildcard routes", "score": 0.5, "matches": ["heading hit: 1 word"], ... }],
//     "source": "local:..."
//   }
```

Errors are always structured `isError` results, with `error.code` taking one of: `validation` / `not-found` / `version` /
`network` / `timeout` / `internal`, and `message` carrying actionable hints (e.g. candidate versions when the requested one is unavailable).
Parameter-validation failures are likewise folded into `isError` (rather than the protocol-level `-32602`), so the model sees a structured error code in one call and can self-correct.

---

## Scoring and ranking algorithm

### Entry retrieval (catalog_lookup)

Score = signal-tier score + popularity fine-tuning, both capped at 1.0:

| Signal | Base score | Notes |
| --- | --- | --- |
| Exact name match (case-insensitive) | 1.0 | name or id exactly equals the query |
| Exact alias match | 0.95 | e.g. query `expressjs` hits an alias |
| Name prefix match | 0.90 | e.g. query `expr` |
| Alias prefix match | 0.85 | |
| Name token overlap | 0.60–0.83 | proportional to hit tokens; ceiling deliberately below the alias-prefix tier to keep tier order invariant |
| Summary token overlap | 0.30–0.50 | when the name is completely unrelated |

- Popularity fine-tuning = `(1 − raw) × min(0.1, log₁₀(popularity)/100)`, applied only within the **headroom of the current signal tier**, so "exact > alias > prefix > token overlap" can never be inverted by popularity;
- Tokenization: English by word, Chinese per character (space-less languages);
- Ties are broken by popularity, descending (stable sort).

### Doc snippet ranking (doc_extract focus)

- Snippet score = `(2 × heading hit words + body hit words) / (2 × query words)`;
- Heading hits count double the body; zero-hit snippets are filtered out; truncated past `maxSections`;
- Without `focus`, snippets return in the index's original order.

### Version selection (catalog_releases / doc_extract version)

`latest` / default → latest stable release (or latest prerelease when no stable exists);
exact version → unique match (a `v`/`V` prefix is tolerated; build metadata such as `+build.2` does not participate in comparison);
prefix (`5` / `5.1` / `5.1.x` / `5.1.*`) → latest release matching the prefix;
prerelease identifiers compare per semver rules (`rc.10` > `rc.9`);
no match → `version` error with a candidate list attached.

---

## Caching strategy

- One **TTL + LRU** in-memory cache per process (default 256 entries, 600 s lifetime), caching:
  remote index fetches and query results; the local index itself is parsed once per process (static data);
- TTL is configurable: `--cache-ttl <sec>` (0–86400, 0 = disabled), `--no-cache` is a shortcut for disabled;
- **Failure cooldown (negative caching)**: after a remote index fetch fails, a 30-second cooldown kicks in during which the plugin falls back to local
  and does not repeat the network request (avoiding a timeout wait on every query while the source is down); after the cooldown it retries automatically and heals itself once the source recovers.
  The cooldown timing is independent of the cache TTL (a `--cache-ttl` shorter than the cooldown does not cut it short);
  it does not apply under `--no-cache` / `--cache-ttl 0` (every failure then really retries);
- LRU evicts by access order; cache stats (hits/misses/evictions) are printed to stderr at exit with `--debug`;
- Local-index cold start is free (synchronous read); after the first remote fetch, all queries hit the cache.

---

## Offline mode and remote index

### Offline mode (default)

Without `--index-url` the plugin is fully offline: it uses the built-in `data/index.json` (3 demo entries:
Express 5.1/4.21 dual versions, Zod 3.24/3.23, Day.js 1.11, including a version-difference demo).
The built-in index can be replaced with your own (`--local-index <path>`), see the format below.

### Remote index

The index is an **open JSON format** hostable on any static HTTP service (GitHub Pages, object storage, intranet file servers all work):

```
index URL(--index-url / DOCTROVE_INDEX_URL,the URL of the directory containing index.json)
   └─ <url>/index.json   ← fetched by the plugin along this path
```

Minimal local hosting (zero dependencies, supports ETag conditional requests; by default listens on the local loopback only — change `host` yourself to expose on LAN):

```bash
node scripts/serve-index.mjs [dir] [port]   # default ./data, port 8730
node src/entry.js --index-url http://localhost:8730
```

Relationship between remote and local: **remote first, local as fallback**. When the remote fetch fails (offline/timeout/non-2xx/invalid format),
the plugin degrades to the local index and keeps serving; every entry and result carries a `source` tag so the model can judge data freshness.

### Index format specification

```jsonc
{
  "format": "doctrove-index@1",          // required, versioned format identifier
  "updatedAt": "2026-08-16T00:00:00.000Z",
  "entries": [{
    "id": "express",                      // required, canonical id (globally unique)
    "name": "Express",                    // required, display name
    "summary": "Minimal web framework for Node.js",
    "aliases": ["expressjs"],             // search aliases (array of strings)
    "homepage": "https://expressjs.com",
    "popularity": 1200,                   // popularity weight (scoring fine-tuning)
    "versions": ["5.1.0", "4.21.2"],      // required, available versions
    "volumes": {                          // required, version → documentation volume
      "5.1.0": {
        "summary": "highlights of this version (optional)",
        "sections": [{                    // required, doc snippets (elements must be non-array objects)
          "heading": "Route handlers",    // snippet title (2x ranking weight)
          "path": "https://expressjs.com/en/5x/api.html#app.METHOD",  // provenance link (optional)
          "body": "snippet body (may include code examples)"
        }]
      }
    }
  }]
}
```

Validation rules: `format` must be `doctrove-index@1`; `entries` must be an array; id/name non-empty and id unique;
`aliases` must be an array of strings; every version in `versions` must have a matching `volumes` volume,
and a volume's `sections` must be a valid array of objects.
Invalid indexes are rejected (remote sources report `network` and degrade to local; local sources report `config` and exit).

---

## Configuration reference

Precedence: **command line > environment variables > config file > defaults**.

| Setting | CLI | Environment variable | Config file key | Default |
| --- | --- | --- | --- | --- |
| Remote index URL | `--index-url <url>` | `DOCTROVE_INDEX_URL` | `indexUrl` | none (offline) |
| Local index path | `--local-index <path>` | `DOCTROVE_LOCAL_INDEX` | `localIndex` | built-in `data/index.json` |
| Cache lifetime (s) | `--cache-ttl <sec>` / `--no-cache` | `DOCTROVE_CACHE_TTL` | `cacheTtl` | 600 |
| Remote timeout (ms) | `--timeout-ms <ms>` | `DOCTROVE_TIMEOUT_MS` | `timeoutMs` | 15000 |
| Debug logging | `--debug` | `DOCTROVE_DEBUG` | `debug` | false |
| Config file | `--config <path>` | `DOCTROVE_CONFIG` | — | none |

The config file is JSON (example: `examples/doctrove.config.example.json`). All configuration is read-only:
the plugin performs no writes and persists no local state. Empty-string environment variables count as unset (defaults apply);
`cacheTtl: 0` is a valid value (cache disabled).

---

## Testing

```bash
node --test        # 105 cases: scoring/versions/cache/config/JSON-RPC/engine/e2e/index hosting
```

Coverage: scoring-ranking boundaries (tier order can never be inverted by popularity), version selection (latest/exact/prefix/prerelease/
build metadata), multi-source merge and degradation self-healing, failure cooldown, cache TTL/LRU, config precedence plus invalid values and empty strings,
MCP protocol (uninitialized gate, version negotiation, error folding, conflicting messages), subprocess-level end-to-end
(handshake + 3 tools + error paths + graceful exit), index hosting (ETag/304/traversal protection/symlink escape/malformed encodings).

---

## Directory structure

```
src/
  entry.js            CLI entry: config → assembly → stdio MCP session
  core/               config (layered config), errors (unified error model), version
  vault/ttl.js        TTL + LRU in-memory cache
  catalog/            scoring (scoring/ranking), releases (version selection), store (catalog hub)
  supply/provider.js  data sources: LocalSource / RemoteSource + index validation
  protocol/           jsonrpc / engine (MCP session engine) / transport (stdio line protocol)
  tools/              registry (registry + parameter validation), definitions (3 tools)
  bridge/             plugin.js (dsh Cordis plugin), client.js (MCP stdio client)
data/index.json       built-in offline index (demo data, replaceable)
scripts/serve-index.mjs  zero-dependency index hosting script
test/                 105 test cases
```

## License

MIT (see [LICENSE](LICENSE)).

Install

dsh plugin --profile web add github:JohnXu22786/docs-retriever

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source