feat(servers): persistent volumes + self-hosted web search and docs templates
Instances are immutable and get recreated on any server edit, so anything an
MCP server wrote to its container filesystem was lost at exactly that point.
That ruled out every stateful MCP server, docs-mcp among them: its index is a
SQLite file (better-sqlite3 + sqlite-vec) and it has no external-database mode,
so no amount of Postgres helps.
A server or template can now declare volumes. The backing store is keyed on the
server, not the instance — `mcpctl-<server>-<name>` — which is the whole point:
an instance-scoped claim would be destroyed precisely when the data needs to
survive. On Kubernetes that is a PVC ensured in the servers namespace before the
pod is created and never deleted with it; on Docker, a named volume (named, not
anonymous, so `removeContainer`'s `v: true` leaves it alone).
Claims are ReadWriteOnce, so volumes and replicas > 1 are mutually exclusive;
validation rejects that pair instead of leaving the extra replicas
unschedulable. storageClassName is omitted rather than sent empty when no class
is configured — to Kubernetes those mean different things.
Also fixes a pre-existing bug in the same path: seedTemplates dropped `runtime`,
so every PyPI-backed template seeded from YAML silently defaulted to node and
would run `npx` against a package that only exists on PyPI. `unifi-network`
declares `runtime: python` and had been seeding with runtime unset.
Templates added, all self-hosted and none needing an API key:
- duckduckgo — no backing service at all
- searxng — needs a SearXNG engine (compose profile in stack/)
- docs-mcp — open-source Context7/Ref alternative, uses the new volume
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-09 18:17:32 +01:00
|
|
|
|
# Web search and documentation lookup
|
|
|
|
|
|
|
2026-09-15 22:16:17 +01:00
|
|
|
|
Four templates. All can run self-hosted without an API key or a vendor account,
|
|
|
|
|
|
and all are deployed by mcpctl like any other server.
|
feat(servers): persistent volumes + self-hosted web search and docs templates
Instances are immutable and get recreated on any server edit, so anything an
MCP server wrote to its container filesystem was lost at exactly that point.
That ruled out every stateful MCP server, docs-mcp among them: its index is a
SQLite file (better-sqlite3 + sqlite-vec) and it has no external-database mode,
so no amount of Postgres helps.
A server or template can now declare volumes. The backing store is keyed on the
server, not the instance — `mcpctl-<server>-<name>` — which is the whole point:
an instance-scoped claim would be destroyed precisely when the data needs to
survive. On Kubernetes that is a PVC ensured in the servers namespace before the
pod is created and never deleted with it; on Docker, a named volume (named, not
anonymous, so `removeContainer`'s `v: true` leaves it alone).
Claims are ReadWriteOnce, so volumes and replicas > 1 are mutually exclusive;
validation rejects that pair instead of leaving the extra replicas
unschedulable. storageClassName is omitted rather than sent empty when no class
is configured — to Kubernetes those mean different things.
Also fixes a pre-existing bug in the same path: seedTemplates dropped `runtime`,
so every PyPI-backed template seeded from YAML silently defaulted to node and
would run `npx` against a package that only exists on PyPI. `unifi-network`
declares `runtime: python` and had been seeding with runtime unset.
Templates added, all self-hosted and none needing an API key:
- duckduckgo — no backing service at all
- searxng — needs a SearXNG engine (compose profile in stack/)
- docs-mcp — open-source Context7/Ref alternative, uses the new volume
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-09 18:17:32 +01:00
|
|
|
|
|
|
|
|
|
|
| Template | Package / image | Needs |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| `searxng` | `mcp-searxng` (node) | a SearXNG instance |
|
2026-09-15 22:16:17 +01:00
|
|
|
|
| `firecrawl` | `firecrawl-mcp` (node) | a Firecrawl instance |
|
|
|
|
|
|
| `duckduckgo` | `duckduckgo-mcp-server` (python) | nothing |
|
2026-08-12 23:11:54 +01:00
|
|
|
|
| `docs-mcp` | `ghcr.io/arabold/docs-mcp-server` | nothing |
|
feat(servers): persistent volumes + self-hosted web search and docs templates
Instances are immutable and get recreated on any server edit, so anything an
MCP server wrote to its container filesystem was lost at exactly that point.
That ruled out every stateful MCP server, docs-mcp among them: its index is a
SQLite file (better-sqlite3 + sqlite-vec) and it has no external-database mode,
so no amount of Postgres helps.
A server or template can now declare volumes. The backing store is keyed on the
server, not the instance — `mcpctl-<server>-<name>` — which is the whole point:
an instance-scoped claim would be destroyed precisely when the data needs to
survive. On Kubernetes that is a PVC ensured in the servers namespace before the
pod is created and never deleted with it; on Docker, a named volume (named, not
anonymous, so `removeContainer`'s `v: true` leaves it alone).
Claims are ReadWriteOnce, so volumes and replicas > 1 are mutually exclusive;
validation rejects that pair instead of leaving the extra replicas
unschedulable. storageClassName is omitted rather than sent empty when no class
is configured — to Kubernetes those mean different things.
Also fixes a pre-existing bug in the same path: seedTemplates dropped `runtime`,
so every PyPI-backed template seeded from YAML silently defaulted to node and
would run `npx` against a package that only exists on PyPI. `unifi-network`
declares `runtime: python` and had been seeding with runtime unset.
Templates added, all self-hosted and none needing an API key:
- duckduckgo — no backing service at all
- searxng — needs a SearXNG engine (compose profile in stack/)
- docs-mcp — open-source Context7/Ref alternative, uses the new volume
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-09 18:17:32 +01:00
|
|
|
|
|
2026-09-15 22:16:17 +01:00
|
|
|
|
For agent use, pair `searxng` (finding pages) with `firecrawl` (reading them).
|
|
|
|
|
|
`duckduckgo` does both with no infrastructure, but it scrapes from your IP and
|
|
|
|
|
|
does not hold up under agent traffic — see below.
|
|
|
|
|
|
|
feat(servers): persistent volumes + self-hosted web search and docs templates
Instances are immutable and get recreated on any server edit, so anything an
MCP server wrote to its container filesystem was lost at exactly that point.
That ruled out every stateful MCP server, docs-mcp among them: its index is a
SQLite file (better-sqlite3 + sqlite-vec) and it has no external-database mode,
so no amount of Postgres helps.
A server or template can now declare volumes. The backing store is keyed on the
server, not the instance — `mcpctl-<server>-<name>` — which is the whole point:
an instance-scoped claim would be destroyed precisely when the data needs to
survive. On Kubernetes that is a PVC ensured in the servers namespace before the
pod is created and never deleted with it; on Docker, a named volume (named, not
anonymous, so `removeContainer`'s `v: true` leaves it alone).
Claims are ReadWriteOnce, so volumes and replicas > 1 are mutually exclusive;
validation rejects that pair instead of leaving the extra replicas
unschedulable. storageClassName is omitted rather than sent empty when no class
is configured — to Kubernetes those mean different things.
Also fixes a pre-existing bug in the same path: seedTemplates dropped `runtime`,
so every PyPI-backed template seeded from YAML silently defaulted to node and
would run `npx` against a package that only exists on PyPI. `unifi-network`
declares `runtime: python` and had been seeding with runtime unset.
Templates added, all self-hosted and none needing an API key:
- duckduckgo — no backing service at all
- searxng — needs a SearXNG engine (compose profile in stack/)
- docs-mcp — open-source Context7/Ref alternative, uses the new volume
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-09 18:17:32 +01:00
|
|
|
|
Search and docs are different jobs, not competing options. A search engine will
|
|
|
|
|
|
hand you a 2023 blog post with a stale method signature; a docs index cannot
|
|
|
|
|
|
tell you why a daemon is crash-looping. Attach both to a project that does real
|
|
|
|
|
|
engineering work.
|
|
|
|
|
|
|
|
|
|
|
|
`docs-mcp` is the open-source replacement for Context7 / Ref.tools — same job,
|
|
|
|
|
|
but the index lives on your infrastructure and can include private repos.
|
|
|
|
|
|
|
2026-09-15 22:16:17 +01:00
|
|
|
|
## No infrastructure: `duckduckgo`
|
feat(servers): persistent volumes + self-hosted web search and docs templates
Instances are immutable and get recreated on any server edit, so anything an
MCP server wrote to its container filesystem was lost at exactly that point.
That ruled out every stateful MCP server, docs-mcp among them: its index is a
SQLite file (better-sqlite3 + sqlite-vec) and it has no external-database mode,
so no amount of Postgres helps.
A server or template can now declare volumes. The backing store is keyed on the
server, not the instance — `mcpctl-<server>-<name>` — which is the whole point:
an instance-scoped claim would be destroyed precisely when the data needs to
survive. On Kubernetes that is a PVC ensured in the servers namespace before the
pod is created and never deleted with it; on Docker, a named volume (named, not
anonymous, so `removeContainer`'s `v: true` leaves it alone).
Claims are ReadWriteOnce, so volumes and replicas > 1 are mutually exclusive;
validation rejects that pair instead of leaving the extra replicas
unschedulable. storageClassName is omitted rather than sent empty when no class
is configured — to Kubernetes those mean different things.
Also fixes a pre-existing bug in the same path: seedTemplates dropped `runtime`,
so every PyPI-backed template seeded from YAML silently defaulted to node and
would run `npx` against a package that only exists on PyPI. `unifi-network`
declares `runtime: python` and had been seeding with runtime unset.
Templates added, all self-hosted and none needing an API key:
- duckduckgo — no backing service at all
- searxng — needs a SearXNG engine (compose profile in stack/)
- docs-mcp — open-source Context7/Ref alternative, uses the new volume
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-09 18:17:32 +01:00
|
|
|
|
|
2026-09-15 22:16:17 +01:00
|
|
|
|
The only one with nothing behind it. It scrapes DuckDuckGo's HTML endpoint
|
|
|
|
|
|
directly, so there is no engine to run and no key to hold.
|
feat(servers): persistent volumes + self-hosted web search and docs templates
Instances are immutable and get recreated on any server edit, so anything an
MCP server wrote to its container filesystem was lost at exactly that point.
That ruled out every stateful MCP server, docs-mcp among them: its index is a
SQLite file (better-sqlite3 + sqlite-vec) and it has no external-database mode,
so no amount of Postgres helps.
A server or template can now declare volumes. The backing store is keyed on the
server, not the instance — `mcpctl-<server>-<name>` — which is the whole point:
an instance-scoped claim would be destroyed precisely when the data needs to
survive. On Kubernetes that is a PVC ensured in the servers namespace before the
pod is created and never deleted with it; on Docker, a named volume (named, not
anonymous, so `removeContainer`'s `v: true` leaves it alone).
Claims are ReadWriteOnce, so volumes and replicas > 1 are mutually exclusive;
validation rejects that pair instead of leaving the extra replicas
unschedulable. storageClassName is omitted rather than sent empty when no class
is configured — to Kubernetes those mean different things.
Also fixes a pre-existing bug in the same path: seedTemplates dropped `runtime`,
so every PyPI-backed template seeded from YAML silently defaulted to node and
would run `npx` against a package that only exists on PyPI. `unifi-network`
declares `runtime: python` and had been seeding with runtime unset.
Templates added, all self-hosted and none needing an API key:
- duckduckgo — no backing service at all
- searxng — needs a SearXNG engine (compose profile in stack/)
- docs-mcp — open-source Context7/Ref alternative, uses the new volume
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-09 18:17:32 +01:00
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
mcpctl create server websearch --from-template duckduckgo --env DDG_SAFE_SEARCH=OFF
|
2026-09-15 22:16:17 +01:00
|
|
|
|
mcpctl get instances | grep websearch # RUNNING / healthy within a few minutes
|
feat(servers): persistent volumes + self-hosted web search and docs templates
Instances are immutable and get recreated on any server edit, so anything an
MCP server wrote to its container filesystem was lost at exactly that point.
That ruled out every stateful MCP server, docs-mcp among them: its index is a
SQLite file (better-sqlite3 + sqlite-vec) and it has no external-database mode,
so no amount of Postgres helps.
A server or template can now declare volumes. The backing store is keyed on the
server, not the instance — `mcpctl-<server>-<name>` — which is the whole point:
an instance-scoped claim would be destroyed precisely when the data needs to
survive. On Kubernetes that is a PVC ensured in the servers namespace before the
pod is created and never deleted with it; on Docker, a named volume (named, not
anonymous, so `removeContainer`'s `v: true` leaves it alone).
Claims are ReadWriteOnce, so volumes and replicas > 1 are mutually exclusive;
validation rejects that pair instead of leaving the extra replicas
unschedulable. storageClassName is omitted rather than sent empty when no class
is configured — to Kubernetes those mean different things.
Also fixes a pre-existing bug in the same path: seedTemplates dropped `runtime`,
so every PyPI-backed template seeded from YAML silently defaulted to node and
would run `npx` against a package that only exists on PyPI. `unifi-network`
declares `runtime: python` and had been seeding with runtime unset.
Templates added, all self-hosted and none needing an API key:
- duckduckgo — no backing service at all
- searxng — needs a SearXNG engine (compose profile in stack/)
- docs-mcp — open-source Context7/Ref alternative, uses the new volume
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-09 18:17:32 +01:00
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Tools: `search` (`query`, `max_results`, `region`) and `fetch_content` (`url`,
|
|
|
|
|
|
`start_index`, `max_length`) for pulling a result as markdown.
|
|
|
|
|
|
|
2026-09-15 22:16:17 +01:00
|
|
|
|
Fine for a person searching occasionally; not for agents. Scraping has no SLA,
|
|
|
|
|
|
and agents search in bursts — one question becomes several parallel queries.
|
|
|
|
|
|
From a single home or office IP, DuckDuckGo starts answering CAPTCHA and stays
|
|
|
|
|
|
that way, and every further request from that IP deepens the block, including
|
|
|
|
|
|
for a SearXNG instance sharing the address. `DDG_SEARCH_BACKEND=curl` survives
|
|
|
|
|
|
some bot checks but does not change that. Use `searxng` for agent search.
|
|
|
|
|
|
|
|
|
|
|
|
The readiness probe is `fetch_content` on `https://example.com` every 300s, not
|
|
|
|
|
|
`search`. A search probe is a DuckDuckGo scrape every interval — 1,440 a day at
|
|
|
|
|
|
the 60s default — from the same IP your agents depend on. The trade: `healthy`
|
|
|
|
|
|
proves the process and its outbound fetch work, not that DuckDuckGo is still
|
|
|
|
|
|
answering you.
|
feat(servers): persistent volumes + self-hosted web search and docs templates
Instances are immutable and get recreated on any server edit, so anything an
MCP server wrote to its container filesystem was lost at exactly that point.
That ruled out every stateful MCP server, docs-mcp among them: its index is a
SQLite file (better-sqlite3 + sqlite-vec) and it has no external-database mode,
so no amount of Postgres helps.
A server or template can now declare volumes. The backing store is keyed on the
server, not the instance — `mcpctl-<server>-<name>` — which is the whole point:
an instance-scoped claim would be destroyed precisely when the data needs to
survive. On Kubernetes that is a PVC ensured in the servers namespace before the
pod is created and never deleted with it; on Docker, a named volume (named, not
anonymous, so `removeContainer`'s `v: true` leaves it alone).
Claims are ReadWriteOnce, so volumes and replicas > 1 are mutually exclusive;
validation rejects that pair instead of leaving the extra replicas
unschedulable. storageClassName is omitted rather than sent empty when no class
is configured — to Kubernetes those mean different things.
Also fixes a pre-existing bug in the same path: seedTemplates dropped `runtime`,
so every PyPI-backed template seeded from YAML silently defaulted to node and
would run `npx` against a package that only exists on PyPI. `unifi-network`
declares `runtime: python` and had been seeding with runtime unset.
Templates added, all self-hosted and none needing an API key:
- duckduckgo — no backing service at all
- searxng — needs a SearXNG engine (compose profile in stack/)
- docs-mcp — open-source Context7/Ref alternative, uses the new volume
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-09 18:17:32 +01:00
|
|
|
|
|
|
|
|
|
|
## Better results: `searxng`
|
|
|
|
|
|
|
|
|
|
|
|
SearXNG aggregates ~25 engines and gives you real filtering, which is what keeps
|
|
|
|
|
|
search results from flooding a context window:
|
|
|
|
|
|
|
|
|
|
|
|
- `min_score` (0.0–1.0) — relevance floor. The most useful knob; start at `0.3`.
|
|
|
|
|
|
- `time_range` — `day` / `week` / `month` / `year`. Essential for "did this break
|
|
|
|
|
|
in the last release" questions.
|
|
|
|
|
|
- `language`, `safesearch`, `pageno`.
|
|
|
|
|
|
|
|
|
|
|
|
`web_url_read` then takes `section`, `paragraphRange`, `startChar`/`maxLength`
|
|
|
|
|
|
and `readHeadings`, so you pull one section of a long page rather than all of it.
|
|
|
|
|
|
Bounded PDF extraction is included, which covers most vendor documentation.
|
|
|
|
|
|
|
|
|
|
|
|
The cost is that SearXNG is a service you have to run. It is plain
|
|
|
|
|
|
infrastructure, not an MCP server, so mcpctl has nothing to manage it with — it
|
|
|
|
|
|
belongs in your cluster or in the compose stack:
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
cd stack
|
|
|
|
|
|
cp .env.example .env # set SEARXNG_SECRET
|
|
|
|
|
|
docker compose --profile websearch up -d
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Then point the MCP server at it:
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
mcpctl create secret searxng-conf --data SEARXNG_URL=http://mcpctl-searxng:8080
|
|
|
|
|
|
mcpctl create server searxng --from-template searxng --env-from-secret searxng-conf
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### The one SearXNG gotcha
|
|
|
|
|
|
|
|
|
|
|
|
SearXNG ships with `search.formats: [html]`. Every `format=json` request against
|
|
|
|
|
|
a stock instance returns **403**, and `mcp-searxng` comes back empty with no
|
|
|
|
|
|
useful error. `stack/searxng/settings.yml` exists only to add `json` to that
|
|
|
|
|
|
list — it is the one setting with no environment-variable override.
|
|
|
|
|
|
|
|
|
|
|
|
This is also why pointing the template at a *public* SearXNG instance usually
|
|
|
|
|
|
fails: nearly all of them leave the JSON API off.
|
|
|
|
|
|
|
|
|
|
|
|
The compose healthcheck probes `format=json` specifically, so a misconfigured
|
|
|
|
|
|
instance shows up as unhealthy rather than as silently empty search results.
|
|
|
|
|
|
|
2026-09-15 22:16:17 +01:00
|
|
|
|
### Probe and engines
|
|
|
|
|
|
|
|
|
|
|
|
The template's readiness probe is `searxng_instance_info`, which reads the
|
|
|
|
|
|
instance's `/config`: a pass proves SearXNG answers without sending a query to
|
|
|
|
|
|
any engine. Do not probe with `searxng_web_search` — every interval becomes a
|
|
|
|
|
|
real search fanned out to every engine, which bills API-key engines and feeds
|
|
|
|
|
|
the IP blocks on scraping ones.
|
|
|
|
|
|
|
|
|
|
|
|
Those blocks are the long-term problem. Scraping engines (duckduckgo, google,
|
|
|
|
|
|
startpage, qwant) CAPTCHA a single IP under agent traffic. SearXNG also ships
|
|
|
|
|
|
API-key engines — `braveapi`, `kagi` — that are not subject to IP reputation;
|
|
|
|
|
|
enable at least one if agents are the main caller, and cap its spend in the
|
|
|
|
|
|
vendor's dashboard.
|
|
|
|
|
|
|
|
|
|
|
|
## Reading pages: `firecrawl`
|
|
|
|
|
|
|
|
|
|
|
|
`firecrawl` is the reader to pair with `searxng`: give it a URL, get the page's
|
|
|
|
|
|
main content as markdown. Firecrawl renders pages in a real browser, so
|
|
|
|
|
|
JavaScript-heavy pages work where a plain fetch comes back empty.
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
mcpctl create server firecrawl --from-template firecrawl \
|
|
|
|
|
|
--env FIRECRAWL_API_URL=http://firecrawl-api:3002
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Point it at a self-hosted Firecrawl (no key needed) or leave `FIRECRAWL_API_URL`
|
|
|
|
|
|
unset and set `FIRECRAWL_API_KEY` for Firecrawl's cloud. The tool agents want is
|
|
|
|
|
|
`firecrawl_scrape` with `formats: ["markdown"]` and `onlyMainContent: true`.
|
|
|
|
|
|
|
|
|
|
|
|
- **Egress port.** Self-hosted Firecrawl listens on `:3002`, outside the default
|
|
|
|
|
|
80/443 server egress, so declare it or every call times out.
|
|
|
|
|
|
- **Search stays off** unless the Firecrawl instance has `SEARXNG_ENDPOINT`
|
|
|
|
|
|
set, so `firecrawl_search` cannot quietly become a second scraper. Use the
|
|
|
|
|
|
`searxng` template for search.
|
|
|
|
|
|
- **Probe:** `firecrawl_scrape` of `https://example.com` every 300s. It is a
|
|
|
|
|
|
real outbound fetch, hence the longer interval.
|
|
|
|
|
|
|
feat(servers): persistent volumes + self-hosted web search and docs templates
Instances are immutable and get recreated on any server edit, so anything an
MCP server wrote to its container filesystem was lost at exactly that point.
That ruled out every stateful MCP server, docs-mcp among them: its index is a
SQLite file (better-sqlite3 + sqlite-vec) and it has no external-database mode,
so no amount of Postgres helps.
A server or template can now declare volumes. The backing store is keyed on the
server, not the instance — `mcpctl-<server>-<name>` — which is the whole point:
an instance-scoped claim would be destroyed precisely when the data needs to
survive. On Kubernetes that is a PVC ensured in the servers namespace before the
pod is created and never deleted with it; on Docker, a named volume (named, not
anonymous, so `removeContainer`'s `v: true` leaves it alone).
Claims are ReadWriteOnce, so volumes and replicas > 1 are mutually exclusive;
validation rejects that pair instead of leaving the extra replicas
unschedulable. storageClassName is omitted rather than sent empty when no class
is configured — to Kubernetes those mean different things.
Also fixes a pre-existing bug in the same path: seedTemplates dropped `runtime`,
so every PyPI-backed template seeded from YAML silently defaulted to node and
would run `npx` against a package that only exists on PyPI. `unifi-network`
declares `runtime: python` and had been seeding with runtime unset.
Templates added, all self-hosted and none needing an API key:
- duckduckgo — no backing service at all
- searxng — needs a SearXNG engine (compose profile in stack/)
- docs-mcp — open-source Context7/Ref alternative, uses the new volume
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-09 18:17:32 +01:00
|
|
|
|
## Documentation: `docs-mcp`
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
mcpctl create server docs --from-template docs-mcp
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Tools: `scrape_docs` (index a site, GitHub repo, npm or PyPI package, or local
|
|
|
|
|
|
files), `search_docs` (query, optionally pinned to a version), and `fetch_url`.
|
|
|
|
|
|
|
|
|
|
|
|
Index the things you actually run — Pulumi, the Kubernetes API, Grafana, the
|
|
|
|
|
|
Terraform provider docs — rather than everything.
|
|
|
|
|
|
|
|
|
|
|
|
### Persistence
|
|
|
|
|
|
|
|
|
|
|
|
The scraped index is a SQLite file (`better-sqlite3` + `sqlite-vec`) under
|
|
|
|
|
|
`/data`. There is **no external-database mode** — no `DATABASE_URL`, no
|
|
|
|
|
|
pgvector — so a Postgres cluster cannot help here. The template declares a
|
|
|
|
|
|
volume instead:
|
|
|
|
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
|
|
volumes:
|
|
|
|
|
|
- name: data
|
|
|
|
|
|
mountPath: /data
|
|
|
|
|
|
sizeGb: 20
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
The backing PVC is named after the *server* (`mcpctl-docs-data`), not the
|
|
|
|
|
|
instance, so editing the server or restarting the pod re-attaches to the same
|
|
|
|
|
|
index rather than starting empty. See "Persistent volumes" in the README.
|
|
|
|
|
|
|
2026-08-12 23:11:54 +01:00
|
|
|
|
The readiness probe is `list_libraries`: no arguments, read-only, and it reads
|
|
|
|
|
|
the SQLite store, so a pass proves the volume is mounted and readable rather
|
|
|
|
|
|
than merely that the process started. It answers "No libraries indexed yet." on
|
|
|
|
|
|
a fresh instance instead of erroring, which is why `search_docs` (which needs a
|
|
|
|
|
|
library that only exists after a scrape) cannot serve as the probe.
|
feat(servers): persistent volumes + self-hosted web search and docs templates
Instances are immutable and get recreated on any server edit, so anything an
MCP server wrote to its container filesystem was lost at exactly that point.
That ruled out every stateful MCP server, docs-mcp among them: its index is a
SQLite file (better-sqlite3 + sqlite-vec) and it has no external-database mode,
so no amount of Postgres helps.
A server or template can now declare volumes. The backing store is keyed on the
server, not the instance — `mcpctl-<server>-<name>` — which is the whole point:
an instance-scoped claim would be destroyed precisely when the data needs to
survive. On Kubernetes that is a PVC ensured in the servers namespace before the
pod is created and never deleted with it; on Docker, a named volume (named, not
anonymous, so `removeContainer`'s `v: true` leaves it alone).
Claims are ReadWriteOnce, so volumes and replicas > 1 are mutually exclusive;
validation rejects that pair instead of leaving the extra replicas
unschedulable. storageClassName is omitted rather than sent empty when no class
is configured — to Kubernetes those mean different things.
Also fixes a pre-existing bug in the same path: seedTemplates dropped `runtime`,
so every PyPI-backed template seeded from YAML silently defaulted to node and
would run `npx` against a package that only exists on PyPI. `unifi-network`
declares `runtime: python` and had been seeding with runtime unset.
Templates added, all self-hosted and none needing an API key:
- duckduckgo — no backing service at all
- searxng — needs a SearXNG engine (compose profile in stack/)
- docs-mcp — open-source Context7/Ref alternative, uses the new volume
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-09 18:17:32 +01:00
|
|
|
|
|
|
|
|
|
|
### Embeddings
|
|
|
|
|
|
|
|
|
|
|
|
Keyword search works out of the box. Semantic search is noticeably better and
|
|
|
|
|
|
needs an embedding model. To keep it local, point the server at Ollama:
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
ollama pull nomic-embed-text
|
|
|
|
|
|
mcpctl create server docs --from-template docs-mcp \
|
|
|
|
|
|
--env DOCS_MCP_EMBEDDING_MODEL=openai:nomic-embed-text \
|
|
|
|
|
|
--env OPENAI_API_BASE=http://ollama:11434/v1 \
|
|
|
|
|
|
--env OPENAI_API_KEY=ollama
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Ollama's OpenAI-compatible endpoint is why the provider prefix is `openai:` —
|
|
|
|
|
|
the key is a placeholder and never leaves the network.
|
|
|
|
|
|
|
|
|
|
|
|
Changing the embedding model invalidates the index: embeddings from different
|
|
|
|
|
|
models are not comparable, so everything has to be re-scraped.
|
|
|
|
|
|
|
|
|
|
|
|
## Alternatives considered
|
|
|
|
|
|
|
|
|
|
|
|
- **Tavily / Brave / Exa / Perplexity** — better formatted results, all require
|
|
|
|
|
|
an API key and send every query to a vendor.
|
|
|
|
|
|
- **Context7** — the popular docs MCP, but a cloud index of public-docs snippets
|
|
|
|
|
|
only. `docs-mcp` covers the same ground locally and takes private sources.
|
|
|
|
|
|
- **agent-search** — bundles SearXNG and a 10-strategy extraction cascade in one
|
|
|
|
|
|
deploy. Attractive, but at ~67 stars it is too young to build the stack on.
|
|
|
|
|
|
Worth revisiting.
|
|
|
|
|
|
- **Writing our own** — the hard parts are extraction heuristics and the
|
|
|
|
|
|
embedding pipeline, and all three projects above already solved them under
|
|
|
|
|
|
MIT.
|