i18n-keeper
Understands i18next plural suffix keys (e.g. item_one, item_few) to avoid false missing/orphan findings and validate plural categories.
Supports Laravel locale files, including PHP array translations and detection of flattened plural selectors.
Supports Symfony-style nested plural keys (e.g. items.one, items.few) for correct plural linting.
Lints YAML locale files for translation issues, including nested keys and plural-form checks.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@i18n-keeperCheck my locale files for missing keys, placeholders, and stale translations."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
i18n-keeper
Deterministic linter for JSON, Laravel PHP, gettext and YAML locale files, as a CLI and an MCP server. No LLM, no network, no API key — every finding is mechanically verifiable, which is the point: you can trust the report in languages you do not read.
npx i18n-keeper checki18n check · source: en · 16 keys · locales
locale coverage missing orphan stale errors warnings
de 93.8% 0 0 2 1 2
es 100.0% 0 1 2 0 5
fr 93.8% 1 0 2 2 3
pl 68.8% 4 0 1 8 2
errors
de nav.home structure_mismatch value in source, object in target
fr cart.total placeholder_missing {{amount}} lost
pl order.thanks placeholder_extra {{imie}} not in source
...Rules
Rule | Default | What it catches |
| error | Key in the source locale, absent in a target |
| error | Key present but the string is empty |
| error | Value on one side, object on the other |
| error |
|
| error | Placeholder that does not exist in the source |
| warning | Key in a target locale, gone from the source |
| warning | Probably untranslated (allowlist with |
| warning | Source changed after the translation was recorded |
| off | Translated but absent from the memory |
| error | Malformed ICU message — throws at format time |
| warning | Plural lacks a form the target language requires |
| warning | Plural branch the target language never selects |
| warning | Laravel `a |
| warning | A do-not-translate token did not survive |
| warning | A glossary term rendered with an unapproved word |
| off | One source string translated two different ways |
| warning | Wider than the limit configured for that key |
| off | Grew more than translation expansion normally allows |
Errors break at runtime. Warnings only look bad — an outdated translation still
renders, so stale is a warning even though it is the most interesting rule
here. Naming a rule with --rule also enables it, so --rule untracked works
without extra configuration.
Related MCP server: Translations MCP Server
Translation memory
Everything above compares locales against each other, which any script can do. The memory is what makes the difference: it remembers which source string a translation was made from, so a later edit to the source surfaces every translation that silently went out of date.
i18n-keeper sync # record what is already translated
# ... someone edits an English string ...
i18n-keeper check # every locale still holding the old translation is staleThe memory lives at .i18n/memory.json, sorted for readable diffs, and is meant
to be committed — it turns translation state into something reviewable in git.
{
"version": 1,
"sourceLocale": "en",
"entries": {
"fr": {
"cart.checkout": {
"sourceHash": "9d0277a31e87",
"value": "Passer à la caisse",
"origin": "human",
"reviewed": true,
"updatedAt": "2026-08-30T15:03:09.972Z"
}
}
}
}Two safeguards matter more than they look:
sync never silently clears a stale flag. An entry whose translation is
unchanged keeps its old source hash, because nothing about the translation was
actually redone. Only sync --force accepts the current state wholesale, and
the command says how many entries it deliberately left stale.
A hand-edited translation is not called stale. If the target no longer matches what the memory recorded, someone already touched it and we cannot claim it is outdated — so the rule stays quiet rather than guessing.
String length
German runs about a third longer than English, so a button that fits in the source overflows its container once translated — silently, because nothing throws.
Measured in display columns, not characters
"Subscribe" .length 9 columns 9
"Newsletter abonnieren" .length 21 columns 21
"ニュースレターを購読する" .length 12 columns 24
"설정" .length 2 columns 4That Japanese string is twelve characters and would pass a limit of sixteen. It occupies twenty-four columns and does not fit. CJK and fullwidth characters count as two, combining marks and variation selectors as zero.
Strings that are never displayed whole are not measured whole: an ICU plural
holds every branch at once but shows one, so it is skipped entirely, and
Laravel's a|b is measured at its widest segment.
Explicit limits
.i18n/limits.json, checked against every locale including the source:
{
"version": 1,
"keys": { "cta.subscribe": 16 },
"patterns": [{ "match": "nav.*.button", "max": 12 }]
}An exact key beats a pattern, the first matching pattern beats default, and
anything unmatched is not checked. * matches any run of characters.
de cta.subscribe length_over_max 21 columns, limit 16
de nav.settings.button length_over_max 13 columns, limit 12
ja cta.subscribe length_over_max 24 columns, limit 16Expansion without configuration
length_overflow needs no limits file: it compares each translation to its
source and complains when it grew more than translation normally does. A single
ratio would be useless — short strings expand far more in relative terms — so
the allowance shrinks as strings grow:
Source width | Allowed |
≤ 10 columns | 300% |
≤ 20 | 200% |
≤ 30 | 180% |
≤ 50 | 160% |
≤ 70 | 140% |
longer | 130% |
These are the conventional expansion rules of thumb, not a standard, and the
rule is approximate by nature — so it is off until asked for with --rule length_overflow.
de settings.delete length_overflow 36 columns vs 14 in source — 257%, allowance 200%
de body.welcome length_overflow 130 columns vs 93 in source — 140%, allowance 130%Glossary and do-not-translate
Translating one string well is easy. Keeping one word rendered the same way across three thousand keys, several translators and two years is the part that drifts — and it is checkable without knowing the language.
.i18n/glossary.json, committed alongside the memory:
{
"version": 1,
"doNotTranslate": ["Acme", "GitHub", "OAuth"],
"terms": [
{
"source": "cart",
"targets": {
"fr": ["panier"],
"pl": ["koszyk"],
"ru": ["корзин"],
"ja": ["カート"]
}
}
]
}A term is only checked in strings whose source actually contains it, and only for locales the entry lists. Anything you have not defined is not judged.
pl cart.empty glossary_violation "cart" should be "koszyk"
pl auth.signin dnt_violation GitHub must stay verbatim
ja cart.add glossary_violation "cart" should be "カート"Matching is built for inflected languages
Demanding a literal substring would fire on every correctly translated Slavic
string, so matching is prefix by default: a term written koszyk accepts
koszyka, and корзин accepts корзина, корзину and корзине. Write the
stem, not the dictionary form. Per entry, "match" can be "exact" or
"substring" instead, and "caseSensitive" can be turned on.
A term still has to start a word, so cart does not match Uncartlike. That
check is skipped for scripts written without spaces — Japanese, Chinese, Thai,
Khmer, Lao, Burmese — where a term is normally surrounded by other letters and a
boundary test would never match at all.
Do-not-translate tokens are compared case-sensitively, because that is the
whole point of a brand name: Github is reported where GitHub was expected.
Consistency without a glossary
inconsistent_translation needs no configuration: it reports one source string
that received two different translations within a locale. Reusing a wording is
often deliberate, so it is off until asked for with --rule inconsistent_translation.
Plural forms
English has two plural forms, Polish has four, Arabic has six, Japanese has one. A translation copied from the English shape is therefore not merely stylistically off — it renders the wrong grammar for whole ranges of numbers, silently.
Categories come from Intl.PluralRules, i.e. the ICU data already in the
runtime, rather than a table in this repository that would drift out of date.
pl cart.removed plural_missing_category pl needs one/few/many/other, has one/other
ja cart.removed plural_extra_category one is not a plural category in ja
ar file_* plural_missing_category ar needs zero/one/two/few/many/other, has one/otherThree plural conventions are understood: ICU messages
({count, plural, one {# item} other {# items}}), i18next suffix keys
(item_one, item_few), and Rails or Symfony nesting (items.one,
items.few). Findings name the group the way the project writes it —
item_* or items.*.
A single sibling is not treated as a plural group, so a key literally named
numbers.one is never asked to grow a few form.
Getting this right also removes findings that a locale-diffing tool would otherwise invent:
item_fewexists in Polish and not in English. That is correct, not an orphan.item_oneis absent from Japanese, which has no such form. That is correct, not a missing key — and it is left out of the coverage denominator, so a complete Japanese locale reads as 100%.
When a locale tag is not recognised, nothing is asserted. Intl.PluralRules
quietly falls back to the system locale for unknown tags — asking about zz on
a Russian machine reports four categories — so a resolved language subtag that
does not match the request is treated as unknown rather than as an answer.
Laravel's a|b and {0} none|[1,*] many selection is its own mechanism, not
CLDR, so it is not judged against CLDR categories. The one unambiguous failure —
a source that selects between forms translated as a single form — is reported as
plural_selector_lost.
Placeholder syntaxes
Detected by default: {{name}} (mustache/i18next), {name} and
{count, plural, ...} (ICU), %{name} (Ruby), %s / %1$s (printf),
<0>…</0> (react-i18next <Trans>).
Patterns are applied most-specific first and each match is masked out, so
{{name}} is never also counted as {name}.
Laravel's :name is off by default in JSON projects, where it false-positives
on prose like Warning:Important; it turns on automatically when the project
has PHP language files, and --syntax overrides the choice either way.
Formats and layouts
JSON, Laravel PHP, gettext and YAML, in either layout, auto-detected:
locales/en.json locales/en/common.json -> common.cart.total
lang/en.php lang/en/validation.php -> validation.max.string
config/locales/en.yml locale/en/LC_MESSAGES/app.po -> app.<msgid>Formats can coexist: a locale directory holding messages.php next to a
lang/en.json is read as one keyspace. When PHP files are present, Laravel's
:name interpolation is enabled automatically, and :name, :Name and
:NAME are treated as one placeholder because Laravel renders them from the
same replacement.
gettext
The msgid is the source text, so a .pot — or any catalogue with empty
msgstr — works as the source locale without a parallel English file.
The format also already tracks what the translation memory was built for: an
entry flagged #, fuzzy is reported as stale with no memory involved.
fr messages.Add to cart stale marked fuzzy in the catalogue
fr messages.Welcome, %s! placeholder_missing %s lost
pl messages.adjective|Open missing_key not translated
pl messages.%d file plural_missing_category header declares nplurals=3, entry has 2msgctxt disambiguates, and shows in keys as context|msgid. Entries
commented out with #~ are already removed from the catalogue and are not
reported as orphans. LC_MESSAGES is dropped from key paths, since it is
directory layout rather than namespace. The last check above needs no CLDR at
all: the catalogue header states its own form count.
YAML
Rails nests a whole file under its locale code, which is stripped — otherwise
every key in en.yml would differ from every key in fr.yml. Rails also
writes plurals as nested one: / other: keys, which are recognised
alongside i18next's item_one suffixes.
This is the one format with a dependency (yaml). The PHP parser is hand
written because the alternative there was executing untrusted code; YAML poses
no such hazard, and its spec is deep enough — anchors, block scalars, implicit
typing — that a hand-rolled subset would quietly misread real files. Notably,
under YAML 1.1 a no: key becomes false, which would silently corrupt a
Norwegian entry; the library's 1.2 default keeps it a string.
PHP files are parsed, never executed
Locale files come from the repository being linted. Running them would mean
executing untrusted code, and would force PHP onto every machine and CI runner
using the linter. So i18n-keeper ships its own parser for the
<?php return [...]; subset — literal arrays, both quote styles with full
escape handling, array(), integer and string keys, and all three comment
styles.
Anything outside that subset — variables, interpolation, concatenation, function calls, heredocs, statements after the return — is a clear error naming the line, not a silent guess:
Cannot parse lang/fr.php
Constants and function calls are not supported (line 4)The parser is verified differentially against PHP itself: npm run test:php
reads every fixture with the real interpreter and compares the two results
structurally. PHP is a development dependency for that test only.
Usage
i18n-keeper check [path] lint locale files
i18n-keeper scan [path] show what would be checked
i18n-keeper sync [path] record current translations in the memory
i18n-keeper translate [path] fill the missing and stale set with Claude
i18n-keeper apply <file> [path] write proposals saved by translate
--locales <dir> locales directory (default: auto-detect)
--source <locale> source locale (default: en, else the first found)
--locale <locale> limit to this locale (repeatable)
--memory <file> translation memory (default: .i18n/memory.json)
--no-memory ignore the memory; disables stale detection
--glossary <file> glossary (default: .i18n/glossary.json)
--no-glossary ignore the glossary
--limits <file> width limits (default: .i18n/limits.json)
--no-limits ignore the width limits
check
--rule <rule> only report this rule, enabling it if off (repeatable)
--ignore-identical <a,b> values allowed to equal the source
--syntax <a,b> override placeholder syntaxes
--limit <n> max findings printed (default: 40)
--json machine-readable output
sync
--origin <human|machine> who produced these translations (default: human)
--force re-record unchanged translations, clearing stale
translate
--write apply accepted translations (default: write nothing)
--cap <n> most strings per run (default: 50)
--batch <n> strings per request (default: 20)
--model <id> default: claude-opus-5
--effort <level> low|medium|high|xhigh|max (default: medium)
--only <kind> fill | repair | refresh (repeatable; default: all)
--save <file> keep the proposals for a later apply
apply
--dry-run re-check the saved proposals and report, writing nothingExit codes: 0 clean, 1 at least one error, 2 the tool itself failed.
Suitable for CI and pre-commit as-is.
MCP server
The same core is exposed over MCP, so an agent can audit locales itself.
claude mcp add i18n-keeper -- node /path/to/i18n-keeper/dist/mcp.jsOr per project, in .mcp.json:
{
"mcpServers": {
"i18n-keeper": {
"command": "node",
"args": ["/path/to/i18n-keeper/dist/mcp.js"]
}
}
}Tool | Returns |
| Locale directory, layout, locales and whether a memory exists |
| Per-locale coverage and counts, no individual findings |
| Findings, filterable by |
| Records translations in the memory — the only tool that writes |
Findings are paged (25 per call by default) because tool output costs the agent
context; i18n_status exists so an agent can get the shape of the problem for a
few dozen tokens before asking for detail. i18n_status, i18n_check and
i18n_sync also return structuredContent, so the numbers can be consumed
without parsing the table.
Machine translation
Everything above is deterministic and offline. This one command is neither: it calls Claude, and its output cannot be verified by reading it in a language you do not speak.
So it is not trusted. Every proposal goes back through the same checks the linter applies, and anything that fails is rejected rather than written.
i18n-keeper translate # propose, validate, print — writes nothing
i18n-keeper translate --write # also apply the accepted ones
i18n-keeper translate --save review.json # keep the proposals for later
i18n-keeper apply review.json # write them, without translating againThe work list comes from the report, so the linter decides what needs doing — in three kinds:
Kind | From | Meaning |
|
| No usable translation exists |
| see below | One exists and the linter proved it wrong |
|
| One exists and its source has moved |
Narrow it with --only fill, --only repair, --only refresh (repeatable).
What counts as repairable
A defect is only handed back to the model if the local check can confirm the repair afterwards. A fix nobody can verify is a fix nobody should trust, so those findings are left for a human. That single rule picks the set:
placeholder_missing, placeholder_extra, icu_syntax_error,
plural_missing_category, glossary_violation, dnt_violation,
length_over_max.
It leaves out identical_to_source — often correct, since "Email" really is
"Email" in French, and forcing a change would make it worse — along with
plural_extra_category and plural_selector_lost, which the single-string
validator does not check and therefore could not confirm.
A repair is sent with the wording someone already chose and the exact findings against it, and asked to change only what those require:
{
"key": "order.thanks",
"source": "Thanks, {{name}}!",
"placeholders_that_must_survive": ["{{name}}"],
"current_translation": "Dziękujemy, {{imie}}!",
"problems_to_fix": [
"placeholder_extra: {{imie}} not in source",
"placeholder_missing: {{name}} lost"
]
}One key broken several ways carries every reason at once, and a repair that does not actually repair is rejected like any other proposal.
The constraints go in, not just on afterwards
Each string is sent with everything the checks will later demand of it: the placeholders that must survive, the plural categories the target language requires, the glossary forms and do-not-translate tokens that apply to that string, and the width limit for that key.
Then the result is checked anyway. When a proposal fails, it goes back once with the specific rule it broke:
request: fr, 4 strings
request: fr, 3 strings (retry)
accept cart.empty Votre panier est vide
accept cart.total Total : {{amount}}
REJECT nav.subscribe S'abonner à la lettre d'information
! 35 columns, limit 12The first attempt had dropped {{amount}} and translated cart as chariot
against the glossary; the retry fixed both. The third string was too wide twice
and was never written.
Look first, apply later, pay once
--save keeps the proposals in a file that apply can write afterwards, so
reviewing before applying does not mean paying for the translation twice. The
file is written even when a run stops early, so partial work survives.
apply puts every proposal through the checks again. A saved file can be
days old and is editable by hand, so nothing is written on the strength of a
check made earlier against files that may since have moved:
dropped
fr cart.total ! placeholders lost: {{amount}}
fr gone ! the key is no longer in the source locale
fr moved ! the source string changed after the proposal was made
fr cart.empty ! was rejected when proposedThe first of those is a translation someone edited inside the saved file after it had passed. It does not get written.
apply --dry-run reports without writing.
Nothing is written by accident
Without --write the command only prints. With it, accepted translations are
applied and recorded in the memory as origin: "machine", reviewed: false —
so a human can find every unreviewed machine string later, and stale keeps
working from there.
Writing back without losing the file
Reading discards everything that is not a key or a value: comments, quote styles, blank lines, anchors, translator notes. Writing must not. So no writer re-serialises a parsed tree — each edits the text in place and leaves every byte it had no reason to touch.
JSON is the exception, and the easy one: no comments, no styles, nothing to lose, so it is re-serialised.
PHP replaces the exact span of one value. A comment above the entry, a
'C:\\Users\\shared'escape, a"caf\u{e9}"written with a unicode escape — all come out byte for byte as they went in. New keys are inserted into their array with the indentation the neighbours use, and missing levels are created.YAML goes through the document model, which keeps comments and anchors across a round trip. An existing scalar is mutated rather than replaced, so a block scalar stays a block scalar. An alias is refused: writing through
*sharedwould silently change every key that shares the anchor.gettext is edited by line, leaving the header, obsolete
#~entries and multi-line msgids alone. A new entry is appended with itsmsgctxt.
A gettext entry written this way is marked #, fuzzy. That flag is gettext's
own word for "no person has reviewed this", which is exactly what the memory
records as reviewed: false; leaving it off would claim an approval nobody
gave. check then reports those entries as stale, which is correct.
A locale file that does not exist yet is created for JSON and PHP, whose empty form is unambiguous. It is not invented for YAML, whose shape depends on whether the project nests under a locale root, nor for gettext, whose header declares the language's own plural rules. Those are reported as not written.
Exit code is 1 whenever anything was rejected, so a pipeline notices.
A refusal applies to one batch and the run carries on. Anything else — no credentials, a rate limit, a dropped connection — will hit every remaining batch identically, so the run stops and says how many strings were never attempted. Those are not reported as rejections: the checks never saw them.
Cost and credentials
Needs ANTHROPIC_API_KEY, or a profile from ant auth login. Defaults to
claude-opus-5 at --effort medium, batches of 20 strings, and at most 50
strings per run — raise with --cap. The source strings, their keys and their
constraints are what gets sent.
Known limits
A gettext key joins
msgctxtandmsgidwith|. A contextless msgid that contains a pipe is indistinguishable from a contextual one, which only matters when appending an entry the catalogue has never carried.New locale files are created for JSON and PHP only; see above.
Development
npm install
npm run build
npm run demo # CLI against the JSON demo fixture
npm run demo:laravel # CLI against the Laravel fixture
npm run walkthrough # the whole memory/stale lifecycle, step by step
npm run smoke # drives the MCP server as a real client would
npm run test:php # our PHP parser vs the real interpreter (needs php)
npm run test:laravel # placeholder casing, flat PHP layout, parse errors
npm run test:plurals # ICU scanner and CLDR category resolution
npm run test:glossary # term matching across scripts, and glossary errors
npm run test:lengths # display width, limit resolution, limits-file errors
npm run test:formats # gettext parsing, YAML typing traps, parse errors
npm run test:translate # the translation gate and repairs, against a stub client
npm run test:apply # save/apply, and every way a saved proposal goes stale
npm run test:writers # writing into PHP, YAML and gettext without losing anything
npm run demo:plurals # five locales with one, two, four and six plural forms
npm run demo:glossary # inflection, Cyrillic stems, CJK and brand names
npm run demo:lengths # German expansion and double-width Japanese
npm run demo:gettext # fuzzy entries, msgctxt, nplurals
npm run demo:rails # locale roots and nested plural keysMIT.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- FlicenseNot gradedqualityCmaintenanceEnables translation of JSON i18n files to multiple languages using various AI providers (Google Gemini, OpenAI, Ollama/DeepSeek) with intelligent caching and deduplication.166
- FlicenseAqualityDmaintenanceEnables automatic discovery and fast searching of translation files in projects, supporting partial/exact key-value matching with file watching and multiple translation file formats.2
- AlicenseNot gradedqualityDmaintenanceAI-powered translation management built for AI agents. Automate localization with regional sensitivity and zero TMS overhead. Works with Claude Code, Cursor, VS Code via MCP protocol. Supports JSON, YAML, Markdown, PO and more.12MIT
- FlicenseBqualityDmaintenanceValidates schema files against customizable lint rules using Claude or Gemini AI, supporting JSON and SQL schemas with rules for naming, structure, and migration safety.1
Related MCP Connectors
Deterministic validation for AI-generated artifacts: JSON Schema, OpenAPI response, SQL syntax.
Lint a SKILL.md for frontmatter, structure, secrets and size. All 6 tools free.
Lints + auto-fixes how AI coding agents discover any new product. 24 rules, 6 tools, score 0-100.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/katerynaKhar/i18n-keeper'
If you have feedback or need assistance with the MCP directory API, please join our Discord server