05 — MCP client
The server's Model Context Protocol endpoint, wrapped: /mcp subpath
over the official @modelcontextprotocol/sdk.
import { createMcpClient } from '@blueforge-studio/worldforge-sdk/mcp';
const mcp = await createMcpClient({ baseUrl, apiKey: process.env.WORLDFORGE_API_KEY! });
The server is stateless (no session persistence), so this is a plain
initialize → tools/list / tools/call flow with a Bearer header.
Unlike /api/ai, API keys work — every tool call is scope-checked,
which makes /mcp the integration surface for programmatic agents.
createMcpClient(opts) takes the flat auth options (apiKey, jwt or
getToken; precedence in that order, and with none it throws a plain
Error), plus baseUrl, an optional injectable fetch, and
clientInfo: { name, version } (the identity sent in initialize;
defaults to worldforge-sdk / 0.1.0). It connects before it resolves.
Call await mcp.close() when done.
Note: getToken (if passed) is resolved once, at connect time.
Calling tools
const tools = await mcp.listTools(); // [{ name, description }]
const world = await mcp.callTool('read_world', { id: 'world_abc' });
callToolunwraps the MCP content envelope: a single text block is parsed as JSON (falling back to the raw text); several text blocks are joined with newlines. Non-text blocks are ignored.- Tool-level failures (
isError: true) throwMcpToolError(.tool,.result, messageMCP tool <name> failed: <detail>) from bothcallToolandcallToolRaw—callToolRawis the same call without the unwrapping, so theisErrorflag it returns is nevertrue; catchMcpToolErrorand read.resultto get the failed envelope. - Transport-level failures (401, scope denials) are ordinary HTTP errors
before JSON-RPC. The wrapper does not convert them: they come out of
@modelcontextprotocol/sdk(atcreateMcpClient, since it connects eagerly, or at the call), not asWorldForgeApiError. The/mcpentry re-exportsWorldForgeApiErrorfor convenience, but this module never throws it. (Not exercised against a live server here — see the tests inpackages/sdk/src/mcp.test.tsfor what is covered.)
Named helpers (stable core)
| Helper | Tool | Args |
|---|---|---|
readWorld(id) | read_world | { id } |
getWorldSummary(worldId) | get_world_summary | { world_id } |
listLore(worldId, collection?) | list_lore | { world_id, collection? } |
proposeLore(worldId, ops, diffSummary, changesetId?) | propose_lore | { world_id, operations, diff_summary, changeset_id? } |
listWikiPages(worldId, kind?) | list_wiki_pages | { world_id, kind? } — kind is 'lore' | 'faction' | 'place' | 'quest-log' |
searchPois(worldId, request) | search_pois | { world_id, request: { name?, category?, tags?, x?, y?, radiusPx?, limit? } } |
All helpers resolve to unknown (cast or pass a type to callTool<T>);
operations for proposeLore is unknown[] — each entry is
{ op: 'upsert', collection, entity } or { op: 'remove', collection, id }
per the server's propose_lore schema (apps/api/src/mcp/lore-tools.ts).
Also exported: McpClientOptions, WorldForgeMcp, McpToolResult,
McpContent, McpPoiSearchRequest and McpToolError.
Everything else goes through callTool — the full inventory is
tools/list (server side: apps/api/src/mcp/server.ts; reference:
docs/api/11-mcp).
Tool semantics worth knowing
propose_*never writes — it files a proposal into the owner/GM review queue (world:writescope).commit_*requiresconfirm: truein the arguments and the matching*:commitscope.lorecovers every collection (the 17 listed in 03: pantheons through eras and events).list_lore/propose_lorealso accept the MCP-onlylore:read/lore:proposescopes as well asworld:read/world:write.- The underlying
mcp.clientis exposed for low-level needs (resources, prompts, progress tokens).