@gripforgeai/mcp 0.1.5 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -2,20 +2,31 @@
2
2
  /**
3
3
  * GripForge MCP — thin client.
4
4
  *
5
- * Exposes gripforge_attach to any MCP client (Claude Code, Cursor, Grok…) and
5
+ * Exposes gripforge_attach to any MCP client (Cursor, Windsurf, Grok…) and
6
6
  * delegates the compute to the hosted GripForge API. Requires an API key:
7
7
  * create one at https://gripforge.ai/login and set GRIPFORGE_API_KEY in the
8
8
  * server env. 1 credit = 1 successful attach.
9
9
  */
10
10
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
11
- import { basename, extname, join, resolve } from 'node:path';
11
+ import { basename, dirname, extname, join, resolve } from 'node:path';
12
12
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
13
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
14
  import { z } from 'zod';
15
+ import { registerVfxProjectTools } from './vfx-project-tools.js';
16
+ import { registerSceneTools } from './scene-tools.js';
17
+ import { registerServerTools } from './server-tools.js';
18
+ import { registerGameKitTools } from './gamekit-tools.js';
19
+ import { registerGameKitLocalTools } from './gamekit-deliver-local.js';
15
20
  const API_URL = process.env.GRIPFORGE_API_URL ?? 'https://gripforge.ai';
16
21
  const API_KEY = process.env.GRIPFORGE_API_KEY;
22
+ const MCP_SELF = '0.1.9';
17
23
  const SUPPORTED = ['.glb', '.gltf', '.fbx', '.obj'];
18
- const server = new McpServer({ name: 'gripforge', version: '0.1.5' });
24
+ const server = new McpServer({ name: 'gripforge', version: MCP_SELF });
25
+ registerVfxProjectTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
26
+ registerSceneTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
27
+ registerServerTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
28
+ registerGameKitTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
29
+ registerGameKitLocalTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY, userAgent: `gripforge-mcp/${MCP_SELF}` });
19
30
  const err = (text) => ({ content: [{ type: 'text', text }], isError: true });
20
31
  server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe…) onto a rigged character. ' +
21
32
  'Finds the hand bone across naming schemes, scales to body height, seats the grip, ' +
@@ -114,11 +125,7 @@ server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe
114
125
  try {
115
126
  res = await fetch(`${API_URL}/api/v1/attach`, {
116
127
  method: 'POST',
117
- headers: {
118
- 'x-api-key': API_KEY,
119
- 'x-gripforge-client': 'mcp',
120
- 'user-agent': 'gripforge-mcp/0.1.5',
121
- },
128
+ headers: apiHeaders(),
122
129
  body: form,
123
130
  });
124
131
  }
@@ -129,11 +136,11 @@ server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe
129
136
  if (res.status === 401)
130
137
  return err('Invalid GRIPFORGE_API_KEY — check it on ' + API_URL + '/account#api');
131
138
  if (res.status === 403) {
132
- return err(`MCP access requires a paid plan (indie or studio); the free plan covers the Studio only. Upgrade at ${API_URL}/#pricing`);
139
+ return err(`MCP access needs a credit pack after the 3 free API/MCP trial attaches. Buy at ${API_URL}/pricing`);
133
140
  }
134
141
  if (res.status === 402) {
135
142
  const u = data;
136
- return err(`Quota exceeded (${u.used}/${u.limit}, plan ${u.plan}) — upgrade at ${API_URL}/#pricing`);
143
+ return err(`Quota exceeded (${u.used}/${u.limit}, plan ${u.plan}) — buy credits at ${API_URL}/pricing`);
137
144
  }
138
145
  if (!res.ok)
139
146
  return err(`Attach failed (${res.status}): ${String(data.error ?? 'unknown error')}`);
@@ -154,7 +161,7 @@ server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe
154
161
  form2.append('hand', hand === 'left' ? 'right' : 'left');
155
162
  const res2 = await fetch(`${API_URL}/api/v1/attach`, {
156
163
  method: 'POST',
157
- headers: { 'x-api-key': API_KEY, 'x-gripforge-client': 'mcp', 'user-agent': 'gripforge-mcp/0.1.5' },
164
+ headers: apiHeaders(),
158
165
  body: form2,
159
166
  });
160
167
  const data2 = (await res2.json().catch(() => ({})));
@@ -258,7 +265,6 @@ server.tool('getWearableFrame', 'Analyze a wearable once: up, bodyFacing, outwar
258
265
  return err(String(data.error ?? res.status));
259
266
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
260
267
  });
