@gripforgeai/mcp 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -16,17 +16,49 @@ import { registerVfxProjectTools } from './vfx-project-tools.js';
16
16
  import { registerSceneTools } from './scene-tools.js';
17
17
  import { registerServerTools } from './server-tools.js';
18
18
  import { registerGameKitTools } from './gamekit-tools.js';
19
+ import { registerStudioToolsTools } from './studio-tools-tools.js';
20
+ import { registerJoystickTools } from './joystick-tools.js';
21
+ import { registerAbilityTools } from './ability-tools.js';
22
+ import { registerAssetProductionTools } from './asset-production-tools.js';
23
+ import { registerCreatureRigTools } from './creature-rig-tools.js';
24
+ import { registerWeaponMotionTools } from './weapon-motion-tools.js';
25
+ import { registerShieldVfxTools } from './shield-vfx-tools.js';
26
+ import { registerProjectileTools } from './projectile-tools.js';
27
+ import { registerArchitectureTools } from './architecture-tools.js';
28
+ import { registerGameConceptTools } from './game-concept-tools.js';
29
+ import { registerMotionTools } from './motion-tools.js';
30
+ import { registerHeroAbilityTools } from './hero-ability-tools.js';
31
+ import { registerGameCreationTools } from './game-creation-tools.js';
19
32
  import { registerGameKitLocalTools } from './gamekit-deliver-local.js';
33
+ import { registerMapUnrealLocalTools } from './map-unreal-local.js';
34
+ import { registerMapUnrealImportLocalTools } from './map-unreal-import-local.js';
35
+ import { registerMapUnrealExportLocalTools } from './map-unreal-export-local.js';
20
36
  const API_URL = process.env.GRIPFORGE_API_URL ?? 'https://gripforge.ai';
21
37
  const API_KEY = process.env.GRIPFORGE_API_KEY;
22
- const MCP_SELF = '0.1.9';
38
+ const MCP_SELF = '0.1.11';
23
39
  const SUPPORTED = ['.glb', '.gltf', '.fbx', '.obj'];
24
40
  const server = new McpServer({ name: 'gripforge', version: MCP_SELF });
25
41
  registerVfxProjectTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
26
42
  registerSceneTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
27
43
  registerServerTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
28
44
  registerGameKitTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
45
+ registerStudioToolsTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
29
46
  registerGameKitLocalTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY, userAgent: `gripforge-mcp/${MCP_SELF}` });
47
+ registerMapUnrealLocalTools(server.registerTool.bind(server));
48
+ registerMapUnrealImportLocalTools(server.registerTool.bind(server));
49
+ registerMapUnrealExportLocalTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
50
+ registerJoystickTools(server.registerTool.bind(server));
51
+ registerAbilityTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
52
+ registerCreatureRigTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
53
+ registerWeaponMotionTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
54
+ registerShieldVfxTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
55
+ registerProjectileTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
56
+ registerArchitectureTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
57
+ registerGameConceptTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
58
+ registerMotionTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
59
+ registerHeroAbilityTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
60
+ registerGameCreationTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
61
+ registerAssetProductionTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
30
62
  const err = (text) => ({ content: [{ type: 'text', text }], isError: true });
31
63
  server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe…) onto a rigged character. ' +
32
64
  'Finds the hand bone across naming schemes, scales to body height, seats the grip, ' +
@@ -37,7 +69,9 @@ server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe
37
69
  'GripForge puts that point in the palm and skips all grip heuristics. ' +
38
70
  'Hand closing: rigs WITH finger bones get bind.gripPose rotations (applied by the snippets); ' +
39
71
  'rigs WITHOUT finger bones (mitten hands) need export_glb: true + out_dir to receive ' +
40
- 'attached.glb with the fist baked in — the JSON bind alone cannot close a mitten hand.', {
72
+ 'attached.glb with the fist baked in — the JSON bind alone cannot close a mitten hand. ' +
73
+ 'Attachment confidence is not visual validation. Inspect bind.review and preview the exported GLB ' +
74
+ 'in the target renderer at idle and during attacks; do not call a needs_review result validated.', {
41
75
  character_path: z.string().optional().describe('Absolute path to the rigged character (.glb .gltf .fbx .obj)'),
42
76
  prop_path: z.string().optional().describe('Absolute path to the prop mesh'),
43
77
  character_id: z.string().optional().describe('Library id of a character (lib_…)'),
@@ -218,7 +252,8 @@ server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe
218
252
  JSON.stringify({ bind: data.bind, confidence: data.confidence, exports: exportsObj, wrote, glbFile, library }, null, 2) +
219
253
  (credits ? `\ncredits: ${credits.remaining}/${credits.limit} remaining (${credits.plan})` : '') +
220
254
  (glbNotes.length ? `\nglb notes:\n- ${glbNotes.join('\n- ')}` : '') +
221
- mittenHint,
255
+ mittenHint +
256
+ '\nVisual review required: verify palm contact, wrist continuity and weapon/body clearance in the target renderer. Bone parenting alone does not validate the grip.',
222
257
  },
223
258
  ],
224
259
  };
@@ -331,7 +366,7 @@ async function downloadTo(url, dest) {
331
366
  await writeFile(dest, Buffer.from(await res.arrayBuffer()));
332
367
  return true;
333
368
  }
334
- const KIND = z.enum(['character', 'fps-arms', 'enemy', 'weapon', 'prop', 'texture', 'skybox', 'loading', 'hud', 'button', 'bind', 'vfx', 'animation', 'audio']);
369
+ const KIND = z.enum(['character', 'fps-arms', 'enemy', 'weapon', 'equipment', 'prop', 'texture', 'skybox', 'loading', 'hud', 'button', 'bind', 'vfx', 'animation', 'audio']);
335
370
  const TEX_EXTS = ['.png', '.jpg', '.jpeg', '.webp'];
