estonian-mcp
# estonian-mcp
> Claude is quite bad at Estonian, so this MCP is here to fix that. Give it a shot.
[](https://github.com/silly-geese/estonian-mcp/actions/workflows/ci.yml)
[](https://smithery.ai/servers/silly-geese/estonian-mcp)
[](LICENSE)
[](pyproject.toml)
[](https://modelcontextprotocol.io)
A small **Model Context Protocol** server that exposes
[EstNLTK](https://github.com/estnltk/estnltk), the Estonian NLP toolkit,
as tools any LLM client can call in real time, backed by EKI's orthography
rules (Reeglid) and public-domain Riigi Teataja legislation. Hand it
Estonian text, get back correct lemmas, morphology, POS tags, spell-check +
suggestions, syllables, named entities, WordNet synonyms, fastText-based
related words, a register hint, orthography/grammar checks, kantseliit and
terminology-consistency checks for reports and academic prose, and, for
legal texts, legalese simplification and canonical legal-usage lookups.
**One-click install** from Anthropic's official Connectors Directory, or
self-host. See below.
If your AI agent has to draft, edit, or proofread Estonian, this wires
in ground truth so it stops guessing on the mechanical layer
(spelling, case forms, conjugation) and gives it real Estonian
synonyms instead of inventing them.
It also covers the **editorial** layer, where a word can be correctly
spelled, morphologically valid and still wrong: `check_translationese` for
Estonian that keeps English sentence structure, `check_officialese` for
bureaucratic Estonian in reports and academic prose, `check_term_consistency`
for a document that names one thing three ways, and `synonyms` read as a
word-fit check: its glosses carry domain constraints (`korpus` is
specifically "kirjaliku või suulise teksti elektrooniline kogu", so a set
of images is not one, however natural it sounds in ML jargon).
> **Benchmark:** on TalTech's [`inflection_et`](https://huggingface.co/datasets/TalTechNLP/inflection_et)
> gold dataset (a noun-phrase inflection benchmark; Lillepalu & Alumäe,
> [arXiv:2510.21193](https://arxiv.org/abs/2510.21193v2)), our morphology
> engine scores **99.1% first-candidate / 99.1% any-candidate** over
> 1,400 items (first-candidate is 96.6% without the optional fastText
> model, which is what ranks a lemma's inflection types; any-candidate is
> 99.1% either way). The 13 answers it gets "wrong" are all ones where the
> benchmark itself contradicts EKI, so once those 13 are corrected the
> score is **100% / 100%**; the 13 are listed with their EKI citations in
> [`data/inflection_et_eki_disputes.json`](data/inflection_et_eki_disputes.json).
> `uv run python scripts/apply_eki_corrections.py` rebuilds the dataset
> locally with those 13 rows corrected. The corrections live here; the
> data does not, because the dataset carries no licence and re-hosting it
> is not ours to do.
> Reproduce: `uv run python scripts/eval_inflection.py`.
> (We're a tool server, not a rankable LLM, so this scores our tools
> against published gold data.)
> **Second benchmark, wider and from real text:** `inflection_et` covers
> four cases, so ten were never measured. Against Pert Lomp's
> [käänamiskorpus](https://github.com/pertlomp/qwen38-et) (11,011
> single-word rows over **all fourteen cases**, drawn from Riigikogu
> stenographs and ERR news and frequency-weighted, CC-BY-SA-4.0), the
> engine scores **99.3% first-candidate / 99.9% any-candidate**.
> The gap between those two is the interesting part: Estonian forms most
> plural oblique cases two ways, and leading with the wrong one cost 11
> points until 0.5.10 ordered a slot's variants by the paradigm's own
> genitive stem (`raamatutele` before the literary `raamatuile`).
> Reproduce:
> `uv run python scripts/eval_kaanamiskorpus.py`. The data is downloaded
> at run time, never vendored here, and the server never touches it.
**Three ways to use it:**
1. 👉 **One-click from Anthropic's Connectors Directory**, the easiest
path, no terminal, no install, no URL to paste. See
[Get started in 30 seconds](#-get-started-in-30-seconds-no-install) below.
2. **One-click on Smithery**, install from the
[estonian-mcp listing](https://smithery.ai/servers/silly-geese/estonian-mcp).
3. **Self-host**, clone, run locally as stdio, or deploy your own
container to Fly.io / any host. See [Self-host (advanced)](#self-host-advanced).
## What it does
| Tool | What it does |
| --- | --- |
| `tokenize(text)` | Split text into sentences and words |
| `analyze_morphology(text)` | Lemma, POS, form, root and ending per word (punctuation left out), plus, where they apply, the form's Estonian name, clitic, compound parts, ambiguity count, usage flags (archaic / foreign / interjection / abbreviation / proper-noun) and an indeclinable flag |
| `paradigm(word)` | Full Vabamorf-generated inflection paradigm, 14 cases × 2 numbers for nominals (including ordinals, comparatives, superlatives and proper nouns, the last from Vabamorf's lexicon only), plus the short illative where a word has one (`majja` beside `majasse`), ~39 verb forms including the umbisikuline tegumood (`kasutatakse`, `kasutati`), with Estonian labels per form. A lemma with several inflection types (`kott` → `koti` or `kota`, two different words) returns one consistent table per type, corpus-ranked, rather than a merged one; pass an inflected form (`koti`) to select the type you mean. Proper nouns inflect from Vabamorf's lexicon (`Tallinn` → `Tallinnasse`); a name it lacks gets an empty table rather than invented forms, and a capitalised common word it has no name for (`Pikk` at the start of a sentence) gets the lowercase word's table |
| `lemmatize(text)` | Just the dictionary form per word (punctuation left out) |
| `pos_tag(text)` | Just the part-of-speech tag per word (punctuation left out) |
| `spell_check(text)` | Misspelled words with correction suggestions; an empty list means every word passed |
| `syllabify(word)` | Syllables with quantity + accent |
| `named_entities(text)` | People / places / organisations |
| `synonyms(word)` | Synsets from Estonian WordNet: synonymous lemmas and a definition per word sense, plus examples where WordNet has them |
| `find_related_words(word)` | Top-N semantically nearby words via fastText embeddings (semantically related, not always synonymous) |
| `classify_register(text)` | Coarse formal/colloquial register hint with matched markers, consistency flag for register-mixed text, plus structural signals (umbisikuline tegumood ratio, noun density) so dense officialese no longer scores "neutral" |
| `check_style(text)` | Style metrics, lemma-aware repetition, umbisikuline-tegumood ratio, sentence-length variance, hedging-word density |
| `check_officialese(text)` | Kantseliit check for **non-legal** prose (reports, academic, business), where `check_legalese` stays silent. Nominalisation density (`hindamine` → `hindama`), impersonal-voice ratio, clause stacking (`mille käigus … ning …`), Estonian-calibrated sentence length, admin filler (`omab` → `on`, `mudeli poolt loodud` → `mudeli loodud`), `läbi viima` in every form (`viis läbi uuringu` → `korraldas / tegi`, `läbiviimine`) with routes (`Rada viib läbi metsa`) left alone, translated stock phrases of letters (`Palun leidke manuses`, `ärge kõhelge ühendust võtmast`, `sealhulgas, kuid mitte ainult`) and the request `Küsin täpsemalt, kas …` (→ `Palun täpsustage, …`). A -mine noun's verb is a real one: `läbivaatamine` → `läbi vaatama`, `riskihindamine` → `hindama` |
| `check_translationese(text)` | English structure in Estonian, the patterns that make a text read as translated, each one EKI or an EKI-published author rewrites: `teie andmed` where the subject's own thing is `oma andmed`, `lähtume me` → `lähtume`, `Kui ..., me saadame` → `saadame` (the verb comes first after a fronted clause), `saab olema` → `on`, `ettepanek võtmaks` → `võtta`, `Tagamaks` → `et tagada`, `Mitte leides` → `Leidmata`, `tänu` with a bad cause → `tõttu`, `minu arvamusel`, a third of the sentences opening with `Ma`, and English habits of form: `Hea Mari,` → `Hea Mari!`, `Tere Mari` → `Tere, Mari`, `Lugupidamisega,` → `Lugupidamisega`, the serial comma (`teed, ja`), title case in headings. Accepted Estonian such as `aset leidma`, `rolli mängima` or `Eile ma läksin` is left alone |
| `check_term_consistency(text, glossary?)` | One referent, one term. Flags a document that calls the same thing `andmestik` on page 1 and `teadusandmestik` on page 2, via shared compound head or shared Estonian WordNet synset, with per-variant counts so you can standardise on the dominant one. With a domain glossary (the server's, or a per-call `glossary`), a variant that is the only one listed in its group is named the group's `preferred` form |
| `check_redundancy(text)` | Pleonasm check, flags semantic doubling like `samuti ka` (also+also), `kõige optimaalsem` (most+optimal), and fixed redundant phrases |
| `check_object_case(text)` | Käändeõpetus, flags direct-object case errors under negation and after partitive-only verbs (armastama, vihkama, vajama, …), and verb government: `teavitama` takes the person in the partitive, `teatama` in the allative (`teavitas politseile` → `teavitas politseid` or `teatas politseile`; `kahtlasest tegevusest teavitamine Rahapesu Andmebüroole` → `teatamine`) |
| `check_abbreviation_hyphenation(text)` | Lühendiortograafia, flags an ending that starts with l written straight onto an all-capital abbreviation (`OÜle` → `OÜ-le`, `THIl` → `THI-l`), where EKI finds the hyphen clearer; a letter whose name ends in a consonant also gets the stem vowel (`ASle` → `AS-ile`). Other hyphenless forms (`ELi`, `APIga`, `MCPst`) are as correct as the hyphenated ones and are not flagged, and chloride formulas (`HCl`) are not abbreviations |
| `check_compound_familiarity(text)` | Attestation check, for each compound noun, reports whether the lemma is in the corpus vocabulary, Estonian WordNet or the legal terms list, and returns the top fastText neighbours of each unattested one. Unattested is ordinary for specialist vocabulary and is not a rewrite signal; the narrow `is_suspect` flag needs an unattested lemma whose nearest real neighbour is still under 0.55 (`mõtteliin`-style translationese, literal English "train of thought" → real Estonian is `mõttekäik`). With a server glossary, each compound also carries `in_domain_glossary` |
| `check_domain_terms(text, glossary?, suggest_cutoff?)` | Domain-glossary check, for Estonian written about an organisation's own systems, where a compound should be the organisation's term or the thing's identifier. Reports compounds that are unattested AND not on the operator's glossary or identifier list (see [Domain glossary](#domain-glossary-optional)): not the organisation's word, though not necessarily coined, since much ordinary Estonian is unattested, each with up to three glossary terms *spelled alike* (look-alikes, not synonyms) and the glossary term it is built on, if any (`vagunireisitabel` on `vagunireis`). The hosted server has no glossary of its own: pass your terms in `glossary` (up to 1,000), used for that call only |
| `check_capitalization(text)` | Algustäheortograafia check, flags wrongly capitalized weekdays, months, nationalities, and language/culture adjectives per EKI's Reeglid |
| `check_compounds(text)` | Liitsõnaõigekiri, flags common AI-generated splits of words that should be a single compound (`kooli maja` → `koolimaja`) |
| `check_punctuation(text)` | Kirjavahemärgid, flags missing commas before subordinating conjunctions (`et`, `sest`, `kuna`, `kuid`, `vaid`, `nagu`, …); a comma before a two-part connective (`nii et`, `ilma et`) passes, and `nagu` is flagged only before a clause with a verb. Also flags the comma that closes an embedded clause (`Mees, kes seal seisis oli minu isa` → `seisis, oli`), which EKI keeps even before `ja` when the main clause continues (`Juhtisin projekti, mis kestis kaks aastat, ja koolitasin …`) |
| `check_hyphenation(word)` | Poolitamine, line-break positions for an Estonian word at syllable boundaries, never fewer than 2 characters from either end. Compound seams are among them but not singled out |
| `check_numbers(text)` | Numbrite õigekirjutus, flags decimal separators (`3.14` → `3,14`) and thousands separators (`1,250` → `1250`, `1,000,000` → `1 000 000`; keep `1,250` if the decimal 1,25 is meant). Skips times after `kell`, time ranges, dates, clause, section and version numbers (`4.13.`, `punkt 3.2`, `Python 3.12`); a time without `kell` mid-sentence (`väljub 8.47 ja`) is still flagged. Also flags a tilde used as "about" before a number (`~50` → `u 50`; EKI keeps the tilde for variants such as `mina ~ ma`), a month and year in digits (`09.2016` → `sept 2016`, where it used to be called a decimal), a spaced range dash (`2020 - 2024` → `2020-2024`) and a suffix on the per cent sign (`20%-line` → `20%` or `20-protsendiline`) |
| `check_legalese(text)` | Legal plain-language aid, flags archaic `kantseliit` filler (`käesolev` → `see`, `juhul kui` → `kui`) and over-long sentences to simplify, while listing the **terms of art** that must be preserved so simplification doesn't change legal meaning |
| `check_defined_terms(text)` | Long-document structure, maps terms defined with `(edaspidi „X“)` or `, edaspidi „X“,` in any common quote style (an adverbial `edaspidi` before a quotation defines nothing), counts their exact-form uses (case-sensitive, so a term used only in inflected forms shows as defined-but-unused), lists `§` / `lõige` / `punkt` / `artikkel` cross-references, and flags defined-but-unused or doubly-defined terms (cap raised to 500k chars) |
| `common_legal_usage(word)` | Canonical legal collocations from an offline corpus index, how often a term occurs in legislation and the commonest lemmas of the nearest content word on each side (`hagi` → `esitama hagi`, `kohustus` → `kohustuse täitmine`), so the model uses real legalese instead of inventing it (bundled index: 5 core Riigi Teataja codes, obligations, civil procedure, property, penal, general; expandable, see [Notes](#notes)) |
POS tag set: `S`=noun, `H`=proper noun, `V`=verb, `A`=adj (`C`=comparative,
`U`=superlative, `G`=indeclinable), `P`=pron, `D`=adv, `K`=adp, `J`=conj,
`N`=cardinal, `O`=ordinal, `I`=interj, `Y`=abbreviation or symbol (`§`, `€`),
`X`=verb-bound word (`plehku`). Punctuation (`Z`) is left out of per-word
output; `tokenize` keeps it.
---
## ✨ Get started in 30 seconds (no install)
This section is for everyone, including if you've never opened a
terminal in your life. You'll be done before your tea is steeped.
**estonian-mcp is in Anthropic's official Connectors Directory**, so on
most Claude apps you can add it with **one click, no URL to paste, no
config, no auth.**
### One-click from the Connectors Directory (Cowork, claude.ai, Claude Desktop)
1. In your Claude app, open **Settings → Connectors**.
2. **Browse connectors** (the directory) and search **estonian**, it
shows up as the Estonian connector.
3. Click **Add** / **Connect**. That's it, the server is public, so
there's no authentication step.
4. Start a new chat and write in Estonian, proofread an email, study a
paragraph, draft a legal clause. Claude reaches for the tools whenever
it needs to verify spelling, lemmas, morphology, or legal phrasing
rather than guessing.
*(On older Claude Desktop without a Connectors menu, use the stdio path in
[Self-host (advanced)](#self-host-advanced).)*
### Prefer to paste the URL? (or don't see it in the directory yet)
Any MCP-over-HTTPS client can also connect directly to the hosted server:
we run it for you at `https://estonian-mcp.fly.dev/mcp`. In **Settings →
Connectors → Add custom connector**, paste:
```
https://estonian-mcp.fly.dev/mcp
```
Leave every "Authentication" / "API key" / "Bearer token" field **empty**
(the server is public, no token needed), then Save.
### In Claude Code (CLI)
One command, no clone, no Python, no `uv`. Point Claude Code at the
hosted server over HTTP:
```sh
claude mcp add --transport http estnltk https://estonian-mcp.fly.dev/mcp
```
Then run `/mcp` inside a session to confirm `estnltk` shows as
connected. The tools are live immediately, ask Claude to proofread
or lemmatize Estonian text and it'll reach for them.
Want a fully local, zero-network setup instead? See the stdio path
in [Self-host (advanced)](#self-host-advanced).
### In Codex (CLI)
One command, the same hosted server over streamable HTTP:
```sh
codex mcp add estonian --url https://estonian-mcp.fly.dev/mcp
```
`codex mcp list` should then show it as `enabled`. Auth reads as
"Unsupported", which is correct here: the server is public and wants no
token. Ask Codex to proofread or lemmatize Estonian and it calls the
tools directly.
### In ChatGPT
ChatGPT reaches MCP servers through a custom connector, which lives
behind developer mode. Turn that on in ChatGPT's settings, add a
connector pointing at `https://estonian-mcp.fly.dev/mcp` with no
authentication, then switch it on inside the chat that should use it. A
saved connector is not active until it is selected in the conversation.
OpenAI has moved that toggle between Settings sections more than once,
so follow their current
[developer mode article](https://help.openai.com/en/articles/12584461-developer-mode-and-mcp-apps-in-chatgpt)
for the exact path rather than a menu name written down here.
One limit worth knowing: ChatGPT's *deep research* connectors expect a
server to expose `search` and `fetch` tools. This server exposes 27
Estonian NLP tools and neither of those, so it belongs in the ordinary
connector slot, not the deep research one.
### Don't see your client here?
Any tool that supports MCP over HTTPS can connect, just point it at
`https://estonian-mcp.fly.dev/mcp` with no auth. If your client only
speaks stdio (Cursor, VS Code MCP, Continue, Zed), jump to the
local-install path in [Self-host](#self-host-advanced).
---
## 💡 Pro tip, teach Claude *your* Estonian alongside the MCP
This MCP gives Claude **correct linguistics**: real lemmas, real case
forms, real spelling. What it can't do is teach Claude **your voice**:
the register, idioms, and tone you actually want when writing.
You handle the voice; the MCP handles the correctness. Layer them.
A few things to add to your Claude project / custom instructions /
system prompt to get this right:
- **Set the register.** *"Always reply in formal officialese Estonian
for legal and government topics, and in conversational Tallinn
speech for chat replies. Never mix the two in one message."*
- **Pin the dialect / region.** *"I'm from Tartu, prefer southern
Estonian phrasings where there's a choice (e.g. 'kus sa lähed'
rather than 'kuhu sa lähed' for casual speech)."*
- **Show your tone with examples.** Paste 3-4 short paragraphs of
your own writing into the project instructions and ask Claude to
match that voice. Real examples beat any abstract description.
- **Anchor common mistakes.** *"You always confuse `kasutama` (to use)
with `käsitlema` (to handle / to deal with). Double-check those
with the lemmatize tool before sending."*
- **Direct the MCP explicitly when it matters.** *"Before sending any
Estonian email, run spell_check on every word. Show me misspelled
words with suggestions before drafting."*
- **Use `classify_register` as a sanity check.** *"After drafting,
run classify_register on the final text and warn me if it lands
in 'formal' or 'colloquial' when I asked for the opposite."* The
classifier is coarse but reliably catches drift into officialese
(`käesolev`, `vastavalt`, `sätestama`) or slang (`mõnus`, `vinge`,
`kuule`).
- **Use `synonyms` to break repetition.** *"This newsletter uses
`kasutama` four times. Look up synonyms via the MCP and suggest
natural-sounding swaps."* You'll get real Estonian alternatives
with definitions, not invented ones.
- **Use `find_related_words` for richer rewrites.** *"What words
pattern with `kohv` in Estonian? Use that to suggest three
alternative phrasings for our café-launch ad copy."* This is
fastText-based, so it surfaces near-neighbours that aren't strict
synonyms, useful when you want adjacent concepts, not just
same-meaning swaps. (Quick rule of thumb: `synonyms` for "say the
same thing differently"; `find_related_words` for "what else
belongs in this conceptual space.")
The MCP catches misspelled words and invented case forms; your
prompt drives the style. Together they make Claude actually useful
for writing in Estonian, not just plausible-looking.
---
## How to prompt it once it's connected
Most prompts don't need to mention the tools by name, Claude picks
the right one. A few patterns that work especially well:
```
Proofread this Estonian email and use spell_check on any words
you're unsure about: <text>
```
```
Lemmatize this Estonian paragraph, then translate the lemmas to
English so I can study vocabulary: <text>
```
```
Analyze the morphology of this sentence and explain the case
markings: "Tallinnas elavad eestlased räägivad eesti keelt."
```
```
Extract the people and places from this Estonian news article,
then summarise in one paragraph.
```
```
This Estonian draft uses "kasutama" three times, look up synonyms
via the MCP and rewrite each occurrence with a natural-sounding
alternative that preserves the meaning.
```
```
Write this reply in Estonian, short, the way a native speaker would,
then run check_translationese on it and fix what holds: <text>
```
```
Classify the register of this draft. If it scores formal, soften
it for a casual newsletter audience. If it scores colloquial,
tighten it for a B2B email.
```
The model calls the tool, gets authoritative output, and bases its
response on that, no more hallucinated lemmas or invented case forms.
---
## All clients at a glance
| Client | No-install path | Local-install path |
| --- | --- | --- |
| **Claude Cowork** | ✅ One click (directory) | ✅ stdio via JSON |
| **Claude Desktop** | ✅ One click (newer) | ✅ stdio via JSON |
| **claude.ai web** | ✅ One click (directory) | n/a |
| **Claude Code** (CLI) | ✅ `claude mcp add --transport http` | ✅ `claude mcp add ...` (stdio) |
| **Codex** (CLI) | ✅ `codex mcp add --url` | ✅ `codex mcp add -- <command>` (stdio) |
| **ChatGPT** | ✅ Custom connector (developer mode) | n/a |
| **Cursor** | ✅ Paste URL | ✅ stdio via JSON |
| **VS Code MCP / Continue / Zed** | n/a | ✅ stdio via JSON |
For Claude apps the no-install path is one click from the Connectors
Directory (search "estonian"). Codex takes the URL as a command, ChatGPT
takes it as a custom connector once developer mode is on, and other MCP
clients paste `https://estonian-mcp.fly.dev/mcp` in their Connectors UI. The
local-install path clones the repo and points the client at
`python server.py`.
## Reducing permission prompts
Claude clients ask for confirmation before calling a tool from a
custom/third-party connector, that's the client's security default,
not something the server controls (there's no MCP field a server can
send to suppress it). You'll especially see it right after adding or
updating the connector, since the client re-checks tools it hasn't
seen before.
Good news: **all 28 tools are marked `readOnlyHint: true`** (they only
read text, never write or call out), so any well-behaved client can
safely let you allow them once and stop asking:
- **Claude Desktop / Cowork / claude.ai**, when the prompt appears,
choose **"Always allow"** for the connector (or toggle it in the
connector's settings). One time, then it's quiet.
- **Claude Code**, run `/permissions` and allow the estonian-mcp
tools, or allow the whole server at once.
Re-releasing or updating the connector can reset that "always allow"
state (the client sees changed tools and re-asks), just allow it
again. A verified listing in the Anthropic Connectors Directory also
gets smoother permission UX than an unverified custom connector.
---
## Self-host (advanced)
The hosted instance is convenient, but if you'd rather run your own
(privacy, latency, custom auth, offline use), the same one-file
server works locally and as a container.
### Run locally as stdio (zero network)
Python 3.11-3.13. EstNLTK stops at 3.13, and the Pillow it pulls in needs 3.11 for its security fixes.
```sh
git clone https://github.com/silly-geese/estonian-mcp.git
cd estonian-mcp
uv sync
uv run python scripts/fetch_resources.py # required, see below
uv run python tests/test_smoke.py # verify
```
**Don't skip the `fetch_resources.py` step.** `uv sync` installs Python
packages, but three of the things the server needs are *data*, not Python
distributions, so they can't live in `uv.lock`: NLTK's `punkt_tab`
tokenizer, Estonian WordNet (~26 MB), and the fastText embeddings
(~33 MB). Without them `check_compounds` and `check_term_consistency`
raise, `synonyms` refuses to run, and `check_term_consistency` reports
`degraded: true`. The script is idempotent, so re-running it is free.
The server **never downloads anything itself**: not at import, not on a
tool call. That's the [privacy promise](PRIVACY.md): no outbound HTTP from
the running process. Fetching is a separate step *you* run knowingly, and
the Docker image does the equivalent at build time.
Then wire it into your client.
**Claude Code:**
```sh
claude mcp add estnltk -- /absolute/path/to/uv \
--directory /absolute/path/to/estonian-mcp \
run python server.py
```
**Claude Desktop / Cowork (local mode)**, edit
`~/Library/Application Support/Claude/claude_desktop_config.json`:
```json
{
"mcpServers": {
"estnltk": {
"command": "/absolute/path/to/uv",
"args": [
"--directory", "/absolute/path/to/estonian-mcp",
"run", "python", "server.py"
]
}
}
}
```
**Cursor**, same JSON shape in `~/.cursor/mcp.json`.
### Run as a remote server (HTTP)
The same `server.py` speaks `streamable-http` over the network.
Two auth postures:
- **Public mode** (`ESTNLTK_MCP_PUBLIC_MODE=1`), no bearer token,
per-IP rate limit (default 300/min). This is how the silly-geese
hosted instance runs.
- **Bearer mode** (default), every request must carry
`Authorization: Bearer <token>` (or Smithery's `?config=<base64>`);
per-token rate limit. Refuses to start without
`ESTNLTK_MCP_AUTH_TOKEN` ≥16 chars.
**Fly.io public deployment** (matches silly-geese):
```sh
fly auth login
fly apps create my-estonian-mcp
# one-time: persistent volume for /metrics counters (~$0.15/month)
fly volumes create estonian_mcp_data --size 1 --region ams -a my-estonian-mcp
fly deploy
```
`fly.toml` already sets `ESTNLTK_MCP_PUBLIC_MODE=1` and mounts the
volume at `/data`, so no token needed and `/metrics` counters survive
machine restarts. Endpoint: `https://my-estonian-mcp.fly.dev/mcp`.
**Fly.io with bearer auth**, remove
`ESTNLTK_MCP_PUBLIC_MODE` from `fly.toml`'s `[env]` block, then:
```sh
fly secrets set ESTNLTK_MCP_AUTH_TOKEN="$(python3 -c 'import secrets;print(secrets.token_urlsafe(32))')"
fly deploy
```
**Generic Docker** (any container host):
```sh
docker build -t estonian-mcp . # no image is published
# Public
docker run -p 8081:8081 -e ESTNLTK_MCP_PUBLIC_MODE=1 estonian-mcp
# Bearer
docker run -p 8081:8081 \
-e ESTNLTK_MCP_AUTH_TOKEN="$(python3 -c 'import secrets;print(secrets.token_urlsafe(32))')" \
estonian-mcp
```
**Behind nginx, with TLS and a token per client**: [`deploy/`](deploy/README.md)
is a Docker Compose stack (app + nginx + certbot) for running this on
your own host. nginx terminates TLS with Let's Encrypt certificates,
holds one bearer token per client with per-client rate limits and
one-line revocation, meters the requests it refuses as well as the ones
it serves, and answers OAuth discovery for connectors that cannot send a
static `Authorization` header. It writes no access log unless you turn
one on. See [`deploy/README.md`](deploy/README.md).
```sh
cp .env.example .env # set DOMAIN, LETSENCRYPT_EMAIL, INTERNAL_TOKEN
./deploy/new-token.sh my-laptop
./deploy/init-letsencrypt.sh
```
**Smithery** auto-builds from `smithery.yaml` and hosts the image
for you. Fork, [connect on Smithery](https://smithery.ai/docs/build),
deploy. The shipped `configSchema` is empty (one-click install)
because the deployment runs in public mode; flip it back if you fork
to a bearer-mode setup.
### Domain glossary (optional)
An organisation writing about its own systems has words no general
corpus contains, and an agent writing for it tends to coin a compound
where the organisation already has a word. Give the server that
vocabulary and `check_domain_terms` can tell the two apart.
| Variable | Contents |
|---|---|
| `ESTNLTK_MCP_DOMAIN_GLOSSARY` | Path to the organisation's terms: UTF-8, one lemma (base form) per line, `#` starts a comment. Matched case-insensitively, and offered as suggestions |
| `ESTNLTK_MCP_DOMAIN_IDENTIFIERS` | Path to names `check_domain_terms` never reports and never suggests: table, column or function names written as words. Same format; matched as written as well as by lemma. Only `check_domain_terms` reads this list |
| `ESTNLTK_MCP_DOMAIN_SUGGEST_CUTOFF` | Spelling similarity, 0.4 to 1, that a glossary term needs to be suggested. Default 0.6 |
The server reads the files once, at startup, so restart it after
editing them. A file it cannot read, or a cutoff outside 0.4 to 1, stops
it with exit status 2. With a glossary set, `check_compound_familiarity`
marks each compound `in_domain_glossary` and `check_term_consistency`
names a variant as its group's `preferred` form when it is the only one
listed. Unset, every other tool answers exactly as before.
For stdio, add `"env": {"ESTNLTK_MCP_DOMAIN_GLOSSARY": "/absolute/path/to/terms.txt"}`
to the server's entry in the client config, or with Claude Code:
```sh
claude mcp add estnltk -e ESTNLTK_MCP_DOMAIN_GLOSSARY=/absolute/path/to/terms.txt \
-- /absolute/path/to/uv --directory /absolute/path/to/estonian-mcp \
run python server.py
```
In a container, mount the file read-only. It must be readable by uid
1000, the user the image runs as. Bearer mode, so the glossary is not
handed to anyone who finds the port:
```sh
docker build -t estonian-mcp .
docker run -p 8081:8081 \
-e ESTNLTK_MCP_AUTH_TOKEN="$(python3 -c 'import secrets;print(secrets.token_urlsafe(32))')" \
-v "$PWD/terms.txt:/etc/estonian-mcp/glossary/terms.txt:ro" \
-e ESTNLTK_MCP_DOMAIN_GLOSSARY=/etc/estonian-mcp/glossary/terms.txt \
estonian-mcp
```
The [`deploy/`](deploy/README.md) stack reads them from `deploy/glossary/`.
**Anyone who can call the server can read the glossary**, because the
suggestions quote it, and can probe the identifier list by what it
stops reporting. On a public instance, list nothing confidential. The
server logs a warning at startup when the two are combined.
---
## Security
- **stdio mode**: pure local subprocess. No network egress, no shell
exec, no fs writes, no telemetry.
- **HTTP / public mode**: no auth required (intentional for the free
public service). Per-IP rate limit (300/min default). Same hardening
as bearer mode: no shell exec, no fs writes, no telemetry,
size-bounded inputs.
- **HTTP / bearer mode**: `ESTNLTK_MCP_AUTH_TOKEN` (≥16 chars)
required, server refuses to start without it. Bearer auth on every
request, constant-time comparison, per-token rate limit (120/min).
- **Common to all HTTP**: `/health` is the only path without a rate
limit. The landing page, favicons, server card and `/metrics` share
a separate per-IP limit (60/min default). No request or token
logging. `proxy_headers` is **off**: the server
reads `X-Forwarded-For` itself, counting
`ESTNLTK_MCP_TRUSTED_PROXY_HOPS` entries from the RIGHT (default 1,
for Fly's single edge proxy), because the leftmost entry is
caller-controlled and letting uvicorn trust it defeated the per-IP
rate limit (0.5.4).
- **Inputs**: 100 KB cap per text tool, 200 chars for `syllabify`,
1,000 terms of up to 200 chars for a per-call `glossary`. Oversized
inputs return a structured error rather than hanging.
- **Supply chain**: deps pinned + hashed in `uv.lock`. Dependabot
watches pip + GitHub Actions weekly. CI runs smoke + HTTP tests +
Docker build/boot on Python 3.11 and 3.13 on every push.
Full threat model and disclosure path: [SECURITY.md](SECURITY.md).
Privacy policy (what we receive, what we don't store): [PRIVACY.md](PRIVACY.md).
Terms of service for the hosted endpoint: [TERMS.md](TERMS.md).
---
## Notes
- **The server never downloads anything at runtime.** Not on import, not
on a tool call, not to fill a gap it notices. That's the
[privacy promise](PRIVACY.md). Resources are fetched at Docker build
time, or by you running `scripts/fetch_resources.py` on a source
install. If a resource is missing, tools say so: `synonyms` raises an
actionable error, and `check_term_consistency` returns
`degraded: true` with the reason in its Estonian summary rather than a
confident-looking partial answer.
- Most EstNLTK models (morph, NER, spell-check) ship inside the wheel.
Three things don't, because they're *data*, not Python distributions,
so `uv.lock` can't carry them: NLTK's `punkt_tab` tokenizer, WordNet,
and the fastText model.
- WordNet is a separate ~26 MB resource (used by `synonyms` and one of
`check_term_consistency`'s two rules).
- The fastText model used by `find_related_words`,
`check_compound_familiarity` and `check_domain_terms` is a ~33 MB compressed resource with a
100K-word vocabulary (built locally from Facebook's cc.et.300 via
compress-fasttext, CC-BY-SA-3.0; see [NOTICE](NOTICE)).
- `common_legal_usage` reads a bundled index of five Riigi Teataja
codes (`data/legal_collocations.json.gz`). To cover more acts, build
one with `scripts/build_legal_collocations.py` (see
[scripts/README.md](scripts/README.md)) and set
`ESTNLTK_MCP_LEGAL_INDEX` to its path; the server reads it on the
first call.
- **Results are lean, because every caller pays for them in tokens.**
Each answer is one compact JSON block holding only the fields that
apply. No tool echoes your input or repeats a fixed note, and the
rules every tool shares are said once in the server instructions.
0.8.0 cut the tool list by 42% and a typical result by about half.
- Heavy neural taggers (`estnltk_neural`, BERT-based NER) are
intentionally not pulled in; this server stays lean and fast.
- First call after server start incurs a one-time tag-layer load
(~1-2 s). Subsequent calls are millisecond-scale.
- The hosted Fly instance scales to zero when idle; the first request
after a quiet period takes ~5 s, then everything is fast again.
## 🤝 Contributing
Contributions are welcome, especially from Estonian speakers who can
sharpen the linguistic rules. Here's how to get started:
1. **Fork** the repo and clone your fork.
2. **Set up** the environment (Python 3.11-3.13):
```sh
uv sync
# punkt_tab + WordNet + fastText (none can come from uv.lock):
uv run python scripts/fetch_resources.py
export ESTNLTK_MCP_FASTTEXT_PATH=~/.cache/estnltk-mcp/fasttext-et-medium
```
3. **Create a feature branch** (`git checkout -b feature/my-feature`).
4. **Run the tests**, both must pass:
```sh
uv run python tests/test_smoke.py # tool behaviour
uv run python tests/test_http.py # transport, auth, /metrics
uv run python tests/test_resources.py # resource-availability handling
```
5. **Commit** and open a pull request against `master`. CI (smoke on
Python 3.11 + 3.13, plus a Docker build/boot check) must be green
before merge.
**Please open an issue first for major changes** so we can discuss the
approach before you invest the work.
### Especially wanted: linguistic corrections
The heuristic tools lean on small hand-curated lexicons in
[`server.py`](server.py), marked/archaic words, register markers,
compound-split pairs, partitive-governing verbs, and the EKI
orthography rule sets. These are deliberately conservative and
incomplete. If you're a fluent Estonian speaker and spot a gap or a
wrong entry, that's the highest-value contribution you can make:
- A missing calque AI agents produce, with the idiomatic native form
- A verb that governs the partitive but isn't in the list
- A compound that should (or shouldn't) be flagged
- A register marker that's miscategorised
Open an issue with the English source (if it's a calque), the bad
Estonian, and the better Estonian, or send a PR adding the entry to
the relevant lexicon with a one-line test case.
## License
[Apache-2.0](LICENSE) for the source. Bundled data + models keep their
own (copyleft) licenses, these apply to those files only, not to the
Apache-2.0 code:
- **EstNLTK**, dual-licensed GPL-2.0 OR Apache-2.0 (we use Apache-2.0).
- **Vabamorf** analyzer, LGPL-2.1 with a separate commercial-use license.
- **Estonian fastText** model (`find_related_words`,
`check_compound_familiarity`, `check_domain_terms`), CC-BY-SA-3.0.
- **Estonian Wordnet** (`synonyms`), CC-BY-SA-4.0.
The CC-BY-SA model + Wordnet data carry share-alike obligations on
those files when you redistribute them (the Docker image includes
both). See [NOTICE](NOTICE) for full attribution and redistribution
terms.
TDQS
Scored across 28 tools
Tool boundaries are unusually well signposted: descriptions explicitly cross-reference siblings (find_related_words vs synonyms, check_officialese vs check_legalese, check_style vs check_translationese, check_compound_familiarity vs check_domain_terms). The only residual overlap is among the register/prose tools (check_style, check_officialese, classify_register) which touch similar metrics like impersonal voice and noun density, but the text guides selection by genre.
Mostly a predictable snake_case scheme with check_* for every linting tool, which makes the large check family scannable. Deviations are minor: bare-verb analysis tools (tokenize, lemmatize, syllabify, paradigm) and the reversed spell_check rather than check_spelling.
At 28 tools this is on the heavy side, though the domain (Estonian morphology, orthography, style, legal drafting) is genuinely broad and each check targets a distinct phenomenon. Still, the surface could likely be consolidated (e.g. merging the many narrow legal checks) without losing capability.
Covers the full language-tooling lifecycle: tokenize, morph analysis, lemma/POS, paradigm generation, syllabify, hyphenation, spell check, NER, plus a deep set of orthography/style/legal checks. Minor gaps remain, e.g. no syntax/agreement parsing and no general grammar-check for sentence structure beyond the curated rule lists.