261
- const MCP_SELF = '0.1.5';
262
268
  server.tool('gripforge_formats', 'List supported mesh formats, grip styles and hands. Also reports this MCP client version vs latest.', {}, async () => {
263
269
  let latest = null;
264
270
  let hosted = null;
@@ -276,7 +282,7 @@ server.tool('gripforge_formats', 'List supported mesh formats, grip styles and h
276
282
  if (outdated) {
277
283
  mcp_upgrade =
278
284
  `GripForge MCP npm ${MCP_SELF} is outdated (latest ${latest}). ` +
279
- `Upgrade: npx -y @gripforgeai/mcp@${latest} then restart Claude. ` +
285
+ `Upgrade: npx -y @gripforgeai/mcp@${latest} then restart your MCP client. ` +
280
286
  `Or hosted: ${hosted ?? 'https://gripforge.ai/mcp'}`;
281
287
  }
282
288
  }
@@ -305,10 +311,27 @@ function apiHeaders() {
305
311
  return {
306
312
  'x-api-key': API_KEY ?? '',
307
313
  'x-gripforge-client': 'mcp',
308
- 'user-agent': 'gripforge-mcp/0.1.5',
314
+ 'user-agent': `gripforge-mcp/${MCP_SELF}`,
309
315
  };
310
316
  }
311
- const KIND = z.enum(['character', 'enemy', 'weapon', 'prop', 'texture', 'bind', 'vfx', 'animation']);
317
+ function safeName(name) {
318
+ const base = basename(name.split('?')[0] ?? name);
319
+ return base.replace(/[^a-zA-Z0-9._-]+/g, '_') || 'file';
320
+ }
321
+ function absUrl(url) {
322
+ if (/^https?:\/\//i.test(url))
323
+ return url;
324
+ return `${API_URL}${url.startsWith('/') ? '' : '/'}${url}`;
325
+ }
326
+ async function downloadTo(url, dest) {
327
+ const res = await fetch(absUrl(url), { headers: apiHeaders(), signal: AbortSignal.timeout(60_000) });
328
+ if (!res.ok)
329
+ return false;
330
+ await mkdir(dirname(dest), { recursive: true });
331
+ await writeFile(dest, Buffer.from(await res.arrayBuffer()));
332
+ return true;
333
+ }
334
+ const KIND = z.enum(['character', 'fps-arms', 'enemy', 'weapon', 'prop', 'texture', 'skybox', 'loading', 'hud', 'button', 'bind', 'vfx', 'animation', 'audio']);
312
335
  const TEX_EXTS = ['.png', '.jpg', '.jpeg', '.webp'];
313
336
  server.tool('gripforge_texture_prep', 'Prep a game texture (terrain / props): optional 50% wrap + seam blend, then a faithful ' +
314
337
  'lanczos upscale (1× / 2× / 4×, capped at 1024 or 2048). The pattern is PRESERVED — ' +
@@ -389,7 +412,7 @@ server.tool('gripforge_texture_prep', 'Prep a game texture (terrain / props): op
389
412
  server.tool('gripforge_style_kit', 'Resolve "Devil May Cry like" / "genshin" to the locker kit already tagged with that game look (characters, enemies, weapons, props). Call this BEFORE generating. Reuse the returned ids.', { prompt: z.string().min(2).max(240).describe('e.g. "devil may cry like", "un ennemi genshin"') }, async ({ prompt }) => {
390
413
  if (!API_KEY)
391
414
  return err('GRIPFORGE_API_KEY missing.');
392
- const qs = new URLSearchParams({ q: prompt, limit: '200' });
415
+ const qs = new URLSearchParams({ limit: '200' }); // style= does the matching; q= would also require the prompt in the NAME and empty the kit;
393
416
  // Hosted matcher lives on the API: pass style= the prompt; the server canonicalizes aliases.
394
417
  qs.set('style', prompt);
395
418
  const res = await fetch(`${API_URL}/api/v1/library?${qs}`, { headers: apiHeaders() });
@@ -407,6 +430,10 @@ server.tool('gripforge_style_kit', 'Resolve "Devil May Cry like" / "genshin" to
407
430
  weapons: of('weapon'),
408
431
  props: of('prop'),
409
432
  textures: of('texture'),
433
+ skyboxes: of('skybox'),
434
+ loading: of('loading'),
435
+ huds: [...of('hud'), ...of('button')],
436
+ buttons: of('button'),
410
437
  binds: of('bind'),
411
438
  vfx: of('vfx'),
412
439
  animations: of('animation'),
@@ -418,21 +445,58 @@ server.tool('gripforge_style_kit', 'Resolve "Devil May Cry like" / "genshin" to
418
445
  };
419
446
  return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] };
420
447
  });
421
- server.tool('gripforge_generate_character', 'CREATE a new game-ready mesh (T-pose, auto-rigged) and save it to Library. ' +
422
- 'If the user wants an existing game look ("Devil May Cry like", "genshin"), call gripforge_style_kit FIRST and reuse those ids — do not regenerate. ' +
423
- 'Put "enemy" / "ennemi" in the prompt (or kind=enemy) to save as kind=enemy, not character. Costs 10 credits; 2–8 minutes.', {
448
+ server.tool('gripforge_generate_character', 'CREATE a Mixamo T-pose auto-rigged character or enemy and save it to Library. ' +
449
+ 'To sculpt from a picture (image-to-3D, Meshy): pass concept_item (lib_… from gripforge_concept_correct / character_concepts), or path / file_url of a T-pose or concept PNG. ' +
450
+ 'Without an image, this is text-to-3D only. ' +
451
+ 'If the look may already be in the locker, call gripforge_style_kit FIRST. ' +
452
+ 'For knives/guns use gripforge_generate_weapon. Costs 10 credits. Returns job_id immediately; poll gripforge_generation_read. Closing this call does not cancel it.', {
424
453
  prompt: z.string().min(3).max(600).describe('Description. "Devil May Cry like enemy" → tagged devil-may-cry, kind=enemy.'),
425
- provider: z.enum(['tripo', 'meshy']).optional().describe('Generation provider (default tripo)'),
454
+ provider: z.enum(['tripo', 'meshy']).optional().describe('Default tripo. Forced meshy when concept_item / path / file_url is set.'),
426
455
  name: z.string().max(160).optional().describe('Library item name (default: the prompt)'),
427
- kind: z.enum(['character', 'enemy']).optional().describe('character (playable) or enemy (foe). Inferred from the prompt if omitted.'),
428
- out_dir: z.string().optional().describe('Write the generated GLB here (default: skip, Library URL only)'),
429
- }, async ({ prompt, provider, name, kind, out_dir }) => {
456
+ kind: z.enum(['character', 'enemy', 'boss']).optional().describe('character | enemy | boss (boss → enemy + meta.boss)'),
457
+ polycount: z.number().optional().describe('Target triangles. Meshy characters default 8000.'),
458
+ underwear: z.boolean().optional().describe('false = keep the described outfit. Implied false when an image is passed.'),
459
+ concept_item: z.string().optional().describe('Library concept / T-pose sheet id (lib_…). Image-to-3D from that picture — not text-to-3D.'),
460
+ path: z.string().optional().describe('Absolute path to a local concept or T-pose image (PNG/JPG/WebP). Image-to-3D.'),
461
+ file_url: z.string().optional().describe('https URL of a concept or T-pose image. Image-to-3D.'),
462
+ out_dir: z.string().optional().describe('After the job completes, gripforge_library_pull into this folder.'),
463
+ }, async ({ prompt, provider, name, kind, polycount, underwear, concept_item, path, file_url, out_dir }) => {
430
464
  if (!API_KEY)
431
465
  return err('GRIPFORGE_API_KEY missing.');
466
+ let image_base64;
467
+ if (path) {
468
+ const abs = resolve(path);
469
+ const buf = await readFile(abs);
470
+ image_base64 = buf.toString('base64');
471
+ }
472
+ else if (file_url) {
473
+ let parsed;
474
+ try {
475
+ parsed = new URL(file_url);
476
+ }
477
+ catch {
478
+ return err('file_url: invalid URL');
479
+ }
480
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')
481
+ return err('file_url: http(s) only');
482
+ const got = await fetch(parsed, { signal: AbortSignal.timeout(60_000) });
483
+ if (!got.ok)
484
+ return err(`file_url: download failed (${got.status})`);
485
+ image_base64 = Buffer.from(await got.arrayBuffer()).toString('base64');
486
+ }
432
487
  const res = await fetch(`${API_URL}/api/v1/generate-character`, {
433
488
  method: 'POST',
434
489
  headers: { ...apiHeaders(), 'content-type': 'application/json' },
435
- body: JSON.stringify({ prompt, provider, name, kind }),
490
+ body: JSON.stringify({
491
+ prompt,
492
+ provider: image_base64 || concept_item ? 'meshy' : provider,
493
+ name,
494
+ kind,
495
+ polycount,
496
+ underwear: image_base64 || concept_item ? false : underwear,
497
+ concept_item,
498
+ image_base64,
499
+ }),
436
500
  });
437
501
  const data = (await res.json().catch(() => ({})));
438
502
  if (!res.ok)
@@ -448,6 +512,275 @@ server.tool('gripforge_generate_character', 'CREATE a new game-ready mesh (T-pos
448
512
  wrote.push(dest);
449
513
  }
450
514
  }
515
+ return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote, ...(out_dir && !wrote.length ? { next: 'Poll gripforge_generation_read, then gripforge_library_pull with the completed asset id and this out_dir.', requested_out_dir: out_dir } : {}) }, null, 2) }] };
516
+ });
517
+ server.tool('gripforge_generate_weapon', 'CREATE a game-ready weapon GLB (knife, gun, sword) and save it to Library as kind=weapon. ' +
518
+ 'Default Meshy, no T-pose, no rig. polycount default 4000. style=melee|gun for attach. 10 credits. ' +
519
+ 'Do NOT write one-off Meshy scripts.', {
520
+ prompt: z.string().min(3).max(600).describe('e.g. CS2 default CT combat knife, silver blade, black grip'),
521
+ name: z.string().max(160).optional(),
522
+ polycount: z.number().optional().describe('Target triangles (default 4000)'),
523
+ style: z.enum(['melee', 'gun', 'shield', 'staff']).optional().describe('Grip style for attach (inferred if omitted)'),
524
+ provider: z.enum(['meshy', 'tripo']).optional().describe('Default meshy'),
525
+ out_dir: z.string().optional().describe('After the durable job completes, use gripforge_library_pull with this out_dir.'),
526
+ }, async ({ prompt, name, polycount, style, provider, out_dir }) => {
527
+ if (!API_KEY)
528
+ return err('GRIPFORGE_API_KEY missing.');
529
+ const res = await fetch(`${API_URL}/api/v1/generate-character`, {
530
+ method: 'POST',
531
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
532
+ body: JSON.stringify({
533
+ prompt,
534
+ name,
535
+ kind: 'weapon',
536
+ polycount: polycount ?? 4000,
537
+ style,
538
+ provider: provider ?? 'meshy',
539
+ }),
540
+ });
541
+ const data = (await res.json().catch(() => ({})));
542
+ if (!res.ok)
543
+ return err(String(data.error ?? `generate weapon failed (${res.status})`));
544
+ const wrote = [];
545
+ if (out_dir && data.item?.file_url) {
546
+ const dir = resolve(out_dir);
547
+ await mkdir(dir, { recursive: true });
548
+ const fileRes = await fetch(data.item.file_url);
549
+ if (fileRes.ok) {
550
+ const dest = join(dir, data.item.filename || `${data.item.id}.glb`);
551
+ await writeFile(dest, Buffer.from(await fileRes.arrayBuffer()));
552
+ wrote.push(dest);
553
+ }
554
+ }
555
+ return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote, ...(out_dir && !wrote.length ? { next: 'Poll gripforge_generation_read, then gripforge_library_pull with the completed asset id and this out_dir.', requested_out_dir: out_dir } : {}) }, null, 2) }] };
556
+ });
557
+ server.tool('gripforge_generate_prop', 'CREATE a game-ready prop GLB (kart, crate, banana, barrier…) and save it to Library as kind=prop. Default Meshy, no T-pose, no rig. polycount default 4000. 10 credits. Returns a durable job_id; poll gripforge_generation_read.', {
558
+ prompt: z.string().min(3).max(600).describe('e.g. compact orange open-cockpit racing kart, isolated'),
559
+ name: z.string().max(160).optional(),
560
+ polycount: z.number().optional().describe('Target triangles (default 4000)'),
561
+ provider: z.enum(['meshy', 'tripo']).optional().describe('Default meshy'),
562
+ out_dir: z.string().optional().describe('After the durable job completes, use gripforge_library_pull with this out_dir.'),
563
+ }, async ({ prompt, name, polycount, provider, out_dir }) => {
564
+ if (!API_KEY)
565
+ return err('GRIPFORGE_API_KEY missing.');
566
+ const res = await fetch(`${API_URL}/api/v1/generate-character`, {
567
+ method: 'POST',
568
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
569
+ body: JSON.stringify({
570
+ prompt,
571
+ name,
572
+ kind: 'prop',
573
+ polycount: polycount ?? 4000,
574
+ provider: provider ?? 'meshy',
575
+ }),
576
+ });
577
+ const data = (await res.json().catch(() => ({})));
578
+ if (!res.ok)
579
+ return err(String(data.error ?? `generate prop failed (${res.status})`));
580
+ const wrote = [];
581
+ if (out_dir && data.item?.file_url) {
582
+ const dir = resolve(out_dir);
583
+ await mkdir(dir, { recursive: true });
584
+ const fileRes = await fetch(data.item.file_url);
585
+ if (fileRes.ok) {
586
+ const dest = join(dir, data.item.filename || `${data.item.id}.glb`);
587
+ await writeFile(dest, Buffer.from(await fileRes.arrayBuffer()));
588
+ wrote.push(dest);
589
+ }
590
+ }
591
+ return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote, ...(out_dir && !wrote.length ? { next: 'Poll gripforge_generation_read, then gripforge_library_pull with the completed asset id and this out_dir.', requested_out_dir: out_dir } : {}) }, null, 2) }] };
592
+ });
593
+ server.tool('gripforge_boss', 'Create a playable boss: stats, phases, attacks bound to anim-pack slots, arena, engine snippets. ' +
594
+ 'Kit is 0 credits. Reuses a locker enemy when one exists. Pass character_id to bind a Library mesh. ' +
595
+ 'Pass generate=true to forge a NEW kind=enemy mesh (10 credits, 2–8 min) — only if the locker is empty. ' +
596
+ 'Then gripforge_animate(archetype) + gripforge_hitbox. Name stays text.', {
597
+ prompt: z.string().min(2).max(240).describe('e.g. "devil may cry like phantom boss"'),
598
+ role: z.enum(['mini', 'mid', 'final']).optional().describe('mini | mid | final (inferred from the prompt)'),
599
+ character_id: z.string().optional().describe('lib_… character/enemy/bind to bind — 0 credits'),
600
+ generate: z.boolean().optional().describe('Forge a new enemy mesh (10 credits)'),
601
+ name: z.string().max(80).optional().describe('Boss display name'),
602
+ provider: z.enum(['tripo', 'meshy']).optional().describe('Only used when generate=true (default tripo)'),
603
+ out_dir: z.string().optional().describe('Write boss.json + engine snippets (+ GLB if generated) here'),
604
+ }, async ({ prompt, role, character_id, generate, name, provider, out_dir }) => {
605
+ if (!API_KEY)
606
+ return err('GRIPFORGE_API_KEY missing.');
607
+ let data;
608
+ if (generate || character_id) {
609
+ const res = await fetch(`${API_URL}/api/v1/boss`, {
610
+ method: 'POST',
611
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
612
+ body: JSON.stringify({ prompt, role, character_id, generate, name, provider }),
613
+ });
614
+ data = (await res.json().catch(() => ({})));
615
+ if (!res.ok)
616
+ return err(String(data.error ?? res.status));
617
+ }
618
+ else {
619
+ const qs = new URLSearchParams({ prompt });
620
+ if (role)
621
+ qs.set('role', role);
622
+ if (name)
623
+ qs.set('name', name);
624
+ const res = await fetch(`${API_URL}/api/v1/boss?${qs}`, { headers: apiHeaders() });
625
+ data = (await res.json().catch(() => ({})));
626
+ if (!res.ok)
627
+ return err(String(data.error ?? res.status));
628
+ }
629
+ const wrote = [];
630
+ if (out_dir) {
631
+ const dir = resolve(out_dir);
632
+ await mkdir(dir, { recursive: true });
633
+ const json = typeof data.json === 'string' ? data.json : JSON.stringify(data, null, 2);
634
+ await writeFile(join(dir, 'boss.json'), json);
635
+ wrote.push(join(dir, 'boss.json'));
636
+ const engines = data.engines;
637
+ if (engines?.three) {
638
+ await writeFile(join(dir, 'three.js.txt'), engines.three);
639
+ wrote.push(join(dir, 'three.js.txt'));
640
+ }
641
+ if (engines?.godot) {
642
+ await writeFile(join(dir, 'godot.gd.txt'), engines.godot);
643
+ wrote.push(join(dir, 'godot.gd.txt'));
644
+ }
645
+ if (engines?.unity) {
646
+ await writeFile(join(dir, 'unity.cs.txt'), engines.unity);
647
+ wrote.push(join(dir, 'unity.cs.txt'));
648
+ }
649
+ if (engines?.unreal) {
650
+ await writeFile(join(dir, 'unreal.cpp.txt'), engines.unreal);
651
+ wrote.push(join(dir, 'unreal.cpp.txt'));
652
+ }
653
+ const fileUrl = typeof data.file_url === 'string' ? data.file_url : null;
654
+ if (fileUrl) {
655
+ const bin = Buffer.from(await (await fetch(fileUrl)).arrayBuffer());
656
+ await writeFile(join(dir, 'boss.glb'), bin);
657
+ wrote.push(join(dir, 'boss.glb'));
658
+ }
659
+ }
660
+ return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote }, null, 2) }] };
661
+ });
662
+ server.tool('gripforge_hud', 'Survivor HUD kit: hud.json + cartoon PNGs + Godot .tscn + Three / Unity / Unreal snippets. ' +
663
+ 'Writes the folder (npm). 1 credit. Do not redraw the hearts.', {
664
+ preset: z.literal('survivor').optional().describe('v1: survivor only (default)'),
665
+ health_type: z.enum(['hearts', 'bar']).optional().describe('default hearts'),
666
+ health_max: z.number().int().min(1).max(20).optional().describe('default 5'),
667
+ health_style: z.string().optional().describe('hearts skin or bar skin, or custom:<slug> from gripforge_hud_bar'),
668
+ currencies: z.array(z.string()).max(6).optional().describe('default ["gem","mushroom","drop"]'),
669
+ minimap: z.boolean().optional().describe('default false'),
670
+ portrait: z.boolean().optional().describe('default true'),
671
+ out_dir: z.string().optional().describe('Write hud.json, PNGs, tscn, snippets (default ./gripforge-hud)'),
672
+ }, async ({ preset, health_type, health_max, health_style, currencies, minimap, portrait, out_dir }) => {
673
+ if (!API_KEY)
674
+ return err('GRIPFORGE_API_KEY missing.');
675
+ const res = await fetch(`${API_URL}/api/v1/hud`, {
676
+ method: 'POST',
677
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
678
+ body: JSON.stringify({
679
+ preset,
680
+ health: { type: health_type, max: health_max, style: health_style },
681
+ currencies,
682
+ minimap,
683
+ portrait,
684
+ }),
685
+ signal: AbortSignal.timeout(30_000),
686
+ });
687
+ const data = (await res.json().catch(() => ({})));
688
+ if (!res.ok)
689
+ return err(String(data.error ?? `hud failed (${res.status})`));
690
+ const dir = resolve(out_dir || join(process.cwd(), 'gripforge-hud'));
691
+ await mkdir(dir, { recursive: true });
692
+ const wrote = [];
693
+ for (const [name, body] of Object.entries(data.files ?? {})) {
694
+ const dest = join(dir, safeName(name));
695
+ await writeFile(dest, body);
696
+ wrote.push(dest);
697
+ }
698
+ for (const url of Object.values(data.assets ?? {})) {
699
+ const dest = join(dir, safeName(url));
700
+ if (await downloadTo(url, dest))
701
+ wrote.push(dest);
702
+ }
703
+ if (data.unreal_plugin_url) {
704
+ const dest = join(dir, 'GripForgeHUD-plugin.zip');
705
+ if (await downloadTo(data.unreal_plugin_url, dest))
706
+ wrote.push(dest);
707
+ }
708
+ return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote }, null, 2) }] };
709
+ });
710
+ server.tool('gripforge_hud_bar', 'Forge an original themed health-bar frame+fill PNG pair (genre/materials, not a specific game). ' +
711
+ 'Writes the PNGs (npm). 2 credits.', {
712
+ theme: z.string().min(3).max(300).describe('e.g. gothic demon-hunter, baroque metal'),
713
+ colors: z
714
+ .array(z.string().regex(/^#[0-9a-fA-F]{6}$/))
715
+ .length(3)
716
+ .optional()
717
+ .describe('Fill gradient [light, mid, dark] hex'),
718
+ name: z.string().max(60).optional().describe('Slug for Library items'),
719
+ out_dir: z.string().optional().describe('Write frame + fill PNGs (default ./gripforge-hud)'),
720
+ }, async ({ theme, colors, name, out_dir }) => {
721
+ if (!API_KEY)
722
+ return err('GRIPFORGE_API_KEY missing.');
723
+ const res = await fetch(`${API_URL}/api/v1/hud/forge-bar`, {
724
+ method: 'POST',
725
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
726
+ body: JSON.stringify({ theme, colors, name }),
727
+ signal: AbortSignal.timeout(280_000),
728
+ });
729
+ const data = (await res.json().catch(() => ({})));
730
+ if (!res.ok)
731
+ return err(String(data.error ?? `hud_bar failed (${res.status})`));
732
+ const dir = resolve(out_dir || join(process.cwd(), 'gripforge-hud'));
733
+ await mkdir(dir, { recursive: true });
734
+ const wrote = [];
735
+ const snippet = data.hud_snippet;
736
+ const frameName = safeName(snippet?.health?.textures?.frame || 'bar_frame.png');
737
+ const fillName = safeName(snippet?.health?.textures?.fill || 'bar_fill.png');
738
+ if (data.frame_url && (await downloadTo(data.frame_url, join(dir, frameName))))
739
+ wrote.push(join(dir, frameName));
740
+ if (data.fill_url && (await downloadTo(data.fill_url, join(dir, fillName))))
741
+ wrote.push(join(dir, fillName));
742
+ return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote }, null, 2) }] };
743
+ });
744
+ server.tool('gripforge_cape', 'Cape rig: 3-column bone grid on neck/spine, skins the cape, bakes attached_cape.glb + Godot spring / Three verlet snippets. ' +
745
+ 'Writes the folder (npm). 1 credit. Inputs: Library ids or https URLs.', {
746
+ character_id: z.string().optional().describe('Library id of a rigged character (lib_…)'),
747
+ character_url: z.string().optional().describe('https URL of a rigged character GLB'),
748
+ cape_id: z.string().optional().describe('Library id of the cape mesh (lib_…)'),
749
+ cape_url: z.string().optional().describe('https URL of the cape GLB'),
750
+ columns: z.number().int().min(2).max(5).optional().describe('Bone columns (default 3)'),
751
+ rows: z.number().int().min(2).max(8).optional().describe('Rows below anchor (default 5)'),
752
+ save: z.boolean().optional().describe('Save attached_cape.glb to Library (default true)'),
753
+ out_dir: z.string().optional().describe('Write bind + scripts + GLB (default ./gripforge-cape)'),
754
+ }, async ({ character_id, character_url, cape_id, cape_url, columns, rows, save, out_dir }) => {
755
+ if (!API_KEY)
756
+ return err('GRIPFORGE_API_KEY missing.');
757
+ if (!character_id && !character_url)
758
+ return err('Provide character_id or character_url.');
759
+ if (!cape_id && !cape_url)
760
+ return err('Provide cape_id or cape_url.');
761
+ const res = await fetch(`${API_URL}/api/v1/cape`, {
762
+ method: 'POST',
763
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
764
+ body: JSON.stringify({ character_id, character_url, cape_id, cape_url, columns, rows, save }),
765
+ signal: AbortSignal.timeout(110_000),
766
+ });
767
+ const data = (await res.json().catch(() => ({})));
768
+ if (!res.ok)
769
+ return err(String(data.error ?? `cape failed (${res.status})`));
770
+ const dir = resolve(out_dir || join(process.cwd(), 'gripforge-cape'));
771
+ await mkdir(dir, { recursive: true });
772
+ const wrote = [];
773
+ for (const [name, body] of Object.entries(data.files ?? {})) {
774
+ const dest = join(dir, safeName(name));
775
+ await writeFile(dest, body);
776
+ wrote.push(dest);
777
+ }
778
+ const glbUrl = data.library?.file_url;
779
+ if (glbUrl) {
780
+ const dest = join(dir, 'attached_cape.glb');
781
+ if (await downloadTo(glbUrl, dest))
782
+ wrote.push(dest);
783
+ }
451
784
  return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote }, null, 2) }] };
452
785
  });
