Content
# edookit-mcp
Neoficiální MCP konektor pro Edookit — umožňuje AI asistentům (Claude, ChatGPT,
Cursor, VS Code Copilot a dalším MCP-kompatibilním klientům) číst zprávy z
žákovské knížky.
> Napsáno v **Rustu**. Projekt původně vznikl jako přepis starší **Go**
> implementace, která už byla vyřazena; upřímné srovnání Go vs Rust z té doby
> je na konci tohoto souboru zachované jako historický zápis. **Neoficiální
> projekt — nemá nic společného s Edookit s.r.o.**
---
## 🇨🇿 Pro uživatele
### Co to je
`edookit-mcp` propojuje vašeho AI asistenta s vaším účtem v
[Edookitu](https://edookit.com) přes [MCP](https://modelcontextprotocol.io/). Po
napojení se můžete ptát přirozeným jazykem:
- *„Mám nějaké nové zprávy?"*
- *„Ukaž mi zprávy od ředitele za poslední týden."*
- *„Co mi přišlo s přílohou?"*
Asistent zavolá konektor, stáhne aktuální data přímo z Edookitu a odpoví českým
souvislým textem.
### K čemu to neslouží
- Nepřepisuje zprávy ani neposílá za vás (zatím jen čte).
- Nesleduje vás na pozadí — spouští se jen, když ho asistent vyvolá.
- Neukládá data do cloudu — vše zůstává na vašem počítači. (Tělo zpráv ovšem
prochází přes poskytovatele vašeho AI asistenta — viz [Bezpečnost](#bezpečnost-a-soukromí).)
### Co je potřeba
1. **macOS / Linux / Windows** s nainstalovaným Chromem (Chromium, Brave, Edge — cokoli na Chromium jádře).
2. **Účet v Edookitu** přihlašovaný přes Plus4U.
3. **AI klient s podporou MCP** (Claude Desktop/Code, ChatGPT, Cursor, VS Code Copilot, Zed, Continue.dev…).
4. **Rust 1.88+ (edition 2024)** pro sestavení ze zdrojáků.
### Instalace
**Homebrew (macOS / Linux) — nejjednodušší:**
```bash
brew install dsaiko/tap/edookit-mcp
```
Nainstaluje se příkaz `edookit-mcp` (i s přibalenou knihovnou PDFium). Tap
[`dsaiko/homebrew-tap`](https://github.com/dsaiko/homebrew-tap) hostí i Go verzi
jako `edookit-mcp` — obě mohou být nainstalované zároveň.
**Ze zdrojáků:**
```bash
git clone git@github.com:dsaiko/edookit-mcp.git
cd edookit-mcp
make build # stáhne přibalený PDFium a sestaví bin do target/release/
```
`make build` je jazykově neutrální — nemusíte řešit `cargo`. Binárku najdete v
`target/release/edookit-mcp`. (Předkompilované binárky pro každý release jsou na
[GitHub Releases](https://github.com/dsaiko/edookit-mcp/releases) — archiv
obsahuje binárku i přibalenou knihovnu PDFium.)
> **PDFium:** rasterizace PDF příloh používá nativní knihovnu PDFium, kterou
> `make build` stáhne do `third_party/pdfium/`. Při běhu z repozitáře se najde
> automaticky; přesunutou/nainstalovanou binárku nasměrujte přes
> `EDOOKIT_PDFIUM_LIB` nebo položte `libpdfium.*` vedle ní.
### Konfigurace
```bash
cp .env.example .env
chmod 600 .env # ať heslo není čitelné pro ostatní uživatele systému
$EDITOR .env
```
```env
EDOOKIT_URL=https://your-school-login.edookit.net
EDOOKIT_USER=vase.jmeno@example.cz
EDOOKIT_PASS=vase-heslo
```
URL je specifická pro vaši školu (najdete ji v adresním řádku po přihlášení).
### První spuštění (ověření)
```bash
make smoke-login # ověří přihlášení (Chrome se na pár vteřin otevře)
make test-messages # vytiskne pár posledních zpráv ze schránky
```
Cookies se uloží do uživatelské cache (na macOS
`~/Library/Caches/edookit-mcp/cookies.json`) a další spuštění už Chrome
neotevírá (~10 h). Vynucené nové přihlášení: `make clear-cookies`. Debug s
viditelným prohlížečem: `EDOOKIT_HEADLESS_LOGIN=false`.
### Připojení k AI asistentovi
`edookit-mcp` je standardní MCP server přes stdio. Společná JSON konfigurace
(Anthropic / Claude formát, který přejala většina ekosystému):
```json
{
"mcpServers": {
"edookit": {
"command": "/absolutní/cesta/k/edookit-mcp/target/release/edookit-mcp",
"env": {
"EDOOKIT_URL": "https://your-school-login.edookit.net",
"EDOOKIT_USER": "vase.jmeno@example.cz",
"EDOOKIT_PASS": "vase-heslo"
}
}
}
}
```
Při instalaci přes Homebrew je `command` jen `"edookit-mcp"` (je na PATH);
absolutní cesta k `target/release/edookit-mcp` platí pro build ze zdrojáků.
Liší se hlavně **kam ji vložit**: Claude Code `~/.claude.json`; Claude Desktop
`~/Library/Application Support/Claude/claude_desktop_config.json`; Cursor
`~/.cursor/mcp.json`; VS Code Copilot `<workspace>/.vscode/mcp.json` (jiný shape
— klíč `servers`). Po každé změně klienta restartujte.
> **Tip: heslo mimo config (Keychain).** Wrapper skript načte heslo z OS secret
> store až při startu — viz [`scripts/edookit-mcp-wrapper.sh.example`](scripts/edookit-mcp-wrapper.sh.example).
> V `command` ukážete na skript a `env` blok vynecháte.
### Vzdálené nasazení (Streamable HTTP)
Pro běžné lokální použití zůstává **stdio** výchozí. Pro vystavení jako
**vzdálený konektor přes HTTP** spusťte s `--http <addr>`. `/mcp` je chráněné
**jedním statickým bearer tokenem** (`EDOOKIT_API_TOKEN`, constant-time
porovnání) — žádný vestavěný OAuth/identity layer; autentizaci/identitu, pokud
ji potřebuješ, řeš vlastní vrstvou (reverse proxy, VPN). Vždy běž za TLS
terminátorem a bind drž na loopbacku:
```bash
EDOOKIT_API_TOKEN="$(openssl rand -base64 24)" \
edookit-mcp --http 127.0.0.1:9000
# klient pak posílá: Authorization: Bearer <ten token>
```
HTTP transport se **odmítne nastartovat bez `EDOOKIT_API_TOKEN`** (≥ 16 znaků) —
`/mcp` se tak nikdy nevystaví úplně bez ochrany. Bind mimo loopback vyžaduje
`EDOOKIT_BIND_NON_LOOPBACK=true`; za Host-preserving proxy přidej veřejné jméno
do `EDOOKIT_ALLOWED_HOST` (rmcp DNS-rebinding allowlist, jinak loopback-only).
`SIGINT`/`SIGTERM` ukončí server čistě. Pro server install jsou DEB/RPM balíčky
se systemd unitou (viz [Distribution](#distribution-and-packaging)).
### Co umí (dostupné nástroje)
| Nástroj | Co dělá |
|---|---|
| `edookit_list_inbox` | Vypíše **Přijaté** (volitelně Nepřečtené/S hvězdičkou/Archiv/Vše). Fulltext + filtr podle data. |
| `edookit_list_sent` | Vypíše **Vytvořené** (odeslané). Stejné filtry. |
| `edookit_get_message` | Plný text jedné zprávy podle ID — subject, status, autor, datum, body_text, body_html, přílohy, doručenky. **Vedlejší efekt:** stažení zprávy ji v Edookitu **označí jako přečtenou** (jako otevření v UI) — slouží i jako „označit jako přečtené". |
| `edookit_download_attachments` | Stáhne všechny přílohy do lokálního adresáře. Default `<temp>/edookit-mcp/m-<id>/`. |
| `edookit_view_attachment` | Zobrazí přílohu inline — obrázky, **PDF vyrenderuje na obrázky stránek** + extrahovaný text, text/CSV jako obsah. |
| `edookit_list_courses` | Kurzy přihlášeného učitele (Hodnocení → Známkování v tabulce), volitelně se žáky. |
| `edookit_server_info` | Build metadata běžícího serveru (`{version, commit, build_time}`). |
Nástroje nevoláte přímo — píšete asistentovi přirozeně a on rozhodne, kdy je
použít. Když máte připojený i Gmail/Slack MCP, pomáhá v promptu zmínit
**„v Edookitu" / „ze školy"**.
### Bezpečnost a soukromí
- **Heslo** v `.env` (nebo bezpečněji v OS secret store přes wrapper). Doporučená
oprávnění `0600` (`chmod 600 .env`).
- **Cookies** v uživatelské cache s oprávněními `0600` (off-Windows).
- **Žádné externí servery** ze strany konektoru — komunikuje jen mezi vaším
počítačem, Edookitem a Plus4U. **Ale** AI asistent posílá výstup nástrojů (těla
zpráv, jména třetích stran) na servery svého poskytovatele. Pro citlivá data
(jména dětí, zdravotní/studijní detaily) použijte **no-train placený tarif s
DPA** nebo **lokální LLM** (Continue.dev/Goose + Ollama). Na free tarifech
zapněte opt-out z trénování. *(Nejde o právní radu — pro školní/firemní kontext
se zeptejte DPO/IT.)*
### Časté problémy
- **Chrome se otevře, ale zůstane na úvodní stránce** → změnil se HTML layout
Edookitu; otevřete issue se snímkem.
- **„login failed … interaction_required"** → přihlaste se ručně do
uuidentity.plus4u.net, pak zkuste znovu.
- **opakovaně „session expired"** → `make clear-cookies`.
- **PDF příloha se nezobrazí jako obrázek** → nenašla se knihovna PDFium;
nastavte `EDOOKIT_PDFIUM_LIB` nebo spusťte `make build` (text se extrahuje i tak).
- **občasná „network error" / 502/503/504** → tyhle blipy konektor 2× opakuje s
backoffem; když chybu vidíte i tak, selhaly všechny tři pokusy.
---
## 🇬🇧 Technical reference
### Architecture
```
┌────────────────────────┐
│ Plus4U OIDC provider │
│ uuidentity.plus4u.net │
└──────────┬─────────────┘
│ (auth code flow)
┌──────────────┐ stdio / HTTP ┌─────────────┐ │
│ AI assistant │ ◄──────────► │ edookit-mcp │ ◄┴── chromium (chromiumoxide)
│ (Claude / …) │ (+ bearer) │ (rmcp) │ only for login
└──────────────┘ └──────┬──────┘
│ reqwest + swappable cookie jar
▼
┌────────────────────┐
│ Edookit backend │
│ *.edookit.net │
└────────────────────┘
```
Runs as a stdio MCP subprocess (default) or a Streamable HTTP server (`--http`).
On the first tool call it warms the session with `GET /` (which resurrects a PHP
session from the persistent `X-EdooAuthToken` / `X-Auth-Id` cookies), then issues
authenticated calls to the SPA's internal JSON API. If cookies are missing/stale,
chromium is driven through the full Plus4U OIDC flow and the resulting cookies are
cached (~10 h).
### Why a real browser for login
Edookit federates to Plus4U OIDC — a uu5loader-driven SPA with reCAPTCHA. The
token endpoint needs `client_secret_basic` (secret lives in Edookit's PHP
backend), so ROPC is closed. Cheapest reliable answer: drive chromium once per
~10 h via [chromiumoxide](https://github.com/mattsse/chromiumoxide), then hand the
session cookie to reqwest. A `Fetch`-domain interceptor strips the lib's hardcoded
`prompt=none` from the outgoing auth request **only** when its `client_id` matches
the per-tenant one captured from the landing page (the IdM SPA's nested silent
renewal, a different `client_id`, is left alone).
### Cookie persistence + warmup, transient retries
Edookit rotates `PHPSESSID` on every response; the persistent tokens are
`X-EdooAuthToken` / `X-Auth-Id`. `ensure_logged_in` always does a warmup `GET /`
before declaring success. All reads funnel through one helper that retries net
errors and HTTP 408/502/503/504 with exponential backoff (default 500 ms → 1 s);
HTTP 500/501/505+ and all 4xx propagate immediately (deterministic, not masked).
The cookie jar is an `ArcSwap<Mutex<…>>` so session invalidation swaps the whole
jar atomically (clearing path-scoped cookies a name-based clear would miss).
### Data flow
- **Lists** (`list_inbox`/`list_sent`): `/handler/grid/objects-for-me-data` /
`…/created-objects-data`, 100 rows/page; each row is `[uid, uid, html]` parsed
with `scraper`. Returns `messages` + optional `parse_warnings` (rows the server
returned that we couldn't parse). Rows-fetched-but-none-parsed → error, not a
silent empty mailbox.
- **Message** (`get_message`): `/handler/page/message-edit?__index=N` — one
workspace JSON carrying the form, the attachment list, and the acceptance
(read-receipt) grid. Interpreted via `serde_json::Value` (the `data` field is an
object for the form/fileviewer panels but a bare array for the grid).
### Project layout
| Path | Purpose |
|---|---|
| `src/main.rs` | flag parsing (clap), env wiring, transport selection, dev runners |
| `src/server.rs` | rmcp tool registration (the 7 tools) + MCP `ServerHandler` |
| `src/http.rs` | Streamable HTTP transport + axum integration + static bearer-token gate + `guard_bind_address` |
| `src/client/` | reqwest session client, swappable cookie jar, retry/origin, cookie cache, chromiumoxide login |
| `src/tools/` | one module per tool + HTML/date utils + untrusted-data envelope + PDFium worker + experimental MCP Apps inbox UI (`ui.rs`) |
| `packaging/` | systemd unit + env template + Debian maintainer scripts |
| `.github/workflows/` | `ci.yml` (fmt/clippy/test/audit) + `release.yml` (cross-platform + deb/rpm) |
### Dependencies
[`rmcp`](https://docs.rs/rmcp) (MCP runtime), [`chromiumoxide`](https://docs.rs/chromiumoxide)
(CDP login), [`reqwest`](https://docs.rs/reqwest) (rustls TLS) +
[`cookie_store`](https://docs.rs/cookie_store), [`scraper`](https://docs.rs/scraper)
(html5ever), [`pdfium-render`](https://docs.rs/pdfium-render) + bundled PDFium,
[`pdf-extract`](https://docs.rs/pdf-extract), [`image`](https://docs.rs/image),
[`jiff`](https://docs.rs/jiff) (bundled tzdata), [`axum`](https://docs.rs/axum) +
`tower` (HTTP transport, with a `subtle` constant-time bearer-token check),
[`tokio`](https://docs.rs/tokio).
### Development
```bash
make build # fetch PDFium + build
make test # 92 tests, race-free
make check # fmt + clippy-fix + test (mutates)
make pre-push # fmt-check + clippy -D + test + audit + build (the gate)
make tools # install cargo-audit (once)
make smoke-login # one-shot OIDC login against EDOOKIT_URL/USER/PASS
make smoke-message MSG=m-NNNNNN # (dev) dump raw message-edit JSON
```
### Testing
White-box `#[cfg(test)]` modules per file (mirroring Go's in-package tests):
HTML/date parsers against captured samples, an `httptest`-equivalent via
[`wiremock`](https://docs.rs/wiremock) for the client + download flows, the
HTTP transport's bearer-token gate via `tower::oneshot`, and the MCP server
handler (tool args, MCP Apps wiring). The PDFium render path is exercised against
a synthetic PDF. The chromiumoxide login is not unit-tested (same as the Go
original) — run `make smoke-login` against a live account.
### Security notes (accepted residual risks)
The threat model assumes the **operator trusts whoever drives the MCP client** —
this is a personal connector to your own school account, not a multi-tenant
service. A few design choices carry residual risk that is accepted deliberately
and documented here rather than engineered away:
- **Attachment download path is not sandboxed.** `edookit_download_attachments`
writes to the caller-supplied `destination_dir` (with `~` expansion), faithful
to the Go tool — there is no base directory confining where files land.
Mitigations in place: each attachment filename is reduced to a single path
component with `..`/traversal, absolute paths, and Windows volume-roots and
reserved names rejected (so a hostile *filename* from Edookit can't escape the
chosen dir), downloads stream to a `0600` temp file and commit atomically
(no-clobber by default), each file is capped at 512 MiB, and all tool arguments
arrive wrapped in the untrusted-data envelope so the model treats them as data.
Residual risk: a caller can still choose *any* writable directory as the
destination. Confine it with OS permissions (the systemd unit runs as an
unprivileged `edookit-mcp` user) if that matters for your deployment.
- **PDFium runs in-process (no WASM sandbox).** PDF rasterization links the
native PDFium dylib into the process, unlike the Go build's WASM (wazero)
sandbox. A memory-safety bug in PDFium parsing a malicious PDF would therefore
execute in the server's address space rather than a sandbox. Mitigations: the
library is pinned + SHA-256-verified at fetch, input is your own school
attachments (not arbitrary internet PDFs), and rendering is bounded (page count
+ pixel dimensions) on a dedicated blocking thread under a mutex. The
WASM-sandbox property is the one place Go's stack is genuinely safer; see the
comparison section. Accepted for the native-render performance and simplicity.
- **The HTTP transport has no built-in identity — only a static bearer token.**
`--http` gates `/mcp` on a single shared secret (`EDOOKIT_API_TOKEN`,
constant-time compared; the transport refuses to start without it). There is no
per-user auth, no OAuth/DCR — an earlier built-in OAuth 2.1 AS was removed once
the public deployment was retired. So anyone holding the token has full access:
run behind a TLS terminator, keep the bind on loopback (`guard_bind_address`
refuses non-loopback without `EDOOKIT_BIND_NON_LOOPBACK=true`), and front it
with your own auth/VPN if you expose it. stdio (the default) has no network
surface at all.
### Experimental: MCP Apps inbox UI
Every tool otherwise returns only the two universally-supported MCP content
types — `text` (JSON wrapped in the untrusted envelope) and `image` (inline
attachment view). As an experiment, the server also implements the **MCP Apps**
extension ([SEP-1865](https://modelcontextprotocol.io/community/seps/1865-mcp-apps-interactive-user-interfaces-for-mcp),
the standardized successor to the community MCP-UI) for the inbox
([`src/tools/ui.rs`](src/tools/ui.rs)). It is **on by default**; set
**`EDOOKIT_UI_RESOURCES=false`** to suppress it.
Concretely, when enabled:
- The server negotiates the **`io.modelcontextprotocol/ui`** extension capability
and declares a predeclared, static UI template resource at `ui://edookit/inbox`
with mimeType **`text/html;profile=mcp-app`**, served via `resources/read`.
- `edookit_list_inbox` links to that template via **`_meta.ui.resourceUri`** on
its tool definition and delivers the rows as the result's **`structuredContent`**.
- The host renders the template in a sandboxed iframe; template and host speak the
**MCP JSON-RPC base protocol over `postMessage`** — the view sends
`ui/initialize`, the host pushes data via `ui/notifications/tool-result`, and a
row click issues a `tools/call` for `edookit_get_message` (**click → detail**).
Design constraints, all deliberate:
- **Negotiated, not just enabled.** Per SEP-1865 the extension is optional and
must be negotiated, so `EDOOKIT_UI_RESOURCES` is only the operator *master
switch*. The server acts on the UI surface — attaching `_meta.ui.resourceUri`,
serving the `ui://` resource, emitting `structuredContent` — **only for a peer
that declared `capabilities.extensions["io.modelcontextprotocol/ui"]` with
`text/html;profile=mcp-app`**. Any other client gets byte-identical plain text.
- **Untrusted-data boundary preserved.** Because `structuredContent` carries the
same third-party rows *outside* the `BEGIN/END_UNTRUSTED_EDOOKIT_DATA` fence,
it is withheld from non-negotiating peers — so a teacher-controlled subject /
preview can't reach a non-UI model as an unfenced prompt-injection payload. A
peer that opts into the UI extension receives it and renders it in a sandboxed
iframe; the enveloped `text` block remains the model-facing source of truth.
- **Predeclared static template.** Per SEP-1865 the HTML is a fixed resource the
host can review before rendering — no per-call HTML — and the data arrives
separately as `structuredContent`.
- **XSS-safe by construction.** Because data is injected client-side at runtime,
the template renders rows with DOM APIs (`textContent`/`createElement`),
**never `innerHTML`** — a hostile subject/sender cannot become markup. A row
click only passes back an opaque message `id`; the message **body** is never
put in the DOM (the detail comes back through `edookit_get_message`'s normal
untrusted-text path). See the XSS / protocol unit tests in `ui.rs`.
- **Known caveat.** Some hosts only render MCP Apps in first-party (`1p`)
inference mode — a gateway / Bedrock / Vertex / Foundry (`3p`) deployment may
fall back to text regardless, even after negotiating the extension.
- `--preview-ui` renders the template plus a built-in mock host (handshake +
sample `tool-result`) so you can eyeball the rendered list in a plain browser.
### Distribution and packaging
`release.yml` builds for darwin/linux/windows × amd64/arm64 on a `v*` tag
(tar.gz/zip per platform), and on Linux produces DEB + RPM (via `cargo-deb` /
`cargo-generate-rpm` — metadata in `Cargo.toml`) bundling the binary, the
matching PDFium library, the systemd unit, and the env conffile, declaring
`chromium` as a dependency. Both packages carry maintainer scriptlets that
create the `edookit-mcp` system user and reload systemd (the DEB via
`packaging/deb/*`, the RPM via `generate-rpm` scriptlets in `Cargo.toml`). It
also emits a `checksums.txt` and — when the `HOMEBREW_TAP_GITHUB_TOKEN` secret is
set — pushes a Homebrew formula to `dsaiko/homebrew-tap` (skipped otherwise,
exactly like GoReleaser's `--skip=homebrew`). The bundled PDFium is pinned to a
specific `bblanchon/pdfium-binaries` release and verified against a checked-in
SHA-256 before use (`scripts/fetch-pdfium.sh`), so a tampered binary fails the
build instead of being loaded. *(The packaging workflow is provided for
GoReleaser parity but hasn't been executed against a real tag yet — the first tag
is its shakedown.)*
### License
[MIT](LICENSE) © 2026 Dušan Saiko
---
## Go vs Rust — an honest comparison
*Written from the actual port (the whole app: client, chromedp login, 6 tools,
stdio + HTTP transports, and — until the public deployment was retired — a
built-in OAuth 2.1 AS). The author wrote both; this is engineering observation,
not advocacy.*
**Framing caveat:** the Go original is a mature, much-reviewed codebase. Porting
it faithfully means the Rust inherits its design and defensive edge-case
handling. So this is largely *"the same careful design, in two languages"* — the
interesting differences are in what each language made **easy, safe, or
annoying**, not in the architecture.
### Measured (Apple Silicon, release builds)
| Metric | Go | Rust | Notes |
|---|---|---|---|
| Binary size (stripped) | **33.8 MB**, single static file | 25.7 MB binary **+ 6.8 MB PDFium dylib** (≈ 32 MB, two files) | Comparable total; the Rust binary alone is smaller because Go embeds PDFium-as-WASM + the Go runtime, whereas Rust keeps PDFium as a separate native dylib (the chosen tradeoff) |
| Cold build | **5.9 s** | ~104 s | Rust's big async dep tree (chromiumoxide, axum, rmcp, reqwest, image) + monomorphization |
| Warm/incremental build | 0.15 s | a few s | |
| Startup (`--version`, mean of 30) | 6.6 ms | **3.8 ms** | Rust has no runtime/GC init to amortize |
| Resident memory (idle HTTP server) | ~25.1 MiB | **~13.5 MiB** | Measured on the production gateway (Rocky Linux 10.2, aarch64): Go 0.1.13 `VmRSS` 25,712 kB / 9 threads / 1.28 GB virtual, vs Rust 0.1.1 `VmRSS` 13,796 kB (14,672 kB after a request) / 3 threads / 163 MB virtual — Rust ~46 % leaner resident, and a fraction of the virtual reservation |
| Prod LOC (excl. tests) | ~6,600 | ~5,100 | Rust a touch tighter |
| Tests | somewhat larger by LOC | **92** | Rust covers the parsers, client (retry/origin/fast-path/concurrency), the HTTP bearer-token gate, the MCP handler (tool args + MCP Apps wiring), and grid/download integration; Go's suite is still a bit larger |
Memory was measured on the actual deployment (the `gateway-ssst` public MCP
endpoint) — the long-running Go service idled at ~25 MiB resident (its lifetime
peak, `VmHWM` == `VmRSS`), while the Rust build serves the same endpoint at
~13–14 MiB with a third of the threads. Throughput under load wasn't
micro-benchmarked: this is a single-user, I/O-bound tool (every call waits on
Edookit), so it wouldn't be a meaningful differentiator. The resident-memory
gap (no GC, rustls, a slimmer thread pool) is the one runtime axis where the
difference is real and measurable.
### Where Rust helped (stability / bug-proofness)
- **The compiler caught real mistakes** during the port that Go would surface
only at runtime (if at all): an `i64`/`u64` byte-count mismatch, a
non-exhaustive `match` on download-classification states, several
"did you handle the missing field?" spots where `Option` forced the decision,
and `#[non_exhaustive]` structs that refused silent construction.
- **Errors are values you can't forget.** `Result` + `?` makes the
re-login/retry flows explicit — no `if err != nil` to omit, no
use-after-error.
- **The hairiest logic ported with structural guarantees.** The trickiest
subsystem the port carried — a built-in OAuth refresh-rotation +
replay-detection state machine (since removed with the public deployment) —
ran under one `parking_lot::Mutex`; the type system made "held a lock across
`.await`" impossible by construction, and exhaustive enums modelled the grant
outcomes. The same guarantees still cover the live concurrency (the PDFium
worker mutex, the swappable cookie jar).
- **Concurrency is checked, not hoped.** The swappable cookie jar
(`ArcSwap<Mutex<…>>`) and the shared `Arc<Client>` are `Send + Sync` by proof;
`cargo test` needs no `-race` flag because the races can't compile.
### Where Rust was more friction (Go won)
- **Compile times.** 104 s cold is felt on every dependency change; Go's ~6 s is
a materially nicer inner loop.
- **Ecosystem archaeology.** Aligning `cookie_store`/`ego-tree` versions and
learning the exact APIs of rmcp, axum 0.8, chromiumoxide, and pdfium-render
each cost real time. Go's std-library-centric surface needed almost none.
- **axum 0.8 sharp edges.** `Option<ConnectInfo>` isn't a valid extractor (needs
a custom one); `ServerInfo`/`Implementation` are `#[non_exhaustive]`.
- **chromiumoxide vs chromedp.** Driving the login surfaced a real behavioural
gap: `page.evaluate("idmLoginClick()")` *errors* because the click starts a
navigation that tears down the JS context — chromedp's `Evaluate` returns
before that happens, so you tolerate the eval error instead. Chasing that also
exposed a latent flaw the Go original *shares*: its rigid "wait-for-Plus4U →
fill form → wait-for-Edookit" sequence **hangs when Plus4U has an active
session and silent-SSO's straight back** (the form never shows, and the fast
bounce slips past the host poll). The Rust port instead waits on the real
success signal — the `X-EdooAuthToken` cookie the OIDC callback sets — and
fills the form only if it appears, so both the form and silent-SSO paths work.
A case where porting *improved* on the reference.
- **One place Go's design was simply better.** Go's hand-rolled cookie jar
snapshots the jar *per request attempt* to fence a set-cookie-during-reset
race. reqwest's `cookie_provider` can't bind the pre-send and post-response
cookie calls to one generation, so that narrow fence is a **documented
simplification** here (the important behavior — atomic invalidation — is kept).
- **PDFium.** Go's `go-pdfium` ships PDFium as WASM (wazero) → a no-cgo single
binary for free, *and* runs the C++ parser inside a memory-safe sandbox. Rust
has no turnkey equivalent, so the port links a native dylib (fetched by
`make build`, pinned + checksum-verified, shipped alongside in packaging) that
runs in-process. That costs the second binary *and* the sandbox: a PDFium
memory-safety bug is in the process here, not contained — the one place Go's
stack is genuinely safer (see *Security notes* above for the mitigations and
why it's accepted).
### Roughly a wash
- **HTML scraping** (`scraper` vs goquery), **JSON** (serde `Value` vs
`map[string]any`), and **MCP plumbing** (rmcp `#[tool]` macros vs mcp-go
`AddTool`) — different ergonomics, similar effort.
### Bottom line
For *this* workload the two are closer than the language wars suggest, because the
design was fixed in advance. Rust's dividend is **compile-time correctness
guarantees** (several latent bugs caught for free) and a slightly faster/leaner
runtime; the price is **build times and ecosystem friction**. Go's dividend is a
**dramatically tighter build loop** and a **simpler single-artifact story** (the
PDFium-as-WASM trick especially); the price is that a class of bugs the Rust
compiler rejected would only show up in tests or production. Neither is "better"
here — they optimize for different things, and this port made the trade-offs
concrete rather than theoretical.
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
everything-claude-code
Complete Claude Code configuration collection - agents, skills, hooks,...
markitdown
Python tool for converting files and office documents to Markdown.
awesome-claude-skills
A curated list of awesome Claude Skills, resources, and tools for...
antigravity-awesome-skills
The Ultimate Collection of 130+ Agentic Skills for Claude...
claude-context-mode
claude-context-mode plugin reduces MCP context bloat, saving up to 99% of tokens.
context-mode
MCP is the protocol for tool access. We're the virtualization layer for context.