run-dmcp 0.1.0 → 0.2.0

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.
Files changed (135) hide show
  1. package/README.md +76 -10
  2. package/dist/bin/run-dmcp.d.ts +2 -0
  3. package/dist/bin/run-dmcp.js +55 -0
  4. package/dist/db/connection.d.ts +32 -0
  5. package/dist/db/connection.js +38 -16
  6. package/dist/db/schema.d.ts +29 -1
  7. package/dist/db/schema.js +439 -7
  8. package/dist/http/server.js +3 -3
  9. package/dist/index.d.ts +36 -2
  10. package/dist/index.js +184 -92
  11. package/dist/mcp-server.d.ts +49 -0
  12. package/dist/mcp-server.js +127 -0
  13. package/dist/reader/turnReader.d.ts +185 -0
  14. package/dist/reader/turnReader.js +288 -0
  15. package/dist/register/batch.js +5 -79
  16. package/dist/register/mcp-resources.d.ts +9 -0
  17. package/dist/register/mcp-resources.js +16 -62
  18. package/dist/register/render.d.ts +17 -0
  19. package/dist/register/render.js +50 -0
  20. package/dist/register/resolve.d.ts +14 -0
  21. package/dist/register/resolve.js +102 -0
  22. package/dist/register/resources.js +11 -4
  23. package/dist/register/timeline.d.ts +2 -0
  24. package/dist/register/timeline.js +311 -0
  25. package/dist/rpg/index.d.ts +29 -0
  26. package/dist/rpg/index.js +55 -0
  27. package/dist/rpg/register/abilities.d.ts +2 -0
  28. package/dist/rpg/register/abilities.js +165 -0
  29. package/dist/rpg/register/batch.d.ts +2 -0
  30. package/dist/rpg/register/batch.js +92 -0
  31. package/dist/rpg/register/combat.d.ts +2 -0
  32. package/dist/rpg/register/combat.js +207 -0
  33. package/dist/rpg/register/mcp-prompts.d.ts +2 -0
  34. package/dist/rpg/register/mcp-prompts.js +684 -0
  35. package/dist/rpg/register/mcp-resources.d.ts +2 -0
  36. package/dist/rpg/register/mcp-resources.js +61 -0
  37. package/dist/rpg/register/quests.d.ts +2 -0
  38. package/dist/rpg/register/quests.js +118 -0
  39. package/dist/rpg/register/status.d.ts +2 -0
  40. package/dist/rpg/register/status.js +130 -0
  41. package/dist/rpg/register/tables.d.ts +2 -0
  42. package/dist/rpg/register/tables.js +146 -0
  43. package/dist/rpg/tools/ability.d.ts +48 -0
  44. package/dist/rpg/tools/ability.js +238 -0
  45. package/dist/rpg/tools/combat.d.ts +13 -0
  46. package/dist/rpg/tools/combat.js +195 -0
  47. package/dist/rpg/tools/dice.d.ts +23 -0
  48. package/dist/rpg/tools/dice.js +111 -0
  49. package/dist/rpg/tools/quest.d.ts +34 -0
  50. package/dist/rpg/tools/quest.js +164 -0
  51. package/dist/rpg/tools/status.d.ts +36 -0
  52. package/dist/rpg/tools/status.js +218 -0
  53. package/dist/rpg/tools/tables.d.ts +33 -0
  54. package/dist/rpg/tools/tables.js +209 -0
  55. package/dist/schemas/index.d.ts +12 -12
  56. package/dist/timeline/adjudication.d.ts +150 -0
  57. package/dist/timeline/adjudication.js +174 -0
  58. package/dist/timeline/changes.d.ts +100 -0
  59. package/dist/timeline/changes.js +161 -0
  60. package/dist/timeline/checkpoint.d.ts +69 -0
  61. package/dist/timeline/checkpoint.js +131 -0
  62. package/dist/timeline/clock.d.ts +89 -0
  63. package/dist/timeline/clock.js +173 -0
  64. package/dist/timeline/constrained.d.ts +220 -0
  65. package/dist/timeline/constrained.js +671 -0
  66. package/dist/timeline/export.d.ts +171 -0
  67. package/dist/timeline/export.js +329 -0
  68. package/dist/timeline/irreversible.d.ts +85 -0
  69. package/dist/timeline/irreversible.js +108 -0
  70. package/dist/timeline/kinds.d.ts +14 -0
  71. package/dist/timeline/kinds.js +22 -0
  72. package/dist/timeline/narration.d.ts +175 -0
  73. package/dist/timeline/narration.js +259 -0
  74. package/dist/timeline/projection.d.ts +97 -0
  75. package/dist/timeline/projection.js +330 -0
  76. package/dist/timeline/provenance.d.ts +66 -0
  77. package/dist/timeline/provenance.js +45 -0
  78. package/dist/timeline/registry.d.ts +95 -0
  79. package/dist/timeline/registry.js +124 -0
  80. package/dist/timeline/render.d.ts +121 -0
  81. package/dist/timeline/render.js +187 -0
  82. package/dist/timeline/replay.d.ts +64 -0
  83. package/dist/timeline/replay.js +104 -0
  84. package/dist/timeline/resolve.d.ts +262 -0
  85. package/dist/timeline/resolve.js +226 -0
  86. package/dist/timeline/schema.d.ts +13 -0
  87. package/dist/timeline/schema.js +262 -0
  88. package/dist/timeline/t.d.ts +80 -0
  89. package/dist/timeline/t.js +37 -0
  90. package/dist/tools/constraint.d.ts +44 -80
  91. package/dist/tools/constraint.js +115 -124
  92. package/dist/tools/relationship.d.ts +83 -2
  93. package/dist/tools/relationship.js +139 -62
  94. package/dist/tools/resource.d.ts +31 -6
  95. package/dist/tools/resource.js +106 -153
  96. package/dist/types/index.d.ts +19 -1
  97. package/dist/utils/output-schemas.d.ts +593 -2
  98. package/dist/utils/output-schemas.js +3 -0
  99. package/dist/utils/webui.d.ts +32 -0
  100. package/dist/utils/webui.js +54 -1
  101. package/package.json +20 -4
  102. package/dist/__tests__/engineVocabulary.test.d.ts +0 -1
  103. package/dist/__tests__/engineVocabulary.test.js +0 -147
  104. package/dist/db/__tests__/connection.test.d.ts +0 -1
  105. package/dist/db/__tests__/connection.test.js +0 -72
  106. package/dist/db/__tests__/testDb.d.ts +0 -33
  107. package/dist/db/__tests__/testDb.js +0 -41
  108. package/dist/test-setup.d.ts +0 -1
  109. package/dist/test-setup.js +0 -13
  110. package/dist/tools/__tests__/audio.test.d.ts +0 -1
  111. package/dist/tools/__tests__/audio.test.js +0 -59
  112. package/dist/tools/__tests__/conserved.test.d.ts +0 -1
  113. package/dist/tools/__tests__/conserved.test.js +0 -488
  114. package/dist/tools/__tests__/constraint.test.d.ts +0 -1
  115. package/dist/tools/__tests__/constraint.test.js +0 -212
  116. package/dist/tools/__tests__/expiry-consequences.test.d.ts +0 -1
  117. package/dist/tools/__tests__/expiry-consequences.test.js +0 -110
  118. package/dist/tools/__tests__/images.test.d.ts +0 -1
  119. package/dist/tools/__tests__/images.test.js +0 -59
  120. package/dist/tools/__tests__/relationship.test.d.ts +0 -1
  121. package/dist/tools/__tests__/relationship.test.js +0 -132
  122. package/dist/tools/__tests__/resource-constraints.test.d.ts +0 -1
  123. package/dist/tools/__tests__/resource-constraints.test.js +0 -131
  124. package/dist/tools/__tests__/resource.test.d.ts +0 -1
  125. package/dist/tools/__tests__/resource.test.js +0 -190
  126. package/dist/tools/__tests__/time.test.d.ts +0 -1
  127. package/dist/tools/__tests__/time.test.js +0 -404
  128. package/dist/tools/__tests__/timers.test.d.ts +0 -1
  129. package/dist/tools/__tests__/timers.test.js +0 -426
  130. package/dist/tools/__tests__/world.test.d.ts +0 -1
  131. package/dist/tools/__tests__/world.test.js +0 -70
  132. package/dist/utils/__tests__/json.test.d.ts +0 -1
  133. package/dist/utils/__tests__/json.test.js +0 -55
  134. package/dist/utils/__tests__/validation.test.d.ts +0 -1
  135. package/dist/utils/__tests__/validation.test.js +0 -90
