@threenative/core 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync, readFileSync } from 'fs';
3
+ import path from 'path';
4
+ import { createInterface } from 'readline';
5
+ import { fileURLToPath } from 'url';
6
+
7
+ var ENGINE_MCP_TOOL_NAMES = [
8
+ "engine_search_capabilities",
9
+ "engine_capability_detail"
10
+ ];
11
+ var DEFAULT_MANIFEST_FILE = "capabilities.json";
12
+ var STOP_WORDS = /* @__PURE__ */ new Set([
13
+ "a",
14
+ "add",
15
+ "an",
16
+ "and",
17
+ "around",
18
+ "build",
19
+ "by",
20
+ "create",
21
+ "for",
22
+ "from",
23
+ "game",
24
+ "in",
25
+ "into",
26
+ "it",
27
+ "make",
28
+ "of",
29
+ "on",
30
+ "that",
31
+ "the",
32
+ "to",
33
+ "use",
34
+ "with"
35
+ ]);
36
+ var MAX_COMPLETE_REQUEST_RESULTS = 15;
37
+ 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.`;
39
+ function defaultManifestPath(cwd = process.cwd()) {
40
+ return path.resolve(cwd, process.env.THREENATIVE_CAPABILITIES_MANIFEST ?? DEFAULT_MANIFEST_FILE);
41
+ }
42
+ function manifestError(file, reason) {
43
+ return new Error(`TN_ENGINE_CAPABILITIES_MANIFEST: ${file}: ${reason}`);
44
+ }
45
+ function isRecord(value) {
46
+ return typeof value === "object" && value !== null && !Array.isArray(value);
47
+ }
48
+ 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");
51
+ }
52
+ 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")) {
54
+ throw manifestError(file, `entry ${index} is malformed`);
55
+ }
56
+ }
57
+ return value;
58
+ }
59
+ function loadCapabilityManifest(file = defaultManifestPath()) {
60
+ let raw;
61
+ try {
62
+ raw = readFileSync(file, "utf8");
63
+ } catch (error) {
64
+ throw manifestError(file, `cannot read committed manifest: ${String(error)}`);
65
+ }
66
+ try {
67
+ return validateManifest(JSON.parse(raw), file);
68
+ } catch (error) {
69
+ if (error instanceof Error && error.message.startsWith("TN_ENGINE_CAPABILITIES_MANIFEST:"))
70
+ throw error;
71
+ throw manifestError(file, `cannot parse JSON: ${String(error)}`);
72
+ }
73
+ }
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
+ });
81
+ }
82
+ function situationScore(query, situations) {
83
+ if (query.length === 0) return { matchedSituation: "", score: 0 };
84
+ const queryText = query.join(" ");
85
+ let best = 0;
86
+ let matchedSituation = "";
87
+ for (const situation of situations) {
88
+ const phrase = tokens(situation);
89
+ const overlap = new Set(phrase.filter((token) => query.includes(token))).size;
90
+ const score = overlap / Math.max(query.length, phrase.length);
91
+ const phraseBonus = phrase.join(" ").includes(queryText) || queryText.includes(phrase.join(" ")) ? 1 : 0;
92
+ if (overlap < (query.length >= 4 ? 2 : 1) && phraseBonus === 0) continue;
93
+ const candidate = score + phraseBonus;
94
+ if (candidate > best) {
95
+ best = candidate;
96
+ matchedSituation = situation;
97
+ }
98
+ }
99
+ return { matchedSituation, score: best };
100
+ }
101
+ function capabilitySearchKey(entry) {
102
+ return `${entry.importPath}
103
+ ${entry.signature}
104
+ ${entry.summary}
105
+ ${entry.situations.join("\n")}`;
106
+ }
107
+ function searchCapabilities(situation, manifestFile = defaultManifestPath(), scope = "mechanic") {
108
+ if (typeof situation !== "string" || situation.trim().length === 0)
109
+ throw new Error("engine_search_capabilities requires a non-empty situation string.");
110
+ const manifest = loadCapabilityManifest(manifestFile);
111
+ const query = tokens(situation);
112
+ 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(
114
+ (left, right) => right.score - left.score || `${left.entry.importPath}:${left.entry.symbol}`.localeCompare(
115
+ `${right.entry.importPath}:${right.entry.symbol}`
116
+ )
117
+ ).filter(
118
+ (candidate, index, candidates) => candidates.findIndex(
119
+ (other) => capabilitySearchKey(other.entry) === capabilitySearchKey(candidate.entry)
120
+ ) === index
121
+ ).slice(0, limit).map(({ entry, matchedSituation }) => ({
122
+ constraints: entry.constraints,
123
+ example: entry.example,
124
+ importPath: entry.importPath,
125
+ matchedSituation,
126
+ summary: entry.summary,
127
+ symbol: entry.symbol
128
+ }));
129
+ }
130
+ function capabilityDetail(symbol, manifestFile = defaultManifestPath()) {
131
+ if (typeof symbol !== "string" || symbol.trim().length === 0)
132
+ throw new Error("engine_capability_detail requires a non-empty symbol string.");
133
+ const manifest = loadCapabilityManifest(manifestFile);
134
+ const entry = manifest.entries.find((candidate) => candidate.symbol === symbol);
135
+ if (entry === void 0) throw new Error(`Unknown engine capability '${symbol}'.`);
136
+ return entry;
137
+ }
138
+ var TOOL_DEFINITIONS = [
139
+ {
140
+ annotations: { destructiveHint: false, openWorldHint: false, readOnlyHint: true },
141
+ 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.',
143
+ inputSchema: {
144
+ additionalProperties: false,
145
+ properties: {
146
+ scope: {
147
+ description: "Use request for the complete cross-system game request; use mechanic or omit it for one mechanic.",
148
+ enum: ["request", "mechanic"],
149
+ type: "string"
150
+ },
151
+ situation: { type: "string" }
152
+ },
153
+ required: ["situation"],
154
+ type: "object"
155
+ }
156
+ },
157
+ {
158
+ annotations: { destructiveHint: false, openWorldHint: false, readOnlyHint: true },
159
+ name: "engine_capability_detail",
160
+ description: "Inspect one engine capability's import, signature, example, constraints, and overrides.",
161
+ inputSchema: {
162
+ additionalProperties: false,
163
+ properties: { symbol: { type: "string" } },
164
+ required: ["symbol"],
165
+ type: "object"
166
+ }
167
+ }
168
+ ];
169
+ function toolDefinitions() {
170
+ return TOOL_DEFINITIONS;
171
+ }
172
+ function jsonRpcError(id, code, message) {
173
+ return JSON.stringify({ id, jsonrpc: "2.0", error: { code, message } });
174
+ }
175
+ function jsonRpcResult(id, result) {
176
+ return JSON.stringify({ id, jsonrpc: "2.0", result });
177
+ }
178
+ function writeLine(value) {
179
+ process.stdout.write(`${value}
180
+ `);
181
+ }
182
+ function toolText(value) {
183
+ return { content: [{ text: JSON.stringify(value, null, 2), type: "text" }] };
184
+ }
185
+ function handleToolCall(params, manifestFile) {
186
+ const name = params.name;
187
+ const argumentsValue = isRecord(params.arguments) ? params.arguments : {};
188
+ if (name === "engine_search_capabilities") {
189
+ if (typeof argumentsValue.situation !== "string")
190
+ throw new Error("engine_search_capabilities requires a string 'situation' argument.");
191
+ const scope = argumentsValue.scope ?? "mechanic";
192
+ if (scope !== "request" && scope !== "mechanic")
193
+ throw new Error("engine_search_capabilities scope must be 'request' or 'mechanic'.");
194
+ return toolText(searchCapabilities(argumentsValue.situation, manifestFile, scope));
195
+ }
196
+ if (name === "engine_capability_detail") {
197
+ if (typeof argumentsValue.symbol !== "string")
198
+ throw new Error("engine_capability_detail requires a string 'symbol' argument.");
199
+ return toolText(capabilityDetail(argumentsValue.symbol, manifestFile));
200
+ }
201
+ throw new Error(`Unknown engine MCP tool '${String(name)}'.`);
202
+ }
203
+ function handleLine(line, manifestFile) {
204
+ if (line.trim().length === 0) return void 0;
205
+ let request;
206
+ try {
207
+ request = JSON.parse(line);
208
+ } catch (error) {
209
+ return jsonRpcError(null, -32700, `Invalid JSON: ${String(error)}`);
210
+ }
211
+ if (!isRecord(request) || request.id === void 0 || typeof request.method !== "string")
212
+ return void 0;
213
+ try {
214
+ if (request.method === "initialize") {
215
+ return jsonRpcResult(request.id, {
216
+ capabilities: { tools: { listChanged: false } },
217
+ instructions: AUTHORING_INSTRUCTIONS,
218
+ protocolVersion: "2025-06-18",
219
+ serverInfo: { name: "threenative-engine-mcp", version: "0.2.0" }
220
+ });
221
+ }
222
+ if (request.method === "tools/list") {
223
+ return jsonRpcResult(request.id, { tools: TOOL_DEFINITIONS });
224
+ }
225
+ if (request.method === "tools/call") {
226
+ if (!isRecord(request.params)) throw new Error("tools/call requires an object params value.");
227
+ return jsonRpcResult(request.id, handleToolCall(request.params, manifestFile));
228
+ }
229
+ return jsonRpcError(request.id, -32601, `Method not found: ${request.method}`);
230
+ } catch (error) {
231
+ return jsonRpcError(request.id, -32e3, error instanceof Error ? error.message : String(error));
232
+ }
233
+ }
234
+ function runServer(manifestFile = defaultManifestPath()) {
235
+ loadCapabilityManifest(manifestFile);
236
+ const input = createInterface({ input: process.stdin });
237
+ input.on("line", (line) => {
238
+ const response = handleLine(line, manifestFile);
239
+ if (response !== void 0) writeLine(response);
240
+ });
241
+ }
242
+ var entryPath = process.argv[1];
243
+ if (entryPath !== void 0 && realpathSync(path.resolve(entryPath)) === realpathSync(fileURLToPath(import.meta.url))) {
244
+ try {
245
+ runServer();
246
+ } catch (error) {
247
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}
248
+ `);
249
+ process.exitCode = 1;
250
+ }
251
+ }
252
+
253
+ export { ENGINE_MCP_TOOL_NAMES, capabilityDetail, defaultManifestPath, handleLine, loadCapabilityManifest, runServer, searchCapabilities, toolDefinitions };
package/mcp/engine.mjs ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { launchMcpServer } from "./launch.mjs";
6
+ import { MCP_PACKAGES } from "./servers.mjs";
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.
11
+ 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]]);
16
+
17
+ const localServer = path.resolve(fileURLToPath(import.meta.url), "..", "engine-server.mjs");
18
+ if (existsSync(localServer)) {
19
+ const { runServer } = await import("./engine-server.mjs");
20
+ runServer(env.THREENATIVE_CAPABILITIES_MANIFEST);
21
+ } else {
22
+ await launchMcpServer({ ...MCP_PACKAGES.engine, env });
23
+ }
@@ -0,0 +1,77 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { mergeMcpServers } from "./servers.mjs";
4
+
5
+ /** The project being installed into, or undefined when this install has no project to write to —
6
+ * a nested dependency install, or core's own workspace build. */
7
+ export function installTarget(environment = process.env, cwd = process.cwd()) {
8
+ const target = path.resolve(environment.INIT_CWD ?? cwd);
9
+ if (target.split(path.sep).includes("node_modules")) return undefined;
10
+ const manifestPath = path.join(target, "package.json");
11
+ if (!existsSync(manifestPath)) return undefined;
12
+ let name;
13
+ try {
14
+ name = JSON.parse(readFileSync(manifestPath, "utf8")).name;
15
+ } catch {
16
+ return undefined;
17
+ }
18
+ return name === "@threenative/core" ? undefined : target;
19
+ }
20
+
21
+ /** Adds the ThreeNative servers to `<target>/.mcp.json`, creating it when absent. Returns what it
22
+ * 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");
25
+ let existing;
26
+ if (existsSync(configPath)) {
27
+ // A config we cannot parse is still the user's. Rewriting it would drop servers we never wrote.
28
+ try {
29
+ existing = JSON.parse(readFileSync(configPath, "utf8"));
30
+ } catch {
31
+ return "unreadable";
32
+ }
33
+ }
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";
38
+ }
39
+
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
+ ];
54
+
55
+ /** Adds any missing ThreeNative servers to Codex's project-scoped MCP config without replacing
56
+ * user-authored settings or server definitions. */
57
+ export function ensureCodexMcpConfig(target) {
58
+ const directory = path.join(target, ".codex");
59
+ const configPath = path.join(directory, "config.toml");
60
+ const existing = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
61
+ const missing = CODEX_MCP_SERVERS.filter(
62
+ ({ name }) => !existing.includes(`[mcp_servers.${name}]`),
63
+ );
64
+ if (missing.length === 0) return "unchanged";
65
+ mkdirSync(directory, { recursive: true });
66
+ const separator =
67
+ existing.length === 0 || existing.endsWith("\n\n")
68
+ ? ""
69
+ : existing.endsWith("\n")
70
+ ? "\n"
71
+ : "\n\n";
72
+ writeFileSync(
73
+ configPath,
74
+ `${existing}${separator}${missing.map(({ body }) => body).join("\n\n")}\n`,
75
+ );
76
+ return existing.length === 0 ? "created" : "updated";
77
+ }
package/mcp/launch.mjs ADDED
@@ -0,0 +1,72 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+
6
+ /** Where a package is installed, or undefined when it is not. Node's own resolver is the wrong tool
7
+ * here: `threenative-engine-mcp` publishes an `exports` map with only an `import` condition, which
8
+ * `createRequire().resolve` refuses, and the answer we want is the directory rather than a
9
+ * specifier's entry point. Walking `node_modules` upward from both the project and this package
10
+ * covers a direct dependency, a hoisted one, and pnpm's nested store alike. */
11
+ function packageDirectory(name) {
12
+ const starts = [process.cwd(), path.dirname(fileURLToPath(import.meta.url))];
13
+ for (const start of starts) {
14
+ let directory = path.resolve(start);
15
+ while (true) {
16
+ const candidate = path.join(directory, "node_modules", name);
17
+ if (existsSync(path.join(candidate, "package.json"))) return candidate;
18
+ const parent = path.dirname(directory);
19
+ if (parent === directory) break;
20
+ directory = parent;
21
+ }
22
+ }
23
+ return undefined;
24
+ }
25
+
26
+ /** The module that starts the server. The published main entry is not always it —
27
+ * `threenative-sculpt-mcp` exports a library from `dist/index.js` and starts its server from the
28
+ * `bin` file — so `bin` wins wherever a package declares one. */
29
+ function serverEntry(directory) {
30
+ const manifest = JSON.parse(readFileSync(path.join(directory, "package.json"), "utf8"));
31
+ const bin = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.[manifest.name];
32
+ const exported =
33
+ typeof manifest.exports === "string"
34
+ ? manifest.exports
35
+ : (manifest.exports?.["."]?.import ?? manifest.exports?.["."]);
36
+ const relative =
37
+ bin ??
38
+ (typeof exported === "string" ? exported : undefined) ??
39
+ manifest.main ??
40
+ manifest.module;
41
+ return relative === undefined ? undefined : path.resolve(directory, relative);
42
+ }
43
+
44
+ /** Runs an MCP server the project may or may not have installed. Nothing here may write to stdout:
45
+ * that stream is the JSON-RPC transport, and one stray line makes the server unusable. */
46
+ export async function launchMcpServer({ name, version, env = {} }) {
47
+ for (const [key, value] of Object.entries(env)) process.env[key] ??= value;
48
+ const directory = packageDirectory(name);
49
+ const entry = directory === undefined ? undefined : serverEntry(directory);
50
+ if (entry !== undefined && existsSync(entry)) {
51
+ // A server entry that is also a `bin` starts itself only when it is the process's main module,
52
+ // which it decides by comparing `process.argv[1]` against its own path. Importing it without
53
+ // this line loads the module and starts nothing, and the host reports a server that connected
54
+ // and then went silent.
55
+ process.argv[1] = entry;
56
+ await import(pathToFileURL(entry).href);
57
+ return;
58
+ }
59
+ // Not installed. `npx` keeps the server available in a project that never listed it, which is the
60
+ // whole point of wiring these in from the library rather than from a scaffold.
61
+ const child = spawn("npx", ["--yes", `${name}@${version}`], {
62
+ env: process.env,
63
+ stdio: "inherit",
64
+ });
65
+ child.on("error", (error) => {
66
+ process.stderr.write(`TN_MCP_LAUNCH: ${name} is not installed and npx failed: ${error}\n`);
67
+ process.exit(1);
68
+ });
69
+ child.on("exit", (code, signal) => {
70
+ process.exit(signal === null ? (code ?? 0) : 1);
71
+ });
72
+ }
package/mcp/sculpt.mjs ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { launchMcpServer } from "./launch.mjs";
3
+ import { MCP_PACKAGES } from "./servers.mjs";
4
+
5
+ await launchMcpServer(MCP_PACKAGES.sculpt);
@@ -0,0 +1,59 @@
1
+ // The MCP servers a ThreeNative project always has. `@threenative/core` is a direct dependency of
2
+ // every generated game, so `./node_modules/@threenative/core/mcp/<file>` is a path that resolves
3
+ // under npm, pnpm and yarn alike — unlike `./node_modules/threenative-asset-mcp/...`, which only
4
+ // exists when the package manager hoists, and which a project that never listed the server as a
5
+ // direct dependency does not have at all.
6
+ export const MCP_SERVERS = Object.freeze({
7
+ "threenative-assets": Object.freeze({
8
+ command: "node",
9
+ args: Object.freeze(["./node_modules/@threenative/core/mcp/assets.mjs"]),
10
+ // Entries rather than an object literal: these are environment variable names, not identifiers,
11
+ // and written as property names they read as names the naming rule must judge.
12
+ env: Object.freeze(
13
+ Object.fromEntries([
14
+ ["ASSET_DOWNLOAD_DIR", "./public/assets"],
15
+ ["AUDIO_DOWNLOAD_DIR", "./public/audio"],
16
+ ]),
17
+ ),
18
+ }),
19
+ "threenative-sculpt": Object.freeze({
20
+ command: "node",
21
+ args: Object.freeze(["./node_modules/@threenative/core/mcp/sculpt.mjs"]),
22
+ }),
23
+ "threenative-engine": Object.freeze({
24
+ command: "node",
25
+ args: Object.freeze(["./node_modules/@threenative/core/mcp/engine.mjs"]),
26
+ }),
27
+ });
28
+
29
+ /** The external package each shim launches. Core installs asset and sculpt transitively and bundles
30
+ * engine discovery itself: a game author must never know these package names or install them
31
+ * separately. The pinned engine version remains an emergency fallback for a legacy development
32
+ * checkout whose bundle has not been built. */
33
+ export const MCP_PACKAGES = Object.freeze({
34
+ assets: Object.freeze({ name: "threenative-asset-mcp", version: "0.6.0" }),
35
+ sculpt: Object.freeze({ name: "threenative-sculpt-mcp", version: "0.1.0" }),
36
+ engine: Object.freeze({ name: "threenative-engine-mcp", version: "0.2.0" }),
37
+ });
38
+
39
+ /** Adds the ThreeNative servers to an existing config without touching anything else in it, and
40
+ * reports whether the file needs rewriting. Fails closed on a config that is not an object: an
41
+ * unreadable `.mcp.json` is the user's, and overwriting it would lose servers we did not write. */
42
+ export function mergeMcpServers(existing) {
43
+ const malformed =
44
+ existing !== undefined &&
45
+ (typeof existing !== "object" || existing === null || Array.isArray(existing));
46
+ if (malformed) {
47
+ throw new Error("MCP config root must be an object.");
48
+ }
49
+ const previous = existing ?? {};
50
+ const servers = { ...(previous.mcpServers ?? {}) };
51
+ let changed = false;
52
+ for (const [name, server] of Object.entries(MCP_SERVERS)) {
53
+ const wanted = JSON.parse(JSON.stringify(server));
54
+ if (JSON.stringify(servers[name]) === JSON.stringify(wanted)) continue;
55
+ servers[name] = wanted;
56
+ changed = true;
57
+ }
58
+ return { changed, config: { ...previous, mcpServers: servers } };
59
+ }
package/package.json CHANGED
@@ -1,10 +1,16 @@
1
1
  {
2
2
  "name": "@threenative/core",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
+ "license": "MIT",
4
5
  "description": "The small vanilla Three.js game runtime foundation.",
5
6
  "type": "module",
6
7
  "files": [
7
- "dist"
8
+ "dist",
9
+ "mcp",
10
+ "scripts",
11
+ "patches",
12
+ "capabilities.json",
13
+ "README.md"
8
14
  ],
9
15
  "main": "./dist/index.js",
10
16
  "module": "./dist/index.js",
@@ -22,6 +28,18 @@
22
28
  "types": "./dist/hot.d.ts",
23
29
  "import": "./dist/hot.js"
24
30
  },
