@gripforgeai/mcp 0.1.10 → 0.1.11

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,421 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { execFile, spawn } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { copyFile, mkdir, open, readFile, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises';
5
+ import { basename, dirname, isAbsolute, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { z } from 'zod/v4';
8
+ import { isDeepStrictEqual } from 'node:util';
9
+ const absolute = z.string().max(4096).refine(isAbsolute, 'Expected an absolute local path.');
10
+ const id = z.string().regex(/^[a-zA-Z0-9_-]{1,128}$/);
11
+ const assetPath = z.string().regex(/^\/(Game|Engine)(?:\/[a-zA-Z0-9_-]+)+$/);
12
+ const outputPath = z.string().regex(/^\/Game\/GripForge\/Maps\/(?:[a-zA-Z0-9_-]+\/)*[a-zA-Z0-9_-]+$/);
13
+ const scalar = z.number().finite();
14
+ const vec3 = z.object({ x: scalar, y: scalar, z: scalar });
15
+ const transform = z.object({ translation: vec3, rotation: vec3.extend({ w: scalar }), scale: vec3 });
16
+ const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/);
17
+ const sha = z.string().regex(/^[0-9a-f]{64}$/);
18
+ const triple = z.tuple([scalar, scalar, scalar]);
19
+ const spawnReviewDefaults = { enabled: true, duration_seconds: 6, tolerance_cm: 200, require_grounded: true };
20
+ // Image files are pinned just like meshes. Native paths remain supported for project-owned materials.
21
+ const textureFile = z.object({
22
+ assetId: id, revisionId: id, fileRole: id.optional(),
23
+ file: absolute.refine(s => /\.(png|jpe?g)$/i.test(s), 'Portable textures require PNG or JPEG files.'), sha256: sha,
24
+ }).strict();
25
+ const texture = z.union([assetPath, textureFile]);
26
+ const sourceSchema = z.object({
27
+ assetId: id, revisionId: id, kind: z.enum(['native_mesh', 'native_actor', 'glb']),
28
+ path: assetPath.optional(), file: absolute.optional(), sha256: sha,
29
+ pivot: z.enum(['source', 'bounds_base']).default('source'), origin: triple.optional(), extent: triple.optional(),
30
+ }).superRefine((a, ctx) => {
31
+ if (a.kind === 'glb' ? !a.file || !/\.glb$/i.test(a.file) : !a.path?.startsWith('/Game/'))
32
+ ctx.addIssue({ code: 'custom', message: 'GLB requires file; native sources require a /Game package path.' });
33
+ if (a.pivot === 'bounds_base' && (!a.origin || !a.extent || a.extent.some(n => n <= 0)))
34
+ ctx.addIssue({ code: 'custom', message: 'bounds_base requires inspected native bounds in centimetres.' });
35
+ });
36
+ const nodeSchema = z.object({
37
+ id, name: z.string().max(1024), type: z.enum(['asset', 'group', 'terrain', 'primitive', 'region', 'path', 'zone', 'camera', 'environment', 'light']),
38
+ parentId: id.nullable(), transform, visible: z.boolean(), locked: z.boolean(),
39
+ }).passthrough();
40
+ /** Portable, versioned delivery binding. The SceneDocument remains the source of instance data. */
41
+ export const unrealScenePlanSchema = z.object({
42
+ schema: z.literal('gripforge.unreal-scene.v1'), project_file: absolute.refine(s => /\.uproject$/i.test(s)),
43
+ target_level: outputPath, asset_folder: outputPath,
44
+ document: z.object({ schemaVersion: z.literal(1), id, name: z.string(), purpose: z.literal('map'), nodes: z.array(nodeSchema).min(1).max(20000), metadata: z.record(z.string(), z.unknown()).default({}) }).passthrough(),
45
+ assets: z.array(sourceSchema).max(5000),
46
+ terrains: z.array(z.object({ nodeId: id, assetId: id, revisionId: id })).default([]),
47
+ materials: z.array(z.object({
48
+ id, color: hex.default('#ffffff'), roughness: scalar.min(0).max(1).default(.8), metalness: scalar.min(0).max(1).default(0),
49
+ textures: z.object({ color: texture.optional(), normal: texture.optional(), roughness: texture.optional(), metalness: texture.optional(), emissive: texture.optional(), ao: texture.optional() }).default({}),
50
+ repeat: z.tuple([scalar.min(.001).max(10000), scalar.min(.001).max(10000)]).default([1, 1]),
51
+ normal_format: z.enum(['opengl', 'directx']).default('opengl'),
52
+ emissive: hex.optional(), emissive_intensity: scalar.min(0).max(100).default(0), unlit: z.boolean().default(false),
53
+ vertex_colors: z.boolean().default(false),
54
+ })).default([]),
55
+ material_bindings: z.array(z.object({ nodeId: id, slot: z.number().int().min(0).max(63).default(0), materialId: id })).default([]),
56
+ collision: z.record(z.string(), z.enum(['none', 'source', 'complex'])).default({}),
57
+ actor_properties: z.record(id, z.record(z.string().min(1).max(128).regex(/^[^\x00-\x1f\x7f]+$/), z.union([scalar, z.boolean(), z.string().max(512)]))).default({}),
58
+ niagara_overrides: z.array(z.object({ nodeId: id, system: assetPath, colors: z.record(z.string().min(1).max(128).regex(/^User\.[^\x00-\x1f\x7f]+$/), z.object({ color: hex, intensity: scalar.min(0).max(100).default(1) })) })).max(32).default([]),
59
+ vfx_previews: z.array(z.object({ nodeId: id, warmup_seconds: scalar.min(0).max(5).default(1), hide_static_meshes: z.boolean().default(false) })).max(16).default([]),
60
+ environment: z.object({ sun_lux: scalar.min(0).max(150000).default(8), sky_intensity: scalar.min(0).max(20).default(1), fog_density: scalar.min(0).max(.1).default(.001), exposure_ev100: scalar.min(-10).max(20).default(1), bloom_intensity: scalar.min(0).max(10).default(.25) }).default({ sun_lux: 8, sky_intensity: 1, fog_density: .001, exposure_ev100: 1, bloom_intensity: .25 }),
61
+ gameplay: z.object({
62
+ game_mode: z.string().regex(/^\/(?:Game|Script)\/[a-zA-Z0-9_/.]+$/).optional(),
63
+ player_starts: z.array(z.object({ nodeId: id, offset: vec3.default({ x: 0, y: 1, z: 0 }) })).default([]),
64
+ spawn_review: z.object({
65
+ enabled: z.boolean().default(true), duration_seconds: scalar.min(2).max(15).default(6),
66
+ tolerance_cm: scalar.min(10).max(500).default(200), require_grounded: z.boolean().default(true),
67
+ }).strict().default(spawnReviewDefaults),
68
+ }).default({ player_starts: [], spawn_review: spawnReviewDefaults }),
69
+ capture: z.object({ camera_ids: z.array(id).max(16).default([]), width: z.number().int().min(320).max(3840).default(1600), height: z.number().int().min(240).max(2160).default(900) }).default({ camera_ids: [], width: 1600, height: 900 }),
70
+ }).strict();
71
+ const textureChannels = { color: 'map', normal: 'normalMap', roughness: 'roughnessMap', metalness: 'metalnessMap', emissive: 'emissiveMap', ao: 'aoMap' };
72
+ const textureKey = (t) => JSON.stringify([t.assetId, t.revisionId, t.fileRole ?? 'main']);
73
+ /** Common material values for the Studio, with image files resolved by the normal SceneAssetResolver. */
74
+ export function unrealMaterialToScene(m) {
75
+ const result = { color: m.color, roughness: m.roughness, metalness: m.metalness, repeat: m.repeat, vertexColors: m.vertex_colors };
76
+ if (m.emissive)
77
+ Object.assign(result, { emissive: m.emissive, emissiveIntensity: m.emissive_intensity });
78
+ for (const [channel, key] of Object.entries(textureChannels)) {
79
+ const t = m.textures[channel];
80
+ if (t && typeof t !== 'string')
81
+ result[key] = { assetId: t.assetId, revisionId: t.revisionId, ...(t.fileRole ? { fileRole: t.fileRole } : {}) };
82
+ }
83
+ if (result.normalMap)
84
+ result.normalScale = [1, m.normal_format === 'directx' ? -1 : 1];
85
+ return result;
86
+ }
87
+ export function portableSceneTextures(p) {
88
+ const found = new Map();
89
+ for (const m of p.materials)
90
+ for (const t of Object.values(m.textures)) {
91
+ if (typeof t === 'string')
92
+ continue;
93
+ const key = textureKey(t), previous = found.get(key);
94
+ if (previous && (previous.sha256 !== t.sha256 || previous.file !== t.file))
95
+ throw Error('Conflicting texture revision binding: ' + t.assetId);
96
+ found.set(key, t);
97
+ }
98
+ return [...found.values()];
99
+ }
100
+ export function validateUnrealScenePlan(value) {
101
+ const p = unrealScenePlanSchema.parse(value), nodes = new Map(p.document.nodes.map(n => [n.id, n]));
102
+ if (nodes.size !== p.document.nodes.length)
103
+ throw Error('Duplicate scene node IDs.');
104
+ if (p.target_level === p.asset_folder || p.asset_folder.startsWith(p.target_level + '/'))
105
+ throw Error('Level package and asset folder must be separate.');
106
+ const sources = new Map(p.assets.map(a => [JSON.stringify([a.assetId, a.revisionId]), a]));
107
+ if (sources.size !== p.assets.length)
108
+ throw Error('Duplicate asset revision binding.');
109
+ const terrains = new Map(p.terrains.map(t => [t.nodeId, t]));
110
+ if (terrains.size !== p.terrains.length)
111
+ throw Error('Duplicate terrain binding.');
112
+ const materials = new Set(p.materials.map(m => m.id));
113
+ if (materials.size !== p.materials.length)
114
+ throw Error('Duplicate material ID.');
115
+ for (const t of portableSceneTextures(p))
116
+ if (sources.has(JSON.stringify([t.assetId, t.revisionId])))
117
+ throw Error('Texture and mesh revisions must be separate assets.');
118
+ const assetRef = z.object({ assetId: id, revisionId: id });
119
+ for (const n of nodes.values()) {
120
+ const q = n.transform.rotation, s = n.transform.scale;
121
+ if (Math.hypot(q.x, q.y, q.z, q.w) < 1e-8 || [s.x, s.y, s.z].some(v => Math.abs(v) < 1e-8))
122
+ throw Error('Invalid transform: ' + n.id);
123
+ const ancestors = new Set([n.id]);
124
+ let parent = n.parentId;
125
+ while (parent) {
126
+ if (!nodes.has(parent) || ancestors.has(parent))
127
+ throw Error('Invalid scene hierarchy: ' + n.id);
128
+ ancestors.add(parent);
129
+ parent = nodes.get(parent).parentId;
130
+ }
131
+ if (n.type === 'asset') {
132
+ if (n.instanceBatch)
133
+ throw Error('Expand compact foliage batches into individual nodes before Unreal delivery: ' + n.id);
134
+ const ref = assetRef.parse(n.asset);
135
+ if (!sources.has(JSON.stringify([ref.assetId, ref.revisionId])))
136
+ throw Error('Missing asset revision binding: ' + n.id);
137
+ if (n.attachment || n.armor || n.performer || n.animation?.enabled)
138
+ throw Error('Animated/attached characters need a skeletal delivery adapter: ' + n.id);
139
+ }
140
+ else if (n.type === 'terrain') {
141
+ z.tuple([scalar.positive(), scalar.positive()]).parse(n.size);
142
+ const b = terrains.get(n.id);
143
+ if (!b || !sources.has(JSON.stringify([b.assetId, b.revisionId])))
144
+ throw Error('Terrain requires an exported mesh binding: ' + n.id);
145
+ const source = sources.get(JSON.stringify([b.assetId, b.revisionId]));
146
+ if (source.kind === 'native_actor')
147
+ throw Error('Terrain requires a static mesh, not a Blueprint: ' + n.id);
148
+ if ((p.collision[n.id] ?? 'complex') === 'complex' && source.kind !== 'glb')
149
+ throw Error('Complex terrain collision requires a newly imported mesh: ' + n.id);
150
+ }
151
+ else if (n.type === 'path') {
152
+ z.array(vec3).min(2).max(20000).parse(n.points);
153
+ scalar.positive().parse(n.width);
154
+ }
155
+ else if (n.type === 'zone' || n.type === 'region') {
156
+ z.array(z.tuple([scalar, scalar])).min(3).max(20000).parse(n.polygon);
157
+ if (n.type === 'zone')
158
+ z.enum(['spawn', 'objective', 'gameplay']).parse(n.zoneKind);
159
+ }
160
+ else if (n.type === 'camera') {
161
+ vec3.parse(n.target);
162
+ scalar.min(1).max(170).parse(n.fov);
163
+ if (n.projection !== 'perspective' || n.keys?.length)
164
+ throw Error('Only static perspective cameras are supported in this delivery version.');
165
+ }
166
+ else if (n.type === 'primitive') {
167
+ z.enum(['box', 'plane', 'sphere', 'cylinder']).parse(n.shape);
168
+ z.tuple([scalar.positive(), scalar.positive(), scalar.positive()]).parse(n.size);
169
+ }
170
+ else if (n.type === 'environment') {
171
+ if (n.hdri || n.clouds?.enabled)
172
+ throw Error('HDRI/clouds require a native Unreal environment adapter; do not silently drop them.');
173
+ }
174
+ else if (n.type === 'light') {
175
+ z.enum(['directional', 'point', 'ambient', 'hemisphere']).parse(n.lightKind);
176
+ hex.parse(n.color);
177
+ scalar.min(0).parse(n.intensity);
178
+ scalar.min(0).parse(n.range);
179
+ }
180
+ }
181
+ if (p.document.nodes.filter(n => n.type === 'environment').length > 1)
182
+ throw Error('Only one shared environment is supported.');
183
+ const materialSlots = new Set();
184
+ for (const b of p.material_bindings) {
185
+ const node = nodes.get(b.nodeId), slot = JSON.stringify([b.nodeId, b.slot]);
186
+ if (!node || !materials.has(b.materialId) || !['asset', 'primitive', 'terrain'].includes(node.type))
187
+ throw Error('Invalid material binding.');
188
+ if (materialSlots.has(slot))
189
+ throw Error('Duplicate material slot binding: ' + b.nodeId);
190
+ materialSlots.add(slot);
191
+ if (node.type === 'asset') {
192
+ const ref = assetRef.parse(node.asset);
193
+ if (sources.get(JSON.stringify([ref.assetId, ref.revisionId]))?.kind === 'native_actor')
194
+ throw Error('Blueprint materials need explicit component bindings; this adapter supports static mesh material slots only: ' + b.nodeId);
195
+ }
196
+ }
197
+ for (const b of p.terrains)
198
+ if (nodes.get(b.nodeId)?.type !== 'terrain')
199
+ throw Error('Invalid terrain binding.');
200
+ // Keep common instance materials in the delivery document. Explicit bindings must agree;
201
+ // silently rendering one material in the Studio and another in UE is forbidden.
202
+ for (const n of nodes.values()) {
203
+ const surface = n.material;
204
+ const values = n.type === 'asset' ? Object.entries(n.materials ?? {}) : ['terrain', 'primitive'].includes(n.type) && surface && Object.values(textureChannels).some(k => surface[k]) ? [['*', surface]] : [];
205
+ for (const [name, value] of values) {
206
+ const bindings = p.material_bindings.filter(b => b.nodeId === n.id), m = p.materials.find(m => m.id === bindings[0]?.materialId);
207
+ if (name !== '*' || bindings.length !== 1 || bindings[0].slot !== 0 || !m || !isDeepStrictEqual(value, unrealMaterialToScene(m)))
208
+ throw Error('Resolve material overrides into matching material_bindings for Unreal delivery: ' + n.id);
209
+ }
210
+ }
211
+ const starts = new Set();
212
+ for (const s of p.gameplay.player_starts) {
213
+ const node = nodes.get(s.nodeId);
214
+ if (node?.type !== 'zone' || node.zoneKind !== 'spawn')
215
+ throw Error('Player start must bind a spawn zone.');
216
+ if (starts.has(s.nodeId))
217
+ throw Error('Duplicate player start binding: ' + s.nodeId);
218
+ starts.add(s.nodeId);
219
+ }
220
+ for (const c of p.capture.camera_ids)
221
+ if (nodes.get(c)?.type !== 'camera')
222
+ throw Error('Capture must bind a scene camera.');
223
+ for (const [key, mode] of Object.entries(p.collision)) {
224
+ const node = nodes.get(key);
225
+ if (!node || !['asset', 'terrain', 'primitive'].includes(node.type))
226
+ throw Error('Invalid collision binding: ' + key);
227
+ if (mode === 'complex' && node.type === 'primitive')
228
+ throw Error('Complex collision may not modify a shared engine primitive.');
229
+ if (node.type === 'asset') {
230
+ const ref = assetRef.parse(node.asset), kind = sources.get(JSON.stringify([ref.assetId, ref.revisionId]))?.kind;
231
+ if (mode === 'complex' && kind !== 'glb')
232
+ throw Error('Complex collision may only modify a newly imported mesh, never a shared native source.');
233
+ if (mode !== 'source' && kind === 'native_actor')
234
+ throw Error('Blueprint collision needs explicit component bindings: ' + key);
235
+ }
236
+ }
237
+ for (const key of Object.keys(p.actor_properties)) {
238
+ const node = nodes.get(key);
239
+ if (node?.type !== 'asset')
240
+ throw Error('Actor properties require a native Blueprint instance: ' + key);
241
+ const ref = assetRef.parse(node.asset);
242
+ if (sources.get(JSON.stringify([ref.assetId, ref.revisionId]))?.kind !== 'native_actor')
243
+ throw Error('Actor properties require a native Blueprint instance: ' + key);
244
+ }
245
+ for (const preview of [...p.vfx_previews, ...p.niagara_overrides]) {
246
+ const node = nodes.get(preview.nodeId);
247
+ if (node?.type !== 'asset')
248
+ throw Error('VFX preview requires a native Blueprint instance.');
249
+ const ref = assetRef.parse(node.asset);
250
+ if (sources.get(JSON.stringify([ref.assetId, ref.revisionId]))?.kind !== 'native_actor')
251
+ throw Error('VFX preview requires a native Blueprint instance.');
252
+ }
253
+ return p;
254
+ }
255
+ const hash = (b) => createHash('sha256').update(b).digest('hex');
256
+ export function unrealLaunchCommand(executable, args, nativeAppleSilicon) {
257
+ // A Rosetta Node process otherwise selects UE's Intel slice and cannot load arm64 project modules.
258
+ return nativeAppleSilicon ? { command: '/usr/bin/arch', args: ['-arm64', executable, ...args] } : { command: executable, args };
259
+ }
260
+ async function readJson(file) { if ((await stat(file)).size > 64 * 1024 * 1024)
261
+ throw Error('Manifest exceeds 64 MB.'); return JSON.parse(await readFile(file, 'utf8')); }
262
+ async function atomicJson(file, value) { const temp = file + '.' + process.pid + '.tmp'; await writeFile(temp, JSON.stringify(value, null, 2)); await rename(temp, file); }
263
+ export async function prepareUnrealSceneImport(planFile, jobDirectory) {
264
+ absolute.parse(planFile);
265
+ absolute.parse(jobDirectory);
266
+ const plan = validateUnrealScenePlan(await readJson(planFile));
267
+ await realpath(plan.project_file);
268
+ for (const source of plan.assets) {
269
+ const file = source.kind === 'glb' ? source.file : join(dirname(plan.project_file), 'Content', source.path.slice(6) + '.uasset');
270
+ if (hash(await readFile(file)) !== source.sha256)
271
+ throw Error('Source revision changed: ' + source.assetId);
272
+ }
273
+ for (const texture of portableSceneTextures(plan)) {
274
+ if ((await stat(texture.file)).size > 64 * 1024 * 1024)
275
+ throw Error('Texture exceeds 64 MB: ' + texture.assetId);
276
+ const bytes = await readFile(texture.file);
277
+ if (hash(bytes) !== texture.sha256)
278
+ throw Error('Texture revision changed: ' + texture.assetId);
279
+ const png = bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])), jpeg = bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255;
280
+ if (!png && !jpeg)
281
+ throw Error('Invalid texture image: ' + texture.assetId);
282
+ }
283
+ const snapshot = JSON.stringify(plan), fingerprint = hash(snapshot);
284
+ await mkdir(jobDirectory, { recursive: true });
285
+ const dir = await realpath(jobDirectory), manifest = join(dir, 'plan.json');
286
+ try {
287
+ await writeFile(manifest, snapshot, { flag: 'wx' });
288
+ }
289
+ catch (error) {
290
+ if (error.code !== 'EEXIST')
291
+ throw error;
292
+ if (hash(await readFile(manifest)) !== fingerprint)
293
+ throw Error('Job already contains a different scene revision. Choose a new job directory and target level.');
294
+ }
295
+ for (const name of ['unreal-scene-import.py', 'unreal_scene_math.py', 'unreal_spawn_review.py']) {
296
+ const source = fileURLToPath(new URL('../runtime/' + name, import.meta.url)), dest = join(dir, name);
297
+ try {
298
+ await copyFile(source, dest, 1);
299
+ }
300
+ catch (error) {
301
+ if (error.code !== 'EEXIST')
302
+ throw error;
303
+ if (hash(await readFile(source)) !== hash(await readFile(dest)))
304
+ throw Error('Worker version changed. Prepare a new job to retain reproducible imports.');
305
+ }
306
+ }
307
+ return { schema: 'gripforge.unreal-import-job.v1', job_directory: dir, plan_hash: fingerprint, target_level: plan.target_level, lifecycle: 'work', node_count: plan.document.nodes.length,
308
+ next: 'Call gripforge_map_unreal_import_local with this job_directory and your UnrealEditor executable. Import creates a separate work level; visual acceptance is a separate step.' };
309
+ }
310
+ export async function unrealImportStatus(jobDirectory) {
311
+ absolute.parse(jobDirectory);
312
+ const plan = validateUnrealScenePlan(await readJson(join(jobDirectory, 'plan.json')));
313
+ let state = { stage: 'prepared', lifecycle: 'work', target_level: plan.target_level };
314
+ try {
315
+ state = await readJson(join(jobDirectory, 'status.json'));
316
+ }
317
+ catch (error) {
318
+ if (error.code !== 'ENOENT')
319
+ throw error;
320
+ }
321
+ try {
322
+ const launch = await readJson(join(jobDirectory, 'launch.json'));
323
+ if (Date.parse(launch.started_at) > Number(state.updated_at ?? 0) * 1000)
324
+ state = { ...state, ...launch, stage: 'launching' };
325
+ }
326
+ catch (error) {
327
+ if (error.code !== 'ENOENT')
328
+ throw error;
329
+ }
330
+ if (typeof state.pid === 'number') {
331
+ try {
332
+ process.kill(state.pid, 0);
333
+ state.editor_running = true;
334
+ }
335
+ catch (error) {
336
+ const code = error.code;
337
+ if (code === 'EPERM')
338
+ state.editor_running = null;
339
+ else {
340
+ if (code !== 'ESRCH')
341
+ throw error;
342
+ state.editor_running = false;
343
+ if (!['failed', 'cancelled', 'awaiting_visual_review'].includes(String(state.stage)))
344
+ state = { ...state, interrupted_stage: state.stage, stage: 'failed', error: 'Unreal exited before finishing. Inspect editor-launch.log / editor.log, then resume this saved job.' };
345
+ }
346
+ }
347
+ }
348
+ return { job_directory: jobDirectory, ...state };
349
+ }
350
+ export async function startUnrealSceneImport(jobDirectory, editorExecutable) {
351
+ absolute.parse(jobDirectory);
352
+ absolute.parse(editorExecutable);
353
+ const dir = await realpath(jobDirectory), executable = await realpath(editorExecutable);
354
+ if (!/^UnrealEditor(?:-Cmd)?(?:\.exe)?$/.test(basename(executable)))
355
+ throw Error('editor_executable must be UnrealEditor or UnrealEditor-Cmd.');
356
+ const lock = join(dir, 'launch.lock');
357
+ try {
358
+ await writeFile(lock, JSON.stringify({ pid: process.pid }), { flag: 'wx' });
359
+ }
360
+ catch (error) {
361
+ if (error.code !== 'EEXIST')
362
+ throw error;
363
+ const owner = await readJson(lock);
364
+ try {
365
+ process.kill(owner.pid, 0);
366
+ throw Error('A launch request is already in progress for this job.');
367
+ }
368
+ catch (check) {
369
+ if (check.code !== 'ESRCH')
370
+ throw check;
371
+ }
372
+ await unlink(lock);
373
+ return startUnrealSceneImport(dir, executable);
374
+ }
375
+ try {
376
+ const plan = validateUnrealScenePlan(await readJson(join(dir, 'plan.json'))), state = await unrealImportStatus(dir);
377
+ if (typeof state.pid === 'number') {
378
+ try {
379
+ process.kill(state.pid, 0);
380
+ return { ...state, next: 'This job is already open in Unreal. Inspect the level there; close that editor before relaunching the saved job.' };
381
+ }
382
+ catch (error) {
383
+ if (error.code === 'EPERM')
384
+ return { ...state, next: 'Editor process exists but cannot be inspected from this sandbox. Do not launch a duplicate editor.' };
385
+ if (error.code !== 'ESRCH')
386
+ throw error;
387
+ }
388
+ }
389
+ await atomicJson(join(dir, 'control.json'), { cancel: false });
390
+ const log = await open(join(dir, 'editor-launch.log'), 'a');
391
+ try {
392
+ const nativeAppleSilicon = process.platform === 'darwin' && (await promisify(execFile)('/usr/sbin/sysctl', ['-n', 'hw.optional.arm64'])).stdout.trim() === '1';
393
+ const launch = unrealLaunchCommand(executable, [plan.project_file, '/Engine/Maps/Entry', '-ExecutePythonScript=' + join(dir, 'unreal-scene-import.py'), '-GFMapPlan=' + join(dir, 'plan.json'), '-EnablePlugins=PythonScriptPlugin,EditorScriptingUtilities', '-nosplash', '-NoSound', '-unattended', '-NoLoadStartupPackages', '-abslog=' + join(dir, 'editor.log')], nativeAppleSilicon);
394
+ const child = spawn(launch.command, launch.args, { cwd: dirname(executable), detached: true, stdio: ['ignore', log.fd, log.fd], shell: false });
395
+ await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); });
396
+ child.unref();
397
+ await atomicJson(join(dir, 'launch.json'), { pid: child.pid, started_at: new Date().toISOString() });
398
+ return { job_directory: dir, pid: child.pid, stage: 'launching', target_level: plan.target_level, lifecycle: 'work', next: 'Poll gripforge_map_unreal_import_status_local. The editor performs the work independently of this MCP call.' };
399
+ }
400
+ finally {
401
+ await log.close();
402
+ }
403
+ }
404
+ finally {
405
+ await unlink(lock);
406
+ }
407
+ }
408
+ export function registerMapUnrealImportLocalTools(register) {
409
+ const job = { job_directory: absolute.describe('Persistent local directory returned by prepare_local.') };
410
+ const wrap = (f) => async (a) => { try {
411
+ const data = await f(a);
412
+ return { structuredContent: data, content: [{ type: 'text', text: JSON.stringify(data) }] };
413
+ }
414
+ catch (e) {
415
+ return { isError: true, content: [{ type: 'text', text: e instanceof Error ? e.message : String(e) }] };
416
+ } };
417
+ register('gripforge_map_unreal_prepare_local', { title: 'Prepare a reusable Unreal scene import', description: 'Validate a common GripForge map SceneDocument and native/GLB asset bindings, pin source revisions and package the bounded UE Python worker into a persistent local job. Preserves PBR and separate instances. No source package changes. A new target under /Game/GripForge/Maps is required; work versions are never automatically visually validated. Local npm MCP only.', inputSchema: { plan_file: absolute, ...job }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false } }, wrap(a => prepareUnrealSceneImport(String(a.plan_file), String(a.job_directory))));
418
+ register('gripforge_map_unreal_import_local', { title: 'Import/resume a map in Unreal', description: 'Launch the installed Unreal editor to materialize a prepared job as a separate native work level. Writes generated assets and that level only; original native meshes/materials remain shared. Continues after the MCP call ends. Closing and relaunching the same job resumes saved checkpoints without duplicate actors. Returns status location, never claims visual acceptance.', inputSchema: { ...job, editor_executable: absolute }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false } }, wrap(a => startUnrealSceneImport(String(a.job_directory), String(a.editor_executable))));
419
+ register('gripforge_map_unreal_import_status_local', { title: 'Read persistent Unreal import progress', description: 'Read stages, saved progress, failures, collision/scale checks and actual viewport captures for a prepared native map import.', inputSchema: job, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } }, wrap(a => unrealImportStatus(String(a.job_directory))));
420
+ register('gripforge_map_unreal_import_cancel_local', { title: 'Cancel a native map import', description: 'Request cancellation at the next safe checkpoint. Completed generated assets and saved actors remain available for a later resume; the current validated map is not replaced.', inputSchema: job, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false } }, wrap(async (a) => { await unrealImportStatus(String(a.job_directory)); await atomicJson(join(String(a.job_directory), 'control.json'), { cancel: true }); return { cancel_requested: true, job_directory: a.job_directory }; }));
421
+ }
@@ -0,0 +1,75 @@
1
+ import { readFile, readdir, realpath, stat } from 'node:fs/promises';
2
+ import { basename, dirname, extname, isAbsolute, join, relative, sep } from 'node:path';
3
+ import { z } from 'zod/v4';
4
+ const inside = (root, path) => { const r = relative(root, path); return r === '' || r !== '..' && !r.startsWith('..' + sep) && !isAbsolute(r); };
5
+ /** Read a selected content pack, not the whole project, config, credentials or Saved directory. */
6
+ export async function inspectUnrealMapReference(projectFile, contentPath, signal) {
7
+ if (!isAbsolute(projectFile) || extname(projectFile).toLowerCase() !== '.uproject')
8
+ throw Error('project_file must be an absolute .uproject path.');
9
+ if (!/^\/Game(?:\/[\p{L}\p{N}_-]+)+$/u.test(contentPath))
10
+ throw Error('content_path must be a pack folder such as /Game/StylizedDesertEnv (no traversal or object suffix).');
11
+ const project = await realpath(projectFile), root = dirname(project);
12
+ if ((await stat(project)).size > 1024 * 1024)
13
+ throw Error('Unexpectedly large .uproject.');
14
+ const descriptor = JSON.parse(await readFile(project, 'utf8'));
15
+ const content = await realpath(join(root, 'Content'));
16
+ const pack = await realpath(join(content, contentPath.slice('/Game/'.length)));
17
+ if (!inside(content, pack))
18
+ throw Error('Content pack resolves outside the project Content directory.');
19
+ const assets = [];
20
+ const skipped = [];
21
+ let visited = 0;
22
+ async function walk(directory) {
23
+ signal?.throwIfAborted();
24
+ const entries = await readdir(directory, { withFileTypes: true });
25
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
26
+ signal?.throwIfAborted();
27
+ if (++visited > 20000)
28
+ throw Error('Content pack exceeds 20,000 entries. Select a smaller folder.');
29
+ const file = join(directory, entry.name), rel = relative(content, file).split(sep).join('/');
30
+ if (entry.isSymbolicLink()) {
31
+ skipped.push(rel);
32
+ continue;
33
+ }
34
+ if (entry.isDirectory()) {
35
+ await walk(file);
36
+ continue;
37
+ }
38
+ const extension = extname(entry.name).toLowerCase();
39
+ if (!entry.isFile() || !['.uasset', '.umap'].includes(extension))
40
+ continue;
41
+ if (assets.length >= 5000)
42
+ throw Error('Content pack exceeds 5,000 packages. Select a smaller folder.');
43
+ // Do not decode or upload proprietary UE package bytes: the editor will reuse their native paths.
44
+ const info = await stat(file), packagePath = '/Game/' + rel.slice(0, -extension.length);
45
+ const kind = extension === '.umap' ? 'map' : /(?:^|\/)(?:meshes|staticmeshes)(?:\/|$)/i.test(rel) ? 'mesh' : /(?:^|\/)materials(?:\/|$)/i.test(rel) ? 'material' : /(?:^|\/)textures(?:\/|$)/i.test(rel) ? 'texture' : 'unknown';
46
+ assets.push({ package_path: packagePath, object_path: packagePath + '.' + entry.name.slice(0, -extension.length), relative_file: 'Content/' + rel, bytes: info.size, modified_at: info.mtime.toISOString(), kind_hint: kind });
47
+ }
48
+ }
49
+ await walk(pack);
50
+ return {
51
+ schema: 'gripforge.unreal-map-reference.v1', project_file: project, project_name: basename(project, '.uproject'),
52
+ engine_version: typeof descriptor.EngineAssociation === 'string' ? descriptor.EngineAssociation : null,
53
+ content_path: contentPath, assets,
54
+ counts: assets.reduce((counts, asset) => { counts[asset.kind_hint] = (counts[asset.kind_hint] ?? 0) + 1; return counts; }, {}),
55
+ limitations: ['Filesystem inventory only: kind_hint is inferred from directories. Actor transforms, dimensions, collision and material bindings need inspection in the Unreal editor.', 'No package was uploaded, converted, imported, modified or visually validated.'],
56
+ skipped_symlinks: skipped,
57
+ next: 'Use an actual viewport screenshot as the pinned reference for gripforge_map_generate stage=concept. Show that concept before building. Reuse these native object paths as independent instances when delivering the chosen scene to a new Unreal level; preserve original materials. This inventory is not an import manifest or a SceneDocument.',
58
+ };
59
+ }
60
+ export function registerMapUnrealLocalTools(register) {
61
+ register('gripforge_map_unreal_reference_local', {
62
+ title: 'Inspect a local Unreal environment pack',
63
+ description: 'Read-only local project inventory for concept-first map creation. Returns reusable /Game asset paths, map files and inferred mesh/material/texture kinds from one selected pack. No Unreal editor or API key required; no upload or conversion. It cannot recover geometry, material bindings or actor transforms from .uasset files. Use an actual viewport image as the map concept reference, then inspect assets in the editor before native scene delivery. Available in the npm MCP client only; the hosted MCP cannot read your local disk.',
64
+ inputSchema: { project_file: z.string().min(1).max(4096).describe('Absolute .uproject path.'), content_path: z.string().min(7).max(512).describe('Selected pack folder, e.g. /Game/StylizedDesertEnv.') },
65
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
66
+ }, async (args, extra) => {
67
+ try {
68
+ const data = await inspectUnrealMapReference(String(args.project_file), String(args.content_path), extra?.signal);
69
+ return { structuredContent: data, content: [{ type: 'text', text: JSON.stringify(data) }] };
70
+ }
71
+ catch (error) {
72
+ return { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : 'Cannot inspect the Unreal content pack.' }] };
73
+ }
74
+ });
75
+ }
@@ -0,0 +1,50 @@
1
+ import { z } from 'zod/v4';
2
+ export const MOTION_TOOL_NAMES = ['gripforge_motion_generate'];
3
+ /**
4
+ * Text-to-motion (NVIDIA Kimodo on GripForge's GPU), retargeted onto a Library character.
5
+ * Hosted and local MCP share the same REST workflow: stage=plan (free), then stage=build.
6
+ */
7
+ export function registerMotionTools(register, options, schema = z) {
8
+ const shape = {
9
+ stage: schema.enum(['plan', 'build']).optional().describe('plan (default, free): the clips that would be made and their price. build: queue the GPU job.'),
10
+ prompts: schema.array(schema.object({
11
+ id: schema.string().regex(/^[a-z0-9_]{1,40}$/).describe('Clip name, snake_case (e.g. "slash_combo")'),
12
+ text: schema.string().min(8).max(400).describe('The body movement in one or two sentences, e.g. "A warrior swings a heavy sword overhead, then spins and slashes horizontally."'),
13
+ duration: schema.number().min(1).max(10).optional().describe('Seconds, default 3'),
14
+ }).strict()).min(1).max(12).optional().describe('Free clips, 1 to 12. Give either prompts or hero_concept_id.'),
15
+ hero_concept_id: schema.string().optional().describe('Kit mode: a hero concept with an ability kit (gripforge_game_concepts with hero_kits). Its basic attack and Q/W/E/R casts become one clip each, from the kit\'s "animation" lines.'),
16
+ character_id: schema.string().optional().describe('Rigged Library character or enemy to retarget onto. Default: the UAL mannequin (any humanoid can reuse the clips later).'),
17
+ name: schema.string().min(2).max(120).optional(),
18
+ workspace_id: schema.string().max(100).optional(),
19
+ idempotency_key: schema.string().regex(/^[a-zA-Z0-9_.:-]{8,160}$/).optional(),
20
+ };
21
+ register(MOTION_TOOL_NAMES[0], {
22
+ title: 'Generate motions from text',
23
+ description: 'Custom animations generated from text with NVIDIA Kimodo on GripForge\'s GPU, retargeted onto a rigged Library character (or the UAL mannequin): spell casts, signature attacks, emotes, anything the standard clip pack (gripforge_animate) lacks. Kit mode turns a MOBA hero concept\'s basic attack and Q/W/E/R into clips in one call. Call stage=plan first (free) and show the prompts and price (1 credit per clip); stage=build queues a durable job — poll gripforge_generation_read with job_id (a cold GPU takes a few minutes). The result is a kind=animation Library item, one clip per prompt.',
24
+ inputSchema: shape,
25
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
26
+ }, async (args, extra) => {
27
+ const parsed = schema.object(shape).strict().safeParse(args);
28
+ if (!parsed.success)
29
+ return { isError: true, content: [{ type: 'text', text: parsed.error.message }] };
30
+ const key = options.getApiKey();
31
+ if (!key)
32
+ return { isError: true, content: [{ type: 'text', text: 'GripForge API key required.' }] };
33
+ const { workspace_id, ...body } = parsed.data;
34
+ try {
35
+ const response = await fetch(options.apiUrl.replace(/\/$/, '') + '/api/v1/motions', {
36
+ method: 'POST',
37
+ headers: { 'content-type': 'application/json', 'x-gripforge-client': 'mcp', 'x-api-key': key, ...(typeof workspace_id === 'string' ? { 'x-workspace-id': workspace_id } : {}) },
38
+ body: JSON.stringify(body),
39
+ signal: AbortSignal.any([AbortSignal.timeout(60_000), ...(extra?.signal ? [extra.signal] : [])]),
40
+ });
41
+ const data = await response.json();
42
+ if (typeof data.status_url === 'string' && data.status_url.startsWith('/'))
43
+ data.status_url = new URL(data.status_url, options.apiUrl).href;
44
+ return { ...(!response.ok ? { isError: true } : {}), structuredContent: data, content: [{ type: 'text', text: JSON.stringify(data) }] };
45
+ }
46
+ catch (error) {
47
+ return { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : 'Motion request failed.' }] };
48
+ }
49
+ });
50
+ }
@@ -0,0 +1,28 @@
1
+ import { z } from 'zod/v4';
2
+ export const PROJECTILE_TOOL_NAME = 'gripforge_projectile_create';
3
+ export function registerProjectileTools(register, options, s = z) {
4
+ const shape = {
5
+ variants: s.array(s.string().min(1).max(40)).min(1).max(20).optional(),
6
+ workspace_id: s.string().max(100).optional(), name: s.string().trim().min(1).max(80).optional(), save: s.boolean().optional(), idempotency_key: s.string().min(8).max(160).optional(),
7
+ config: s.object({ version: s.literal(1).optional(), style: s.enum(['fire', 'ice', 'arcane', 'poison']).optional(), variant: s.enum(['elemental', 'arcane-dart', 'ember-dart', 'venom-dart', 'crimson-bullet', 'frost-bullet', 'frost-orb', 'arcane-orb', 'solar-wisp', 'frost-wisp', 'carrot-shot', 'arcane-disc', 'ice-spear', 'venom-spear', 'venom-orb', 'void-comet', 'solar-comet', 'flame-coil', 'frost-coil', 'storm-bolt', 'solar-bolt']).optional(), radius: s.number().min(.04).max(1).optional(), trailLifetime: s.number().min(.06).max(1).optional(), impactDuration: s.number().min(.15).max(2).optional(), color: s.string().regex(/^#[a-f\d]{6}$/i).optional(), accent: s.string().regex(/^#[a-f\d]{6}$/i).optional(), seed: s.number().int().min(0).max(65535).optional(), quality: s.enum(['low', 'high']).optional() }).strict().optional(),
8
+ };
9
+ register(PROJECTILE_TOOL_NAME, { title: 'Create projectile VFX', description: 'ADMIN ONLY. Create an editable 3D projectile: ignition, elemental volume, world-space trail, embers, contact flash, oriented ring and soot. 20 named variants: darts, short bolts, orbs, wisps, carrot rocket, spinning disc, spears, comets, coils and lightning. config.variant selects the geometry, trail and impact; style/color optionally override its palette. Omit variant to retain the original elemental recipe. To save several catalogue entries, pass variants=[IDs] without config/name: one persistent job renders/reviews each item sequentially, preserves completed items on retry and returns result.items. Publication remains explicit. Uses the shared trail and Scene Engine modules. save=true (default) queues persistent rendering/review/workspace save; poll gripforge_generation_read for Library ID and Studio link. save=false returns an unsaved recipe. Bind the resulting VFX to combat.projectiles/projectile_vfx using the Game Kit tools; gameplay speed/gravity/damage stay in the module. Web/Three.js only in v1. Does not install a kit or modify a game automatically. Recipe compilation needs no AI; saved previews use the existing review pipeline. No automatic visual validation.', inputSchema: shape, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false } }, async (args, extra) => {
10
+ const parsed = s.object(shape).strict().safeParse(args);
11
+ if (!parsed.success)
12
+ return { isError: true, content: [{ type: 'text', text: parsed.error.message }] };
13
+ const key = options.getApiKey();
14
+ if (!key)
15
+ return { isError: true, content: [{ type: 'text', text: 'GripForge API key required.' }] };
16
+ const { workspace_id, save, ...body } = parsed.data;
17
+ try {
18
+ const response = await fetch(options.apiUrl.replace(/\/$/, '') + '/api/v1/vfx/projectiles', { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': key, ...(workspace_id ? { 'x-workspace-id': workspace_id } : {}) }, body: JSON.stringify({ ...body, action: save === false ? 'preview' : 'create' }), signal: AbortSignal.any([AbortSignal.timeout(90000), ...(extra?.signal ? [extra.signal] : [])]) });
19
+ const data = await response.json();
20
+ if (data.studio_url?.startsWith('/'))
21
+ data.studio_url = new URL(data.studio_url, options.apiUrl).href;
22
+ return { ...(!response.ok ? { isError: true } : {}), structuredContent: data, content: [{ type: 'text', text: JSON.stringify(data) }] };
23
+ }
24
+ catch (e) {
25
+ return { isError: true, content: [{ type: 'text', text: e.message }] };
26
+ }
27
+ });
28
+ }