WorldForge Studio

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)

ToolArguments (* = required)ScopeWho / notes
read_worldid*world:readOwner only. Returns the row with runtime state merged into json
read_campaignid*campaign:readOwner only
list_campaigns—campaign:readOwner's campaigns, summaries
read_questid*quest:readOwner only
list_questscampaign_idquest:readOwner's quests, summaries; optional campaign filter
read_encounterid*encounter:readOwner only
list_encounterscampaign_idencounter:readSame shape as list_quests
list_wiki_pagesworld_id*, kind (lore|faction|place|quest-log)wiki:readOwner/GM. Live pages only
read_wiki_pageid*wiki:readOwner/GM of the page's world
search_wiki_pagesworld_id, q, kind, limit (1–100, default 20)wiki:readOwner/GM
wiki_page_historyid*, limit (1–200, default 50)wiki:readOwner/GM
list_factionsworld_id*wiki:readOwner/GM
get_world_summaryworld_id*wiki:read and atlas:readOwner/GM. Counts: wiki pages by kind, atlas nodes
list_atlas_placesworld_id*, level (1–5)atlas:readOwner/GM
read_atlas_nodeid*atlas:readOwner/GM of the node's world
search_atlas_nodesworld_id, q, limit (1–100)atlas:readOwner/GM
search_poisworld_id*, request { name, category, tags[], x, y, radiusPx, limit } (all optional, extra keys pass through; defaults {})world:readOwner only — the per-world GM/member split lives in GET/POST /worlds/:id/pois/search, not here
list_loreworld_id*, collection (one of the 17 lore collections)lore:read or world:readOwner/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.

ToolArgumentsScopeWho / notes
propose_questid, payload { name (1–120), json }, diff_summary, changeset_idquest:writeNew 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_encountersame as propose_questencounter:writeSame rule, for encounters
propose_wiki_pageworld_id, payload { title (1–200, required in practice), body, kind, audience (gm|party) }, diff_summary*, changeset_idwiki:proposeOwner/GM. A missing title is rejected. Extra payload keys pass the schema
propose_wiki_editpage_id, payload { title, body, audience }, diff_summary*, changeset_idwiki:proposeOwner/GM of the page's world. Pinned to the page version
propose_atlas_placeworld_id, payload { name (1–200), terrainType (≤50), parentId }, diff_summary*, changeset_idatlas:proposeOwner/GM
propose_loreworld_id, operations (1–100 of {op:'upsert',collection,entity} | {op:'remove',collection,id} | {op:'add_preset',preset_id}), diff_summary*, changeset_idlore:propose or world:writeOwner/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)

ToolArgumentsScopeWho / notes
commit_questproposal_id, confirm (literal true)quest:commitOwner of the quest only. Claim-once + version-predicate update + audit row in one transaction. Errors: proposal_not_found, not_found, version_conflict
commit_encountersameencounter:commitSame, for encounters
create_campaignname* (1–120), world_id, jsoncampaign:writeWorld owner or GM. Returns { id, version: 1 }
worldforge_create_worldname* (1–120), template, seed, size (small|medium|large, default medium), populate (default true)world:writeCreates 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_worldworld_id, cols (1–4096), rows* (1–4096)world:writeOwner 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_pickupworld_id, poi_id, item_id, cell { x, y } (ints)world:writeWorld 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_openworld_id, poi_id, cell* { x, y }world:writeSame 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.