Content
# zhtw-mcp
A linguistic linter for Traditional Chinese (zh-TW) that enforces Taiwan Ministry of Education (MoE) standards on vocabulary, punctuation, and character shapes. It plugs into AI coding assistants through the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) and catches Mainland Chinese (zh-CN) regional drift before it reaches the user.
The tool enforces three official Taiwan standards:
- [Revised Handbook of Punctuation](https://language.moe.gov.tw/001/upload/files/site_content/m0001/hau/c2.htm) (《重訂標點符號手冊》修訂版) -- punctuation marks
- [Standard Form of National Characters](https://language.moe.gov.tw/001/Upload/files/SITE_CONTENT/M0001/STD/F4.HTML) (《國字標準字體》) -- character shapes
- Cross-strait vocabulary normalization, grounded in [OpenCC](https://github.com/BYVoid/OpenCC)'s TWPhrases/TWVariants datasets -- word choices
Over 1100 vocabulary rules and 15 casing rules are compiled into the binary. For ambiguous terms, the server asks the AI assistant it runs inside for help deciding -- no extra API keys required.
## Why this exists
### Modern Chinese is an inadequately standardized language
In the late Qing dynasty, scholars had to express Western concepts in a writing system with no native vocabulary for them. Whether coining new words or importing translations via Japanese (和製漢語), they assembled a literary system under enormous time pressure. Many translated terms were inconsistent, ambiguous, or contradictory. The Chinese-speaking world has lived with these deficiencies for over a century.
### Simplified Chinese made it worse
The PRC simplification effort reduced not just stroke counts but vocabulary precision. Terms that should vary by domain got flattened into single catch-all translations. Many PRC translations were coined hastily: if a term worked in one context, it spread uncritically to others.
### AI models amplify the problem
AI language models learn from web text where Simplified Chinese vastly outweighs Traditional Chinese (roughly 2.6:1 in [CC-100](https://data.statmt.org/cc-100/)). Major datasets like [CulturaX](https://huggingface.co/datasets/uonlp/CulturaX) do not even track Traditional Chinese separately. A [FAccT 2025 study](https://arxiv.org/abs/2505.22645) confirmed that most models favor zh-CN terminology when asked to write zh-TW. The output looks plausible but is not how people in Taiwan actually write.
This goes beyond character conversion. The same word often means different things across the strait:
| English | zh-CN | zh-TW | Why it matters |
|---------|-------|-------|----------------|
| concurrency | 並發 | 並行 | In zh-CN, 並行 means "parallel" -- a different concept entirely |
| parallel | 並行 | 平行 | zh-CN 並行 = "parallel"; in Taiwan, 並行 = "concurrent" |
| process (OS) | 進程 | 行程 | 進程 in Taiwan means "progress," not an OS process |
| file / document | 文件 / 文檔 | 檔案 / 文件 | 文件 in China = "file"; in Taiwan = "document" |
| render | 渲染 | 算繪 | 渲染 in Taiwan = "exaggerate" (a painting technique) |
| traverse | 遍歷 | 走訪 | 遍歷 in Taiwan is reserved for Ergodic theory (遍歷理論) |
### What this project does
Automatically check and correct zh-TW text produced by AI, catching cross-strait terminology leaks:
- Half-width punctuation (`,` `.` `:`) that should be full-width (`,` `。` `:`)
- Mainland-style `""` curly quotes replaced with Taiwan-style `「」` corner brackets
- Missing or extra CJK-Latin/digit spacing when the project's spacing policy requires it
- Mainland vocabulary -- 軟件→軟體, 內存→記憶體, 默認→預設, etc.
- Non-standard character variants -- 裏→裡, 着→著 per MoE standard forms in the strict profile; form preferences are advisory
- Politically colored terms -- 祖國, 內地
- Casing -- JavaScript, GitHub, macOS
These standards are enforced through two profiles on the strictness axis, plus orthogonal capability flags:
| Profile | Purpose |
|---------|---------|
| `base` | Cross-strait vocabulary, punctuation, casing, grammar, politically colored terms |
| `strict` | Full MoE enforcement: character variants (裏→裡), grammar (臺/台), all punctuation |
| Flag | Purpose |
|------|---------|
| `relaxed` | Relaxed for software UI: disables colon/dunhao enforcement and grammar checks; uses en-dash for ranges |
| `detect_ai` | AI writing review: filler phrase detection, semantic safety words, copula/passive voice checks, density-based pattern detection |
| `spacing` | CJK boundary policy, `--spacing require\|strip`: `require` stores spaces (default); `strip` leaves the gap to a controlled HTML renderer. The one flag here that takes a value, and a different thing from `--off spacing`, which turns the whole family off |
For unsupported authority attributions, select `document_genre` in MCP or
`--document-genre casual|technical|financial` in the CLI. The check runs only
with AI detection on (`--detect-ai` / `detect_ai`), and never suggests an edit
in any genre: deleting an attribution changes what the sentence claims, so the
genre selects the advice rather than a rewrite. Casual prose is told to name
the source or drop the appeal; technical and financial prose are told the
claim needs a citation.
Profiles control how strict the zh-TW norm enforcement is. Flags are orthogonal -- `detect_ai` works with either profile, `relaxed` can combine with `strict` if you want variant normalization but lenient punctuation.
`spacing=require` is the default because source text also appears outside CSS-capable renderers. Projects that control their HTML can select `strip` and use [`text-autospace`](https://developer.mozilla.org/en-US/docs/Web/CSS/text-autospace), a Baseline newly-available property since November 2025. [UTR #59](https://www.unicode.org/reports/tr59/) is a draft for layout-time autospacing, not a requirement to remove source-text spaces. Either way the policy governs the U+0020 space, and only where CJK meets an ASCII letter or an ASCII digit.
See [docs/rules.md](docs/rules.md) for the full rule reference.
## Naming convention: cn and tw
This project follows [BCP 47](https://www.rfc-editor.org/info/bcp47). The region subtag comes from [ISO 3166-1 alpha-2](https://www.iso.org/iso-3166-country-codes.html), where "region" can denote a sovereign state, territory, or economic area -- not necessarily a "country."
- `zh-CN`: Chinese as written in the CN region (Simplified)
- `zh-TW`: Chinese as written in the TW region (Traditional)
Throughout the codebase, `cn` and `tw` denote regional writing conventions, not a political statement.
## Getting started
### Pre-built binaries
Every successful push to `main` refreshes the rolling [`latest`](https://github.com/sysprog21/zhtw-mcp/releases/tag/latest) release, which is what GitHub reports as the latest release. No version tag is involved. Each archive holds the binary, `LICENSE`, and `README.md`, and `SHA256SUMS` ships next to them.
| Platform | Asset |
| --- | --- |
| Linux x86_64 (glibc 2.39 or newer) | `zhtw-mcp-x86_64-unknown-linux-gnu.tar.gz` |
| Linux arm64 (glibc 2.39 or newer) | `zhtw-mcp-aarch64-unknown-linux-gnu.tar.gz` |
| macOS arm64 | `zhtw-mcp-aarch64-apple-darwin.tar.gz` |
| Windows x86_64 | `zhtw-mcp-x86_64-pc-windows-msvc.tar.gz` |
On any other platform, use Nix below or build from source.
The browser extension is packaged onto the same release as
`zhtw-mcp-extension.zip`. Unpack it and load it through `chrome://extensions`
with developer mode on.
#### macOS / Linux
```bash
base=https://github.com/sysprog21/zhtw-mcp/releases/download/latest
case "$(uname -sm)" in
"Darwin arm64") asset=zhtw-mcp-aarch64-apple-darwin.tar.gz ;;
"Linux x86_64") asset=zhtw-mcp-x86_64-unknown-linux-gnu.tar.gz ;;
"Linux aarch64") asset=zhtw-mcp-aarch64-unknown-linux-gnu.tar.gz ;;
*) asset=""; echo "no pre-built binary for $(uname -sm)" >&2 ;;
esac
[ -n "$asset" ] &&
curl -fsSLO "$base/$asset" -O "$base/SHA256SUMS" &&
shasum -a 256 --ignore-missing -c SHA256SUMS &&
tar -xzf "$asset" zhtw-mcp
```
On Linux without `shasum`, use `sha256sum --ignore-missing -c SHA256SUMS` instead.
#### Windows (PowerShell)
```powershell
$base = "https://github.com/sysprog21/zhtw-mcp/releases/download/latest"
$asset = "zhtw-mcp-x86_64-pc-windows-msvc.tar.gz"
irm "$base/$asset" -OutFile $asset
irm "$base/SHA256SUMS" -OutFile SHA256SUMS
$want = ((Select-String -Path SHA256SUMS -SimpleMatch $asset).Line -split '\s+')[0]
if ((Get-FileHash -Algorithm SHA256 $asset).Hash -ine $want) { throw "checksum mismatch" }
tar -xzf $asset zhtw-mcp.exe
```
Both snippets leave the binary in the current directory; move it somewhere on your `PATH` to run it by name.
### Nix
On any system with Nix and flakes enabled:
```bash
# builds and drops into a temporary shell with `zhtw-mcp` on `$PATH`
nix shell "github:sysprog21/zhtw-mcp"
# builds and runs zhtw-mcp (you can use this command to register with an MCP
# client as shown in the Installing section)
nix run "github:sysprog21/zhtw-mcp"
```
> **Note:** The first run compiles the project from source, which can take
several minutes. Subsequent runs reuse the Nix store cache and start instantly.
To speed up the initial build, run `nix build --cores 0 "github:sysprog21/zhtw-mcp"`
first. And `--cores 0` tells Nix to use all available CPU cores.
### Building from source
Requires stable Rust 1.91+.
```bash
make
```
The binary is at `target/release/zhtw-mcp`.
Python 3 is a build requirement, not just a test requirement: the OpenCC
conversion tables are generated rather than committed.
### Working on the code
```bash
make check # the gate CI runs: tests, clippy, formatting, hooks
make indent # run the formatters; the gate checks their result
make hooks # install the git hooks; uninstall-hooks removes them
make corpus # precision, recall and false-positive metrics
```
`scripts/indent.sh` holds the formatter chain: comment reflow with
`commentflow`, then `cargo fmt`, `black`, `shfmt`, and the `assets/ruleset.json`
normalization that `scripts/check-ruleset.py` owns. `make indent` runs it with
`--write` and the gate runs it with `--check`, against a copy of the tree so a
check never rewrites what it is judging. The chain runs to a fixed point, since
reindenting a block can invalidate the wrap of a comment inside it.
No formatter here is passed a style flag. `shfmt` takes its settings from
`.editorconfig` and `commentflow` takes its column limit from `.clang-format`,
which exists only for that number: comments wrap at 80 while `rustfmt` allows
code 100.
Every lane reports that it skipped rather than failing when its tool is missing,
which is why a green local run is weaker evidence than a green CI run. CI
installs `commentflow`, `shfmt` and `shellcheck` on the Linux leg and sets
`ZHTW_REQUIRE_TOOLS=1` there, which turns a skip into a failure.
Any `cargo build` installs the git hooks, through `build.rs`, and `make hooks`
does it on its own. A configured `core.hooksPath` is left untouched, since it
may be shared by unrelated repositories. The pre-commit hook runs `rustfmt`, `black`, `shellcheck`,
`shfmt`, `commentflow` and the ruleset checks over a checkout of the index, so
an unstaged edit neither fails a commit nor rides along in one. The commit-msg
hook holds the subject to 50 columns and the body to 72, imperative and free of
em dashes, counting a CJK character as the two columns a terminal spends on
it. The pre-push hook replays
those rules over commits a rebase or an amend rewrote, and CI runs the same
script over a pull request's own commits, so the rules bind a contributor who
never installed the hooks as well.
`scripts/check-comments.sh` holds source comments to the two prose rules no
formatter knows about: no em dash, and no backtick outside a `///` or `//!` doc
comment, where backticks are rustdoc markup rather than prose. It runs in the
gate and in the pre-commit hook.
`scripts/test-git-hooks.sh` drives all four hooks against a scratch repository
and runs in the gate, so a hook that stops rejecting fails there rather than on
somebody's next commit.
### Installing
The quickest way to build, install to `$XDG_BIN_HOME` (or `~/.local/bin`),
stop older server processes, and register with detected MCP clients
(Claude Code and/or Codex):
```bash
make install # build release, install binary, register detected MCP clients
make uninstall # remove binary and detected MCP registrations
make status # check binary freshness, process, and registration state
```
For manual setup or other MCP clients:
```bash
# Claude Code
claude mcp add zhtw-mcp -- /path/to/zhtw-mcp
# Codex CLI
codex mcp add zhtw -- /path/to/zhtw-mcp
# OpenCode
opencode mcp add zhtw-mcp /path/to/zhtw-mcp
```
Other MCP clients may use `.mcp.json` in your project root:
```json
{
"mcpServers": {
"zhtw-mcp": {
"command": "/path/to/zhtw-mcp",
"args": []
}
}
}
```
Replace `/path/to/zhtw-mcp` with the actual binary path (e.g., `target/release/zhtw-mcp`).
### CLI quick start
```bash
zhtw-mcp lint README.md # lint a file
zhtw-mcp lint file.md --fix # auto-fix in place
zhtw-mcp lint file.md --fix --dry-run # preview fixes
zhtw-mcp lint file.md --telemetry # print stderr summary counters
zhtw-mcp cache clear # clear persistent judgment cache
```
See [docs/cli.md](docs/cli.md) for the full CLI reference and [docs/mcp.md](docs/mcp.md) for MCP tool/resource/prompt details.
### Common prompts
When running as an MCP server, you interact through natural language. The assistant translates your intent into `zhtw` tool calls:
| Intent | Say | Maps to | What happens |
|--------|-----|---------|--------------|
| Lint text | *"Check this paragraph for mainland terms"* | `zhtw({ "text": "..." })` | Returns issues with line/column, suggestions, and rule type |
| Auto-fix | *"Fix the zh-TW issues in this document"* | `zhtw({ "text": "...", "fix_mode": "lexical_safe" })` | Deterministic fixes applied; corrected text returned |
| Quality gate | *"Reject if more than 3 zh-TW errors"* | `zhtw({ "text": "...", "max_errors": 3 })` | `accepted: true/false` verdict based on error count |
| Strict MoE | *"Check this with strict MoE rules"* | `zhtw({ "text": "...", "profile": "strict" })` | Adds character variant (裏→裡) and full punctuation enforcement |
| UI strings | *"Lint this UI string, skip grammar"* | `zhtw({ "text": "...", "relaxed": true })` | Disables colon/dunhao/grammar enforcement; uses en-dash for ranges |
| AI writing review | *"Review this for AI writing artifacts"* | `zhtw({ "text": "...", "detect_ai": true })` | Flags filler phrases, semantic safety words, copula/passive overuse |
| Markdown-aware | *"Lint this markdown, skip code blocks"* | `zhtw({ "text": "...", "content_type": "markdown" })` | Fenced code, inline code, and HTML blocks excluded from scanning |
| Cost telemetry | *"Lint this and include telemetry"* | `zhtw({ "text": "...", "include_telemetry": true })` | Returns estimated token/caching metrics for the call |
Each `zhtw` call is stateless -- parameters like `profile` are per-call, not session state. Omitting `profile` defaults to `base`.
The server also exposes two read-only resources for assistants to consult: `zh-tw://style-guide/moe` (MoE standards) and `zh-tw://dictionary/ambiguous` (cross-strait term disambiguation). See [docs/mcp.md](docs/mcp.md) for the full prompt catalog.
## Further reading
- [docs/cli.md](docs/cli.md) -- full CLI reference, config files, CI/CD integration, S2T conversion
- [docs/mcp.md](docs/mcp.md) -- MCP tool parameters, resources, prompts, sampling, usage examples
- [docs/internals.md](docs/internals.md) -- processing pipeline, script detection, design decisions, testing
- [docs/rules.md](docs/rules.md) -- rule type reference, extending the ruleset, runtime overrides
## License
`zhtw-mcp` is available under a permissive MIT-style license.
Use of this source code is governed by a MIT license that can be found in the [LICENSE](LICENSE) file.
MCP Config
Below is the configuration for this MCP Server. You can copy it directly to Cursor or other MCP clients.
mcp.json
Connection Info
You Might Also Like
No recommendations available