Elamur: **Mova** · [Parse](https://mova.elamur.ai/free/en/parse/) · [Dictionary](https://md.dictionary.elamur.ai/free/en/) · [Translate](https://md.translate.elamur.ai/free/en/) · [Research](https://md.research.elamur.ai/free/en/) · [MCP](https://mcp.elamur.ai/) · [Groupants](https://md.groupants.elamur.ai/free/en/)

[Mova](https://md.mova.elamur.ai/free/en/) · [this page in HTML](https://mova.elamur.ai/free/en/docs/tools/mova-serve/)

[home](https://md.mova.elamur.ai/free/en/) / [docs](https://md.mova.elamur.ai/free/en/docs/) / [tools](https://md.mova.elamur.ai/free/en/docs/tools/) / mova-serve

- [tests/golden](https://md.mova.elamur.ai/free/en/docs/tools/mova-serve/tests/golden/) — 1

# mova-serve

The public parse service of Mova at `mova.elamur.ai/free/en/parse/`: a page with a text box and shareable addresses, an API (`https://api.mova.elamur.ai/v1/parse`, also `/api/v1/parse` on the site; documented at `/free/en/parse/api/`) that answers in Markdown, JSON, JSONL, YAML or CoNLL-U, an MCP server (`/v1/mcp` on the API host, `/api/v1/mcp` on the site, and a stdio transport), word quotas, and, Elamur-wide, the terms (`https://elamur.ai/terms/`), the privacy policy (`https://elamur.ai/privacy/`) and the account (`https://cabinet.elamur.ai/`, sign-in with Google, callback `https://elamur.ai/account/callback`; one sign-in for every subdomain of elamur.ai). It runs [mova-parse](https://md.mova.elamur.ai/free/en/docs/tools/mova-parse/) (level 1: the language, dialect and style of every sentence and span; level 2: each language's own model, `en.bin`, `fr.bin`) and the pragmatics layer [prag](https://md.mova.elamur.ai/free/en/docs/prag/) (frames, the speech-act perceptron, implicatures, presuppositions, irony candidates) on every English sentence.

## How it works

- **Hosts.** One process serves four hosts by the `Host` header: `mova.elamur.ai` (pages and `/api/v1/…`), `api.mova.elamur.ai` (the API at the root: `/v1/parse`, `/v1/r/<id>.md`, `/v1/mcp`, `/v1/health`; the origin is `--api-origin` or `$MOVA_API_ORIGIN`), `elamur.ai` (`/terms/`, `/privacy/` and the Google callback `/account/callback` only) and `cabinet.elamur.ai` (the account: `/`, `/signin`, `/callback`, `/keys`, …; `?return=` goes back only to https addresses on elamur.ai). The old `/free/en/parse/terms/` and `/free/en/parse/account/…` redirect (301); `/api` and `/api/` redirect to the API page. The session cookie `elamur_session` has `Domain=.elamur.ai`, `Secure`, `HttpOnly`, `SameSite=Lax`. `Origin` checks accept `https://elamur.ai` and `https://*.elamur.ai` only. Terms version `elamur-2026-11-06`: accounts that accepted an older one sign in again.
- **Formats** (`formats`, `md`). `format=md|json|jsonl|yaml|conllu`, a suffix on a stored result (`/v1/r/<id>.md`, long texts only: they have a short id) or the `Accept` header; JSON by default. Markdown: title, per sentence language/dialect/style, token table, tree outline, frames, pragmatics, model SHA-256s, CoNLL-U in a fence (golden file [Mova analysis: Can you pass the salt?](https://md.mova.elamur.ai/free/en/docs/tools/mova-serve/tests/golden/analysis/), bless with `MOVA_BLESS=1`). MCP tools answer in Markdown by default and take `format`. The result page links the short link and the analysis in every format.
- **Analysis** (`engine`). A text is normalised (line ends, runs of spaces, empty lines), parsed by `Dispatcher::parse` (or `parse_as` when the reader picks the language), written as CoNLL-U, and the tokens of the answer are read back from that document, so the JSON and the download agree. English sentences go to `prag::frames::frames_of_tree` with one discourse context for the text, `prag::infer` and `prag::irony`; the speech act, form, indirectness, hedging, polarity and voice come from `prag::eval::Labeler`, trained at start on a pragmatics silver run (`--prag-silver`). Deterministic: the same text, language and model versions give the same bytes; the model files' SHA-256 are in every answer and in the cache key.
- **Pages** (`html`, `svg`). The tree is an inline SVG (arcs stacked by span, the hub's tree classes), with a table as its text alternative and a CoNLL-U download; frames and pragmatics in `<details>` sections; no script. The hub's header, menu, icons, stylesheet and footer come from a shell file written by the hub's generator (`papers parse-shell`), with the hub's release in it; without one a plain shell is used.
- **Cache** (`cache`). W-TinyLFU (Einziger, Friedman & Manes 2017): a 1% window LRU, a segmented main LRU, admission by a count-min sketch of 4-bit counters halved every `10 × width` accesses. Bounded in bytes. The most valuable queries are written to `warm.jsonl` and parsed again at start.
- **Concurrency** (`server`). The models are loaded once and shared (`Arc`) by a fixed pool of workers behind a bounded queue; a full queue answers 503 with `Retry-After`, a slow parse 504.
- **Limits** (`quota`, `ratelimit`). Words by Mova's tokenizer: anonymous 5 000 a request and 250 000 a day per IP address; a free account 20 000 and 1 000 000 a day; more tiers from `--plans` (no billing). Day counts persist in `usage/<date>.json` for two days. A token bucket per client against bursts.
- **Accounts** (`auth`). OpenID Connect authorization code flow with PKCE (S256); the ID token's RS256 signature is verified against Google's JWKS, with `iss`, `aud`, `exp`, `nonce` and a verified e-mail. Sessions and API keys are random tokens stored as SHA-256 hashes (a session only as a hash; a key also as an AES-256-GCM sealed copy bound to its account, with the secret in `keys.secret` of the state directory, mode 600, apart from `oauth.env`, so its owner can see it again in the cabinet and on the API and MCP pages: `?key=<name>` picks a key by name, `none` the placeholder; those pages are private when signed in); sign-in and keys record the terms version accepted and when. CSRF: HMAC tokens bound to the session, a double-submit cookie before it, the `Origin` header checked.
- **MCP** (`mcp`). Revisions 2025-03-26, 2025-06-18 and 2025-11-25 of the Model Context Protocol specification: `initialize`, `ping`, `tools/list`, `tools/call` with `parse_text` and `explain_sentence`; Streamable HTTP (JSON answers, 202 for notifications, no session id, `Origin` checked) and stdio (`mova-serve mcp-stdio`, which forwards to the HTTP endpoint with the key). Authentication is an account's API key, not the specification's OAuth 2.1 authorization.
- **The Elamur MCP** (`commands`, `warm`, `mcp`). One tool, `elamur`, takes `<service> <verb> <prompt>` (`mova parse Can you pass the salt?`): the prompt is the rest of the string, verbatim. The match is lenient: case, a leading `elamur`, separators (spaces, colons, commas, dashes) and one-edit typos (a swap of neighbours counts as one) when the name is unambiguous; otherwise the answer lists the commands. The registry (`commands::COMMANDS`) is data: help, the tool's description and the docs page read it, and a deployment enables commands by name in its settings. The typed tools `parse_text` and `explain_sentence` stay. A prompt that is no command but a kiss, a hug, thanks or a greeting is answered warmly first (`warm.tsv`: a small multilingual lexicon with emoji, en/uk/fr; four replies picked by the prompt's hash), then with the list; never for a real command. Hosts: `ai.mcp.<domain>` is the protocol endpoint (POST at `/` and `/mcp`; a key is optional there: without one the anonymous per-IP quota and a notice apply, a key that is sent but invalid is refused), `GET /get/?query=…` runs the same commands over a plain GET (Markdown, or `format=` / `Accept`), and `mcp.<domain>` is the human page with configs. The earlier `/api/v1/mcp` needs a key as before.
- **Settings** (`settings`). `mova-serve serve --config FILE` reads one JSON file: `hosts` (apex, cabinet, mcp_site), `origins` (api, mcp), `commands` (enabled, `"mova parse"`…) and `serve` (the options by flag name: models, state, secrets, limits); a flag overrides the file. The repository holds `config.example.json` with placeholders; a deployment keeps its own file with its private files. The CSP, the cookie domain and the documented public URLs of the pages are still in the code.
- **What is kept** (`store`). The request log (JSONL, one line per parse with the text; daily zstd archives, bounded in size, 90 days), a ring of the last 20 results, short links of long texts (at most 20 000, 90 days), the day's word counts by IP address and account (2 days), accounts until deleted. The terms page says the same.

## Run

```
cargo build --release -p mova-serve
mova-serve serve --port 8495 --model en=$MOVA_DATA/en/models/en.bin --model fr=$MOVA_DATA/fr/models/fr.bin \
  --prag-silver <silver run dir> --state <dir> [--shell shell.html] [--oauth oauth.env] [--plans plans.json]
mova-serve bench --model en=… --model fr=… --prag-silver <dir>      latency, memory, determinism digest
mova-serve mcp-stdio --endpoint https://api.mova.elamur.ai/v1/mcp   with ELAMUR_API_KEY set
```

## Measurements

2026-11-06, `mova-serve bench` (one worker, through the API route, JSON included; `en.bin`, `fr.bin`, the pragmatics perceptron on 2 986 silver sentences): models loaded in 7 s, 821 MB resident after loading, 836 MB peak.

| words | cold p50 ms | cold p95 ms | cached p50 ms | cached p95 ms | answer KB |
| --- | --- | --- | --- | --- | --- |
| 1 | 0.52 | 1.36 | 0.052 | 0.057 | 2.3 |
| 13 | 2.87 | 4.16 | 0.014 | 0.015 | 5.6 |
| 103 | 18.09 | 19.69 | 0.059 | 0.063 | 38.9 |

A 5 000-word text (the anonymous limit): 0.66 s cold, 8 ms cached, 1.9 MB of JSON. Two processes give the same digest of 19 fixed answers. The cache's negative control: with admission off (plain LRU) a one-off flood of 5 000 unique queries evicts all 20 popular entries; with W-TinyLFU it keeps all 20.

## Limits

Frames and pragmatics are English only; French gives trees (`fr.bin`, experimental). The speech-act labels are the perceptron's (measured 2026-09-26 on held-out silver: act 68.8% against a 33.3% majority baseline, form 94.1%); its margins are scores, not probabilities. The expression lens of `prag` (Wiktionary meanings, CC BY-SA) is not shown. The attachment of a switched span is a placeholder (`CSAttach=root`), as in mova-parse.

- [← previous mova-parse](https://md.mova.elamur.ai/free/en/docs/tools/mova-parse/)
- [next → Nautilus — mova-nautilus](https://md.mova.elamur.ai/free/en/docs/tools/nautilus/)
- [↑ back to tools](https://md.mova.elamur.ai/free/en/docs/tools/)
