threenative-engine-mcp 0.2.6 → 0.2.8

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/README.md CHANGED
@@ -1,18 +1,48 @@
1
1
  # threenative-engine-mcp
2
2
 
3
- An offline MCP server that lets a ThreeNative authoring agent search the committed engine
4
- capability manifest by situation and inspect one capability in detail.
3
+ The ThreeNative planning gate uses this MCP server to discover engine capabilities and classic-game
4
+ mechanics. Start with the installed `threenative-capabilities` skill, then `prd-creator`; the skill
5
+ owns the discovery order and plan record.
5
6
 
6
- The server exposes exactly two read-only tools: `engine_search_capabilities` and
7
- `engine_capability_detail`.
7
+ The server exposes `engine_search_capabilities`, `engine_capability_detail`,
8
+ `engine_search_references`, `engine_reference_detail`, and `engine_save_reference`.
9
+ Capability search works offline. Full requests use `scope: "request"` (up to 15 deduplicated
10
+ matches); mechanic searches use `scope: "mechanic"` (up to five). Inspect `matchedSituation`
11
+ and the detail constraints before implementing. A `none` capability result includes up to three
12
+ reference suggestions.
8
13
 
9
14
  First infer the concrete mechanics implied by the request. Call `engine_search_capabilities` once
10
15
  with that mechanically explicit request and `scope: "request"`, then once per mechanic with
11
16
  `scope: "mechanic"`. Complete requests return up to 15 deduplicated capabilities; focused
12
- situations return up to five. A genre label alone returns no guessed recipe. Every result includes
17
+ situations return up to five. A request that names a game or genre a starter kit covers (`like mario`, `zelda-style`) leads with a
18
+ `starterKits` entry carrying `npx create-threenative <dir> --template <name>`; scaffold it first, then
19
+ search the mechanics it does not cover. The kits and their words come from each `kit.json` through
20
+ the manifest's `starterKits` section (verdict `"starterKit"` when no mechanic also matched). Every result includes
13
21
  `matchedSituation`, so an authoring agent can verify why it was selected instead of treating a
14
22
  lexical coincidence as engine guidance.
15
23
 