@@ -2,9 +2,24 @@ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import * as gameTools from "../tools/game.js";
3
3
  import * as characterTools from "../tools/character.js";
4
4
  import * as worldTools from "../tools/world.js";
5
- import * as questTools from "../tools/quest.js";
6
5
  import * as narrativeTools from "../tools/narrative.js";
7
6
  import * as rulesTools from "../tools/rules.js";
7
+ // Helper to create list callback for game-scoped resources. Hoisted to
8
+ // module scope and exported (design §8, issue #17) so that
9
+ // src/rpg/register/mcp-resources.ts's game-quests and quest resources --
10
+ // the two RPG-shaped entries pulled out of this file -- share this one
11
+ // implementation rather than growing a second copy of it.
12
+ export const createGameListCallback = (suffix, nameFormatter) => {
13
+ return async () => {
14
+ const games = gameTools.listGames();
15
+ return {
16
+ resources: games.map((g) => ({
17
+ uri: `dmcp://game/${g.id}${suffix}`,
18
+ name: nameFormatter(g),
19
+ })),
20
+ };
21
+ };
22
+ };
8
23
  export function registerMcpResources(server) {
9
24
  // ============================================================================
10
25
  // STATIC RESOURCES
@@ -28,18 +43,6 @@ export function registerMcpResources(server) {
28
43
  // ============================================================================
29
44
  // GAME-SCOPED RESOURCES (templated)
30
45
  // ============================================================================
31
- // Helper to create list callback for game-scoped resources
32
- const createGameListCallback = (suffix, nameFormatter) => {
33
- return async () => {
34
- const games = gameTools.listGames();
35
- return {
36
- resources: games.map((g) => ({
37
- uri: `dmcp://game/${g.id}${suffix}`,
38
- name: nameFormatter(g),
39
- })),
40
- };
41
- };
42
- };
43
46
  // Game details
44
47
  server.registerResource("game", new ResourceTemplate("dmcp://game/{gameId}", {
45
48
  list: createGameListCallback("", (g) => g.name),
@@ -171,25 +174,6 @@ export function registerMcpResources(server) {
171
174
  ],
172
175
  };
173
176
  });
174
- // Game quests
175
- server.registerResource("game-quests", new ResourceTemplate("dmcp://game/{gameId}/quests", {
176
- list: createGameListCallback("/quests", (g) => `${g.name} - Quests`),
177
- }), {
178
- description: "All quests in the game",
179
- mimeType: "application/json",
180
- }, async (uri, variables) => {
181
- const gameId = variables.gameId;
182
- const quests = questTools.listQuests(gameId);
183
- return {
184
- contents: [
185
- {
186
- uri: uri.href,
187
- mimeType: "application/json",
188
- text: JSON.stringify(quests, null, 2),
189
- },
190
- ],
191
- };
192
- });
193
177
  // Game history (recent narrative events)
194
178
  server.registerResource("game-history", new ResourceTemplate("dmcp://game/{gameId}/history", {
195
179
  list: createGameListCallback("/history", (g) => `${g.name} - History`),
@@ -302,34 +286,4 @@ export function registerMcpResources(server) {
302
286
  ],
303
287
  };
304
288
  });
305
- // Quest by ID
306
- server.registerResource("quest", new ResourceTemplate("dmcp://quest/{questId}", {
307
- list: undefined, // No enumeration - access by ID only
308
- }), {
309
- description: "Quest details with objectives",
310
- mimeType: "application/json",
311
- }, async (uri, variables) => {
312
- const questId = variables.questId;
313
- const quest = questTools.getQuest(questId);
314
- if (!quest) {
315
- return {
316
- contents: [
317
- {
318
- uri: uri.href,
319
- mimeType: "application/json",
320
- text: JSON.stringify({ error: "Quest not found" }),
321
- },
322
- ],
323
- };
324
- }
325
- return {
326
- contents: [
327
- {
328
- uri: uri.href,
329
- mimeType: "application/json",
330
- text: JSON.stringify(quest, null, 2),
331
- },
332
- ],
333
- };
334
- });
335
289
  }
@@ -0,0 +1,17 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { StateRenderer } from "../timeline/render.js";
3
+ /**
4
+ * The MCP surface for the engine's state-to-text projection (design §7,
5
+ * §8, GitHub issue #16) -- one tool, `render_state_at`. Registered ONLY
6
+ * when a caller supplies a `StateRenderer` to `createCoreMcpServer`
7
+ * (src/mcp-server.ts), itself built by `createStateRenderer({ vocabulary
8
+ * })` (timeline/render.ts) over a caller-injected, caller-owned vocabulary.
9
+ * An engine with no injected vocabulary has nothing to name and registers
10
+ * no render tool at all -- the same "injection at construction" shape
11
+ * `registerResolveTools` (resolve.ts) already uses for its `Resolver`, and
12
+ * for the identical reason: there is no global renderer anywhere in this
13
+ * codebase, and this file carries no vocabulary of its own, not even an
14
+ * example (root CLAUDE.md hard rule 3, design §7's "mechanism is core, the
15
+ * vocabulary is injected").
16
+ */
17
+ export declare function registerRenderTools(server: McpServer, renderer: StateRenderer): void;
@@ -0,0 +1,50 @@
1
+ import { z } from "zod";
2
+ import { ANNOTATIONS } from "../utils/tool-annotations.js";
3
+ import { createLogger } from "../utils/logger.js";
4
+ const log = createLogger("render");
5
+ /**
6
+ * The MCP surface for the engine's state-to-text projection (design §7,
7
+ * §8, GitHub issue #16) -- one tool, `render_state_at`. Registered ONLY
8
+ * when a caller supplies a `StateRenderer` to `createCoreMcpServer`
9
+ * (src/mcp-server.ts), itself built by `createStateRenderer({ vocabulary
10
+ * })` (timeline/render.ts) over a caller-injected, caller-owned vocabulary.
11
+ * An engine with no injected vocabulary has nothing to name and registers
12
+ * no render tool at all -- the same "injection at construction" shape
13
+ * `registerResolveTools` (resolve.ts) already uses for its `Resolver`, and
14
+ * for the identical reason: there is no global renderer anywhere in this
15
+ * codebase, and this file carries no vocabulary of its own, not even an
16
+ * example (root CLAUDE.md hard rule 3, design §7's "mechanism is core, the
17
+ * vocabulary is injected").
18
+ */
19
+ export function registerRenderTools(server, renderer) {
20
+ server.registerTool("render_state_at", {
21
+ description: "Render a game's world at t as positive concrete nouns, drawn only from the caller's own injected " +
22
+ "vocabulary -- 'the grain stores are full and the treasury coffers overflow', never 'the grain stores " +
23
+ "are no longer empty'. The renderer's sole source of state is replay(t): a fact that does not hold at " +
24
+ "t produces nothing, not a phrase about its absence. A fact that DOES hold but has no vocabulary entry " +
25
+ "is reported as an unnamed row rather than described or invented -- that is how a caller learns its " +
26
+ "vocabulary is too thin, without the engine passing judgement on it. No diff, no comparison against " +
27
+ "another t, and no transition/change form: state at one t, full stop.",
28
+ inputSchema: {
29
+ gameId: z.string().max(100).describe("The game ID"),
30
+ t: z
31
+ .number()
32
+ .finite()
33
+ .describe("An opaque ordinal on this game's declared time axis -- never a datetime, and never an index " +
34
+ "into units you might later re-cut."),
35
+ },
36
+ annotations: ANNOTATIONS.READ_ONLY,
37
+ }, async ({ gameId, t }) => {
38
+ try {
39
+ const rendered = renderer.render({ gameId, t });
40
+ return { content: [{ type: "text", text: JSON.stringify(rendered, null, 2) }] };
41
+ }
42
+ catch (error) {
43
+ log.error("render_state_at failed", { gameId, t, error: error.message });
44
+ return {
45
+ content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
46
+ isError: true,
47
+ };
48
+ }
49
+ });
50
+ }
@@ -0,0 +1,14 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { type Resolver } from "../timeline/resolve.js";
3
+ /**
4
+ * The MCP surface for the resolve protocol (design §5.2a, issue #10) --
5
+ * `resolve` and `list_mechanics`. Registered ONLY when a caller supplies a
6
+ * non-empty `mechanics` array to `createCoreMcpServer` (src/mcp-server.ts): an
7
+ * engine with no mechanics has no resolve surface, and that is the correct
8
+ * shape rather than a resolve tool that could only ever answer
9
+ * "unknown-mechanic" and a list_mechanics that could only ever answer `[]`.
10
+ * This keeps injection at construction -- there is still no global resolver
11
+ * anywhere in this codebase, only the one `Resolver` instance a caller built
12
+ * and handed in, closed over by the two tool handlers below.
13
+ */
14
+ export declare function registerResolveTools(server: McpServer, resolver: Resolver): void;
@@ -0,0 +1,102 @@
1
+ import { z } from "zod";
2
+ import { ANNOTATIONS } from "../utils/tool-annotations.js";
3
+ import { createLogger } from "../utils/logger.js";
4
+ import { errors, formatErrorResponse } from "../utils/errors.js";
5
+ import { ConstraintViolationError } from "../timeline/registry.js";
6
+ import { ResolveProtocolError } from "../timeline/resolve.js";
7
+ const log = createLogger("resolve");
8
+ /**
9
+ * The MCP surface for the resolve protocol (design §5.2a, issue #10) --
10
+ * `resolve` and `list_mechanics`. Registered ONLY when a caller supplies a
11
+ * non-empty `mechanics` array to `createCoreMcpServer` (src/mcp-server.ts): an
12
+ * engine with no mechanics has no resolve surface, and that is the correct
13
+ * shape rather than a resolve tool that could only ever answer
14
+ * "unknown-mechanic" and a list_mechanics that could only ever answer `[]`.
15
+ * This keeps injection at construction -- there is still no global resolver
16
+ * anywhere in this codebase, only the one `Resolver` instance a caller built
17
+ * and handed in, closed over by the two tool handlers below.
18
+ */
19
+ export function registerResolveTools(server, resolver) {
20
+ server.registerTool("resolve", {
21
+ description: "Propose a resolution to a registered mechanic and get back its outcome. The engine enforces the " +
22
+ "PROTOCOL -- resolution happens before narration, writes go through the audited path, declared " +
23
+ "expectations are checked before dispatch -- without knowing what the mechanic itself means. " +
24
+ "Refuses (no window opens, nothing is written) for an unknown mechanic, a game with no timeline " +
25
+ "clock, or a declared expectation that does not hold at the game's current t -- the last of these " +
26
+ "carries one hop of causality per contradicted expectation, so a caller can tell whether the fact " +
27
+ "is wrong or the claim is. A constraint violated mid-adjudication (bounded, monotonic, conserved, " +
28
+ "resolve_only, irreversible) rolls back every change in the resolution and records no event.",
29
+ inputSchema: {
30
+ gameId: z.string().max(100).describe("The game ID"),
31
+ mechanic: z.string().min(1).max(200).describe("The name of a mechanic registered with this resolver"),
32
+ parameters: z
33
+ .record(z.string(), z.unknown())
34
+ .optional()
35
+ .describe("Opaque to the engine -- handed to the mechanic verbatim, never inspected here"),
36
+ expects: z
37
+ .array(z.object({
38
+ entityId: z.string().max(100).describe("The entity this expectation is about"),
39
+ key: z.string().max(200).describe("The fact key this expectation is about"),
40
+ value: z.union([z.string(), z.number()]).describe("The value this proposal declares it depends on"),
41
+ }))
42
+ .optional()
43
+ .describe("Facts this proposal declares it depends on, verified BEFORE the mechanic is dispatched. A caller's " +
44
+ "own declared precondition -- the engine only reports whether it holds, never why it should."),
45
+ },
46
+ annotations: ANNOTATIONS.UPDATE,
47
+ }, async ({ gameId, mechanic, parameters, expects }) => {
48
+ try {
49
+ const outcome = resolver.resolve({ gameId, mechanic, parameters, expects });
50
+ return { content: [{ type: "text", text: JSON.stringify(outcome, null, 2) }] };
51
+ }
52
+ catch (error) {
53
+ if (error instanceof ResolveProtocolError) {
54
+ log.error("resolve refused", { gameId, mechanic, reason: error.reason, error: error.message });
55
+ return {
56
+ content: [
57
+ {
58
+ type: "text",
59
+ text: JSON.stringify({
60
+ error: error.message,
61
+ reason: error.reason,
62
+ contradictions: error.contradictions ?? undefined,
63
+ }, null, 2),
64
+ },
65
+ ],
66
+ isError: true,
67
+ };
68
+ }
69
+ if (error instanceof ConstraintViolationError) {
70
+ log.error("resolve rolled back", {
71
+ gameId,
72
+ mechanic,
73
+ constraintKind: error.constraintKind,
74
+ error: error.message,
75
+ });
76
+ return formatErrorResponse(errors.constraintViolation(error.resourceId, error.message));
77
+ }
78
+ log.error("resolve failed", { gameId, mechanic, error: error.message });
79
+ return {
80
+ content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
81
+ isError: true,
82
+ };
83
+ }
84
+ });
85
+ server.registerTool("list_mechanics", {
86
+ description: "List every mechanic name registered with this resolver. The engine holds these names; it never " +
87
+ "reads meaning into them -- they are exactly what a caller handed to createCoreMcpServer's mechanics option.",
88
+ inputSchema: {},
89
+ annotations: ANNOTATIONS.READ_ONLY,
90
+ }, async () => {
91
+ try {
92
+ return { content: [{ type: "text", text: JSON.stringify(resolver.mechanics(), null, 2) }] };
93
+ }
94
+ catch (error) {
95
+ log.error("list_mechanics failed", { error: error.message });
96
+ return {
97
+ content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
98
+ isError: true,
99
+ };
100
+ }
101
+ });
102
+ }
@@ -207,17 +207,19 @@ export function registerResourceTools(server) {
207
207
  description: "Declare a server-enforced invariant on one or more resources, so update_resource_value cannot write a value that violates it. " +
208
208
  "'bounded': the resource must already have minValue and/or maxValue set (via create_resource/update_resource) -- once declared, writes outside those bounds are REJECTED instead of the default silent clamp. " +
209
209
  "'monotonic': the resource's value may only move in one direction ('increasing' = never decreases, 'decreasing' = never increases); holding steady is always allowed. " +
210
- "'conserved': declares a set of 2+ resources that must always sum to a fixed total -- the members' current values must already sum to `total` (this does not rewrite them to match). Once declared, update_resource_value REJECTS direct writes to any member (ambiguous -- it can't know where the offsetting change comes from); use transfer_resource_value to move value between two members of the set atomically instead. A resource can belong to at most one 'conserved' set at a time.",
210
+ "'conserved': declares a set of 2+ resources that must always sum to a fixed total -- the members' current values must already sum to `total` (this does not rewrite them to match). Once declared, update_resource_value REJECTS direct writes to any member (ambiguous -- it can't know where the offsetting change comes from); use transfer_resource_value to move value between two members of the set atomically instead. A resource can belong to at most one 'conserved' set at a time. " +
211
+ "'resolve_only': every DIRECT write to the given fact key is refused -- both update_resource_value and transfer_resource_value -- so it can move only through an adjudicating call. Scoped to one fact key (factKey, default 'value'); a resource can hold a separate 'resolve_only' declaration per fact key.",
211
212
  inputSchema: {
212
213
  gameId: z.string().max(100).describe("The game ID"),
213
- kind: z.enum(["bounded", "monotonic", "conserved"]).describe("Constraint kind"),
214
- resourceId: z.string().max(100).optional().describe("Required for 'bounded' or 'monotonic': the resource to constrain"),
214
+ kind: z.enum(["bounded", "monotonic", "conserved", "resolve_only"]).describe("Constraint kind"),
215
+ resourceId: z.string().max(100).optional().describe("Required for 'bounded', 'monotonic' or 'resolve_only': the resource to constrain"),
215
216
  resourceIds: z.array(z.string().max(100)).optional().describe("Required for 'conserved': 2 or more resource IDs that must sum to `total`"),
216
217
  direction: z.enum(["increasing", "decreasing"]).optional().describe("Required for 'monotonic': the only direction the value may move"),
217
218
  total: z.number().optional().describe("Required for 'conserved': the fixed sum the resource set must maintain"),
219
+ factKey: z.string().max(100).optional().describe("For 'resolve_only' only: the fact key the constraint governs, defaulting to 'value'"),
218
220
  },
219
221
  annotations: ANNOTATIONS.CREATE,
220
- }, async ({ gameId, kind, resourceId, resourceIds, direction, total }) => {
222
+ }, async ({ gameId, kind, resourceId, resourceIds, direction, total, factKey }) => {
221
223
  try {
222
224
  let constraint;
223
225
  if (kind === "bounded") {
@@ -231,6 +233,11 @@ export function registerResourceTools(server) {
231
233
  }
232
234
  constraint = constraintTools.declareMonotonicConstraint({ gameId, resourceId, direction });
233
235
  }
236
+ else if (kind === "resolve_only") {
237
+ if (!resourceId)
238
+ throw new Error("resourceId is required for a 'resolve_only' constraint");
239
+ constraint = constraintTools.declareResolveOnlyConstraint({ gameId, resourceId, factKey });
240
+ }
234
241
  else {
235
242
  if (!resourceIds || total === undefined) {
236
243
  throw new Error("resourceIds and total are required for a 'conserved' constraint");
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerTimelineTools(server: McpServer): void;
@@ -0,0 +1,311 @@
1
+ import { z } from "zod";
2
+ import { ANNOTATIONS } from "../utils/tool-annotations.js";
3
+ import { createLogger } from "../utils/logger.js";
4
+ import { replay } from "../timeline/replay.js";
5
+ import { declareTimeAxis, setStoryTime, currentStoryTime } from "../timeline/clock.js";
6
+ import { declareIrreversible, listIrreversibleFacts } from "../timeline/irreversible.js";
7
+ import { exportTimelineToFile, importTimelineFromFile } from "../timeline/export.js";
8
+ import { changesWithin } from "../timeline/changes.js";
9
+ import { narrationConstraintAt } from "../timeline/narration.js";
10
+ const log = createLogger("timeline");
11
+ /**
12
+ * The `t` every tool below takes or returns. This is the surface an
13
+ * implementer actually meets the invariance property at (design §14, root
14
+ * CLAUDE.md hard rule 6) -- the description is deliberately short, because
15
+ * the property itself lives in `t.ts`'s `TimeAxis` doc comment; this is
16
+ * where a caller is reminded of it at the moment it matters.
17
+ */
18
+ const tSchema = z
19
+ .number()
20
+ .finite()
21
+ .describe("An opaque ordinal on this game's declared time axis -- never a datetime, and never an " +
22
+ "index into units you might later re-cut.");
23
+ /**
24
+ * Exactly the three variants `TimeAxis` (t.ts) allows, and no index-into-
25
+ * units fourth one -- the schema shape itself is part of what this issue
26
+ * asks for. `sequence` needs no `unit`; `elapsed`/`counter` need a
27
+ * caller-named one so the tool description alone can't be mistaken for
28
+ * always meaning "seconds".
29
+ */
30
+ const timeAxisSchema = z.discriminatedUnion("kind", [
31
+ z.object({
32
+ kind: z.literal("sequence").describe("The engine's own append ordinal, one tick per write. Default for a game that declares nothing."),
33
+ }),
34
+ z.object({
35
+ kind: z.literal("elapsed").describe("Time since a fixed origin."),
36
+ unit: z.string().min(1).max(100).describe("What the elapsed count is measured in (e.g. 'second', 'minute')."),
37
+ }),
38
+ z.object({
39
+ kind: z.literal("counter").describe("A count of things that happened -- turns, ticks -- not of things authored."),
40
+ unit: z.string().min(1).max(100).describe("What is being counted (e.g. 'turn', 'tick')."),
41
+ }),
42
+ ]);
43
+ export function registerTimelineTools(server) {
44
+ server.registerTool("replay_world_at", {
45
+ description: "Replay a game's world as it stood at a given t: every entity alive then, with every fact valid then. Works at any point in the game's recorded history, not only 'now'.",
46
+ inputSchema: {
47
+ gameId: z.string().max(100).describe("The game ID"),
48
+ t: tSchema,
49
+ },
50
+ annotations: ANNOTATIONS.READ_ONLY,
51
+ }, async ({ gameId, t }) => {
52
+ try {
53
+ const snapshot = replay({ gameId, t });
54
+ return { content: [{ type: "text", text: JSON.stringify(snapshot, null, 2) }] };
55
+ }
56
+ catch (error) {
57
+ log.error("replay_world_at failed", { gameId, t, error: error.message });
58
+ return {
59
+ content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
60
+ isError: true,
61
+ };
62
+ }
63
+ });
64
+ server.registerTool("changes_within", {
65
+ description: "List every event and fact-interval transition a game recorded in the half-open window [t0, t1) -- t0 is in, t1 is not. The range companion to replay_world_at's point query, for callers whose units have duration. A fact that both opens and closes inside the window returns two rows, one per endpoint. Returns rows and nothing else: no verdict, no severity, no judgement about whether the window is 'clean'. What a change inside a window means is the caller's policy, not the engine's.",
66
+ inputSchema: {
67
+ gameId: z.string().max(100).describe("The game ID"),
68
+ t0: tSchema.describe("Start of the window, inclusive. Same axis as every other t for this game."),
69
+ t1: tSchema.describe("End of the window, exclusive. Must be >= t0. There is no open-ended form and no start-plus-length form -- a window is two points on the axis, never a point and a duration."),
70
+ },
71
+ annotations: ANNOTATIONS.READ_ONLY,
72
+ }, async ({ gameId, t0, t1 }) => {
73
+ try {
74
+ const changeSet = changesWithin({ gameId, t0, t1 });
75
+ return { content: [{ type: "text", text: JSON.stringify(changeSet, null, 2) }] };
76
+ }
77
+ catch (error) {
78
+ log.error("changes_within failed", { gameId, t0, t1, error: error.message });
79
+ return {
80
+ content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
81
+ isError: true,
82
+ };
83
+ }
84
+ });
85
+ server.registerTool("get_story_time", {
86
+ description: "Get a game's current position on its own timeline: t, and the axis it is measured on.",
87
+ inputSchema: {
88
+ gameId: z.string().max(100).describe("The game ID"),
89
+ },
90
+ annotations: ANNOTATIONS.READ_ONLY,
91
+ }, async ({ gameId }) => {
92
+ try {
93
+ const storyTime = currentStoryTime(gameId);
94
+ if (!storyTime) {
95
+ return {
96
+ content: [
97
+ {
98
+ type: "text",
99
+ text: JSON.stringify({
100
+ error: `timeline: game '${gameId}' has no timeline clock yet -- nothing has been declared or written for it`,
101
+ }),
102
+ },
103
+ ],
104
+ isError: true,
105
+ };
106
+ }
107
+ return { content: [{ type: "text", text: JSON.stringify(storyTime, null, 2) }] };
108
+ }
109
+ catch (error) {
110
+ log.error("get_story_time failed", { gameId, error: error.message });
111
+ return {
112
+ content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
113
+ isError: true,
114
+ };
115
+ }
116
+ });
117
+ server.registerTool("declare_time_axis", {
118
+ description: "Declare the axis a game's t is measured on -- fixed for the life of that game's timeline once set for real. Call this before creating anything else so the game's world starts at its own origin, not partway up the engine's default append ordinal. A game that already has a recorded t (e.g. from its own creation) has a floor there: startAt below it is refused with the floor named, never silently shifted down to fit.",
119
+ inputSchema: {
120
+ gameId: z.string().max(100).describe("The game ID"),
121
+ axis: timeAxisSchema.describe("The time axis. There is no variant for an index into units you might later re-cut -- only sequence, elapsed, or counter."),
122
+ startAt: z
123
+ .number()
124
+ .finite()
125
+ .optional()
126
+ .describe("Where this axis's t should start (or continue); defaults to 0 for a brand-new game, or to the current t otherwise. Must never be behind the current t."),
127
+ },
128
+ annotations: ANNOTATIONS.IDEMPOTENT_UPDATE,
129
+ }, async ({ gameId, axis, startAt }) => {
130
+ try {
131
+ const storyTime = declareTimeAxis({ gameId, axis, startAt });
132
+ return { content: [{ type: "text", text: JSON.stringify(storyTime, null, 2) }] };
133
+ }
134
+ catch (error) {
135
+ log.error("declare_time_axis failed", {
136
+ gameId,
137
+ axis,
138
+ startAt,
139
+ error: error.message,
140
+ });
141
+ return {
142
+ content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
143
+ isError: true,
144
+ };
145
+ }
146
+ });
147
+ server.registerTool("set_story_time", {
148
+ description: "Move a game's t forward on its declared axis. Only works once a non-sequence axis has been declared -- the engine's own append ordinal cannot be positioned by a caller. Never moves t backwards, including below whatever floor the axis inherited at declaration time.",
149
+ inputSchema: {
150
+ gameId: z.string().max(100).describe("The game ID"),
151
+ t: tSchema,
152
+ },
153
+ annotations: ANNOTATIONS.IDEMPOTENT_UPDATE,
154
+ }, async ({ gameId, t }) => {
155
+ try {
156
+ const storyTime = setStoryTime({ gameId, t });
157
+ return { content: [{ type: "text", text: JSON.stringify(storyTime, null, 2) }] };
158
+ }
159
+ catch (error) {
160
+ log.error("set_story_time failed", { gameId, t, error: error.message });
161
+ return {
162
+ content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
163
+ isError: true,
164
+ };
165
+ }
166
+ });
167
+ server.registerTool("declare_fact_irreversible", {
168
+ description: "Mark the fact currently open for one entity and key as irreversible: from its valid_from_t onward, the engine refuses any later assertion of a different value under that key. The temporal member of the same constraint family as bounded, monotonic and conserved sets. Closing the fact, and deleting the entity, both remain legal -- ending a record is not asserting its opposite -- but reopening the key at a different value is still refused. Irreversibility cannot be withdrawn once declared. Because the flag is per-fact, a property you intend to declare irreversible must live under its own key: declaring it locks the whole value stored under that key.",
169
+ inputSchema: {
170
+ entityId: z
171
+ .string()
172
+ .max(100)
173
+ .describe("The entity ID -- the same id as the row it was projected from (e.g. a resource ID)"),
174
+ key: z
175
+ .string()
176
+ .max(200)
177
+ .describe("The fact key, i.e. the column name the fact was projected from (e.g. 'value')"),
178
+ },
179
+ annotations: ANNOTATIONS.IDEMPOTENT_UPDATE,
180
+ }, async ({ entityId, key }) => {
181
+ try {
182
+ const fact = declareIrreversible({ entityId, key });
183
+ return { content: [{ type: "text", text: JSON.stringify(fact, null, 2) }] };
184
+ }
185
+ catch (error) {
186
+ log.error("declare_fact_irreversible failed", {
187
+ entityId,
188
+ key,
189
+ error: error.message,
190
+ });
191
+ return {
192
+ content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
193
+ isError: true,
194
+ };
195
+ }
196
+ });
197
+ server.registerTool("list_irreversible_facts", {
198
+ description: "List every fact declared irreversible in a game, optionally narrowed to one entity. Each row carries the fact, its valid_from_t, and the event that opened it -- one hop of provenance, so a reviewer meeting a refusal can tell whether the fact is wrong or the claim is. Returns rows, never a verdict.",
199
+ inputSchema: {
200
+ gameId: z.string().max(100).describe("The game ID"),
201
+ entityId: z.string().max(100).optional().describe("Narrow the listing to one entity"),
202
+ },
203
+ annotations: ANNOTATIONS.READ_ONLY,
204
+ }, async ({ gameId, entityId }) => {
205
+ try {
206
+ const facts = listIrreversibleFacts({ gameId, entityId });
207
+ return { content: [{ type: "text", text: JSON.stringify(facts, null, 2) }] };
208
+ }
209
+ catch (error) {
210
+ log.error("list_irreversible_facts failed", {
211
+ gameId,
212
+ entityId,
213
+ error: error.message,
214
+ });
215
+ return {
216
+ content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
217
+ isError: true,
218
+ };
219
+ }
220
+ });
221
+ /**
222
+ * Both export tools deal in a PATH and return only counts, never the
223
+ * artifact itself. That is design §6's point -- the deliverable is a file
224
+ * the client owns, not a payload passed back through a conversation -- and
225
+ * it is also what keeps a whole world's timeline from being pasted into
226
+ * the caller's context window, which is how a tool result kills the
227
+ * conversation that asked for it.
228
+ */
229
+ server.registerTool("export_timeline", {
230
+ description: "Freeze a game's entire timeline -- every entity, every fact interval, every event, and the declared time axis -- into a JSON file the caller owns. Deterministic: the same world exports byte-identically every time. Carries no media references and no live table rows by design, so the file can be re-imported anywhere and answer replay(t) identically. Returns the path and row counts, never the artifact itself.",
231
+ inputSchema: {
232
+ gameId: z.string().max(100).describe("The game ID"),
233
+ filePath: z.string().min(1).max(4096).describe("Absolute path to write the export file to"),
234
+ },
235
+ annotations: ANNOTATIONS.IDEMPOTENT_UPDATE,
236
+ }, async ({ gameId, filePath }) => {
237
+ try {
238
+ const artifact = exportTimelineToFile({ gameId, filePath });
239
+ return {
240
+ content: [
241
+ {
242
+ type: "text",
243
+ text: JSON.stringify({
244
+ filePath,
245
+ gameId: artifact.gameId,
246
+ formatVersion: artifact.formatVersion,
247
+ entities: artifact.entities.length,
248
+ facts: artifact.facts.length,
249
+ events: artifact.events.length,
250
+ }, null, 2),
251
+ },
252
+ ],
253
+ };
254
+ }
255
+ catch (error) {
256
+ log.error("export_timeline failed", { gameId, filePath, error: error.message });
257
+ return {
258
+ content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
259
+ isError: true,
260
+ };
261
+ }
262
+ });
263
+ server.registerTool("import_timeline", {
264
+ description: "Restore a frozen timeline export into this database, verbatim -- ids and every t are carried through unchanged. Refuses rather than merges if the game already has any recorded history, and refuses an artifact whose rows do not all belong to the game it names. Imports the timeline only; the live tables are not repopulated.",
265
+ inputSchema: {
266
+ filePath: z.string().min(1).max(4096).describe("Absolute path of the export file to read"),
267
+ },
268
+ annotations: ANNOTATIONS.CREATE,
269
+ }, async ({ filePath }) => {
270
+ try {
271
+ const result = importTimelineFromFile(filePath);
272
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
273
+ }
274
+ catch (error) {
275
+ log.error("import_timeline failed", { filePath, error: error.message });
276
+ return {
277
+ content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
278
+ isError: true,
279
+ };
280
+ }
281
+ });
282
+ server.registerTool("narration_constraint_at", {
283
+ description: "Return what IS true for a game at t, and nothing else: every fact valid then on an entity alive then, plus every irreversible fact from valid_from_t onward even if it has since closed or its entity has been destroyed. Each fact carries one hop of causality -- the event that opened it, if any is recorded -- so a reviewer can tell whether a disputed fact or a disputed claim is the one that's wrong. This tool does not check anything and returns no verdict: it hands back the serialized constraint so a caller can run its own check (a live one now, or an offline lint over the saved JSON hours later) -- see the library-exported `contradictions` function for that half, which takes no database and is not exposed as a tool because wrapping a pure function in a tool call would defeat the point of it being callable offline.",
284
+ inputSchema: {
285
+ gameId: z.string().max(100).describe("The game ID"),
286
+ t: tSchema,
287
+ entityIds: z
288
+ .array(z.string().max(100))
289
+ .optional()
290
+ .describe("Narrow the constraint to only these entities. Omit for the whole game; an empty array narrows to nothing."),
291
+ },
292
+ annotations: ANNOTATIONS.READ_ONLY,
293
+ }, async ({ gameId, t, entityIds }) => {
294
+ try {
295
+ const constraint = narrationConstraintAt({ gameId, t, entityIds });
296
+ return { content: [{ type: "text", text: JSON.stringify(constraint, null, 2) }] };
297
+ }
298
+ catch (error) {
299
+ log.error("narration_constraint_at failed", {
300
+ gameId,
301
+ t,
302
+ entityIds,
303
+ error: error.message,
304
+ });
305
+ return {
306
+ content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
307
+ isError: true,
308
+ };
309
+ }
310
+ });
311
+ }