encoach
v0.7.4
Published
EnglishCoach MCP server — SQLite-backed vocab/grammar/known store with stdio + HTTP transports and a review UI
Downloads
458
Maintainers
Readme
encoach-mcp
EnglishCoach MCP server. SQLite-backed store for vocab / grammar corrections / known words, exposed to MCP clients (Claude Code, Codex, …) as tools, resources and a prompt, plus a small web review UI for marking words "known".
SQLite is the single source of truth ($ENCOACH_DIR/encoach.db, default
~/.encoach). On first run it imports any legacy english_level.md /
english_log.md / known_words.md from $ENCOACH_DIR, backing the originals
up to $ENCOACH_DIR/backups/.
Run modes
node server.js # stdio MCP — one per client session (local clients)
node server.js serve # network MCP over Streamable HTTP at /mcp + UI (k8s)
node server.js http # review UI only (localhost:4487)
node server.js inject # print profile + known words (SessionStart hook)serve is stateless: a fresh MCP server+transport per POST /mcp, so it scales
horizontally behind a Service. Routes: POST /mcp, GET /healthz, GET /
(review UI), GET /word?term= (word detail page), GET /api/state,
GET /api/word?term=, POST /api/mark-known, POST /api/unmark-known.
Env: ENCOACH_DIR (data dir), ENCOACH_DB (db path override),
ENCOACH_DICT (ECDICT path, default $ENCOACH_DIR/stardict.db), PORT, HOST.
Client adapters (encoachctl)
The coaching behavior is one platform-neutral policy
(policy/englishcoach-policy.md); each client
gets a generated adapter so wording never drifts between Claude and Codex.
bin/encoachctl renders that policy into per-client managed blocks —
delimited by <!-- ENCOACH:BEGIN managed --> / END markers — and rewrites
only what's between the markers, so hand-written notes in the same file survive.
encoachctl status # enabled / token / remote reachable / adapter drift
encoachctl update [--claude] [--codex] # dry-run: show what would change
encoachctl update --write # apply (timestamped backups in $ENCOACH_DIR/backups)
encoachctl doctor # verify /healthz, /inject, MCP initialize+tools/list, drift, hooksTargets:
- Claude —
~/.claude/output-styles/EnglishCoach.md; SessionStart and UserPromptSubmit hook scripts under~/.claude/hooks/(registered insettings.json); and the generated skills~/.claude/skills/{english-log,english-eval}/SKILL.md. - Codex —
~/.codex/AGENTS.mdplus the SessionStart / UserPromptSubmit hook scripts under~/.codex/hooks/(registered inhooks.json).
Skills are generated from templates/skills/ so they stay in
sync with the policy instead of drifting as hand-authored files — english-log
logs via the encoach_log MCP tool, and english-eval runs the heavy re-assessment
in an isolated subagent through encoach_eval_data / encoach_set_profile.
Neither reads legacy local markdown. (Skills are a Claude Code mechanism; Codex
steers via AGENTS.md.)
The SessionStart hook fails loudly: if the token is missing or /inject is
unreachable, it prints a visible warning instead of a silent no-op, so a degraded
coaching layer is surfaced rather than swallowed.
Dry-run is the default — nothing touches your home config until --write.
Drift is detected by comparing actual block content (not just a stored hash), so
in-block hand-edits are caught and repaired by update --write.
Path overrides (for testing against a sandbox instead of real home config):
ENCOACH_DIR, ENCOACH_CLAUDE_OUTPUT_STYLE, ENCOACH_CLAUDE_SETTINGS,
ENCOACH_CLAUDE_HOOKS, ENCOACH_CLAUDE_SKILLS, ENCOACH_CODEX_AGENTS,
ENCOACH_CODEX_HOOKS, ENCOACH_URL.
Multi-user / auth (Keycloak OIDC)
Auth is off by default — without OIDC_ISSUER the server is single-user and
every request is the owner (user_id=1). Set OIDC_ISSUER to turn it on:
| env | meaning |
|-----|---------|
| OIDC_ISSUER | Keycloak realm issuer, e.g. https://keycloak.example.com/realms/master (enables auth) |
| OIDC_CLIENT_ID | OAuth client id (default encoach) |
| OIDC_CLIENT_SECRET | confidential client secret (k8s secret encoach-oidc/client-secret) |
| BASE_URL | public base, for redirect URI + discovery (default https://encoach.example.com) |
| ENCOACH_AUDIENCE | expected aud on access tokens (default = client id) |
| SESSION_SECRET | optional; auto-generated and persisted in the DB if unset |
Data is partitioned per user (vocab/grammar/known/profile); the ECDICT
dictionary stays shared. The first Keycloak login claims the owner row, so
all pre-auth data becomes that user's.
Browser uses Authorization Code + PKCE → signed session cookie. MCP
clients and the inject hook present a bearer: either a Keycloak JWT (validated
against JWKS) or a personal access token minted at /account. Mark-known stays
a human UI action. GET /.well-known/oauth-protected-resource advertises
Keycloak for OAuth-capable clients (RFC 9728).
Word detail + dictionary
Clicking a candidate term in the review UI opens a dedicated page
(/word?term=) that combines two sources:
- agent-supplied context — the
zhgloss + example sentence logged viaencoach_logduring a conversation; - extended definition — looked up on demand from ECDICT, a local
read-only English→Chinese SQLite dictionary (
stardicttable: translation, phonetic, definition, POS, exam tags, word forms). Cached intovocab.detailon first lookup.
ECDICT is not in the image or git (206MB). Load it onto the PVC once with
scripts/load-ecdict.sh. If absent, the word page
degrades gracefully (shows context + external dictionary links only).
MCP surface
- tools:
encoach_log({kind:"grammar"|"vocab", ...}),encoach_eval_data(),encoach_set_profile({text}) - resources:
encoach://profile,encoach://known,encoach://log - prompt:
encoach(behavior kernel)
Marking a word "known" is a human action in the review UI, not a model tool.
Local client wiring
# Claude Code (stdio)
claude mcp add encoach -s user -- node /path/to/encoach/server.js# Codex ~/.codex/config.toml
[mcp_servers.encoach]
command = "node"
args = ["/path/to/encoach/server.js"]SessionStart hook (both clients): sh /path/to/encoach/inject.sh.
For a remote (k8s) deployment, point HTTP-capable clients at
http://<host>/mcp instead.
Container
docker build -t <registry>/encoach-mcp:0.1.0 .
docker push <registry>/encoach-mcp:0.1.0Kubernetes
Manifests in k8s/ (namespace, PVC, Deployment, Service, Ingress) wired
with kustomize. Single replica (SQLite single-writer on an RWO PVC), Recreate
strategy, /healthz probes, non-root.
kubectl apply -k k8s
kubectl -n encoach rollout status deploy/encoach-mcpImage: registry.example.com/encoach/encoach-mcp. Ingress: encoach.example.com
(nginx + letsencrypt-prod TLS).
CI/CD (Gitea Actions)
.gitea/workflows/release.yaml triggers on tag v*: build amd64 image → push to
Harbor tagged with the git tag → kubectl set image + rollout on the cluster.
git tag v0.2.0 && git push origin v0.2.0Requires a registered runner and three repo Actions secrets:
| secret | value |
|--------|-------|
| HARBOR_USER | Harbor robot name, e.g. robot$encoach+pusher |
| HARBOR_TOKEN | Harbor robot token |
| KUBE_CONFIG | base64 of a kubeconfig scoped to patch deploy/encoach-mcp in ns encoach |
Runner: k8s/act-runner.yaml — act_runner + privileged
DinD sidecar. Create the registration-token secret first (see the file header),
then kubectl apply -f k8s/act-runner.yaml.
