holonovel 2026.8.30 → 2026.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -53,8 +53,14 @@ state.buildFingerprint.lastSpecReview = new Date().toISOString();
53
53
  // ── Server ─────────────────────────────────────────────────────────
54
54
  const server = new McpServer({
55
55
  name: "inform-holonovel",
56
- version: "2026.08.30",
57
- });
56
+ version: "2026.09.02",
57
+ });
58
+ // REQ-426c — MCP Apps capability negotiation: the server declares the
59
+ // `io.modelcontextprotocol/ui` extension in its capabilities; a client that
60
+ // does not negotiate the extension sees no `ui://` surface (see
61
+ // appsNegotiated() below). REQ-426a — UI resources are served under the
62
+ // `ui://` scheme as `text/html;profile=mcp-app`.
63
+ server.server.registerCapabilities({ extensions: { "io.modelcontextprotocol/ui": {} } });
58
64
  // REQ-133 — forbidden-call audit: every tool handler is wrapped so that a
59
65
  // thrown `[FORBIDDEN]` records the call (badge, tool name, arguments,
60
66
  // violation_type: boundary) in the Novel audit log before propagating.
@@ -446,7 +452,10 @@ function terseOutput(tool, args, normal, terse) {
446
452
  }
447
453
  // REQ-409 — normalize the per-call detail request: absent → summary (lean
448
454
  // default); explicit `true` → full entries; explicit `false` → summary.
449
- const detailZod = { detail: z.boolean().optional() };
455
+ // REQ-427 every advertised input parameter carries a JSON Schema description
456
+ // (verified by T509); REQ-024 — three-clause tool descriptions ("Use when" /
457
+ // "Do NOT use when").
458
+ const detailZod = { detail: z.boolean().optional().describe("When true, return full schema and description instead of a summary.") };
450
459
  function wantsDetail(detail) {
451
460
  return detail === true;
452
461
  }
@@ -1100,6 +1109,121 @@ function formatNpcSheet(npc) {
1100
1109
  s += `**Location:** ${npc.location}\n`;
1101
1110
  return s;
1102
1111
  }
