holonovel 2026.8.30 → 2026.9.1

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.01",
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.
@@ -1100,6 +1106,121 @@ function formatNpcSheet(npc) {
1100
1106
  s += `**Location:** ${npc.location}\n`;
1101
1107
  return s;
1102
1108
  }
1109
+ // ── Output Format Catalog (REQ-425) ────────────────────────────────
1110
+ //
1111
+ // REQ-425a — every user-requestable artifact surface accepts an optional
1112
+ // `format` selector drawn from this catalog; the default is `markdown`.
1113
+ // REQ-425b — an unsupported format returns `[INVALID_INPUT]` enumerating the
1114
+ // surface's supported set, derived at call time (REQ-059). REQ-425c — the
1115
+ // same artifact in the same format renders byte-identically across surfaces
1116
+ // (tools and resources share the same render functions). REQ-425d — ruleset
1117
+ // packages may declare additional formats via the registry below.
1118
+ const UNIVERSAL_FORMATS = ["markdown", "json", "html"];
1119
+ const STATBLOCK_FORMATS = ["markdown", "json", "html", "ascii"];
1120
+ const SESSION_FORMATS = ["markdown", "lonelog"];
1121
+ const INTERCHANGE_FORMATS = ["json", "markdown"];
1122
+ // Ruleset-declared formats (REQ-425d). Packages register additional format
1123
+ // identifiers here at load time; they are surfaced in spec_health and in the
1124
+ // `[INVALID_INPUT]` enumeration of every surface.
1125
+ const declaredFormats = new Set([]);
1126
+ function registerDeclaredFormat(name) { declaredFormats.add(name); }
1127
+ function supportedFormats(surface) {
1128
+ return [...new Set([...surface, ...declaredFormats])];
1129
+ }
1130
+ // REQ-425b — validate a requested format against a surface's supported set;
1131
+ // returns the normalized format or an `[INVALID_INPUT]` result.
1132
+ function resolveFormat(format, surface) {
1133
+ const fmt = format ?? "markdown";
1134
+ if (!supportedFormats(surface).includes(fmt)) {
1135
+ const list = supportedFormats(surface).join(", ");
1136
+ return err("INVALID_INPUT", `Unsupported format '${fmt}'. Supported formats: ${list}.`);
1137
+ }
1138
+ return fmt;
1139
+ }
1140
+ // REQ-425c / REQ-426a — a presentational HTML render of a Markdown artifact.
1141
+ // Self-contained (no external origins) per REQ-426d. Minimal Markdown→HTML:
1142
+ // headings, bold, italics, and line breaks; everything else is escaped.
1143
+ function htmlEscape(s) {
1144
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1145
+ }
1146
+ function toHtml(markdown) {
1147
+ const body = markdown
1148
+ .split("\n")
1149
+ .map((line) => {
1150
+ const heading = line.match(/^(#{1,3})\s+(.+)$/);
1151
+ if (heading) {
1152
+ const level = heading[1].length;
1153
+ const text = htmlEscape(heading[2].replace(/\*\*(.+?)\*\*/g, "$1").replace(/\*(.+?)\*/g, "$1"));
1154
+ return `<h${level}>${text}</h${level}>`;
1155
+ }
1156
+ let l = htmlEscape(line);
1157
+ l = l.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>").replace(/\*(.+?)\*/g, "<i>$1</i>");
1158
+ return l;
1159
+ })
1160
+ .join("<br>\n");
1161
+ return `<!DOCTYPE html>\n<html><body><article>${body}</article></body></html>\n`;
1162
+ }
1163
+ // REQ-426d — UI resource CSP metadata: no external network origins.
1164
+ function uiResourceMeta() {
1165
+ return { csp: { connectDomains: [], resourceDomains: [], frameDomains: [] } };
1166
+ }
1167
+ // REQ-426c — a negotiating client declares `io.modelcontextprotocol/ui` in its
1168
+ // capabilities.extensions. Non-negotiating clients fall back to text surfaces.
1169
+ function appsNegotiated() {
1170
+ const ext = server.server.getClientCapabilities()?.extensions;
1171
+ return !!ext && "io.modelcontextprotocol/ui" in ext;
1172
+ }
1173
+ // REQ-425c — the structured (`json`) render of a stat block, shared by the
1174
+ // character_sheet tool and the entity/npc resources so both agree byte-for-byte.
1175
+ function entitySheetJson(entity) {
1176
+ return {
1177
+ id: entity.id ?? null,
1178
+ name: entity.name ?? null,
1179
+ stats: entity.stats ?? null,
1180
+ personality: entity.personality ?? {},
1181
+ inventory: entity.inventory ?? [],
1182
+ current_room: entity.current_room ?? null,
1183
+ conditions: entity.conditions ?? [],
1184
+ };
1185
+ }
1186
+ // REQ-425c — a Markdown render of a codex entry, shared by the codex://
1187
+ // resource and its `ui://` HTML view.
1188
+ function codexEntryMarkdown(entry) {
1189
+ let md = `## ${entry.name}\n`;
1190
+ if (entry.kind)
1191
+ md += `**Kind:** ${entry.kind}\n`;
1192
+ if (entry.description)
1193
+ md += `*${entry.description}*\n`;
1194
+ if (entry.content)
1195
+ md += `\n${JSON.stringify(entry.content, null, 2)}\n`;
1196
+ return md;
1197
+ }
1198
+ // REQ-426a/c — build a `ui://` resource result. A negotiating client receives
1199
+ // `text/html;profile=mcp-app` with restrictive CSP metadata (REQ-426d); a
1200
+ // non-negotiating client receives a plain-text fallback (REQ-426c).
1201
+ function uiResourceResult(uri, html, negotiated) {
1202
+ if (!negotiated) {
1203
+ return { contents: [{ uri, text: `[STATE_CONFLICT] ui:// resources require the MCP Apps extension (io.modelcontextprotocol/ui).`, mimeType: "text/plain" }] };
1204
+ }
1205
+ return { contents: [{ uri, text: html, mimeType: "text/html;profile=mcp-app", _meta: { ui: uiResourceMeta() } }] };
1206
+ }
1207
+ // REQ-426b — attach `ui://` linkage metadata to a tool result when a client has
1208
+ // negotiated the MCP Apps extension; non-negotiating clients get the bare result.
1209
+ function withUiLinkage(result, resourceUri) {
1210
+ if (!appsNegotiated())
1211
+ return result;
1212
+ const content = (result.content ?? []).map((c) => ({ ...c, _meta: { ui: { resourceUri } } }));
1213
+ return { ...result, content };
1214
+ }
1215
+ // REQ-425a — read the `format` query parameter from a resource URI (default
1216
+ // markdown) and extract the resource id/key, excluding the query string.
1217
+ function resourceFormat(uri) {
1218
+ return uri.searchParams.get("format") ?? "markdown";
1219
+ }
1220
+ function resourceKey(uri) {
1221
+ const raw = uri.href.split("?")[0].split("/").filter(Boolean).pop() ?? "";
1222
+ return decodeURIComponent(raw);
1223
+ }
1103
1224
  // ── Tools ──────────────────────────────────────────────────────────
1104
1225
  // --- Badge & Workflow ---
1105
1226
  function badgeLabel(badge) {
@@ -1726,21 +1847,37 @@ server.registerTool("import_character", {
1726
1847
  });
1727
1848
  server.registerTool("character_sheet", {
1728
1849
  title: "Character Sheet",
1729
- description: "Render a character sheet for an entity. Formats: markdown (default), ascii.",
1850
+ description: "Render a character sheet for an entity. Formats: markdown (default), json, html, ascii.",
1730
1851
  // REQ-120 — NPC rendering via the same sheet mechanism; REQ-124 — NPC damage
1731
1852
  // resolution targets NPCs by identifier; REQ-129 — property group cardinality.
1853
+ // REQ-425a/b — the `format` selector is drawn from the output format catalog
1854
+ // (STATBLOCK_FORMATS) and validated at call time; REQ-426b — the result
1855
+ // carries `ui://` linkage metadata when the Apps extension is negotiated.
1732
1856
  inputSchema: {
1733
1857
  entity_id: z.string().optional(),
1734
- format: z.enum(["markdown", "ascii"]).optional(),
1858
+ format: z.string().optional(),
1735
1859
  },
1736
1860
  }, async ({ entity_id, format }) => {
1737
1861
  const entity = resolveEntityOrNpc(entity_id);
1738
1862
  if (!entity)
1739
1863
  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}`);
1864
+ const fmt = resolveFormat(format, STATBLOCK_FORMATS);
1865
+ if (typeof fmt !== "string")
1866
+ return fmt;
1867
+ let result;
1868
+ if (fmt === "ascii") {
1869
+ result = raw(`[OK] ${entity.name} Room: ${entity.current_room || "(none)"} Held: ${entity.inventory?.length || 0}`);
1870
+ }
1871
+ else if (fmt === "json") {
1872
+ result = raw(JSON.stringify(entitySheetJson(entity), null, 2));
1873
+ }
1874
+ else if (fmt === "html") {
1875
+ result = raw(toHtml(fmtEntitySheet(entity)));
1742
1876
  }
1743
- return ok(fmtEntitySheet(entity));
1877
+ else {
1878
+ result = ok(fmtEntitySheet(entity));
1879
+ }
1880
+ return withUiLinkage(result, `ui://character-sheet/${entity.id}`);
1744
1881
  });
1745
1882
  server.registerTool("set_active_entity", {
1746
1883
  title: "Set Active Entity",
@@ -3210,9 +3347,14 @@ server.registerTool("export_lorebook", {
3210
3347
  description: "Export novel lore entries in interchange format. Game Master only.",
3211
3348
  // REQ-094 — lorebook interchange: lore-only export/import with merge, replace,
3212
3349
  // and dry-run modes; round-trip preserves lore metadata (Appendix L).
3213
- inputSchema: { format: z.enum(["json", "markdown"]).optional() },
3350
+ // REQ-425b — interchange surfaces accept only json/markdown; html is a
3351
+ // presentation-only format and returns [INVALID_INPUT] enumerating the set.
3352
+ inputSchema: { format: z.string().optional() },
3214
3353
  }, async ({ format: fmt }) => {
3215
3354
  requireGM();
3355
+ if (fmt && !INTERCHANGE_FORMATS.includes(fmt)) {
3356
+ return err("INVALID_INPUT", `Unsupported format '${fmt}'. Supported formats: ${INTERCHANGE_FORMATS.join(", ")}.`);
3357
+ }
3216
3358
  const novel = requireNovel();
3217
3359
  const entries = [...novel.lore.values()];
3218
3360
  if (fmt === "markdown") {
@@ -4808,9 +4950,14 @@ Options: yes, cancel`);
4808
4950
  server.registerTool("export_novel", {
4809
4951
  title: "Export Novel",
4810
4952
  description: "Export the active novel in interchange format. Game Master only.",
4811
- inputSchema: { format: z.enum(["json", "markdown"]).optional(), scope: z.string().optional() },
4953
+ inputSchema: { format: z.string().optional(), scope: z.string().optional() },
4812
4954
  }, async ({ format: fmt, scope }) => {
4813
4955
  requireGM();
4956
+ // REQ-425b — interchange surfaces accept only json/markdown; html returns
4957
+ // [INVALID_INPUT] enumerating the set (presentation-only, not round-trippable).
4958
+ if (fmt && !INTERCHANGE_FORMATS.includes(fmt)) {
4959
+ return err("INVALID_INPUT", `Unsupported format '${fmt}'. Supported formats: ${INTERCHANGE_FORMATS.join(", ")}.`);
4960
+ }
4814
4961
  const novel = requireNovel();
4815
4962
  if (fmt === "markdown") {
4816
4963
  let md = `# ${novel.name}\n\n`;
@@ -5287,6 +5434,17 @@ server.registerTool("spec_health", {
5287
5434
  resource_count: (server._registeredResources ? Object.keys(server._registeredResources).length : 0),
5288
5435
  resource_uris, // REQ-139 — resource URI presence from the live resource map.
5289
5436
  prompt_health, // REQ-138 — per-prompt presence, length, budget, stale refs.
5437
+ // REQ-425d — the output format catalog (universal + surface sets + any
5438
+ // ruleset-declared formats) and whether the MCP Apps extension (REQ-426c)
5439
+ // is negotiated by the current client.
5440
+ output_formats: {
5441
+ universal: [...UNIVERSAL_FORMATS],
5442
+ statblock: [...STATBLOCK_FORMATS],
5443
+ session: [...SESSION_FORMATS],
5444
+ interchange: [...INTERCHANGE_FORMATS],
5445
+ declared: [...declaredFormats],
5446
+ },
5447
+ mcp_apps: { negotiated: appsNegotiated() },
5290
5448
  confidence: { overall: "N/A — ruleset-free", per_file: {}, per_category: {} },
5291
5449
  indexed_counts: {
5292
5450
  anchors: rulesets.installedSlugs().reduce((n, s) => n + (rulesets.hydrate(s)?.index.length ?? 0), 0),
@@ -5496,13 +5654,22 @@ server.registerResource("npc-single", new ResourceTemplate("npc://{id}", { list:
5496
5654
  return { resources: [...novel.npcs.keys()].map(id => ({ uri: `npc://${id}`, name: id })) };
5497
5655
  } }), { title: "NPC Record" }, async (uri) => {
5498
5656
  const novel = state.activeNovel;
5499
- const id = uri.href.split("/").pop() ?? "";
5657
+ const id = resourceKey(uri);
5500
5658
  if (!novel)
5501
5659
  return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "no active novel" }), mimeType: "application/json" }] };
5502
5660
  const npc = novel.npcs.get(id);
5503
5661
  if (!npc)
5504
5662
  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" }] };
5663
+ // REQ-425a/c — the NPC stat block honors the output format catalog; the
5664
+ // markdown render is byte-identical to character_sheet(npc_id) via the same
5665
+ // fmtEntitySheet renderer.
5666
+ const fmt = resourceFormat(uri);
5667
+ if (fmt === "json")
5668
+ return { contents: [{ uri: uri.href, text: JSON.stringify(npc, null, 2), mimeType: "application/json" }] };
5669
+ const md = fmtEntitySheet(npc);
5670
+ if (fmt === "html")
5671
+ return { contents: [{ uri: uri.href, text: toHtml(md), mimeType: "text/html" }] };
5672
+ return { contents: [{ uri: uri.href, text: md, mimeType: "text/markdown" }] };
5506
5673
  });
5507
5674
  server.registerResource("npcs", "npcs://", { title: "All NPCs" }, async () => {
5508
5675
  // REQ-121 — NPC resource URIs: npcs:// lists all active NPCs with summary
@@ -5554,13 +5721,21 @@ server.registerResource("lore-single", new ResourceTemplate("lore://{key}", { li
5554
5721
  return { resources: [...novel.lore.keys()].map(k => ({ uri: `lore://${k}`, name: k })) };
5555
5722
  } }), { title: "Lore Entry" }, async (uri) => {
5556
5723
  const novel = state.activeNovel;
5557
- const key = uri.href.split("/").pop() ?? "";
5724
+ const key = resourceKey(uri);
5558
5725
  if (!novel)
5559
5726
  return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "no active novel" }), mimeType: "application/json" }] };
5560
5727
  const entry = novel.lore.get(key);
5561
5728
  if (!entry)
5562
5729
  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" }] };
5730
+ // REQ-425a/c — the lore entry honors the output format catalog (markdown
5731
+ // default, json, html).
5732
+ const fmt = resourceFormat(uri);
5733
+ if (fmt === "json")
5734
+ return { contents: [{ uri: uri.href, text: JSON.stringify(entry, null, 2), mimeType: "application/json" }] };
5735
+ const md = `## ${entry.key}\n\n${entry.content}`;
5736
+ if (fmt === "html")
5737
+ return { contents: [{ uri: uri.href, text: toHtml(md), mimeType: "text/html" }] };
5738
+ return { contents: [{ uri: uri.href, text: md, mimeType: "text/markdown" }] };
5564
5739
  });
