11 — MCP
The Model Context Protocol endpoint — stateless streamable HTTP at /mcp.
POST /mcp initialize / tools/list / tools/call (JSON-RPC)
GET /mcp server → client SSE stream (optional)
DELETE /mcp end stream (stateless server: replies 200 and closes the per-request transport)
All three require Authorization: Bearer <token> (requireAuth; a
missing or invalid credential is 401 { error: 'unauthorized' }) —
API keys work (wk_live_…; their scopes are checked per tool call),
which makes /mcp the natural integration surface for programmatic
agents, unlike /api/ai.
The server is stateless (sessionIdGenerator: undefined): each
request builds a fresh McpServer + WebStandardStreamableHTTPServerTransport
for the actor (handleMcp in apps/api/src/server.ts). No Mcp-Session-Id
is issued or validated, no session persistence, every POST is
self-contained. Transport details (Accept header requirements, the GET
stream's 406 without text/event-stream) are the MCP SDK's own; a
streaming GET is not useful here because the server never initiates
messages — not verified against a live client.
Tools
Registered in apps/api/src/mcp/server.ts (createMcpServer), with the
core campaign/quest/encounter block in campaign-tools.ts and the
handlers in handlers/{wiki,atlas,world}.ts, lore-tools.ts,
poi-tools.ts and inventory-tools.ts. 31 tools — the list is
pinned by apps/api/src/mcp/server.test.ts ("lists the 18 read tools
and 13 write tools"). A fresh McpServer is built per request for the
authenticated actor.
Names are snake_case; argument names are snake_case too
(world_id, campaign_id, diff_summary). A tool result is one text
content item holding JSON.stringify(data).
Access. Every tool checks its scope in the handler (a * session
JWT passes all of them), then resolves the effective owner — a user's
id, or an API key's owning user. No resolvable owner → missing_scopes:unresolved_owner.
/mcp has no row in the API-key scope table
(auth/api-key-scopes.ts), so scopes are enforced per tool only.
Beyond scope, "owner" means the row's ownerId; "owner/GM" means the
world's owner or one of its GMs (requireOwnerOrGm). Everyone else —
players, observers, strangers — gets not_found, the same answer as
for a missing id.
Read tools (18)
| Tool | Arguments (* = required) | Scope | Who / notes |
|---|---|---|---|
read_world | id* | world:read | Owner only. Returns the row with runtime state merged into json |
read_campaign | id* | campaign:read | Owner only |
list_campaigns | — | campaign:read | Owner's campaigns, summaries |
read_quest | id* | quest:read | Owner only |
list_quests | campaign_id | quest:read | Owner's quests, summaries; optional campaign filter |
read_encounter | id* | encounter:read | Owner only |
list_encounters | campaign_id | encounter:read | Same shape as list_quests |
list_wiki_pages | world_id*, kind (lore|faction|place|quest-log) | wiki:read | Owner/GM. Live pages only |
read_wiki_page | id* | wiki:read | Owner/GM of the page's world |
search_wiki_pages | world_id, q, kind, limit (1–100, default 20) | wiki:read | Owner/GM |
wiki_page_history | id*, limit (1–200, default 50) | wiki:read | Owner/GM |
list_factions | world_id* | wiki:read | Owner/GM |
get_world_summary | world_id* | wiki:read and atlas:read | Owner/GM. Counts: wiki pages by kind, atlas nodes |
list_atlas_places | world_id*, level (1–5) | atlas:read | Owner/GM |
read_atlas_node | id* | atlas:read | Owner/GM of the node's world |
search_atlas_nodes | world_id, q, limit (1–100) | atlas:read | Owner/GM |
search_pois | world_id*, request { name, category, tags[], x, y, radiusPx, limit } (all optional, extra keys pass through; defaults {}) | world:read | Owner only — the per-world GM/member split lives in GET/POST /worlds/:id/pois/search, not here |
list_lore | world_id*, collection (one of the 17 lore collections) | lore:read or world:read | Owner/GM. Returns { lore, counts, issues, presets }, or { collection, items, counts, issues } when collection is given |
Propose tools (6) — never direct writes
Each stores a proposal (owner/GM reviews it through
/worlds/:id/proposals or POST /proposals/:id/commit) and returns
{ proposal_id, audit_id, world_id?, changeset_id?, auto_accepted? }
(audit_id is the same value, kept for older clients;
auto_accepted appears when one of the owner's auto-accept rules
committed it at once). Optional changeset_id (must match
CHANGESET_ID_PATTERN) groups proposals into a changeset.
diff_summary is required, max 500 chars.
| Tool | Arguments | Scope | Who / notes |
|---|---|---|---|
propose_quest | id, payload { name (1–120), json }, diff_summary, changeset_id | quest:write | New quest when id is absent; else the quest's owner, or the owner/GM of its world. Pinned to the quest's current version |
propose_encounter | same as propose_quest | encounter:write | Same rule, for encounters |
propose_wiki_page | world_id, payload { title (1–200, required in practice), body, kind, audience (gm|party) }, diff_summary*, changeset_id | wiki:propose | Owner/GM. A missing title is rejected. Extra payload keys pass the schema |
propose_wiki_edit | page_id, payload { title, body, audience }, diff_summary*, changeset_id | wiki:propose | Owner/GM of the page's world. Pinned to the page version |
propose_atlas_place | world_id, payload { name (1–200), terrainType (≤50), parentId }, diff_summary*, changeset_id | atlas:propose | Owner/GM |
propose_lore | world_id, operations (1–100 of {op:'upsert',collection,entity} | {op:'remove',collection,id} | {op:'add_preset',preset_id}), diff_summary*, changeset_id | lore:propose or world:write | Owner/GM. Stores a world proposal pinned to the world version; nothing changes until accepted. Entities are validated against the GAS lore schemas |
Commit and direct-write tools (7)
| Tool | Arguments | Scope | Who / notes |
|---|---|---|---|
commit_quest | proposal_id, confirm (literal true) | quest:commit | Owner of the quest only. Claim-once + version-predicate update + audit row in one transaction. Errors: proposal_not_found, not_found, version_conflict |
commit_encounter | same | encounter:commit | Same, for encounters |
create_campaign | name* (1–120), world_id, json | campaign:write | World owner or GM. Returns { id, version: 1 } |
worldforge_create_world | name* (1–120), template, seed, size (small|medium|large, default medium), populate (default true) | world:write | Creates a world owned by the caller; unknown template → unknown_template. Template ids named in the code: starter-jungle, starter-dungeon, starter-city, starter-desert, starter-wetland, starter-archipelago, starter-volcanic, starter-caverns, empty-hex; absent = blank grid. Returns { id, version, template, seed, cols, rows } |
worldforge_resize_world | world_id, cols (1–4096), rows* (1–4096) | world:write | Owner only, destructive, applied at once through the world write path (not propose → confirm). Returns a resize report; refusals surface the error code (freeform_grid, out_of_bounds_cap, too_small, non_integer, no_grid, no_size). The editor must reload |
worldforge_player_pickup | world_id, poi_id, item_id, cell { x, y } (ints) | world:write | World owner or member (a session JWT's * passes the scope, so membership is the real gate). Position comes from server presence; the POI must be adjacent and pickable. Errors are bad_request: too_far / not_pickable / unknown_item, conflict: no_presence, not_found |
worldforge_player_open | world_id, poi_id, cell* { x, y } | world:write | Same access rule as pickup |
commit_* checks confirm before any DB work. Only the quest and
encounter commits are MCP tools: wiki, atlas, lore and world proposals
are committed over REST by the world's owner/GM.
Tool failures (missing scope, not_found, bad arguments) are thrown
inside the handler, so they arrive as an MCP tool result with
isError: true and the message as text (missing_scopes:<a>,<b>,
not_found, …) — not as an HTTP error.
list_lore / propose_lore cover every lore collection:
pantheons, deities, faiths, orders, traditions, spells,
factions, entries, artifacts, rumours, dynasties,
creatures, festivals, strongholds, cultures, eras, events
(LORE_COLLECTIONS in packages/world-engine/src/lore.ts).
Every successful read, list and write tool writes one audit_log row;
not_found results write none.
Not covered here: the in-process agent dispatcher
(mcp/call-mcp-tool.ts) calls the same handlers for the AI chat but
dispatches only the wiki, atlas, POI, player, world and lore tools — not
the campaign/quest/encounter ones.
Clients
Any MCP client speaks this endpoint. The SDK wraps the official
@modelcontextprotocol/sdk client:
import { createMcpClient } from '@blueforge-studio/worldforge-sdk/mcp';
const mcp = await createMcpClient({ baseUrl, apiKey });
const tools = await mcp.listTools();
const lore = await mcp.callTool('list_lore', { world_id: worldId });
(The /mcp SDK subpath ships with PR2 of the SDK series; until then use
any MCP client against this endpoint directly.)
Errors
JSON-RPC errors ride the MCP envelope. Transport-level failures are
ordinary HTTP responses: 401 { error: 'unauthorized' } for a bad
credential, and the path-level gate for API keys. /mcp itself is not
in that gate, so a missing tool scope is not an HTTP 403: it is a
tool result with isError: true and text missing_scopes:<scope,…>
(see Tools). Check status first, then isError.