Skip to main content
Glama
BaconDroid

libretranslate-mcp

by BaconDroid
README.md
# libretranslate-mcp

[![build](https://github.com/BaconDroid/libretranslate-mcp/actions/workflows/build.yml/badge.svg?branch=main)](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

A4.3/5.0

Scored across 3 tools

Disambiguation5/5

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.

Naming Consistency4/5

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.

Tool Count5/5

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.

Completeness5/5

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.

Maintenance

ActivityMaintained
ResponsivenessNo issues