Skip to main content
Glama
README.md
# liara-docs-mcp

[![CI](https://github.com/SalehB1/Lira-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/SalehB1/Lira-mcp/actions/workflows/ci.yml)
[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
[![MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)

An [MCP](https://modelcontextprotocol.io) server over the official [Liara](https://liara.ir) cloud
documentation. Four read-only tools — search, page read, deployment config and build-log
diagnosis — over a corpus that ships inside the package: **3,502 chunks across 1,143 pages**, mostly
Persian, some English.

No ingest step, no database, no vector store, and no API key required. A fresh install answers
immediately.

```
you  ▸ my database gets wiped after every deploy on Liara. why?
      ⚙ search_docs("liara disk persistence deploy")
      ⚙ read_page("https://docs.liara.ir/paas/disks/getting-started/")
```

The retrieval stack and the prompt-injection defenses are lifted from
[lia-helper](https://github.com/SalehB1/lia-helper), a Persian RAG assistant over the same corpus
that won Best Solution for the Liara challenge at the StartCoach hackathon in August 2026. This
repository is the part of it that is useful to any assistant, not just that one.

---

## Install

The server needs no configuration. Point your host at it and restart.

**Claude Code**

```bash
claude mcp add liara-docs -- uvx --from git+https://github.com/SalehB1/Lira-mcp liara-docs-mcp
```

**Claude Desktop** — `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS,
`%APPDATA%\Claude\claude_desktop_config.json` on Windows. Quit Claude fully and reopen it after
editing; the config is read at launch.

```json
{
  "mcpServers": {
    "liara-docs": {
      "command": "uvx",
      "args": ["--from", "git+https://github.com/SalehB1/Lira-mcp", "liara-docs-mcp"]
    }
  }
}
```

**Cursor** — `.cursor/mcp.json`

```json
{
  "mcpServers": {
    "liara-docs": {
      "command": "uvx",
      "args": ["--from", "git+https://github.com/SalehB1/Lira-mcp", "liara-docs-mcp"]
    }
  }
}
```

**VS Code** — `.vscode/mcp.json`. Note the different wrapper key and the `type` field.

```json
{
  "servers": {
    "liara-docs": {
      "type": "stdio",
      "command": "uvx",
      "args": ["--from", "git+https://github.com/SalehB1/Lira-mcp", "liara-docs-mcp"]
    }
  }
}
```

Then ask your assistant something it could not answer before:

> دیتابیسم بعد از هر دیپلوی پاک میشه، چیکار کنم؟

---

## The tools

| Tool | Returns | Bounds |
|---|---|---|
| `search_docs` | Numbered snippets with their source URLs. The tool to reach for on any factual claim. | `query` ≤ 300 chars, `k` clamped to 1–8, 600 chars per snippet |
| `read_page` | One whole documentation page. **No network fetch exists on this path** — the URL must already be in the loaded corpus. | body ≤ 8,000 chars |
| `platform_docs` | Every canonical deployment page for one of 15 platforms, plus the complete `liara.json` reference, plus one search per stated need. Enough to write a correct `liara.json` and cite every key. | `platform` through an enum, ≤ 10 needs of ≤ 80 chars |
| `diagnose_log` | The error signature extracted from a failing build or runtime log, and the pages that explain that class of failure. | log ≤ 20,000 chars, last 300 lines, signature ≤ 200 chars |

There is also one resource, `liara://corpus`, reporting which documentation snapshot answered:
upstream commit, ingest date, chunk and page counts, and whether dense search is active.

Every tool is annotated `read_only_hint`. Nothing here writes anything.

---

## How it searches

**Three ranked lists, fused by reciprocal rank at equal weight.** Word-level BM25 and dense cosine
over 50 candidates each, character-4-gram BM25 over 20, combined as `1/(60 + rank + 1)` with ties
broken on raw cosine. No reranker, no cross-encoder: either one puts a model call on every query.

**The n-gram list is deliberately the short one.** Sub-word overlap is the weakest of the three
signals, and a long tail of it outvotes the word index on literal questions. Measured on a
100-question set: at 18–22 candidates literal recall@5 holds its 51/55 baseline while paraphrase
recall goes 14 → 18; at the full 50, paraphrase reaches 20 but literal drops to 49.

**One Persian normalizer, hand-written, no NLP dependency.** A 49-key `str.translate` table applied
after NFC: Arabic→Persian letter unifications, both Arabic-Indic digit ranges folded to ASCII, and
19 deletions covering tatweel, diacritics and invisible bidi marks. No target is itself a key, which
makes `normalize` idempotent by construction. ZWNJ is kept and ZWJ is deleted, because ZWNJ is
load-bearing orthography: `پایگاه‌داده` must stay one token while `Node.js` splits into two.
Stemming is four suffixes, additive — `tokenize` emits both the surface form and the stem, so a
wrong cut can only ever add a term the query side produces identically.

**Character n-grams buy morphology and typos, not scripts.** `داکر` and `docker` share no n-gram at
all. Bridging that is what the 73 bidirectional synonym groups are for, and synonym expansion is
BM25-only: the dense leg embeds the untouched query.

**The dense list is optional and degrades silently by design.** Set `AVALAI_API_KEY` and each query
is embedded through an OpenAI-compatible `/embeddings` endpoint, cached in process. Without it — or
on a timeout, a bad response, or a provenance mismatch between `embeddings.npz` and the corpus it
was built from — the query runs on the two lexical lists and nothing errors. Seven independent
conditions drop the dense leg, and none of them fails a request.

---

## Grounding

The corpus is public third-party markdown and demonstrably contains instruction-shaped text: one
page in it is a leftover content brief telling the reader to rewrite links. Four layers assume it
is hostile, and all four are enforced in code rather than asked for in a prompt.

| Layer | Where |
|---|---|
| Every result carrying documents is wrapped in `<docs source="untrusted">…</docs>`, so a model is told the contents are data. | `tools.py` |
| `_neutralize` escapes any `<docs>` tag in corpus text so a page cannot close the envelope or forge a trusted one, and breaks a line-leading `[n]` so a page author cannot mint a citation header. Applied separately to URL, title, heading and body. | `tools.py` |
| A URL allowlist at load: prefix `https://docs.liara.ir/` **and** a slug-shaped path regex, because everything after the prefix is author-chosen and a URL carrying `</docs>` would close the envelope from inside. | `retrieval.py` |
| Citation numbers are minted server-side by the registry, keyed by chunk id, at the moment a chunk is formatted. | `citations.py` |

`read_page` reads only URLs already in the loaded index. There is no HTTP client on the tool path,
so it cannot be pointed at an internal host.

Two honest limits. A number can only be *created* here, but nothing resolves the markers back out
of a finished answer, because over MCP the answer is written by the client's model and this server
never sees it — the upstream project could check that and this one cannot. And the instruction that
the corpus is data rather than instructions travels in the server's `instructions` field, which a
client is free to drop; the four rows above are the part that does not depend on being read.

Runnable check: `uv run pytest tests/test_untrusted_corpus.py -v` drives a `javascript:` URL, a
plain-`http` URL and a URL carrying `</docs>` through the real loader, then feeds `_neutralize` a
forged envelope and two forged citation headers.

---

## Run it as a shared service

`--http` serves Streamable HTTP instead of stdio, for one instance several people point at.

```bash
uvx --from git+https://github.com/SalehB1/Lira-mcp liara-docs-mcp --http --port 8000
# clients connect to http://127.0.0.1:8000/mcp
# GET /healthz answers {"status":"ok","chunks":3502,...}
```

**The `Host` allowlist is the one thing to get right.** The MCP SDK defaults to DNS-rebinding
protection that accepts only localhost `Host` headers, which is correct on a laptop and returns
`421 Misdirected Request` to every request behind a real hostname. So:

- Set `MCP_ALLOWED_HOSTS=mcp.example.com,mcp.example.com:*` and the server allowlists exactly those.
  Entries are matched literally, so list the bare host and the `:*` port form both. There is no
  wildcard, and `MCP_ALLOWED_ORIGINS` on its own is refused at startup rather than left to 421 every
  request.
- Bind to loopback with nothing set, and the SDK default stands.
- Bind to a public interface with nothing set, and the server assumes a reverse proxy already
  controls `Host`, switches the check off, and **says so in the log** rather than silently refusing
  every caller.

Deploying to Liara, whose CLI reads the committed `liara.json`:

```bash
liara app:create --platform docker --name liara-docs-mcp
liara deploy
```

**Do not set `AVALAI_API_KEY` on a public instance** unless you accept paying for one embedding call
per distinct query from anyone who can reach it. There is no authentication on the HTTP transport;
this serves public documentation and nothing else.

---

## Environment

Every key is optional and every default works.

| Key | Default | What it does |
|---|---|---|
| `AVALAI_API_KEY` | *(unset)* | Turns on the dense retrieval list. The only outbound call this server makes. |
| `AVALAI_BASE_URL` | `https://api.avalai.ir/v1` | Any OpenAI-compatible `/embeddings` endpoint. |
| `EMBED_MODEL` | `text-embedding-3-small` | Must match what `embeddings.npz` was built with, or the matrix is refused at load. |
| `EMBED_DIM` | `1536` | Same. A different-width model is caught here; a same-width one is caught by the provenance hash. |
| `DATA_DIR` | packaged `data/` | Where `chunks.jsonl`, `embeddings.npz` and `corpus_meta.json` live. |
| `LOG_LEVEL` | `INFO` | Logs go to **stderr**; stdout is the stdio wire. |
| `NO_PROXY_HOSTS` | `api.avalai.ir` | Hosts to keep away from a local proxy. `api.avalai.ir` answers in 0.2 s directly and hangs for 30 s through one. |
| `MCP_ALLOWED_HOSTS` | *(unset)* | `--http` only. See above. |
| `MCP_ALLOWED_ORIGINS` | *(unset)* | `--http` only. Browsers, and nothing else, send `Origin`. Requires `MCP_ALLOWED_HOSTS`. |

---

## Development

```bash
git clone https://github.com/SalehB1/Lira-mcp && cd Lira-mcp
uv sync
uv run pytest -q            # 73 checks, no key and no network
uv run ruff check .
uv run mcp dev src/liara_docs_mcp/server.py    # the MCP Inspector
```

The test suite runs the real corpus through the real tools and drives the server through an
in-process MCP client, so what it asserts is what a host actually sees. It forces the keyless
configuration, so a real key in your shell cannot turn a test run into a spend.

---

## The corpus

A frozen snapshot of `public/llms/**/*.md` from
[liara-cloud/docs](https://github.com/liara-cloud/docs), committed to this repository rather than
fetched at startup — so there is no crawler in the boot path and a cold start does no work.

| | |
|---|---|
| Upstream commit | `dbb7430b1abc5bf92ccca3538f45c54bdc632fa8` |
| Ingested | 2026-08-21 |
| Contents | 3,502 chunks over 1,143 pages — 3,270 Persian, 232 English |
| Embeddings | 3,502 × 1,536, `text-embedding-3-small` |

`corpus_meta.json` carries that provenance, and `embeddings.npz` is stamped with the corpus hash it
was built from: a matrix that disagrees with the chunks actually on disk is refused rather than
multiplied. Ask the server itself by reading the `liara://corpus` resource.

Refreshing it means running [lia-helper's](https://github.com/SalehB1/lia-helper) `ingest/` against
a newer docs commit and copying the three files back into `src/liara_docs_mcp/data/`.

---

## Limitations

- **The corpus is frozen** at one upstream commit. Nothing here schedules a refresh, so pages added
  to the documentation since August 2026 are not searchable.
- **Dense retrieval has one provider** and no fallback. Without a key it is simply absent, which is
  a quality difference a caller cannot see — `/healthz` reports `dense_search` for exactly this
  reason.
- **The whole corpus is scored on every query.** A `matrix @ query` dot product over 3,502 chunks
  with no approximate-nearest-neighbour index is right at this size and does not carry to a million.
- **Persian-first.** Queries work in both languages and 174 of the 375 synonym keys are Latin, but
  the documentation itself is overwhelmingly Persian and the answers a model composes from it will
  be too.
- **No authentication on the HTTP transport.** It serves public documentation; see the spend note
  above before exposing an instance with a key set.
- **Retrieval quality is unmeasured against what actually ships.** The numbers quoted above come
  from the original project's harness against raw user wording, not against queries an MCP client's
  model composes.

---

MIT — see [`LICENSE`](LICENSE).

TDQS

A4.4/5.0

Scored across 4 tools

Disambiguation5/5

Each tool has a clearly distinct purpose: search, read a specific page, aggregate platform-specific docs, and diagnose logs. No overlap or ambiguity.

Naming Consistency4/5

All tools use snake_case and mostly follow verb_noun (search_docs, read_page, diagnose_log), but platform_docs is a noun phrase rather than a verb, which is a minor deviation.

Tool Count5/5

Four tools are well-scoped for a documentation server, covering search, retrieval, and specialized workflows without unnecessary bloat.

Completeness4/5

The core documentation lifecycle (search, read, platform-specific aggregation, and error diagnosis) is covered. Minor gaps like browsing an index or listing available platforms exist, but they are not critical.

Maintenance

ActivityMaintained
ResponsivenessNo issues