@threenative/core 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +10 -0
  2. package/capabilities.json +1881 -131
  3. package/dist/assets-kyoF7JlJ.d.ts +103 -0
  4. package/dist/{audio-Dp2mXpD3.d.ts → audio-BFiGneTL.d.ts} +62 -0
  5. package/dist/canvas-layer-BLVijiUJ.d.ts +62 -0
  6. package/dist/{game-CYIaKhgl.d.ts → game-XGrTzapq.d.ts} +350 -164
  7. package/dist/gpu-readback-D2iRvoe9.d.ts +112 -0
  8. package/dist/hot.d.ts +5 -3
  9. package/dist/hot.js +5 -1
  10. package/dist/index.d.ts +813 -143
  11. package/dist/index.js +4548 -941
  12. package/dist/net.d.ts +65 -0
  13. package/dist/net.js +643 -0
  14. package/dist/playtest.d.ts +29 -5
  15. package/dist/playtest.js +181 -35
  16. package/dist/react.d.ts +4 -2
  17. package/dist/{canvas-layer-CtrZHgIh.d.ts → renderer-C6hqZpoG.d.ts} +237 -75
  18. package/dist/world.d.ts +203 -4
  19. package/dist/world.js +2536 -25
  20. package/gpl/LICENSE.GPL +117 -0
  21. package/gpl/convert.py +192 -0
  22. package/gpl/recipes/_common.py +169 -0
  23. package/gpl/recipes/bake_ao.py +111 -0
  24. package/gpl/recipes/decimate.py +64 -0
  25. package/gpl/recipes/retarget.py +131 -0
  26. package/gpl/recipes/unwrap.py +71 -0
  27. package/mcp/blender-server.mjs +632 -0
  28. package/mcp/blender.mjs +27 -0
  29. package/mcp/engine-server.mjs +271 -23
  30. package/mcp/engine.mjs +15 -7
  31. package/mcp/install.d.mts +37 -0
  32. package/mcp/install.mjs +94 -26
  33. package/mcp/servers.d.mts +34 -0
  34. package/mcp/servers.mjs +110 -9
  35. package/package.json +36 -8
  36. package/patches/three@0.185.1.patch +249 -14
  37. package/scripts/ensure-mcp.mjs +20 -13
  38. package/scripts/bundle-engine-mcp.mjs +0 -15
  39. package/scripts/generate-version.mjs +0 -13
