threenative-engine-mcp 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/dist/index.d.ts +20 -2
- package/dist/index.js +271 -23
- package/package.json +19 -1
package/README.md
CHANGED
|
@@ -12,3 +12,21 @@ with that mechanically explicit request and `scope: "request"`, then once per me
|
|
|
12
12
|
situations return up to five. A genre label alone returns no guessed recipe. Every result includes
|
|
13
13
|
`matchedSituation`, so an authoring agent can verify why it was selected instead of treating a
|
|
14
14
|
lexical coincidence as engine guidance.
|
|
15
|
+
|
|
16
|
+
## Which manifest the server reads
|
|
17
|
+
|
|
18
|
+
The first that exists wins, so the manifest can never drift from the engine the project runs:
|
|
19
|
+
|
|
20
|
+
1. `THREENATIVE_CAPABILITIES_MANIFEST` — an explicit override, resolved against the working
|
|
21
|
+
directory.
|
|
22
|
+
2. `node_modules/@threenative/core/capabilities.json` — found by walking up from the working
|
|
23
|
+
directory. The installed package ships the manifest its own build generated, so it always
|
|
24
|
+
describes the engine the project actually links; it moves exactly when the dependency does.
|
|
25
|
+
3. `packages/core/capabilities.json` — found by walking up from the running server file, which
|
|
26
|
+
answers from the engine repository's own generated manifest when the server is launched from
|
|
27
|
+
the repository rather than from an installed package.
|
|
28
|
+
4. `capabilities.json` in the working directory — the original default, kept for a bare checkout
|
|
29
|
+
with a committed copy and no installed engine package.
|
|
30
|
+
|
|
31
|
+
A project copy committed at scaffold time is therefore inert whenever the package is installed:
|
|
32
|
+
the two never need re-syncing by hand.
|
package/dist/index.d.ts
CHANGED
|
@@ -6,13 +6,21 @@ interface ICapabilityEntry {
|
|
|
6
6
|
readonly signature: string;
|
|
7
7
|
readonly summary: string;
|
|
8
8
|
readonly situations: readonly string[];
|
|
9
|
+
readonly aliases: readonly string[];
|
|
9
10
|
readonly example: string;
|
|
10
11
|
readonly constraints: readonly string[];
|
|
11
12
|
readonly overrides?: readonly string[];
|
|
13
|
+
readonly requires?: readonly string[];
|
|
14
|
+
}
|
|
15
|
+
interface INotOwnedCapability {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly situations: readonly string[];
|
|
18
|
+
readonly guidance: string;
|
|
12
19
|
}
|
|
13
20
|
interface ICapabilityManifest {
|
|
14
21
|
readonly version: number;
|
|
15
22
|
readonly entries: readonly ICapabilityEntry[];
|
|
23
|
+
readonly notOwned: readonly INotOwnedCapability[];
|
|
16
24
|
}
|
|
17
25
|
interface ICapabilitySearchResult {
|
|
18
26
|
readonly symbol: string;
|
|
@@ -20,7 +28,14 @@ interface ICapabilitySearchResult {
|
|
|
20
28
|
readonly summary: string;
|
|
21
29
|
readonly example: string;
|
|
22
30
|
readonly constraints: readonly string[];
|
|
31
|
+
readonly requires?: readonly string[];
|
|
23
32
|
readonly matchedSituation: string;
|
|
33
|
+
readonly score: number;
|
|
34
|
+
}
|
|
35
|
+
interface ICapabilitySearchResponse {
|
|
36
|
+
readonly verdict: "matched" | "none";
|
|
37
|
+
readonly results: readonly ICapabilitySearchResult[];
|
|
38
|
+
readonly guidance: string;
|
|
24
39
|
}
|
|
25
40
|
interface ICapabilityDetail extends ICapabilityEntry {
|
|
26
41
|
}
|
|
@@ -44,9 +59,12 @@ interface IEngineTool {
|
|
|
44
59
|
};
|
|
45
60
|
}
|
|
46
61
|
declare const ENGINE_MCP_TOOL_NAMES: readonly ["engine_search_capabilities", "engine_capability_detail"];
|
|
62
|
+
/** Chosen by the current-manifest threshold sweep: the highest recalled candidate at 0.27. */
|
|
63
|
+
declare const RELEVANCE_FLOOR = 0.27;
|
|
47
64
|
declare function defaultManifestPath(cwd?: string): string;
|
|
48
65
|
declare function loadCapabilityManifest(file?: string): ICapabilityManifest;
|
|
49
|
-
declare function
|
|
66
|
+
declare function capabilitySituationTokens(value: string): string[];
|
|
67
|
+
declare function searchCapabilities(situation: string, manifestFile?: string, scope?: "mechanic" | "request"): ICapabilitySearchResponse;
|
|
50
68
|
declare function capabilityDetail(symbol: string, manifestFile?: string): ICapabilityDetail;
|
|
51
69
|
declare function toolDefinitions(): readonly IEngineTool[];
|
|
52
70
|
/**
|
|
@@ -57,4 +75,4 @@ declare function toolDefinitions(): readonly IEngineTool[];
|
|
|
57
75
|
declare function handleLine(line: string, manifestFile: string): string | undefined;
|
|
58
76
|
declare function runServer(manifestFile?: string): void;
|
|
59
77
|
|
|
60
|
-
export { ENGINE_MCP_TOOL_NAMES, type ICapabilityDetail, type ICapabilityEntry, type ICapabilityManifest, type ICapabilitySearchResult, type IEngineTool, capabilityDetail, defaultManifestPath, handleLine, loadCapabilityManifest, runServer, searchCapabilities, toolDefinitions };
|
|
78
|
+
export { ENGINE_MCP_TOOL_NAMES, type ICapabilityDetail, type ICapabilityEntry, type ICapabilityManifest, type ICapabilitySearchResponse, type ICapabilitySearchResult, type IEngineTool, type INotOwnedCapability, RELEVANCE_FLOOR, capabilityDetail, capabilitySituationTokens, defaultManifestPath, handleLine, loadCapabilityManifest, runServer, searchCapabilities, toolDefinitions };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { realpathSync, readFileSync } from 'fs';
|
|
2
|
+
import { realpathSync, readFileSync, existsSync } from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { createInterface } from 'readline';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
@@ -12,32 +12,143 @@ var DEFAULT_MANIFEST_FILE = "capabilities.json";
|
|
|
12
12
|
var STOP_WORDS = /* @__PURE__ */ new Set([
|
|
13
13
|
"a",
|
|
14
14
|
"add",
|
|
15
|
+
"after",
|
|
16
|
+
"again",
|
|
17
|
+
"all",
|
|
18
|
+
"also",
|
|
15
19
|
"an",
|
|
16
20
|
"and",
|
|
21
|
+
"any",
|
|
22
|
+
"are",
|
|
17
23
|
"around",
|
|
24
|
+
"as",
|
|
25
|
+
"at",
|
|
26
|
+
"be",
|
|
27
|
+
"because",
|
|
28
|
+
"been",
|
|
29
|
+
"being",
|
|
30
|
+
"both",
|
|
18
31
|
"build",
|
|
32
|
+
"but",
|
|
19
33
|
"by",
|
|
34
|
+
"can",
|
|
20
35
|
"create",
|
|
36
|
+
"do",
|
|
37
|
+
"does",
|
|
38
|
+
"each",
|
|
39
|
+
"either",
|
|
40
|
+
"else",
|
|
41
|
+
"every",
|
|
21
42
|
"for",
|
|
22
43
|
"from",
|
|
23
44
|
"game",
|
|
45
|
+
"get",
|
|
46
|
+
"give",
|
|
47
|
+
"had",
|
|
48
|
+
"has",
|
|
49
|
+
"have",
|
|
50
|
+
"how",
|
|
51
|
+
"if",
|
|
24
52
|
"in",
|
|
25
53
|
"into",
|
|
54
|
+
"is",
|
|
26
55
|
"it",
|
|
56
|
+
"its",
|
|
57
|
+
"let",
|
|
58
|
+
"made",
|
|
27
59
|
"make",
|
|
60
|
+
"may",
|
|
61
|
+
"might",
|
|
62
|
+
"must",
|
|
63
|
+
"no",
|
|
64
|
+
"nor",
|
|
65
|
+
"not",
|
|
28
66
|
"of",
|
|
29
67
|
"on",
|
|
30
|
-
"
|
|
31
|
-
"
|
|
68
|
+
"once",
|
|
69
|
+
"one",
|
|
70
|
+
"only",
|
|
71
|
+
"or",
|
|
72
|
+
"other",
|
|
73
|
+
"our",
|
|
74
|
+
"own",
|
|
75
|
+
"put",
|
|
76
|
+
"rather",
|
|
77
|
+
"same",
|
|
78
|
+
"shall",
|
|
79
|
+
"should",
|
|
80
|
+
"since",
|
|
81
|
+
"so",
|
|
82
|
+
"some",
|
|
83
|
+
"such",
|
|
84
|
+
"take",
|
|
85
|
+
"than",
|
|
86
|
+
"their",
|
|
87
|
+
"them",
|
|
88
|
+
"then",
|
|
89
|
+
"there",
|
|
90
|
+
"these",
|
|
91
|
+
"they",
|
|
92
|
+
"this",
|
|
93
|
+
"those",
|
|
94
|
+
"though",
|
|
95
|
+
"thus",
|
|
32
96
|
"to",
|
|
97
|
+
"too",
|
|
33
98
|
"use",
|
|
34
|
-
"
|
|
99
|
+
"very",
|
|
100
|
+
"was",
|
|
101
|
+
"want",
|
|
102
|
+
"way",
|
|
103
|
+
"we",
|
|
104
|
+
"well",
|
|
105
|
+
"were",
|
|
106
|
+
"what",
|
|
107
|
+
"when",
|
|
108
|
+
"whenever",
|
|
109
|
+
"where",
|
|
110
|
+
"whether",
|
|
111
|
+
"which",
|
|
112
|
+
"while",
|
|
113
|
+
"who",
|
|
114
|
+
"whose",
|
|
115
|
+
"why",
|
|
116
|
+
"will",
|
|
117
|
+
"with",
|
|
118
|
+
"within",
|
|
119
|
+
"without",
|
|
120
|
+
"would",
|
|
121
|
+
"yet",
|
|
122
|
+
"you",
|
|
123
|
+
"your"
|
|
35
124
|
]);
|
|
36
125
|
var MAX_COMPLETE_REQUEST_RESULTS = 15;
|
|
37
126
|
var MAX_SITUATION_RESULTS = 5;
|
|
38
|
-
var
|
|
127
|
+
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.`;
|
|
129
|
+
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.";
|
|
39
130
|
function defaultManifestPath(cwd = process.cwd()) {
|
|
40
|
-
|
|
131
|
+
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 installed = path.join(
|
|
135
|
+
directory,
|
|
136
|
+
"node_modules",
|
|
137
|
+
"@threenative",
|
|
138
|
+
"core",
|
|
139
|
+
"capabilities.json"
|
|
140
|
+
);
|
|
141
|
+
if (existsSync(installed)) return installed;
|
|
142
|
+
const parent = path.dirname(directory);
|
|
143
|
+
if (parent === directory) break;
|
|
144
|
+
}
|
|
145
|
+
for (let directory = path.dirname(fileURLToPath(import.meta.url)); ; directory = path.dirname(directory)) {
|
|
146
|
+
const repository = path.join(directory, "packages", "core", "capabilities.json");
|
|
147
|
+
if (existsSync(repository)) return repository;
|
|
148
|
+
const parent = path.dirname(directory);
|
|
149
|
+
if (parent === directory) break;
|
|
150
|
+
}
|
|
151
|
+
return path.resolve(cwd, DEFAULT_MANIFEST_FILE);
|
|
41
152
|
}
|
|
42
153
|
function manifestError(file, reason) {
|
|
43
154
|
return new Error(`TN_ENGINE_CAPABILITIES_MANIFEST: ${file}: ${reason}`);
|
|
@@ -46,14 +157,29 @@ function isRecord(value) {
|
|
|
46
157
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
47
158
|
}
|
|
48
159
|
function validateManifest(value, file) {
|
|
49
|
-
if (!isRecord(value) ||
|
|
50
|
-
throw manifestError(
|
|
160
|
+
if (!isRecord(value) || value.version !== 2 || !Array.isArray(value.entries) || !Array.isArray(value.notOwned)) {
|
|
161
|
+
throw manifestError(
|
|
162
|
+
file,
|
|
163
|
+
"root must contain manifest version 2, entries array, and notOwned array"
|
|
164
|
+
);
|
|
51
165
|
}
|
|
52
166
|
for (const [index, raw] of value.entries.entries()) {
|
|
53
|
-
if (!isRecord(raw) || typeof raw.symbol !== "string" || typeof raw.package !== "string" || typeof raw.importPath !== "string" || typeof raw.kind !== "string" || typeof raw.signature !== "string" || typeof raw.summary !== "string" || typeof raw.example !== "string" || !Array.isArray(raw.situations) || !Array.isArray(raw.constraints) || !raw.situations.every((situation) => typeof situation === "string") || !raw.constraints.every((constraint) => typeof constraint === "string")) {
|
|
167
|
+
if (!isRecord(raw) || typeof raw.symbol !== "string" || typeof raw.package !== "string" || typeof raw.importPath !== "string" || typeof raw.kind !== "string" || typeof raw.signature !== "string" || typeof raw.summary !== "string" || typeof raw.example !== "string" || !Array.isArray(raw.situations) || !Array.isArray(raw.aliases) || !Array.isArray(raw.constraints) || raw.requires !== void 0 && !Array.isArray(raw.requires) || !raw.situations.every((situation) => typeof situation === "string") || !raw.aliases.every((alias) => typeof alias === "string") || !raw.constraints.every((constraint) => typeof constraint === "string") || Array.isArray(raw.requires) && !raw.requires.every((requirement) => typeof requirement === "string")) {
|
|
54
168
|
throw manifestError(file, `entry ${index} is malformed`);
|
|
55
169
|
}
|
|
56
170
|
}
|
|
171
|
+
const notOwnedIds = /* @__PURE__ */ new Set();
|
|
172
|
+
for (const [index, raw] of value.notOwned.entries()) {
|
|
173
|
+
if (!isRecord(raw) || typeof raw.id !== "string" || raw.id.trim().length === 0 || !Array.isArray(raw.situations) || raw.situations.length === 0 || !raw.situations.every(
|
|
174
|
+
(situation) => typeof situation === "string" && situation.trim().length > 0
|
|
175
|
+
) || typeof raw.guidance !== "string" || raw.guidance.trim().length === 0) {
|
|
176
|
+
throw manifestError(file, `notOwned ${index} is malformed`);
|
|
177
|
+
}
|
|
178
|
+
if (notOwnedIds.has(raw.id)) {
|
|
179
|
+
throw manifestError(file, `notOwned contains duplicate id '${raw.id}'`);
|
|
180
|
+
}
|
|
181
|
+
notOwnedIds.add(raw.id);
|
|
182
|
+
}
|
|
57
183
|
return value;
|
|
58
184
|
}
|
|
59
185
|
function loadCapabilityManifest(file = defaultManifestPath()) {
|
|
@@ -71,15 +197,103 @@ function loadCapabilityManifest(file = defaultManifestPath()) {
|
|
|
71
197
|
throw manifestError(file, `cannot parse JSON: ${String(error)}`);
|
|
72
198
|
}
|
|
73
199
|
}
|
|
74
|
-
function
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
200
|
+
function undouble(stem2) {
|
|
201
|
+
const last = stem2.at(-1);
|
|
202
|
+
return stem2.length > 3 && last !== void 0 && last === stem2.at(-2) && !"aeiou".includes(last) ? stem2.slice(0, -1) : stem2;
|
|
203
|
+
}
|
|
204
|
+
function stem(token) {
|
|
205
|
+
const stripped = suffixStripped(token);
|
|
206
|
+
return stripped.length > 3 && stripped.endsWith("e") ? stripped.slice(0, -1) : stripped;
|
|
207
|
+
}
|
|
208
|
+
function suffixStripped(token) {
|
|
209
|
+
if (token.length > 4 && token.endsWith("ies")) return `${token.slice(0, -3)}y`;
|
|
210
|
+
if (token.length > 4 && token.endsWith("ing")) return undouble(token.slice(0, -3));
|
|
211
|
+
if (token.length > 4 && token.endsWith("ed")) return undouble(token.slice(0, -2));
|
|
212
|
+
if (token.length > 3 && token.endsWith("s") && !token.endsWith("ss")) return token.slice(0, -1);
|
|
213
|
+
return token;
|
|
214
|
+
}
|
|
215
|
+
function capabilitySituationTokens(value) {
|
|
216
|
+
return value.toLocaleLowerCase().split(/[^a-z0-9]+/u).filter((token) => token.length > 1 && !STOP_WORDS.has(token)).map(stem);
|
|
217
|
+
}
|
|
218
|
+
var tokens = capabilitySituationTokens;
|
|
219
|
+
function tokenWeights(entries) {
|
|
220
|
+
const frequency = /* @__PURE__ */ new Map();
|
|
221
|
+
let situations = 0;
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
for (const situation of [...entry.situations, ...entry.aliases]) {
|
|
224
|
+
situations += 1;
|
|
225
|
+
for (const token of new Set(tokens(situation)))
|
|
226
|
+
frequency.set(token, (frequency.get(token) ?? 0) + 1);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const weights = /* @__PURE__ */ new Map();
|
|
230
|
+
for (const [token, count] of frequency) weights.set(token, Math.log(1 + situations / count));
|
|
231
|
+
return weights;
|
|
232
|
+
}
|
|
233
|
+
function weightOf(weights, token) {
|
|
234
|
+
return weights.get(token) ?? 0;
|
|
235
|
+
}
|
|
236
|
+
var DISTINCTIVE_SITUATION_SHARE = 0.02;
|
|
237
|
+
var DISTINCTIVE_FLOOR = Math.log(1 + 1 / DISTINCTIVE_SITUATION_SHARE);
|
|
238
|
+
var AGREEMENT_WEIGHT = 0.4;
|
|
239
|
+
var LONE_WORD_COVERAGE = 0.22;
|
|
240
|
+
function agreement(situation, query) {
|
|
241
|
+
const matched = situation.filter((token) => query.has(token)).length;
|
|
242
|
+
return 1 + AGREEMENT_WEIGHT * Math.max(0, matched - 1);
|
|
243
|
+
}
|
|
244
|
+
function scorePhrase(value, queryTokens, queryText, weights) {
|
|
245
|
+
const phrase = tokens(value);
|
|
246
|
+
const unique = [...new Set(phrase)];
|
|
247
|
+
const total = unique.reduce((sum, token) => sum + weightOf(weights, token), 0);
|
|
248
|
+
const matched = unique.filter((token) => queryTokens.has(token)).reduce((sum, token) => sum + weightOf(weights, token), 0);
|
|
249
|
+
const phraseBonus = phrase.join(" ").includes(queryText) || queryText.includes(phrase.join(" ")) ? 1 : 0;
|
|
250
|
+
if (total === 0 || matched === 0 && phraseBonus === 0) return void 0;
|
|
251
|
+
const coverage = matched / total;
|
|
252
|
+
const agreed = agreement(unique, queryTokens);
|
|
253
|
+
return { agreed, coverage, matched, phraseBonus, score: coverage * agreed + phraseBonus };
|
|
254
|
+
}
|
|
255
|
+
function isDistinctivePhrase(candidate) {
|
|
256
|
+
if (candidate.phraseBonus > 0) return true;
|
|
257
|
+
if (candidate.matched < DISTINCTIVE_FLOOR) return false;
|
|
258
|
+
return !(candidate.agreed === 1 && candidate.coverage < LONE_WORD_COVERAGE);
|
|
259
|
+
}
|
|
260
|
+
function scoreReadableSituations(situations, queryTokens, queryText, weights) {
|
|
261
|
+
let best = 0;
|
|
262
|
+
let matchedSituation = "";
|
|
263
|
+
let bestReadableScore = 0;
|
|
264
|
+
let bestReadableSituation = situations[0] ?? "";
|
|
265
|
+
for (const situation of situations) {
|
|
266
|
+
const candidate = scorePhrase(situation, queryTokens, queryText, weights);
|
|
267
|
+
if (candidate === void 0) continue;
|
|
268
|
+
if (candidate.score > bestReadableScore) {
|
|
269
|
+
bestReadableScore = candidate.score;
|
|
270
|
+
bestReadableSituation = situation;
|
|
271
|
+
}
|
|
272
|
+
if (isDistinctivePhrase(candidate) && candidate.score > best) {
|
|
273
|
+
best = candidate.score;
|
|
274
|
+
matchedSituation = situation;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return { best, bestReadableSituation, matchedSituation };
|
|
81
278
|
}
|
|
82
|
-
function
|
|
279
|
+
function scoreAliases(aliases, queryTokens, queryText, weights) {
|
|
280
|
+
let best = 0;
|
|
281
|
+
for (const alias of aliases) {
|
|
282
|
+
const candidate = scorePhrase(alias, queryTokens, queryText, weights);
|
|
283
|
+
if (candidate !== void 0) best = Math.max(best, candidate.score);
|
|
284
|
+
}
|
|
285
|
+
return best;
|
|
286
|
+
}
|
|
287
|
+
function situationScore(query, situations, aliases, weights) {
|
|
288
|
+
if (query.length === 0) return { matchedSituation: "", score: 0 };
|
|
289
|
+
const queryTokens = new Set(query);
|
|
290
|
+
const queryText = query.join(" ");
|
|
291
|
+
const readable = scoreReadableSituations(situations, queryTokens, queryText, weights);
|
|
292
|
+
const best = Math.max(readable.best, scoreAliases(aliases, queryTokens, queryText, weights));
|
|
293
|
+
const matchedSituation = readable.matchedSituation.length > 0 ? readable.matchedSituation : best >= RELEVANCE_FLOOR ? readable.bestReadableSituation : "";
|
|
294
|
+
return matchedSituation.length > 0 ? { matchedSituation, score: best } : { matchedSituation: "", score: 0 };
|
|
295
|
+
}
|
|
296
|
+
function notOwnedSituationScore(query, situations) {
|
|
83
297
|
if (query.length === 0) return { matchedSituation: "", score: 0 };
|
|
84
298
|
const queryText = query.join(" ");
|
|
85
299
|
let best = 0;
|
|
@@ -98,9 +312,24 @@ function situationScore(query, situations) {
|
|
|
98
312
|
}
|
|
99
313
|
return { matchedSituation, score: best };
|
|
100
314
|
}
|
|
315
|
+
function notOwnedMatch(manifest, query) {
|
|
316
|
+
return manifest.notOwned.map((entry) => ({ entry, ...notOwnedSituationScore(query, entry.situations) })).filter(
|
|
317
|
+
({ matchedSituation, score }) => matchedSituation.length > 0 && score >= RELEVANCE_FLOOR
|
|
318
|
+
).sort(
|
|
319
|
+
(left, right) => right.score - left.score || left.entry.id.localeCompare(right.entry.id)
|
|
320
|
+
)[0];
|
|
321
|
+
}
|
|
322
|
+
function isSpecificNotOwnedMatch(query, matchedSituation) {
|
|
323
|
+
const queryText = query.join(" ");
|
|
324
|
+
const phrase = tokens(matchedSituation);
|
|
325
|
+
const phraseText = phrase.join(" ");
|
|
326
|
+
if (queryText === phraseText) return true;
|
|
327
|
+
if (query.length === 1 && phrase.length <= 2 && phrase.includes(query[0] ?? "")) return true;
|
|
328
|
+
if (query.length > phrase.length + 1) return false;
|
|
329
|
+
return new Set(phrase.filter((token) => query.includes(token))).size >= 2;
|
|
330
|
+
}
|
|
101
331
|
function capabilitySearchKey(entry) {
|
|
102
332
|
return `${entry.importPath}
|
|
103
|
-
${entry.signature}
|
|
104
333
|
${entry.summary}
|
|
105
334
|
${entry.situations.join("\n")}`;
|
|
106
335
|
}
|
|
@@ -110,7 +339,18 @@ function searchCapabilities(situation, manifestFile = defaultManifestPath(), sco
|
|
|
110
339
|
const manifest = loadCapabilityManifest(manifestFile);
|
|
111
340
|
const query = tokens(situation);
|
|
112
341
|
const limit = scope === "request" ? MAX_COMPLETE_REQUEST_RESULTS : MAX_SITUATION_RESULTS;
|
|
113
|
-
|
|
342
|
+
const weights = tokenWeights(manifest.entries);
|
|
343
|
+
const notOwned = notOwnedMatch(manifest, query);
|
|
344
|
+
if (notOwned !== void 0 && isSpecificNotOwnedMatch(query, notOwned.matchedSituation)) {
|
|
345
|
+
return {
|
|
346
|
+
guidance: notOwned.entry.guidance,
|
|
347
|
+
results: [],
|
|
348
|
+
verdict: "none"
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
const results = manifest.entries.map((entry) => ({ entry, ...situationScore(query, entry.situations, entry.aliases, weights) })).filter(
|
|
352
|
+
({ matchedSituation, score }) => matchedSituation.length > 0 && score >= RELEVANCE_FLOOR
|
|
353
|
+
).sort(
|
|
114
354
|
(left, right) => right.score - left.score || `${left.entry.importPath}:${left.entry.symbol}`.localeCompare(
|
|
115
355
|
`${right.entry.importPath}:${right.entry.symbol}`
|
|
116
356
|
)
|
|
@@ -118,14 +358,22 @@ function searchCapabilities(situation, manifestFile = defaultManifestPath(), sco
|
|
|
118
358
|
(candidate, index, candidates) => candidates.findIndex(
|
|
119
359
|
(other) => capabilitySearchKey(other.entry) === capabilitySearchKey(candidate.entry)
|
|
120
360
|
) === index
|
|
121
|
-
).slice(0, limit).map(({ entry, matchedSituation }) => ({
|
|
361
|
+
).slice(0, limit).map(({ entry, matchedSituation, score }) => ({
|
|
122
362
|
constraints: entry.constraints,
|
|
123
363
|
example: entry.example,
|
|
124
364
|
importPath: entry.importPath,
|
|
125
365
|
matchedSituation,
|
|
366
|
+
...entry.requires === void 0 ? {} : { requires: entry.requires },
|
|
367
|
+
score,
|
|
126
368
|
summary: entry.summary,
|
|
127
369
|
symbol: entry.symbol
|
|
128
370
|
}));
|
|
371
|
+
if (results.length > 0) return { guidance: "", results, verdict: "matched" };
|
|
372
|
+
return {
|
|
373
|
+
guidance: notOwned?.entry.guidance ?? GENERIC_GUIDANCE,
|
|
374
|
+
results: [],
|
|
375
|
+
verdict: "none"
|
|
376
|
+
};
|
|
129
377
|
}
|
|
130
378
|
function capabilityDetail(symbol, manifestFile = defaultManifestPath()) {
|
|
131
379
|
if (typeof symbol !== "string" || symbol.trim().length === 0)
|
|
@@ -139,7 +387,7 @@ var TOOL_DEFINITIONS = [
|
|
|
139
387
|
{
|
|
140
388
|
annotations: { destructiveHint: false, openWorldHint: false, readOnlyHint: true },
|
|
141
389
|
name: "engine_search_capabilities",
|
|
142
|
-
description: 'Search the installed engine by concrete gameplay mechanic. Decompose genres first. Use scope "request" for the mechanically explicit full request and "mechanic" for each focused search; matchedSituation
|
|
390
|
+
description: 'Search the installed engine by concrete gameplay mechanic. Decompose genres first. Use scope "request" for the mechanically explicit full request and "mechanic" for each focused search; matchedSituation and score explain every result. The response verdict "none" is an actionable answer with guidance, not a failed search.',
|
|
143
391
|
inputSchema: {
|
|
144
392
|
additionalProperties: false,
|
|
145
393
|
properties: {
|
|
@@ -216,7 +464,7 @@ function handleLine(line, manifestFile) {
|
|
|
216
464
|
capabilities: { tools: { listChanged: false } },
|
|
217
465
|
instructions: AUTHORING_INSTRUCTIONS,
|
|
218
466
|
protocolVersion: "2025-06-18",
|
|
219
|
-
serverInfo: { name: "threenative-engine-mcp", version: "0.2.
|
|
467
|
+
serverInfo: { name: "threenative-engine-mcp", version: "0.2.2" }
|
|
220
468
|
});
|
|
221
469
|
}
|
|
222
470
|
if (request.method === "tools/list") {
|
|
@@ -250,4 +498,4 @@ if (entryPath !== void 0 && realpathSync(path.resolve(entryPath)) === realpathSy
|
|
|
250
498
|
}
|
|
251
499
|
}
|
|
252
500
|
|
|
253
|
-
export { ENGINE_MCP_TOOL_NAMES, capabilityDetail, defaultManifestPath, handleLine, loadCapabilityManifest, runServer, searchCapabilities, toolDefinitions };
|
|
501
|
+
export { ENGINE_MCP_TOOL_NAMES, RELEVANCE_FLOOR, capabilityDetail, capabilitySituationTokens, defaultManifestPath, handleLine, loadCapabilityManifest, runServer, searchCapabilities, toolDefinitions };
|
package/package.json
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "threenative-engine-mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Offline MCP capability discovery for ThreeNative authoring agents.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"threenative",
|
|
8
|
+
"mcp",
|
|
9
|
+
"model-context-protocol",
|
|
10
|
+
"three",
|
|
11
|
+
"threejs",
|
|
12
|
+
"agents",
|
|
13
|
+
"capabilities"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://github.com/ThreeNativeHQ/threenative#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/ThreeNativeHQ/threenative/issues"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/ThreeNativeHQ/threenative.git",
|
|
22
|
+
"directory": "packages/engine-mcp"
|
|
23
|
+
},
|
|
6
24
|
"type": "module",
|
|
7
25
|
"files": [
|
|
8
26
|
"dist",
|