24
+ Reference search reads a gzipped stub index; detail fetches one pinned game's map and cards lazily.
25
+ The source is the latest release of [threenative-reverse-engineer-mcp](https://github.com/ThreeNativeHQ/threenative-reverse-engineer-mcp),
26
+ separate CC-BY 4.0 data. Downloads validate before replacing the cache in
27
+ `$XDG_CACHE_HOME/threenative/refs` (default `~/.cache/threenative/refs`), refresh with ETags, and
28
+ work offline from that cache. The index has a 5-second fetch budget; game files have 30 seconds,
29
+ including their response bodies. `THREENATIVE_REFERENCES_URL` overrides the release asset base URL;
30
+ `THREENATIVE_REFERENCES_CACHE` overrides the cache directory. Without network or cache, tools report
31
+ `references unavailable` and capability discovery keeps working.
32
+
33
+ Saved references live in the game's `.threenative/refs/<game-id>.json`. Local entries take priority;
34
+ published entries without a local override remain searchable and loadable, with their own pinned SHA.
35
+ Successive local saves merge by id at the same commit SHA. A conflicting SHA is rejected without changing the saved file.
36
+ The save tool returns a `gh issue create` command for a POSIX shell; run it in that shell.
37
+ It does not send the contribution. Reference guardrail: mechanics and feel only, with pinned
38
+ `file:line` sources; no code, level layouts, character names or art.
39
+
40
+ ## One call from the command line
41
+
42
+ A session started outside the game folder never loads its `.mcp.json`. It calls a tool once
43
+ instead: `node <game>/node_modules/@threenative/core/mcp/engine.mjs engine_search_capabilities
44
+ '{"situation":"triple jump"}'` prints the tool's answer and exits 0, or names the error and exits 1.
45
+
16
46
  ## Which manifest the server reads
17
47
 
18
48
  The first that exists wins, so the manifest can never drift from the engine the project runs:
package/dist/index.d.ts CHANGED
@@ -23,6 +23,20 @@ interface ICapabilityManifest {
23
23
  readonly version: number;
24
24
  readonly entries: readonly ICapabilityEntry[];
25
25
  readonly notOwned: readonly INotOwnedCapability[];
26
+ readonly starterKits?: readonly IStarterKit[];
27
+ }
28
+ interface IStarterKit {
29
+ readonly name: string;
30
+ readonly title: string;
31
+ readonly blurb: string;
32
+ /** Genre plus kit.json aliases, lowercase. */
33
+ readonly words: readonly string[];
34
+ }
35
+ interface IStarterKitResult {
36
+ readonly name: string;
37
+ readonly title: string;
38
+ readonly blurb: string;
39
+ readonly command: string;
26
40
  }
27
41
  interface ICapabilitySearchResult {
28
42
  readonly symbol: string;
@@ -35,7 +49,12 @@ interface ICapabilitySearchResult {
35
49
  readonly score: number;
36
50
  }
37
51
  interface ICapabilitySearchResponse {
38
- readonly verdict: "matched" | "none";
52
+ /** Present when the request names a genre or game a starter kit covers; it leads the response. */
53
+ readonly starterKits?: readonly IStarterKitResult[];
54
+ /** Present when the request asks to reverse engineer a game: the tools that read its pinned decomp. */
55
+ readonly referenceTools?: readonly string[];
56
+ /** "starterKit": a kit matched but no engine capability did; scaffold the kit, then search its gaps. */
57
+ readonly verdict: "matched" | "none" | "starterKit";
39
58
  readonly results: readonly ICapabilitySearchResult[];
40
59
  readonly guidance: string;
41
60
  }
@@ -46,21 +65,21 @@ interface IEngineTool {
46
65
  readonly description: string;
47
66
  readonly annotations: {
48
67
  readonly destructiveHint: false;
49
- readonly openWorldHint: false;
50
- readonly readOnlyHint: true;
68
+ readonly openWorldHint: boolean;
69
+ readonly readOnlyHint: boolean;
51
70
  };
52
71
  readonly inputSchema: {
53
72
  readonly type: "object";
54
73
  readonly properties: Record<string, {
55
74
  readonly description?: string;
56
75
  readonly enum?: readonly string[];
57
- readonly type: "string";
76
+ readonly type: "string" | "object";
58
77
  }>;
59
78
  readonly required: readonly string[];
60
79
  readonly additionalProperties: false;
61
80
  };
62
81
  }
63
- declare const ENGINE_MCP_TOOL_NAMES: readonly ["engine_search_capabilities", "engine_capability_detail"];
82
+ declare const ENGINE_MCP_TOOL_NAMES: readonly ["engine_search_capabilities", "engine_capability_detail", "engine_search_references", "engine_reference_detail", "engine_save_reference"];
64
83
  /** Chosen by the current-manifest threshold sweep: the highest recalled candidate at 0.27. */
65
84
  declare const RELEVANCE_FLOOR = 0.27;
66
85
  declare function defaultManifestPath(cwd?: string): string;
@@ -81,7 +100,16 @@ declare function toolDefinitions(): readonly IEngineTool[];
81
100
  * line must be consumed silently. Extracted so the stdio contract is testable without a live
82
101
  * `process.stdin`: this framing is the whole surface an authoring agent's MCP client speaks.
83
102
  */
84
- declare function handleLine(line: string, manifestFile: string): string | undefined;
103
+ declare function handleLine(line: string, manifestFile: string): Promise<string | undefined>;
104
+ /**
105
+ * One tool call from the command line: `engine.mjs <tool> '<json arguments>'` prints the tool's
106
+ * text and exits 0, or names the error and exits 1. A session started outside the project never
107
+ * loads its `.mcp.json`, and this is how it still searches (PRD-750 kit-loop row 1).
108
+ */
109
+ declare function runCli(argv: readonly string[], manifestFile?: string, io?: {
110
+ stderr: (text: string) => void;
111
+ stdout: (text: string) => void;
112
+ }): Promise<number>;
85
113
  declare function runServer(manifestFile?: string): void;
86
114
 
87
- export { ENGINE_MCP_TOOL_NAMES, type ICapabilityDetail, type ICapabilityEntry, type ICapabilityManifest, type ICapabilitySearchResponse, type ICapabilitySearchResult, type IEngineTool, type INotOwnedCapability, RELEVANCE_FLOOR, capabilityDetail, capabilityDetails, capabilitySituationTokens, defaultManifestPath, handleLine, loadCapabilityManifest, runServer, searchCapabilities, toolDefinitions };
115
+ export { ENGINE_MCP_TOOL_NAMES, type ICapabilityDetail, type ICapabilityEntry, type ICapabilityManifest, type ICapabilitySearchResponse, type ICapabilitySearchResult, type IEngineTool, type INotOwnedCapability, type IStarterKit, type IStarterKitResult, RELEVANCE_FLOOR, capabilityDetail, capabilityDetails, capabilitySituationTokens, defaultManifestPath, handleLine, loadCapabilityManifest, runCli, runServer, searchCapabilities, toolDefinitions };
package/dist/index.js CHANGED
@@ -1,12 +1,444 @@
1
1
  #!/usr/bin/env node
2
- import { realpathSync, readFileSync, existsSync, mkdirSync, appendFileSync } from 'fs';
3
- import path from 'path';
2
+ import { realpathSync, readFileSync, existsSync, mkdirSync, rmSync, writeFileSync, appendFileSync, renameSync, readdirSync } from 'fs';
3
+ import path2 from 'path';
4
4
  import { createInterface } from 'readline';
5
5
  import { fileURLToPath } from 'url';
6
+ import { createHash } from 'crypto';
7
+ import os from 'os';
8
+ import { gunzipSync } from 'zlib';
6
9
 
10
+ var DEFAULT_REFERENCES_URL = "https://github.com/ThreeNativeHQ/threenative-reverse-engineer-mcp/releases/latest/download/";
11
+ var FETCH_TIMEOUT_MS = 5e3;
12
+ var GAME_FETCH_TIMEOUT_MS = 3e4;
13
+ var ID = /^[a-z0-9][a-z0-9-]*$/u;
14
+ function referencesBaseUrl() {
15
+ const value = process.env.THREENATIVE_REFERENCES_URL?.trim() || DEFAULT_REFERENCES_URL;
16
+ return value.endsWith("/") ? value : `${value}/`;
17
+ }
18
+ function referencesCacheDir() {
19
+ const override = process.env.THREENATIVE_REFERENCES_CACHE?.trim();
20
+ const source = createHash("sha256").update(referencesBaseUrl()).digest("hex").slice(0, 16);
21
+ if (override) return path2.join(path2.resolve(override), source);
22
+ const cacheHome = process.env.XDG_CACHE_HOME?.trim() || path2.join(os.homedir(), ".cache");
23
+ return path2.join(cacheHome, "threenative", "refs", source);
24
+ }
25
+ function localReferencesDir(cwd = process.cwd()) {
26
+ return path2.join(cwd, ".threenative", "refs");
27
+ }
28
+ function fail(where, reason) {
29
+ throw new Error(`TN_REFERENCES_MALFORMED: ${where}: ${reason}`);
30
+ }
31
+ function record(value, where, keys) {
32
+ if (typeof value !== "object" || value === null || Array.isArray(value))
33
+ fail(where, "expected an object");
34
+ const unknown = Object.keys(value).filter((key) => !keys.includes(key));
35
+ if (unknown.length > 0)
36
+ fail(where, `unknown field ${unknown.map((key) => `'${key}'`).join(", ")}`);
37
+ return value;
38
+ }
39
+ function text(value, where) {
40
+ if (typeof value !== "string" || value.trim().length === 0)
41
+ fail(where, "expected a non-empty string");
42
+ if (/```|~~~/u.test(value)) fail(where, "holds a code block; describe the behaviour instead");
43
+ return value;
44
+ }
45
+ function id(value, where) {
46
+ const result = text(value, where);
47
+ if (!ID.test(result)) fail(where, `'${result}' is not a lowercase kebab-case id`);
48
+ return result;
49
+ }
50
+ function texts(value, where) {
51
+ if (!Array.isArray(value)) fail(where, "expected an array of strings");
52
+ return value.map((entry, index) => text(entry, `${where}[${index}]`));
53
+ }
54
+ function list(value, where, item) {
55
+ if (!Array.isArray(value)) fail(where, "expected an array");
56
+ return value.map((entry, index) => item(entry, `${where}[${index}]`));
57
+ }
58
+ function line(value, where) {
59
+ if (!Number.isInteger(value) || value < 1) fail(where, "expected a line number");
60
+ return value;
61
+ }
62
+ function sourcePath(value, where) {
63
+ const result = text(value, where);
64
+ if (path2.isAbsolute(result) || result.split("/").includes("..") || !/^[a-zA-Z0-9_./-]+$/u.test(result))
65
+ fail(where, "expected a safe relative source path");
66
+ return result;
67
+ }
68
+ function prose(value, where) {
69
+ const result = text(value, where);
70
+ if (result.includes("```")) fail(where, "holds a code block; describe the behaviour instead");
71
+ return result;
72
+ }
73
+ function validateStub(value, where) {
74
+ const stub = record(value, where, [
75
+ "id",
76
+ "game",
77
+ "kind",
78
+ "name",
79
+ "summary",
80
+ "tags",
81
+ "capabilities"
82
+ ]);
83
+ if (stub.kind !== "node" && stub.kind !== "card") fail(`${where}.kind`, "expected node or card");
84
+ return {
85
+ capabilities: texts(stub.capabilities, `${where}.capabilities`),
86
+ game: id(stub.game, `${where}.game`),
87
+ id: id(stub.id, `${where}.id`),
88
+ kind: stub.kind,
89
+ name: text(stub.name, `${where}.name`),
90
+ summary: prose(stub.summary, `${where}.summary`),
91
+ tags: texts(stub.tags, `${where}.tags`)
92
+ };
93
+ }
94
+ function validateReferenceIndex(value, where = "index") {
95
+ const index = record(value, where, ["version", "games", "stubs"]);
96
+ if (index.version !== 1) fail(`${where}.version`, "expected 1");
97
+ const games = list(index.games, `${where}.games`, (entry, at) => {
98
+ const game = record(entry, at, [
99
+ "id",
100
+ "title",
101
+ "platform",
102
+ "repo",
103
+ "tier",
104
+ "kind",
105
+ "rating",
106
+ "genre",
107
+ "subgenre"
108
+ ]);
109
+ if (game.tier !== 1 && game.tier !== 2) fail(`${at}.tier`, "expected 1 or 2");
110
+ return {
111
+ id: id(game.id, `${at}.id`),
112
+ platform: text(game.platform, `${at}.platform`),
113
+ repo: text(game.repo, `${at}.repo`),
114
+ tier: game.tier,
115
+ title: text(game.title, `${at}.title`)
116
+ };
117
+ });
118
+ const stubs = list(index.stubs, `${where}.stubs`, validateStub);
119
+ unique(
120
+ games.map((game) => game.id),
121
+ `${where}.games`
122
+ );
123
+ unique(
124
+ stubs.map((stub) => `${stub.game}/${stub.kind}/${stub.id}`),
125
+ `${where}.stubs`
126
+ );
127
+ for (const stub of stubs)
128
+ if (!games.some((game) => game.id === stub.game)) fail(where, `missing game '${stub.game}'`);
129
+ return { games, stubs, version: 1 };
130
+ }
131
+ function validateNode(value, where) {
132
+ const node = record(value, where, [
133
+ "id",
134
+ "name",
135
+ "purpose",
136
+ "parent",
137
+ "paths",
138
+ "functions",
139
+ "dependsOn"
140
+ ]);
141
+ return {
142
+ dependsOn: texts(node.dependsOn, `${where}.dependsOn`),
143
+ functions: list(node.functions, `${where}.functions`, (entry, at) => {
144
+ const fn = record(entry, at, ["name", "path", "lines"]);
145
+ if (!Array.isArray(fn.lines) || fn.lines.length !== 2)
146
+ fail(`${at}.lines`, "expected [from, to]");
147
+ const from = line(fn.lines[0], `${at}.lines[0]`);
148
+ const to = line(fn.lines[1], `${at}.lines[1]`);
149
+ if (to < from) fail(`${at}.lines`, "ends before it starts");
150
+ return {
151
+ lines: [from, to],
152
+ name: text(fn.name, `${at}.name`),
153
+ path: sourcePath(fn.path, `${at}.path`)
154
+ };
155
+ }),
156
+ id: id(node.id, `${where}.id`),
157
+ name: text(node.name, `${where}.name`),
158
+ paths: list(node.paths, `${where}.paths`, sourcePath),
159
+ purpose: prose(node.purpose, `${where}.purpose`),
160
+ ...node.parent === void 0 ? {} : { parent: id(node.parent, `${where}.parent`) }
161
+ };
162
+ }
163
+ function validateCard(value, where) {
164
+ const card = record(value, where, [
165
+ "id",
166
+ "node",
167
+ "name",
168
+ "summary",
169
+ "behavior",
170
+ "constants",
171
+ "sources",
172
+ "capabilities",
173
+ "tags"
174
+ ]);
175
+ const sources = list(card.sources, `${where}.sources`, (entry, at) => {
176
+ const source = record(entry, at, ["path", "line"]);
177
+ return { line: line(source.line, `${at}.line`), path: sourcePath(source.path, `${at}.path`) };
178
+ });
179
+ if (sources.length === 0) fail(`${where}.sources`, "expected at least one source");
180
+ return {
181
+ behavior: prose(card.behavior, `${where}.behavior`),
182
+ capabilities: texts(card.capabilities, `${where}.capabilities`),
183
+ constants: list(card.constants, `${where}.constants`, (entry, at) => {
184
+ const constant = record(entry, at, ["name", "value", "unit", "source"]);
185
+ if ((typeof constant.value !== "number" || !Number.isFinite(constant.value)) && typeof constant.value !== "string")
186
+ fail(`${at}.value`, "expected a number or a string");
187
+ const source = text(constant.source, `${at}.source`);
188
+ const citation = /^(.*):([1-9][0-9]*)$/u.exec(source);
189
+ if (!citation) fail(`${at}.source`, "expected source path:line");
190
+ sourcePath(citation[1], `${at}.source`);
191
+ return {
192
+ name: text(constant.name, `${at}.name`),
193
+ source,
194
+ unit: text(constant.unit, `${at}.unit`),
195
+ value: typeof constant.value === "string" ? text(constant.value, `${at}.value`) : constant.value
196
+ };
197
+ }),
198
+ id: id(card.id, `${where}.id`),
199
+ name: text(card.name, `${where}.name`),
200
+ node: id(card.node, `${where}.node`),
201
+ sources,
202
+ summary: prose(card.summary, `${where}.summary`),
203
+ tags: texts(card.tags, `${where}.tags`)
204
+ };
205
+ }
206
+ function unique(ids, where) {
207
+ const seen = /* @__PURE__ */ new Set();
208
+ for (const value of ids) {
209
+ if (seen.has(value)) fail(where, `duplicate id '${value}'`);
210
+ seen.add(value);
211
+ }
212
+ }
213
+ function validateReferenceGame(value, where = "game") {
214
+ const game = record(value, where, ["version", "id", "title", "repo", "sha", "nodes", "cards"]);
215
+ if (game.version !== 1) fail(`${where}.version`, "expected 1");
216
+ const nodes = list(game.nodes, `${where}.nodes`, validateNode);
217
+ const cards = list(game.cards, `${where}.cards`, validateCard);
218
+ unique(
219
+ nodes.map((node) => node.id),
220
+ `${where}.nodes`
221
+ );
222
+ unique(
223
+ cards.map((card) => card.id),
224
+ `${where}.cards`
225
+ );
226
+ const nodeIds = new Set(nodes.map((node) => node.id));
227
+ for (const node of nodes) {
228
+ for (const dependency of node.dependsOn)
229
+ if (!nodeIds.has(dependency)) fail(where, `missing dependency '${dependency}'`);
230
+ const ancestors = /* @__PURE__ */ new Set([node.id]);
231
+ let parent = node.parent;
232
+ while (parent !== void 0) {
233
+ if (ancestors.has(parent)) fail(where, `cyclic parent '${parent}'`);
234
+ ancestors.add(parent);
235
+ parent = nodes.find((entry) => entry.id === parent)?.parent;
236
+ }
237
+ }
238
+ for (const node of nodes)
239
+ if (node.parent !== void 0 && !nodeIds.has(node.parent))
240
+ fail(`${where}.nodes.${node.id}.parent`, `no node '${node.parent}'`);
241
+ for (const card of cards)
242
+ if (!nodeIds.has(card.node)) fail(`${where}.cards.${card.id}.node`, `no node '${card.node}'`);
243
+ const sha = text(game.sha, `${where}.sha`);
244
+ if (!/^[0-9a-f]{40}$/u.test(sha)) fail(`${where}.sha`, "expected a commit SHA");
245
+ return {
246
+ cards,
247
+ id: id(game.id, `${where}.id`),
248
+ nodes,
249
+ repo: text(game.repo, `${where}.repo`),
250
+ sha,
251
+ title: text(game.title, `${where}.title`),
252
+ version: 1
253
+ };
254
+ }
255
+ function referenceStubs(game) {
256
+ return [
257
+ ...game.nodes.map((node) => ({
258
+ capabilities: [],
259
+ game: game.id,
260
+ id: node.id,
261
+ kind: "node",
262
+ name: node.name,
263
+ summary: node.purpose,
264
+ tags: []
265
+ })),
266
+ ...game.cards.map((card) => ({
267
+ capabilities: card.capabilities,
268
+ game: game.id,
269
+ id: card.id,
270
+ kind: "card",
271
+ name: card.name,
272
+ summary: card.summary,
273
+ tags: card.tags
274
+ }))
275
+ ];
276
+ }
277
+ async function cachedAsset(name, parse, timeout = FETCH_TIMEOUT_MS) {
278
+ const file = path2.join(referencesCacheDir(), name);
279
+ const etagFile = `${file}.etag`;
280
+ let response;
281
+ try {
282
+ const etag2 = existsSync(file) && existsSync(etagFile) ? readFileSync(etagFile, "utf8") : "";
283
+ response = await fetch(new URL(name, referencesBaseUrl()), {
284
+ headers: etag2.length > 0 ? { "if-none-match": etag2 } : {},
285
+ signal: AbortSignal.timeout(timeout)
286
+ });
287
+ } catch (error) {
288
+ if (existsSync(file)) return parse(readFileSync(file));
289
+ throw new Error(`TN_REFERENCES_UNAVAILABLE: ${name}: ${String(error)}`);
290
+ }
291
+ if (response.status === 304 && existsSync(file)) return parse(readFileSync(file));
292
+ if (!response.ok) {
293
+ if (existsSync(file)) return parse(readFileSync(file));
294
+ throw new Error(`TN_REFERENCES_UNAVAILABLE: ${name}: HTTP ${response.status}`);
295
+ }
296
+ let body;
297
+ try {
298
+ body = Buffer.from(await response.arrayBuffer());
299
+ } catch (error) {
300
+ if (existsSync(file)) return parse(readFileSync(file));
301
+ throw new Error(`TN_REFERENCES_UNAVAILABLE: ${name}: ${String(error)}`);
302
+ }
303
+ const parsed = parse(body);
304
+ mkdirSync(path2.dirname(file), { recursive: true });
305
+ atomicWrite(file, body);
306
+ const etag = response.headers.get("etag");
307
+ if (etag === null) rmSync(etagFile, { force: true });
308
+ else writeFileSync(etagFile, etag);
309
+ return parsed;
310
+ }
311
+ function parseJson(body, name) {
312
+ try {
313
+ return JSON.parse(body.toString("utf8"));
314
+ } catch (error) {
315
+ return fail(name, `cannot parse JSON: ${String(error)}`);
316
+ }
317
+ }
318
+ var indexLoad;
319
+ var gameLoads = /* @__PURE__ */ new Map();
320
+ function loadReferenceIndex() {
321
+ indexLoad ??= cachedAsset(
322
+ "index.json.gz",
323
+ (body) => validateReferenceIndex(parseJson(gunzipSync(body), "index.json.gz"), "index.json.gz")
324
+ ).catch((error) => {
325
+ indexLoad = void 0;
326
+ throw error;
327
+ });
328
+ return indexLoad;
329
+ }
330
+ function loadLocalReferenceGames(cwd = process.cwd()) {
331
+ const directory = localReferencesDir(cwd);
332
+ if (!existsSync(directory)) return [];
333
+ return readdirSync(directory).filter((file) => file.endsWith(".json")).sort().map((file) => {
334
+ const game = validateReferenceGame(
335
+ parseJson(readFileSync(path2.join(directory, file)), file),
336
+ path2.join(".threenative", "refs", file)
337
+ );
338
+ if (game.id !== path2.basename(file, ".json"))
339
+ fail(file, `id '${game.id}' differs from filename`);
340
+ return game;
341
+ });
342
+ }
343
+ function loadReferenceGame(gameId, cwd = process.cwd()) {
344
+ id(gameId, "game id");
345
+ const file = path2.join(localReferencesDir(cwd), `${gameId}.json`);
346
+ if (existsSync(file)) {
347
+ const local = validateReferenceGame(parseJson(readFileSync(file), file), file);
348
+ if (local.id !== gameId) fail(file, `id '${local.id}' differs from requested '${gameId}'`);
349
+ return Promise.resolve(local);
350
+ }
351
+ return loadReleaseReferenceGame(gameId);
352
+ }
353
+ function loadReleaseReferenceGame(gameId) {
354
+ const name = `game-${id(gameId, "game id")}.json`;
355
+ let load = gameLoads.get(name);
356
+ if (load === void 0) {
357
+ load = cachedAsset(
358
+ name,
359
+ (body) => {
360
+ const game = validateReferenceGame(parseJson(body, name), name);
361
+ if (game.id !== gameId) fail(name, `id '${game.id}' differs from requested '${gameId}'`);
362
+ return game;
363
+ },
364
+ GAME_FETCH_TIMEOUT_MS
365
+ ).catch((error) => {
366
+ gameLoads.delete(name);
367
+ throw error;
368
+ });
369
+ gameLoads.set(name, load);
370
+ }
371
+ return load;
372
+ }
373
+ function atomicWrite(file, value) {
374
+ const temporary = `${file}.${process.pid}.tmp`;
375
+ try {
376
+ writeFileSync(temporary, value);
377
+ renameSync(temporary, file);
378
+ } finally {
379
+ rmSync(temporary, { force: true });
380
+ }
381
+ }
382
+ function shellQuote(value) {
383
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
384
+ }
385
+ var CONTRIBUTION_REPO = "ThreeNativeHQ/threenative-reverse-engineer-mcp";
386
+ function saveReferenceGame(value, cwd = process.cwd()) {
387
+ const incoming = record(value, "reference", [
388
+ "version",
389
+ "id",
390
+ "title",
391
+ "repo",
392
+ "sha",
393
+ "nodes",
394
+ "cards"
395
+ ]);
396
+ const gameId = id(incoming.id, "reference.id");
397
+ const file = path2.join(localReferencesDir(cwd), `${gameId}.json`);
398
+ const previous = existsSync(file) ? validateReferenceGame(parseJson(readFileSync(file), file), file) : void 0;
399
+ if (previous !== void 0 && previous.sha !== incoming.sha)
400
+ fail(
401
+ "reference.sha",
402
+ `saved game '${gameId}' is pinned to ${previous.sha}; cannot merge revision ${String(incoming.sha)}`
403
+ );
404
+ const merge = (old, next) => {
405
+ if (!Array.isArray(next)) fail("reference", "expected an array");
406
+ const replacements = new Set(
407
+ next.map(
408
+ (entry) => typeof entry === "object" && entry !== null && "id" in entry ? entry.id : void 0
409
+ )
410
+ );
411
+ return [...old.filter((entry) => !replacements.has(entry.id)), ...next];
412
+ };
413
+ const merged = validateReferenceGame(
414
+ previous === void 0 ? incoming : {
415
+ ...incoming,
416
+ cards: merge(previous.cards, incoming.cards),
417
+ nodes: merge(previous.nodes, incoming.nodes)
418
+ },
419
+ "reference"
420
+ );
421
+ mkdirSync(path2.dirname(file), { recursive: true });
422
+ atomicWrite(file, `${JSON.stringify(merged, null, 2)}
423
+ `);
424
+ const relative = path2.relative(cwd, file);
425
+ const title = `Reference cards: ${merged.title} (${merged.nodes.length} nodes, ${merged.cards.length} cards)`;
426
+ return {
427
+ contribution: {
428
+ command: `gh issue create --repo ${CONTRIBUTION_REPO} --title ${shellQuote(title)} --body-file ${shellQuote(relative)}`,
429
+ title
430
+ },
431
+ saved: relative
432
+ };
433
+ }
434
+
435
+ // src/index.ts
7
436
  var ENGINE_MCP_TOOL_NAMES = [
8
437
  "engine_search_capabilities",
9
- "engine_capability_detail"
438
+ "engine_capability_detail",
439
+ "engine_search_references",
440
+ "engine_reference_detail",
441
+ "engine_save_reference"
10
442
  ];
11
443
  var DEFAULT_MANIFEST_FILE = "capabilities.json";
12
444
  var STOP_WORDS = /* @__PURE__ */ new Set([
@@ -125,29 +557,29 @@ var STOP_WORDS = /* @__PURE__ */ new Set([
125
557
  var MAX_COMPLETE_REQUEST_RESULTS = 15;
126
558
  var MAX_SITUATION_RESULTS = 5;
127
559
  var RELEVANCE_FLOOR = 0.27;
128
- var AUTHORING_INSTRUCTIONS = `Before authoring, infer concrete gameplay mechanics. Preserve the request's distinctive fantasy: choose the smallest loop that uses its characteristic setting, traversal medium, or simulation instead of a generic character game with themed props, and search those implied mechanics even when the user did not name engine terms. Search the mechanically explicit complete request with scope "request", then each mechanic with scope "mechanic". A genre label alone is not a capability query: clarify or decompose it; do not assume a preset. Inspect capability detail and obey constraints before implementing. Capability detail is authoritative on platform support: never invent a platform limitation it does not state. A response with verdict "none" is an actionable answer: follow its guidance and write game-owned behavior in src/ instead of rephrasing the same request.`;
560
+ var AUTHORING_INSTRUCTIONS = "Invoke threenative-capabilities to route authoring. New games: steps 1-8 \u2014 prompt, matching starter kit (minimal fallback), mechanics references PLUS built-in capabilities, core-mechanics plan via prd-creator, required textures, execute, mechanics playtest, reference-image visual judge. Existing games: steps 5-7 only \u2014 required textures, execute and mechanics playtest using existing context; no kit selection, rediscovery, whole-game replanning or added step 8. In both routes, execute plan with subagents focused on specific areas: bounded tasks, file ownership, then coordinator integration before step 7 mechanics playtests. Follow binding capability constraints. References borrow mechanics only: no code, level layouts, character names or art; cite pinned file:line sources.";
129
561
  var GENERIC_GUIDANCE = "No installed engine capability matches this situation. Decompose it into concrete mechanics and write the game-owned behavior in your project's src/; inspect the relevant template AGENTS.md before adding a package.";
130
562
  function defaultManifestPath(cwd = process.cwd()) {
131
563
  const override = process.env.THREENATIVE_CAPABILITIES_MANIFEST;
132
- if (override !== void 0 && override.trim().length > 0) return path.resolve(cwd, override);
133
- for (let directory = path.resolve(cwd); ; directory = path.dirname(directory)) {
134
- const nodeModules = path.join(directory, "node_modules");
135
- const installed = path.join(nodeModules, "@threenative", "core", "capabilities.json");
564
+ if (override !== void 0 && override.trim().length > 0) return path2.resolve(cwd, override);
565
+ for (let directory = path2.resolve(cwd); ; directory = path2.dirname(directory)) {
566
+ const nodeModules = path2.join(directory, "node_modules");
567
+ const installed = path2.join(nodeModules, "@threenative", "core", "capabilities.json");
136
568
  if (existsSync(installed)) {
137
- const relative = path.relative(realpathSync(directory), realpathSync(nodeModules));
138
- if (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
569
+ const relative = path2.relative(realpathSync(directory), realpathSync(nodeModules));
570
+ if (relative !== ".." && !relative.startsWith(`..${path2.sep}`) && !path2.isAbsolute(relative))
139
571
  return installed;
140
572
  }
141
- const parent = path.dirname(directory);
573
+ const parent = path2.dirname(directory);
142
574
  if (parent === directory) break;
143
575
  }
144
- for (let directory = path.dirname(fileURLToPath(import.meta.url)); ; directory = path.dirname(directory)) {
145
- const repository = path.join(directory, "packages", "core", "capabilities.json");
576
+ for (let directory = path2.dirname(fileURLToPath(import.meta.url)); ; directory = path2.dirname(directory)) {
577
+ const repository = path2.join(directory, "packages", "core", "capabilities.json");
146
578
  if (existsSync(repository)) return repository;
147
- const parent = path.dirname(directory);
579
+ const parent = path2.dirname(directory);
148
580
  if (parent === directory) break;
149
581
  }
150
- return path.resolve(cwd, DEFAULT_MANIFEST_FILE);
582
+ return path2.resolve(cwd, DEFAULT_MANIFEST_FILE);
151
583
  }
152
584
  function manifestError(file, reason) {
153
585
  return new Error(`TN_ENGINE_CAPABILITIES_MANIFEST: ${file}: ${reason}`);
@@ -170,6 +602,11 @@ function validateManifest(value, file) {
170
602
  throw manifestError(file, `entry ${index} is malformed`);
171
603
  }
172
604
  }
605
+ for (const [index, raw] of (Array.isArray(value.starterKits) ? value.starterKits : []).entries()) {
606
+ if (!isRecord(raw) || typeof raw.name !== "string" || typeof raw.title !== "string" || typeof raw.blurb !== "string" || !isStringArray(raw.words)) {
607
+ throw manifestError(file, `starterKits ${index} is malformed`);
608
+ }
609
+ }
173
610
  const notOwnedIds = /* @__PURE__ */ new Set();
174
611
  for (const [index, raw] of value.notOwned.entries()) {
175
612
  if (!isRecord(raw) || typeof raw.id !== "string" || raw.id.trim().length === 0 || !Array.isArray(raw.situations) || raw.situations.length === 0 || !raw.situations.every(
@@ -245,13 +682,13 @@ function agreement(situation, query) {
245
682
  }
246
683
  function scorePhrase(value, queryTokens, queryText, weights) {
247
684
  const phrase = tokens(value);
248
- const unique = [...new Set(phrase)];
249
- const total = unique.reduce((sum, token) => sum + weightOf(weights, token), 0);
250
- const matched = unique.filter((token) => queryTokens.has(token)).reduce((sum, token) => sum + weightOf(weights, token), 0);
685
+ const unique2 = [...new Set(phrase)];
686
+ const total = unique2.reduce((sum, token) => sum + weightOf(weights, token), 0);
687
+ const matched = unique2.filter((token) => queryTokens.has(token)).reduce((sum, token) => sum + weightOf(weights, token), 0);
251
688
  const phraseBonus = phrase.join(" ").includes(queryText) || queryText.includes(phrase.join(" ")) ? 1 : 0;
252
689
  if (total === 0 || matched === 0 && phraseBonus === 0) return void 0;
253
690
  const coverage = matched / total;
254
- const agreed = agreement(unique, queryTokens);
691
+ const agreed = agreement(unique2, queryTokens);
255
692
  return { agreed, coverage, matched, phraseBonus, score: coverage * agreed + phraseBonus };
256
693
  }
257
694
  function isDistinctivePhrase(candidate) {
@@ -335,10 +772,62 @@ function capabilitySearchKey(entry) {
335
772
  ${entry.summary}
336
773
  ${entry.situations.join("\n")}`;
337
774
  }
775
+ function matchedStarterKits(situation, kits) {
776
+ const words = (text2) => ` ${text2.toLowerCase().replace(/[^a-z0-9]+/gu, " ").trim()} `;
777
+ const request = words(situation);
778
+ const claims = kits.map((kit) => ({
779
+ kit,
780
+ spans: kit.words.flatMap((word) => {
781
+ const at = request.indexOf(words(word));
782
+ return at < 0 ? [] : [{ at, end: at + words(word).length }];
783
+ })
784
+ }));
785
+ const inside = (span, own) => claims.some(
786
+ (other) => other !== own && other.spans.some(
787
+ (o) => o.at <= span.at && o.end >= span.end && o.end - o.at > span.end - span.at
788
+ )
789
+ );
790
+ return claims.map((claim) => ({
791
+ at: Math.min(...claim.spans.filter((span) => !inside(span, claim)).map((span) => span.at)),
792
+ kit: claim.kit
793
+ })).filter(({ at }) => Number.isFinite(at)).sort((left, right) => left.at - right.at).map(({ kit: { blurb, name, title } }) => ({
794
+ blurb,
795
+ command: `npx create-threenative <dir> --template ${name}`,
796
+ name,
797
+ title
798
+ }));
799
+ }
338
800
  function searchCapabilities(situation, manifestFile = defaultManifestPath(), scope = "mechanic") {
339
801
  if (typeof situation !== "string" || situation.trim().length === 0)
340
802
  throw new Error("engine_search_capabilities requires a non-empty situation string.");
341
803
  const manifest = loadCapabilityManifest(manifestFile);
804
+ const starterKits = matchedStarterKits(situation, manifest.starterKits ?? []);
805
+ const response = withReferenceTools(situation, searchMechanics(situation, manifest, scope));
806
+ if (starterKits.length === 0) return response;
807
+ return {
808
+ starterKits,
809
+ ...response,
810
+ ...response.verdict === "none" ? {
811
+ guidance: `Scaffold from the starter kit first, then search the mechanics it does not cover. ${response.guidance}`,
812
+ verdict: "starterKit"
813
+ } : {}
814
+ };
815
+ }
816
+ var REVERSE_ENGINEER = /\breverse[\s-]?engineer|\bdecomp|\bdisassembl/iu;
817
+ var REFERENCE_TOOLS = [
818
+ "engine_search_references",
819
+ "engine_reference_detail",
820
+ "engine_save_reference"
821
+ ];
822
+ function withReferenceTools(situation, response) {
823
+ if (!REVERSE_ENGINEER.test(situation)) return response;
824
+ return {
825
+ referenceTools: REFERENCE_TOOLS,
826
+ ...response,
827
+ guidance: `Reverse engineer the game with engine_search_references, then engine_reference_detail for its pinned cards. ${response.guidance}`
828
+ };
829
+ }
830
+ function searchMechanics(situation, manifest, scope) {
342
831
  const query = tokens(situation);
343
832
  const limit = scope === "request" ? MAX_COMPLETE_REQUEST_RESULTS : MAX_SITUATION_RESULTS;
344
833
  const weights = tokenWeights(manifest.entries);
@@ -432,19 +921,61 @@ var TOOL_DEFINITIONS = [
432
921
  required: ["symbol"],
433
922
  type: "object"
434
923
  }
924
+ },
925
+ {
926
+ name: "engine_search_references",
927
+ description: "Reverse engineer classic games: search mechanic cards and repository map nodes from their decomps. Tier-2 titles link to repos to explore and save. References describe feel only; borrow algorithms, cite pinned sources, copy no code or art.",
928
+ annotations: { destructiveHint: false, openWorldHint: true, readOnlyHint: true },
929
+ inputSchema: {
930
+ type: "object",
931
+ additionalProperties: false,
932
+ properties: { situation: { type: "string" } },
933
+ required: ["situation"]
934
+ }
935
+ },
936
+ {
937
+ name: "engine_reference_detail",
938
+ description: "Reverse engineer a game: read its pinned map or mechanic card. A node includes files, key functions, dependencies and child nodes; a card includes constants and sources.",
939
+ annotations: { destructiveHint: false, openWorldHint: true, readOnlyHint: true },
940
+ inputSchema: {
941
+ type: "object",
942
+ additionalProperties: false,
943
+ properties: {
944
+ game: { type: "string" },
945
+ id: { type: "string" },
946
+ kind: { type: "string", enum: ["node", "card"] }
947
+ },
948
+ required: ["game", "id", "kind"]
949
+ }
950
+ },
951
+ {
952
+ name: "engine_save_reference",
953
+ description: "Save what you reverse engineer: validate and save a pinned game's repository map and mechanic cards to .threenative/refs, merged by id at the same SHA; conflicting revisions are refused. Pass a game object with version:1, id, title, repo, sha, nodes and cards. Returns a POSIX-shell contribution command for review; does not send it. Unknown fields and fenced code are refused.",
954
+ annotations: { destructiveHint: false, openWorldHint: false, readOnlyHint: false },
955
+ inputSchema: {
956
+ type: "object",
957
+ additionalProperties: false,
958
+ properties: {
959
+ game: {
960
+ type: "object",
961
+ description: "Pinned game reference: version, id, title, repo, sha (40 hex), nodes [{id,name,purpose,parent?,paths,functions:[{name,path,lines:[from,to]}],dependsOn}], cards [{id,node,name,summary,behavior,constants:[{name,value,unit,source}],sources:[{path,line}],capabilities,tags}]. No extra fields or code fences."
962
+ }
963
+ },
964
+ required: ["game"]
965
+ }
435
966
  }
436
967
  ];
437
968
  function toolDefinitions() {
438
969
  return TOOL_DEFINITIONS;
439
970
  }
440
- var DEFAULT_LOG_FILE = path.join(".threenative", "engine-mcp.log");
971
+ var DEFAULT_LOG_FILE = path2.join(".threenative", "engine-mcp.log");
441
972
  var logBroken = false;
442
973
  function logToolCall(entry) {
443
974
  if (logBroken) return;
444
975
  const target = process.env.THREENATIVE_ENGINE_MCP_LOG ?? DEFAULT_LOG_FILE;
445
976
  if (target === "off") return;
446
977
  try {
447
- mkdirSync(path.dirname(path.resolve(target)), { recursive: true });
978
+ mkdirSync(path2.dirname(path2.resolve(target)), { recursive: true });
448
979
  appendFileSync(target, `${JSON.stringify({ time: (/* @__PURE__ */ new Date()).toISOString(), ...entry })}
449
980
  `);
450
981
  } catch (error) {
@@ -453,11 +984,11 @@ function logToolCall(entry) {
453
984
  `);
454
985
  }
455
986
  }
456
- function jsonRpcError(id, code, message) {
457
- return JSON.stringify({ id, jsonrpc: "2.0", error: { code, message } });
987
+ function jsonRpcError(id2, code, message) {
988
+ return JSON.stringify({ id: id2, jsonrpc: "2.0", error: { code, message } });
458
989
  }
459
- function jsonRpcResult(id, result) {
460
- return JSON.stringify({ id, jsonrpc: "2.0", result });
990
+ function jsonRpcResult(id2, result) {
991
+ return JSON.stringify({ id: id2, jsonrpc: "2.0", result });
461
992
  }
462
993
  function writeLine(value) {
463
994
  process.stdout.write(`${value}
@@ -466,7 +997,77 @@ function writeLine(value) {
466
997
  function toolText(value) {
467
998
  return { content: [{ text: JSON.stringify(value, null, 2), type: "text" }] };
468
999
  }
469
- function handleToolCall(params, manifestFile) {
1000
+ async function searchReferences(situation, limit = 5) {
1001
+ const local = loadLocalReferenceGames();
1002
+ let release;
1003
+ try {
1004
+ release = await loadReferenceIndex();
1005
+ } catch (error) {
1006
+ if (!(error instanceof Error && error.message.startsWith("TN_REFERENCES_UNAVAILABLE:")))
1007
+ throw error;
1008
+ if (local.length === 0)
1009
+ return {
1010
+ results: [],
1011
+ guidance: "references unavailable: no release cache or network; capability search still works."
1012
+ };
1013
+ }
1014
+ const localIds = new Set(local.map((game) => game.id));
1015
+ const localStubs = local.flatMap(referenceStubs);
1016
+ const localKeys = new Set(localStubs.map((stub) => `${stub.game}/${stub.kind}/${stub.id}`));
1017
+ const games = [
1018
+ ...(release?.games ?? []).filter((game) => !localIds.has(game.id)),
1019
+ ...local.map((game) => ({
1020
+ id: game.id,
1021
+ title: game.title,
1022
+ repo: game.repo,
1023
+ platform: "local",
1024
+ tier: 1
1025
+ }))
1026
+ ];
1027
+ const stubs = [
1028
+ ...(release?.stubs ?? []).filter(
1029
+ (stub) => !localKeys.has(`${stub.game}/${stub.kind}/${stub.id}`)
1030
+ ),
1031
+ ...localStubs
1032
+ ];
1033
+ const titles = new Map(games.map((game) => [game.id, game.title]));
1034
+ const candidates = [
1035
+ ...stubs.map((stub) => ({
1036
+ ...stub,
1037
+ title: titles.get(stub.game) ?? ""
1038
+ })),
1039
+ ...games.filter((game) => game.tier === 2).map((game) => ({
1040
+ ...game,
1041
+ game: game.id,
1042
+ kind: "game",
1043
+ name: game.title,
1044
+ summary: game.title,
1045
+ tags: [],
1046
+ capabilities: [],
1047
+ guidance: "Explore this repository, then call engine_save_reference."
1048
+ }))
1049
+ ];
1050
+ const phrases = candidates.map((stub) => ({
1051
+ situations: [stub.name, stub.summary, ...stub.tags],
1052
+ aliases: [stub.title]
1053
+ }));
1054
+ const weights = tokenWeights(phrases);
1055
+ const query = tokens(situation);
1056
+ return {
1057
+ guidance: "",
1058
+ results: candidates.map((stub, index) => {
1059
+ const content = situationScore(query, phrases[index]?.situations ?? [], [], weights);
1060
+ const title = scoreAliases(
1061
+ phrases[index]?.aliases ?? [],
1062
+ new Set(query),
1063
+ query.join(" "),
1064
+ weights
1065
+ );
1066
+ return { ...stub, ...content, score: content.score + title };
1067
+ }).filter((stub) => stub.score >= RELEVANCE_FLOOR).sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, limit)
1068
+ };
1069
+ }
1070
+ async function handleToolCall(params, manifestFile) {
470
1071
  const name = params.name;
471
1072
  const argumentsValue = isRecord(params.arguments) ? params.arguments : {};
472
1073
  if (name === "engine_search_capabilities") {
@@ -483,7 +1084,17 @@ function handleToolCall(params, manifestFile) {
483
1084
  tool: name,
484
1085
  verdict: response.verdict
485
1086
  });
486
- return toolText(response);
1087
+ return toolText(
1088
+ response.verdict === "none" ? {
1089
+ ...response,
1090
+ references: await searchReferences(argumentsValue.situation, 3).catch(
1091
+ (error) => ({
1092
+ results: [],
1093
+ guidance: `references unavailable: ${String(error)}`
1094
+ })
1095
+ )
1096
+ } : response
1097
+ );
487
1098
  }
488
1099
  if (name === "engine_capability_detail") {
489
1100
  if (typeof argumentsValue.symbol !== "string")
@@ -506,13 +1117,49 @@ function handleToolCall(params, manifestFile) {
506
1117
  });
507
1118
  return toolText(detail);
508
1119
  }
1120
+ if (name === "engine_search_references") {
1121
+ if (typeof argumentsValue.situation !== "string" || !argumentsValue.situation.trim())
1122
+ throw new Error("engine_search_references requires a non-empty string 'situation'.");
1123
+ const response = await searchReferences(argumentsValue.situation);
1124
+ logToolCall({
1125
+ tool: name,
1126
+ situation: argumentsValue.situation,
1127
+ results: response.results.map((entry) => entry.id)
1128
+ });
1129
+ return toolText(response);
1130
+ }
1131
+ if (name === "engine_reference_detail") {
1132
+ if (typeof argumentsValue.game !== "string" || typeof argumentsValue.id !== "string" || argumentsValue.kind !== "node" && argumentsValue.kind !== "card")
1133
+ throw new Error("engine_reference_detail requires game, id and kind (node or card).");
1134
+ let game = await loadReferenceGame(argumentsValue.game);
1135
+ const { id: id2, kind } = argumentsValue;
1136
+ let entry = kind === "node" ? game.nodes.find((node) => node.id === id2) : game.cards.find((card) => card.id === id2);
1137
+ if (!entry) {
1138
+ game = await loadReleaseReferenceGame(argumentsValue.game);
1139
+ entry = kind === "node" ? game.nodes.find((node) => node.id === id2) : game.cards.find((card) => card.id === id2);
1140
+ }
1141
+ if (!entry) throw new Error(`No reference ${kind} '${id2}' in '${game.id}'.`);
1142
+ logToolCall({ tool: name, game: game.id, id: id2, kind });
1143
+ return toolText({
1144
+ game: game.id,
1145
+ title: game.title,
1146
+ repo: game.repo,
1147
+ sha: game.sha,
1148
+ ...kind === "node" ? { node: entry, children: game.nodes.filter((node) => node.parent === id2) } : { card: entry }
1149
+ });
1150
+ }
1151
+ if (name === "engine_save_reference") {
1152
+ const saved = saveReferenceGame(argumentsValue.game);
1153
+ logToolCall({ tool: name, saved: saved.saved });
1154
+ return toolText(saved);
1155
+ }
509
1156
  throw new Error(`Unknown engine MCP tool '${String(name)}'.`);
510
1157
  }
511
- function handleLine(line, manifestFile) {
512
- if (line.trim().length === 0) return void 0;
1158
+ async function handleLine(line2, manifestFile) {
1159
+ if (line2.trim().length === 0) return void 0;
513
1160
  let request;
514
1161
  try {
515
- request = JSON.parse(line);
1162
+ request = JSON.parse(line2);
516
1163
  } catch (error) {
517
1164
  return jsonRpcError(null, -32700, `Invalid JSON: ${String(error)}`);
518
1165
  }
@@ -524,7 +1171,7 @@ function handleLine(line, manifestFile) {
524
1171
  capabilities: { tools: { listChanged: false } },
525
1172
  instructions: AUTHORING_INSTRUCTIONS,
526
1173
  protocolVersion: "2025-06-18",
527
- serverInfo: { name: "threenative-engine-mcp", version: "0.2.6" }
1174
+ serverInfo: { name: "threenative-engine-mcp", version: "0.2.8" }
528
1175
  });
529
1176
  }
530
1177
  if (request.method === "tools/list") {
@@ -532,7 +1179,7 @@ function handleLine(line, manifestFile) {
532
1179
  }
533
1180
  if (request.method === "tools/call") {
534
1181
  if (!isRecord(request.params)) throw new Error("tools/call requires an object params value.");
535
- return jsonRpcResult(request.id, handleToolCall(request.params, manifestFile));
1182
+ return jsonRpcResult(request.id, await handleToolCall(request.params, manifestFile));
536
1183
  }
537
1184
  return jsonRpcError(request.id, -32601, `Method not found: ${request.method}`);
538
1185
  } catch (error) {
@@ -541,16 +1188,45 @@ function handleLine(line, manifestFile) {
541
1188
  return jsonRpcError(request.id, -32e3, message);
542
1189
  }
543
1190
  }
1191
+ async function runCli(argv, manifestFile = defaultManifestPath(), io = {
1192
+ stderr: (text2) => void process.stderr.write(text2),
1193
+ stdout: (text2) => void process.stdout.write(text2)
1194
+ }) {
1195
+ const [name, json = "{}"] = argv;
1196
+ try {
1197
+ const result = await handleToolCall(
1198
+ { arguments: JSON.parse(json), name },
1199
+ manifestFile
1200
+ );
1201
+ io.stdout(`${result.content[0].text}
1202
+ `);
1203
+ return 0;
1204
+ } catch (error) {
1205
+ io.stderr(`${name}: ${error instanceof Error ? error.message : String(error)}
1206
+ `);
1207
+ return 1;
1208
+ }
1209
+ }
544
1210
  function runServer(manifestFile = defaultManifestPath()) {
1211
+ const tool = process.argv[2];
1212
+ if (tool?.startsWith("engine_") === true) {
1213
+ void runCli(process.argv.slice(2), manifestFile).then((code) => {
1214
+ process.exitCode = code;
1215
+ });
1216
+ return;
1217
+ }
545
1218
  loadCapabilityManifest(manifestFile);
546
1219
  const input = createInterface({ input: process.stdin });
547
- input.on("line", (line) => {
548
- const response = handleLine(line, manifestFile);
549
- if (response !== void 0) writeLine(response);
1220
+ void loadReferenceIndex().catch((error) => process.stderr.write(`${String(error)}
1221
+ `));
1222
+ input.on("line", (line2) => {
1223
+ void handleLine(line2, manifestFile).then((response) => {
1224
+ if (response !== void 0) writeLine(response);
1225
+ });
550
1226
  });
551
1227
  }
552
1228
  var entryPath = process.argv[1];
553
- if (entryPath !== void 0 && realpathSync(path.resolve(entryPath)) === realpathSync(fileURLToPath(import.meta.url))) {
1229
+ if (entryPath !== void 0 && realpathSync(path2.resolve(entryPath)) === realpathSync(fileURLToPath(import.meta.url))) {
554
1230
  try {
555
1231
  runServer();
556
1232
  } catch (error) {
@@ -560,4 +1236,4 @@ if (entryPath !== void 0 && realpathSync(path.resolve(entryPath)) === realpathSy
560
1236
  }
561
1237
  }
562
1238
 
563
- export { ENGINE_MCP_TOOL_NAMES, RELEVANCE_FLOOR, capabilityDetail, capabilityDetails, capabilitySituationTokens, defaultManifestPath, handleLine, loadCapabilityManifest, runServer, searchCapabilities, toolDefinitions };
1239
+ export { ENGINE_MCP_TOOL_NAMES, RELEVANCE_FLOOR, capabilityDetail, capabilityDetails, capabilitySituationTokens, defaultManifestPath, handleLine, loadCapabilityManifest, runCli, runServer, searchCapabilities, toolDefinitions };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "threenative-engine-mcp",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "license": "MIT",
5
5
  "description": "Offline MCP capability discovery for ThreeNative authoring agents.",
6
6
  "keywords": [