@gripforgeai/mcp 0.1.10 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +275 -4
- package/dist/ability-tools.js +21 -0
- package/dist/architecture-tools.js +73 -0
- package/dist/asset-production-tools.js +101 -0
- package/dist/creature-rig-tools.js +41 -0
- package/dist/game-concept-tools.js +56 -0
- package/dist/game-creation-tools.js +45 -0
- package/dist/gamekit-deliver-local.js +5 -7
- package/dist/gamekit-tools.js +127 -24
- package/dist/hero-ability-tools.js +48 -0
- package/dist/joystick-kit/godot.js +146 -0
- package/dist/joystick-kit/index.js +32 -0
- package/dist/joystick-kit/threejs.js +93 -0
- package/dist/joystick-kit/unity.js +182 -0
- package/dist/joystick-kit/unreal.js +231 -0
- package/dist/joystick-tools.js +26 -0
- package/dist/map-unreal-export-local.js +285 -0
- package/dist/map-unreal-import-local.js +421 -0
- package/dist/map-unreal-local.js +75 -0
- package/dist/motion-tools.js +50 -0
- package/dist/projectile-tools.js +28 -0
- package/dist/scene-tools.js +77 -13
- package/dist/server-tools.js +3 -3
- package/dist/server.js +164 -22
- package/dist/shield-vfx-tools.js +29 -0
- package/dist/studio-tools-tools.js +106 -0
- package/dist/unreal-export-package.js +212 -0
- package/dist/weapon-motion-tools.js +34 -0
- package/package.json +6 -1
- package/runtime/unreal-scene-export.py +341 -0
- package/runtime/unreal-scene-import.py +622 -0
- package/runtime/unreal-scene-upload.mjs +7 -0
- package/runtime/unreal_scene_math.py +96 -0
- package/runtime/unreal_spawn_review.py +135 -0
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.
|
|
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" / "
|
|
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
|
-
|
|
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
|
-
|
|
836
|
-
|
|
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', '
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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).
|
|
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
|
-
|
|
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
|
+
}
|