5565
5740
  // Audit resource
5566
5741
  server.registerResource("audit-novel", "audit://novel", { title: "Audit Log" }, async () => {
@@ -5741,7 +5916,7 @@ server.registerResource("server-notes-single", new ResourceTemplate("server-note
5741
5916
  server.registerResource("codex-single", new ResourceTemplate("codex://{id}", { list: () => {
5742
5917
  return { resources: [...state.codex.keys()].map(id => ({ uri: `codex://${id}`, name: state.codex.get(id)?.name ?? id })) };
5743
5918
  } }), { title: "Codex Entry" }, async (uri) => {
5744
- const id = decodeURIComponent(uri.href.split("/").pop() ?? "");
5919
+ const id = resourceKey(uri);
5745
5920
  const entry = state.codex.get(id);
5746
5921
  if (!entry)
5747
5922
  return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "not found" }), mimeType: "application/json" }] };
@@ -5749,7 +5924,15 @@ server.registerResource("codex-single", new ResourceTemplate("codex://{id}", { l
5749
5924
  if (entry.visibility === "private" && badge !== "game_master" && badge !== "none") {
5750
5925
  return { contents: [{ uri: uri.href, text: "[FORBIDDEN] This codex entry is private.", mimeType: "text/plain" }] };
5751
5926
  }
5752
- return { contents: [{ uri: uri.href, text: JSON.stringify(entry, null, 2), mimeType: "application/json" }] };
5927
+ // REQ-425a/c — the codex entry honors the output format catalog (markdown
5928
+ // default, json, html).
5929
+ const fmt = resourceFormat(uri);
5930
+ if (fmt === "json")
5931
+ return { contents: [{ uri: uri.href, text: JSON.stringify(entry, null, 2), mimeType: "application/json" }] };
5932
+ const md = codexEntryMarkdown(entry);
5933
+ if (fmt === "html")
5934
+ return { contents: [{ uri: uri.href, text: toHtml(md), mimeType: "text/html" }] };
5935
+ return { contents: [{ uri: uri.href, text: md, mimeType: "text/markdown" }] };
5753
5936
  });
5754
5937
  // Faction resources (REQ-233)
5755
5938
  server.registerResource("factions-collection", "factions://", { title: "All Factions" }, async () => {
@@ -5860,6 +6043,51 @@ server.registerResource("synthesis-status", "synthesis://status", { title: "Synt
5860
6043
  }
5861
6044
  return { contents: [{ uri: "synthesis://status", text: md, mimeType: "text/markdown" }] };
5862
6045
  });
6046
+ // ── MCP Apps UI resources (REQ-426) ────────────────────────────────
6047
+ //
6048
+ // REQ-426a — interactive HTML views of user-requestable artifacts under the
6049
+ // `ui://` scheme, served `text/html;profile=mcp-app` with restrictive CSP
6050
+ // metadata (REQ-426d). REQ-426c — the view is gated on extension negotiation.
6051
+ server.registerResource("ui-character-sheet", new ResourceTemplate("ui://character-sheet/{id}", { list: () => {
6052
+ const novel = state.activeNovel;
6053
+ if (!novel)
6054
+ return { resources: [] };
6055
+ return { resources: [...novel.entities.keys(), ...novel.npcs.keys()].map(id => ({ uri: `ui://character-sheet/${id}`, name: id })) };
6056
+ } }), { title: "Character Sheet (UI)" }, async (uri) => {
6057
+ const id = resourceKey(uri);
6058
+ const entity = resolveEntityOrNpc(id);
6059
+ if (!entity)
6060
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "not found" }), mimeType: "application/json" }] };
6061
+ return uiResourceResult(uri.href, toHtml(fmtEntitySheet(entity)), appsNegotiated());
6062
+ });
6063
+ server.registerResource("ui-codex", new ResourceTemplate("ui://codex/{id}", { list: () => {
6064
+ return { resources: [...state.codex.keys()].map(id => ({ uri: `ui://codex/${id}`, name: state.codex.get(id)?.name ?? id })) };
6065
+ } }), { title: "Codex Entry (UI)" }, async (uri) => {
6066
+ const entry = state.codex.get(resourceKey(uri));
6067
+ if (!entry)
6068
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "not found" }), mimeType: "application/json" }] };
6069
+ return uiResourceResult(uri.href, toHtml(codexEntryMarkdown(entry)), appsNegotiated());
6070
+ });
6071
+ server.registerResource("ui-lore", new ResourceTemplate("ui://lore/{key}", { list: () => {
6072
+ const novel = state.activeNovel;
6073
+ if (!novel)
6074
+ return { resources: [] };
6075
+ return { resources: [...novel.lore.keys()].map(k => ({ uri: `ui://lore/${k}`, name: k })) };
6076
+ } }), { title: "Lore Entry (UI)" }, async (uri) => {
6077
+ const novel = state.activeNovel;
6078
+ const key = resourceKey(uri);
6079
+ const entry = novel?.lore.get(key);
6080
+ if (!entry)
6081
+ return { contents: [{ uri: uri.href, text: JSON.stringify({ error: "not found" }), mimeType: "application/json" }] };
6082
+ return uiResourceResult(uri.href, toHtml(`## ${entry.key}\n\n${entry.content}`), appsNegotiated());
6083
+ });
6084
+ server.registerResource("ui-novel", "ui://novel/current", { title: "Active Novel (UI)" }, async () => {
6085
+ const novel = state.activeNovel;
6086
+ if (!novel)
6087
+ return { contents: [{ uri: "ui://novel/current", text: JSON.stringify({ error: "no active novel" }), mimeType: "application/json" }] };
6088
+ const md = `## ${novel.name}\n\n${novel.description ?? ""}\n`;
6089
+ return uiResourceResult("ui://novel/current", toHtml(md), appsNegotiated());
6090
+ });
5863
6091
  // ── Additional tools (REQ-307, REQ-213/214, REQ-321, REQ-103, REQ-239) ──
5864
6092
  server.registerTool("set_party_presence", {
5865
6093
  title: "Set Party Presence",