habitica-mcp
by sharkusmanch
README.md
# habitica-mcp
An MCP server for a **self-hosted** [Habitica](https://github.com/HabitRPG/habitica)
instance, served over **Streamable HTTP** so it can run as a normal networked service
rather than a per-client stdio subprocess.
## Why this exists
The existing community server ([iBreaker/habitica-mcp-server][ibreaker]) hardcodes
`https://habitica.com/api/v3`, is stdio-only, and has been unmaintained since three days
after it was created. None of that works for a self-hosted instance behind an ingress.
Here, `HABITICA_BASE_URL` is **required with no default** — pointing at the wrong
instance is made impossible rather than merely discouraged.
[ibreaker]: https://github.com/iBreaker/habitica-mcp-server
## Tools
| Tool | Notes |
| ------------------------------------------ | ----------------------------------------------------------- |
| `list_tasks` | Optional type filter; history excluded (see below) |
| `get_task` | |
| `create_task` | Not idempotent — Habitica has no idempotency key |
| `update_task` | Partial update |
| `delete_task` | Destructive |
| `score_task` | **Destructive** — mutates gold/XP/streaks, cannot be undone |
| `list_tags` / `create_tag` | |
| `add_tag_to_task` / `remove_tag_from_task` | Take a tag **name**, resolved to its UUID |
| `get_user_stats` | Server-side projection, not the full user document |
## Configuration
| Variable | Required | Default | Purpose |
| ----------------------- | -------- | -------------------------- | --------------------------------------------------- |
| `HABITICA_BASE_URL` | **yes** | — | e.g. `http://habitica.tools.svc.cluster.local:3000` |
| `HABITICA_USER_ID` | **yes** | — | `x-api-user` |
| `HABITICA_API_TOKEN` | **yes** | — | `x-api-key` |
| `MCP_ALLOWED_HOSTS` | no | _(empty — validation off)_ | Comma-separated Host allowlist for `/mcp` |
| `MCP_HOST` / `MCP_PORT` | no | `0.0.0.0` / `8080` | |
| `HABITICA_TIMEOUT_MS` | no | `15000` | |
| `LOG_LEVEL` | no | `info` | |
Endpoints: `POST/GET/DELETE /mcp`, and `GET /healthz`.
## Design notes
Four decisions that are load-bearing and non-obvious:
**Response projection, not pagination.** Habitica's `GET /tasks/user` returns
`history: [{date, value}]` on every habit and daily — one entry per scoring event for the
life of the account, and it is **on by default**. The API offers no limit/offset, so the
fix is projection: this server always sends `history=false` and additionally projects each
task to a fixed field set, so an upstream schema change cannot silently reintroduce
hundreds of KB into a model's context. `get_user_stats` uses `?userFields=` for the same
reason.
**The list filter is plural and irregular.** `GET /tasks/user?type=` accepts
`habits | dailys | todos | rewards | completedTodos` (note `dailys`), while the create
body takes the singular `habit | daily | todo | reward`. Tools expose the singular form
and map internally; passing the singular form to the list endpoint 400s.
**Host validation is scoped to `/mcp`, never app-wide.** `createMcpExpressApp` applies it
globally, which would break both kubelet probes (an `httpGet` probe sends
`Host: <podIP>`, and pod IPs can never be allowlisted) and blackbox monitoring (which
sends `Host: <svc>.<ns>.svc`). `/healthz` therefore sits outside the guard; it exposes
nothing, and DNS-rebinding protection only matters for the JSON-RPC surface.
**`/healthz` reports process liveness only — never Habitica reachability.** A
connectivity check would turn a Habitica restart into a CrashLoopBackOff here, and the
liveness probe would then keep killing a process that is perfectly healthy and simply has
nothing to talk to. Habitica outages surface as clean per-tool JSON-RPC errors instead.
## Transport
Stateless Streamable HTTP (`sessionIdGenerator: undefined`), built on
`@modelcontextprotocol/server` v2 — the current stable major, whose HTTP transport lives
in the separate `@modelcontextprotocol/express` / `@modelcontextprotocol/node` adapters.
The negotiated protocol version is `2025-11-25` (`LATEST_PROTOCOL_VERSION` in the SDK);
v1.x is now security-and-bugfix only.
A fresh `McpServer` + transport is created **per request**, torn down on the response's
`close` event. Per-request construction is required rather than tidy: SDK v1 throws
outright on stateless transport reuse ("Stateless transport cannot be reused across
requests"), because reuse causes message-ID collisions between concurrent clients.
The cost is real and worth knowing: each request rebuilds 11 zod→JSON-Schema conversions,
which measured at roughly 0.5 MB of garbage per call. It is reclaimed under GC pressure
(1500 sequential calls settled at ~193 MiB with a 96 MB heap cap) rather than leaking, but
it is why the deployment requests more memory than the idle footprint suggests.
`GET /mcp` returns **405** with `Allow: POST`. This is spec-legal (a server may refuse the
standalone stream) and is what the MCP client explicitly expects — it special-cases 405 as
"no server stream here" and stops.
An earlier version tried to be accommodating by returning an empty SSE stream instead.
That caused an **infinite reconnect loop**: the client treats a cleanly-ended stream that
carried no response as a dropped connection and reschedules, but its retry counter only
advances on _failure_, so a successful empty stream reset nothing. Measured at ~1 req/s
forever — 1 → 4 → 8 → 12 GETs over 12s idle, roughly 86k requests/day per connected
client, with no error surfaced anywhere. Returning 405 holds it at exactly 1.
Stateless has a real cost, not just upside: server→client round-trips (**sampling**,
**elicitation**) and unsolicited `*ListChanged` notifications cannot work, because the
client's reply arrives as a new HTTP request that lands on a fresh server instance with no
memory of the pending call. Progress notifications _do_ work — they ride the originating
request's own stream. None of that matters for a CRUD tool surface, but do not build on
those capabilities here.
[gh]: https://github.com/anthropics/claude-code/issues/39790
## Security
The `/mcp` endpoint is **unauthenticated**. The Habitica credential lives server-side, so
anyone who can reach the endpoint can read and write the account's entire task list. This
is deliberate — an auth proxy in front of an MCP endpoint breaks MCP clients — and it is
why the deployment is restricted to a private network and a single replica.
The API token is a _user-level_ Habitica credential (stored in plaintext by Habitica
itself), so leaking it is full account compromise. All log output passes through a
redacting logger, with a test asserting the token never appears in any emitted line.
## Development
```bash
npm ci
npm test
npm run lint && npm run typecheck
npm run build && node dist/index.js
```
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues