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 +35 -5
- package/dist/index.d.ts +35 -7
- package/dist/index.js +713 -37
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,18 +1,48 @@
|
|
|
1
1
|
# threenative-engine-mcp
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
7
|
-
`
|
|
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
|
|
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
|
-
|
|
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:
|
|
50
|
-
readonly readOnlyHint:
|
|
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
|
|
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 =
|
|
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
|
|
133
|
-
for (let directory =
|
|
134
|
-
const nodeModules =
|
|
135
|
-
const installed =
|
|
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 =
|
|
138
|
-
if (relative !== ".." && !relative.startsWith(`..${
|
|
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 =
|
|
573
|
+
const parent = path2.dirname(directory);
|
|
142
574
|
if (parent === directory) break;
|
|
143
575
|
}
|
|
144
|
-
for (let directory =
|
|
145
|
-
const repository =
|
|
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 =
|
|
579
|
+
const parent = path2.dirname(directory);
|
|
148
580
|
if (parent === directory) break;
|
|
149
581
|
}
|
|
150
|
-
return
|
|
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
|
|
249
|
-
const total =
|
|
250
|
-
const matched =
|
|
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(
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
512
|
-
if (
|
|
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(
|
|
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.
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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(
|
|
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 };
|