MCP server by torinvdb
kanka-mcp
A Model Context Protocol (MCP) server for Kanka, 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:
cd kanka-mcp
npm run quickstart
The script will:
- Verify Node ≥ 20
npm installand build TypeScript- Ask whether you want to authenticate via Personal API token or OAuth 2.0
- Prompt for the relevant credentials (input is hidden) and persist them with
0600perms — token to~/.config/kanka-mcp/tokenor OAuth client/secret to.envin the repo root - Run the end-to-end smoke test (and the OAuth browser flow if you chose option 2)
- Optionally build and install the
.mcpbextension — if Claude Desktop is detected, the script offers to build the bundle andopenit 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. - 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
- Sign in at https://app.kanka.io
- Go to Settings → API: https://app.kanka.io/settings/api
- Click Generate a new token and copy the value
- 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:
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:
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):
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:
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.
Verify the token is set
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:
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.
KANKA_TOKEN="your-token-here" npm run smoke
It will:
- List the tools the server exposes
- Call
kanka_auth_status - Call
kanka_list_campaignsand print the first 5 - Call
kanka_get_campaignfor the first one (or pass a campaign id as the second arg) - Call
kanka_list_entitiesforcharacterand print the first 5
Pass an explicit campaign id if you don't want the script to auto-pick:
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.
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:
- Build the bundle:
npm run pack:mcpb(produceskanka-mcp-<version>.mcpbin the repo root). Or download a pre-built release from https://github.com/torinvdb/kanka-mcp/releases/latest. - Double-click the
.mcpbfile — Claude Desktop is registered as the handler for theDesktop ExtensionUTI on macOS, so this launches the install dialog directly. (Equivalent:open kanka-mcp-0.1.0.mcpb.) - Or navigate manually: Claude Desktop → Settings → Extensions → Advanced settings → Install Extension… → select the file.
- 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. - 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
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
{
"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:
- Stored OAuth tokens (
~/.config/kanka-mcp/oauth.json) — refreshed transparently on 401 or within 24h of expiry KANKA_TOKENenvironment variable~/.config/kanka-mcp/tokenfile
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
- 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 rejects127.0.0.1— uselocalhost). Pin a port viaKANKA_OAUTH_REDIRECT_PORTand use the same port here. - 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:
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 - From your MCP client (or via
npm run smoke -- --oauth), callkanka_oauth_login. The server opens your browser to Kanka's authorize page. After approval, tokens are persisted toKANKA_OAUTH_TOKEN_FILE(0600perms) and used automatically. - Call
kanka_auth_logoutto 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: requiresweekday(array of at least 2 strings)conversation: requirestarget_id(1 = users, 2 = characters)dice_roll: requiresparameters(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:
// 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_searchwith HTML stripping - Phase 4 ✓ — vitest + msw test harness (43 tests),
npm run checkpipeline, 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
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. 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.
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/:
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 — optionalmutatecheckbox to additionally run the create/update/delete cycle, optionalcampaign_idto 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 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 anAbortController; hangs surface asNETWORK_ERRORrather than blocking the agent indefinitely. - Response size cap (
KANKA_MAX_RESPONSE_BYTES, default 10 MiB) — both the declaredContent-Lengthand 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-reportedrate_limit, and resizes the bucket automatically (30 rpm free / 90 rpm subscriber).KANKA_RATE_LIMIT_PER_MINoverrides and disables auto-tuning. - Token files — written with
0600mode under~/.config/kanka-mcp/(created0700). - OAuth — Authorization Code + PKCE (S256), 24-byte random
statecompared viacrypto.timingSafeEqual, loopback callback bound to 127.0.0.1. - Log redaction — pino is configured to censor
Authorizationheaders and any field named*token*,*secret*, etc., before writing to stderr. - Stdout pollution guard — ESLint bans
console.*insrc/so a future contributor can't accidentally corrupt MCP framing. - CI audit —
npm audit --omit=dev --audit-level=highruns on every push/PR; the workflow fails on high-severity advisories in production deps.
Run npm audit locally any time:
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