31
+ "./react": {
32
+ "types": "./dist/react.d.ts",
33
+ "import": "./dist/react.js"
34
+ },
35
+ "./ui-layer": {
36
+ "types": "./dist/ui-layer.d.ts",
37
+ "import": "./dist/ui-layer.js"
38
+ },
39
+ "./world": {
40
+ "types": "./dist/world.d.ts",
41
+ "import": "./dist/world.js"
42
+ },
25
43
  "./package.json": "./package.json"
26
44
  },
27
45
  "publishConfig": {
@@ -30,18 +48,43 @@
30
48
  "sideEffects": false,
31
49
  "dependencies": {
32
50
  "three": "0.185.1",
33
- "zustand": "5.x"
51
+ "three-mesh-bvh": "0.9.14",
52
+ "threenative-asset-mcp": "0.6.0",
53
+ "threenative-sculpt-mcp": "0.1.1",
54
+ "zustand": "5.0.14"
34
55
  },
35
56
  "devDependencies": {
57
+ "@types/react": "19.2.0",
58
+ "@types/react-reconciler": "0.32.1",
36
59
  "@types/three": "0.185.3",
37
60
  "publint": "0.3.22",
61
+ "react": "19.2.0",
62
+ "react-reconciler": "0.33.0",
38
63
  "tsup": "8.5.1",
39
64
  "typescript": "5.9.3",
40
- "@threenative/playtest": "0.1.0"
65
+ "@threenative/playtest": "0.3.0",
66
+ "threenative-engine-mcp": "0.2.0"
67
+ },
68
+ "peerDependencies": {
69
+ "@threenative/playtest": ">=0.3.0 <0.4.0",
70
+ "react": ">=19.2.0 <20",
71
+ "react-reconciler": ">=0.33.0 <0.34"
72
+ },
73
+ "peerDependenciesMeta": {
74
+ "@threenative/playtest": {
75
+ "optional": true
76
+ },
77
+ "react": {
78
+ "optional": true
79
+ },
80
+ "react-reconciler": {
81
+ "optional": true
82
+ }
41
83
  },
42
84
  "scripts": {
43
- "build": "tsup --config tsup.config.ts && publint",
85
+ "postinstall": "node ./scripts/postinstall.mjs",
86
+ "build": "node scripts/generate-version.mjs && tsup --config tsup.config.ts && node scripts/bundle-engine-mcp.mjs && publint",
44
87
  "typecheck": "tsc --noEmit",
45
- "test": "pnpm run build"
88
+ "test": "publint --strict"
46
89
  }
47
90
  }