@@ -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
- "that",
31
- "the",
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
- "with"
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 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.`;
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
- return path.resolve(cwd, process.env.THREENATIVE_CAPABILITIES_MANIFEST ?? DEFAULT_MANIFEST_FILE);
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) || typeof value.version !== "number" || !Array.isArray(value.entries)) {
50
- throw manifestError(file, "root must contain a numeric version and entries array");
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 tokens(value) {
75
- return value.toLocaleLowerCase().split(/[^a-z0-9]+/u).filter((token) => token.length > 1 && !STOP_WORDS.has(token)).map((token) => {
76
- if (token.length > 4 && token.endsWith("ies")) return `${token.slice(0, -3)}y`;
77
- if (token.length > 3 && token.endsWith("s") && !token.endsWith("ss"))
78
- return token.slice(0, -1);
79
- return token;
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 situationScore(query, situations) {
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
- return manifest.entries.map((entry) => ({ entry, ...situationScore(query, entry.situations) })).filter(({ score }) => score > 0).sort(
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 explains every result.',
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.0" }
467
+ serverInfo: { name: "threenative-engine-mcp", version: "0.2.1" }
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/mcp/engine.mjs CHANGED
@@ -5,19 +5,27 @@ import { fileURLToPath } from "node:url";
5
5
  import { launchMcpServer } from "./launch.mjs";
6
6
  import { MCP_PACKAGES } from "./servers.mjs";
7
7
 
8
- // The capability server reads a committed manifest from the project root. A scaffolded game has
9
- // one; a project that added ThreeNative to an existing tree does not, so fall back to the copy this
10
- // package ships rather than letting the server refuse to start.
8
+ // The capability server must answer with the manifest of the engine the game actually runs, and
9
+ // the copy this package ships is generated by the same build as that engine. Prefer it over any
10
+ // copy the project committed: a committed snapshot drifts the moment the engine dependency moves.
11
+ // With no bundled copy (a stripped install), fall through to the server's own defaults.
11
12
  const bundled = path.resolve(fileURLToPath(import.meta.url), "..", "..", "capabilities.json");
12
- const env =
13
- existsSync(path.join(process.cwd(), "capabilities.json")) || !existsSync(bundled)
14
- ? {}
15
- : Object.fromEntries([["THREENATIVE_CAPABILITIES_MANIFEST", bundled]]);
13
+ const env = existsSync(bundled)
14
+ ? Object.fromEntries([["THREENATIVE_CAPABILITIES_MANIFEST", bundled]])
15
+ : {};
16
16
 
17
17
  const localServer = path.resolve(fileURLToPath(import.meta.url), "..", "engine-server.mjs");
18
18
  if (existsSync(localServer)) {
19
19
  const { runServer } = await import("./engine-server.mjs");
20
20
  runServer(env.THREENATIVE_CAPABILITIES_MANIFEST);
21
21
  } else {
22
+ // Say so. This branch fetches a package over the network, and when the network is slow, absent
23
+ // or the version unpublished it produces no output at all — the host simply waits. A CI run
24
+ // spent 30 s here twice and reported `threenative-engine initialize timed out after 30000ms`
25
+ // with empty stderr, which named neither the fallback nor the reason for it. The bundled server
26
+ // is missing here for a reason worth reporting even when the fetch then succeeds.
27
+ process.stderr.write(
28
+ `TN_MCP_ENGINE_FALLBACK: no bundled capability server at ${localServer}; fetching ${MCP_PACKAGES.engine.name}@${MCP_PACKAGES.engine.version} over the network instead. A packaged @threenative/core should always carry the bundle.\n`,
29
+ );
22
30
  await launchMcpServer({ ...MCP_PACKAGES.engine, env });
23
31
  }
@@ -0,0 +1,37 @@
1
+ // Types for `install.mjs`, the same contract `servers.d.mts` provides for `servers.mjs`. That file
2
+ // is plain JavaScript by design — `@threenative/core`'s postinstall runs it before anything is
3
+ // built — so it can never be a `.ts`. Without this declaration every consumer reached for
4
+ // `@ts-expect-error` and then retyped the host table locally.
5
+
6
+ /** One project-scoped host config the installer writes. */
7
+ export interface IMcpHost {
8
+ readonly file: string;
9
+ readonly format: string;
10
+ readonly id: string;
11
+ readonly label: string;
12
+ readonly seed?: Readonly<Record<string, unknown>>;
13
+ }
14
+
15
+ /** What a single config write did, or why it was left alone. */
16
+ export type McpConfigOutcome =
17
+ | "conflict"
18
+ | "created"
19
+ | "unchanged"
20
+ | "unreadable"
21
+ | "unwritable"
22
+ | "updated";
23
+
24
+ export declare const MCP_HOSTS: readonly IMcpHost[];
25
+ export declare function installTarget(
26
+ environment?: NodeJS.ProcessEnv,
27
+ cwd?: string,
28
+ ): string | undefined;
29
+ export declare function ensureJsonMcpConfig(
30
+ target: string,
31
+ file: string,
32
+ format: string,
33
+ seed?: Readonly<Record<string, unknown>>,
34
+ ): McpConfigOutcome;
35
+ export declare function ensureMcpConfig(target: string): McpConfigOutcome;
36
+ export declare function ensureCodexMcpConfig(target: string): McpConfigOutcome;
37
+ export declare function ensureHostMcpConfigs(target: string): Map<string, McpConfigOutcome>;
package/mcp/install.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
- import { mergeMcpServers } from "./servers.mjs";
3
+ import { CODEX_MCP_SERVERS, mergeMcpServers } from "./servers.mjs";
4
4
 
5
5
  /** The project being installed into, or undefined when this install has no project to write to —
6
6
  * a nested dependency install, or core's own workspace build. */
@@ -9,19 +9,68 @@ export function installTarget(environment = process.env, cwd = process.cwd()) {
9
9
  if (target.split(path.sep).includes("node_modules")) return undefined;
10
10
  const manifestPath = path.join(target, "package.json");
11
11
  if (!existsSync(manifestPath)) return undefined;
12
- let name;
12
+ let manifest;
13
13
  try {
14
- name = JSON.parse(readFileSync(manifestPath, "utf8")).name;
14
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
15
15
  } catch {
16
16
  return undefined;
17
17
  }
18
- return name === "@threenative/core" ? undefined : target;
18
+ if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest))
19
+ return undefined;
20
+ if (manifest.name === "@threenative/core") return undefined;
21
+ const dependencyGroups = [
22
+ manifest.dependencies,
23
+ manifest.devDependencies,
24
+ manifest.optionalDependencies,
25
+ manifest.peerDependencies,
26
+ ];
27
+ const declaresCore = dependencyGroups.some(
28
+ (dependencies) =>
29
+ typeof dependencies === "object" &&
30
+ dependencies !== null &&
31
+ Object.hasOwn(dependencies, "@threenative/core"),
32
+ );
33
+ return declaresCore ? target : undefined;
19
34
  }
20
35
 
21
- /** Adds the ThreeNative servers to `<target>/.mcp.json`, creating it when absent. Returns what it
36
+ /** Every agent host that reads a **project-scoped** MCP config, and the file each one reads.
37
+ *
38
+ * Project-scoped is the whole admission rule. A host that only reads a machine-wide config
39
+ * (Windsurf, Cline, Amp, the JetBrains assistants) is deliberately absent: installing a library
40
+ * into one game must never edit a file that governs every other project on the machine. Those
41
+ * hosts are wired by hand, and `docs/architecture/` says so. */
42
+ export const MCP_HOSTS = Object.freeze([
43
+ Object.freeze({
44
+ id: "claude-code",
45
+ label: "Claude Code",
46
+ file: ".mcp.json",
47
+ format: "mcpServers",
48
+ }),
49
+ Object.freeze({ id: "codex", label: "Codex", file: ".codex/config.toml", format: "codex" }),
50
+ Object.freeze({ id: "cursor", label: "Cursor", file: ".cursor/mcp.json", format: "mcpServers" }),
51
+ Object.freeze({ id: "vscode", label: "VS Code", file: ".vscode/mcp.json", format: "vscode" }),
52
+ Object.freeze({
53
+ id: "gemini-cli",
54
+ label: "Gemini CLI",
55
+ file: ".gemini/settings.json",
56
+ format: "mcpServers",
57
+ }),
58
+ Object.freeze({
59
+ id: "opencode",
60
+ label: "opencode",
61
+ file: "opencode.json",
62
+ format: "opencode",
63
+ // Written only when this installer creates the file, so a second install still reports
64
+ // "unchanged" rather than rewriting a config the user has since edited.
65
+ seed: Object.freeze({ $schema: "https://opencode.ai/config.json" }),
66
+ }),
67
+ Object.freeze({ id: "zed", label: "Zed", file: ".zed/settings.json", format: "zed" }),
68
+ ]);
69
+
70
+ /** Adds the ThreeNative servers to one host's JSON config, creating it when absent. Returns what it
22
71
  * did so the installer can say so once and stay quiet otherwise. */
23
- export function ensureMcpConfig(target) {
24
- const configPath = path.join(target, ".mcp.json");
72
+ export function ensureJsonMcpConfig(target, file, format, seed = undefined) {
73
+ const configPath = path.join(target, file);
25
74
  let existing;
26
75
  if (existsSync(configPath)) {
27
76
  // A config we cannot parse is still the user's. Rewriting it would drop servers we never wrote.
@@ -31,29 +80,28 @@ export function ensureMcpConfig(target) {
31
80
  return "unreadable";
32
81
  }
33
82
  }
34
- const { changed, config } = mergeMcpServers(existing);
35
- if (!changed) return "unchanged";
36
- writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
37
- return existing === undefined ? "created" : "updated";
83
+ let merged;
84
+ try {
85
+ merged = mergeMcpServers(existing, format);
86
+ } catch {
87
+ return "unreadable";
88
+ }
89
+ const { changed, config, conflicts } = merged;
90
+ if (!changed) return conflicts.length > 0 ? "conflict" : "unchanged";
91
+ const written = existing === undefined ? { ...seed, ...config } : config;
92
+ mkdirSync(path.dirname(configPath), { recursive: true });
93
+ writeFileSync(configPath, `${JSON.stringify(written, null, 2)}\n`);
94
+ return conflicts.length > 0 ? "conflict" : existing === undefined ? "created" : "updated";
38
95
  }
39
96
 
40
- const CODEX_MCP_SERVERS = [
41
- {
42
- body: `[mcp_servers.threenative-assets]\ncommand = "node"\nargs = ["./node_modules/@threenative/core/mcp/assets.mjs"]\n\n[mcp_servers.threenative-assets.env]\nASSET_DOWNLOAD_DIR = "./public/assets"\nAUDIO_DOWNLOAD_DIR = "./public/audio"`,
43
- name: "threenative-assets",
44
- },
45
- {
46
- body: `[mcp_servers.threenative-sculpt]\ncommand = "node"\nargs = ["./node_modules/@threenative/core/mcp/sculpt.mjs"]`,
47
- name: "threenative-sculpt",
48
- },
49
- {
50
- body: `[mcp_servers.threenative-engine]\ncommand = "node"\nargs = ["./node_modules/@threenative/core/mcp/engine.mjs"]`,
51
- name: "threenative-engine",
52
- },
53
- ];
97
+ /** Adds the ThreeNative servers to `<target>/.mcp.json`, the config Claude Code reads. */
98
+ export function ensureMcpConfig(target) {
99
+ return ensureJsonMcpConfig(target, ".mcp.json", "mcpServers");
100
+ }
54
101
 
55
102
  /** Adds any missing ThreeNative servers to Codex's project-scoped MCP config without replacing
56
- * user-authored settings or server definitions. */
103
+ * user-authored settings or server definitions. TOML rather than JSON, so this one appends text
104
+ * instead of re-serialising: a config we cannot parse back is one we must not rewrite. */
57
105
  export function ensureCodexMcpConfig(target) {
58
106
  const directory = path.join(target, ".codex");
59
107
  const configPath = path.join(directory, "config.toml");
@@ -75,3 +123,23 @@ export function ensureCodexMcpConfig(target) {
75
123
  );
76
124
  return existing.length === 0 ? "created" : "updated";
77
125
  }
126
+
127
+ /** Wires every host in `MCP_HOSTS`, mapping host id to what the write did. One host's unreadable
128
+ * or unwritable config never stops the rest: a project with a hand-edited `.cursor/mcp.json`
129
+ * should still get its Codex and VS Code tools. */
130
+ export function ensureHostMcpConfigs(target) {
131
+ const outcomes = new Map();
132
+ for (const host of MCP_HOSTS) {
133
+ try {
134
+ outcomes.set(
135
+ host.id,
136
+ host.format === "codex"
137
+ ? ensureCodexMcpConfig(target)
138
+ : ensureJsonMcpConfig(target, host.file, host.format, host.seed),
139
+ );
140
+ } catch {
141
+ outcomes.set(host.id, "unreadable");
142
+ }
143
+ }
144
+ return outcomes;
145
+ }
@@ -0,0 +1,34 @@
1
+ // Types for `servers.mjs`. That file is plain JavaScript by design — `@threenative/core`'s
2
+ // postinstall runs it before anything is built — so it can never be a `.ts`. Without this
3
+ // declaration every consumer reached for `@ts-expect-error` and then cast the result back into a
4
+ // shape of its own, which is three private copies of one contract.
5
+
6
+ export interface IMcpServerEntry {
7
+ readonly args: readonly string[];
8
+ readonly command: string;
9
+ readonly env?: Readonly<Record<string, string>>;
10
+ }
11
+
12
+ export interface IMcpPackage {
13
+ readonly name: string;
14
+ readonly version: string;
15
+ }
16
+
17
+ /** One host's whole config with this format's server table merged in. The table's key is the
18
+ * host's own (`mcpServers`, `servers`, `mcp`, `context_servers`), so a caller reading a key it did
19
+ * not ask for gets `undefined` rather than a silently empty object. */
20
+ export interface IMcpConfigFormat {
21
+ readonly changed: boolean;
22
+ readonly config: Record<string, Record<string, Record<string, unknown>> | undefined>;
23
+ }
24
+
25
+ export interface ICodexMcpServer {
26
+ readonly body: string;
27
+ readonly name: string;
28
+ }
29
+
30
+ export declare const MCP_SERVERS: Readonly<Record<string, IMcpServerEntry>>;
31
+ export declare const MCP_PACKAGES: Readonly<Record<string, IMcpPackage>>;
32
+ export declare const SERVER_FORMAT_NAMES: readonly string[];
33
+ export declare const CODEX_MCP_SERVERS: readonly ICodexMcpServer[];
34
+ export declare function mergeMcpServers(existing: unknown, format?: string): IMcpConfigFormat;