Skip to main content
Glama
LHxis

fontforge-mcp

by LHxis
README.md
# fontforge-mcp

An MCP server that exposes [FontForge](https://fontforge.org) as tools, so an AI
assistant can design, repair and build fonts — and **see** the result, because
every operation can be rendered back as a PNG.

Draw glyphs from SVG paths, import an SVG folder as an icon font, clean up
contours, set metrics and kerning, build OpenType features, derive weights, and
compile variable fonts. Then look at a proof sheet before claiming it works.

---

## Requirements

| | | |
|---|---|---|
| **FontForge** | 2023-01-01 or newer | tested on `20251009` |
| **Python** | 3.10+ | for the server; `mcp` requires 3.10 |
| `mcp`, `Pillow`, `fonttools[woff]` | see `requirements.txt` | installed into a venv |
| **An MCP client** | Claude Code, or any other | stdio transport |

That's the whole list. No Node, no compiler, no Rust toolchain.

**You do not install the Python packages into FontForge.** They go into a normal
Python venv that talks to FontForge over a pipe. The next section explains why —
it is the single most important thing to understand before you file a bug.

---

## Why two processes

The obvious design is one process: run the MCP server inside FontForge's own
Python, since that's where `import fontforge` works. On Windows that is
impossible, for two measured reasons:

```console
$ ffpython -c "import sysconfig; print(sysconfig.get_platform())"
mingw_x86_64_msvcrt_gnu

$ ffpython -m pip install pydantic
ModuleNotFoundError: No module named 'pip._vendor.distlib'
```

FontForge for Windows bundles a **MinGW-built CPython**. PyPI's `win_amd64`
wheels are MSVC-ABI and will not load in it, and the bundled `pip` is broken out
of the box. So `mcp` and its `pydantic` dependency can never live there.

Hence the split:

```
MCP client (Claude Code, …)
      │  MCP over stdio
      ▼
server.py                    your Python 3.10+ venv
  ├─ mcp                     tool definitions and schemas
  ├─ Pillow                  preview rendering via FreeType
  └─ fontTools               binary inspection, WOFF2, variable fonts
      │  newline-delimited JSON over pipes
      ▼
worker.py                    FontForge's interpreter — stdlib only
  └─ holds open fonts in memory between calls
```

On Linux and macOS the `fontforge` module is usually importable from a system
`python3`, so the "two interpreters" may resolve to the same binary. The
architecture is unchanged; only the discovery result differs.

One more detail worth knowing: `libfontforge` writes notices straight to **fd 1**
from C, which would corrupt the JSON stream. The worker's first act is to dup the
real stdout to a private descriptor and point fd 1 at stderr.

---

## Install

### 1. FontForge

**Windows** — download the installer from
[github.com/fontforge/fontforge/releases](https://github.com/fontforge/fontforge/releases).
It is Inno Setup, so it takes silent flags:

```powershell
.\FontForge-2025-10-09-Windows-x64.exe /VERYSILENT /NORESTART
```

`ffpython.exe` lands in `<install>\bin\` and is found automatically.

**Debian / Ubuntu**

```bash
sudo apt install fontforge python3-fontforge
```

**Fedora**

```bash
sudo dnf install fontforge python3-fontforge
```

**macOS**

```bash
brew install fontforge
```

On Linux and macOS you can confirm it immediately — if this prints a version,
the hard part is done:

```bash
python3 -c "import fontforge; print(fontforge.version())"
```

On Windows there is nothing to check by hand yet: FontForge ships its own
interpreter and its location depends on where you installed it. **Step 3 finds
it for you, on any drive** — don't go hunting for the path.

### 2. The server

**Linux / macOS**

```bash
git clone https://github.com/LHxis/fontforge-mcp
cd fontforge-mcp
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
```

**Windows** — identical in `cmd.exe` and PowerShell:

```
git clone https://github.com/LHxis/fontforge-mcp
cd fontforge-mcp
python -m venv .venv
.venv\Scripts\python.exe -m pip install -r requirements.txt
```

### 3. Verify, before registering anything

Two scripts, in this order. The first finds FontForge; the second proves the
whole chain works.

```bash
.venv/bin/python bridge.py             # Linux / macOS
.venv/bin/python selftest.py
```
```
.venv\Scripts\python.exe bridge.py     :: Windows — same in cmd.exe and PowerShell
.venv\Scripts\python.exe selftest.py
```

**`bridge.py`** prints every interpreter it tried, marks which ones can actually
`import fontforge`, and names the winner. If it finds nothing, this is where you
learn that — with the exact command to point `FFPYTHON` at your install. Run it
first: everything downstream depends on this one answer.

**`selftest.py`** runs 13 checks: it builds a small font from SVG paths,
validates the contours, generates TTF/OTF/WOFF2, renders a preview, and drives
the server over real MCP stdio — the same path your client will use. It also
confirms that a tool error surfaces as an error instead of killing the
connection.

If both pass, the problem is never the install. That matters later: when
something misbehaves, re-running `selftest.py` tells you in seconds whether to
suspect your setup or your font.

### 4. Register it

The client stores the command verbatim and does **not** expand `~` or resolve
paths relative to your shell — it needs absolute paths. You do not have to type
them: `selftest.py` printed the exact command for your machine at the end of
step 3. Copy that line as printed and you are done.

To build it yourself instead, let the shell fill in the path. Run these from
inside the project directory — no username, no drive letter, nothing to edit:

**cmd.exe**

```
claude mcp add fontforge --scope user -- "%CD%\.venv\Scripts\python.exe" "%CD%\server.py"
```

**PowerShell**

```powershell
claude mcp add fontforge --scope user -- "$PWD\.venv\Scripts\python.exe" "$PWD\server.py"
```

**Linux / macOS**

```bash
claude mcp add fontforge --scope user -- "$PWD/.venv/bin/python" "$PWD/server.py"
```

The shell expands `%CD%` / `$PWD` before `claude` ever sees it, so what gets
stored is a plain absolute path.

> Keep the Windows command on **one line**. PowerShell's backtick and bash's
> backslash continuations both fail in `cmd.exe`, which is still the default
> shell for a lot of Windows users — and a broken continuation is a confusing
> error to hit at the last step.

Any other MCP client — the equivalent stdio entry:

```json
{
  "mcpServers": {
    "fontforge": {
      "command": "/abs/path/fontforge-mcp/.venv/bin/python",
      "args": ["/abs/path/fontforge-mcp/server.py"]
    }
  }
}
```

Restart the client — MCP servers are loaded at startup. Then ask it to run
`ff_status`; it reports the FontForge version, the interpreter in use and the
workspace path.

---

## Configuration

| Variable | Default | Purpose |
|---|---|---|
| `FONTFORGE_MCP_WORKSPACE` | `~/fonts` | where relative paths resolve |
| `FFPYTHON` | auto-detected | pin the FontForge interpreter |

### How the interpreter is found

Cheapest lookup first, stopping at the first one that can actually
`import fontforge` — existence on disk is never enough:

1. **`$FFPYTHON`** — an explicit override always wins, even when it is wrong, so
   a bad value fails loudly instead of silently falling through.
2. **Windows registry** — FontForge's installer records `InstallLocation` under
   the uninstall keys. One lookup finds it on **any drive**, no scanning.
3. **`PATH`** — `ffpython`, `fontforge-python`, and `python3` (not on Windows,
   where `python3` resolves to the Microsoft Store stub and pops the Store UI).
4. **Standard roots on every mounted drive** — `%ProgramFiles%`, `Program Files`,
   `Program Files (x86)`, `Programs`, `Apps`, and each drive root from A: to Z:.
   This is the fallback for installs the registry does not know about.
5. **The running interpreter** — a venv built with `--system-site-packages` may
   already see the module.

To see exactly what discovery finds on your machine:

```bash
.venv/bin/python bridge.py             # Linux / macOS
```
```
.venv\Scripts\python.exe bridge.py     # Windows
```

It prints every candidate it tried, which ones can import `fontforge`, and the
command to pin the winner.

### Pinning it

Skip discovery entirely by setting the variable once:

```
setx FFPYTHON "D:\wherever\FontForgeBuilds\bin\ffpython.exe"
```

`setx` writes it permanently for your user, but only affects **new** terminals —
the one you typed it in keeps the old environment. On Linux/macOS, add
`export FFPYTHON=/path/to/python3` to your shell rc.

If your MCP client is already running, restart it so it picks up the new
environment.

---

## Tools

**Font** · `ff_status` `ff_new_font` `ff_open_font` `ff_list_fonts`
`ff_close_font` `ff_font_info` `ff_save` `ff_generate`

**Glyphs** · `ff_list_glyphs` `ff_glyph_info` `ff_draw_glyph` `ff_read_glyph`
`ff_delete_glyph` `ff_import_svg` `ff_import_svg_folder` `ff_transform_glyph`
`ff_clean_glyph` `ff_stroke_glyph`

**Metrics** · `ff_set_metrics` `ff_auto_metrics` `ff_kern`

**Composites & OpenType** · `ff_add_reference` `ff_build_accented` `ff_features`

**Derivation & variable fonts** · `ff_derive` `ff_build_variable_font`

**Verification** · `ff_validate` `ff_inspect_binary` `ff_preview`

**Escape hatch** · `ff_python` — runs FontForge Python in the worker, with
`font` pre-bound. FontForge's API is far larger than this server.

`ff_preview` is the one that matters most. Four modes: `outline` (one glyph with
control points, handles and metric guides), `text` (real FreeType rasterization
of the compiled font), `waterfall` (many sizes, for small-size legibility), and
`sheet` (a grid of every glyph). Images come back as PNG, so the assistant can
judge the result instead of assuming it.

> Tool descriptions are currently written in **Brazilian Portuguese**. They are
> instructions for the model, not UI text, and models handle them fine — but if
> you want them in English, they all live in the docstrings in `server.py`.

---

## Coordinate conventions

Getting these wrong is the number one cause of mangled glyphs.

- **Font units, Y axis UP**, baseline at `y=0`. This is not screen SVG.
- At `em=1000`: ascender 800, descender 200, cap height ~700, x-height ~500.
- **Outer contours run CLOCKWISE**, counters run counter-clockwise
  (PostScript/cubic convention). Starting from the bottom-left corner, clockwise
  means **going up first**. `ff_clean_glyph` repairs the direction if you get it
  backwards.
- Round letters (`O`, `o`, `e`, `s`) need **overshoot** — about 1.5% of the em
  past the baseline and cap height, or they read smaller than the flat letters
  beside them.

`ff_draw_glyph` accepts full SVG path syntax (`M L H V C S Q T A Z` plus the
relative lowercase forms) but interprets the numbers as font units.

---

## Workflows

**A typeface from scratch**

```
ff_new_font → ff_draw_glyph (per glyph) → ff_preview mode=outline
→ ff_clean_glyph → ff_set_metrics / ff_kern → ff_preview mode=text
→ ff_generate
```

Draw `H O n o` first. Those four fix cap height, x-height, stem weight and
overshoot; the rest of the alphabet inherits all of it.

**Derive from an existing font**

```
ff_open_font → ff_font_info (rename the family!) → ff_derive → ff_generate
```

**Icon font**

```
ff_new_font → ff_import_svg_folder (maps from U+E000)
→ ff_clean_glyph → ff_generate .woff2
```

Keep the name→codepoint map the tool returns — that is what your CSS uses.

**Variable font**

```
ff_open_font(copy=true)  — once per master
→ a different ff_derive on each handle → ff_generate one TTF per master
→ ff_build_variable_font
```

**`copy=true` is not optional here.** FontForge returns the *same* font object
for a file that is already open, so without it every handle points at one font:
the derives stack on each other and all your masters come out identical — with
no error to tell you.

Masters must also be **interpolable**: identical point counts and contour order
in every glyph. Deriving them all from one `.sfd` preserves that; drawing each
master by hand almost never does.

---

## Round-tripping with the GUI

`ff_save` writes `.sfd`, FontForge's editable source format. Open it in the GUI,
adjust by hand, save, and reopen with `ff_open_font`. **Always keep the `.sfd`
next to the binary** — it is the font's source code; the `.ttf` is the build
output.

---

## Troubleshooting

**"Nenhum Python capaz de 'import fontforge' foi encontrado"** — FontForge isn't
installed, or its Python bindings are a separate package (`python3-fontforge` on
Debian/Fedora). Point `FFPYTHON` at the right interpreter if it lives somewhere
unusual.

**Deleting `.venv` fails with "access denied" (Windows)** — unregistering the
server does not stop it. The process started when your client launched is still
alive and holds `python.exe` and the loaded `.pyd` files open, and Windows will
not delete a binary that is mapped into a running process. Restart the client
first, or kill the tree by hand. Note there are **three** processes per server,
not one:

```
python.exe    (.venv\Scripts)   the venv launcher
 └ python.exe (base install)    what it re-execs — this runs server.py
    └ ffpython.exe              the FontForge worker
```

```powershell
Get-CimInstance Win32_Process -Filter "Name='python.exe' OR Name='ffpython.exe'" |
  Where-Object { $_.CommandLine -like "*fontforge-mcp*" } |
  ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
```

**Glyphs render as garbage after copying between fonts** — TrueType-sourced fonts
are quadratic. Copying into a cubic font through a pen drops the curve data and
flattens everything into polygons. Match `is_quadratic` on both fonts, or use
FontForge's own copy/paste, which preserves it.

**`ff_validate` reports "missing extrema" right after cleaning** — order matters.
`round()` after `addExtrema()` nudges points off the true extreme. The order that
works for TrueType is `removeOverlap → correctDirection → addExtrema → round`,
with rounding **last**.

**`correctDirection` won't fix a glyph** — run `removeOverlap` on it first.
Direction can't be resolved while contours overlap.

**Private Use Area characters come back empty from tools** — PUA codepoints don't
always survive transport as strings. Pass glyph names, or copy the glyphs into a
scratch font mapped to ASCII for rendering.

**varLib "succeeds" but a glyph doesn't vary** — its masters have different point
counts, so varLib skipped it silently. `ff_build_variable_font` detects this and
reports the affected glyphs under `glifos_que_NAO_variam`.

---

## Platform support

Developed and tested on **Windows 10/11** with FontForge `20251009`. The Linux
and macOS discovery paths are implemented but **not yet tested on real machines**
— reports and fixes welcome.

---

## License

**MIT** — see [`LICENSE`](LICENSE).

On the FontForge question, since it comes up: FontForge's own `LICENSE` states
that the program **as a whole** is GPL-3.0-or-later, while **almost every
individual part** carries the 3-clause BSD license (George Williams wrote most
of it under BSD; only some post-2012 contributions are GPLv3). That mix is why
GitHub reports FontForge as `NOASSERTION` rather than a single SPDX identifier.

This project **does not bundle or distribute FontForge** — you install it
yourself. `worker.py` is a standalone script that runs inside whichever
FontForge you already have, and it reaches the server only through a pipe:
separate processes, no linking, no shared address space. That separation is what
makes a permissive license appropriate here.

The license covers *this software*, not the fonts you build with it. Those are
yours.