kanka-mcp
# kanka-mcp
A Model Context Protocol (MCP) server for [Kanka](https://kanka.io), the worldbuilding platform. Exposes a small set of tools that let any MCP-compatible agent (Claude Desktop, Claude Code, Cursor, custom clients) authenticate to a Kanka account and work with campaigns, entities, and search.
> **Status:** Phase 4 — feature-complete. 15 tools, full CRUD over all 18 entity types, posts and relations sub-resources, OAuth 2.0 (Authorization Code + PKCE) with transparent refresh, and a client-side full-text search. Backed by a vitest test suite (43 tests across 7 files, including msw-mocked HTTP integration tests).
## Quickstart (recommended)
One command takes you from a fresh clone to a verified setup:
```bash
cd kanka-mcp
npm run quickstart
```
The script will:
1. Verify Node ≥ 20
2. `npm install` and build TypeScript
3. Ask whether you want to authenticate via **Personal API token** or **OAuth 2.0**
4. Prompt for the relevant credentials (input is hidden) and persist them with `0600` perms — token to `~/.config/kanka-mcp/token` or OAuth client/secret to `.env` in the repo root
5. Run the end-to-end smoke test (and the OAuth browser flow if you chose option 2)
6. **Optionally build and install the `.mcpb` extension** — if Claude Desktop is detected, the script offers to build the bundle and `open` it directly so Claude Desktop's install dialog launches automatically. Because your credentials are already on disk, you can leave every field blank in the install dialog — the server resolves them from `~/.config/kanka-mcp/` at runtime.
7. Print ready-to-paste MCP-client config snippets as a manual fallback
Re-running `npm run quickstart` is safe — it'll detect existing credentials and offer to reuse them. Skip the rest of this README unless you want manual control.
---
## Prerequisites
- Node.js 20 or newer
- A Kanka account
- A Kanka **Personal API token** (free tier allows 30 req/min; subscribers 90)
### Get a Kanka API token
1. Sign in at <https://app.kanka.io>
2. Go to **Settings → API**: <https://app.kanka.io/settings/api>
3. Click *Generate a new token* and copy the value
4. **Save it immediately** — Kanka only shows the token once. If you lose it, you'll need to regenerate.
Tokens are valid for 365 days.
### Set the `KANKA_TOKEN`
The server reads the token from the `KANKA_TOKEN` environment variable. Pick whichever method fits your workflow:
**1. Inline for a single command** — quickest way to run the smoke test once:
```bash
KANKA_TOKEN="paste-your-token-here" npm run smoke
```
**2. Export for the current shell session** — persists for as long as the terminal is open:
```bash
export KANKA_TOKEN="paste-your-token-here"
npm run smoke
npm run smoke -- --mutate
```
**3. Persistent across shells** — add it to your shell rc file (zsh shown; bash users use `~/.bashrc`):
```bash
echo 'export KANKA_TOKEN="paste-your-token-here"' >> ~/.zshrc
source ~/.zshrc
```
⚠️ Avoid this on shared machines — your token is sensitive.
**4. Token file (no shell env at all)** — drop the token into a `0600` file the server reads as a fallback:
```bash
mkdir -p ~/.config/kanka-mcp
printf '%s' 'paste-your-token-here' > ~/.config/kanka-mcp/token
chmod 600 ~/.config/kanka-mcp/token
```
The server checks `KANKA_TOKEN` first, then this file.
**5. MCP client config** — once you've verified the smoke test, supply the token directly to your client (Claude Desktop / Claude Code / etc.) so you never have to touch your shell. See [Connect to an MCP client](#connect-to-an-mcp-client).
#### Verify the token is set
```bash
echo "$KANKA_TOKEN" | head -c 8 ; echo "…"
```
Should print the first 8 characters of your token. If it prints `…` only, the variable isn't set in this shell.
## Install & build
From the repo root:
```bash
cd kanka-mcp
npm install
npm run build
```
This compiles TypeScript to `dist/`.
## Run a smoke test (recommended first step)
The smoke script spawns the built server, performs the MCP handshake, and exercises the read-only path against the real Kanka API. It's the fastest way to confirm your token works end-to-end before configuring an agent.
```bash
KANKA_TOKEN="your-token-here" npm run smoke
```
It will:
1. List the tools the server exposes
2. Call `kanka_auth_status`
3. Call `kanka_list_campaigns` and print the first 5
4. Call `kanka_get_campaign` for the first one (or pass a campaign id as the second arg)
5. Call `kanka_list_entities` for `character` and print the first 5
Pass an explicit campaign id if you don't want the script to auto-pick:
```bash
KANKA_TOKEN="..." npm run smoke -- 12345
```
### `--mutate` mode (validates Phase 2 CRUD)
Add `--mutate` to also exercise the create/update/get/delete cycle. The script creates a throwaway Note named `kanka-mcp smoke test <ISO timestamp>`, updates its `entry`, fetches it back **by `entity_id`** (which exercises the dual-ID resolver), and deletes it.
```bash
KANKA_TOKEN="..." npm run smoke -- --mutate
KANKA_TOKEN="..." npm run smoke -- 12345 --mutate
```
The Note appears in your campaign briefly. If the script crashes between create and delete, you may have to remove the Note manually.
Expected output (abridged):
```
→ initialize
kanka-mcp v0.1.0
→ tools/list
15 tools registered
→ kanka_auth_status
{ authenticated: true, source: 'env' }
→ kanka_list_campaigns
2 campaign(s):
- 12345: Legends of Tolria
- ...
→ kanka_get_campaign(12345)
name: Legends of Tolria
→ kanka_list_entities(12345, character)
N character(s); first 5: ...
✓ smoke test passed
```
## Connect to an MCP client
### Claude Desktop — install the `.mcpb` extension (easiest)
Claude Desktop has a native Extensions UI. The repo ships a bundle (`.mcpb` file) you can install in two clicks — no JSON editing, no PATH wiring.
**The fastest path:** run `npm run quickstart`. After the smoke test passes, the script offers to build the bundle and (on macOS) `open` it directly — Claude Desktop's install dialog launches automatically, and because your credentials are already saved at `~/.config/kanka-mcp/`, you can leave every field in the dialog blank.
**Manual flow:**
1. Build the bundle: `npm run pack:mcpb` (produces `kanka-mcp-<version>.mcpb` in the repo root). Or download a pre-built release from <https://github.com/torinvdb/kanka-mcp/releases/latest>.
2. **Double-click the `.mcpb` file** — Claude Desktop is registered as the handler for the `Desktop Extension` UTI on macOS, so this launches the install dialog directly. (Equivalent: `open kanka-mcp-0.1.0.mcpb`.)
3. *Or* navigate manually: Claude Desktop → **Settings → Extensions → Advanced settings → Install Extension…** → select the file.
4. In the install dialog: paste your Kanka API token *or leave it blank* if you've already saved one at `~/.config/kanka-mcp/token` (the server resolves it from disk as a fallback). OAuth fields are only needed if you registered an OAuth client.
5. Click **Install** — Kanka tools appear in your next conversation.
To upgrade later, repeat with the new `.mcpb`. Claude Desktop preserves your saved configuration across reinstalls of the same extension name.
### Claude Code CLI
```bash
claude mcp add kanka-mcp --env KANKA_TIER=subscriber \
-- node /absolute/path/to/kanka-mcp/dist/index.js
```
(Token resolves automatically from `~/.config/kanka-mcp/token` if you ran `npm run quickstart`.)
### Manual JSON config (Claude Desktop power users, other MCP clients)
If you'd rather edit the config file directly — for example, if you use multiple Kanka accounts and want different config per workspace — edit Claude Desktop's `claude_desktop_config.json`:
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
```json
{
"mcpServers": {
"kanka-mcp": {
"command": "node",
"args": ["/absolute/path/to/kanka-mcp/dist/index.js"],
"env": { "KANKA_TOKEN": "your-token-here" }
}
}
}
```
The file may not exist yet on a fresh Claude Desktop install — create it with the snippet above. Restart Claude Desktop to pick up the change.
### Any other MCP client
The server speaks MCP over stdio. Any client that can launch a stdio MCP server will work — point it at `node /absolute/path/to/kanka-mcp/dist/index.js` with `KANKA_TOKEN` in the environment.
## Configuration
All configuration is via environment variables.
| Variable | Required | Default | Purpose |
|---|---|---|---|
| `KANKA_TOKEN` | one of token *or* OAuth | — | Personal API token (Bearer) |
| `KANKA_TIER` | no | auto-detected | `free` or `subscriber`. Sets the **initial** rate limit before `/profile` auto-detection kicks in. Rarely needed — the server queries `/profile` on startup and resizes the bucket to match the API-reported `rate_limit` (30 free / 90 subscriber). |
| `KANKA_RATE_LIMIT_PER_MIN` | no | auto-tuned via `/profile` | Hard override on the rate-limit bucket capacity. Setting this disables `/profile`-based auto-tuning so a deliberately conservative value won't be silently raised. |
| `KANKA_BASE_URL` | no | `https://api.kanka.io/1.0` | Override the API base (for testing) |
| `KANKA_OAUTH_BASE_URL` | no | `https://app.kanka.io` | Override the OAuth host (for testing) |
| `KANKA_TOKEN_FILE` | no | `~/.config/kanka-mcp/token` | Fallback token location if `KANKA_TOKEN` is unset |
| `KANKA_OAUTH_CLIENT_ID` | OAuth | — | OAuth app client id (register at app.kanka.io/settings/api-apps) |
| `KANKA_OAUTH_CLIENT_SECRET` | OAuth | — | OAuth app client secret |
| `KANKA_OAUTH_REDIRECT_PORT` | no | random ephemeral | Pin the loopback callback port (useful if your OAuth app's redirect URI is fixed) |
| `KANKA_OAUTH_TOKEN_FILE` | no | `~/.config/kanka-mcp/oauth.json` | Where access + refresh tokens are persisted |
| `KANKA_REQUEST_TIMEOUT_MS` | no | `30000` | Per-request HTTP timeout; aborts the fetch and returns `NETWORK_ERROR` |
| `KANKA_MAX_RESPONSE_BYTES` | no | `10485760` (10 MiB) | Hard cap on response body size; oversized responses are rejected |
| `KANKA_LOG_LEVEL` | no | `info` | `trace`, `debug`, `info`, `warn`, `error`, `fatal` |
The server logs to **stderr** (stdout is reserved for MCP frames).
### Auth resolution order
When making an API call, the server picks a token in this order:
1. Stored OAuth tokens (`~/.config/kanka-mcp/oauth.json`) — refreshed transparently on 401 or within 24h of expiry
2. `KANKA_TOKEN` environment variable
3. `~/.config/kanka-mcp/token` file
Most users only need a Personal API token. Use OAuth when you want a long-running login that can refresh itself, or when you're delegating access to a Kanka account that's not yours.
### OAuth setup
1. Register an app at <https://app.kanka.io/settings/api?clients=1>. Set the redirect URI to `http://localhost:<port>/cb` (Kanka's URL validator **rejects `127.0.0.1`** — use `localhost`). Pin a port via `KANKA_OAUTH_REDIRECT_PORT` and use the same port here.
2. After saving, Kanka issues you a **Client ID** (a numeric or UUID identifier) and a **Client Secret**. ⚠️ *Do not confuse the Client ID with the app name you typed* — they're different. Set the issued values:
```bash
export KANKA_OAUTH_CLIENT_ID="paste-issued-client-id" # number/UUID, NOT the app name
export KANKA_OAUTH_CLIENT_SECRET="paste-issued-secret"
export KANKA_OAUTH_REDIRECT_PORT=53117
```
3. From your MCP client (or via `npm run smoke -- --oauth`), call `kanka_oauth_login`. The server opens your browser to Kanka's authorize page. After approval, tokens are persisted to `KANKA_OAUTH_TOKEN_FILE` (`0600` perms) and used automatically.
4. Call `kanka_auth_logout` to clear the stored tokens.
Tokens are stored as a JSON file with restrictive permissions; we deliberately avoid native keyring dependencies for portability.
## Tools
**Auth & discovery**
| Tool | Purpose |
|---|---|
| `kanka_auth_status` | Report whether a token is configured and where it was loaded from |
| `kanka_oauth_login` | Run the OAuth Authorization Code + PKCE flow; persists tokens locally |
| `kanka_auth_logout` | Clear stored OAuth tokens |
| `kanka_describe_entity_type` | Return the JSON Schema for an entity type's create/update payload |
**Campaigns**
| Tool | Purpose |
|---|---|
| `kanka_list_campaigns` | List campaigns the authenticated user has access to |
| `kanka_get_campaign` | Fetch metadata for one campaign by id |
**Search**
| Tool | Purpose |
|---|---|
| `kanka_search` | Native Kanka name search — fast, but matches names only |
| `kanka_full_text_search` | Client-side full-text search across `entry` HTML. Paginates typed list endpoints, strips HTML, and matches locally. Costs API budget — narrow `types` and `max_pages_per_type` to keep it cheap. Supports `regex: true` and `case_sensitive: true`. |
**Entities (CRUD)**
| Tool | Purpose |
|---|---|
| `kanka_list_entities` | Paginated list of entities, optionally filtered by type and arbitrary query filters |
| `kanka_get_entity` | Fetch a single entity by type-scoped `id` OR global `entity_id` (resolves the dual-ID system transparently) |
| `kanka_create_entity` | Create an entity. `data` is validated client-side against the per-type Zod schema before sending |
| `kanka_update_entity` | Partial PATCH on an existing entity |
| `kanka_delete_entity` | Permanently delete an entity. Requires `confirm: true` |
**Sub-resources** — both follow a unified `action: list | get | create | update | delete` shape. They hang off the **global `entity_id`**, never the type-scoped id.
| Tool | Purpose |
|---|---|
| `kanka_posts` | List/read/create/update/delete posts (sub-notes) attached to an entity |
| `kanka_relations` | List/read/create/update/delete typed links between entities (with attitude, two_way, etc.) |
### Workflow
Call `kanka_describe_entity_type` first whenever you're about to send a `data` payload — it returns the exact JSON Schema for that type, including which fields are required and any constraints. Some types have type-specific required fields beyond `name`:
- `calendar`: requires `weekday` (array of at least 2 strings)
- `conversation`: requires `target_id` (1 = users, 2 = characters)
- `dice_roll`: requires `parameters` (e.g. `"1d20+3"`)
### Incremental sync
`kanka_list_entities` accepts an optional `since` parameter (ISO 8601 timestamp) and returns a `sync` token in the response. To walk only what's changed:
```jsonc
// 1st call — full pull, save the returned token
{ "tool": "kanka_list_entities", "args": { "campaign_id": 113176, "entity_type": "character" } }
// → { "data": [...all 109 characters...], "sync": "2026-05-08T18:30:00.000Z" }
// 2nd call later — pass back the token to get only deltas
{ "tool": "kanka_list_entities", "args": {
"campaign_id": 113176, "entity_type": "character",
"since": "2026-05-08T18:30:00.000Z"
}}
// → { "data": [...only entities updated since...], "sync": "2026-05-08T19:42:11.000Z" }
```
Backed by Kanka's native `?lastSync=` query parameter — efficient for long-running agent workflows that don't want to refetch entire entity lists on every turn.
### Supported entity types (18)
`character`, `location`, `family`, `organisation`, `object`, `note`, `event`, `calendar`, `creature`, `race`, `quest`, `map`, `journal`, `ability`, `tag`, `conversation`, `dice_roll`, `timeline`
## Architecture
```
MCP Client <—stdio JSON-RPC—> kanka-mcp (Node)
├─ Tool layer (15 tools)
├─ Service layer (id-resolver, full-text-search, html strip)
├─ Kanka HTTP client (token-bucket rate limiter, retry, error map)
└─ Auth (composite: OAuth → env token → file token)
│
└─ HTTPS → api.kanka.io/1.0 + app.kanka.io/oauth/*
```
The Kanka API exposes every entity through both a type-scoped `id` (used by `/characters/{id}`) and a global `entity_id` (used by `/entities/{id}` and as the parent of posts/relations). The server resolves between the two transparently — pass whichever one you have.
Rate limiting is conservative: a token bucket sized to the configured tier with exponential backoff on `429`. Adjust `KANKA_RATE_LIMIT_PER_MIN` if you have headroom.
## Roadmap
- **Phase 1** ✓ — read-only path (auth, campaigns, search, list/get entities, describe schema)
- **Phase 2** ✓ — full CRUD (`kanka_create_entity`/`update`/`delete`), all 18 entity schemas, posts & relations
- **Phase 3** ✓ — OAuth 2.0 Authorization Code flow with PKCE + transparent refresh, file-based token persistence (0600 perms), `kanka_full_text_search` with HTML stripping
- **Phase 4** ✓ — vitest + msw test harness (43 tests), `npm run check` pipeline, ESLint guard against stdout pollution, TtlCache wired for the campaigns list
### Future / out of scope for v1
- Bulk endpoints (Kanka has them for some types, but use cases are niche)
- Image upload via URL fetch (Kanka uses `multipart/form-data`; v1 accepts pre-uploaded image UUIDs only)
- Recorded fixtures from a real campaign for offline replay testing
## Development
```bash
npm run dev # tsx watch mode
npm run typecheck # tsc --noEmit
npm run lint # eslint (bans `console` to protect stdout / MCP framing)
npm run test # vitest run — unit + msw HTTP integration tests
npm run test:watch # vitest in watch mode
npm run check # typecheck + lint + test (run this before committing)
npm run build # compile to dist/
npm run smoke # end-to-end smoke against the live Kanka API
npm run smoke -- --mutate # additionally exercises CRUD
npm run smoke -- --oauth # exercises the OAuth flow
npm run validate:mcpb # validate manifest.json against the MCPB schema
npm run pack:mcpb # build kanka-mcp-<version>.mcpb for Claude Desktop
```
### Releasing
Push a `vX.Y.Z` tag to trigger [.github/workflows/release.yml](.github/workflows/release.yml). The workflow runs the full check pipeline, builds the `.mcpb`, and attaches it to a GitHub Release with install instructions. Users download from the Releases tab.
```bash
npm version patch # bumps package.json + creates a git tag
git push --follow-tags
```
### Test layout
Tests live next to the code they cover (`*.test.ts`). The `tsconfig.json` excludes them from `dist/`, and `eslint.config.js` excludes them from the `no-console` rule so test files can log freely.
| File | Coverage |
|---|---|
| `src/client/rate-limiter.test.ts` | Token bucket, burst window, penalty, refill wait |
| `src/client/errors.test.ts` | Status-code → `KankaError` mapping, structured 422 details |
| `src/client/pagination.test.ts` | Cursor encode/decode, `paginateAll` generator |
| `src/client/http.test.ts` | msw-mocked HTTP: query encoding, 401 + refresh hook, 422 fields, 429 retry, 204 |
| `src/services/html.test.ts` | HTML strip + snippet extraction |
| `src/services/id-resolver.test.ts` | Cache hit/miss, `forget()`, unknown-type rejection |
| `src/schemas/index.test.ts` | Required-field enforcement per type, `describeEntityType` JSON Schema output |
## Continuous integration
Two GitHub Actions workflows live under [.github/workflows/](.github/workflows/):
### `ci.yml` — runs on every push/PR
Typecheck, lint, full vitest suite, and build. No secrets needed; safe to run on fork PRs. Fails the merge if any step regresses.
### `integration.yml` — live API smoke against your own campaign
Runs `npm run smoke` against the real Kanka API. Triggers:
- **Manual** (`workflow_dispatch`) from the Actions tab — optional `mutate` checkbox to additionally run the create/update/delete cycle, optional `campaign_id` to pin the test target
- **Weekly schedule** (Mondays 08:00 UTC) — catches upstream Kanka API regressions
Requires one secret on the repository:
| Setting | Where | Value |
|---|---|---|
| `KANKA_TOKEN` | Settings → Secrets and variables → Actions → **Secrets** | Your Personal API token |
| `KANKA_TIER` (optional) | Settings → Secrets and variables → Actions → **Variables** | `subscriber` if you have a Boosted/Premium account; defaults to `free` |
The workflow is gated to `workflow_dispatch` and `schedule` triggers only — it deliberately never runs on `pull_request` or `push`, so a fork PR can't ever trigger a run that would expose or consume your token. The `--mutate` job creates a throwaway Note named `kanka-mcp smoke test <ISO timestamp>` and deletes it; if a run crashes between create and delete, the leftover Note is named so you can find and remove it manually.
## Security
See [SECURITY.md](SECURITY.md) for the threat model and disclosure policy.
Hardening defaults baked into the server:
- **Request timeout** (`KANKA_REQUEST_TIMEOUT_MS`, default 30 s) — every HTTP call to Kanka is wrapped in an `AbortController`; hangs surface as `NETWORK_ERROR` rather than blocking the agent indefinitely.
- **Response size cap** (`KANKA_MAX_RESPONSE_BYTES`, default 10 MiB) — both the declared `Content-Length` and streamed bytes are checked; oversized responses are rejected before they OOM the process.
- **Rate limiter** — token bucket with burst guard and exponential backoff on 429. On startup the server hits `/profile`, reads the API-reported `rate_limit`, and resizes the bucket automatically (30 rpm free / 90 rpm subscriber). `KANKA_RATE_LIMIT_PER_MIN` overrides and disables auto-tuning.
- **Token files** — written with `0600` mode under `~/.config/kanka-mcp/` (created `0700`).
- **OAuth** — Authorization Code + PKCE (S256), 24-byte random `state` compared via `crypto.timingSafeEqual`, loopback callback bound to 127.0.0.1.
- **Log redaction** — pino is configured to censor `Authorization` headers and any field named `*token*`, `*secret*`, etc., before writing to stderr.
- **Stdout pollution guard** — ESLint bans `console.*` in `src/` so a future contributor can't accidentally corrupt MCP framing.
- **CI audit** — `npm audit --omit=dev --audit-level=high` runs on every push/PR; the workflow fails on high-severity advisories in production deps.
Run `npm audit` locally any time:
```bash
npm audit # all deps (may show low-severity dev-only items)
npm audit --omit=dev # production deps only — should always be 0
```
> **Note:** `npm audit` (without flags) currently surfaces a few **low-severity** advisories from dev tooling (`@anthropic-ai/mcpb` → `@inquirer/prompts` → `tmp`). These are interactive-CLI components used only when packing the extension bundle locally; they never run at server runtime and aren't shipped in the `.mcpb`. Production deps remain at zero advisories — see CI for the authoritative gate.
## License
MIT
TDQS
Scored across 15 tools
Most tools have clearly distinct purposes (e.g., create vs. update vs. delete, search vs. full text search vs. list). However, kanka_search and kanka_full_text_search could be confused if descriptions are overlooked, and kanka_list_entities overlaps slightly with browse/search capabilities.
Tools generally follow a 'kanka_verb_noun' pattern, but 'kanka_posts' and 'kanka_relations' are nouns only, breaking consistency. The use of 'full_text_search' with underscores is fine, but the mix of verbs and bare nouns is noticeable.
With 15 tools, the server covers authentication, campaign management, entity CRUD, posts, relations, search, and schema discovery without feeling bloated. Each tool addresses a distinct need, and the count is appropriate for the domain.
The tool surface covers essential CRUD for entities, posts, and relations, plus search and auth. Minor gaps: there is no tool to list entity types or manage campaigns beyond fetching metadata, but these are not critical for typical workflows.