Skip to main content
Glama
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