libretranslate-mcp
# libretranslate-mcp
[](https://github.com/BaconDroid/libretranslate-mcp/actions/workflows/build.yml)
An MCP server that exposes a **self-hosted LibreTranslate** instance as three
tools: `translate`, `detect`, `languages`.
It is a thin, honest wrapper over LibreTranslate's REST API. It does no
translation itself, has no glossary, no document translation, and no
translation memory.
---
## What is verified, and what is not
The badge above is the source of truth for the current CI state. This section
deliberately makes no claim about it, because such claims go stale on every
run. What follows are durable facts, none of which a CI run can change.
**Not verified — runtime behaviour**
- The client has **never been pointed at a real LibreTranslate instance**. The
response shapes it parses are implemented from a reading of LibreTranslate's
`app.py`, not from observed traffic. That is why the parsing is defensive: a
payload mismatch is designed to fail loudly, but it has never actually
mismatched in the wild.
- The server has **never been started against a real upstream**. Neither
transport has been observed working, no request has ever been served, and the
bearer auth, the body cap and the 401/405/413 paths have only been reasoned
about, never exercised.
**Dependency surface**
- `package-lock.json` is committed and CI installs with `npm ci`, so the
dependency tree is pinned and reproducible. Bumping the SDK or `zod` is now a
deliberate, reviewable change to the lockfile rather than something that
happens silently on the next run.
- The pinned versions were resolved once and never re-checked against the SDK's
published type definitions by hand. The `McpServer` / `registerTool` /
`StreamableHTTPServerTransport` calls follow the SDK's documented usage; a
real signature mismatch would surface as a typecheck failure, not a silent
breakage.
**Not verified — published image**
- `unraid-stack/my-libretranslate-mcp.xml` still references the placeholder
`REPLACE_ME/libretranslate-mcp:latest`. It must be replaced with a real
published image before that template will start; see
`unraid-stack/docs/translation-mcp.md`.
To reproduce the CI checks locally on a machine with Node 20+:
```sh
npm ci
npm run typecheck
npm run build
```
---
## Requirements
- Node.js >= 20 (native `fetch`, `AbortSignal.timeout`, `timingSafeEqual` from
`node:crypto`)
- A reachable LibreTranslate instance
- A LibreTranslate instance that has the language pairs you intend to use
loaded — see "Which languages are available" below
## Install and build
```sh
npm install
npm run build # tsc -p tsconfig.json
```
## Run
```sh
# stdio (default) — for local MCP clients that spawn the server
node dist/index.js
# http — for a container
TRANSPORT=http PORT=8787 AUTH_TOKEN=$(openssl rand -hex 32) node dist/index.js
```
## Environment variables
| Variable | Default | Applies to | Meaning |
|---|---|---|---|
| `TRANSPORT` | `stdio` | both | `stdio` or `http`. |
| `LIBRETRANSLATE_URL` | `http://localhost:5000` | both | Base URL of LibreTranslate. No trailing slash. |
| `LIBRETRANSLATE_TIMEOUT_MS` | `60000` | both | Per-request timeout, via `AbortSignal.timeout`. |
| `PORT` | `8787` | http | Listen port. |
| `AUTH_TOKEN` | *(empty)* | http | Bearer token required on `POST /mcp`. **Empty means no authentication** — a warning is printed to stderr. |
| `MAX_BODY_BYTES` | `1048576` | http | Request body cap; enforced while reading, `413` on exceed. |
| `LOG_LEVEL` | `info` | both | `debug` / `info` / `warn` / `error`. |
All logs go to **stderr**, always. stdout carries the stdio JSON-RPC framing and
any stray write there corrupts the protocol.
## HTTP endpoints
| Route | Auth | Notes |
|---|---|---|
| `GET /health` | none | Liveness. Never contacts LibreTranslate, so a slow upstream does not make the container look dead. |
| `POST /mcp` | bearer, when `AUTH_TOKEN` is set | Stateless Streamable HTTP. `GET`/`DELETE` on this path return `405`. |
`/health` is unauthenticated by design: a liveness probe that fails without a
token is a liveness probe nobody runs. It exposes no secrets — service name,
version, transport mode, whether auth is on, the upstream URL, and the body cap.
Clients of `POST /mcp` must send `Accept: application/json, text/event-stream`,
which is what the MCP Streamable HTTP transport specifies. That is why
`curl` alone is a poor smoke test; use the MCP inspector (see
`../unraid-stack/docs/translation-mcp.md`).
## Tools
### `translate`
| Input | Type | Default | Notes |
|---|---|---|---|
| `q` | string | — | required, non-empty |
| `source` | string | `"auto"` | `"auto"` asks LibreTranslate to detect |
| `target` | string | — | required |
| `format` | `"text"` \| `"html"` | `"text"` | |
| `alternatives` | integer 0–10 | `0` | rejected with `format: "html"` |
Returns `translatedText`, plus `detectedLanguage` when the upstream response
carries one. With `alternatives > 0`, the candidates are included when the
response has them; when the field is absent, the result says so explicitly
instead of returning an empty list that looks like "no alternatives existed".
`format: "html"` with `alternatives > 0` is refused **before** any network
call: LibreTranslate does not return alternatives for HTML input, and sending
it produces a confusing upstream error instead of a clear one.
### `detect`
| Input | Type | Notes |
|---|---|---|
| `q` | string or string[] (1–20) | one detection per input, in order |
### `languages`
No input. Lists what *this instance* can do: `code`, `name`, and `targets` per
language. Use it to pick valid `source`/`target` values — a language absent
here cannot be used.
## Which languages are available
This client does not know. LibreTranslate only loads the language models the
deployment asks for (`LT_LOAD_ONLY` on the official container). A `target`
value that is not loaded produces an upstream `400`, which is surfaced verbatim
in the tool error. Call `languages` to see reality.
## Design notes
- **Defensive response parsing.** `/translate` accepts either a bare JSON
string or an object with a `translatedText` string. Anything else throws an
`Error` containing the received shape (JSON-stringified, truncated to ~300
chars) and the endpoint, so a payload change is diagnosable from the log
instead of surfacing as a silent `undefined`. The same posture applies to
`/detect` (array or single object) and `/languages` (array of objects).
- **Non-2xx responses** always carry the status, the endpoint, and the upstream
`{"error": "..."}` body when there is one.
- **`createServer()` is a factory, not a singleton.** Registering tools on a
module-level server instance makes it impossible to build a second one in the
same process, which the stateless HTTP transport needs (one transport + one
server per request). See `src/server.ts`.
- **Body cap before buffering.** The `Content-Length` header is checked up
front, and the running total is checked on every chunk; on exceed the
response is `413` and the request stream is destroyed. The body is never
accumulated in full and measured afterwards.
- **Token comparison is constant-time.** `crypto.timingSafeEqual`, with a
length check first because that function throws on unequal buffer lengths.
## Project layout
```
src/
index.ts entry point, TRANSPORT switch, HTTP transport
(auth, body cap, per-request transport)
server.ts createServer() factory
constants.ts shared constants
types.ts shared types
services/
libretranslate.ts HTTP client + defensive response parsing
tools/
translate.ts registerTranslateTools
detect.ts registerDetectTools
languages.ts registerLanguagesTools
tsconfig.json
Dockerfile
entrypoint-mcp.sh
.env.example
.github/workflows/build.yml
```
## License
MIT
TDQS
Scored across 3 tools
Each tool maps to a distinct LibreTranslate API endpoint: languages lists supported languages, translate performs translation, and detect identifies language. There is no overlap, and agents can easily select the right tool based on the operation needed.
All tool names are simple, lowercase, single words that clearly indicate their function. While 'languages' is a noun and 'translate'/'detect' are verbs, the naming style is consistent in length and simplicity, and there is no confusion or mixed conventions.
With exactly three tools, the surface is minimal but perfectly scoped for the core functionality of a translation API. Each tool is essential, and the count falls well within the typical range for a well-scoped server.
The tools cover the primary operations of the LibreTranslate API: listing languages, translating text, and detecting language. There are no obvious missing features for a basic translation service, and agents can perform standard workflows (e.g., detect source, then translate) without dead ends.