453
786
  server.tool('gripforge_concept_correct', 'Turn a character concept image into a strict T-pose sheet (optional: remove flowing cape/train only — sleeved jacket stays; outfit or sport base). Saves PNG to Library. 1 credit.', {
@@ -496,7 +829,7 @@ server.tool('gripforge_concept_correct', 'Turn a character concept image into a
496
829
  return err(String(data.error ?? res.status));
497
830
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
498
831
  });
499
- server.tool('gripforge_library_list', 'List the GripForge Library locker (characters, enemies, weapons, props, textures, binds). Filter by kind and/or game style.', {
832
+ server.tool('gripforge_library_list', 'List the GripForge Library locker (characters, enemies, weapons, props, textures, HUD, buttons, binds). Filter by kind and/or game style.', {
500
833
  kind: KIND.optional().describe('Filter by kind'),
501
834
  q: z.string().optional().describe('Search name/filename'),
502
835
  style: z.string().optional().describe('Game look (devil-may-cry, dmc, genshin)'),
@@ -525,7 +858,7 @@ server.tool('gripforge_library_get', 'Get one Library item (metadata + signed fi
525
858
  return err(String(data.error ?? res.status));
526
859
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
527
860
  });
528
- server.tool('gripforge_library_push', 'Upload a local file into the GripForge Library locker (character, weapon, prop, texture, or bind).', {
861
+ server.tool('gripforge_library_push', 'Upload a local file into the GripForge Library locker (character, weapon, prop, texture, HUD, or bind).', {
529
862
  path: z.string().describe('Absolute path of the file to upload'),
530
863
  kind: KIND.describe('Locker kind'),
531
864
  name: z.string().optional().describe('Display name (defaults to the filename stem)'),
@@ -646,20 +979,68 @@ server.tool('gripforge_library_tag', 'Add a game look to tags so style_kit can f
646
979
  return err(String(data.error ?? res.status));
647
980
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
648
981
  });
649
- server.tool('gripforge_animate', 'Retarget the standard clip pack (idle, walk, run, attack1-3, hit, death, dodge) onto a Mixamo-named Library character/enemy/armed bind. Saves kind=animation. 0 credits.', {
982
+ server.tool('gripforge_hand_rig', 'Complete a rigged Library character with finger bones (3 phalanges × 5 fingers per hand, Mixamo names) built from its hand mesh — no provider rigs fingers, so without this a hand cannot close on a weapon and pistol/knife clips have nothing to drive. New characters get it automatically in gripforge_generate_character; use this on characters generated before. Updates the item in place. 0 credits. Then re-run gripforge_animate.', { character_id: z.string().describe('Library character / enemy / bind (lib_…)') }, async ({ character_id }) => {
983
+ if (!API_KEY)
984
+ return err('GRIPFORGE_API_KEY missing.');
985
+ const res = await fetch(`${API_URL}/api/v1/hand-rig`, {
986
+ method: 'POST',
987
+ headers: { 'content-type': 'application/json', 'x-api-key': API_KEY },
988
+ body: JSON.stringify({ item: character_id }),
989
+ });
990
+ const data = (await res.json().catch(() => ({})));
991
+ if (!res.ok)
992
+ return err(String(data.error ?? res.statusText));
993
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
994
+ });
995
+ server.tool('gripforge_viewmodel_arms', 'First-person arms cut out of a rigged + animated Library character (animation kit preferred): keeps the skeleton, the 23 clips and the materials, drops every triangle that is not skinned to the arm chains. The player then sees the same gloves/sleeves the others see on the body, and weapons attach on RightHand with the gripforge_attach binds. New Library item. 0 credits.', { item: z.string().describe('Library character or animation kit (lib_…)'), out_dir: z.string().optional().describe('write the GLB there (e.g. <godot>/assets/characters), named <team>_arms.glb when team is given'), team: z.string().optional().describe('file prefix, e.g. T or CT') }, async ({ item, out_dir, team }) => {
996
+ if (!API_KEY)
997
+ return err('GRIPFORGE_API_KEY missing.');
998
+ const res = await fetch(`${API_URL}/api/v1/viewmodel-arms`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': API_KEY }, body: JSON.stringify({ item }) });
999
+ const data = (await res.json().catch(() => ({})));
1000
+ if (!res.ok)
1001
+ return err(String(data.error ?? res.statusText));
1002
+ const wrote = [];
1003
+ if (out_dir && data.item?.file_url) {
1004
+ const fs = await import('node:fs/promises');
1005
+ const path = await import('node:path');
1006
+ await fs.mkdir(out_dir, { recursive: true });
1007
+ const r = await fetch(data.item.file_url);
1008
+ if (r.ok) {
1009
+ const f = path.join(out_dir, team ? `${team}_arms.glb` : (data.item.filename ?? 'arms.glb'));
1010
+ await fs.writeFile(f, Buffer.from(await r.arrayBuffer()));
1011
+ wrote.push(f);
1012
+ }
1013
+ }
1014
+ return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote }, null, 2) }] };
1015
+ });
1016
+ server.tool('gripforge_hand_check', 'Verify that a rigged character\'s fingers fold cleanly — per hand and per clip: skin-weight sanity, fingertip ownership, skin coherence (animated reach vs rest), bend angles, spike vertices. Run it after gripforge_hand_rig or gripforge_animate; ok=false comes with a problems list and what to re-run. The Character Studio shows the same clips visually (hand check panel). 0 credits.', { item: z.string().describe('Library character / enemy / bind / animation kit (lib_…)') }, async ({ item }) => {
1017
+ if (!API_KEY)
1018
+ return err('GRIPFORGE_API_KEY missing.');
1019
+ const res = await fetch(`${API_URL}/api/v1/hand-check`, {
1020
+ method: 'POST',
1021
+ headers: { 'content-type': 'application/json', 'x-api-key': API_KEY },
1022
+ body: JSON.stringify({ item }),
1023
+ });
1024
+ const data = (await res.json().catch(() => ({})));
1025
+ if (!res.ok)
1026
+ return err(String(data.error ?? res.statusText));
1027
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1028
+ });
1029
+ server.tool('gripforge_animate', 'Retarget the standard clip pack onto a Mixamo-named Library character/enemy/armed bind. Base slots (idle, walk, run, attack1-3, hit, death, dodge) + extras (taunt, stinger, knockback, block, jump_start/loop/land, idle_guns, shoot, reload, aim, crouch_idle, crouch_walk, knife). archetype remaps the base slots: sword (default), claws (zombie scratch), heavy (slow + heavy combo), brawler (punches), puppet (stiff walk + throws), operator (CS-style: knife/punch strikes). Opt-in slots fire_breath (mouth exhalation) and energy_cast (two-hand azure wave) use the Studio VFX character motions. Saves kind=animation. 0 credits.', {
650
1030
  character_id: z.string().describe('Library character, enemy, or armed bind'),
1031
+ archetype: z.enum(['sword', 'claws', 'heavy', 'brawler', 'puppet', 'operator']).optional().describe('Movement/strike archetype — remaps the base slots (default sword); operator = CS-style (knife/punch strikes, guns via idle_guns/shoot/reload/aim, crouch_idle/crouch_walk)'),
651
1032
  slots: z
652
- .array(z.enum(['idle', 'walk', 'run', 'attack1', 'attack2', 'attack3', 'hit', 'death', 'dodge']))
1033
+ .array(z.enum(['idle', 'walk', 'run', 'attack1', 'attack2', 'attack3', 'hit', 'death', 'dodge', 'taunt', 'stinger', 'knockback', 'block', 'jump_start', 'jump_loop', 'jump_land', 'idle_guns', 'shoot', 'fire_breath', 'energy_cast', 'reload', 'aim', 'crouch_idle', 'crouch_walk', 'knife']))
653
1034
  .optional(),
654
1035
  name: z.string().optional(),
655
1036
  out_dir: z.string().optional().describe('Also write the animated GLB here'),
656
- }, async ({ character_id, slots, name, out_dir }) => {
1037
+ }, async ({ character_id, archetype, slots, name, out_dir }) => {
657
1038
  if (!API_KEY)
658
1039
  return err('GRIPFORGE_API_KEY missing.');
659
1040
  const res = await fetch(`${API_URL}/api/v1/animate-pack`, {
660
1041
  method: 'POST',
661
1042
  headers: { ...apiHeaders(), 'content-type': 'application/json' },
662
- body: JSON.stringify({ character_id, slots, name }),
1043
+ body: JSON.stringify({ character_id, archetype, slots, name }),
663
1044
  });
664
1045
  const data = (await res.json().catch(() => ({})));
665
1046
  if (!res.ok)
@@ -675,6 +1056,214 @@ server.tool('gripforge_animate', 'Retarget the standard clip pack (idle, walk, r
675
1056
  }
676
1057
  return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote }, null, 2) }] };
677
1058
  });