336
371
  server.tool('gripforge_texture_prep', 'Prep a game texture (terrain / props): optional 50% wrap + seam blend, then a faithful ' +
337
372
  'lanczos upscale (1× / 2× / 4×, capped at 1024 or 2048). The pattern is PRESERVED — ' +
@@ -409,7 +444,7 @@ server.tool('gripforge_texture_prep', 'Prep a game texture (terrain / props): op
409
444
  ],
410
445
  };
411
446
  });
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 }) => {
447
+ server.tool('gripforge_style_kit', 'Resolve "Devil May Cry like" / "open world adventure" 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 open world adventure"') }, async ({ prompt }) => {
413
448
  if (!API_KEY)
414
449
  return err('GRIPFORGE_API_KEY missing.');
415
450
  const qs = new URLSearchParams({ limit: '200' }); // style= does the matching; q= would also require the prompt in the NAME and empty the kit;
@@ -450,6 +485,7 @@ server.tool('gripforge_generate_character', 'CREATE a Mixamo T-pose auto-rigged
450
485
  'Without an image, this is text-to-3D only. ' +
451
486
  'If the look may already be in the locker, call gripforge_style_kit FIRST. ' +
452
487
  '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.', {
488
+ idempotency_key: z.string().regex(/^[a-zA-Z0-9_.:-]{8,160}$/).optional().describe('Stable asset request key; reuse after an uncertain response.'),
453
489
  prompt: z.string().min(3).max(600).describe('Description. "Devil May Cry like enemy" → tagged devil-may-cry, kind=enemy.'),
454
490
  provider: z.enum(['tripo', 'meshy']).optional().describe('Default tripo. Forced meshy when concept_item / path / file_url is set.'),
455
491
  name: z.string().max(160).optional().describe('Library item name (default: the prompt)'),
@@ -460,7 +496,7 @@ server.tool('gripforge_generate_character', 'CREATE a Mixamo T-pose auto-rigged
460
496
  path: z.string().optional().describe('Absolute path to a local concept or T-pose image (PNG/JPG/WebP). Image-to-3D.'),
461
497
  file_url: z.string().optional().describe('https URL of a concept or T-pose image. Image-to-3D.'),
462
498
  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 }) => {
499
+ }, async ({ idempotency_key, prompt, provider, name, kind, polycount, underwear, concept_item, path, file_url, out_dir }) => {
464
500
  if (!API_KEY)
465
501
  return err('GRIPFORGE_API_KEY missing.');
466
502
  let image_base64;
@@ -488,6 +524,7 @@ server.tool('gripforge_generate_character', 'CREATE a Mixamo T-pose auto-rigged
488
524
  method: 'POST',
489
525
  headers: { ...apiHeaders(), 'content-type': 'application/json' },
490
526
  body: JSON.stringify({
527
+ idempotency_key,
491
528
  prompt,
492
529
  provider: image_base64 || concept_item ? 'meshy' : provider,
493
530
  name,
@@ -517,19 +554,21 @@ server.tool('gripforge_generate_character', 'CREATE a Mixamo T-pose auto-rigged
517
554
  server.tool('gripforge_generate_weapon', 'CREATE a game-ready weapon GLB (knife, gun, sword) and save it to Library as kind=weapon. ' +
518
555
  'Default Meshy, no T-pose, no rig. polycount default 4000. style=melee|gun for attach. 10 credits. ' +
519
556
  'Do NOT write one-off Meshy scripts.', {
557
+ idempotency_key: z.string().regex(/^[a-zA-Z0-9_.:-]{8,160}$/).optional().describe('Stable asset request key; reuse after an uncertain response.'),
520
558
  prompt: z.string().min(3).max(600).describe('e.g. CS2 default CT combat knife, silver blade, black grip'),
521
559
  name: z.string().max(160).optional(),
522
560
  polycount: z.number().optional().describe('Target triangles (default 4000)'),
523
561
  style: z.enum(['melee', 'gun', 'shield', 'staff']).optional().describe('Grip style for attach (inferred if omitted)'),
524
562
  provider: z.enum(['meshy', 'tripo']).optional().describe('Default meshy'),
525
563
  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 }) => {
564
+ }, async ({ idempotency_key, prompt, name, polycount, style, provider, out_dir }) => {
527
565
  if (!API_KEY)
528
566
  return err('GRIPFORGE_API_KEY missing.');
529
567
  const res = await fetch(`${API_URL}/api/v1/generate-character`, {
530
568
  method: 'POST',
531
569
  headers: { ...apiHeaders(), 'content-type': 'application/json' },
532
570
  body: JSON.stringify({
571
+ idempotency_key,
533
572
  prompt,
534
573
  name,
535
574
  kind: 'weapon',
@@ -554,24 +593,32 @@ server.tool('gripforge_generate_weapon', 'CREATE a game-ready weapon GLB (knife,
554
593
  }
555
594
  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
595
  });
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.', {
596
+ 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. Quoted against the shared subscription, or 10 GripForge credits if it cannot cover the complete operation. Returns a durable job_id; poll gripforge_generation_read.', {
597
+ idempotency_key: z.string().regex(/^[a-zA-Z0-9_.:-]{8,160}$/).optional().describe('Stable asset request key; reuse after an uncertain response.'),
558
598
  prompt: z.string().min(3).max(600).describe('e.g. compact orange open-cockpit racing kart, isolated'),
559
599
  name: z.string().max(160).optional(),
560
600
  polycount: z.number().optional().describe('Target triangles (default 4000)'),
561
601
  provider: z.enum(['meshy', 'tripo']).optional().describe('Default meshy'),
602
+ concept_item: z.string().min(1).max(160).optional().describe('Owned concept image in this workspace; forces Meshy image-to-3D.'),
603
+ concept_items: z.array(z.string().min(1).max(160)).min(2).max(4).optional().describe('2–4 distinct owned views of the SAME prop for Meshy multi-image-to-3D. Do not combine with concept_item. Geometry standard/2k only.'),
604
+ meshQuality: z.object({ geometry: z.enum(['standard', '2k', '4k']), texture: z.enum(['2k', '4k', '8k']) }).optional().describe('Meshy geometry and PBR texture quality. Requested texture resolution is retained through optimization.'),
605
+ optimize: z.boolean().optional().describe('Default true; false retains the provider mesh for close-up review.'),
562
606
  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 }) => {
607
+ subtype: z.enum(['vehicle', 'none']).optional().describe('vehicle: made as the Vehicles prop sub-kind — no grip style, nose on +Z, the four wheels split with hub pivots so it drives. Then finished like a game car: paint, glass, chrome, grille and lamp materials, smoothed body, modelled wheels. Detected from the prompt (car, truck, van, kart…) when omitted; none opts out.'),
608
+ }, async ({ idempotency_key, prompt, name, polycount, provider, concept_item, concept_items, meshQuality, optimize, out_dir, subtype }) => {
564
609
  if (!API_KEY)
565
610
  return err('GRIPFORGE_API_KEY missing.');
566
611
  const res = await fetch(`${API_URL}/api/v1/generate-character`, {
567
612
  method: 'POST',
568
613
  headers: { ...apiHeaders(), 'content-type': 'application/json' },
569
614
  body: JSON.stringify({
615
+ idempotency_key,
570
616
  prompt,
571
617
  name,
572
618
  kind: 'prop',
619
+ subtype,
573
620
  polycount: polycount ?? 4000,
574
- provider: provider ?? 'meshy',
621
+ provider: provider ?? 'meshy', concept_item, concept_items, meshQuality, optimize,
575
622
  }),
576
623
  });
577
624
  const data = (await res.json().catch(() => ({})));
@@ -829,26 +876,56 @@ server.tool('gripforge_concept_correct', 'Turn a character concept image into a
829
876
  return err(String(data.error ?? res.status));
830
877
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
831
878
  });
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.', {
879
+ 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. Every item carries `rank` {score/100, grade A–D, reasons}: fitness for a GripForge game; sort=rank lists the best fit first. Assets carry a QUALIFICATION, and using it beats searching by name: the KIND is the main type and drives placement (unique per asset); SUBTYPES say what it covers or concerns and several can apply at once — body zones for equipment (helm, chest, shoulder, sleeve, bracer, glove, belt, thigh, greave, boot, cape), usage for a sound (music, ambience, sfx, voice), anchor for a vfx (world, character, weapon). So subtype=boot finds every boot, and kind=equipment with subtype=chest finds a breastplate. roles=true also returns assets where the kind is only a SECONDARY role (the same humanoid body often serves as hero and enemy without a copy). meta.subtypes and meta.alsoKinds come back on every item; tags stay free keywords.', {
833
880
  kind: KIND.optional().describe('Filter by kind'),
834
881
  q: z.string().optional().describe('Search name/filename'),
835
- style: z.string().optional().describe('Game look (devil-may-cry, dmc, genshin)'),
836
- }, async ({ kind, q, style }) => {
882
+ subtype: z.string().optional().describe('What it covers or concerns: boot, chest, sfx, world…'),
883
+ roles: z.boolean().optional().describe('With kind: also match assets where this kind is a secondary role'),
884
+ style: z.string().optional().describe('Game look (devil-may-cry, dmc, open-world-adventure)'),
885
+ sort: z.enum(['newest', 'rank']).optional().describe('newest (default) or rank: best fit first'),
886
+ target: z.enum(['mobile', 'desktop']).optional().describe('Platform for the rank budgets. Defaults to mobile.'),
887
+ }, async ({ kind, q, subtype, roles, style, sort, target }) => {
837
888
  if (!API_KEY)
838
889
  return err('GRIPFORGE_API_KEY missing.');
839
890
  const qs = new URLSearchParams();
840
891
  if (kind)
841
892
  qs.set('kind', kind);
893
+ if (subtype)
894
+ qs.set('subtype', subtype);
895
+ if (roles)
896
+ qs.set('roles', '1');
842
897
  if (q)
843
898
  qs.set('q', q);
844
899
  if (style)
845
900
  qs.set('style', style);
901
+ if (sort === 'rank')
902
+ qs.set('sort', 'rank');
903
+ if (target)
904
+ qs.set('target', target);
846
905
  const res = await fetch(`${API_URL}/api/v1/library?${qs}`, { headers: apiHeaders() });
847
906
  const data = await res.json().catch(() => ({}));
848
907
  if (!res.ok)
849
908
  return err(String(data.error ?? res.status));
850
909
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
851
910
  });
911
+ server.tool('gripforge_library_optimize', 'Derive a lighter version of a static model (prop, weapon, equipment, vehicle) for a target: decimated to `triangles`, textures bounded to `texture` px, saved as a NEW Library item linked to its master (meta.lod_of). The master is never changed — generate high once, derive mobile/desktop versions. Wheel pivots and nodes are kept. 0 credits.', {
912
+ id: z.string().describe('Library id of the master model (lib_…)'),
913
+ triangles: z.number().int().min(500).max(200000).describe('Target triangles, e.g. 12000 for a mobile car, 40000 desktop'),
914
+ texture: z.union([z.literal(512), z.literal(1024), z.literal(2048), z.literal(4096)]).optional().describe('Max texture size in px (default 2048)'),
915
+ name: z.string().max(160).optional(),
916
+ }, async ({ id, triangles, texture, name }) => {
917
+ if (!API_KEY)
918
+ return err('GRIPFORGE_API_KEY missing.');
919
+ const res = await fetch(`${API_URL}/api/v1/library/${encodeURIComponent(id)}/optimize`, {
920
+ method: 'POST',
921
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
922
+ body: JSON.stringify({ triangles, texture, name }),
923
+ });
924
+ const data = await res.json().catch(() => ({}));
925
+ if (!res.ok)
926
+ return err(String(data.error ?? res.status));
927
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
928
+ });
852
929
  server.tool('gripforge_library_get', 'Get one Library item (metadata + signed file URL).', { id: z.string().describe('Library id (lib_…)') }, async ({ id }) => {
853
930
  if (!API_KEY)
854
931
  return err('GRIPFORGE_API_KEY missing.');
@@ -962,21 +1039,26 @@ server.tool('gripforge_library_pull', 'Download a Library item into the open rep
962
1039
  ],
963
1040
  };
964
1041
  });
965
- server.tool('gripforge_library_tag', 'Add a game look to tags so style_kit can find the item. Does not overwrite grip style melee/gun.', {
1042
+ server.tool('gripforge_library_tag', 'Tag or REQUALIFY a Library item. Tags: add a game look so style_kit can find the item (does not overwrite grip style melee/gun). Qualification: kind is the main type and drives placement (only kinds the FILE can carry: a mesh never becomes a HUD); subtypes are cumulative (body zones for equipment: helm, chest, shoulder, sleeve, bracer, glove, belt, thigh, greave, boot, cape; sound usage; vfx anchor) and each must belong to the main type or a secondary role; also_kinds are secondary roles (a humanoid body that is both character and enemy). A refused value comes back with the field it concerns. Fields left out are kept.', {
966
1043
  id: z.string().describe('Library id (lib_…)'),
967
1044
  style: z.string().optional().describe('Game look id or alias (devil-may-cry, dmc)'),
968
1045
  tags: z.array(z.string()).optional().describe('Replace tags. Omit to keep existing and just add style.'),
969
- }, async ({ id, style, tags }) => {
1046
+ kind: z.string().optional().describe('Requalify: the main type (character, enemy, equipment, weapon, prop, texture, hud, vfx, animation, audio…)'),
1047
+ subtypes: z.array(z.string()).optional().describe('Requalify: replace the cumulative subtypes ([] clears them)'),
1048
+ also_kinds: z.array(z.string()).optional().describe('Requalify: replace the secondary roles ([] clears them)'),
1049
+ }, async ({ id, style, tags, kind, subtypes, also_kinds }) => {
970
1050
  if (!API_KEY)
971
1051
  return err('GRIPFORGE_API_KEY missing.');
972
1052
  const res = await fetch(`${API_URL}/api/v1/library/${encodeURIComponent(id)}`, {
973
1053
  method: 'PATCH',
974
1054
  headers: { ...apiHeaders(), 'content-type': 'application/json' },
975
- body: JSON.stringify({ style, tags }),
1055
+ body: JSON.stringify({ style, tags, kind, subtypes, alsoKinds: also_kinds }),
976
1056
  });
977
1057
  const data = await res.json().catch(() => ({}));
978
- if (!res.ok)
979
- return err(String(data.error ?? res.status));
1058
+ if (!res.ok) {
1059
+ const fields = data.fields;
1060
+ return err(fields ? Object.entries(fields).map(([f, m]) => `${f}: ${m.join('; ')}`).join(' | ') : String(data.error ?? res.status));
1061
+ }
980
1062
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
981
1063
  });
982
1064
  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 }) => {
@@ -1110,7 +1192,7 @@ server.tool('gripforge_audio_kit', 'Search locker + Community SFX by description
1110
1192
  hits,
1111
1193
  wrote: [],
1112
1194
  hint: wanted
1113
- ? 'Pick by description/use. Locker ids: gripforge_library_pull. Community ids: clone first (1 credit), then pull.'
1195
+ ? 'Pick by description/use. Locker ids: gripforge_library_pull. Community ids: clone first (free unless the creator sets a price in credits), then pull.'
1114
1196
  : 'Pass prompt to filter. Descriptions are on each hit.',
1115
1197
  };
1116
1198
  if (out_dir) {
@@ -1343,16 +1425,76 @@ server.tool('gripforge_hitbox', 'Body capsules + weapons[] (one per gf_prop_ wra
1343
1425
  return err(String(data.error ?? res.status));
1344
1426
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1345
1427
  });
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.', {
1428
+ 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, radiusMeters, axle, spin}. Geometric cut (cylinder + plane, seam invariant under rotation, capped). normalize=true also puts the nose on +Z, the real size in metres and the wheels on y = 0 (works on a vehicle already split). 0 credits. apply=false → analysis only.', {
1347
1429
  id: z.string().describe('Library id of the vehicle GLB (lib_…)'),
1348
1430
  apply: z.boolean().optional().describe('default true — rewrite the Library file with the split wheels'),
1349
- }, async ({ id, apply }) => {
1431
+ normalize: z.boolean().optional().describe('nose on +Z, metres, wheels on the ground'),
1432
+ reverse: z.boolean().optional().describe('the nose came out backwards: turn the vehicle 180° (with normalize)'),
1433
+ length_m: z.number().min(0.5).max(25).optional().describe('bumper-to-bumper length in metres; default: typical for the vehicle named (car 4.4, SUV 4.7, pickup 5.8, van 5.5, kart 1.8…)'),
1434
+ }, async ({ id, apply, normalize, length_m, reverse }) => {
1350
1435
  if (!API_KEY)
1351
1436
  return err('GRIPFORGE_API_KEY missing.');
1352
1437
  const res = await fetch(`${API_URL}/api/v1/vehicle-wheels`, {
1353
1438
  method: 'POST',
1354
1439
  headers: { ...apiHeaders(), 'content-type': 'application/json' },
1355
- body: JSON.stringify({ id, apply }),
1440
+ body: JSON.stringify({ id, apply, normalize, length_m, reverse }),
1441
+ });
1442
+ const data = await res.json().catch(() => ({}));
1443
+ if (!res.ok)
1444
+ return err(String(data.error ?? res.status));
1445
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1446
+ });
1447
+ server.tool('gripforge_vehicle_finish', 'Repair generated automotive surfaces. Preferred: source (immutable static GLB with Body/Wheel_* groups) + repair recipe queues a persistent Blender job; guided glazing/panel regions, paint cleanup, coloured caliper extraction, measured replacement panels/curves with PBR and fitted boundaries, optional rebuilt wheels and rigid wheel rig. features.marks adds surface-fitted pinned PNG logos, relief badges and mesh lettering; wheel markings follow their wheel, named authored marks are replaced in the new work copy. For grilles or trim on a reconstructed recess, set curves.fitSurface.panel to its non-structural panel name to follow the final part. Returns a NEW private work version with GLB/Blend/optional FBX and GripForge review link. Does not promote or overwrite the source. Regions use +Y up/+Z forward metres, bounded displacement; region-only glass stays opaque, authored transmissive panes need an authored cabin. cabin fabricates a measured interior; shellDenoise refines replaced coachwork; material.microSurface embeds reusable PBR detail. render_profile configures shared automotive lighting and post-processing. Poll/cancel/retry generation-jobs. 0 provider credits. Legacy id without repair uses one-time material/wheel finish and rewrites its Library file.', {
1448
+ id: z.string().optional().describe('Library id; with repair pins source, without repair uses legacy finish'),
1449
+ source: z.object({ assetId: z.string(), revisionId: z.string(), fileRole: z.string().optional() }).optional().describe('Pinned static source, instead of id'),
1450
+ repair: z.object({
1451
+ regions: z.array(z.object({ name: z.string(), bounds: z.tuple([z.tuple([z.number(), z.number(), z.number()]), z.tuple([z.number(), z.number(), z.number()])]), select: z.enum(['neutral', 'paint', 'all']), surface: z.enum(['glass', 'paint']), fitAxis: z.enum(['x', 'y', 'z']), maxOffsetM: z.number().min(0).max(.05).optional() })).max(16),
1452
+ denoise: z.object({ iterations: z.number().int().min(1).max(80).optional(), maxOffsetM: z.number().min(0).max(.05).optional(), normalIterations: z.number().int().min(0).max(30).optional() }).optional().describe('Bounded Body smoothing across UV seams; pins open boundaries, retains UVs and wheel/caliper transforms; review small details'),
1453
+ cleanPaint: z.boolean().optional(), paintColor: z.tuple([z.number(), z.number(), z.number()]).optional(),
1454
+ cabin: z.object({ bounds: z.tuple([z.tuple([z.number(), z.number(), z.number()]), z.tuple([z.number(), z.number(), z.number()])]), seats: z.union([z.literal(2), z.literal(4)]).optional(), steeringSide: z.enum(['left', 'right']).optional(), glassMeshes: z.array(z.string().min(1).max(120)).max(12).optional(), glassTransmission: z.number().min(0).max(1).optional(), glassThicknessM: z.number().min(.001).max(.02).optional(), upholsteryColor: z.tuple([z.number(), z.number(), z.number()]).optional(), glassColor: z.tuple([z.number(), z.number(), z.number()]).optional() }).optional().describe('Measured parametric cabin: seats, dashboard, steering, console, floor and trim. Exact isolated transmissive Body pane names only; no inferred segmentation.'),
1455
+ calipers: z.object({ color: z.tuple([z.number(), z.number(), z.number()]), tolerance: z.number().min(.05).max(.4).optional() }).optional(),
1456
+ features: z.object({
1457
+ replaceBody: z.boolean().optional().describe('Explicitly replace coachwork in a new work copy using structural panels; preserve wheels and Body calipers.'),
1458
+ shellDenoise: z.object({ iterations: z.number().int().min(1).max(80).optional(), normalIterations: z.number().int().min(0).max(30).optional(), maxOffsetM: z.number().min(0).max(.05).optional() }).optional().describe('Refine only a newly replaced structural shell; preserves window/cut boundaries and never smooths badges, lamps or wheels.'),
1459
+ panels: z.array(z.object({
1460
+ name: z.string(), points: z.array(z.array(z.tuple([z.number(), z.number(), z.number()])).min(2).max(12)).min(2).max(12),
1461
+ structural: z.boolean().optional().describe('New coachwork shell, required with replaceBody; cannot fit or clip the discarded source.'),
1462
+ normal: z.tuple([z.number(), z.number(), z.number()]).optional(),
1463
+ segmentsU: z.number().int().min(4).max(96).optional(), segmentsV: z.number().int().min(4).max(96).optional(),
1464
+ mirrorX: z.boolean().optional(), material: z.object({ color: z.tuple([z.number(), z.number(), z.number()]).optional(), metallic: z.number().min(0).max(1).optional(), roughness: z.number().min(.02).max(1).optional(), coat: z.number().min(0).max(1).optional(), transmission: z.number().min(0).max(1).optional(), ior: z.number().min(1).max(2.5).optional(), emission: z.number().min(0).max(10).optional(), coatRoughness: z.number().min(0).max(1).optional(), microSurface: z.enum(['paint', 'leather', 'rubber', 'metal']).optional(), detail: z.number().min(0).max(1).optional() }).optional(),
1465
+ replace: z.object({ axis: z.enum(['x', 'y', 'z']), depthM: z.number().min(.001).max(.5).optional() }).optional(),
1466
+ fitBoundary: z.object({ axis: z.enum(['x', 'y', 'z']), direction: z.union([z.literal(1), z.literal(-1)]).optional(), offsetM: z.number().min(0).max(.01).optional(), fullSurface: z.boolean().optional() }).optional(), sealRadiusM: z.number().min(.0005).max(.03).optional(),
1467
+ })).max(32).optional(),
1468
+ curves: z.array(z.object({
1469
+ name: z.string(), points: z.array(z.tuple([z.number(), z.number(), z.number()])).min(2).max(128),
1470
+ fitSurface: z.object({ axis: z.enum(['x', 'y', 'z']), direction: z.union([z.literal(1), z.literal(-1)]).optional(), offsetM: z.number().min(0).max(.01).optional(), panel: z.string().trim().min(1).max(80).optional() }).optional(),
1471
+ radiusM: z.number().min(.0005).max(.08).optional(), closed: z.boolean().optional(), mirrorX: z.boolean().optional(),
1472
+ material: z.object({ color: z.tuple([z.number(), z.number(), z.number()]).optional(), metallic: z.number().min(0).max(1).optional(), roughness: z.number().min(.02).max(1).optional(), coat: z.number().min(0).max(1).optional(), transmission: z.number().min(0).max(1).optional(), ior: z.number().min(1).max(2.5).optional(), emission: z.number().min(0).max(10).optional(), coatRoughness: z.number().min(0).max(1).optional(), microSurface: z.enum(['paint', 'leather', 'rubber', 'metal']).optional(), detail: z.number().min(0).max(1).optional() }).optional(),
1473
+ })).max(256).optional(),
1474
+ marks: z.array(z.object({
1475
+ name: z.string().min(1).max(80), kind: z.enum(['decal', 'badge', 'text']), target: z.enum(['Body', 'Wheel_FL', 'Wheel_FR', 'Wheel_RL', 'Wheel_RR']).optional(),
1476
+ receiver: z.string().trim().min(1).max(120).optional(), position: z.tuple([z.number(), z.number(), z.number()]), normal: z.tuple([z.number(), z.number(), z.number()]), up: z.tuple([z.number(), z.number(), z.number()]),
1477
+ widthM: z.number().min(.005).max(.8), heightM: z.number().min(.005).max(.8), offsetM: z.number().min(.0002).max(.01).optional(), depthM: z.number().min(0).max(.01).optional(), maxDistanceM: z.number().min(.001).max(.12).optional(), segments: z.number().int().min(4).max(32).optional(), mirrorX: z.boolean().optional(),
1478
+ outline: z.array(z.tuple([z.number().min(-.5).max(.5), z.number().min(-.5).max(.5)])).min(3).max(64).optional(),
1479
+ artwork: z.object({ assetId: z.string(), revisionId: z.string(), fileRole: z.string().optional() }).optional(), text: z.string().min(1).max(48).optional(),
1480
+ material: z.object({ color: z.tuple([z.number(), z.number(), z.number()]).optional(), metallic: z.number().min(0).max(1).optional(), roughness: z.number().min(.02).max(1).optional(), coat: z.number().min(0).max(1).optional(), transmission: z.number().min(0).max(1).optional(), ior: z.number().min(1).max(2.5).optional(), emission: z.number().min(0).max(10).optional(), coatRoughness: z.number().min(0).max(1).optional(), microSurface: z.enum(['paint', 'leather', 'rubber', 'metal']).optional(), detail: z.number().min(0).max(1).optional() }).optional(),
1481
+ })).max(32).optional().describe('Surface-fitted logos and relief: decals require pinned PNG artwork; badges add thickness and a convex outline; text makes mesh lettering. Explicit normal/up frame; target wheel marks spin with that wheel. Optional receiver scopes fitting to a mesh in the target. PNGs <=1 MiB/2048px, max 16 distinct sources. No URLs or local paths.'),
1482
+ }).optional().describe('Measured panels, curves, badges and decals; explicit PBR, clipping, symmetry, 250k vertex budget.'),
1483
+ rig: z.object({ wheelRadius: z.number(), wheelWidth: z.number() }).optional(),
1484
+ wheelRebuild: z.object({ radiusM: z.number().min(.1).max(1.5), widthM: z.number().min(.05).max(.8), spokes: z.number().int().min(3).max(12).optional(), segments: z.number().int().min(24).max(128).optional(), trackM: z.number().min(.3).max(6).optional() }).optional().describe('Replace generated wheel meshes with measured tyres, dished spokes and brake rotors. Optional trackM adjusts axle track and matching explicit Body calipers together; omitted preserves wheel pivots.'),
1485
+ }).optional().describe('Guided surface repair; never edits the source'),
1486
+ render_profile: z.object({ look: z.enum(['studio', 'daylight']).optional(), quality: z.enum(['performance', 'balanced', 'quality']).optional(), lengthM: z.number().min(1).max(25).optional(), ground: z.boolean().optional(), depthOfField: z.boolean().optional(), focusDistanceM: z.number().min(.1).max(1000).optional() }).optional().describe('Shared automotive review scene profile; balanced studio by default. DOF off unless explicitly requested.'),
1487
+ name: z.string().optional(), idempotency_key: z.string().min(8).max(160).optional(),
1488
+ smooth: z.number().int().min(0).max(60).optional().describe('body smoothing passes (default 15, 0 = none)'),
1489
+ wheels: z.boolean().optional().describe('false keeps the generated wheels'),
1490
+ segments: z.number().int().min(12).max(128).optional().describe('wheel roundness: 64 default, 24 for mobile'),
1491
+ }, async (args) => {
1492
+ if (!API_KEY)
1493
+ return err('GRIPFORGE_API_KEY missing.');
1494
+ const res = await fetch(`${API_URL}/api/v1/vehicle-finish`, {
1495
+ method: 'POST',
1496
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
1497
+ body: JSON.stringify(args),
1356
1498
  });
1357
1499
  const data = await res.json().catch(() => ({}));
1358
1500
  if (!res.ok)
@@ -0,0 +1,29 @@
1
+ import { z } from 'zod/v4';
2
+ const styles = ['fire', 'water', 'water-petal', 'blood', 'cat', 'cosmic', 'cyber', 'cyber-grid', 'holy', 'ice', 'ink', 'light', 'lightning', 'poison', 'rock', 'void', 'wind'];
3
+ export const SHIELD_VFX_TOOL_NAMES = ['gripforge_shield_vfx', 'gripforge_shield_export'];
4
+ export function registerShieldVfxTools(register, options, s = z) {
5
+ const config = s.object({ version: s.literal(1).optional(), style: s.enum(styles).optional(), radius: s.number().min(.15).max(5).optional(), stretch: s.tuple([s.number().min(.25).max(2), s.number().min(.25).max(2), s.number().min(.25).max(2)]).optional(), color: s.string().regex(/^#[a-f\d]{6}$/i).optional(), accent: s.string().regex(/^#[a-f\d]{6}$/i).optional(), intensity: s.number().min(.1).max(3).optional(), opacity: s.number().min(0).max(1).optional(), speed: s.number().min(.1).max(4).optional(), seed: s.number().int().min(0).max(65535).optional(), quality: s.enum(['low', 'high']).optional() }).strict();
6
+ const common = { workspace_id: s.string().max(100).optional(), config: config.optional() };
7
+ const definitions = [{ name: SHIELD_VFX_TOOL_NAMES[0], shape: { ...common, save: s.boolean().optional(), idempotency_key: s.string().min(8).max(160).optional() }, description: 'ADMIN ONLY. Create an original editable 3D elemental shield using the shared shield-vfx module: 17 styles, radius/stretch, colours, intensity, quality and seed. Same shaders used in Studio and native exports. save=true by default queues persistent rendering/review/workspace save; poll generation_read for Studio URL. save=false returns an unsaved project. No commercial reference assets, collision or damage. Recipe compilation needs no AI; saving uses the normal VFX review pipeline. Work is not visually validated automatically.' },
8
+ { name: SHIELD_VFX_TOOL_NAMES[1], shape: { ...common, engine: s.enum(['threejs', 'godot', 'unity', 'unreal']) }, description: 'ADMIN ONLY. Compile a shield recipe into reusable native source files for Three.js, Godot 4, Unity or Unreal 5, using the same geometry and shader functions. Returns files keyed by relative path plus install instructions. Write ALL files into a dedicated directory; never overwrite an existing modified package without checking. Includes enable/hit/shatter runtime; no mannequin or preview environment. Native builds require target-engine verification; source export is not visual certification.' }];
9
+ for (const d of definitions)
10
+ register(d.name, { title: d.name, description: d.description, inputSchema: d.shape, annotations: { readOnlyHint: d.name.endsWith('export'), destructiveHint: false, idempotentHint: d.name.endsWith('export'), openWorldHint: false } }, async (args, extra) => {
11
+ const key = options.getApiKey();
12
+ if (!key)
13
+ return { isError: true, content: [{ type: 'text', text: 'GripForge API key required.' }] };
14
+ const parsed = s.object(d.shape).strict().safeParse(args);
15
+ if (!parsed.success)
16
+ return { isError: true, content: [{ type: 'text', text: parsed.error.message }] };
17
+ const { workspace_id, ...body } = parsed.data;
18
+ try {
19
+ const r = await fetch(options.apiUrl.replace(/\/$/, '') + '/api/v1/vfx/shields', { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': key, ...(workspace_id ? { 'x-workspace-id': String(workspace_id) } : {}) }, body: JSON.stringify({ ...body, action: d.name.endsWith('export') ? 'export' : body.save === false ? 'preview' : 'create' }), signal: AbortSignal.any([AbortSignal.timeout(90000), ...(extra?.signal ? [extra.signal] : [])]) });
20
+ const data = await r.json();
21
+ if (data.studio_url?.startsWith('/'))
22
+ data.studio_url = new URL(data.studio_url, options.apiUrl).href;
23
+ return { ...(!r.ok ? { isError: true } : {}), structuredContent: data, content: [{ type: 'text', text: JSON.stringify(data) }] };
24
+ }
25
+ catch (e) {
26
+ return { isError: true, content: [{ type: 'text', text: e.message }] };
27
+ }
28
+ });
29
+ }
@@ -0,0 +1,106 @@
1
+ import { z } from 'zod/v4';
2
+ /**
3
+ * Les outils MCP des outils chaînés (studios → outils → étapes), enregistrés par le MCP hébergé
4
+ * (`apps/web/src/lib/mcp-server.ts`) et par le client npm (`server.ts`).
5
+ *
6
+ * Même contrat que la library : tout passe par `/api/v1/tools*`, ouvert aux membres du workspace de
7
+ * la clé (lecture pour tous, lancer et corriger pour ceux qui écrivent dans le casier). Quand une
8
+ * chaîne s'arrête, la réponse porte l'étape, la phrase qui dit quoi
9
+ * faire et l'URL où le faire — on ne décrit pas le problème, on donne l'endroit où le régler.
10
+ */
11
+ export const STUDIO_TOOLS_TOOL_NAMES = ['gripforge_studios', 'gripforge_tool_run', 'gripforge_tool_runs', 'gripforge_mesh_passport'];
12
+ export function registerStudioToolsTools(register, options, schema = z) {
13
+ const libId = schema.string().regex(/^lib_[A-Za-z0-9_-]{8,64}$/);
14
+ const ws = schema.string().max(100).optional().describe('Workspace id (defaults to the key’s workspace).');
15
+ const shapes = {
16
+ studios: { item_id: libId.optional().describe('A locker item: the answer says which tools are possible on it right now.'), workspace_id: ws },
17
+ run: {
18
+ tool: schema.string().min(1).max(40).describe('Tool id from gripforge_studios (passport, rig, mannequin).'),
19
+ item_id: libId.optional().describe('Locker item the chain works on. mannequin without item = the adult canon.'),
20
+ step: schema.string().max(40).optional().describe('Replay ONE step (the one you just corrected) instead of the whole chain.'),
21
+ force: schema.boolean().optional().describe('Recompute fresh blocks too. Default: fresh blocks are skipped.'),
22
+ workspace_id: ws,
23
+ },
24
+ runs: {
25
+ item_id: libId.optional(),
26
+ tool: schema.string().max(40).optional(),
27
+ limit: schema.number().int().min(1).max(100).optional(),
28
+ workspace_id: ws,
29
+ },
30
+ passport: {
31
+ item_id: libId,
32
+ manual: schema.record(schema.string(), schema.unknown()).optional().describe('Partial PassportManual to MERGE (e.g. {"orientation":{"up":[0,1,0],"front":[0,0,1],"toCanonical":[0,0,0,1],"confidence":1,"source":"manual"}}). Omit to read.'),
33
+ clear: schema.string().max(60).optional().describe('Remove one correction (orientation, joints.elbow.L, fits.helm) or "*" for all.'),
34
+ workspace_id: ws,
35
+ },
36
+ };
37
+ async function call(method, path, args, body, signal) {
38
+ const key = options.getApiKey();
39
+ if (!key)
40
+ return { isError: true, content: [{ type: 'text', text: 'GripForge API key required.' }] };
41
+ const ws = typeof args.workspace_id === 'string' ? args.workspace_id : null;
42
+ try {
43
+ const response = await fetch(options.apiUrl.replace(/\/$/, '') + path, {
44
+ method,
45
+ headers: { 'content-type': 'application/json', 'x-gripforge-client': 'mcp', 'x-api-key': key, ...(ws ? { 'x-workspace-id': ws } : {}) },
46
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
47
+ signal: AbortSignal.any([AbortSignal.timeout(300_000), ...(signal ? [signal] : [])]),
48
+ });
49
+ const data = await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
50
+ return { ...(!response.ok ? { isError: true } : {}), structuredContent: data, content: [{ type: 'text', text: JSON.stringify(data) }] };
51
+ }
52
+ catch (error) {
53
+ return { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : 'GripForge tools request failed.' }] };
54
+ }
55
+ }
56
+ const q = (o) => {
57
+ const p = new URLSearchParams();
58
+ for (const [k, v] of Object.entries(o))
59
+ if (v !== undefined && v !== null && v !== '')
60
+ p.set(k, String(v));
61
+ const s = p.toString();
62
+ return s ? `?${s}` : '';
63
+ };
64
+ const tools = [
65
+ {
66
+ name: 'gripforge_studios', title: 'Studios · tools map', readOnly: true, shape: shapes.studios,
67
+ description: 'The map of GripForge studios and their chained tools (steps, kind code/ai/tool/analysis, manual equivalent of each step). Call it BEFORE guessing a tool name. With item_id, `available` says what is possible on that item now.',
68
+ run: (a, s) => call('GET', `/api/v1/tools${q({ item: a.item_id })}`, a, undefined, s),
69
+ },
70
+ {
71
+ name: 'gripforge_tool_run', title: 'Studios · run a chained tool', readOnly: false, shape: shapes.run,
72
+ description: 'Run a chained tool server-side (computation happens in the GripForge compute service). Returns the step-by-step run. status "needs-manual" = the chain stopped on purpose: read manual_hint, fix it (gripforge_mesh_passport) or send the user to `url`, then replay with step=<that step>. mannequin with an item = chain passport → rig → mannequin, a new locker item.',
73
+ run: (a, s) => call('POST', '/api/v1/tools/run', a, { tool: a.tool, id: a.item_id ?? null, step: a.step ?? null, force: a.force === true, source: 'mcp' }, s),
74
+ },
75
+ {
76
+ name: 'gripforge_tool_runs', title: 'Studios · run history', readOnly: true, shape: shapes.runs,
77
+ description: 'The shared history of chained-tool runs (web page and agents alike), newest first: which tool, on which item, each step, where it stopped and the URL to resume.',
78
+ run: (a, s) => call('GET', `/api/v1/tools/runs${q({ item: a.item_id, tool: a.tool, limit: a.limit })}`, a, undefined, s),
79
+ },
80
+ {
81
+ name: 'gripforge_mesh_passport', title: 'Studios · mesh passport', readOnly: false, shape: shapes.passport,
82
+ description: 'Read or correct the passport of a locker mesh (size, orientation, reference skeleton, colliders). `manual` merges a human correction (it always wins and survives regeneration); `clear` removes one. Measurements themselves come from gripforge_tool_run tool=passport.',
83
+ run: (a, s) => {
84
+ const path = `/api/v1/tools/passport/${a.item_id}`;
85
+ if (typeof a.clear === 'string')
86
+ return call('DELETE', `${path}${a.clear === '*' ? '' : q({ field: a.clear })}`, a, undefined, s);
87
+ if (a.manual)
88
+ return call('PUT', path, a, { manual: a.manual }, s);
89
+ return call('GET', path, a, undefined, s);
90
+ },
91
+ },
92
+ ];
93
+ for (const tool of tools) {
94
+ register(tool.name, {
95
+ title: tool.title,
96
+ description: tool.description,
97
+ inputSchema: tool.shape,
98
+ annotations: { readOnlyHint: tool.readOnly, destructiveHint: false, idempotentHint: tool.readOnly, openWorldHint: false },
99
+ }, async (args, extra) => {
100
+ const parsed = schema.object(tool.shape).strict().safeParse(args);
101
+ if (!parsed.success)
102
+ return { isError: true, content: [{ type: 'text', text: parsed.error.message }] };
103
+ return tool.run(parsed.data, extra?.signal);
104
+ });
105
+ }
106
+ }