1112
+ // ── Output Format Catalog (REQ-425) ────────────────────────────────
1113
+ //
1114
+ // REQ-425a — every user-requestable artifact surface accepts an optional
1115
+ // `format` selector drawn from this catalog; the default is `markdown`.
1116
+ // REQ-425b — an unsupported format returns `[INVALID_INPUT]` enumerating the
1117
+ // surface's supported set, derived at call time (REQ-059). REQ-425c — the
1118
+ // same artifact in the same format renders byte-identically across surfaces
1119
+ // (tools and resources share the same render functions). REQ-425d — ruleset
1120
+ // packages may declare additional formats via the registry below.
1121
+ const UNIVERSAL_FORMATS = ["markdown", "json", "html"];
1122
+ const STATBLOCK_FORMATS = ["markdown", "json", "html", "ascii"];
1123
+ const SESSION_FORMATS = ["markdown", "lonelog"];
1124
+ const INTERCHANGE_FORMATS = ["json", "markdown"];
1125
+ // Ruleset-declared formats (REQ-425d). Packages register additional format
1126
+ // identifiers here at load time; they are surfaced in spec_health and in the
1127
+ // `[INVALID_INPUT]` enumeration of every surface.
1128
+ const declaredFormats = new Set([]);
1129
+ function registerDeclaredFormat(name) { declaredFormats.add(name); }
1130
+ function supportedFormats(surface) {
1131
+ return [...new Set([...surface, ...declaredFormats])];
1132
+ }
1133
+ // REQ-425b — validate a requested format against a surface's supported set;
1134
+ // returns the normalized format or an `[INVALID_INPUT]` result.
1135
+ function resolveFormat(format, surface) {
1136
+ const fmt = format ?? "markdown";
1137
+ if (!supportedFormats(surface).includes(fmt)) {
1138
+ const list = supportedFormats(surface).join(", ");
1139
+ return err("INVALID_INPUT", `Unsupported format '${fmt}'. Supported formats: ${list}.`);
1140
+ }
1141
+ return fmt;
1142
+ }
1143
+ // REQ-425c / REQ-426a — a presentational HTML render of a Markdown artifact.
1144
+ // Self-contained (no external origins) per REQ-426d. Minimal Markdown→HTML:
1145
+ // headings, bold, italics, and line breaks; everything else is escaped.
1146
+ function htmlEscape(s) {
1147
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1148
+ }
1149
+ function toHtml(markdown) {
1150
+ const body = markdown
1151
+ .split("\n")
1152
+ .map((line) => {
1153
+ const heading = line.match(/^(#{1,3})\s+(.+)$/);
1154
+ if (heading) {
1155
+ const level = heading[1].length;
1156
+ const text = htmlEscape(heading[2].replace(/\*\*(.+?)\*\*/g, "$1").replace(/\*(.+?)\*/g, "$1"));
1157
+ return `<h${level}>${text}</h${level}>`;
1158
+ }
1159
+ let l = htmlEscape(line);
1160
+ l = l.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>").replace(/\*(.+?)\*/g, "<i>$1</i>");
1161
+ return l;
1162
+ })
1163
+ .join("<br>\n");
1164
+ return `<!DOCTYPE html>\n<html><body><article>${body}</article></body></html>\n`;
1165
+ }
1166
+ // REQ-426d — UI resource CSP metadata: no external network origins.
1167
+ function uiResourceMeta() {
1168
+ return { csp: { connectDomains: [], resourceDomains: [], frameDomains: [] } };
1169
+ }
1170
+ // REQ-426c — a negotiating client declares `io.modelcontextprotocol/ui` in its
1171
+ // capabilities.extensions. Non-negotiating clients fall back to text surfaces.
1172
+ function appsNegotiated() {
1173
+ const ext = server.server.getClientCapabilities()?.extensions;
1174
+ return !!ext && "io.modelcontextprotocol/ui" in ext;
1175
+ }
1176
+ // REQ-425c — the structured (`json`) render of a stat block, shared by the
1177
+ // character_sheet tool and the entity/npc resources so both agree byte-for-byte.
1178
+ function entitySheetJson(entity) {
1179
+ return {
1180
+ id: entity.id ?? null,
1181
+ name: entity.name ?? null,
1182
+ stats: entity.stats ?? null,
1183
+ personality: entity.personality ?? {},
1184
+ inventory: entity.inventory ?? [],
1185
+ current_room: entity.current_room ?? null,
1186
+ conditions: entity.conditions ?? [],
1187
+ };
1188
+ }
1189
+ // REQ-425c — a Markdown render of a codex entry, shared by the codex://
1190
+ // resource and its `ui://` HTML view.
1191
+ function codexEntryMarkdown(entry) {
1192
+ let md = `## ${entry.name}\n`;
1193
+ if (entry.kind)
1194
+ md += `**Kind:** ${entry.kind}\n`;
1195
+ if (entry.description)
1196
+ md += `*${entry.description}*\n`;
1197
+ if (entry.content)
1198
+ md += `\n${JSON.stringify(entry.content, null, 2)}\n`;
1199
+ return md;
1200
+ }
1201
+ // REQ-426a/c — build a `ui://` resource result. A negotiating client receives
1202
+ // `text/html;profile=mcp-app` with restrictive CSP metadata (REQ-426d); a
1203
+ // non-negotiating client receives a plain-text fallback (REQ-426c).
1204
+ function uiResourceResult(uri, html, negotiated) {
1205
+ if (!negotiated) {
1206
+ return { contents: [{ uri, text: `[STATE_CONFLICT] ui:// resources require the MCP Apps extension (io.modelcontextprotocol/ui).`, mimeType: "text/plain" }] };
1207
+ }
1208
+ return { contents: [{ uri, text: html, mimeType: "text/html;profile=mcp-app", _meta: { ui: uiResourceMeta() } }] };
1209
+ }
1210
+ // REQ-426b — attach `ui://` linkage metadata to a tool result when a client has
1211
+ // negotiated the MCP Apps extension; non-negotiating clients get the bare result.
1212
+ function withUiLinkage(result, resourceUri) {
1213
+ if (!appsNegotiated())
1214
+ return result;
1215
+ const content = (result.content ?? []).map((c) => ({ ...c, _meta: { ui: { resourceUri } } }));
1216
+ return { ...result, content };
1217
+ }
1218
+ // REQ-425a — read the `format` query parameter from a resource URI (default
1219
+ // markdown) and extract the resource id/key, excluding the query string.
1220
+ function resourceFormat(uri) {
1221
+ return uri.searchParams.get("format") ?? "markdown";
1222
+ }
1223
+ function resourceKey(uri) {
1224
+ const raw = uri.href.split("?")[0].split("/").filter(Boolean).pop() ?? "";
1225
+ return decodeURIComponent(raw);
1226
+ }
1103
1227
  // ── Tools ──────────────────────────────────────────────────────────
1104
1228
  // --- Badge & Workflow ---
1105
1229
  function badgeLabel(badge) {
@@ -1112,8 +1236,8 @@ function badgeLabel(badge) {
1112
1236
  // context; REQ-305 — observer mode: read-only spectator, AI plays both roles.
1113
1237
  server.registerTool("set_badge", {
1114
1238
  title: "Set Active Badge",
1115
- description: "Switch active badge: player, game_master, observer, or none (Editor). Always callable.",
1116
- inputSchema: { badge: z.enum(["player", "game_master", "observer", "none"]) },
1239
+ description: "Switch the active badge to player, game_master, observer, or none (Editor), gating tool access server-side for the session; always callable. Use when: entering the story, spectating, or stepping away to edit. Do NOT use when: answering a pending workflow decision — use respond.",
1240
+ inputSchema: { badge: z.enum(["player", "game_master", "observer", "none"]).describe("The badge to activate: player, game_master, observer, or none (Editor).") },
1117
1241
  }, async ({ badge }) => {
1118
1242
  const novel = state.activeNovel;
1119
1243
  if (novel) {
@@ -1137,8 +1261,8 @@ function canon(text) {
1137
1261
  }
1138
1262
  server.registerTool("respond", {
1139
1263
  title: "Respond to Workflow Decision",
1140
- description: "Respond to a pending workflow decision.",
1141
- inputSchema: { decision: z.string(), option: z.string() },
1264
+ description: "Answer a pending workflow decision, atomically draining it and persisting the outcome to the Novel. Use when: the server emitted a [NEED_INPUT] prompt and the caller must choose. Do NOT use when: no decision is pending — use set_badge or a state tool instead.",
1265
+ inputSchema: { decision: z.string().describe("The canonical decision text the workflow is waiting on."), option: z.string().describe("The chosen option, or 'cancel' to abort the workflow and restore its snapshot.") },
1142
1266
  }, async ({ decision, option }) => {
1143
1267
  requireNotObserver();
1144
1268
  const novel = requireNovel();
@@ -1372,7 +1496,7 @@ function kwMatch(canonicalDecision, keywords) {
1372
1496
  }
1373
1497
  server.registerTool("undo", {
1374
1498
  title: "Undo",
1375
- description: "Undo the most recent mutation. Restores previous snapshot.",
1499
+ description: "Undo the most recent state mutation, restoring the prior per-badge snapshot. Use when: reverting a mistaken or unwanted change. Do NOT use when: re-applying an undone change — use redo.",
1376
1500
  inputSchema: {},
1377
1501
  }, async () => {
1378
1502
  requireNotObserver();
@@ -1385,7 +1509,7 @@ server.registerTool("undo", {
1385
1509
  });
1386
1510
  server.registerTool("redo", {
1387
1511
  title: "Redo",
1388
- description: "Redo the most recently undone mutation.",
1512
+ description: "Re-apply the most recently undone mutation, restoring the per-badge snapshot that undo removed. Use when: an undo was issued by mistake and the change should be restored. Do NOT use when: reverting a new change — use undo.",
1389
1513
  inputSchema: {},
1390
1514
  }, async () => {
1391
1515
  requireNotObserver();
@@ -1398,8 +1522,8 @@ server.registerTool("redo", {
1398
1522
  });
1399
1523
  server.registerTool("help", {
1400
1524
  title: "Help and Tool Discovery",
1401
- description: "Show available commands and tools. Accepts optional query for focused search.",
1402
- inputSchema: { query: z.string().optional() },
1525
+ description: "Show the available tools grouped by category, badge-filtered for the active badge. Use when: the caller needs to discover what tools exist or find one by keyword. Do NOT use when: reading the current badge's guidance — use the badge_briefing prompt.",
1526
+ inputSchema: { query: z.string().optional().describe("Optional search term matched against tool name, description, and title.") },
1403
1527
  }, async ({ query }) => {
1404
1528
  // REQ-024 — tool documentation: tools carry a human title and descriptions
1405
1529
  // using the ruleset's own terms; full descriptions remain at resources/read.
@@ -1476,8 +1600,8 @@ server.registerTool("help", {
1476
1600
  });
1477
1601
  server.registerTool("set_help_category", {
1478
1602
  title: "Set Help Category Override",
1479
- description: "Override the builder-assigned category for a tool. Game Master only. Set category to empty string or null to restore defaults.",
1480
- inputSchema: { tool_name: z.string(), category: z.string().nullable() },
1603
+ description: "Override the builder-assigned category a tool appears under in help output, persisted to the Novel (Game Master only). Use when: reorganizing the tool catalog for a session. Do NOT use when: setting briefing section order — use set_briefing_order.",
1604
+ inputSchema: { tool_name: z.string().describe("The registered tool name to reassign."), category: z.string().nullable().describe("The new category label, or null/empty to restore the builder default.") },
1481
1605
  }, async ({ tool_name, category }) => {
1482
1606
  requireGM();
1483
1607
  const novel = requireNovel();
@@ -1567,30 +1691,10 @@ function buildCharacterStats(build, rules) {
1567
1691
  }
1568
1692
  server.registerTool("create_character", {
1569
1693
  title: "Create Character",
1570
- description: "Create a character. Quick-create: pass name, species, classes. Step-by-step: call with no params to begin a guided [NEED_INPUT] workflow.",
1694
+ description: "Create a character via quick-create (name, species, classes) or a guided [NEED_INPUT] step-by-step workflow when called without a name, persisting the entity to the Novel. Use when: introducing a new player character or NPC. Do NOT use when: modifying an existing entity — use set_personality, set_voice_examples, or set_active_entity.",
1571
1695
  // REQ-408 — parameter ceiling (8): compact entry (name + identity + source)
1572
1696
  // with mechanical and personality detail grouped into refinement objects.
1573
- inputSchema: {
1574
- name: z.string().optional(),
1575
- species: z.string().optional(),
1576
- classes: z.union([z.string(), z.array(z.object({ className: z.string(), levels: z.number().optional() }))]).optional(),
1577
- stat_method: z.string().optional(),
1578
- seed: z.string().optional(),
1579
- stage_to_roster: z.boolean().optional(),
1580
- personality: z.object({
1581
- description: z.string().optional(),
1582
- voice: z.string().optional(),
1583
- background: z.string().optional(),
1584
- goals: z.string().optional(),
1585
- }).optional(),
1586
- details: z.object({
1587
- ability_scores: z.union([z.string(), z.array(z.number())]).optional(),
1588
- skills: z.union([z.string(), z.array(z.string())]).optional(),
1589
- feats: z.union([z.string(), z.array(z.string())]).optional(),
1590
- talents: z.union([z.string(), z.array(z.string())]).optional(),
1591
- equipment: z.union([z.string(), z.array(z.string())]).optional(),
1592
- }).optional(),
1593
- },
1697
+ inputSchema: { name: z.string().optional().describe("The character's name; omit to begin the step-by-step creation workflow."), species: z.string().optional().describe("The species name; required for quick-create."), classes: z.union([z.string(), z.array(z.object({ className: z.string(), levels: z.number().optional() }))]).optional().describe("Class levels as 'Class 5 / Other 2' or an array of {className, levels}."), stat_method: z.string().optional().describe("The stat-generation method; defaults to the ruleset's first method."), seed: z.string().optional().describe("Deterministic seed for ability-score generation."), stage_to_roster: z.boolean().optional().describe("When true, also stage the entity into the persistent roster."), personality: z.object({ description: z.string().optional().describe("Narrative description."), voice: z.string().optional().describe("Voice and speech pattern."), background: z.string().optional().describe("Backstory."), goals: z.string().optional().describe("Character goals.") }).optional().describe("Grouped personality fields."), details: z.object({ ability_scores: z.union([z.string(), z.array(z.number())]).optional().describe("Ability scores as '15 14 13 12 10 8' or an array."), skills: z.union([z.string(), z.array(z.string())]).optional().describe("Trained skills."), feats: z.union([z.string(), z.array(z.string())]).optional().describe("Feats."), talents: z.union([z.string(), z.array(z.string())]).optional().describe("Talents."), equipment: z.union([z.string(), z.array(z.string())]).optional().describe("Starting equipment.") }).optional().describe("Grouped mechanical details.") },
1594
1698
  }, async ({ name, species, classes, stat_method, seed, stage_to_roster, personality: personalityObj, details, description, voice, background, goals, ability_scores, skills, feats, talents, equipment }) => {
1595
1699
  // Legacy-tolerant normalization: accept the grouped objects or their
1596
1700
  // top-level spellings interchangeably.
@@ -1698,8 +1802,8 @@ ${stage_to_roster ? `Staged to roster as ${entity.id}.` : `Character '${name}' c
1698
1802
  });
1699
1803
  server.registerTool("stage_character", {
1700
1804
  title: "Stage Character to Roster",
1701
- description: "Stage an existing novel entity into the persistent roster for later import.",
1702
- inputSchema: { entity_id: z.string().optional() },
1805
+ description: "Stage an existing Novel entity into the persistent roster, so it survives the Novel and can be imported elsewhere. Use when: persisting a character for reuse in another Novel. Do NOT use when: importing a roster character into the Novel — use import_character.",
1806
+ inputSchema: { entity_id: z.string().optional().describe("The entity to stage; defaults to the active entity.") },
1703
1807
  }, async ({ entity_id }) => {
1704
1808
  requireNotObserver();
1705
1809
  const novel = requireNovel();
@@ -1712,8 +1816,8 @@ server.registerTool("stage_character", {
1712
1816
  });
1713
1817
  server.registerTool("import_character", {
1714
1818
  title: "Import Character",
1715
- description: "Import a roster character into the active novel.",
1716
- inputSchema: { roster_id: z.string() },
1819
+ description: "Import a roster character into the active Novel, copying name, personality, voice examples, and inventory. Use when: bringing a staged character into play. Do NOT use when: persisting a Novel entity to the roster — use stage_character.",
1820
+ inputSchema: { roster_id: z.string().describe("The roster identifier to import.") },
1717
1821
  }, async ({ roster_id }) => {
1718
1822
  requireNotObserver();
1719
1823
  const novel = requireNovel();
@@ -1726,26 +1830,39 @@ server.registerTool("import_character", {
1726
1830
  });
1727
1831
  server.registerTool("character_sheet", {
1728
1832
  title: "Character Sheet",
1729
- description: "Render a character sheet for an entity. Formats: markdown (default), ascii.",
1833
+ description: "Render a character sheet for an entity or NPC in markdown (default), json, html, or ascii. Use when: the caller needs the full mechanical or profile view of a character. Do NOT use when: creating or editing a character — use create_character or set_personality.",
1730
1834
  // REQ-120 — NPC rendering via the same sheet mechanism; REQ-124 — NPC damage
1731
1835
  // resolution targets NPCs by identifier; REQ-129 — property group cardinality.
1732
- inputSchema: {
1733
- entity_id: z.string().optional(),
1734
- format: z.enum(["markdown", "ascii"]).optional(),
1735
- },
1836
+ // REQ-425a/b — the `format` selector is drawn from the output format catalog
1837
+ // (STATBLOCK_FORMATS) and validated at call time; REQ-426b — the result
1838
+ // carries `ui://` linkage metadata when the Apps extension is negotiated.
1839
+ inputSchema: { entity_id: z.string().optional().describe("The entity or NPC to render; defaults to the active entity."), format: z.string().optional().describe("Output format: markdown (default), json, html, or ascii.") },
1736
1840
  }, async ({ entity_id, format }) => {
1737
1841
  const entity = resolveEntityOrNpc(entity_id);
1738
1842
  if (!entity)
1739
1843
  return err("NOT_FOUND", `Entity or NPC '${entity_id || "none"}' not found. Corrective action: list entities with party://current or NPCs with npcs://.`);
1740
- if (format === "ascii") {
1741
- return raw(`[OK] ${entity.name} Room: ${entity.current_room || "(none)"} Held: ${entity.inventory?.length || 0}`);
1844
+ const fmt = resolveFormat(format, STATBLOCK_FORMATS);
1845
+ if (typeof fmt !== "string")
1846
+ return fmt;
1847
+ let result;
1848
+ if (fmt === "ascii") {
1849
+ result = raw(`[OK] ${entity.name} Room: ${entity.current_room || "(none)"} Held: ${entity.inventory?.length || 0}`);
1850
+ }
1851
+ else if (fmt === "json") {
1852
+ result = raw(JSON.stringify(entitySheetJson(entity), null, 2));
1742
1853
  }
1743
- return ok(fmtEntitySheet(entity));
1854
+ else if (fmt === "html") {
1855
+ result = raw(toHtml(fmtEntitySheet(entity)));
1856
+ }
1857
+ else {
1858
+ result = ok(fmtEntitySheet(entity));
1859
+ }
1860
+ return withUiLinkage(result, `ui://character-sheet/${entity.id}`);
1744
1861
  });
1745
1862
  server.registerTool("set_active_entity", {
1746
1863
  title: "Set Active Entity",
1747
- description: "Set the currently active entity.",
1748
- inputSchema: { entity_id: z.string(), pov: z.enum(["character", "omniscient"]).optional() },
1864
+ description: "Set the currently active entity and, optionally, the point-of-view mode (character or omniscient), persisting the choice to the Novel. Use when: switching which character the Player inhabits. Do NOT use when: changing the acting badge — use set_badge.",
1865
+ inputSchema: { entity_id: z.string().describe("The entity to make active."), pov: z.enum(["character", "omniscient"]).optional().describe("Point-of-view mode for the active entity.") },
1749
1866
  }, async ({ entity_id, pov }) => {
1750
1867
  requireNotObserver();
1751
1868
  const novel = requireNovel();
@@ -1759,19 +1876,13 @@ server.registerTool("set_active_entity", {
1759
1876
  });
1760
1877
  server.registerTool("set_personality", {
1761
1878
  title: "Set Entity or NPC Personality",
1762
- description: "Set narrative personality fields for an entity or NPC.",
1879
+ description: "Set narrative personality fields for an entity or NPC, persisting the most recent write across all read surfaces. Use when: defining how a character speaks, thinks, or what they want. Do NOT use when: setting dialogue examples — use set_voice_examples.",
1763
1880
  // REQ-127 — ruleset-native personality mapping (set_personality description
1764
1881
  // references ruleset-native construct names when a ruleset defines them);
1765
1882
  // REQ-165 — entity ownership gating (Player for own entities, GM for all);
1766
1883
  // REQ-166 — personality briefing rendering (fields surfaced in badge_briefing
1767
1884
  // alongside stats); REQ-122 — NPC narrative fields (NPC identifiers accepted).
1768
- inputSchema: {
1769
- entity_id: z.string(),
1770
- description: z.string().optional(),
1771
- voice: z.string().optional(),
1772
- background: z.string().optional(),
1773
- goals: z.string().optional(),
1774
- },
1885
+ inputSchema: { entity_id: z.string().describe("The entity or NPC to update."), description: z.string().optional().describe("Narrative description."), voice: z.string().optional().describe("Voice and speech pattern."), background: z.string().optional().describe("Backstory."), goals: z.string().optional().describe("Character goals.") },
1775
1886
  }, async ({ entity_id, description, voice, background, goals }) => {
1776
1887
  requireNotObserver();
1777
1888
  const novel = requireNovel();
@@ -1801,13 +1912,10 @@ server.registerTool("set_personality", {
1801
1912
  });
1802
1913
  server.registerTool("set_voice_examples", {
1803
1914
  title: "Set Voice Examples",
1804
- description: "Set voice and dialogue examples for an entity or NPC.",
1915
+ description: "Set voice and dialogue examples for an entity or NPC, rendered ahead of trait descriptions to show rather than tell. Use when: giving the narrator concrete lines to imitate. Do NOT use when: setting abstract personality traits — use set_personality.",
1805
1916
  // REQ-126 — voice examples render ahead of trait descriptions in prompts
1806
1917
  // (show-don't-tell); REQ-077f — the primary dialogue-consistency mechanism.
1807
- inputSchema: {
1808
- entity_id: z.string(),
1809
- examples: z.array(z.object({ context: z.string(), dialogue: z.string(), tag: z.string().optional() })),
1810
- },
1918
+ inputSchema: { entity_id: z.string().describe("The entity or NPC to update."), examples: z.array(z.object({ context: z.string().describe("When the line was spoken."), dialogue: z.string().describe("The example line."), tag: z.string().optional().describe("Optional tag.") })).describe("Voice and dialogue examples.") },
1811
1919
  }, async ({ entity_id, examples }) => {
1812
1920
  requireNotObserver();
1813
1921
  const novel = requireNovel();
@@ -1822,11 +1930,8 @@ server.registerTool("set_voice_examples", {
1822
1930
  // REQ-069 — player feedback signal to the GM.
1823
1931
  server.registerTool("player_signal", {
1824
1932
  title: "Player Signal",
1825
- description: "Send a narrative signal from the player to the GM.",
1826
- inputSchema: {
1827
- signal: z.enum(["pace", "difficulty", "tone", "focus", "boundary", "voice_feedback"]),
1828
- value: z.string(),
1829
- },
1933
+ description: "Send a narrative feedback signal from the Player to the GM (Player badge only), recording it to the Novel and correcting the entity's voice examples when the signal is voice_feedback. Use when: the player wants to steer pacing, difficulty, tone, focus, or set a boundary. Do NOT use when: making a game action — use command or a mechanical tool.",
1934
+ inputSchema: { signal: z.enum(["pace", "difficulty", "tone", "focus", "boundary", "voice_feedback"]).describe("The feedback category."), value: z.string().describe("The feedback text.") },
1830
1935
  }, async ({ signal, value }) => {
1831
1936
  requirePlayer();
1832
1937
  const novel = requireNovel();
@@ -1855,13 +1960,8 @@ server.registerTool("player_signal", {
1855
1960
  // ── Autonomy (REQ-306) ────────────────────────────────────────────
1856
1961
  server.registerTool("set_autonomy", {
1857
1962
  title: "Adjustable Autonomy",
1858
- description: "Set the AI autonomy sliders for the active Novel. level: full/mechanical_prompt/manual; confirmation: auto/confirm/prompt; safety: safe/moderate/hardcore; creativity: predictable/standard/chaotic. Game Master only.",
1859
- inputSchema: {
1860
- level: z.enum(["full", "mechanical_prompt", "manual"]).optional(),
1861
- confirmation: z.enum(["auto", "confirm", "prompt"]).optional(),
1862
- safety: z.enum(["safe", "moderate", "hardcore"]).optional(),
1863
- creativity: z.enum(["predictable", "standard", "chaotic"]).optional(),
1864
- },
1963
+ description: "Set the AI autonomy sliders for the active Novel (level, confirmation, safety, creativity), persisting them and escalating safety raises through a confirmation workflow (Game Master only). Use when: tuning how much the AI auto-plays. Do NOT use when: switching the acting badge — use set_badge.",
1964
+ inputSchema: { level: z.enum(["full", "mechanical_prompt", "manual"]).optional().describe("How much the AI auto-plays: full, mechanical_prompt, or manual."), confirmation: z.enum(["auto", "confirm", "prompt"]).optional().describe("When the AI asks before acting: auto, confirm, or prompt."), safety: z.enum(["safe", "moderate", "hardcore"]).optional().describe("Safety tier: safe, moderate, or hardcore."), creativity: z.enum(["predictable", "standard", "chaotic"]).optional().describe("Creativity: predictable, standard, or chaotic.") },
1865
1965
  }, async ({ level, confirmation, safety, creativity }) => {
1866
1966
  requireGM();
1867
1967
  const novel = requireNovel();
@@ -1990,8 +2090,8 @@ function recordExplorationKnowledge(novel, entity, type, name) {
1990
2090
  }
1991
2091
  server.registerTool("command", {
1992
2092
  title: "Parser Command",
1993
- description: "Execute a natural-language parser command against the world model. Use for navigation (go, n/s/e/w), inspection (look, examine), object interaction (take, drop, open, close), inventory, and wait.",
1994
- inputSchema: { command: z.string() },
2093
+ description: "Execute a natural-language parser command against the world model, mutating it for navigation, inspection, object interaction, inventory, and wait. Use when: a player or narrator takes a physical action in the world. Do NOT use when: checking the outcome of an action without mutating state — use resolve_intent.",
2094
+ inputSchema: { command: z.string().describe("The natural-language command, e.g. 'go north', 'look', 'take torch', 'open door'.") },
1995
2095
  }, async ({ command }) => {
1996
2096
  const novel = requireNovel();
1997
2097
  // REQ-197 — description mode commands are always recognized verbs.
@@ -2156,11 +2256,8 @@ function findMatchingThing(name, world, roomName) {
2156
2256
  // --- World-Model CRUD (GM-only) ---
2157
2257
  server.registerTool("create_room", {
2158
2258
  title: "Create Room",
2159
- description: "Create a new room in the world model. Game Master only.",
2160
- inputSchema: {
2161
- name: z.string(),
2162
- description: z.string().optional(),
2163
- },
2259
+ description: "Create a new room in the world model, persisting it to the Novel save file (Game Master only). Use when: the GM needs to add a location to the world model. Do NOT use when: adding a thing or an exit — use create_thing or create_exit.",
2260
+ inputSchema: { name: z.string().describe("The room name."), description: z.string().optional().describe("Optional room description.") },
2164
2261
  }, async ({ name, description }) => {
2165
2262
  requireGM();
2166
2263
  const novel = requireNovel();
@@ -2182,8 +2279,8 @@ server.registerTool("create_room", {
2182
2279
  });
2183
2280
  server.registerTool("remove_room", {
2184
2281
  title: "Remove Room",
2185
- description: "Remove a room and its contained things and exits. Game Master only.",
2186
- inputSchema: { name: z.string() },
2282
+ description: "Remove a room along with its contained things and exits, persisting the removal (Game Master only). Use when: deleting a location that is no longer needed. Do NOT use when: removing a single thing or exit — use remove_thing or remove_exit.",
2283
+ inputSchema: { name: z.string().describe("The room to remove.") },
2187
2284
  }, async ({ name }) => {
2188
2285
  requireGM();
2189
2286
  const novel = requireNovel();
@@ -2211,29 +2308,8 @@ server.registerTool("remove_room", {
2211
2308
  });
2212
2309
  server.registerTool("create_thing", {
2213
2310
  title: "Create Thing",
2214
- description: "Create a new thing in the world model. Game Master only.",
2215
- inputSchema: {
2216
- name: z.string(),
2217
- kind: z.string().optional(),
2218
- description: z.string().optional(),
2219
- location: z.string().optional(),
2220
- location_type: z.enum(["room", "container", "supporter"]).optional(),
2221
- fixed: z.boolean().optional(),
2222
- openable: z.boolean().optional(),
2223
- lockable: z.boolean().optional(),
2224
- locked: z.boolean().optional(),
2225
- lit: z.boolean().optional(),
2226
- switched_on: z.boolean().optional(),
2227
- switchable: z.boolean().optional(),
2228
- transparent: z.boolean().optional(),
2229
- readable: z.boolean().optional(),
2230
- read_text: z.string().optional(),
2231
- wearable: z.boolean().optional(),
2232
- edible: z.boolean().optional(),
2233
- drinkable: z.boolean().optional(),
2234
- enterable: z.boolean().optional(),
2235
- climbable: z.boolean().optional(),
2236
- },
2311
+ description: "Create a new thing in the world model with an optional kind, container, and properties, persisting it (Game Master only). Use when: the GM needs to place an object, door, or device. Do NOT use when: adding a room — use create_room.",
2312
+ inputSchema: { name: z.string().describe("The thing name."), kind: z.string().optional().describe("Optional kind from the hierarchy (thing, container, supporter, door, device, vehicle, person, backdrop, region)."), description: z.string().optional().describe("Optional description."), location: z.string().optional().describe("Optional containing room or thing name."), location_type: z.enum(["room", "container", "supporter"]).optional().describe("Where the thing is placed: room, container, or supporter."), fixed: z.boolean().optional().describe("When true the thing cannot be taken."), openable: z.boolean().optional().describe("When true the thing can be opened."), lockable: z.boolean().optional().describe("When true the thing can be locked."), locked: z.boolean().optional().describe("When true the thing starts locked."), lit: z.boolean().optional().describe("When true the thing is lit."), switched_on: z.boolean().optional().describe("When true the thing is switched on."), switchable: z.boolean().optional().describe("When true the thing can be switched."), transparent: z.boolean().optional().describe("When true the thing is transparent."), readable: z.boolean().optional().describe("When true the thing can be read."), read_text: z.string().optional().describe("Text revealed when the thing is read."), wearable: z.boolean().optional().describe("When true the thing can be worn."), edible: z.boolean().optional().describe("When true the thing can be eaten."), drinkable: z.boolean().optional().describe("When true the thing can be drunk."), enterable: z.boolean().optional().describe("When true the thing can be entered."), climbable: z.boolean().optional().describe("When true the thing can be climbed.") },
2237
2313
  }, async ({ name, kind, description, location, location_type, fixed, openable, lockable, locked, lit, switched_on, switchable, transparent, readable, read_text, wearable, edible, drinkable, enterable, climbable }) => {
2238
2314
  requireGM();
2239
2315
  const novel = requireNovel();
@@ -2287,8 +2363,8 @@ server.registerTool("create_thing", {
2287
2363
  });
2288
2364
  server.registerTool("remove_thing", {
2289
2365
  title: "Remove Thing",
2290
- description: "Remove a thing from the world model. Game Master only.",
2291
- inputSchema: { name: z.string() },
2366
+ description: "Remove a thing from the world model, persisting the removal (Game Master only). Use when: deleting an object that no longer exists in the fiction. Do NOT use when: removing a room — use remove_room.",
2367
+ inputSchema: { name: z.string().describe("The thing to remove.") },
2292
2368
  }, async ({ name }) => {
2293
2369
  requireGM();
2294
2370
  const novel = requireNovel();
@@ -2303,12 +2379,8 @@ server.registerTool("remove_thing", {
2303
2379
  });
2304
2380
  server.registerTool("create_exit", {
2305
2381
  title: "Create Exit",
2306
- description: "Create a directional exit between two rooms. Reverse exit created implicitly. Game Master only.",
2307
- inputSchema: {
2308
- direction: z.string(),
2309
- room_a: z.string(),
2310
- room_b: z.string(),
2311
- },
2382
+ description: "Create a directional exit between two rooms, creating the reverse exit implicitly and persisting both (Game Master only). Use when: connecting two locations. Do NOT use when: removing a connection — use remove_exit.",
2383
+ inputSchema: { direction: z.string().describe("The direction from room_a (n/s/e/w/up/down/out or a named direction)."), room_a: z.string().describe("The source room."), room_b: z.string().describe("The destination room.") },
2312
2384
  }, async ({ direction, room_a, room_b }) => {
2313
2385
  requireGM();
2314
2386
  const novel = requireNovel();
@@ -2330,11 +2402,8 @@ server.registerTool("create_exit", {
2330
2402
  });
2331
2403
  server.registerTool("remove_exit", {
2332
2404
  title: "Remove Exit",
2333
- description: "Remove a directional exit from a room. Game Master only.",
2334
- inputSchema: {
2335
- direction: z.string(),
2336
- room: z.string(),
2337
- },
2405
+ description: "Remove a directional exit from a room, persisting the removal (Game Master only). Use when: severing a connection between locations. Do NOT use when: creating a connection — use create_exit.",
2406
+ inputSchema: { direction: z.string().describe("The direction of the exit to remove."), room: z.string().describe("The room the exit belongs to.") },
2338
2407
  }, async ({ direction, room: roomName }) => {
2339
2408
  requireGM();
2340
2409
  const novel = requireNovel();
@@ -2354,8 +2423,8 @@ server.registerTool("remove_exit", {
2354
2423
  });
2355
2424
  server.registerTool("convert_source", {
2356
2425
  title: "Convert Source",
2357
- description: "Parse hybrid world-model assertions and populate the Novel's world model. Game Master only. Only on an empty world model.",
2358
- inputSchema: { source: z.string() },
2426
+ description: "Parse hybrid world-model assertions and populate the Novel's world model (Game Master only, empty world model only). Use when: importing a large room/thing map from prose or structured text. Do NOT use when: adding a single room or thing — use create_room or create_thing.",
2427
+ inputSchema: { source: z.string().describe("Hybrid world-model assertions in prose or structured text.") },
2359
2428
  }, async ({ source }) => {
2360
2429
  requireGM();
2361
2430
  const novel = requireNovel();
@@ -2471,7 +2540,7 @@ function composeRoomContext(room, novel, world) {
2471
2540
  server.registerTool("resolve_intent", {
2472
2541
  title: "Resolve Intent",
2473
2542
  description: "Resolve a natural-language spatial intent against the world model without mutating state. Use when: a player or the AI narrator needs to determine the outcome of a movement or inspection against the world model. Do NOT use when: you are the Game Master inspecting the model directly — use the parser command tool for that.",
2474
- inputSchema: { intent: z.string() },
2543
+ inputSchema: { intent: z.string().describe("The natural-language spatial intent to resolve (e.g. 'go north', 'open the door').") },
2475
2544
  }, async ({ intent }) => {
2476
2545
  const badge = getBadge();
2477
2546
  if (badge === "player") {
@@ -2485,12 +2554,8 @@ server.registerTool("resolve_intent", {
2485
2554
  // --- Combat (GM, auto-advance in ruleset-free mode) ---
2486
2555
  server.registerTool("init_combat", {
2487
2556
  title: "Initiate Combat",
2488
- description: "Start a combat encounter. Game Master only. In ruleset-free mode, all participants auto-advance.",
2489
- inputSchema: {
2490
- participants: z.array(z.string()),
2491
- dangers: z.array(z.object({ name: z.string(), ac: z.number().optional(), hp: z.number().optional(), initiative_bonus: z.number().optional() })).optional(),
2492
- seed: z.string().optional(),
2493
- },
2557
+ description: "Start a combat encounter with participants and optional dangers, persisting the combat state (Game Master only). Use when: initiating a fight. Do NOT use when: progressing an active fight — use advance_combat.",
2558
+ inputSchema: { participants: z.array(z.string()).describe("Entity identifiers participating in combat."), dangers: z.array(z.object({ name: z.string(), ac: z.number().optional(), hp: z.number().optional(), initiative_bonus: z.number().optional() })).optional().describe("Optional non-entity combatants with armor class, hit points, and initiative bonus."), seed: z.string().optional().describe("Optional deterministic seed for initiative and rolls.") },
2494
2559
  }, async ({ participants, dangers, seed }) => {
2495
2560
  requireGM();
2496
2561
  const novel = requireNovel();
@@ -2513,7 +2578,7 @@ server.registerTool("init_combat", {
2513
2578
  });
2514
2579
  server.registerTool("advance_combat", {
2515
2580
  title: "Advance Combat",
2516
- description: "Advance to the next turn in combat. Game Master only.",
2581
+ description: "Advance combat to the next turn, applying round effects and persisting the change (Game Master only). Use when: moving the active fight forward one turn. Do NOT use when: starting or ending combat — use init_combat or end_combat.",
2517
2582
  inputSchema: {},
2518
2583
  }, async () => {
2519
2584
  requireGM();
@@ -2533,8 +2598,8 @@ server.registerTool("advance_combat", {
2533
2598
  });
2534
2599
  server.registerTool("end_combat", {
2535
2600
  title: "End Combat",
2536
- description: "End the active combat encounter. Game Master only.",
2537
- inputSchema: { outcome: z.string().optional() },
2601
+ description: "End the active combat encounter, clearing combat state and persisting it (Game Master only). Use when: the fight is resolved. Do NOT use when: advancing the fight — use advance_combat.",
2602
+ inputSchema: { outcome: z.string().optional().describe("Optional text describing how the combat ended.") },
2538
2603
  }, async ({ outcome }) => {
2539
2604
  requireGM();
2540
2605
  const novel = requireNovel();
@@ -2546,8 +2611,8 @@ server.registerTool("end_combat", {
2546
2611
  });
2547
2612
  server.registerTool("add_combat_participant", {
2548
2613
  title: "Add Combat Participant",
2549
- description: "Add a participant to active combat. Game Master only.",
2550
- inputSchema: { participant_id: z.string() },
2614
+ description: "Add a participant to the active combat, persisting the roster change (Game Master only). Use when: a new combatant joins mid-fight. Do NOT use when: removing a combatant — use remove_combat_participant.",
2615
+ inputSchema: { participant_id: z.string().describe("The entity identifier to add to combat.") },
2551
2616
  }, async ({ participant_id }) => {
2552
2617
  requireGM();
2553
2618
  const novel = requireNovel();
@@ -2558,8 +2623,8 @@ server.registerTool("add_combat_participant", {
2558
2623
  });
2559
2624
  server.registerTool("remove_combat_participant", {
2560
2625
  title: "Remove Combat Participant",
2561
- description: "Remove a participant from active combat. Game Master only.",
2562
- inputSchema: { participant_id: z.string() },
2626
+ description: "Remove a participant from the active combat, persisting the roster change (Game Master only). Use when: a combatant flees or is removed. Do NOT use when: adding a combatant — use add_combat_participant.",
2627
+ inputSchema: { participant_id: z.string().describe("The participant to remove from combat.") },
2563
2628
  }, async ({ participant_id }) => {
2564
2629
  requireGM();
2565
2630
  const novel = requireNovel();
@@ -2709,28 +2774,28 @@ function advanceSceneTransitionCountdowns(novel) {
2709
2774
  }
2710
2775
  server.registerTool("set_scene_state", {
2711
2776
  title: "Set Scene State",
2712
- description: "Set the scene description and location. Game Master only.",
2777
+ description: "Set the scene description, location, time of day, atmosphere, and scene type, persisting them (Game Master only). Use when: the fiction moves to a new scene. Do NOT use when: setting the narrative directive — use set_narrative_directive.",
2713
2778
  inputSchema: {
2714
- description: z.string(),
2715
- location: z.string().optional(),
2716
- time_of_day: z.string().optional(),
2717
- atmosphere: z.string().optional(),
2779
+ description: z.string().describe("The scene description."),
2780
+ location: z.string().optional().describe("Optional scene location."),
2781
+ time_of_day: z.string().optional().describe("Optional time of day."),
2782
+ atmosphere: z.string().optional().describe("Optional atmosphere."),
2718
2783
  // REQ-087 — scene type tagging: scene_type accepts a single tag or an array
2719
2784
  // from the canonical catalog (social/exploration/neutral; combat is a
2720
2785
  // resolution mode, added automatically on init_combat).
2721
- scene_type: z.union([z.enum(["combat", "social", "exploration", "neutral"]), z.array(z.enum(["combat", "social", "exploration", "neutral"]))]).optional(),
2722
- beat: z.enum(BEAT_VALUES).optional(),
2723
- skip_transition_hook: z.boolean().optional(),
2786
+ scene_type: z.union([z.enum(["combat", "social", "exploration", "neutral"]), z.array(z.enum(["combat", "social", "exploration", "neutral"]))]).optional().describe("A scene-type tag or array of tags from combat/social/exploration/neutral."),
2787
+ beat: z.enum(BEAT_VALUES).optional().describe("Optional story-beat tag."),
2788
+ skip_transition_hook: z.boolean().optional().describe("When true, skip the scene-transition hook."),
2724
2789
  // REQ-250 — adventure scene waypoint: heading anchor from the adventure
2725
2790
  // structural index; empty/null clears; unknown anchors → [NOT_FOUND].
2726
- adventure_scene: z.string().nullable().optional(),
2791
+ adventure_scene: z.string().nullable().optional().describe("Optional adventure-scene waypoint anchor; empty or null clears it."),
2727
2792
  // REQ-252 — narrative fast-forward: skip intervening time with a bridging
2728
2793
  // summary, countdown adjustments, and NPC changes.
2729
2794
  fast_forward: z.object({
2730
- interval: z.string(),
2731
- changes: z.array(z.object({ npc_id: z.string(), location: z.string().optional(), disposition: z.string().optional(), condition: z.string().optional() })).optional(),
2732
- skip_countdowns: z.boolean().optional(),
2733
- }).optional(),
2795
+ interval: z.string().describe("The time interval to skip."),
2796
+ changes: z.array(z.object({ npc_id: z.string().describe("The NPC to change."), location: z.string().optional().describe("New location."), disposition: z.string().optional().describe("New disposition."), condition: z.string().optional().describe("New condition.") })).optional().describe("Optional NPC changes during the skip."),
2797
+ skip_countdowns: z.boolean().optional().describe("When true, do not advance countdowns during the skip."),
2798
+ }).optional().describe("Optional fast-forward: skip intervening time with a bridging summary, countdown adjustments, and NPC changes."),
2734
2799
  },
2735
2800
  }, async ({ description, location, time_of_day, atmosphere, scene_type, beat, skip_transition_hook, adventure_scene, fast_forward }) => {
2736
2801
  requireGM();
@@ -2861,8 +2926,8 @@ server.registerTool("set_scene_state", {
2861
2926
  });
2862
2927
  server.registerTool("set_narrative_directive", {
2863
2928
  title: "Set Narrative Directive",
2864
- description: "Set overarching narrative directive for the current scene. Game Master only.",
2865
- inputSchema: { directive: z.string() },
2929
+ description: "Set the overarching narrative directive for the current scene, persisting it (Game Master only). Use when: steering tone or scene focus. Do NOT use when: setting scene location or description — use set_scene_state.",
2930
+ inputSchema: { directive: z.string().describe("The overarching narrative directive for the current scene.") },
2866
2931
  }, async ({ directive }) => {
2867
2932
  requireGM();
2868
2933
  const novel = requireNovel();
@@ -2874,15 +2939,15 @@ server.registerTool("set_narrative_directive", {
2874
2939
  // --- NPCs (GM) ---
2875
2940
  server.registerTool("create_npc", {
2876
2941
  title: "Create NPC",
2877
- description: "Create a named NPC with optional description and narrative fields. Game Master only.",
2942
+ description: "Create a named NPC with optional description, disposition, location, and goals, persisting it to the Novel (Game Master only). Use when: introducing a non-player character. Do NOT use when: editing an NPC — use update_npc.",
2878
2943
  inputSchema: {
2879
- name: z.string(),
2880
- description: z.string().optional(),
2881
- disposition: z.string().optional(),
2882
- location: z.string().optional(),
2883
- goals: z.string().optional(),
2944
+ name: z.string().describe("The NPC name."),
2945
+ description: z.string().optional().describe("Optional description."),
2946
+ disposition: z.string().optional().describe("Optional disposition."),
2947
+ location: z.string().optional().describe("Optional location."),
2948
+ goals: z.string().optional().describe("Optional goals."),
2884
2949
  // REQ-119 — optional ruleset stat-block reference.
2885
- ruleset_reference: z.string().optional(),
2950
+ ruleset_reference: z.string().optional().describe("Optional ruleset stat-block reference."),
2886
2951
  },
2887
2952
  }, async ({ name, description, disposition, location, goals, ruleset_reference }) => {
2888
2953
  requireGM();
@@ -2932,17 +2997,10 @@ server.registerTool("create_npc", {
2932
2997
  });
2933
2998
  server.registerTool("update_npc", {
2934
2999
  title: "Update NPC",
2935
- description: "Update an existing NPC's fields. Game Master only.",
3000
+ description: "Update an existing NPC's name, description, disposition, location, or goals, persisting the change (Game Master only). Use when: revising a non-player character. Do NOT use when: creating an NPC — use create_npc.",
2936
3001
  // REQ-123 — builder-defined NPC stat fields: fields are builder-determined
2937
3002
  // from the ruleset's stat conventions; every field optional except name.
2938
- inputSchema: {
2939
- npc_id: z.string(),
2940
- name: z.string().optional(),
2941
- description: z.string().optional(),
2942
- disposition: z.string().optional(),
2943
- location: z.string().optional(),
2944
- goals: z.string().optional(),
2945
- },
3003
+ inputSchema: { npc_id: z.string().describe("The NPC identifier."), name: z.string().optional().describe("Optional new name."), description: z.string().optional().describe("Optional description."), disposition: z.string().optional().describe("Optional disposition."), location: z.string().optional().describe("Optional location."), goals: z.string().optional().describe("Optional goals.") },
2946
3004
  }, async ({ npc_id, name, description, disposition, location, goals }) => {
2947
3005
  requireGM();
2948
3006
  const novel = requireNovel();
@@ -2966,8 +3024,8 @@ server.registerTool("update_npc", {
2966
3024
  });
2967
3025
  server.registerTool("remove_npc", {
2968
3026
  title: "Remove NPC",
2969
- description: "Remove an NPC from the novel. Game Master only.",
2970
- inputSchema: { npc_id: z.string() },
3027
+ description: "Remove an NPC from the Novel, persisting the removal (Game Master only). Use when: a non-player character leaves the story. Do NOT use when: removing a player entity — use remove_entity.",
3028
+ inputSchema: { npc_id: z.string().describe("The NPC to remove.") },
2971
3029
  }, async ({ npc_id }) => {
2972
3030
  requireGM();
2973
3031
  const novel = requireNovel();
@@ -2982,26 +3040,26 @@ server.registerTool("remove_npc", {
2982
3040
  // --- Countdowns (GM) ---
2983
3041
  server.registerTool("set_countdown", {
2984
3042
  title: "Set Countdown",
2985
- description: "Set a countdown timer. Game Master only.",
3043
+ description: "Set a countdown timer with ticks, type, scope, direction, and optional world-model triggers, persisting it (Game Master only). Use when: starting a clock or timer. Do NOT use when: advancing a clock — use advance_countdown.",
2986
3044
  inputSchema: {
2987
- name: z.string(),
2988
- ticks: z.number().min(1),
2989
- type: z.enum(["round", "narrative"]).optional(),
2990
- scope: z.string().optional(),
2991
- direction: z.string().optional(),
2992
- on_scene_transition: z.boolean().optional(),
3045
+ name: z.string().describe("The countdown name."),
3046
+ ticks: z.number().min(1).describe("Starting number of ticks (minimum 1)."),
3047
+ type: z.enum(["round", "narrative"]).optional().describe("round or narrative."),
3048
+ scope: z.string().optional().describe("Optional scope name."),
3049
+ direction: z.string().optional().describe("Optional direction: increment or decrement."),
3050
+ on_scene_transition: z.boolean().optional().describe("When true, advance on each scene transition."),
2993
3051
  // REQ-329 — world-model coupling triggers (on_room_enter/on_thing_take/
2994
3052
  // on_door_open). Any match advances one tick; supplements normal advancement.
2995
- triggers: z.array(z.string()).optional(),
3053
+ triggers: z.array(z.string()).optional().describe("Optional world-model triggers (on_room_enter/on_thing_take/on_door_open)."),
2996
3054
  // REQ-368 — world-model effect coupling applied when the countdown fires.
2997
3055
  world_effect: z.object({
2998
- type: z.enum(["describe", "property", "exit"]),
2999
- target: z.string(),
3000
- direction: z.string().optional(),
3001
- destination: z.string().optional(),
3002
- property: z.string().optional(),
3003
- value: z.string().optional(),
3004
- }).optional(),
3056
+ type: z.enum(["describe", "property", "exit"]).describe("The effect kind."),
3057
+ target: z.string().describe("The effect target."),
3058
+ direction: z.string().optional().describe("Optional direction (for exits)."),
3059
+ destination: z.string().optional().describe("Optional destination (for exits)."),
3060
+ property: z.string().optional().describe("Optional property (for property effects)."),
3061
+ value: z.string().optional().describe("Optional value (for property effects)."),
3062
+ }).optional().describe("Optional world-model effect applied when the countdown fires."),
3005
3063
  },
3006
3064
  }, async ({ name, ticks, type, scope, direction, on_scene_transition, triggers, world_effect }) => {
3007
3065
  requireGM();
@@ -3015,8 +3073,8 @@ server.registerTool("set_countdown", {
3015
3073
  });
3016
3074
  server.registerTool("advance_countdown", {
3017
3075
  title: "Advance Countdown",
3018
- description: "Advance a countdown timer by one tick. Game Master only.",
3019
- inputSchema: { name: z.string() },
3076
+ description: "Advance a countdown timer by one tick, firing it when it reaches its end (Game Master only). Use when: the fiction moves a clock forward. Do NOT use when: creating or removing a clock — use set_countdown or remove_countdown.",
3077
+ inputSchema: { name: z.string().describe("The countdown to advance.") },
3020
3078
  }, async ({ name }) => {
3021
3079
  requireGM();
3022
3080
  const novel = requireNovel();
@@ -3056,8 +3114,8 @@ server.registerTool("advance_countdown", {
3056
3114
  });
3057
3115
  server.registerTool("remove_countdown", {
3058
3116
  title: "Remove Countdown",
3059
- description: "Remove a countdown timer. Game Master only.",
3060
- inputSchema: { name: z.string() },
3117
+ description: "Remove a countdown timer, persisting the removal (Game Master only). Use when: a clock is no longer relevant. Do NOT use when: advancing a clock — use advance_countdown.",
3118
+ inputSchema: { name: z.string().describe("The countdown to remove.") },
3061
3119
  }, async ({ name }) => {
3062
3120
  requireGM();
3063
3121
  const novel = requireNovel();
@@ -3072,17 +3130,17 @@ server.registerTool("remove_countdown", {
3072
3130
  // --- Lore (GM) ---
3073
3131
  server.registerTool("set_lore_entry", {
3074
3132
  title: "Set Lore Entry",
3075
- description: "Log a lore entry for the current novel. Game Master only.",
3133
+ description: "Create a lore entry with content, triggers, badge scope, priority, and group for the active Novel (Game Master only). Use when: recording world facts the narrator should recall. Do NOT use when: recording a story beat — use record_story.",
3076
3134
  inputSchema: {
3077
- key: z.string(),
3078
- content: z.string(),
3079
- triggers: z.array(z.string()).optional(),
3080
- badge_scope: z.enum(["game_master", "shared"]).optional(),
3081
- priority: z.number().optional(),
3082
- sticky: z.number().optional(),
3083
- group: z.string().optional(),
3135
+ key: z.string().describe("The lore key."),
3136
+ content: z.string().describe("The lore content."),
3137
+ triggers: z.array(z.string()).optional().describe("Optional recall triggers."),
3138
+ badge_scope: z.enum(["game_master", "shared"]).optional().describe("game_master or shared."),
3139
+ priority: z.number().optional().describe("Optional priority."),
3140
+ sticky: z.number().optional().describe("Optional sticky weight."),
3141
+ group: z.string().optional().describe("Optional group name."),
3084
3142
  // REQ-328 — lore-world coupling: world-model target (room/thing/exit ref).
3085
- world_target: z.string().optional(),
3143
+ world_target: z.string().optional().describe("Optional world-model target reference."),
3086
3144
  },
3087
3145
  }, async ({ key, content, triggers, badge_scope, priority, sticky, group, world_target }) => {
3088
3146
  requireGM();
@@ -3107,16 +3165,8 @@ server.registerTool("set_lore_entry", {
3107
3165
  });
3108
3166
  server.registerTool("update_lore_entry", {
3109
3167
  title: "Update Lore Entry",
3110
- description: "Update fields of an existing lore entry. Game Master only.",
3111
- inputSchema: {
3112
- key: z.string(),
3113
- content: z.string().optional(),
3114
- triggers: z.array(z.string()).optional(),
3115
- badge_scope: z.enum(["game_master", "shared"]).optional(),
3116
- priority: z.number().optional(),
3117
- sticky: z.number().optional(),
3118
- group: z.string().nullable().optional(),
3119
- },
3168
+ description: "Update fields of an existing lore entry, persisting the change (Game Master only). Use when: revising a world fact. Do NOT use when: creating a lore entry — use set_lore_entry.",
3169
+ inputSchema: { key: z.string().describe("The lore key to update."), content: z.string().optional().describe("Optional new content."), triggers: z.array(z.string()).optional().describe("Optional recall triggers."), badge_scope: z.enum(["game_master", "shared"]).optional().describe("game_master or shared."), priority: z.number().optional().describe("Optional priority."), sticky: z.number().optional().describe("Optional sticky weight."), group: z.string().nullable().optional().describe("Optional group name, or null to clear.") },
3120
3170
  }, async ({ key, content, triggers, badge_scope, priority, sticky, group }) => {
3121
3171
  requireGM();
3122
3172
  const novel = requireNovel();
@@ -3147,8 +3197,8 @@ server.registerTool("update_lore_entry", {
3147
3197
  });
3148
3198
  server.registerTool("remove_lore_entry", {
3149
3199
  title: "Remove Lore Entry",
3150
- description: "Remove a lore entry. Game Master only.",
3151
- inputSchema: { key: z.string() },
3200
+ description: "Remove a lore entry from the Novel, persisting the removal (Game Master only). Use when: a world fact is obsolete. Do NOT use when: hiding a lore entry without deleting it — use toggle_lore_entry.",
3201
+ inputSchema: { key: z.string().describe("The lore key to remove.") },
3152
3202
  }, async ({ key }) => {
3153
3203
  requireGM();
3154
3204
  const novel = requireNovel();
@@ -3161,8 +3211,8 @@ server.registerTool("remove_lore_entry", {
3161
3211
  });
3162
3212
  server.registerTool("toggle_lore_entry", {
3163
3213
  title: "Toggle Lore Entry",
3164
- description: "Enable or disable a lore entry. Game Master only.",
3165
- inputSchema: { key: z.string() },
3214
+ description: "Enable or disable a lore entry without deleting it, persisting the toggle (Game Master only). Use when: temporarily hiding a world fact. Do NOT use when: deleting a lore entry — use remove_lore_entry.",
3215
+ inputSchema: { key: z.string().describe("The lore key to toggle.") },
3166
3216
  }, async ({ key }) => {
3167
3217
  requireGM();
3168
3218
  const novel = requireNovel();
@@ -3176,8 +3226,8 @@ server.registerTool("toggle_lore_entry", {
3176
3226
  });
3177
3227
  server.registerTool("set_lore_group", {
3178
3228
  title: "Set Lore Group",
3179
- description: "Assign or remove a lore entry from a named group. Game Master only.",
3180
- inputSchema: { key: z.string(), group: z.string().nullable() },
3229
+ description: "Assign or remove a lore entry from a named group, persisting the grouping (Game Master only). Use when: organizing lore by theme or faction. Do NOT use when: creating a lore entry — use set_lore_entry.",
3230
+ inputSchema: { key: z.string().describe("The lore key."), group: z.string().nullable().describe("The group name, or null to remove from its group.") },
3181
3231
  }, async ({ key, group }) => {
3182
3232
  requireGM();
3183
3233
  const novel = requireNovel();
@@ -3194,7 +3244,7 @@ server.registerTool("set_lore_group", {
3194
3244
  });
3195
3245
  server.registerTool("suggest_lore", {
3196
3246
  title: "Suggest Lore",
3197
- description: "Suggest lore entries from enrichment templates based on current scene. Game Master only.",
3247
+ description: "Suggest lore entries from enrichment templates based on the current scene, without persisting them (Game Master only). Use when: the GM wants candidate world facts to adopt. Do NOT use when: creating a lore entry directly — use set_lore_entry.",
3198
3248
  inputSchema: {},
3199
3249
  }, async () => {
3200
3250
  requireGM();
@@ -3207,12 +3257,17 @@ server.registerTool("suggest_lore", {
3207
3257
  });
3208
3258
  server.registerTool("export_lorebook", {
3209
3259
  title: "Export Lorebook",
3210
- description: "Export novel lore entries in interchange format. Game Master only.",
3260
+ description: "Export the Novel's lore entries in interchange format for backup or transfer (Game Master only). Use when: moving lore between Novels. Do NOT use when: importing lore — use import_lorebook.",
3211
3261
  // REQ-094 — lorebook interchange: lore-only export/import with merge, replace,
3212
3262
  // and dry-run modes; round-trip preserves lore metadata (Appendix L).
3213
- inputSchema: { format: z.enum(["json", "markdown"]).optional() },
3263
+ // REQ-425b interchange surfaces accept only json/markdown; html is a
3264
+ // presentation-only format and returns [INVALID_INPUT] enumerating the set.
3265
+ inputSchema: { format: z.string().optional().describe("Optional output format.") },
3214
3266
  }, async ({ format: fmt }) => {
3215
3267
  requireGM();
3268
+ if (fmt && !INTERCHANGE_FORMATS.includes(fmt)) {
3269
+ return err("INVALID_INPUT", `Unsupported format '${fmt}'. Supported formats: ${INTERCHANGE_FORMATS.join(", ")}.`);
3270
+ }
3216
3271
  const novel = requireNovel();
3217
3272
  const entries = [...novel.lore.values()];
3218
3273
  if (fmt === "markdown") {
@@ -3230,11 +3285,8 @@ server.registerTool("export_lorebook", {
3230
3285
  });
3231
3286
  server.registerTool("import_lorebook", {
3232
3287
  title: "Import Lorebook",
3233
- description: "Import lore entries from JSON or Markdown. Modes: dry-run, merge, or replace. Game Master only.",
3234
- inputSchema: {
3235
- data: z.string(),
3236
- mode: z.enum(["dry-run", "merge", "replace"]).optional(),
3237
- },
3288
+ description: "Import lore entries from JSON or Markdown in dry-run, merge, or replace mode (Game Master only). Use when: loading a lorebook. Do NOT use when: exporting lore — use export_lorebook.",
3289
+ inputSchema: { data: z.string().describe("JSON or Markdown lorebook data."), mode: z.enum(["dry-run", "merge", "replace"]).optional().describe("dry-run, merge, or replace.") },
3238
3290
  }, async ({ data, mode }) => {
3239
3291
  requireGM();
3240
3292
  const novel = requireNovel();
@@ -3280,8 +3332,8 @@ function conditionCatalogue(novel) {
3280
3332
  }
3281
3333
  server.registerTool("apply_condition", {
3282
3334
  title: "Apply Condition",
3283
- description: "Apply a condition to an entity.",
3284
- inputSchema: { entity_id: z.string(), condition: z.string(), rounds: z.number().optional() },
3335
+ description: "Apply a condition to an entity, persisting it to the Novel. Use when: an entity gains a mechanical or narrative status. Do NOT use when: clearing a condition — use remove_condition.",
3336
+ inputSchema: { entity_id: z.string().describe("The entity to affect."), condition: z.string().describe("The condition name."), rounds: z.number().optional().describe("Optional duration in rounds.") },
3285
3337
  }, async ({ entity_id, condition, rounds }) => {
3286
3338
  requireGM();
3287
3339
  const novel = requireNovel();
@@ -3310,8 +3362,8 @@ server.registerTool("apply_condition", {
3310
3362
  });
3311
3363
  server.registerTool("remove_condition", {
3312
3364
  title: "Remove Condition",
3313
- description: "Remove a condition from an entity.",
3314
- inputSchema: { entity_id: z.string(), condition: z.string() },
3365
+ description: "Remove a condition from an entity, persisting the change. Use when: a status ends or is cured. Do NOT use when: applying a condition — use apply_condition.",
3366
+ inputSchema: { entity_id: z.string().describe("The entity to affect."), condition: z.string().describe("The condition to remove.") },
3315
3367
  }, async ({ entity_id, condition }) => {
3316
3368
  requireGM();
3317
3369
  const novel = requireNovel();
@@ -3331,8 +3383,8 @@ server.registerTool("remove_condition", {
3331
3383
  // --- Factions (GM) ---
3332
3384
  server.registerTool("create_faction", {
3333
3385
  title: "Create Faction",
3334
- description: "Create a named faction with goals, resources, and a progress clock. Game Master only.",
3335
- inputSchema: { name: z.string(), description: z.string().optional(), goals: z.array(z.string()).optional(), resources: z.string().optional(), territory: z.array(z.string()).optional() },
3386
+ description: "Create a named faction with goals, resources, and a progress clock, persisting it (Game Master only). Use when: introducing an organization. Do NOT use when: editing a faction — use update_faction.",
3387
+ inputSchema: { name: z.string().describe("The faction name."), description: z.string().optional().describe("Optional description."), goals: z.array(z.string()).optional().describe("Optional goals."), resources: z.string().optional().describe("Optional resources."), territory: z.array(z.string()).optional().describe("Optional territory names.") },
3336
3388
  }, async ({ name, description, goals, resources, territory }) => {
3337
3389
  requireGM();
3338
3390
  const novel = requireNovel();
@@ -3350,8 +3402,8 @@ server.registerTool("create_faction", {
3350
3402
  });
3351
3403
  server.registerTool("update_faction", {
3352
3404
  title: "Update Faction",
3353
- description: "Update a faction's fields. Game Master only.",
3354
- inputSchema: { faction_id: z.string(), description: z.string().optional(), goals: z.array(z.string()).optional(), resources: z.string().optional(), territory: z.array(z.string()).optional() },
3405
+ description: "Update a faction's fields, persisting the change (Game Master only). Use when: revising an organization. Do NOT use when: creating a faction — use create_faction.",
3406
+ inputSchema: { faction_id: z.string().describe("The faction identifier."), description: z.string().optional().describe("Optional description."), goals: z.array(z.string()).optional().describe("Optional goals."), resources: z.string().optional().describe("Optional resources."), territory: z.array(z.string()).optional().describe("Optional territory names.") },
3355
3407
  }, async ({ faction_id, ...fields }) => {
3356
3408
  requireGM();
3357
3409
  const novel = requireNovel();
@@ -3364,8 +3416,8 @@ server.registerTool("update_faction", {
3364
3416
  });
3365
3417
  server.registerTool("remove_faction", {
3366
3418
  title: "Remove Faction",
3367
- description: "Remove a faction and its clock. Game Master only.",
3368
- inputSchema: { faction_id: z.string() },
3419
+ description: "Remove a faction and its progress clock, persisting the removal (Game Master only). Use when: an organization leaves the story. Do NOT use when: editing a faction — use update_faction.",
3420
+ inputSchema: { faction_id: z.string().describe("The faction to remove.") },
3369
3421
  }, async ({ faction_id }) => {
3370
3422
  requireGM();
3371
3423
  const novel = requireNovel();
@@ -3381,8 +3433,8 @@ server.registerTool("remove_faction", {
3381
3433
  // --- Secrets (GM) ---
3382
3434
  server.registerTool("set_secret", {
3383
3435
  title: "Set Secret",
3384
- description: "Create a secret lore entry. GM-only; visible to entities after reveal_secret. Game Master only.",
3385
- inputSchema: { key: z.string(), content: z.string(), triggers: z.array(z.string()).optional(), badge_scope: z.enum(["game_master", "shared"]).optional(), world_target: z.string().optional() },
3436
+ description: "Create a GM-only secret lore entry that becomes visible to an entity only after reveal_secret (Game Master only). Use when: recording hidden information. Do NOT use when: creating public lore — use set_lore_entry.",
3437
+ inputSchema: { key: z.string().describe("The secret key."), content: z.string().describe("The secret content."), triggers: z.array(z.string()).optional().describe("Optional recall triggers."), badge_scope: z.enum(["game_master", "shared"]).optional().describe("game_master or shared."), world_target: z.string().optional().describe("Optional world-model target.") },
3386
3438
  }, async ({ key, content, triggers, badge_scope, world_target }) => {
3387
3439
  requireGM();
3388
3440
  const novel = requireNovel();
@@ -3394,8 +3446,8 @@ server.registerTool("set_secret", {
3394
3446
  });
3395
3447
  server.registerTool("reveal_secret", {
3396
3448
  title: "Reveal Secret",
3397
- description: "Make a secret known to a specific entity. Game Master only.",
3398
- inputSchema: { key: z.string(), entity_id: z.string() },
3449
+ description: "Reveal a secret to a specific entity, persisting that entity's knowledge (Game Master only). Use when: a character learns hidden information. Do NOT use when: creating a secret — use set_secret.",
3450
+ inputSchema: { key: z.string().describe("The secret to reveal."), entity_id: z.string().describe("The entity to reveal it to.") },
3399
3451
  }, async ({ key, entity_id }) => {
3400
3452
  requireGM();
3401
3453
  const novel = requireNovel();
@@ -3411,8 +3463,8 @@ server.registerTool("reveal_secret", {
3411
3463
  });
3412
3464
  server.registerTool("get_knowledge", {
3413
3465
  title: "Get Knowledge",
3414
- description: "Return what secrets an entity knows. Game Master only.",
3415
- inputSchema: { entity_id: z.string(), key: z.string().optional() },
3466
+ description: "Return which secrets an entity currently knows (Game Master only). Use when: checking what a character has learned. Do NOT use when: revealing a secret — use reveal_secret.",
3467
+ inputSchema: { entity_id: z.string().describe("The entity whose knowledge to read."), key: z.string().optional().describe("Optional secret key filter.") },
3416
3468
  }, async ({ entity_id, key }) => {
3417
3469
  requireGM();
3418
3470
  const novel = requireNovel();
@@ -3428,8 +3480,8 @@ server.registerTool("get_knowledge", {
3428
3480
  // --- Relationships (GM) ---
3429
3481
  server.registerTool("set_relationship", {
3430
3482
  title: "Set Relationship",
3431
- description: "Set a directed relationship between entities, NPCs, or factions. Types: ally, rival, neutral, mentor, dependent, suspicious. Game Master only.",
3432
- inputSchema: { entity_a: z.string(), entity_b: z.string(), type: z.enum(["ally", "rival", "neutral", "mentor", "dependent", "suspicious"]), value: z.number().optional(), description: z.string().optional() },
3483
+ description: "Set a directed relationship (ally, rival, neutral, mentor, dependent, suspicious) between entities, NPCs, or factions, persisting it (Game Master only). Use when: recording how two parties relate. Do NOT use when: listing relationships — use get_relationships.",
3484
+ inputSchema: { entity_a: z.string().describe("The source entity."), entity_b: z.string().describe("The target entity."), type: z.enum(["ally", "rival", "neutral", "mentor", "dependent", "suspicious"]).describe("Relationship type."), value: z.number().optional().describe("Optional relationship strength."), description: z.string().optional().describe("Optional description.") },
3433
3485
  }, async ({ entity_a, entity_b, type, value, description }) => {
3434
3486
  requireGM();
3435
3487
  const novel = requireNovel();
@@ -3439,8 +3491,8 @@ server.registerTool("set_relationship", {
3439
3491
  });
3440
3492
  server.registerTool("get_relationships", {
3441
3493
  title: "Get Relationships",
3442
- description: "Return all relationships (incoming and outgoing) for an entity. Game Master only.",
3443
- inputSchema: { entity_id: z.string() },
3494
+ description: "Return all incoming and outgoing relationships for an entity (Game Master only). Use when: reviewing how a character is connected. Do NOT use when: setting a relationship — use set_relationship.",
3495
+ inputSchema: { entity_id: z.string().describe("The entity whose relationships to list.") },
3444
3496
  }, async ({ entity_id }) => {
3445
3497
  requireGM();
3446
3498
  const novel = requireNovel();
@@ -3451,8 +3503,8 @@ server.registerTool("get_relationships", {
3451
3503
  // --- Vows (GM) ---
3452
3504
  server.registerTool("set_vow", {
3453
3505
  title: "Set Vow",
3454
- description: "Track a narrative vow, quest, or obligation. Game Master only.",
3455
- inputSchema: { name: z.string(), description: z.string(), parties: z.array(z.string()), difficulty: z.enum(["troublesome", "dangerous", "formidable", "extreme", "epic"]), scope: z.enum(["gm", "shared", "faction", "party"]).optional() },
3506
+ description: "Track a narrative vow, quest, or obligation with milestones, persisting it (Game Master only). Use when: the story commits a character to a goal. Do NOT use when: advancing a vow — use mark_milestone.",
3507
+ inputSchema: { name: z.string().describe("The vow name."), description: z.string().describe("The vow description."), parties: z.array(z.string()).describe("Parties bound by the vow."), difficulty: z.enum(["troublesome", "dangerous", "formidable", "extreme", "epic"]).describe("troublesome, dangerous, formidable, extreme, or epic."), scope: z.enum(["gm", "shared", "faction", "party"]).optional().describe("gm, shared, faction, or party.") },
3456
3508
  }, async ({ name, description, parties, difficulty, scope }) => {
3457
3509
  requireGM();
3458
3510
  const novel = requireNovel();
@@ -3479,8 +3531,8 @@ server.registerTool("set_vow", {
3479
3531
  });
3480
3532
  server.registerTool("mark_milestone", {
3481
3533
  title: "Mark Milestone",
3482
- description: "Advance a vow's progress by one milestone. Game Master only.",
3483
- inputSchema: { vow_name: z.string() },
3534
+ description: "Advance a vow's progress by one milestone, persisting it (Game Master only). Use when: the character makes progress. Do NOT use when: closing a vow — use resolve_vow.",
3535
+ inputSchema: { vow_name: z.string().describe("The vow to advance.") },
3484
3536
  }, async ({ vow_name }) => {
3485
3537
  requireGM();
3486
3538
  const novel = requireNovel();
@@ -3504,8 +3556,8 @@ server.registerTool("mark_milestone", {
3504
3556
  });
3505
3557
  server.registerTool("resolve_vow", {
3506
3558
  title: "Resolve Vow",
3507
- description: "Close a completed vow with outcome and consequences. Game Master only.",
3508
- inputSchema: { vow_name: z.string(), outcome: z.string(), consequences: z.string().optional() },
3559
+ description: "Close a completed vow with an outcome and consequences, persisting it (Game Master only). Use when: the goal is achieved. Do NOT use when: abandoning a vow — use forsake_vow.",
3560
+ inputSchema: { vow_name: z.string().describe("The vow to close."), outcome: z.string().describe("The resolution outcome."), consequences: z.string().optional().describe("Optional consequences.") },
3509
3561
  }, async ({ vow_name, outcome, consequences }) => {
3510
3562
  requireGM();
3511
3563
  const novel = requireNovel();
@@ -3520,8 +3572,8 @@ server.registerTool("resolve_vow", {
3520
3572
  });
3521
3573
  server.registerTool("forsake_vow", {
3522
3574
  title: "Forsake Vow",
3523
- description: "Abandon a vow with a reason. Game Master only.",
3524
- inputSchema: { vow_name: z.string(), reason: z.string() },
3575
+ description: "Abandon a vow with a reason, persisting the abandonment (Game Master only). Use when: the character gives up. Do NOT use when: completing a vow — use resolve_vow.",
3576
+ inputSchema: { vow_name: z.string().describe("The vow to abandon."), reason: z.string().describe("The reason for abandoning.") },
3525
3577
  }, async ({ vow_name, reason }) => {
3526
3578
  requireGM();
3527
3579
  const novel = requireNovel();
@@ -3538,8 +3590,8 @@ server.registerTool("forsake_vow", {
3538
3590
  // immutable → [RULE_VIOLATION]; existing key → [STATE_CONFLICT]).
3539
3591
  server.registerTool("promote_story_to_lore", {
3540
3592
  title: "Promote Story to Lore",
3541
- description: "Promote a story journal entry into a lore entry. Game Master only.",
3542
- inputSchema: { index: z.number(), key: z.string().optional() },
3593
+ description: "Promote a story journal entry into a lore entry, persisting it (Game Master only). Use when: a story moment becomes a durable world fact. Do NOT use when: recording a new story beat — use record_story.",
3594
+ inputSchema: { index: z.number().describe("The story journal index to promote."), key: z.string().optional().describe("Optional lore key.") },
3543
3595
  }, async ({ index, key }) => {
3544
3596
  requireGM();
3545
3597
  const novel = requireNovel();
@@ -3568,8 +3620,8 @@ server.registerTool("promote_story_to_lore", {
3568
3620
  // --- Story Journal (GM) ---
3569
3621
  server.registerTool("record_story", {
3570
3622
  title: "Record Story",
3571
- description: "Record a narrative memory in the story journal. Types: decision, moment, revelation, bond, consequence. Game Master only.",
3572
- inputSchema: { type: z.enum(["decision", "moment", "revelation", "bond", "consequence"]), entry: z.string() },
3623
+ description: "Record a narrative memory (decision, moment, revelation, bond, or consequence) in the story journal (Game Master only). Use when: capturing a story beat. Do NOT use when: recording a durable world fact — use set_lore_entry.",
3624
+ inputSchema: { type: z.enum(["decision", "moment", "revelation", "bond", "consequence"]).describe("The story entry type."), entry: z.string().describe("The story entry text.") },
3573
3625
  }, async ({ type, entry }) => {
3574
3626
  requireGM();
3575
3627
  const novel = requireNovel();
@@ -3593,8 +3645,8 @@ server.registerTool("record_story", {
3593
3645
  });
3594
3646
  server.registerTool("update_story", {
3595
3647
  title: "Update Story",
3596
- description: "Edit a story journal entry by index. Decision and consequence entries are immutable. Game Master only.",
3597
- inputSchema: { index: z.number().min(0), type: z.enum(["decision", "moment", "revelation", "bond", "consequence"]).optional(), entry: z.string().optional() },
3648
+ description: "Edit a story journal entry by index; decision and consequence entries are immutable (Game Master only). Use when: correcting a recorded memory. Do NOT use when: deleting an entry — use remove_story.",
3649
+ inputSchema: { index: z.number().min(0).describe("The story entry index."), type: z.enum(["decision", "moment", "revelation", "bond", "consequence"]).optional().describe("Optional new type."), entry: z.string().optional().describe("Optional new entry text.") },
3598
3650
  }, async ({ index, type, entry }) => {
3599
3651
  requireGM();
3600
3652
  const novel = requireNovel();
@@ -3612,8 +3664,8 @@ server.registerTool("update_story", {
3612
3664
  });
3613
3665
  server.registerTool("remove_story", {
3614
3666
  title: "Remove Story",
3615
- description: "Delete a story journal entry by index. Game Master only.",
3616
- inputSchema: { index: z.number().min(0) },
3667
+ description: "Delete a story journal entry by index, persisting the removal (Game Master only). Use when: removing a mistaken memory. Do NOT use when: editing an entry — use update_story.",
3668
+ inputSchema: { index: z.number().min(0).describe("The story entry index to remove.") },
3617
3669
  }, async ({ index }) => {
3618
3670
  requireGM();
3619
3671
  const novel = requireNovel();
@@ -3625,8 +3677,8 @@ server.registerTool("remove_story", {
3625
3677
  });
3626
3678
  server.registerTool("list_stories", {
3627
3679
  title: "List Stories",
3628
- description: "List story journal entries with optional type filter and pagination. Game Master only.",
3629
- inputSchema: { filter: z.enum(["decision", "moment", "revelation", "bond", "consequence"]).optional(), offset: z.number().min(0).optional(), limit: z.number().min(0).optional(), ...detailZod },
3680
+ description: "List story journal entries with an optional type filter and pagination (Game Master only). Use when: reviewing the story so far. Do NOT use when: recording a new entry — use record_story.",
3681
+ inputSchema: { filter: z.enum(["decision", "moment", "revelation", "bond", "consequence"]).optional().describe("Optional type filter."), offset: z.number().min(0).optional().describe("Optional pagination offset."), limit: z.number().min(0).optional().describe("Optional page size."), ...detailZod },
3630
3682
  }, async ({ filter, offset, limit, detail }) => {
3631
3683
  requireGM();
3632
3684
  const novel = requireNovel();
@@ -3644,8 +3696,8 @@ server.registerTool("list_stories", {
3644
3696
  // --- Notes ---
3645
3697
  server.registerTool("set_note", {
3646
3698
  title: "Set Note",
3647
- description: "Create or update a key-value note. Badge-scoped: game_master (default), player, or shared.",
3648
- inputSchema: { key: z.string(), content: z.string(), badge_scope: z.enum(["game_master", "player", "shared"]).optional() },
3699
+ description: "Create or update a key-value note, badge-scoped to game_master (default), player, or shared. Use when: storing scratch state the caller will reuse. Do NOT use when: recording durable world facts — use set_lore_entry.",
3700
+ inputSchema: { key: z.string().describe("The note key."), content: z.string().describe("The note content."), badge_scope: z.enum(["game_master", "player", "shared"]).optional().describe("game_master, player, or shared.") },
3649
3701
  }, async ({ key, content, badge_scope }) => {
3650
3702
  requireNotObserver();
3651
3703
  const novel = requireNovel();
@@ -3671,8 +3723,8 @@ server.registerTool("set_note", {
3671
3723
  });
3672
3724
  server.registerTool("remove_note", {
3673
3725
  title: "Remove Note",
3674
- description: "Remove a note by key. Badge-scoped: caller's badge must own the scope.",
3675
- inputSchema: { key: z.string() },
3726
+ description: "Remove a note by key; the caller's badge must own the note's scope. Use when: discarding scratch state. Do NOT use when: creating a note — use set_note.",
3727
+ inputSchema: { key: z.string().describe("The note key to remove.") },
3676
3728
  }, async ({ key }) => {
3677
3729
  requireNotObserver();
3678
3730
  const novel = requireNovel();
@@ -3689,7 +3741,7 @@ server.registerTool("remove_note", {
3689
3741
  });
3690
3742
  server.registerTool("list_notes", {
3691
3743
  title: "List Notes",
3692
- description: "List all notes (100-character preview), badge-filtered.",
3744
+ description: "List all notes with a 100-character preview, badge-filtered to the caller's scope. Use when: reviewing stored scratch state. Do NOT use when: setting a note — use set_note.",
3693
3745
  inputSchema: {},
3694
3746
  }, async () => {
3695
3747
  const novel = requireNovel();
@@ -3701,8 +3753,8 @@ server.registerTool("list_notes", {
3701
3753
  // --- Server Notes (GM) ---
3702
3754
  server.registerTool("set_server_note", {
3703
3755
  title: "Set Server Note",
3704
- description: "Create or update a server-level note. Game Master only.",
3705
- inputSchema: { key: z.string(), content: z.string(), narrative_tag: z.enum(["campaign_bible", "house_rules", "lore_seed", "session_reminder"]).optional() },
3756
+ description: "Create or update a server-level note that survives Novels and rebuilds (Game Master only). Use when: storing cross-Novel scratch state. Do NOT use when: storing Novel-scoped notes — use set_note.",
3757
+ inputSchema: { key: z.string().describe("The note key."), content: z.string().describe("The note content."), narrative_tag: z.enum(["campaign_bible", "house_rules", "lore_seed", "session_reminder"]).optional().describe("Optional narrative tag: campaign_bible, house_rules, lore_seed, or session_reminder.") },
3706
3758
  }, async ({ key, content, narrative_tag }) => {
3707
3759
  requireGM();
3708
3760
  state.serverNotes.set(key, { content, narrative_tag });
@@ -3711,8 +3763,8 @@ server.registerTool("set_server_note", {
3711
3763
  });
3712
3764
  server.registerTool("remove_server_note", {
3713
3765
  title: "Remove Server Note",
3714
- description: "Remove a server-level note. Game Master only.",
3715
- inputSchema: { key: z.string() },
3766
+ description: "Remove a server-level note, persisting the removal (Game Master only). Use when: discarding cross-Novel scratch state. Do NOT use when: creating a server note — use set_server_note.",
3767
+ inputSchema: { key: z.string().describe("The server note key to remove.") },
3716
3768
  }, async ({ key }) => {
3717
3769
  requireGM();
3718
3770
  if (!state.serverNotes.has(key))
@@ -3723,7 +3775,7 @@ server.registerTool("remove_server_note", {
3723
3775
  });
3724
3776
  server.registerTool("list_server_notes", {
3725
3777
  title: "List Server Notes",
3726
- description: "List all server-level notes. Game Master only.",
3778
+ description: "List all server-level notes (Game Master only). Use when: reviewing cross-Novel scratch state. Do NOT use when: listing Novel-scoped notes — use list_notes.",
3727
3779
  inputSchema: {},
3728
3780
  }, async () => {
3729
3781
  requireGM();
@@ -3733,8 +3785,8 @@ server.registerTool("list_server_notes", {
3733
3785
  // --- Pause/Resume (GM) ---
3734
3786
  server.registerTool("set_pause_context", {
3735
3787
  title: "Set Pause Context",
3736
- description: "Save GM context for session resumption. Game Master only.",
3737
- inputSchema: { current_scene: z.string().optional(), immediate_situation: z.string().optional(), pending_player_action: z.string().optional(), short_term_plans: z.string().optional(), long_term_plans: z.string().optional(), player_goals: z.string().optional() },
3788
+ description: "Save GM context for session resumption, persisting it (Game Master only). Use when: ending a session with notes for the next. Do NOT use when: reading saved context — use get_pause_context.",
3789
+ inputSchema: { current_scene: z.string().optional().describe("Optional current-scene summary."), immediate_situation: z.string().optional().describe("Optional immediate situation."), pending_player_action: z.string().optional().describe("Optional pending player action."), short_term_plans: z.string().optional().describe("Optional short-term plans."), long_term_plans: z.string().optional().describe("Optional long-term plans."), player_goals: z.string().optional().describe("Optional player goals.") },
3738
3790
  }, async (fields) => {
3739
3791
  requireGM();
3740
3792
  const novel = requireNovel();
@@ -3757,7 +3809,7 @@ server.registerTool("set_pause_context", {
3757
3809
  });
3758
3810
  server.registerTool("get_pause_context", {
3759
3811
  title: "Get Pause Context",
3760
- description: "Return the saved GM context plus Novel state summary for session resumption.",
3812
+ description: "Return the saved GM context plus a Novel state summary for session resumption. Use when: resuming after a break. Do NOT use when: saving context — use set_pause_context.",
3761
3813
  inputSchema: {},
3762
3814
  }, async () => {
3763
3815
  const novel = requireNovel();
@@ -3773,8 +3825,8 @@ server.registerTool("get_pause_context", {
3773
3825
  // --- Checkpoints (GM) ---
3774
3826
  server.registerTool("set_checkpoint", {
3775
3827
  title: "Set Checkpoint",
3776
- description: "Save a named checkpoint of the full Novel state. Game Master only.",
3777
- inputSchema: { label: z.string() },
3828
+ description: "Save a named checkpoint of the full Novel state, persisting it (Game Master only). Use when: marking a returnable point in the story. Do NOT use when: restoring a checkpoint — use restore_checkpoint.",
3829
+ inputSchema: { label: z.string().describe("The checkpoint label.") },
3778
3830
  }, async ({ label }) => {
3779
3831
  requireGM();
3780
3832
  const novel = requireNovel();
@@ -3784,7 +3836,7 @@ server.registerTool("set_checkpoint", {
3784
3836
  });
3785
3837
  server.registerTool("list_checkpoints", {
3786
3838
  title: "List Checkpoints",
3787
- description: "List all checkpoints for the active Novel. Game Master only.",
3839
+ description: "List all checkpoints for the active Novel (Game Master only). Use when: reviewing available return points. Do NOT use when: creating a checkpoint — use set_checkpoint.",
3788
3840
  inputSchema: {},
3789
3841
  }, async () => {
3790
3842
  requireGM();
@@ -3793,8 +3845,8 @@ server.registerTool("list_checkpoints", {
3793
3845
  });
3794
3846
  server.registerTool("restore_checkpoint", {
3795
3847
  title: "Restore Checkpoint",
3796
- description: "Restore a checkpoint (confirmation required). Game Master only.",
3797
- inputSchema: { label: z.string() },
3848
+ description: "Restore a checkpoint after a confirmation workflow, replacing the Novel state (Game Master only). Use when: returning to a prior point. Do NOT use when: saving a checkpoint — use set_checkpoint.",
3849
+ inputSchema: { label: z.string().describe("The checkpoint to restore.") },
3798
3850
  }, async ({ label }) => {
3799
3851
  requireGM();
3800
3852
  const novel = requireNovel();
@@ -3815,8 +3867,8 @@ Options: yes, cancel`);
3815
3867
  });
3816
3868
  server.registerTool("remove_checkpoint", {
3817
3869
  title: "Remove Checkpoint",
3818
- description: "Remove a named checkpoint. Game Master only.",
3819
- inputSchema: { label: z.string() },
3870
+ description: "Remove a named checkpoint, persisting the removal (Game Master only). Use when: discarding a return point. Do NOT use when: restoring a checkpoint — use restore_checkpoint.",
3871
+ inputSchema: { label: z.string().describe("The checkpoint to remove.") },
3820
3872
  }, async ({ label }) => {
3821
3873
  requireGM();
3822
3874
  const novel = requireNovel();
@@ -3830,8 +3882,8 @@ server.registerTool("remove_checkpoint", {
3830
3882
  // --- Novel Lifecycle additions (GM) ---
3831
3883
  server.registerTool("rename_novel", {
3832
3884
  title: "Rename Novel",
3833
- description: "Rename the active Novel on disk. Game Master only.",
3834
- inputSchema: { new_slug: z.string() },
3885
+ description: "Rename the active Novel on disk, persisting the change (Game Master only). Use when: correcting the Novel's name. Do NOT use when: switching to another Novel — use switch_novel.",
3886
+ inputSchema: { new_slug: z.string().describe("The new Novel slug.") },
3835
3887
  }, async ({ new_slug }) => {
3836
3888
  requireGM();
3837
3889
  const novel = requireNovel();
@@ -3851,8 +3903,8 @@ server.registerTool("rename_novel", {
3851
3903
  // description, surfaced in novel_info and badge_briefing.
3852
3904
  server.registerTool("update_novel_description", {
3853
3905
  title: "Update Novel Description",
3854
- description: "Set or replace the active Novel's description. An empty string clears it. Game Master only.",
3855
- inputSchema: { description: z.string() },
3906
+ description: "Set or replace the active Novel's description; an empty string clears it (Game Master only). Use when: summarizing the campaign premise. Do NOT use when: setting the genre — use set_genre.",
3907
+ inputSchema: { description: z.string().describe("The new Novel description; empty clears it.") },
3856
3908
  }, async ({ description }) => {
3857
3909
  requireGM();
3858
3910
  const novel = requireNovel();
@@ -3863,8 +3915,8 @@ server.registerTool("update_novel_description", {
3863
3915
  });
3864
3916
  server.registerTool("list_novels", {
3865
3917
  title: "List Novels",
3866
- description: "List all Novels on disk with metadata. Always callable.",
3867
- inputSchema: { ...detailZod, filter: z.enum(["active", "archived", "all"]).optional() },
3918
+ description: "List all Novels on disk with metadata, always callable. Use when: discovering available save files. Do NOT use when: inspecting one Novel — use novel_info.",
3919
+ inputSchema: { ...detailZod, filter: z.enum(["active", "archived", "all"]).optional().describe("Optional filter: active, archived, or all.") },
3868
3920
  }, async ({ detail, filter }) => {
3869
3921
  const arch = state.archivedNovels();
3870
3922
  const archivedSlugs = new Set(arch.map((a) => a.slug));
@@ -3883,8 +3935,8 @@ server.registerTool("list_novels", {
3883
3935
  // REQ-334 — archive/unarchive Novel (Game Master only).
3884
3936
  server.registerTool("archive_novel", {
3885
3937
  title: "Archive Novel",
3886
- description: "Move a Novel to the long-term archive (read-only). Game Master only.",
3887
- inputSchema: { slug: z.string() },
3938
+ description: "Move a Novel to the long-term read-only archive (Game Master only). Use when: retiring a finished campaign. Do NOT use when: restoring an archived Novel — use unarchive_novel.",
3939
+ inputSchema: { slug: z.string().describe("The Novel slug to archive.") },
3888
3940
  }, async ({ slug }) => {
3889
3941
  requireGM();
3890
3942
  if (state.activeNovelId === slug)
@@ -3895,8 +3947,8 @@ server.registerTool("archive_novel", {
3895
3947
  });
3896
3948
  server.registerTool("unarchive_novel", {
3897
3949
  title: "Unarchive Novel",
3898
- description: "Restore an archived Novel to active status. Game Master only.",
3899
- inputSchema: { slug: z.string() },
3950
+ description: "Restore an archived Novel to active status (Game Master only). Use when: reviving a retired campaign. Do NOT use when: archiving a Novel — use archive_novel.",
3951
+ inputSchema: { slug: z.string().describe("The Novel slug to restore.") },
3900
3952
  }, async ({ slug }) => {
3901
3953
  requireGM();
3902
3954
  const novel = state.unarchiveNovel(slug);
@@ -3905,8 +3957,8 @@ server.registerTool("unarchive_novel", {
3905
3957
  });
3906
3958
  server.registerTool("novel_info", {
3907
3959
  title: "Novel Info",
3908
- description: "Return extended metadata for a Novel. Always callable.",
3909
- inputSchema: { slug: z.string().optional() },
3960
+ description: "Return extended metadata for a Novel, always callable. Use when: inspecting a Novel's settings and stats. Do NOT use when: listing all Novels — use list_novels.",
3961
+ inputSchema: { slug: z.string().optional().describe("Optional Novel slug; defaults to the active Novel.") },
3910
3962
  }, async ({ slug }) => {
3911
3963
  const novel = slug ? state.novels.get(slug) : state.activeNovel;
3912
3964
  if (!novel)
@@ -3926,8 +3978,8 @@ server.registerTool("novel_info", {
3926
3978
  const GENRE_CATALOG = ["noir", "high_fantasy", "sword_and_sorcery", "sci_fi_horror", "cosmic_horror", "historical", "western", "modern", "cyberpunk"];
3927
3979
  server.registerTool("set_genre", {
3928
3980
  title: "Set Genre",
3929
- description: "Set the active Novel's genre tag. Game Master only. Valid: noir, high_fantasy, sword_and_sorcery, sci_fi_horror, cosmic_horror, historical, western, modern, cyberpunk.",
3930
- inputSchema: { genre: z.string() },
3981
+ description: "Set the active Novel's genre tag from the fixed catalog (noir, high_fantasy, sword_and_sorcery, sci_fi_horror, cosmic_horror, historical, western, modern, cyberpunk) (Game Master only). Use when: declaring the campaign's genre. Do NOT use when: setting the Novel description — use update_novel_description.",
3982
+ inputSchema: { genre: z.string().describe("The genre tag from the fixed catalog.") },
3931
3983
  }, async ({ genre }) => {
3932
3984
  requireGM();
3933
3985
  const novel = requireNovel();
@@ -3941,8 +3993,8 @@ server.registerTool("set_genre", {
3941
3993
  });
3942
3994
  server.registerTool("clone_novel", {
3943
3995
  title: "Clone Novel",
3944
- description: "Create an independent copy of a Novel. Game Master only.",
3945
- inputSchema: { source_slug: z.string(), new_name: z.string(), trim_audit_sessions: z.number().min(0).optional() },
3996
+ description: "Create an independent copy of a Novel, persisting the copy (Game Master only). Use when: branching the story or testing an alternative. Do NOT use when: renaming a Novel — use rename_novel.",
3997
+ inputSchema: { source_slug: z.string().describe("The Novel to copy."), new_name: z.string().describe("The name for the copy."), trim_audit_sessions: z.number().min(0).optional().describe("Optional number of recent audit sessions to keep.") },
3946
3998
  }, async ({ source_slug, new_name }) => {
3947
3999
  requireGM();
3948
4000
  const source = state.novels.get(source_slug);
@@ -3962,11 +4014,11 @@ server.registerTool("clone_novel", {
3962
4014
  // --- Entity Management ---
3963
4015
  server.registerTool("remove_entity", {
3964
4016
  title: "Remove Entity",
3965
- description: "Remove an entity from the active Novel. Game Master only.",
4017
+ description: "Remove an entity from the active Novel, persisting the removal (Game Master only). Use when: a character permanently leaves. Do NOT use when: removing an NPC — use remove_npc.",
3966
4018
  // REQ-176 — entity removal: clears the active-entity field when the removed
3967
4019
  // entity was active; party://current excludes removed entities; roster
3968
4020
  // baseline unaffected (re-import creates a fresh copy). Player → [FORBIDDEN].
3969
- inputSchema: { entity_id: z.string() },
4021
+ inputSchema: { entity_id: z.string().describe("The entity to remove.") },
3970
4022
  }, async ({ entity_id }) => {
3971
4023
  requireGM();
3972
4024
  const novel = requireNovel();
@@ -3980,11 +4032,11 @@ server.registerTool("remove_entity", {
3980
4032
  });
3981
4033
  server.registerTool("remove_roster_character", {
3982
4034
  title: "Remove Roster Character",
3983
- description: "Remove a character from the roster. Game Master only.",
4035
+ description: "Remove a character from the roster, persisting the removal (Game Master only). Use when: discarding a staged character. Do NOT use when: listing roster characters — use list_roster_characters.",
3984
4036
  // REQ-177 — roster entity removal: removes from roster:// only; Novel copies
3985
4037
  // survive independently; Player → [FORBIDDEN]; absent → [NOT_FOUND] with
3986
4038
  // valid roster IDs enumerated.
3987
- inputSchema: { roster_id: z.string() },
4039
+ inputSchema: { roster_id: z.string().describe("The roster character to remove.") },
3988
4040
  }, async ({ roster_id }) => {
3989
4041
  requireGM();
3990
4042
  if (!state.roster.has(roster_id))
@@ -3995,7 +4047,7 @@ server.registerTool("remove_roster_character", {
3995
4047
  });
3996
4048
  server.registerTool("list_roster_characters", {
3997
4049
  title: "List Roster Characters",
3998
- description: "List all characters in the roster.",
4050
+ description: "List all characters in the persistent roster. Use when: discovering staged characters to import. Do NOT use when: importing a roster character — use import_character.",
3999
4051
  // REQ-178 — roster listing: any-badge, structured (id/name), empty-state
4000
4052
  // marker when the roster is empty; novel_setup sources its list from here.
4001
4053
  inputSchema: {},
@@ -4008,7 +4060,7 @@ server.registerTool("list_roster_characters", {
4008
4060
  // --- Special tools ---
4009
4061
  server.registerTool("toggle_action_patterns", {
4010
4062
  title: "Toggle Action Patterns",
4011
- description: "Toggle enrich-derived action patterns on or off for the active Novel. Game Master only.",
4063
+ description: "Toggle enrich-derived action patterns on or off for the active Novel (Game Master only). Use when: enabling or disabling suggested actions. Do NOT use when: setting autonomy — use set_autonomy.",
4012
4064
  // REQ-115 — action pattern activation: flips the Novel-scoped boolean; when
4013
4065
  // enabled, synthesis patterns supplement suggest_actions; pure-resolution.
4014
4066
  inputSchema: {},
@@ -4021,13 +4073,8 @@ server.registerTool("toggle_action_patterns", {
4021
4073
  });
4022
4074
  server.registerTool("present_choices", {
4023
4075
  title: "Present Choices",
4024
- description: "Present structured choice prompts to the player. Resolved via respond. Game Master only.",
4025
- inputSchema: {
4026
- prompt: z.string(),
4027
- choices: z.array(z.object({ id: z.string(), label: z.string(), description: z.string().optional() })),
4028
- allow_freeform: z.boolean().optional(),
4029
- context: z.record(z.string(), z.any()).optional(),
4030
- },
4076
+ description: "Present structured choice prompts to the player, resolved later via respond (Game Master only). Use when: offering the player a decision. Do NOT use when: resolving an open decision — use respond.",
4077
+ inputSchema: { prompt: z.string().describe("The choice prompt."), choices: z.array(z.object({ id: z.string(), label: z.string(), description: z.string().optional() })).describe("The list of choices."), allow_freeform: z.boolean().optional().describe("When true, allow a free-form response."), context: z.record(z.string(), z.any()).optional().describe("Optional context for the choice.") },
4031
4078
  }, async ({ prompt, choices, allow_freeform, context }) => {
4032
4079
  requireGM();
4033
4080
  const novel = requireNovel();
@@ -4046,8 +4093,8 @@ server.registerTool("present_choices", {
4046
4093
  });
4047
4094
  server.registerTool("ask_oracle", {
4048
4095
  title: "Ask Oracle",
4049
- description: "Resolve uncertainty with a d100 roll against the Ask-the-Oracle ladder: almost_certain (≥11), likely (≥26), 50_50 (≥51), unlikely (≥76), small_chance (≥91). Defaults to 50_50 when likelihood is omitted. Callable by Player and Game Master.",
4050
- inputSchema: { question: z.string(), likelihood: z.enum(["almost_certain", "likely", "50_50", "unlikely", "small_chance"]).optional(), seed: z.string().optional() },
4096
+ description: "Resolve uncertainty with a deterministic d100 roll against the Ask-the-Oracle ladder (almost_certain, likely, 50_50, unlikely, small_chance), callable by Player and Game Master. Use when: the rules or fiction leave an outcome open. Do NOT use when: rolling on a fixed table — use roll_on_table.",
4097
+ inputSchema: { question: z.string().describe("The question to resolve."), likelihood: z.enum(["almost_certain", "likely", "50_50", "unlikely", "small_chance"]).optional().describe("almost_certain, likely, 50_50, unlikely, or small_chance."), seed: z.string().optional().describe("Optional deterministic seed.") },
4051
4098
  }, async ({ question, likelihood, seed }) => {
4052
4099
  requireNotObserver();
4053
4100
  const novel = requireNovel();
@@ -4187,18 +4234,18 @@ function normalizeSceneTypeState(raw) {
4187
4234
  // --- Guidance (GM) ---
4188
4235
  server.registerTool("set_verbosity", {
4189
4236
  title: "Set Output Verbosity",
4190
- description: "Set the session output verbosity mode: normal (full entries) or terse (minimum mechanical content). Callable by both badges.",
4237
+ description: "Set the session output verbosity to normal (full entries) or terse (minimum mechanical content), callable by both badges. Use when: the caller wants shorter or fuller replies. Do NOT use when: setting briefing section order — use set_briefing_order.",
4191
4238
  // REQ-253 — tool-output verbosity control: session-scoped mode, discarded on
4192
4239
  // connection close; reported in spec_health.
4193
- inputSchema: { mode: z.enum(["normal", "terse"]) },
4240
+ inputSchema: { mode: z.enum(["normal", "terse"]).describe("normal or terse.") },
4194
4241
  }, async ({ mode }) => {
4195
4242
  outputVerbosity = mode;
4196
4243
  return ok(`Output verbosity set to '${mode}'.`);
4197
4244
  });
4198
4245
  server.registerTool("set_briefing_order", {
4199
4246
  title: "Set Briefing Order",
4200
- description: "Reorder sections of badge_briefing. Game Master only.",
4201
- inputSchema: { sections: z.array(z.string()) },
4247
+ description: "Reorder sections of badge_briefing, persisting the order (Game Master only). Use when: customizing what the briefing emphasizes. Do NOT use when: setting verbosity — use set_verbosity.",
4248
+ inputSchema: { sections: z.array(z.string()).describe("The ordered list of briefing sections.") },
4202
4249
  }, async ({ sections }) => {
4203
4250
  requireGM();
4204
4251
  const novel = requireNovel();
@@ -4217,11 +4264,11 @@ server.registerTool("set_briefing_order", {
4217
4264
  });
4218
4265
  server.registerTool("compress_audit", {
4219
4266
  title: "Compress Audit Log",
4220
- description: "Return a Markdown prompt compressing recent audit entries. Callable by both badges.",
4267
+ description: "Return a Markdown prompt compressing recent audit entries, callable by both badges. Use when: the caller needs a compact history. Do NOT use when: permanently compacting the audit log — use compact_audit_log.",
4221
4268
  // REQ-086 — audit compression: header line + one line per entry in
4222
4269
  // `[timestamp] [badge] tool_name — output_prefix` (or [BOUNDARY_VIOLATION]);
4223
4270
  // pure-generation, badge-filtered; max_entries ≤ 0 → [INVALID_INPUT].
4224
- inputSchema: { max_entries: z.number().optional() },
4271
+ inputSchema: { max_entries: z.number().optional().describe("Optional maximum number of entries.") },
4225
4272
  }, async ({ max_entries }) => {
4226
4273
  const novel = requireNovel();
4227
4274
  const max = max_entries ?? 20;
@@ -4242,8 +4289,8 @@ server.registerTool("compress_audit", {
4242
4289
  // compact_audit_log: legacy alias for compress_audit (REQ-086).
4243
4290
  server.registerTool("compact_audit_log", {
4244
4291
  title: "Compact Audit Log",
4245
- description: "Alias for compress_audit.",
4246
- inputSchema: { max_entries: z.number().optional() },
4292
+ description: "Alias for compress_audit: permanently compacts the audit log. Use when: reducing stored audit size. Do NOT use when: generating a compression prompt — use compress_audit.",
4293
+ inputSchema: { max_entries: z.number().optional().describe("Optional maximum number of entries.") },
4247
4294
  }, async ({ max_entries }) => {
4248
4295
  const novel = requireNovel();
4249
4296
  const max = max_entries ?? 20;
@@ -4280,14 +4327,11 @@ function assessGenerationGuard(input) {
4280
4327
  // (default otherwise), or both. No Novel is required for the codex target.
4281
4328
  server.registerTool("generate_adventure", {
4282
4329
  title: "Generate Adventure",
4283
- description: "Generate an adventure scaffold from a premise. Game Master only.",
4330
+ description: "Generate an adventure scaffold from a premise, targeting the Novel, the codex, or both (Game Master only). Use when: the GM wants a new adventure outline. Do NOT use when: generating a single scene — use generate_encounter.",
4284
4331
  // REQ-132 — adventure generation lifecycle: transient Novel-scoped artifact
4285
4332
  // surfaced at adventure://generated/<anchor>, replaced on regeneration,
4286
4333
  // discarded by end_novel, never persisted to TTRPG_ADVENTURE.
4287
- inputSchema: {
4288
- premise: z.string(),
4289
- target: z.enum(["novel", "codex", "both"]).optional(),
4290
- },
4334
+ inputSchema: { premise: z.string().describe("The adventure premise."), target: z.enum(["novel", "codex", "both"]).optional().describe("novel, codex, or both.") },
4291
4335
  }, async ({ premise, target }) => {
4292
4336
  requireGM();
4293
4337
  const novel = state.activeNovel;
@@ -4383,8 +4427,8 @@ server.registerTool("generate_adventure", {
4383
4427
  // undo target. Player badge → [FORBIDDEN].
4384
4428
  server.registerTool("generate_encounter", {
4385
4429
  title: "Generate Encounter",
4386
- description: "Generate a scene + NPC + lore entry from context. Game Master only.",
4387
- inputSchema: { context: z.string() },
4430
+ description: "Generate a scene, NPC, and lore entry from context (Game Master only). Use when: the GM wants an encounter on demand. Do NOT use when: generating a full adventure — use generate_adventure.",
4431
+ inputSchema: { context: z.string().describe("The scene context to generate from.") },
4388
4432
  }, async ({ context }) => {
4389
4433
  requireGM();
4390
4434
  const novel = requireNovel();
@@ -4448,8 +4492,8 @@ server.registerTool("generate_encounter", {
4448
4492
  });
4449
4493
  server.registerTool("load_adventure", {
4450
4494
  title: "Load Adventure",
4451
- description: "Load an adventure module. Game Master only.",
4452
- inputSchema: { slug: z.string() },
4495
+ description: "Load an adventure module, persisting it as the active adventure (Game Master only). Use when: starting a prepared adventure. Do NOT use when: listing adventures — use list_adventures.",
4496
+ inputSchema: { slug: z.string().describe("The adventure module slug to load.") },
4453
4497
  }, async ({ slug }) => {
4454
4498
  requireGM();
4455
4499
  const novel = requireNovel();
@@ -4508,8 +4552,8 @@ server.registerTool("load_adventure", {
4508
4552
  // empty-state when no modules; badge-filtered.
4509
4553
  server.registerTool("list_adventures", {
4510
4554
  title: "List Adventures",
4511
- description: "List adventure modules with metadata. Always callable.",
4512
- inputSchema: { filter: z.string().optional() },
4555
+ description: "List adventure modules with metadata, always callable. Use when: discovering available adventures. Do NOT use when: loading an adventure — use load_adventure.",
4556
+ inputSchema: { filter: z.string().optional().describe("Optional filter.") },
4513
4557
  }, async ({ filter }) => {
4514
4558
  const adventureDir = process.env.TTRPG_ADVENTURE_DIR ?? path.join(__dirname, "..", "adventures");
4515
4559
  let files = [];
@@ -4599,7 +4643,7 @@ server.registerResource("adventure-navigation", new ResourceTemplate("adventure:
4599
4643
  // REQ-072 — session_recap summarizes recent session activity.
4600
4644
  server.registerTool("session_recap", {
4601
4645
  title: "Session Recap",
4602
- description: "Summarize recent session activity.",
4646
+ description: "Summarize recent session activity into a recap. Use when: reviewing what happened this session. Do NOT use when: reading the story journal in detail — use list_stories.",
4603
4647
  inputSchema: {},
4604
4648
  }, async () => {
4605
4649
  const novel = requireNovel();
@@ -4685,8 +4729,8 @@ server.registerTool("session_recap", {
4685
4729
  // --- Novel Lifecycle ---
4686
4730
  server.registerTool("create_novel", {
4687
4731
  title: "Create Novel",
4688
- description: "Create a named novel. Novel persists to disk.",
4689
- inputSchema: { name: z.string(), ruleset: z.string().optional(), genre: z.string().optional(), description: z.string().optional(), codex_adventure: z.string().optional() },
4732
+ description: "Create a named Novel that persists to disk. Use when: starting a new campaign. Do NOT use when: resuming an existing Novel — use resume_novel.",
4733
+ inputSchema: { name: z.string().describe("The Novel name."), ruleset: z.string().optional().describe("Optional ruleset slug."), genre: z.string().optional().describe("Optional genre."), description: z.string().optional().describe("Optional description."), codex_adventure: z.string().optional().describe("Optional codex adventure to seed from.") },
4690
4734
  }, async ({ name, ruleset, genre, description, codex_adventure }) => {
4691
4735
  requireNotObserver();
4692
4736
  if (ruleset && !rulesets.isInstalled(ruleset)) {
@@ -4731,8 +4775,8 @@ Next step: run the novel_setup guide to add characters, choose a story source, a
4731
4775
  });
4732
4776
  server.registerTool("resume_novel", {
4733
4777
  title: "Resume Novel",
4734
- description: "Resume a previously created novel from disk.",
4735
- inputSchema: { slug: z.string() },
4778
+ description: "Resume a previously created Novel from disk, making it the active Novel. Use when: continuing an existing campaign. Do NOT use when: creating a new Novel — use create_novel.",
4779
+ inputSchema: { slug: z.string().describe("The Novel slug to resume.") },
4736
4780
  }, async ({ slug }) => {
4737
4781
  // REQ-402 — resuming closes the prior session window; a window with zero
4738
4782
  // state writes is surfaced as [session-no-mutations].
@@ -4758,8 +4802,8 @@ server.registerTool("resume_novel", {
4758
4802
  });
4759
4803
  server.registerTool("switch_novel", {
4760
4804
  title: "Switch Novel",
4761
- description: "Switch the active novel for this connection. Always callable.",
4762
- inputSchema: { slug: z.string() },
4805
+ description: "Switch the active Novel for this connection, always callable. Use when: changing which save file the session works on. Do NOT use when: ending a Novel — use end_novel.",
4806
+ inputSchema: { slug: z.string().describe("The Novel slug to switch to.") },
4763
4807
  }, async ({ slug }) => {
4764
4808
  // REQ-403b — TTRPG_STATE_GATE=block refuses to leave a drifting Novel.
4765
4809
  const active = state.activeNovel;
@@ -4787,7 +4831,7 @@ server.registerTool("switch_novel", {
4787
4831
  });
4788
4832
  server.registerTool("end_novel", {
4789
4833
  title: "End Novel",
4790
- description: "End the current novel. Deactivates badge, removes save file.",
4834
+ description: "End the current Novel, deactivating the badge and removing the save file after a confirmation workflow. Use when: concluding a campaign. Do NOT use when: merely switching Novels — use switch_novel.",
4791
4835
  inputSchema: {},
4792
4836
  }, async () => {
4793
4837
  requireNotObserver();
@@ -4807,10 +4851,15 @@ Options: yes, cancel`);
4807
4851
  });
4808
4852
  server.registerTool("export_novel", {
4809
4853
  title: "Export Novel",
4810
- description: "Export the active novel in interchange format. Game Master only.",
4811
- inputSchema: { format: z.enum(["json", "markdown"]).optional(), scope: z.string().optional() },
4854
+ description: "Export the active Novel in interchange format for backup or transfer (Game Master only). Use when: moving a Novel between servers. Do NOT use when: importing a Novel — use import_novel.",
4855
+ inputSchema: { format: z.string().optional().describe("Optional output format."), scope: z.string().optional().describe("Optional export scope.") },
4812
4856
  }, async ({ format: fmt, scope }) => {
4813
4857
  requireGM();
4858
+ // REQ-425b — interchange surfaces accept only json/markdown; html returns
4859
+ // [INVALID_INPUT] enumerating the set (presentation-only, not round-trippable).
4860
+ if (fmt && !INTERCHANGE_FORMATS.includes(fmt)) {
4861
+ return err("INVALID_INPUT", `Unsupported format '${fmt}'. Supported formats: ${INTERCHANGE_FORMATS.join(", ")}.`);
4862
+ }
4814
4863
  const novel = requireNovel();
4815
4864
  if (fmt === "markdown") {
4816
4865
  let md = `# ${novel.name}\n\n`;
@@ -4896,12 +4945,8 @@ server.registerTool("export_novel", {
4896
4945
  });
4897
4946
  server.registerTool("import_novel", {
4898
4947
  title: "Import Novel",
4899
- description: "Import a previously exported novel. Game Master only.",
4900
- inputSchema: {
4901
- data: z.string(),
4902
- mode: z.enum(["dry-run", "merge", "replace"]).optional(),
4903
- strict: z.boolean().optional(),
4904
- },
4948
+ description: "Import a previously exported Novel in dry-run, merge, or replace mode (Game Master only). Use when: loading a Novel from interchange format. Do NOT use when: exporting a Novel — use export_novel.",
4949
+ inputSchema: { data: z.string().describe("The exported Novel JSON."), mode: z.enum(["dry-run", "merge", "replace"]).optional().describe("dry-run, merge, or replace."), strict: z.boolean().optional().describe("When true, fail on any cross-reference mismatch.") },
4905
4950
  }, async ({ data, mode, strict }) => {
4906
4951
  requireGM();
4907
4952
  const m = mode ?? "dry-run";
@@ -5012,7 +5057,7 @@ server.registerTool("import_novel", {
5012
5057
  // --- Enrichment ---
5013
5058
  server.registerTool("revert_synthesis", {
5014
5059
  title: "Revert Synthesis",
5015
- description: "Remove all synthesis state, restoring pre-synthesis server state. Game Master only.",
5060
+ description: "Remove all synthesis state, restoring pre-synthesis server state (Game Master only). Use when: discarding generated synthesis content wholesale. Do NOT use when: deactivating a single item — use deactivate_synthesis_item.",
5016
5061
  inputSchema: {},
5017
5062
  }, async () => {
5018
5063
  requireGM();
@@ -5025,8 +5070,8 @@ server.registerTool("revert_synthesis", {
5025
5070
  // --- Anchor-only tools (ruleset-free, REQ-218) ---
5026
5071
  server.registerTool("search_rules", {
5027
5072
  title: "Search Rules",
5028
- description: "Search the active ruleset's index for matching terms. Empty when no ruleset is bound.",
5029
- inputSchema: { query: z.string(), max_results: z.number().optional() },
5073
+ description: "Search the active ruleset's index for matching terms, returning empty when no ruleset is bound. Use when: looking up a rule, item, or concept in the ruleset. Do NOT use when: the Novel is ruleset-free — use suggest_actions or spec_health.",
5074
+ inputSchema: { query: z.string().describe("The search query."), max_results: z.number().optional().describe("Optional maximum number of results.") },
5030
5075
  }, async ({ query, max_results }) => {
5031
5076
  const novel = state.activeNovel;
5032
5077
  const slug = novel?.ruleset ?? null;
@@ -5047,16 +5092,8 @@ server.registerTool("search_rules", {
5047
5092
  });
5048
5093
  server.registerTool("install_ruleset", {
5049
5094
  title: "Install Ruleset",
5050
- description: "Install a ruleset package from a files bundle. Game Master or Editor only.",
5051
- inputSchema: {
5052
- slug: z.string(),
5053
- manifest: z.any(),
5054
- index: z.any().optional(),
5055
- model: z.any().optional(),
5056
- tools: z.any().optional(),
5057
- resources: z.any().optional(),
5058
- prompts: z.any().optional(),
5059
- },
5095
+ description: "Install a ruleset package from a files bundle, persisting it to the install directory (Game Master or Editor only). Use when: adding a ruleset to the host. Do NOT use when: removing a ruleset — use remove_ruleset.",
5096
+ inputSchema: { slug: z.string().describe("The ruleset slug."), manifest: z.any().describe("The package manifest."), index: z.any().optional().describe("Optional search index."), model: z.any().optional().describe("Optional extraction model."), tools: z.any().optional().describe("Optional tool schemas."), resources: z.any().optional().describe("Optional resources."), prompts: z.any().optional().describe("Optional prompts.") },
5060
5097
  }, async (args) => {
5061
5098
  requireGM();
5062
5099
  try {
@@ -5076,8 +5113,8 @@ server.registerTool("install_ruleset", {
5076
5113
  });
5077
5114
  server.registerTool("remove_ruleset", {
5078
5115
  title: "Remove Ruleset",
5079
- description: "Remove an installed ruleset package. Game Master or Editor only.",
5080
- inputSchema: { slug: z.string() },
5116
+ description: "Remove an installed ruleset package, deregistering its tools, resources, and prompts (Game Master or Editor only). Use when: uninstalling a ruleset. Do NOT use when: listing rulesets — use list_rulesets.",
5117
+ inputSchema: { slug: z.string().describe("The ruleset slug to remove.") },
5081
5118
  }, async ({ slug }) => {
5082
5119
  requireGM();
5083
5120
  const novel = state.activeNovel;
@@ -5094,7 +5131,7 @@ server.registerTool("remove_ruleset", {
5094
5131
  });
5095
5132
  server.registerTool("list_rulesets", {
5096
5133
  title: "List Rulesets",
5097
- description: "List installed ruleset packages with loaded-versus-installed state.",
5134
+ description: "List installed ruleset packages with loaded-versus-installed state. Use when: reviewing which rulesets the host knows. Do NOT use when: installing a ruleset — use install_ruleset.",
5098
5135
  // REQ-391 — scoped tool listing: default surface is active Novel's ruleset
5099
5136
  // tools plus infrastructure; list_rulesets exposes per-package state without
5100
5137
  // forcing hydration of inactive packages (REQ-390 lazy hydration).
@@ -5111,8 +5148,8 @@ server.registerTool("list_rulesets", {
5111
5148
  });
5112
5149
  server.registerTool("bind_novel_ruleset", {
5113
5150
  title: "Bind Novel Ruleset",
5114
- description: "Bind the active ruleset-free Novel to an installed ruleset. Game Master or Editor only; one-way and audited.",
5115
- inputSchema: { slug: z.string() },
5151
+ description: "Bind the active ruleset-free Novel to an installed ruleset, a one-way, audited migration (Game Master or Editor only). Use when: adding mechanical rules to a freeform Novel. Do NOT use when: removing a ruleset — use remove_ruleset.",
5152
+ inputSchema: { slug: z.string().describe("The installed ruleset slug to bind.") },
5116
5153
  }, async ({ slug }) => {
5117
5154
  requireGM();
5118
5155
  if (!rulesets.isInstalled(slug)) {
@@ -5129,8 +5166,8 @@ server.registerTool("bind_novel_ruleset", {
5129
5166
  });
5130
5167
  server.registerTool("suggest_actions", {
5131
5168
  title: "Suggest Actions",
5132
- description: "Map player intent to world-model tool invocations. No mechanical suggestions in ruleset-free mode.",
5133
- inputSchema: { intent: z.string(), entity_id: z.string().optional() },
5169
+ description: "Map player intent to world-model tool invocations, omitting mechanical suggestions in ruleset-free mode. Use when: translating natural-language intent into concrete tool calls. Do NOT use when: resolving a spatial action directly — use command or resolve_intent.",
5170
+ inputSchema: { intent: z.string().describe("The player intent to map to tool calls."), entity_id: z.string().optional().describe("Optional entity context.") },
5134
5171
  }, async ({ intent, entity_id }) => {
5135
5172
  const novel = requireNovel();
5136
5173
  const entity = entity_id ? novel.entities.get(entity_id) : state.getActiveEntity();
@@ -5227,7 +5264,7 @@ const REQ022_URI_CATALOG = [
5227
5264
  // REQ-025 — spec_health reports build health, indexed counts, and URI completeness.
5228
5265
  server.registerTool("spec_health", {
5229
5266
  title: "Spec Health",
5230
- description: "Report build health, indexed counts, and resource URI completeness.",
5267
+ description: "Report build health, indexed counts, and resource URI completeness derived from live registrations. Use when: diagnosing server or ruleset state. Do NOT use when: searching ruleset content — use search_rules.",
5231
5268
  inputSchema: {},
5232
5269
  }, async () => {
5233
5270
  const novel = state.activeNovel;
@@ -5287,6 +5324,17 @@ server.registerTool("spec_health", {
5287
5324
  resource_count: (server._registeredResources ? Object.keys(server._registeredResources).length : 0),
5288
5325
  resource_uris, // REQ-139 — resource URI presence from the live resource map.
5289
5326
  prompt_health, // REQ-138 — per-prompt presence, length, budget, stale refs.
5327
+ // REQ-425d — the output format catalog (universal + surface sets + any
5328
+ // ruleset-declared formats) and whether the MCP Apps extension (REQ-426c)
5329
+ // is negotiated by the current client.
5330
+ output_formats: {
5331
+ universal: [...UNIVERSAL_FORMATS],
5332
+ statblock: [...STATBLOCK_FORMATS],
5333
+ session: [...SESSION_FORMATS],
5334
+ interchange: [...INTERCHANGE_FORMATS],
5335
+ declared: [...declaredFormats],
5336
+ },
5337
+ mcp_apps: { negotiated: appsNegotiated() },
5290
5338
  confidence: { overall: "N/A — ruleset-free", per_file: {}, per_category: {} },
5291
5339
  indexed_counts: {
5292
5340
  anchors: rulesets.installedSlugs().reduce((n, s) => n + (rulesets.hydrate(s)?.index.length ?? 0), 0),
@@ -5496,13 +5544,22 @@ server.registerResource("npc-single", new ResourceTemplate("npc://{id}", { list:
5496
5544
  return { resources: [...novel.npcs.keys()].map(id => ({ uri: `npc://${id}`, name: id })) };
5497
5545
  } }), { title: "NPC Record" }, async (uri) => {
5498
5546
  const novel = state.activeNovel;
5499
- const id = uri.href.split("/").pop() ?? "";
5547
+ const id = resourceKey(uri);
5500
5548
  if (!novel)
5501
5549
  return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "no active novel" }), mimeType: "application/json" }] };
5502
5550
  const npc = novel.npcs.get(id);
5503
5551
  if (!npc)
5504
5552
  return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "not found" }), mimeType: "application/json" }] };
5505
- return { contents: [{ uri: uri.href, text: JSON.stringify(npc), mimeType: "application/json" }] };
5553
+ // REQ-425a/c the NPC stat block honors the output format catalog; the
5554
+ // markdown render is byte-identical to character_sheet(npc_id) via the same
5555
+ // fmtEntitySheet renderer.
5556
+ const fmt = resourceFormat(uri);
5557
+ if (fmt === "json")
5558
+ return { contents: [{ uri: uri.href, text: JSON.stringify(npc, null, 2), mimeType: "application/json" }] };
5559
+ const md = fmtEntitySheet(npc);
5560
+ if (fmt === "html")
5561
+ return { contents: [{ uri: uri.href, text: toHtml(md), mimeType: "text/html" }] };
5562
+ return { contents: [{ uri: uri.href, text: md, mimeType: "text/markdown" }] };
5506
5563
  });
5507
5564
  server.registerResource("npcs", "npcs://", { title: "All NPCs" }, async () => {
5508
5565
  // REQ-121 — NPC resource URIs: npcs:// lists all active NPCs with summary
@@ -5554,13 +5611,21 @@ server.registerResource("lore-single", new ResourceTemplate("lore://{key}", { li
5554
5611
  return { resources: [...novel.lore.keys()].map(k => ({ uri: `lore://${k}`, name: k })) };
5555
5612
  } }), { title: "Lore Entry" }, async (uri) => {
5556
5613
  const novel = state.activeNovel;
5557
- const key = uri.href.split("/").pop() ?? "";
5614
+ const key = resourceKey(uri);
5558
5615
  if (!novel)
5559
5616
  return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "no active novel" }), mimeType: "application/json" }] };
5560
5617
  const entry = novel.lore.get(key);
5561
5618
  if (!entry)
5562
5619
  return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "not found" }), mimeType: "application/json" }] };
5563
- return { contents: [{ uri: uri.href, text: JSON.stringify(entry), mimeType: "application/json" }] };
5620
+ // REQ-425a/c the lore entry honors the output format catalog (markdown
5621
+ // default, json, html).
5622
+ const fmt = resourceFormat(uri);
5623
+ if (fmt === "json")
5624
+ return { contents: [{ uri: uri.href, text: JSON.stringify(entry, null, 2), mimeType: "application/json" }] };
5625
+ const md = `## ${entry.key}\n\n${entry.content}`;
5626
+ if (fmt === "html")
5627
+ return { contents: [{ uri: uri.href, text: toHtml(md), mimeType: "text/html" }] };
5628
+ return { contents: [{ uri: uri.href, text: md, mimeType: "text/markdown" }] };
5564
5629
  });
5565
5630
  // Audit resource
5566
5631
  server.registerResource("audit-novel", "audit://novel", { title: "Audit Log" }, async () => {
@@ -5741,7 +5806,7 @@ server.registerResource("server-notes-single", new ResourceTemplate("server-note
5741
5806
  server.registerResource("codex-single", new ResourceTemplate("codex://{id}", { list: () => {
5742
5807
  return { resources: [...state.codex.keys()].map(id => ({ uri: `codex://${id}`, name: state.codex.get(id)?.name ?? id })) };
5743
5808
  } }), { title: "Codex Entry" }, async (uri) => {
5744
- const id = decodeURIComponent(uri.href.split("/").pop() ?? "");
5809
+ const id = resourceKey(uri);
5745
5810
  const entry = state.codex.get(id);
5746
5811
  if (!entry)
5747
5812
  return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "not found" }), mimeType: "application/json" }] };
@@ -5749,7 +5814,15 @@ server.registerResource("codex-single", new ResourceTemplate("codex://{id}", { l
5749
5814
  if (entry.visibility === "private" && badge !== "game_master" && badge !== "none") {
5750
5815
  return { contents: [{ uri: uri.href, text: "[FORBIDDEN] This codex entry is private.", mimeType: "text/plain" }] };
5751
5816
  }
5752
- return { contents: [{ uri: uri.href, text: JSON.stringify(entry, null, 2), mimeType: "application/json" }] };
5817
+ // REQ-425a/c the codex entry honors the output format catalog (markdown
5818
+ // default, json, html).
5819
+ const fmt = resourceFormat(uri);
5820
+ if (fmt === "json")
5821
+ return { contents: [{ uri: uri.href, text: JSON.stringify(entry, null, 2), mimeType: "application/json" }] };
5822
+ const md = codexEntryMarkdown(entry);
5823
+ if (fmt === "html")
5824
+ return { contents: [{ uri: uri.href, text: toHtml(md), mimeType: "text/html" }] };
5825
+ return { contents: [{ uri: uri.href, text: md, mimeType: "text/markdown" }] };
5753
5826
  });
5754
5827
  // Faction resources (REQ-233)
5755
5828
  server.registerResource("factions-collection", "factions://", { title: "All Factions" }, async () => {
@@ -5860,11 +5933,56 @@ server.registerResource("synthesis-status", "synthesis://status", { title: "Synt
5860
5933
  }
5861
5934
  return { contents: [{ uri: "synthesis://status", text: md, mimeType: "text/markdown" }] };
5862
5935
  });
5936
+ // ── MCP Apps UI resources (REQ-426) ────────────────────────────────
5937
+ //
5938
+ // REQ-426a — interactive HTML views of user-requestable artifacts under the
5939
+ // `ui://` scheme, served `text/html;profile=mcp-app` with restrictive CSP
5940
+ // metadata (REQ-426d). REQ-426c — the view is gated on extension negotiation.
5941
+ server.registerResource("ui-character-sheet", new ResourceTemplate("ui://character-sheet/{id}", { list: () => {
5942
+ const novel = state.activeNovel;
5943
+ if (!novel)
5944
+ return { resources: [] };
5945
+ return { resources: [...novel.entities.keys(), ...novel.npcs.keys()].map(id => ({ uri: `ui://character-sheet/${id}`, name: id })) };
5946
+ } }), { title: "Character Sheet (UI)" }, async (uri) => {
5947
+ const id = resourceKey(uri);
5948
+ const entity = resolveEntityOrNpc(id);
5949
+ if (!entity)
5950
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "not found" }), mimeType: "application/json" }] };
5951
+ return uiResourceResult(uri.href, toHtml(fmtEntitySheet(entity)), appsNegotiated());
5952
+ });
5953
+ server.registerResource("ui-codex", new ResourceTemplate("ui://codex/{id}", { list: () => {
5954
+ return { resources: [...state.codex.keys()].map(id => ({ uri: `ui://codex/${id}`, name: state.codex.get(id)?.name ?? id })) };
5955
+ } }), { title: "Codex Entry (UI)" }, async (uri) => {
5956
+ const entry = state.codex.get(resourceKey(uri));
5957
+ if (!entry)
5958
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "not found" }), mimeType: "application/json" }] };
5959
+ return uiResourceResult(uri.href, toHtml(codexEntryMarkdown(entry)), appsNegotiated());
5960
+ });
5961
+ server.registerResource("ui-lore", new ResourceTemplate("ui://lore/{key}", { list: () => {
5962
+ const novel = state.activeNovel;
5963
+ if (!novel)
5964
+ return { resources: [] };
5965
+ return { resources: [...novel.lore.keys()].map(k => ({ uri: `ui://lore/${k}`, name: k })) };
5966
+ } }), { title: "Lore Entry (UI)" }, async (uri) => {
5967
+ const novel = state.activeNovel;
5968
+ const key = resourceKey(uri);
5969
+ const entry = novel?.lore.get(key);
5970
+ if (!entry)
5971
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "not found" }), mimeType: "application/json" }] };
5972
+ return uiResourceResult(uri.href, toHtml(`## ${entry.key}\n\n${entry.content}`), appsNegotiated());
5973
+ });
5974
+ server.registerResource("ui-novel", "ui://novel/current", { title: "Active Novel (UI)" }, async () => {
5975
+ const novel = state.activeNovel;
5976
+ if (!novel)
5977
+ return { contents: [{ uri: "ui://novel/current", text: JSON.stringify({ error: "no active novel" }), mimeType: "application/json" }] };
5978
+ const md = `## ${novel.name}\n\n${novel.description ?? ""}\n`;
5979
+ return uiResourceResult("ui://novel/current", toHtml(md), appsNegotiated());
5980
+ });
5863
5981
  // ── Additional tools (REQ-307, REQ-213/214, REQ-321, REQ-103, REQ-239) ──
5864
5982
  server.registerTool("set_party_presence", {
5865
5983
  title: "Set Party Presence",
5866
5984
  description: "Declare which entities are present in the current scene. Use when: the GM needs to override party presence without altering other scene fields. Do NOT use when: setting scene description — use set_scene_state.",
5867
- inputSchema: { entity_ids: z.array(z.string()), location: z.string().optional() },
5985
+ inputSchema: { entity_ids: z.array(z.string()).describe("The entities present in the current scene."), location: z.string().optional().describe("Optional location override.") },
5868
5986
  }, async ({ entity_ids, location }) => {
5869
5987
  requireGM();
5870
5988
  const novel = requireNovel();
@@ -5876,7 +5994,7 @@ server.registerTool("set_party_presence", {
5876
5994
  server.registerTool("roll_on_table", {
5877
5995
  title: "Roll On Table",
5878
5996
  description: "Roll on a generation table from the bound ruleset. Use when: resolving a random-generation table (names, treasure, events). Do NOT use when: resolving a fixed lookup — use a lookup tool.",
5879
- inputSchema: { table: z.string(), seed: z.string().optional() },
5997
+ inputSchema: { table: z.string().describe("The generation table to roll on."), seed: z.string().optional().describe("Optional deterministic seed.") },
5880
5998
  }, async ({ table, seed }) => {
5881
5999
  const novel = state.activeNovel;
5882
6000
  const slug = novel?.ruleset ?? null;
@@ -5910,14 +6028,7 @@ server.registerTool("roll_on_table", {
5910
6028
  server.registerTool("codex_set", {
5911
6029
  title: "Set Codex Entry",
5912
6030
  description: "Create or update a typed codex entry that persists across Novels. Use when: storing reusable content (NPCs, factions, rooms, spells, etc.) for later import. Do NOT use when: storing Novel-scoped content — use set_lore_entry or set_note.",
5913
- inputSchema: {
5914
- kind: z.string(),
5915
- name: z.string(),
5916
- content: z.any(),
5917
- description: z.string().optional(),
5918
- tags: z.array(z.string()).optional(),
5919
- visibility: z.enum(["library", "shared", "private"]).optional(),
5920
- },
6031
+ inputSchema: { kind: z.string().describe("The codex entry kind."), name: z.string().describe("The entry name."), content: z.any().describe("The entry content."), description: z.string().optional().describe("Optional description."), tags: z.array(z.string()).optional().describe("Optional tags."), visibility: z.enum(["library", "shared", "private"]).optional().describe("library, shared, or private.") },
5921
6032
  }, async ({ kind, name, content, description, tags, visibility }) => {
5922
6033
  requireGM();
5923
6034
  const id = `${kind.toLowerCase()}_${name.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`;
@@ -5938,7 +6049,7 @@ server.registerTool("codex_set", {
5938
6049
  server.registerTool("codex_list", {
5939
6050
  title: "List Codex Entries",
5940
6051
  description: "List codex entries by kind, badge-filtered by visibility. Use when: discovering reusable content to import. Do NOT use when: listing Novel entities — use list_notes or list_stories.",
5941
- inputSchema: { kind: z.string().optional() },
6052
+ inputSchema: { kind: z.string().optional().describe("Optional kind filter.") },
5942
6053
  }, async ({ kind }) => {
5943
6054
  const badge = getBadge();
5944
6055
  let entries = [...state.codex.values()];
@@ -5952,7 +6063,7 @@ server.registerTool("codex_list", {
5952
6063
  server.registerTool("codex_capture", {
5953
6064
  title: "Capture to Codex",
5954
6065
  description: "Capture an entity's voice profile to the Codex. Use when: persisting a character's corrected voice across Novels. Do NOT use when: storing Novel-scoped lore — use set_lore_entry.",
5955
- inputSchema: { kind: z.enum(["voice_profile"]), entity_id: z.string(), update_source: z.boolean().optional() },
6066
+ inputSchema: { kind: z.enum(["voice_profile"]).describe("The codex kind (voice_profile)."), entity_id: z.string().describe("The entity whose voice to capture."), update_source: z.boolean().optional().describe("When true, update the source entity too.") },
5956
6067
  }, async ({ kind, entity_id, update_source }) => {
5957
6068
  requireGM();
5958
6069
  const novel = requireNovel();
@@ -5976,7 +6087,7 @@ server.registerTool("codex_capture", {
5976
6087
  server.registerTool("codex_import", {
5977
6088
  title: "Import from Codex",
5978
6089
  description: "Import a Codex entry into the active Novel. Use when: pulling reusable content (voice profiles, adventures) in. Do NOT use when: reading the Codex — use codex_list.",
5979
- inputSchema: { entry_id: z.string() },
6090
+ inputSchema: { entry_id: z.string().describe("The codex entry identifier to import.") },
5980
6091
  }, async ({ entry_id }) => {
5981
6092
  requireGM();
5982
6093
  const novel = requireNovel();
@@ -6024,8 +6135,8 @@ server.registerTool("codex_import", {
6024
6135
  const PLAYER_SYNTH_MODULES = ["voice_examples", "action_patterns", "supplementary_guidance", "narrative_voices", "lore_templates"];
6025
6136
  server.registerTool("player_synthesize", {
6026
6137
  title: "Player Synthesize",
6027
- description: "Create a player-authored synthesis item. Player badge only.",
6028
- inputSchema: { module: z.string(), key: z.string(), content: z.string(), triggers: z.array(z.string()).optional(), badge_scope: z.enum(["shared", "player"]).optional() },
6138
+ description: "Create a player-authored synthesis item (Player badge only), persisting it to the Novel. Use when: the player wants to add their own lore or voice content. Do NOT use when: the GM is synthesizing — use synthesize.",
6139
+ inputSchema: { module: z.string().describe("The synthesis module."), key: z.string().describe("The item key."), content: z.string().describe("The item content."), triggers: z.array(z.string()).optional().describe("Optional recall triggers."), badge_scope: z.enum(["shared", "player"]).optional().describe("shared or player.") },
6029
6140
  }, async ({ module, key, content, triggers, badge_scope }) => {
6030
6141
  requirePlayer();
6031
6142
  const novel = requireNovel();
@@ -6045,8 +6156,8 @@ server.registerTool("player_synthesize", {
6045
6156
  });
6046
6157
  server.registerTool("player_remove_synthesis", {
6047
6158
  title: "Player Remove Synthesis",
6048
- description: "Remove a player-authored synthesis item. Player badge only.",
6049
- inputSchema: { module: z.string(), key: z.string() },
6159
+ description: "Remove a player-authored synthesis item (Player badge only), persisting the removal. Use when: the player discards their own synthesis content. Do NOT use when: deactivating an item without deleting it — use deactivate_synthesis_item.",
6160
+ inputSchema: { module: z.string().describe("The synthesis module."), key: z.string().describe("The item key to remove.") },
6050
6161
  }, async ({ module, key }) => {
6051
6162
  requirePlayer();
6052
6163
  const novel = requireNovel();
@@ -6061,8 +6172,8 @@ server.registerTool("player_remove_synthesis", {
6061
6172
  });
6062
6173
  server.registerTool("player_list_synthesis", {
6063
6174
  title: "Player List Synthesis",
6064
- description: "List player-authored synthesis items. Player badge only.",
6065
- inputSchema: { module: z.string().optional() },
6175
+ description: "List player-authored synthesis items (Player badge only). Use when: reviewing the player's own synthesis content. Do NOT use when: listing all synthesis items — use list_synthesis_items.",
6176
+ inputSchema: { module: z.string().optional().describe("Optional module filter.") },
6066
6177
  }, async ({ module }) => {
6067
6178
  requirePlayer();
6068
6179
  const novel = requireNovel();
@@ -6079,7 +6190,7 @@ server.registerTool("synthesize", {
6079
6190
  // items with novel:// source URIs; fingerprint staleness + force bypass.
6080
6191
  // REQ-264 — confidence model: explicit-field items carry MEDIUM, inferred
6081
6192
  // items LOW, tagged [supplementary] [MEDIUM|LOW].
6082
- inputSchema: { force: z.boolean().optional() },
6193
+ inputSchema: { force: z.boolean().optional().describe("When true, re-run synthesis even if unchanged.") },
6083
6194
  }, async ({ force }) => {
6084
6195
  requireGM();
6085
6196
  const novel = requireNovel();
@@ -6095,7 +6206,7 @@ server.registerTool("synthesize", {
6095
6206
  server.registerTool("list_synthesis_items", {
6096
6207
  title: "List Synthesis Items",
6097
6208
  description: "List synthesis items by module and tier. Use when: reviewing available synthesis content. Do NOT use when: browsing the codex — use codex_list.",
6098
- inputSchema: { module: z.string().optional(), ...detailZod },
6209
+ inputSchema: { module: z.string().optional().describe("Optional module filter."), ...detailZod },
6099
6210
  }, async ({ module, detail }) => {
6100
6211
  const manifest = state.enrichmentManifest;
6101
6212
  if (!manifest)
@@ -6116,7 +6227,7 @@ server.registerTool("list_synthesis_items", {
6116
6227
  server.registerTool("activate_synthesis_item", {
6117
6228
  title: "Activate Synthesis Item",
6118
6229
  description: "Activate a synthesis item for the active Novel. Use when: incorporating synthesis content into play. Do NOT use when: deactivating — use deactivate_synthesis_item.",
6119
- inputSchema: { module: z.string(), key: z.number() },
6230
+ inputSchema: { module: z.string().describe("The synthesis module."), key: z.number().describe("The item key to activate.") },
6120
6231
  }, async ({ module, key }) => {
6121
6232
  requireGM();
6122
6233
  const novel = requireNovel();
@@ -6131,7 +6242,7 @@ server.registerTool("activate_synthesis_item", {
6131
6242
  server.registerTool("deactivate_synthesis_item", {
6132
6243
  title: "Deactivate Synthesis Item",
6133
6244
  description: "Deactivate a synthesis item for the active Novel. Use when: removing a synthesis item from play without deleting it. Do NOT use when: removing Ruleset Wisdom — use revert_synthesis.",
6134
- inputSchema: { module: z.string() },
6245
+ inputSchema: { module: z.string().describe("The synthesis module to deactivate.") },
6135
6246
  }, async ({ module }) => {
6136
6247
  requireGM();
6137
6248
  const novel = requireNovel();
@@ -6144,7 +6255,7 @@ server.registerTool("deactivate_synthesis_item", {
6144
6255
  server.registerTool("toggle_synthesis_module", {
6145
6256
  title: "Toggle Synthesis Module",
6146
6257
  description: "Enable or disable a synthesis module for the active Novel. Use when: controlling whether a module's content appears in surfaces. Do NOT use when: activating a single item — use activate_synthesis_item.",
6147
- inputSchema: { module: z.string(), enabled: z.boolean() },
6258
+ inputSchema: { module: z.string().describe("The synthesis module."), enabled: z.boolean().describe("Whether to enable or disable it.") },
6148
6259
  }, async ({ module, enabled }) => {
6149
6260
  requireGM();
6150
6261
  const novel = requireNovel();