WorldForge Studio

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' });
  • callTool unwraps 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) throw McpToolError (.tool, .result, message MCP tool <name> failed: <detail>) from both callTool and callToolRaw — callToolRaw is the same call without the unwrapping, so the isError flag it returns is never true; catch McpToolError and read .result to 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 (at createMcpClient, since it connects eagerly, or at the call), not as WorldForgeApiError. The /mcp entry re-exports WorldForgeApiError for convenience, but this module never throws it. (Not exercised against a live server here — see the tests in packages/sdk/src/mcp.test.ts for what is covered.)

Named helpers (stable core)

HelperToolArgs
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:write scope).
  • commit_* requires confirm: true in the arguments and the matching *:commit scope.
  • lore covers every collection (the 17 listed in 03: pantheons through eras and events). list_lore / propose_lore also accept the MCP-only lore:read / lore:propose scopes as well as world:read / world:write.
  • The underlying mcp.client is exposed for low-level needs (resources, prompts, progress tokens).