1059
+ server.tool('gripforge_audio_kit', 'Search locker + Community SFX by description/tags (gunshot, ui, magie, pas, dinosaure…). Hits include description + use[]. Community: clone then pull. out_dir downloads matched files. Free to search.', {
1060
+ prompt: z.string().optional().describe('What you need, e.g. "gunshot", "ui click", "dinosaur roar" (omit for a short catalog)'),
1061
+ out_dir: z.string().optional().describe('Download matched audio files here'),
1062
+ }, async ({ prompt, out_dir }) => {
1063
+ if (!API_KEY)
1064
+ return err('GRIPFORGE_API_KEY missing.');
1065
+ const wanted = (prompt ?? '').toLowerCase().trim();
1066
+ const qTokens = wanted.split(/[^a-z0-9]+/).filter((w) => w.length >= 2);
1067
+ const blobOf = (it) => {
1068
+ const m = it.meta ?? {};
1069
+ const tags = Array.isArray(m.tags) ? m.tags.join(' ') : '';
1070
+ const use = Array.isArray(m.use) ? m.use.join(' ') : '';
1071
+ return `${it.name} ${it.filename ?? ''} ${m.description ?? ''} ${m.search ?? ''} ${m.pack ?? ''} ${tags} ${use}`.toLowerCase();
1072
+ };
1073
+ const match = (it) => {
1074
+ if (!qTokens.length)
1075
+ return true;
1076
+ const hay = blobOf(it).split(/[^a-z0-9]+/).filter(Boolean);
1077
+ return qTokens.every((w) => hay.some((h) => h === w || (w.length >= 4 && h.startsWith(w))));
1078
+ };
1079
+ const lite = (it, source) => {
1080
+ const m = it.meta ?? {};
1081
+ return {
1082
+ id: it.id,
1083
+ name: it.name,
1084
+ filename: it.filename ?? null,
1085
+ description: typeof m.description === 'string' ? m.description : null,
1086
+ use: Array.isArray(m.use) ? m.use : [],
1087
+ pack: typeof m.pack === 'string' ? m.pack : null,
1088
+ file_url: it.file_url ?? it.fileUrl ?? null,
1089
+ source,
1090
+ };
1091
+ };
1092
+ const [lockRes, commRes] = await Promise.all([
1093
+ fetch(`${API_URL}/api/v1/library?kind=audio&limit=500`, { headers: apiHeaders() }),
1094
+ fetch(`${API_URL}/api/v1/community`),
1095
+ ]);
1096
+ const lockData = (await lockRes.json().catch(() => ({})));
1097
+ const commData = (await commRes.json().catch(() => ({})));
1098
+ if (!lockRes.ok)
1099
+ return err('library list failed');
1100
+ const locker = (lockData.items ?? []).filter(match);
1101
+ const have = new Set(locker.map((it) => it.id));
1102
+ const community = (commData.items ?? []).filter((it) => it.kind === 'audio' && !have.has(it.id) && match(it));
1103
+ const all = [...locker.map((it) => lite(it, 'locker')), ...community.map((it) => lite(it, 'community'))];
1104
+ const hits = all.filter((i) => !/^(sfx|music)[_-]/i.test(i.name)).slice(0, wanted ? 40 : 24);
1105
+ const out = {
1106
+ query: wanted || null,
1107
+ total: all.length,
1108
+ sfx: all.filter((i) => /^sfx[_-]/i.test(i.name)),
1109
+ music: all.filter((i) => /^music[_-]/i.test(i.name)),
1110
+ hits,
1111
+ wrote: [],
1112
+ hint: wanted
1113
+ ? 'Pick by description/use. Locker ids: gripforge_library_pull. Community ids: clone first (1 credit), then pull.'
1114
+ : 'Pass prompt to filter. Descriptions are on each hit.',
1115
+ };
1116
+ if (out_dir) {
1117
+ const dir = resolve(out_dir);
1118
+ await mkdir(dir, { recursive: true });
1119
+ const download = [...out.sfx, ...out.music, ...out.hits];
1120
+ for (const it of download) {
1121
+ if (!it.file_url)
1122
+ continue;
1123
+ const bin = Buffer.from(await (await fetch(it.file_url)).arrayBuffer());
1124
+ const dest = join(dir, it.filename || `${it.name.replace(/[^\w.-]+/g, '_')}.wav`);
1125
+ await writeFile(dest, bin);
1126
+ out.wrote.push(dest);
1127
+ }
1128
+ }
1129
+ return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] };
1130
+ });
1131
+ server.tool('gripforge_aim', 'Aim-offset poses for a Library character/armed bind (0 credits): for each hand, two-bone IK + wrist alignment so the WEAPON AXIS (gf_muzzle locator from gripforge_loadout, else the seat forward) points at every direction of a yaw×pitch grid. Returns bone-local quaternions per pose + rest, and a Three.js/Godot blend snippet. Works for dual wield (independent hands) and any Mixamo/UAL/Tripo rig. out_dir writes aim.json.', {
1132
+ attach_id: z.string().optional().describe('Armed bind / loadout id (lib_…) — preferred'),
1133
+ character_id: z.string().optional().describe('Character id if no bind'),
1134
+ hands: z.array(z.enum(['left', 'right'])).optional().describe('Default both'),
1135
+ yaws: z.array(z.number()).optional().describe('Default [-60,-30,0,30,60] (left positive)'),
1136
+ pitches: z.array(z.number()).optional().describe('Default [-40,-20,0,20,40] (up positive)'),
1137
+ extension: z.number().optional().describe('Arm extension 0.5–0.98 (default 0.9)'),
1138
+ out_dir: z.string().optional().describe('Write aim.json here'),
1139
+ }, async ({ attach_id, character_id, hands, yaws, pitches, extension, out_dir }) => {
1140
+ if (!API_KEY)
1141
+ return err('GRIPFORGE_API_KEY missing.');
1142
+ if (!attach_id && !character_id)
1143
+ return err('attach_id or character_id required');
1144
+ const res = await fetch(`${API_URL}/api/v1/aim`, {
1145
+ method: 'POST',
1146
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
1147
+ body: JSON.stringify({ attach_id, character_id, hands, yaws, pitches, extension }),
1148
+ });
1149
+ const data = (await res.json().catch(() => ({})));
1150
+ if (!res.ok)
1151
+ return err(String(data.error ?? res.status));
1152
+ const wrote = [];
1153
+ if (out_dir) {
1154
+ const dir = resolve(out_dir);
1155
+ await mkdir(dir, { recursive: true });
1156
+ const dest = join(dir, 'aim.json');
1157
+ await writeFile(dest, JSON.stringify(data, null, 2));
1158
+ wrote.push(dest);
1159
+ }
1160
+ return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote }, null, 2) }] };
1161
+ });
1162
+ server.tool('gripforge_terrain_concepts', 'Three concept pictures of a level (aerial three-quarter view, readable layout) BEFORE any 3D: pick one, then build the terrain from it in the Terrain Studio (the picture is read into a LevelIntent) or pass its id as concept_item. Each image is a Library concept item. 1 credit per image (default 3).', { prompt: z.string().min(3).max(600).describe('the level, e.g. "snowy ARPG zone, castle north, dense forests, frozen lake"'), n: z.number().min(1).max(4).optional().describe('default 3') }, async ({ prompt, n }) => {
1163
+ if (!API_KEY)
1164
+ return err('GRIPFORGE_API_KEY missing.');
1165
+ const res = await fetch(`${API_URL}/api/v1/terrain-concepts`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': API_KEY }, body: JSON.stringify({ prompt, n: n ?? 3 }) });
1166
+ const data = (await res.json().catch(() => ({})));
1167
+ if (!res.ok)
1168
+ return err(String(data.error ?? res.statusText));
1169
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1170
+ });
1171
+ server.tool('gripforge_character_concepts', 'Three LOOK pictures (3/4, Imagine low 1K) of a named character, enemy or boss BEFORE any 3D. Pass weapon to show it on the looks; 3D later is T-pose empty hands. Pick one, then gripforge_generate_character with concept_item. 1 credit per image (default 3).', {
1172
+ name: z.string().min(2).max(80).describe('Creation name, stored on each concept'),
1173
+ prompt: z.string().min(3).max(600).describe('Look, outfit, silhouette'),
1174
+ kind: z.enum(['character', 'enemy', 'boss']).optional().describe('Default character'),
1175
+ weapon: z.string().max(160).optional().describe('Shown on the looks only; empty hands if omitted'),
1176
+ n: z.number().min(1).max(4).optional().describe('Default 3'),
1177
+ }, async ({ name, prompt, kind, weapon, n }) => {
1178
+ if (!API_KEY)
1179
+ return err('GRIPFORGE_API_KEY missing.');
1180
+ const res = await fetch(`${API_URL}/api/v1/character-concepts`, {
1181
+ method: 'POST',
1182
+ headers: { 'content-type': 'application/json', 'x-api-key': API_KEY },
1183
+ body: JSON.stringify({ name, prompt, kind, weapon, n: n ?? 3 }),
1184
+ });
1185
+ const data = (await res.json().catch(() => ({})));
1186
+ if (!res.ok)
1187
+ return err(String(data.error ?? res.statusText));
1188
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1189
+ });
1190
+ server.tool('gripforge_level', 'Plan a playable level from a prompt (0 credits): rooms placed in metres (entrance, combat rooms, junctions, key/lock, boss antechamber + boss room, treasure, secret room), connections, critical path, shortcuts, and gameplay spawns (player/packs/boss/rewards). Same seed → same level. Pair with gripforge_scene_kit for dressing props and gripforge_style_kit for enemies.', {
1191
+ prompt: z.string().describe('e.g. "gothic castle dungeon, 3 combat rooms, one boss, exploration pacing"'),
1192
+ seed: z.number().optional(),
1193
+ combat_rooms: z.number().optional(),
1194
+ floors: z.number().optional(),
1195
+ optional_branches: z.number().optional(),
1196
+ out_dir: z.string().optional().describe('Write level.json here'),
1197
+ }, async ({ prompt, seed, combat_rooms, floors, optional_branches, out_dir }) => {
1198
+ if (!API_KEY)
1199
+ return err('GRIPFORGE_API_KEY missing.');
1200
+ const res = await fetch(`${API_URL}/api/v1/level`, {
1201
+ method: 'POST',
1202
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
1203
+ body: JSON.stringify({ prompt, seed, combat_rooms, floors, optional_branches }),
1204
+ });
1205
+ const data = (await res.json().catch(() => ({})));
1206
+ if (!res.ok)
1207
+ return err(String(data.error ?? res.status));
1208
+ const wrote = [];
1209
+ if (out_dir) {
1210
+ const dir = resolve(out_dir);
1211
+ await mkdir(dir, { recursive: true });
1212
+ const dest = join(dir, 'level.json');
1213
+ await writeFile(dest, JSON.stringify(data, null, 2));
1214
+ wrote.push(dest);
1215
+ }
1216
+ return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote }, null, 2) }] };
1217
+ });
1218
+ server.tool('gripforge_loadout', 'Build a multi-weapon character in ONE call: attaches every prop of every set onto the same rig, ' +
1219
+ 'names each prop node gf_prop_<slug>_<l|r>, stamps glTF extras { gripforge: { prop, set, hand } } and a ' +
1220
+ 'scene manifest { default, sets }, then (by default) retargets the standard clip pack. ' +
1221
+ 'The engine switches weapons by toggling node visibility — the returned Three.js snippet does it. ' +
1222
+ 'Example sets: { "sword": [{"prop_id":"lib_sword"}], "guns": [{"prop_id":"lib_gun","hand":"left","style":"gun"}, {"prop_id":"lib_gun","hand":"right","style":"gun"}] }. ' +
1223
+ 'Credits: one attach per prop.', {
1224
+ character_id: z.string().describe('Library character id (lib_…)'),
1225
+ sets: z
1226
+ .record(z.string(), z.array(z.object({
1227
+ prop_id: z.string(),
1228
+ hand: z.enum(['left', 'right']).optional(),
1229
+ style: z.enum(['melee', 'gun', 'shield', 'staff']).optional(),
1230
+ })))
1231
+ .describe('set name → props to hold in that set'),
1232
+ default_set: z.string().optional().describe('Set visible by default (first set otherwise)'),
1233
+ archetype: z.enum(['sword', 'claws', 'heavy', 'brawler', 'puppet', 'operator']).optional().describe('Movement/strike archetype — remaps the base slots (default sword); operator = CS-style (knife/punch strikes, guns via idle_guns/shoot/reload/aim, crouch_idle/crouch_walk)'),
1234
+ name: z.string().optional(),
1235
+ animate: z.boolean().optional().describe('Also retarget the standard clip pack (default true)'),
1236
+ out_dir: z.string().optional().describe('Write the animated loadout GLB (or the bind GLB) + manifest.json here'),
1237
+ }, async ({ character_id, sets, default_set, archetype, name, animate, out_dir }) => {
1238
+ if (!API_KEY)
1239
+ return err('GRIPFORGE_API_KEY missing.');
1240
+ const res = await fetch(`${API_URL}/api/v1/loadout`, {
1241
+ method: 'POST',
1242
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
1243
+ body: JSON.stringify({ character_id, sets, default: default_set, archetype, name, animate }),
1244
+ });
1245
+ const data = (await res.json().catch(() => ({})));
1246
+ if (!res.ok)
1247
+ return err(String(data.error ?? res.status));
1248
+ const wrote = [];
1249
+ if (out_dir) {
1250
+ const dir = resolve(out_dir);
1251
+ await mkdir(dir, { recursive: true });
1252
+ const best = data.animation?.file_url ? data.animation : data.item;
1253
+ if (best?.file_url) {
1254
+ const bin = Buffer.from(await (await fetch(best.file_url)).arrayBuffer());
1255
+ const dest = join(dir, 'loadout.glb');
1256
+ await writeFile(dest, bin);
1257
+ wrote.push(dest);
1258
+ }
1259
+ if (data.manifest) {
1260
+ const mf = join(dir, 'manifest.json');
1261
+ await writeFile(mf, JSON.stringify(data.manifest, null, 2));
1262
+ wrote.push(mf);
1263
+ }
1264
+ }
1265
+ return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote }, null, 2) }] };
1266
+ });
678
1267
  server.tool('gripforge_retarget', 'Convert clips from skeleton A (source_id) onto skeleton B (target_id). Saves kind=animation.', {
679
1268
  source_id: z.string(),
680
1269
  target_id: z.string(),
@@ -735,49 +1324,836 @@ server.tool('gripforge_scene_kit', 'Locker props for a game look plus a suggeste
735
1324
  return err(String(data.error ?? res.status));
736
1325
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
737
1326
  });
738
- server.tool('gripforge_hitbox', 'Body capsules + weapon reach from a Library character or armed bind. 0 credits.', {
1327
+ server.tool('gripforge_hitbox', 'Body capsules + weapons[] (one per gf_prop_ wrapper) from a Library character, armed bind or loadout. 0 credits.', {
739
1328
  character_id: z.string().optional().describe('Library character/enemy id'),
740
1329
  attach_id: z.string().optional().describe('Library armed bind id'),
741
- }, async ({ character_id, attach_id }) => {
1330
+ id: z.string().optional().describe('Library id of a loadout / bind / character'),
1331
+ }, async ({ character_id, attach_id, id }) => {
742
1332
  if (!API_KEY)
743
1333
  return err('GRIPFORGE_API_KEY missing.');
744
- if (!character_id && !attach_id)
745
- return err('Provide character_id or attach_id.');
1334
+ if (!character_id && !attach_id && !id)
1335
+ return err('Provide character_id, attach_id or id.');
746
1336
  const res = await fetch(`${API_URL}/api/v1/hitbox`, {
747
1337
  method: 'POST',
748
1338
  headers: { ...apiHeaders(), 'content-type': 'application/json' },
749
- body: JSON.stringify({ character_id, attach_id }),
1339
+ body: JSON.stringify({ character_id, attach_id, id }),
750
1340
  });
751
1341
  const data = await res.json().catch(() => ({}));
752
1342
  if (!res.ok)
753
1343
  return err(String(data.error ?? res.status));
754
1344
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
755
1345
  });
756
- server.tool('gripforge_vfx', 'ADMIN ONLY. Generate + export 3D slash/thrust/spin VFX on an existing Library bind, or a world-space fire meteor (head + trail + impact). Pass prompt to fill type/style/color/emitters. Additive PNG, vfx.json + snippets. Refused for non-admin keys.', {
757
- attach_id: z.string().optional().describe('Library bind id (lib_…). Not required for meteor.'),
758
- prompt: z.string().optional().describe('Natural-language VFX prompt. Fills type/style/color/emitters when omitted.'),
759
- type: z.enum(['slash', 'thrust', 'spin', 'meteor', 'beam', 'swing', 'claw', 'charge', 'shield']).optional().describe('Strike type (default slash)'),
760
- style: z.enum(['energy', 'metal', 'magic']).optional().describe('Look (default energy)'),
761
- look: z.enum(['toon', 'solid']).optional().describe('Beam look: toon lightning or solid filled kamehameha'),
762
- color: z.string().optional().describe('Hex color'),
763
- duration: z.number().optional().describe('Seconds (default 0.25)'),
764
- width: z.number().optional().describe('Ribbon width in metres'),
765
- out_dir: z.string().optional().describe('Write vfx.glb, additive.png, vfx.json here'),
766
- }, async ({ attach_id, prompt, type, style, look, color, duration, width, out_dir }) => {
1346
+ server.tool('gripforge_vehicle_wheels', 'Split the 4 wheels out of a fused kart/car GLB so they can spin: Wheel_FL/FR/RL/RR nodes, hub pivots, extras gf_wheel {radius, axle, spin}. Geometric cut (cylinder + plane, seam invariant under rotation, capped). Run gripforge_vehicle_orient first. 0 credits. apply=false → analysis only.', {
1347
+ id: z.string().describe('Library id of the vehicle GLB (lib_…)'),
1348
+ apply: z.boolean().optional().describe('default true — rewrite the Library file with the split wheels'),
1349
+ }, async ({ id, apply }) => {
767
1350
  if (!API_KEY)
768
1351
  return err('GRIPFORGE_API_KEY missing.');
769
- const res = await fetch(`${API_URL}/api/v1/vfx`, {
1352
+ const res = await fetch(`${API_URL}/api/v1/vehicle-wheels`, {
770
1353
  method: 'POST',
771
- headers: { 'x-api-key': API_KEY, 'x-gripforge-client': 'mcp', 'content-type': 'application/json' },
772
- body: JSON.stringify({
773
- attach_id,
774
- prompt,
1354
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
1355
+ body: JSON.stringify({ id, apply }),
1356
+ });
1357
+ const data = await res.json().catch(() => ({}));
1358
+ if (!res.ok)
1359
+ return err(String(data.error ?? res.status));
1360
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1361
+ });
1362
+ server.tool('gripforge_vehicle_orient', 'Yaw so a kart/vehicle GLB nose is local +Z (Meshy often faces +X). Then heading = yawToward(tangent) = atan2(fx, fz). Pass Library id. 0 credits.', {
1363
+ id: z.string().optional().describe('Library id of the vehicle/kart GLB (lib_…)'),
1364
+ prop_id: z.string().optional().describe('Same as id'),
1365
+ }, async ({ id, prop_id }) => {
1366
+ if (!API_KEY)
1367
+ return err('GRIPFORGE_API_KEY missing.');
1368
+ if (!id && !prop_id)
1369
+ return err('Provide id or prop_id.');
1370
+ const res = await fetch(`${API_URL}/api/v1/vehicle-orient`, {
1371
+ method: 'POST',
1372
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
1373
+ body: JSON.stringify({ id, prop_id }),
1374
+ });
1375
+ const data = await res.json().catch(() => ({}));
1376
+ if (!res.ok)
1377
+ return err(String(data.error ?? res.status));
1378
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1379
+ });
1380
+ server.tool('gripforge_map_plan', 'FPS/bomb map. Interprets overhead as 3D compound (hollow rooms, corniche, A plat+pit, arches, vaulted tunnels, sidewalks). Preview https://gripforge.ai/map-plan?prompt=dust2. Pair gripforge_map_props + gripforge_map_wires. 0 credits. Dungeons: gripforge_level.', { prompt: z.string().min(2).max(240).describe('e.g. counter-strike dust2 bomb defusal'),
1381
+ item: z.string().optional().describe('lib_… traced plan from gripforge_map_concept (overrides the preset)'),
1382
+ windows: z.enum(['shuttered', 'barred', 'none']).optional().describe('Boundary windows: volets (default), grilles, ou aucune'),
1383
+ window_density: z.number().min(0).max(1).optional().describe('Share of boundary segments that get a window (default 0.25)'),
1384
+ }, async ({ prompt, item, windows, window_density }) => {
1385
+ if (!API_KEY)
1386
+ return err('GRIPFORGE_API_KEY missing.');
1387
+ const qs = new URLSearchParams({ prompt });
1388
+ if (item)
1389
+ qs.set('item', item);
1390
+ if (windows)
1391
+ qs.set('windows', windows);
1392
+ if (window_density != null)
1393
+ qs.set('window_density', String(window_density));
1394
+ const res = await fetch(`${API_URL}/api/v1/map-plan?${qs}`, { headers: apiHeaders() });
1395
+ const data = await res.json().catch(() => ({}));
1396
+ if (!res.ok)
1397
+ return err(String(data.error ?? res.status));
1398
+ const rec = data;
1399
+ const preview = typeof rec.preview_url === 'string' ? `Preview: ${rec.preview_url}\n\n` : '';
1400
+ return { content: [{ type: 'text', text: `${preview}${JSON.stringify(data, null, 2)}` }] };
1401
+ });
1402
+ server.tool('gripforge_map_concept', 'AI-DRAW a map layout: gpt-image-2 generates a top-down plan schematic (white walls on black) + a 3/4 greybox concept render, auto-traces the plan into MapPlan wall boxes, saves both to the Library. Reuse via gripforge_map_plan { item } or https://gripforge.ai/map-plan?item=lib_…&greybox=1. Alternative to the code presets when the layout should come from an image.', {
1403
+ prompt: z.string().min(3).max(400).describe('e.g. "moroccan desert village bomb map, tight alleys, two plazas"'),
1404
+ extent_m: z.number().min(40).max(300).optional().describe('Playfield size in metres the image maps onto (default 110)'),
1405
+ threshold: z.number().min(30).max(240).optional().describe('Luminance above which a pixel is wall (default 145 — higher = thinner walls, wider streets)'),
1406
+ plan_item: z.string().optional().describe('Existing traced plan: re-render its concept image, or with retrace=true re-trace it'),
1407
+ retrace: z.boolean().optional().describe('With plan_item: re-trace the stored plan image with the new threshold/extent (no image regen)'),
1408
+ min_street_m: z.number().min(1.5).max(6).optional().describe('Guaranteed minimum street width in metres — narrower passages are carved wider (default 2.6)'),
1409
+ image_path: z.string().optional().describe('Trace THIS local image instead of generating one (hand-drawn plan, radar crop). White walls on black — or set invert for radar-style (bright walkable on dark)'),
1410
+ concept_path: z.string().optional().describe('With image_path: local concept/reference image stored alongside'),
1411
+ invert: z.boolean().optional().describe('image_path is radar-style: walkable is bright, everything dark becomes wall'),
1412
+ convert: z.boolean().optional().describe('image_path is an ILLUSTRATED/textured top view: gpt-image-2 converts it into the binary wall diagram first (the original becomes the concept image)'),
1413
+ views: z.number().min(0).max(4).optional().describe('Also generate N street-level KEY-ANGLE views (main lane, plaza, chokepoint, site doors) conditioned on the aerial concept — linked to the plan, shown in the studio CONCEPT tab'),
1414
+ out_dir: z.string().optional().describe('Also write plan.png + greybox.png here'),
1415
+ }, async ({ prompt, extent_m, threshold, plan_item, retrace, min_street_m, image_path, concept_path, invert, convert, views, out_dir }) => {
1416
+ if (!API_KEY)
1417
+ return err('GRIPFORGE_API_KEY missing.');
1418
+ let image_b64;
1419
+ let concept_b64;
1420
+ if (image_path) {
1421
+ const { readFileSync } = await import('node:fs');
1422
+ image_b64 = readFileSync(image_path).toString('base64');
1423
+ if (concept_path)
1424
+ concept_b64 = readFileSync(concept_path).toString('base64');
1425
+ }
1426
+ const res = await fetch(`${API_URL}/api/v1/map-concept`, {
1427
+ method: 'POST',
1428
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
1429
+ body: JSON.stringify({ prompt, extent_m, threshold, plan_item, retrace, min_street_m, image_b64, concept_b64, invert, convert, views }),
1430
+ });
1431
+ const data = (await res.json().catch(() => ({})));
1432
+ if (!res.ok)
1433
+ return err(String(data.error ?? res.status));
1434
+ const wrote = [];
1435
+ if (out_dir) {
1436
+ const { writeFileSync, mkdirSync } = await import('node:fs');
1437
+ const { join } = await import('node:path');
1438
+ mkdirSync(out_dir, { recursive: true });
1439
+ for (const [name, url] of [['map-concept-plan.png', data.plan_item?.file_url], ['map-concept-greybox.png', data.concept_item?.file_url]]) {
1440
+ if (!url)
1441
+ continue;
1442
+ const bin = await fetch(url, { headers: apiHeaders() });
1443
+ if (bin.ok) {
1444
+ const fp = join(out_dir, name);
1445
+ writeFileSync(fp, Buffer.from(await bin.arrayBuffer()));
1446
+ wrote.push(fp);
1447
+ }
1448
+ }
1449
+ }
1450
+ const preview = typeof data.preview_url === 'string' ? `Preview: ${data.preview_url}\n\n` : '';
1451
+ return { content: [{ type: 'text', text: `${preview}${JSON.stringify({ ...data, wrote }, null, 2)}` }] };
1452
+ });
1453
+ server.tool('gripforge_map_match', 'stage=match — the reference-constrained loop: renders the greybox top-down orthographic (the concept camera), compares it to the stored reference diagram (IoU, missing/extra %, per-quadrant errors), and with iterate=true grid-searches the tracer parameters and PERSISTS the best-scoring plan (manual spawns kept). Chain: map_concept -> map_analyze -> map_match -> map_paths -> map_export.', {
1454
+ item: z.string().describe('lib_… traced plan item'),
1455
+ iterate: z.boolean().optional().describe('run the correction loop (12 tracer-parameter candidates) and persist the best plan'),
1456
+ }, async ({ item, iterate }) => {
1457
+ if (!API_KEY)
1458
+ return err('GRIPFORGE_API_KEY missing.');
1459
+ const res = await fetch(`${API_URL}/api/v1/map-match`, {
1460
+ method: 'POST',
1461
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
1462
+ body: JSON.stringify({ item, iterate }),
1463
+ });
1464
+ const data = await res.json().catch(() => ({}));
1465
+ if (!res.ok)
1466
+ return err(String(data.error ?? res.status));
1467
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1468
+ });
1469
+ server.tool('gripforge_map_paths', 'WALKABILITY report of a traced greybox: free-space grid, connected regions (sealed pockets), spawns validated/snapped onto the main region, T-to-CT path length + tightest corridor width + waypoints. Read it before shipping a map — spawns-in-walls and unreachable areas show up here.', { item: z.string().describe('lib_… traced plan item') }, async ({ item }) => {
1470
+ if (!API_KEY)
1471
+ return err('GRIPFORGE_API_KEY missing.');
1472
+ const res = await fetch(`${API_URL}/api/v1/map-paths?${new URLSearchParams({ item })}`, { headers: apiHeaders() });
1473
+ const data = await res.json().catch(() => ({}));
1474
+ if (!res.ok)
1475
+ return err(String(data.error ?? res.status));
1476
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1477
+ });
1478
+ server.tool('gripforge_map_textures', 'OUR texture pipeline (not a black-box retexture): generates 5 seamless tileable surfaces FROM the concept (4 wall variants + ground stone), conditioned on a street-level view and the analysis palette. Linked to the plan (meta.texture_items) — map-export embeds them with computed normal maps instead of the stock stucco files. The tiling-killer step after map_analyze.', {
1479
+ plan_item: z.string().describe('lib_… traced plan item'),
1480
+ prompt: z.string().max(200).optional().describe('Extra flavor, e.g. "more cracked, war-torn"'),
1481
+ }, async ({ plan_item, prompt }) => {
1482
+ if (!API_KEY)
1483
+ return err('GRIPFORGE_API_KEY missing.');
1484
+ const res = await fetch(`${API_URL}/api/v1/map-textures`, {
1485
+ method: 'POST',
1486
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
1487
+ body: JSON.stringify({ plan_item, prompt }),
1488
+ });
1489
+ const data = await res.json().catch(() => ({}));
1490
+ if (!res.ok)
1491
+ return err(String(data.error ?? res.status));
1492
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1493
+ });
1494
+ server.tool('gripforge_map_analyze', 'READ a map concept image like a level designer (GPT-5 vision): counts doors, windows, stairs, domes, awnings, crates, vehicles, palms + wall height, elevation levels, palette. Stored on the plan item (meta.analysis) — the traced dressing then uses the counts as QUOTAS so the greybox carries exactly what the concept drew. Run after gripforge_map_concept.', {
1495
+ item: z.string().describe('lib_… traced plan item'),
1496
+ image: z.string().optional().describe('lib_… concept image to read (default: the linked concept_item)'),
1497
+ }, async ({ item, image }) => {
1498
+ if (!API_KEY)
1499
+ return err('GRIPFORGE_API_KEY missing.');
1500
+ const res = await fetch(`${API_URL}/api/v1/map-analyze`, {
1501
+ method: 'POST',
1502
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
1503
+ body: JSON.stringify({ item, image }),
1504
+ });
1505
+ const data = await res.json().catch(() => ({}));
1506
+ if (!res.ok)
1507
+ return err(String(data.error ?? res.status));
1508
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1509
+ });
1510
+ server.tool('gripforge_map_decor', 'AI street-dressing sheet for AI-traced maps: gpt-image-2 draws a 3x2 TRANSPARENT sheet of weathered street posters, saved to the Library. Pass plan_item to link it — the traced dressing already pastes poster quads at eye height on facades, and map-plan/map-export embed the linked sheet automatically. Realism layer over gripforge_map_concept.', {
1511
+ prompt: z.string().max(300).optional().describe('Flavor for the 6 items'),
1512
+ sheet: z.enum(['posters', 'doors', 'debris', 'windows']).optional().describe('posters (default) = street posters at eye height; doors = photoreal weathered double doors, one per house facade'),
1513
+ plan_item: z.string().optional().describe('lib_… traced plan to link (meta.decor_item)'),
1514
+ }, async ({ prompt, plan_item, sheet }) => {
1515
+ if (!API_KEY)
1516
+ return err('GRIPFORGE_API_KEY missing.');
1517
+ const res = await fetch(`${API_URL}/api/v1/map-decor`, {
1518
+ method: 'POST',
1519
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
1520
+ body: JSON.stringify({ prompt, plan_item, sheet }),
1521
+ });
1522
+ const data = await res.json().catch(() => ({}));
1523
+ if (!res.ok)
1524
+ return err(String(data.error ?? res.status));
1525
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1526
+ });
1527
+ server.tool('gripforge_map_refs', 'Official in-game stills for a map (Dust II A/B/mid/cat/tunnels). HTTPS links, Valve copyright — reference only, never Meshy image-to-3d. live=true HEAD-checks. 0 credits.', {
1528
+ prompt: z.string().min(2).max(240).describe('e.g. dust2'),
1529
+ live: z.boolean().optional().describe('HEAD-check URLs'),
1530
+ }, async ({ prompt, live }) => {
1531
+ if (!API_KEY)
1532
+ return err('GRIPFORGE_API_KEY missing.');
1533
+ const qs = new URLSearchParams({ prompt });
1534
+ if (live)
1535
+ qs.set('live', '1');
1536
+ const res = await fetch(`${API_URL}/api/v1/map-refs?${qs}`, { headers: apiHeaders() });
1537
+ const data = await res.json().catch(() => ({}));
1538
+ if (!res.ok)
1539
+ return err(String(data.error ?? res.status));
1540
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1541
+ });
1542
+ server.tool('gripforge_map_props', 'FPS map callout props (xbox, goose, A/B crates, mid/long doors, T palms). Matches locker. Pair with gripforge_map_plan. Preview https://gripforge.ai/map-plan?prompt=dust2. 0 credits.', { prompt: z.string().min(2).max(240).describe('e.g. counter-strike dust2') }, async ({ prompt }) => {
1543
+ if (!API_KEY)
1544
+ return err('GRIPFORGE_API_KEY missing.');
1545
+ const res = await fetch(`${API_URL}/api/v1/map-props?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
1546
+ const data = await res.json().catch(() => ({}));
1547
+ if (!res.ok)
1548
+ return err(String(data.error ?? res.status));
1549
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1550
+ });
1551
+ server.tool('gripforge_map_wires', 'Sagging electrical cables for an FPS map: two anchors + sag + strands. Not a generated mesh. Pair with gripforge_map_plan. Dust2: T yard, cat (shot5a), mid, A. 0 credits.', { prompt: z.string().min(2).max(240).describe('e.g. counter-strike dust2') }, async ({ prompt }) => {
1552
+ if (!API_KEY)
1553
+ return err('GRIPFORGE_API_KEY missing.');
1554
+ const res = await fetch(`${API_URL}/api/v1/map-wires?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
1555
+ const data = await res.json().catch(() => ({}));
1556
+ if (!res.ok)
1557
+ return err(String(data.error ?? res.status));
1558
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1559
+ });
1560
+ server.tool('gripforge_map_reconstruct', 'Hierarchical FPS reconstruction: classified official stills (LINK) → MapGraph → greybox (no props) → zone parts → camera match → dress (original textures + map_props + map_wires). prompt e.g. Reconstruct map: Counter-Strike 2 Dust II. stage=collect|graph|greybox|zone|dress|all. Dust II is Valve IP — benchmark only, no BSP/VPK, never Meshy a screenshot. Greybox preview https://gripforge.ai/map-plan?prompt=dust2&greybox=1 — dressed /map-plan?prompt=dust2. 0 credits.', {
1561
+ prompt: z.string().min(2).max(240).describe('e.g. Reconstruct map: Counter-Strike 2 Dust II'),
1562
+ stage: z
1563
+ .enum(['collect', 'graph', 'greybox', 'zone', 'validate', 'capture', 'dress', 'all'])
1564
+ .optional()
1565
+ .describe('collect | graph | greybox | zone | validate | capture | dress | all'),
1566
+ zone: z.string().min(2).max(40).optional().describe('LONG_A | A_SITE | CATWALK | …'),
1567
+ }, async ({ prompt, stage, zone }) => {
1568
+ if (!API_KEY)
1569
+ return err('GRIPFORGE_API_KEY missing.');
1570
+ const qs = new URLSearchParams({ prompt });
1571
+ if (stage)
1572
+ qs.set('stage', stage);
1573
+ if (zone)
1574
+ qs.set('zone', zone);
1575
+ const res = await fetch(`${API_URL}/api/v1/map-reconstruct?${qs}`, { headers: apiHeaders() });
1576
+ const data = await res.json().catch(() => ({}));
1577
+ if (!res.ok)
1578
+ return err(String(data.error ?? res.status));
1579
+ const rec = data;
1580
+ const preview = typeof rec.preview_url === 'string' ? `Preview: ${rec.preview_url}\n\n` : '';
1581
+ return { content: [{ type: 'text', text: `${preview}${JSON.stringify(data, null, 2)}` }] };
1582
+ });
1583
+ server.tool('gripforge_map_export', 'Export the dressed FPS map as ONE engine-ready GLB: merged textured meshes named <family>-col (Godot builds trimesh colliders on import), metric UVs, ground slab. Pair with a NavigationRegion3D bake. 0 credits.', {
1584
+ prompt: z.string().min(2).max(240).describe('e.g. "dust2"'),
1585
+ item: z.string().optional().describe('lib_… traced plan from gripforge_map_concept — exports the AI-drawn layout instead of dust2'),
1586
+ extent_m: z.number().min(30).max(300).optional().describe('Rescale the traced plan to this playfield size in metres'),
1587
+ out_dir: z.string().optional().describe('Write dust2-map.glb here'),
1588
+ save: z.boolean().optional().describe('Also store the GLB in the Library (kind prop)'),
1589
+ godot_project: z.boolean().optional().describe('Write a ready-to-run Godot 4 project around the GLB into out_dir (scenes, FPS player, navmesh bake) — then: godot --path <out_dir>'),
1590
+ lighting: z.enum(['noon', 'golden', 'dusk', 'night', 'overcast']).optional().describe('Godot mood preset (SDFGI real-time GI, no bake): noon = desert midday (default), golden = warm low sun, dusk = orange sunset, night = cool moonlight, overcast = soft grey. Tunable afterwards in scenes/main.tscn (Sun.light_energy, ambient_light_energy, sdfgi_energy — see the kit README)'),
1591
+ }, async ({ prompt, item, extent_m, out_dir, save, godot_project, lighting }) => {
1592
+ if (!API_KEY)
1593
+ return err('GRIPFORGE_API_KEY missing.');
1594
+ const qs = new URLSearchParams({ prompt });
1595
+ if (item)
1596
+ qs.set('item', item);
1597
+ if (extent_m != null)
1598
+ qs.set('extent_m', String(extent_m));
1599
+ if (save)
1600
+ qs.set('save', '1');
1601
+ const res = await fetch(`${API_URL}/api/v1/map-export?${qs}`, { headers: apiHeaders() });
1602
+ if (!res.ok) {
1603
+ const data = await res.json().catch(() => ({}));
1604
+ return err(String(data.error ?? res.status));
1605
+ }
1606
+ if (save) {
1607
+ const data = await res.json();
1608
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1609
+ }
1610
+ const buf = Buffer.from(await res.arrayBuffer());
1611
+ const wrote = [];
1612
+ if (out_dir) {
1613
+ const { writeFileSync, mkdirSync } = await import('node:fs');
1614
+ const { join, dirname } = await import('node:path');
1615
+ mkdirSync(out_dir, { recursive: true });
1616
+ if (godot_project) {
1617
+ const kitQs = new URLSearchParams({ prompt, godot: '1' });
1618
+ if (item)
1619
+ kitQs.set('item', item);
1620
+ if (extent_m != null)
1621
+ kitQs.set('extent_m', String(extent_m));
1622
+ if (lighting)
1623
+ kitQs.set('lighting', lighting);
1624
+ const kitRes = await fetch(`${API_URL}/api/v1/map-export?${kitQs}`, { headers: apiHeaders() });
1625
+ const kit = (await kitRes.json());
1626
+ for (const [rel, content] of Object.entries(kit.files ?? {})) {
1627
+ const fp = join(out_dir, rel);
1628
+ mkdirSync(dirname(fp), { recursive: true });
1629
+ writeFileSync(fp, content);
1630
+ wrote.push(fp);
1631
+ }
1632
+ for (const [rel, b64] of Object.entries(kit.binaries ?? {})) {
1633
+ const fp = join(out_dir, rel);
1634
+ mkdirSync(dirname(fp), { recursive: true });
1635
+ writeFileSync(fp, Buffer.from(b64, 'base64'));
1636
+ wrote.push(fp);
1637
+ }
1638
+ mkdirSync(join(out_dir, 'assets'), { recursive: true });
1639
+ const gp = join(out_dir, 'assets', 'dust2.glb');
1640
+ writeFileSync(gp, buf);
1641
+ wrote.push(gp);
1642
+ }
1643
+ else {
1644
+ const gp = join(out_dir, 'dust2-map.glb');
1645
+ writeFileSync(gp, buf);
1646
+ wrote.push(gp);
1647
+ }
1648
+ }
1649
+ return { content: [{ type: 'text', text: JSON.stringify({ bytes: buf.length, wrote, run: godot_project && out_dir ? `godot --path ${out_dir} --import --headless && godot --path ${out_dir}` : undefined, meshes: res.headers.get('x-gripforge-meshes') }, null, 2) }] };
1650
+ });
1651
+ server.tool('gripforge_map_look', 'Reproduce a Dust II still without copying pixels: camera + volume/prop/wire ids. shot=shot21a (A), shot1a (T), shot5a (cat). LINK only, never Meshy image-to-3d. Pair map_plan. 0 credits.', {
1652
+ prompt: z.string().min(2).max(240).describe('e.g. dust2'),
1653
+ shot: z.string().min(2).max(80).optional().describe('shot21a | a-site | shot1a | catwalk'),
1654
+ }, async ({ prompt, shot }) => {
1655
+ if (!API_KEY)
1656
+ return err('GRIPFORGE_API_KEY missing.');
1657
+ const qs = new URLSearchParams({ prompt });
1658
+ if (shot)
1659
+ qs.set('shot', shot);
1660
+ const res = await fetch(`${API_URL}/api/v1/map-look?${qs}`, { headers: apiHeaders() });
1661
+ const data = await res.json().catch(() => ({}));
1662
+ if (!res.ok)
1663
+ return err(String(data.error ?? res.status));
1664
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1665
+ });
1666
+ server.tool('gripforge_kit', 'Read and compose reusable game engines. list/get return manifests and GameRules; create/patch persist rules and asset slots in the authenticated workspace. FPS rules are shared by Godot and web. Existing vehicle kits remain supported.', { action: z.enum(['list', 'get', 'create', 'patch']), id: z.string().max(80).optional(), style: z.string().max(64).optional(), name: z.string().max(160).optional(), rules: z.record(z.string(), z.unknown()).nullable().optional(), slots: z.array(z.object({ id: z.string().max(40), itemId: z.string().max(64).nullable() })).optional(), prompts: z.record(z.string(), z.string().max(4000)).optional() }, async (args) => {
1667
+ if (!API_KEY)
1668
+ return err('GRIPFORGE_API_KEY missing.');
1669
+ if ((args.action === 'get' || args.action === 'patch') && !args.id)
1670
+ return err('id required');
1671
+ const endpoint = args.action === 'list' ? '/api/v1/library?kind=kit&limit=100' : args.action === 'create' ? '/api/v1/library/kit' : `/api/v1/library/${encodeURIComponent(args.id)}/kit`;
1672
+ const method = args.action === 'create' ? 'POST' : args.action === 'patch' ? 'PATCH' : 'GET';
1673
+ const body = args.action === 'create' ? { style: args.style ?? 'custom', name: args.name, rules: args.rules, slots: args.slots, prompts: args.prompts } : args;
1674
+ const response = await fetch(API_URL + endpoint, { method, headers: { ...apiHeaders(), 'content-type': 'application/json' }, ...(method !== 'GET' ? { body: JSON.stringify(body) } : {}) });
1675
+ const data = await response.json();
1676
+ if (!response.ok)
1677
+ return err(String(data.error ?? response.status));
1678
+ const result = args.action === 'list' ? { kits: (data.items ?? []).map((o) => ({ id: o.id, name: o.name, style: o.meta?.style, engine: o.meta?.engine ?? null, rules: o.meta?.rulesSummary ?? null, slotsFilled: o.meta?.slotsFilled, slotsTotal: o.meta?.slotsTotal })) } : data;
1679
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result };
1680
+ });
1681
+ server.tool('gripforge_fps_kit', 'Reusable tactical FPS engine for web and Godot. Get resolved rules/map, validate asset dependencies or export runtime files. Same weapon, animation, grenade, bot and bomb rules in both targets. Offline bots; no network claim. Persist edits with gripforge_kit. 0 credits.', { action: z.enum(['get', 'validate', 'export']).default('get'), target: z.enum(['web', 'godot', 'both']).default('both'), kit_id: z.string().optional(), rules: z.record(z.string(), z.unknown()).optional(), map: z.record(z.string(), z.unknown()).optional(), asset_slots: z.array(z.string()).optional() }, async (args) => {
1682
+ if (!API_KEY)
1683
+ return err('GRIPFORGE_API_KEY missing.');
1684
+ const res = await fetch(`${API_URL}/api/v1/fps-kit`, { method: 'POST', headers: { ...apiHeaders(), 'content-type': 'application/json' }, body: JSON.stringify(args) });
1685
+ const data = await res.json();
1686
+ if (!res.ok)
1687
+ return err(String(data.error ?? res.status));
1688
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1689
+ });
1690
+ server.tool('gripforge_viewmodel_kit', 'CS-style FPS viewmodel: arms + gun under Camera3D/Hold. Write viewmodel.json + gf_viewmodel.gd. 0 credits.', { prompt: z.string().min(2).max(240).describe('e.g. fps or counter-strike dust2') }, async ({ prompt }) => {
1691
+ if (!API_KEY)
1692
+ return err('GRIPFORGE_API_KEY missing.');
1693
+ const res = await fetch(`${API_URL}/api/v1/viewmodel-kit?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
1694
+ const data = await res.json().catch(() => ({}));
1695
+ if (!res.ok)
1696
+ return err(String(data.error ?? res.status));
1697
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1698
+ });
1699
+ server.tool('gripforge_screen_concept', 'Rebuild a UI screen from a concept image: vision pass returns every button/panel/label with a normalised rect, colours and action; gf_screen.gd renders it as real Godot controls at any resolution. Saved in the Library (kind concept, category ui). 1 credit.', {
1700
+ image_path: z.string().optional().describe('local PNG/JPG of the mockup'),
1701
+ image_id: z.string().optional().describe('or a Library id (lib_...)'),
1702
+ name: z.string().optional().describe('slug for the screen (main_menu, lobby, hud)'),
1703
+ out_dir: z.string().optional().describe('write screen json + gf_screen.gd here'),
1704
+ }, async ({ image_path, image_id, name, out_dir }) => {
1705
+ if (!API_KEY)
1706
+ return err('GRIPFORGE_API_KEY missing.');
1707
+ const payload = {};
1708
+ if (image_path)
1709
+ payload.image_base64 = (await readFile(image_path)).toString('base64');
1710
+ else if (image_id)
1711
+ payload.image = image_id;
1712
+ else
1713
+ return err('image_path or image_id required');
1714
+ if (name)
1715
+ payload.name = name;
1716
+ const res = await fetch(`${API_URL}/api/v1/screen-concept`, {
1717
+ method: 'POST',
1718
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
1719
+ body: JSON.stringify(payload),
1720
+ });
1721
+ const data = (await res.json().catch(() => ({})));
1722
+ if (!res.ok)
1723
+ return err(String(data.error ?? res.status));
1724
+ const wrote = [];
1725
+ if (out_dir) {
1726
+ for (const [rel, content] of Object.entries(data.files ?? {})) {
1727
+ const fp = join(out_dir, rel);
1728
+ await mkdir(dirname(fp), { recursive: true });
1729
+ await writeFile(fp, content);
1730
+ wrote.push(fp);
1731
+ }
1732
+ }
1733
+ return { content: [{ type: 'text', text: JSON.stringify({ ...data, files: undefined, wrote }, null, 2) }] };
1734
+ });
1735
+ server.tool('gripforge_screen_assets', 'Cut the sprites of an analysed screen out of its own concept image (buttons, panels, icons, background) with 9-slice margins, and write them next to the layout. Pixel-identical to the concept, 0 credits.', {
1736
+ item: z.string().describe('Library id of a screen analysed by gripforge_screen_concept'),
1737
+ out_dir: z.string().describe('project root: files land under gripforge/screens/<id>/'),
1738
+ }, async ({ item, out_dir }) => {
1739
+ if (!API_KEY)
1740
+ return err('GRIPFORGE_API_KEY missing.');
1741
+ const res = await fetch(`${API_URL}/api/v1/screen-assets`, {
1742
+ method: 'POST',
1743
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
1744
+ body: JSON.stringify({ item }),
1745
+ });
1746
+ const data = (await res.json().catch(() => ({})));
1747
+ if (!res.ok)
1748
+ return err(String(data.error ?? res.status));
1749
+ const wrote = [];
1750
+ for (const [rel, b64] of Object.entries(data.files ?? {})) {
1751
+ const fp = join(out_dir, rel);
1752
+ await mkdir(dirname(fp), { recursive: true });
1753
+ await writeFile(fp, Buffer.from(b64, 'base64'));
1754
+ wrote.push(fp);
1755
+ }
1756
+ if (data.layout?.id) {
1757
+ const lp = join(out_dir, `gripforge/screens/${data.layout.id}.json`);
1758
+ await mkdir(dirname(lp), { recursive: true });
1759
+ await writeFile(lp, JSON.stringify(data.layout, null, 2));
1760
+ wrote.push(lp);
1761
+ }
1762
+ return { content: [{ type: 'text', text: JSON.stringify({ ...data, files: undefined, layout: undefined, wrote: wrote.length, dir: wrote[0] }, null, 2) }] };
1763
+ });
1764
+ server.tool('gripforge_screen_icons', 'Redraw the icons of an analysed screen as clean TRANSPARENT PNGs (crops carry the panel behind them and cannot be reused). Uses each crop as the reference so the symbol stays the same. 1 credit per 4 icons.', {
1765
+ item: z.string().describe('Library id of an analysed screen'),
1766
+ ids: z.array(z.string()).optional().describe('specific element ids, default every icon'),
1767
+ style: z.string().optional().describe('art direction, e.g. "crisp flat white symbol"'),
1768
+ out_dir: z.string().describe('project root: icons land under gripforge/screens/<id>/icons/'),
1769
+ }, async ({ item, ids, style, out_dir }) => {
1770
+ if (!API_KEY)
1771
+ return err('GRIPFORGE_API_KEY missing.');
1772
+ const res = await fetch(`${API_URL}/api/v1/screen-icons`, {
1773
+ method: 'POST',
1774
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
1775
+ body: JSON.stringify({ item, ids, style }),
1776
+ });
1777
+ const data = (await res.json().catch(() => ({})));
1778
+ if (!res.ok)
1779
+ return err(String(data.error ?? res.status));
1780
+ const wrote = [];
1781
+ for (const [rel, b64] of Object.entries(data.files ?? {})) {
1782
+ const fp = join(out_dir, rel);
1783
+ await mkdir(dirname(fp), { recursive: true });
1784
+ await writeFile(fp, Buffer.from(b64, 'base64'));
1785
+ wrote.push(fp);
1786
+ }
1787
+ if (data.layout?.id) {
1788
+ const lp = join(out_dir, `gripforge/screens/${data.layout.id}.json`);
1789
+ await writeFile(lp, JSON.stringify(data.layout, null, 2));
1790
+ wrote.push(lp);
1791
+ }
1792
+ return { content: [{ type: 'text', text: JSON.stringify({ icons: data.icons, wrote: wrote.length }, null, 2) }] };
1793
+ });
1794
+ server.tool('gripforge_screen_background', 'Clean backdrop for an analysed screen: inpaints the interface away (mask built from the layout rects) so the art no longer carries buttons and text baked in. 1 credit.', {
1795
+ item: z.string().describe('Library id of an analysed screen'),
1796
+ keep: z.array(z.string()).optional().describe('element ids to leave in the art'),
1797
+ prompt: z.string().optional().describe('extra art direction'),
1798
+ out_dir: z.string().describe('project root'),
1799
+ }, async ({ item, keep, prompt, out_dir }) => {
1800
+ if (!API_KEY)
1801
+ return err('GRIPFORGE_API_KEY missing.');
1802
+ const res = await fetch(`${API_URL}/api/v1/screen-background`, {
1803
+ method: 'POST',
1804
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
1805
+ body: JSON.stringify({ item, keep, prompt }),
1806
+ });
1807
+ const data = (await res.json().catch(() => ({})));
1808
+ if (!res.ok)
1809
+ return err(String(data.error ?? res.status));
1810
+ const wrote = [];
1811
+ for (const [rel, b64] of Object.entries(data.files ?? {})) {
1812
+ const fp = join(out_dir, rel);
1813
+ await mkdir(dirname(fp), { recursive: true });
1814
+ await writeFile(fp, Buffer.from(b64, 'base64'));
1815
+ wrote.push(fp);
1816
+ }
1817
+ if (data.layout?.id) {
1818
+ const lp = join(out_dir, `gripforge/screens/${data.layout.id}.json`);
1819
+ await writeFile(lp, JSON.stringify(data.layout, null, 2));
1820
+ wrote.push(lp);
1821
+ }
1822
+ return { content: [{ type: 'text', text: JSON.stringify({ erased: data.erased, wrote }, null, 2) }] };
1823
+ });
1824
+ server.tool('gripforge_menu_kit', 'Front-end for a multiplayer FPS: main menu (host/join/quit), lobby listing connected peers where only the host starts, and an in-game HUD (health, round timer, score, weapon, kill feed) fed by replicated state. Pairs with gripforge_net_kit. 0 credits.', { prompt: z.string().min(2).max(240).describe('e.g. counter-strike, minimal') }, async ({ prompt }) => {
1825
+ if (!API_KEY)
1826
+ return err('GRIPFORGE_API_KEY missing.');
1827
+ const res = await fetch(`${API_URL}/api/v1/menu-kit?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
1828
+ const data = await res.json().catch(() => ({}));
1829
+ if (!res.ok)
1830
+ return err(String(data.error ?? res.status));
1831
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1832
+ });
1833
+ server.tool('gripforge_bot_kit', 'AI opponents for the Godot FPS: navmesh bots (patrol/hunt/shoot, reaction time + aim error, respawn) driven by one autoload; they reuse the player scene under server authority so spawner/synchronizer/net-kit damage apply unchanged. 0 credits.', { prompt: z.string().min(2).max(240).describe('difficulty: easy, normal, hard') }, async ({ prompt }) => {
1834
+ if (!API_KEY)
1835
+ return err('GRIPFORGE_API_KEY missing.');
1836
+ const res = await fetch(`${API_URL}/api/v1/bot-kit?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
1837
+ const data = await res.json().catch(() => ({}));
1838
+ if (!res.ok)
1839
+ return err(String(data.error ?? res.status));
1840
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1841
+ });
1842
+ server.tool('gripforge_build', 'Build the Godot game and register the version in the workspace: smoke test (GF_SMOKE=1, script errors fail the build), headless export for the preset (macos | windows | linux | web; presets written into export_presets.cfg when missing), zip, upload as a Library "build" (kind kit, meta.tool=build: game, version, preset, commit, engine, size, smoke) and return the download URL. Needs Godot installed locally (GODOT_BIN or /Applications/Godot.app) and the export templates for the platform. 0 credits.', {
1843
+ project: z.string().describe('Godot project directory (contains project.godot)'),
1844
+ preset: z.enum(['macos', 'windows', 'linux', 'web']).default('macos'),
1845
+ version: z.string().optional().describe('e.g. 0.3.0 — default: project.godot config/version, else git describe, else date'),
1846
+ changelog: z.string().optional(),
1847
+ smoke: z.boolean().default(true).describe('run the GF_SMOKE=1 launch before exporting'),
1848
+ upload: z.boolean().default(true).describe('push the zip to the workspace'),
1849
+ }, async ({ project, preset, version, changelog, smoke, upload }) => {
1850
+ const fs = await import('node:fs/promises');
1851
+ const path = await import('node:path');
1852
+ const os = await import('node:os');
1853
+ const { execFile } = await import('node:child_process');
1854
+ const run = (cmd, args, opts = {}) => new Promise((resolve) => {
1855
+ const child = execFile(cmd, args, { cwd: opts.cwd, env: { ...process.env, ...(opts.env ?? {}) }, maxBuffer: 64 * 1024 * 1024, timeout: opts.timeoutMs ?? 600000 }, (err, stdout, stderr) => {
1856
+ resolve({ code: err && typeof err.code === 'number' ? Number(err.code) : err ? 1 : 0, out: `${stdout}\n${stderr}` });
1857
+ });
1858
+ void child;
1859
+ });
1860
+ const notes = [];
1861
+ const proj = path.resolve(project);
1862
+ const pg = path.join(proj, 'project.godot');
1863
+ let pgText = '';
1864
+ try {
1865
+ pgText = await fs.readFile(pg, 'utf8');
1866
+ }
1867
+ catch {
1868
+ return err(`no project.godot in ${proj}`);
1869
+ }
1870
+ const game = (pgText.match(/config\/name="([^"]+)"/)?.[1] ?? path.basename(proj)).trim();
1871
+ // godot binary
1872
+ let godot = process.env.GODOT_BIN ?? '';
1873
+ if (!godot) {
1874
+ for (const c of ['/Applications/Godot.app/Contents/MacOS/Godot', '/Applications/Godot_mono.app/Contents/MacOS/Godot', '/usr/local/bin/godot', '/usr/bin/godot']) {
1875
+ try {
1876
+ await fs.access(c);
1877
+ godot = c;
1878
+ break;
1879
+ }
1880
+ catch { /* next */ }
1881
+ }
1882
+ }
1883
+ if (!godot)
1884
+ return err('Godot not found: set GODOT_BIN');
1885
+ const ver = (await run(godot, ['--version'])).out.trim().split('\n').pop() ?? '';
1886
+ const verKey = ver.match(/^(\d+\.\d+(?:\.\d+)?)\.(\w+)/) ? `${ver.match(/^(\d+\.\d+(?:\.\d+)?)\.(\w+)/)[1]}.${ver.match(/^(\d+\.\d+(?:\.\d+)?)\.(\w+)/)[2]}` : ver;
1887
+ notes.push(`godot ${ver} (${godot})`);
1888
+ // export templates
1889
+ const tplDir = process.platform === 'darwin'
1890
+ ? path.join(os.homedir(), 'Library/Application Support/Godot/export_templates', verKey)
1891
+ : path.join(os.homedir(), '.local/share/godot/export_templates', verKey);
1892
+ const need = { macos: ['macos.zip'], windows: ['windows_release_x86_64.exe'], linux: ['linux_release.x86_64'], web: ['web_release.zip', 'web_nothreads_release.zip'] };
1893
+ let have = [];
1894
+ try {
1895
+ have = await fs.readdir(tplDir);
1896
+ }
1897
+ catch { /* none */ }
1898
+ const ok = need[preset].some((f) => have.includes(f));
1899
+ if (!ok) {
1900
+ return err(`export templates for ${preset} missing in ${tplDir} (have: ${have.join(', ') || 'none'}). Install: Godot → Editor → Manage Export Templates → Download, or unzip Godot_v${verKey.replace('.stable', '-stable')}_export_templates.tpz (templates/*) into that folder.`);
1901
+ }
1902
+ // version
1903
+ let v = version ?? (pgText.match(/config\/version="([^"]+)"/)?.[1] ?? '');
1904
+ if (!v) {
1905
+ const g = await run('git', ['describe', '--tags', '--always'], { cwd: proj });
1906
+ v = g.code === 0 ? g.out.trim() : '';
1907
+ }
1908
+ if (!v)
1909
+ v = new Date().toISOString().slice(0, 10).replace(/-/g, '.');
1910
+ const commit = (await run('git', ['rev-parse', '--short', 'HEAD'], { cwd: proj })).out.trim() || null;
1911
+ // presets
1912
+ const presetName = `gf-${preset}`;
1913
+ const presetsPath = path.join(proj, 'export_presets.cfg');
1914
+ let presets = '';
1915
+ try {
1916
+ presets = await fs.readFile(presetsPath, 'utf8');
1917
+ }
1918
+ catch { /* new */ }
1919
+ const outDir = path.join(proj, 'builds', v, preset);
1920
+ await fs.mkdir(outDir, { recursive: true });
1921
+ const outFile = { macos: `${game.replace(/[^\w.-]+/g, '_')}.zip`, windows: `${game.replace(/[^\w.-]+/g, '_')}.exe`, linux: `${game.replace(/[^\w.-]+/g, '_')}.x86_64`, web: 'index.html' }[preset];
1922
+ if (!presets.includes(`name="${presetName}"`)) {
1923
+ const idx = (presets.match(/\[preset\.\d+\]/g) ?? []).length;
1924
+ const platform = { macos: 'macOS', windows: 'Windows Desktop', linux: 'Linux', web: 'Web' }[preset];
1925
+ const useNoThreads = preset === 'web' && !have.includes('web_release.zip');
1926
+ const opts = preset === 'web'
1927
+ ? `variant/thread_support=${useNoThreads ? 'false' : 'true'}\nhtml/export_icon=true\nprogressive_web_app/enabled=false\n`
1928
+ : preset === 'macos'
1929
+ ? `application/bundle_identifier="ai.gripforge.${game.replace(/[^a-z0-9]+/gi, '').toLowerCase()}"\ncodesign/codesign=0\nnotarization/notarization=0\napplication/export_format=0\n`
1930
+ : preset === 'windows'
1931
+ ? `binary_format/embed_pck=true\napplication/modify_resources=false\n`
1932
+ : `binary_format/embed_pck=true\n`;
1933
+ presets += `${presets.trim() ? '\n' : ''}[preset.${idx}]\n\nname="${presetName}"\nplatform="${platform}"\nruntime/platform="${platform}"\ncustom_features=""\nexport_filter="all_resources"\ninclude_filter=""\nexclude_filter="builds/*"\nexport_path="builds/${v}/${preset}/${outFile}"\npatches=PackedStringArray()\nencryption_include_filters=""\nencryption_exclude_filters=""\nseed=0\nencrypt_pck=false\nencrypt_directory=false\nscript_export_mode=2\n\n[preset.${idx}.options]\n\n${opts}`;
1934
+ await fs.writeFile(presetsPath, presets);
1935
+ notes.push(`export preset "${presetName}" written to export_presets.cfg`);
1936
+ }
1937
+ // smoke
1938
+ let smokeVerdict = 'skipped';
1939
+ if (smoke) {
1940
+ const s = await run(godot, ['--path', proj, '--resolution', '960x600', '--position', '40,40', '--always-on-top'], { env: { GF_SMOKE: '1' }, timeoutMs: 120000 });
1941
+ const errors = (s.out.match(/SCRIPT ERROR:[^\n]*/g) ?? []).slice(0, 5);
1942
+ const saved = /SMOKE saved/.test(s.out);
1943
+ smokeVerdict = errors.length ? `failed: ${errors.join(' | ')}` : saved ? 'ok' : `no screenshot (exit ${s.code})`;
1944
+ notes.push(`smoke: ${smokeVerdict}`);
1945
+ if (errors.length)
1946
+ return err(`smoke test failed, build refused — ${errors.join(' | ')}`);
1947
+ }
1948
+ // export
1949
+ const target = path.join(outDir, outFile);
1950
+ const ex = await run(godot, ['--headless', '--path', proj, '--export-release', presetName, target], { timeoutMs: 900000 });
1951
+ const exErrors = (ex.out.match(/ERROR:[^\n]*/g) ?? []).filter((l) => !/nav_mesh|navigation|Image format/i.test(l)).slice(0, 5);
1952
+ let exists = false;
1953
+ try {
1954
+ await fs.access(target);
1955
+ exists = true;
1956
+ }
1957
+ catch { /* missing */ }
1958
+ if (!exists)
1959
+ return err(`export failed (exit ${ex.code}): ${exErrors.join(' | ') || ex.out.slice(-600)}`);
1960
+ notes.push(`exported ${presetName} → ${target}${exErrors.length ? ` (warnings: ${exErrors.length})` : ''}`);
1961
+ // zip
1962
+ const zipName = `${game.replace(/[^\w.-]+/g, '_')}-${v}-${preset}.zip`;
1963
+ const zipPath = path.join(proj, 'builds', zipName);
1964
+ try {
1965
+ await fs.unlink(zipPath);
1966
+ }
1967
+ catch { /* fresh */ }
1968
+ const z = await run('zip', ['-qr', zipPath, '.'], { cwd: outDir, timeoutMs: 600000 });
1969
+ if (z.code !== 0)
1970
+ return err(`zip failed: ${z.out.slice(-300)}`);
1971
+ const bytes = (await fs.stat(zipPath)).size;
1972
+ notes.push(`zip ${zipName} (${(bytes / 1048576).toFixed(1)} MB)`);
1973
+ // upload
1974
+ let item = null;
1975
+ if (upload) {
1976
+ if (!API_KEY)
1977
+ return err('GRIPFORGE_API_KEY missing (build kept locally at ' + zipPath + ')');
1978
+ const form = new FormData();
1979
+ form.append('file', new Blob([await fs.readFile(zipPath)]), zipName);
1980
+ form.append('kind', 'kit');
1981
+ form.append('name', `${game} v${v} · ${preset}`);
1982
+ form.append('meta', JSON.stringify({ tool: 'build', game, version: v, preset, platform: preset, commit, engine: ver, bytes, smoke: smokeVerdict, changelog: changelog ?? null, built_at: new Date().toISOString(), tags: ['build', preset] }));
1983
+ const res = await fetch(`${API_URL}/api/v1/library`, { method: 'POST', headers: apiHeaders(), body: form });
1984
+ const data = (await res.json().catch(() => ({})));
1985
+ if (!res.ok)
1986
+ return err(`upload failed: ${String(data.error ?? res.status)} (build kept locally at ${zipPath})`);
1987
+ item = data.item ?? data;
1988
+ notes.push('registered in the workspace');
1989
+ }
1990
+ return { content: [{ type: 'text', text: JSON.stringify({ game, version: v, preset, commit, engine: ver, smoke: smokeVerdict, zip: zipPath, bytes, item, notes }, null, 2) }] };
1991
+ });
1992
+ server.tool('gripforge_gunfx_kit', 'Gun feedback for the Godot FPS: muzzle flash, tracer, impact sparks + bullet-hole decal, blood spray (+ wall splat). One procedural autoload GfGunfx (no textures) fired by the net kit through a shot_fx RPC after the server raycast, misses included. Presets counter-strike / arcade / subtle. 0 credits.', {
1993
+ prompt: z.string().min(2).max(240).describe('e.g. counter-strike, arcade, subtle tactical'),
1994
+ out_dir: z.string().optional().describe('Godot project root: writes scripts/gf_gunfx.gd, gripforge/gunfx.json and the CC0 sprites into gripforge/gunfx/ (+ LICENSES.txt)'),
1995
+ }, async ({ prompt, out_dir }) => {
1996
+ if (!API_KEY)
1997
+ return err('GRIPFORGE_API_KEY missing.');
1998
+ const res = await fetch(`${API_URL}/api/v1/gunfx-kit?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
1999
+ const data = (await res.json().catch(() => ({})));
2000
+ if (!res.ok)
2001
+ return err(String(data.error ?? res.status));
2002
+ const wrote = [];
2003
+ if (out_dir) {
2004
+ const fs = await import('node:fs/promises');
2005
+ const path = await import('node:path');
2006
+ await fs.mkdir(path.join(out_dir, 'scripts'), { recursive: true });
2007
+ await fs.mkdir(path.join(out_dir, 'gripforge', 'gunfx'), { recursive: true });
2008
+ await fs.writeFile(path.join(out_dir, 'scripts', 'gf_gunfx.gd'), data.snippet_godot ?? '');
2009
+ await fs.writeFile(path.join(out_dir, 'gripforge', 'gunfx.json'), data.json ?? '{}');
2010
+ wrote.push('scripts/gf_gunfx.gd', 'gripforge/gunfx.json');
2011
+ for (const sp of data.sprites ?? []) {
2012
+ const r = await fetch(sp.url);
2013
+ if (!r.ok)
2014
+ continue;
2015
+ await fs.writeFile(path.join(out_dir, 'gripforge', 'gunfx', `${sp.name}.png`), Buffer.from(await r.arrayBuffer()));
2016
+ wrote.push(`gripforge/gunfx/${sp.name}.png`);
2017
+ }
2018
+ if (data.licenses_url) {
2019
+ const r = await fetch(data.licenses_url);
2020
+ if (r.ok) {
2021
+ await fs.writeFile(path.join(out_dir, 'gripforge', 'gunfx', 'LICENSES.txt'), await r.text());
2022
+ wrote.push('gripforge/gunfx/LICENSES.txt');
2023
+ }
2024
+ }
2025
+ }
2026
+ return { content: [{ type: 'text', text: JSON.stringify({ ...data, snippet_godot: out_dir ? '(written)' : data.snippet_godot, wrote }, null, 2) }] };
2027
+ });
2028
+ server.tool('gripforge_match_kit', 'Listen-server match contract: party size, teams, maps, ranked flag, join-by-IP. Write match.json + autoload GfMatch on top of GfNet. GripForge does not host a queue. 0 credits.', { prompt: z.string().min(2).max(240).describe('e.g. counter-strike, deathmatch, ranked 5v5') }, async ({ prompt }) => {
2029
+ if (!API_KEY)
2030
+ return err('GRIPFORGE_API_KEY missing.');
2031
+ const res = await fetch(`${API_URL}/api/v1/match-kit?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
2032
+ const data = await res.json().catch(() => ({}));
2033
+ if (!res.ok)
2034
+ return err(String(data.error ?? res.status));
2035
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
2036
+ });
2037
+ server.tool('gripforge_net_kit', 'Godot 4 multiplayer (ENet): GfNet autoload host/join/spawn with server-side hit validation, gf_round.gd for the round + bomb, MultiplayerSpawner/Synchronizer wiring. 0 credits.', { prompt: z.string().min(2).max(240).describe('e.g. counter-strike, deathmatch, fps') }, async ({ prompt }) => {
2038
+ if (!API_KEY)
2039
+ return err('GRIPFORGE_API_KEY missing.');
2040
+ const res = await fetch(`${API_URL}/api/v1/net-kit?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
2041
+ const data = await res.json().catch(() => ({}));
2042
+ if (!res.ok)
2043
+ return err(String(data.error ?? res.status));
2044
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
2045
+ });
2046
+ server.tool('gripforge_input_kit', 'InputMap presets: fps (WASD + arrows on move_*) or kart (accelerate/brake/steer + standard gamepad RT/LT/stick, same indices as the web Gamepad API). Write gripforge/input.json, autoload GfInput. Optional third_person=true adds portable character-facing JavaScript and a Three.js adapter; orientation only, not a physics controller. 0 credits.', {
2047
+ prompt: z.string().min(2).max(240).describe('e.g. fps or counter-strike dust2'),
2048
+ third_person: z.boolean().optional().describe('Include character-facing JavaScript and Three.js usage (default false; existing input map unchanged).'),
2049
+ }, async ({ prompt, third_person }) => {
2050
+ if (!API_KEY)
2051
+ return err('GRIPFORGE_API_KEY missing.');
2052
+ const qs = new URLSearchParams({ prompt });
2053
+ if (third_person)
2054
+ qs.set('third_person', '1');
2055
+ const res = await fetch(`${API_URL}/api/v1/input-kit?${qs}`, { headers: apiHeaders() });
2056
+ const data = await res.json().catch(() => ({}));
2057
+ if (!res.ok)
2058
+ return err(String(data.error ?? res.status));
2059
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
2060
+ });
2061
+ server.tool('gripforge_light_kit', 'Moon / sun / candle lights + fog + Three.js snippet for a game look. 0 credits. Pair with scene_kit.', { prompt: z.string().min(2).max(240).describe('e.g. devil may cry like') }, async ({ prompt }) => {
2062
+ if (!API_KEY)
2063
+ return err('GRIPFORGE_API_KEY missing.');
2064
+ const res = await fetch(`${API_URL}/api/v1/light-kit?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
2065
+ const data = await res.json().catch(() => ({}));
2066
+ if (!res.ok)
2067
+ return err(String(data.error ?? res.status));
2068
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
2069
+ });
2070
+ server.tool('gripforge_font', 'Google-font CSS + exact HUD copy (DMC: Cinzel D–SSS, kart: Teko, CS: Barlow Condensed). Put letters in HTML — do not rasterize. 0 credits.', { prompt: z.string().min(2).max(240).describe('e.g. devil may cry like') }, async ({ prompt }) => {
2071
+ if (!API_KEY)
2072
+ return err('GRIPFORGE_API_KEY missing.');
2073
+ const res = await fetch(`${API_URL}/api/v1/font-kit?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
2074
+ const data = await res.json().catch(() => ({}));
2075
+ if (!res.ok)
2076
+ return err(String(data.error ?? res.status));
2077
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
2078
+ });
2079
+ server.tool('gripforge_loading_page', 'Loading overlay for Three/Godot/Unity/Unreal. Title is text. Overlay 0 credits. Pass character_id to generate a 16:9 cinematic still of that Library character (1 credit, no letters in the image).', {
2080
+ prompt: z.string().min(2).max(240).describe('e.g. devil may cry like'),
2081
+ character_id: z.string().optional().describe('lib_… character/enemy/bind — generate background still'),
2082
+ }, async ({ prompt, character_id }) => {
2083
+ if (!API_KEY)
2084
+ return err('GRIPFORGE_API_KEY missing.');
2085
+ if (character_id) {
2086
+ const res = await fetch(`${API_URL}/api/v1/loading-page`, {
2087
+ method: 'POST',
2088
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
2089
+ body: JSON.stringify({ prompt, character_id }),
2090
+ });
2091
+ const data = await res.json().catch(() => ({}));
2092
+ if (!res.ok)
2093
+ return err(String(data.error ?? res.status));
2094
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
2095
+ }
2096
+ const res = await fetch(`${API_URL}/api/v1/loading-page?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
2097
+ const data = await res.json().catch(() => ({}));
2098
+ if (!res.ok)
2099
+ return err(String(data.error ?? res.status));
2100
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
2101
+ });
2102
+ server.tool('gripforge_navmesh', 'Walkable XZ AABB + pillar holes for a scene_kit room. JSON clamp, not a baked navmesh. 0 credits.', { prompt: z.string().min(2).max(240).describe('e.g. devil may cry like') }, async ({ prompt }) => {
2103
+ if (!API_KEY)
2104
+ return err('GRIPFORGE_API_KEY missing.');
2105
+ const res = await fetch(`${API_URL}/api/v1/navmesh?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
2106
+ const data = await res.json().catch(() => ({}));
2107
+ if (!res.ok)
2108
+ return err(String(data.error ?? res.status));
2109
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
2110
+ });
2111
+ server.tool('gripforge_vfx_generate', 'ADMIN ONLY. Text-to-VFX or image-to-VFX: interpret a prompt or reference into an editable animated effect specification. No Library save or credit charge. Pass the resulting type, style, look, color, duration, width, name, emitters and generation to gripforge_vfx to save/export (1 credit). Image mode requires a configured vision provider.', { preset: z.enum(['slash-trail', 'slash-emerald', 'slash-steel', 'slash-fire', 'slash-ice', 'slash-nature', 'arc-pulse', 'fire-breath', 'neon-impact']).optional().describe('Load a ready-to-play preset instantly, without AI. Supply this field alone.'),
2112
+ prompt: z.string().max(500).optional().describe('Effect description; optional with an image.'),
2113
+ visual_style: z.enum(['realistic', 'stylized', 'anime', 'lowpoly']).optional(),
2114
+ image_data: z.string().max(12 * 1024 * 1024).optional().describe('PNG/JPEG/WebP base64 data URI, up to 8 MB decoded.'),
2115
+ image_id: z.string().optional().describe('Owned Library image id; use instead of image_data.'), }, async (args) => {
2116
+ if (!API_KEY)
2117
+ return err('GRIPFORGE_API_KEY missing.');
2118
+ const res = await fetch(`${API_URL}/api/v1/vfx/prompt`, {
2119
+ method: 'POST', headers: { ...apiHeaders(), 'content-type': 'application/json' }, body: JSON.stringify(args), signal: AbortSignal.timeout(55_000),
2120
+ });
2121
+ const data = await res.json().catch(() => ({}));
2122
+ if (!res.ok)
2123
+ return err(String(data.error ?? res.status));
2124
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
2125
+ });
2126
+ server.tool('gripforge_vfx', 'ADMIN ONLY. Generate + export 3D slash/thrust/spin VFX on an existing Library bind, or a world-space fire meteor (head + trail + impact). Pass prompt to fill type/style/color/emitters. Returns studio_url to edit the saved effect in GripForge, workspace_id, GLB, PNG, vfx.json + snippets. Refused for non-admin keys.', {
2127
+ name: z.string().max(80).optional(),
2128
+ emitters: z.array(z.record(z.string(), z.unknown())).max(6).optional().describe('Emitters returned by gripforge_vfx_generate.'),
2129
+ generation: z.record(z.string(), z.unknown()).optional().describe('Generation provenance returned by gripforge_vfx_generate.'),
2130
+ attach_id: z.string().optional().describe('Library bind id (lib_…). Not required for meteor.'),
2131
+ prompt: z.string().optional().describe('Natural-language VFX prompt. Fills type/style/color/emitters when omitted.'),
2132
+ type: z.enum(['slash', 'thrust', 'spin', 'meteor', 'beam', 'swing', 'claw', 'charge', 'shield', 'portal', 'slam']).optional().describe('Strike type (default slash). portal = standing blood rift / demonic seal (world-space, loops); slam = ground-impact eruption: light pillar + fireball + sparks/debris (world-space, one-shot)'),
2133
+ style: z.enum(['energy', 'metal', 'magic']).optional().describe('Look (default energy)'),
2134
+ look: z.enum(['toon', 'solid', 'crystal', 'flame', 'pulse', 'firejet', 'impact']).optional().describe('toon: stylized ribbon/lightning; solid: filled beam; crystal: faceted weapon trail; flame: burning crescent; pulse: released electric orb; firejet: flared fire stream with torn tongues and pressure rings; impact: slam-family comic explosion with red telegraph, violet core, ink debris and optional impact frames'),
2135
+ color: z.string().optional().describe('Hex color'),
2136
+ duration: z.number().optional().describe('Seconds (default 0.25)'),
2137
+ width: z.number().optional().describe('Ribbon width in metres'),
2138
+ core: z.number().min(0.02).max(0.4).optional().describe('portal only: shadow-core radius as a fraction of the portal (default 0.09)'),
2139
+ out_dir: z.string().optional().describe('Write vfx.glb, additive.png, vfx.json here'),
2140
+ }, async ({ name, emitters, generation, attach_id, prompt, type, style, look, color, duration, width, core, out_dir }) => {
2141
+ if (!API_KEY)
2142
+ return err('GRIPFORGE_API_KEY missing.');
2143
+ const res = await fetch(`${API_URL}/api/v1/vfx`, {
2144
+ method: 'POST',
2145
+ headers: { 'x-api-key': API_KEY, 'x-gripforge-client': 'mcp', 'content-type': 'application/json' },
2146
+ body: JSON.stringify({
2147
+ name, emitters, generation,
2148
+ attach_id,
2149
+ prompt,
775
2150
  type,
776
2151
  style,
777
2152
  look,
778
2153
  color,
779
2154
  duration,
780
2155
  width,
2156
+ core,
781
2157
  }),
782
2158
  });
783
2159
  const data = (await res.json().catch(() => ({})));
@@ -917,5 +2293,61 @@ server.tool('gripforge_shader_pull', 'Pull a catalog shader into the open repo a
917
2293
  ],
918
2294
  };
919
2295
  });
2296
+ // Kept in sync with apps/web/src/lib/performance.ts; this package ships alone.
2297
+ const performanceMs = z.number().finite().min(0).max(60_000);
2298
+ const performanceSampleSchema = z.object({
2299
+ fps: z.number().finite().min(0).max(1000).optional(),
2300
+ frameMs: performanceMs.optional(),
2301
+ p95Ms: performanceMs.optional(),
2302
+ pixelRatio: z.number().finite().min(.01).max(8).optional(),
2303
+ quality: z.enum(['auto', 'high', 'performance']).optional(),
2304
+ level: z.number().int().min(0).max(10).optional(),
2305
+ cpuMs: performanceMs.optional(),
2306
+ submitMs: performanceMs.optional(),
2307
+ gpuMs: performanceMs.nullable().optional(),
2308
+ drawCalls: z.number().int().min(0).max(1_000_000).optional(),
2309
+ triangles: z.number().int().min(0).max(1_000_000_000).optional(),
2310
+ networkMs: performanceMs.nullable().optional(),
2311
+ snapshotAgeMs: performanceMs.optional(),
2312
+ }).refine(sample => sample.fps !== undefined || sample.frameMs !== undefined, 'Each sample needs fps or frameMs.');
2313
+ server.tool('gripforge_performance', 'Analyze measured game performance: FPS, frame times, CPU/submission work, draw calls and network latency. ' +
2314
+ 'Pass a numeric report to analyze it without saving, or omit report to retrieve the latest explicitly shared capture in your workspace. ' +
2315
+ 'Optional kit_id filters the capture. Returns measured findings and limitations, not a claimed optimization or GPU benchmark. 0 credits.', {
2316
+ kit_id: z.string().trim().min(1).max(128).regex(/^[A-Za-z0-9_.:-]+$/).optional().describe('Kit id for the latest workspace capture (optional).'),
2317
+ report: z.object({
2318
+ game: z.string().trim().max(80).optional(),
2319
+ version: z.string().trim().max(80).optional(),
2320
+ viewport: z.object({ width: z.number().int().min(1).max(32_768), height: z.number().int().min(1).max(32_768) }).optional(),
2321
+ samples: z.array(performanceSampleSchema).min(1).max(120),
2322
+ }).optional().describe('Measured diagnostic JSON, including 1–120 numeric samples. Only documented performance fields are sent.'),
2323
+ }, async ({ kit_id, report }) => {
2324
+ if (!API_KEY)
2325
+ return err('GRIPFORGE_API_KEY missing. Set a workspace API key to access performance diagnostics.');
2326
+ const query = kit_id ? `?${new URLSearchParams({ kit_id })}` : '';
2327
+ const body = report ? JSON.stringify({ mode: 'analyze', kitId: kit_id, report }) : undefined;
2328
+ if (body && Buffer.byteLength(body, 'utf8') > 65_536)
2329
+ return err('Performance report exceeds the 64 KiB request limit. Reduce the number of samples.');
2330
+ try {
2331
+ const response = await fetch(`${API_URL}/api/v1/performance${report ? '' : query}`, {
2332
+ method: report ? 'POST' : 'GET',
2333
+ headers: { ...apiHeaders(), ...(report ? { 'content-type': 'application/json' } : {}) },
2334
+ body,
2335
+ signal: AbortSignal.timeout(15_000),
2336
+ });
2337
+ const data = await response.json().catch(() => ({}));
2338
+ if (response.status === 401)
2339
+ return err('Invalid GRIPFORGE_API_KEY. Check your workspace API key.');
2340
+ if (response.status === 404 && !report)
2341
+ return err('No performance capture is available for this workspace or kit. Share a capture from the game, or provide a measured report.');
2342
+ if (!response.ok)
2343
+ return err(String(data.error ?? `Performance diagnostics failed (${response.status}).`));
2344
+ if (data.schema !== 'gripforge.performance.v1')
2345
+ return err('The API returned an unsupported performance report. Check that the GripForge server is up to date.');
2346
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
2347
+ }
2348
+ catch {
2349
+ return err('Performance diagnostics could not be retrieved within 15 seconds. Check the GripForge API connection and try again.');
2350
+ }
2351
+ });
920
2352
  const transport = new StdioServerTransport();
921
2353
  await server.connect(transport);