@gripforgeai/mcp 0.1.10 → 0.1.12

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.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +82 -2
  3. package/dist/ability-tools.js +21 -0
  4. package/dist/architecture-tools.js +83 -0
  5. package/dist/asset-production-tools.js +101 -0
  6. package/dist/creature-rig-tools.js +41 -0
  7. package/dist/game-concept-tools.js +56 -0
  8. package/dist/game-creation-tools.js +45 -0
  9. package/dist/gamekit-deliver-local.js +5 -7
  10. package/dist/gamekit-tools.js +133 -24
  11. package/dist/hero-ability-tools.js +48 -0
  12. package/dist/joystick-kit/godot.js +146 -0
  13. package/dist/joystick-kit/index.js +32 -0
  14. package/dist/joystick-kit/threejs.js +93 -0
  15. package/dist/joystick-kit/unity.js +182 -0
  16. package/dist/joystick-kit/unreal.js +231 -0
  17. package/dist/joystick-tools.js +26 -0
  18. package/dist/look.js +6 -0
  19. package/dist/map-unreal-export-local.js +285 -0
  20. package/dist/map-unreal-import-local.js +421 -0
  21. package/dist/map-unreal-local.js +75 -0
  22. package/dist/motion-tools.js +50 -0
  23. package/dist/projectile-tools.js +28 -0
  24. package/dist/scene-tools.js +79 -13
  25. package/dist/server-tools.js +3 -3
  26. package/dist/server.js +139 -13
  27. package/dist/shield-vfx-tools.js +29 -0
  28. package/dist/studio-tools-tools.js +106 -0
  29. package/dist/unreal-export-package.js +212 -0
  30. package/dist/vfx-project-tools.js +2 -1
  31. package/dist/weapon-motion-tools.js +34 -0
  32. package/package.json +6 -1
  33. package/runtime/unreal-scene-export.py +341 -0
  34. package/runtime/unreal-scene-import.py +622 -0
  35. package/runtime/unreal-scene-upload.mjs +7 -0
  36. package/runtime/unreal_scene_math.py +96 -0
  37. package/runtime/unreal_spawn_review.py +135 -0
@@ -1,15 +1,28 @@
1
1
  import { z } from 'zod/v4';
2
+ import { LOOK_DESCRIPTION, LOOK_VALUES } from './look.js';
2
3
  /** UI and agents call the same scene commands, access checks and revision store. */
3
4
  export function registerSceneTools(register, options, schema = z) {
4
5
  const identifier = schema.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,159}$/);
5
6
  const record = schema.record(schema.string(), schema.unknown());
6
7
  const revision = schema.number().int().positive();
8
+ const imageRef = schema.object({ assetId: identifier, revisionId: identifier, fileRole: identifier.optional() });
9
+ const mapWizard = schema.object({
10
+ style: schema.enum(['realistic', 'stylized', 'lowpoly', 'handpainted']).optional().describe('Legacy wording kept in the intent; it does not change the rendering. Use look.'),
11
+ look: schema.enum(LOOK_VALUES).optional().describe(`${LOOK_DESCRIPTION} Map: the theme gives the biome, the look the materials (LevelSpec.look).`),
12
+ world: schema.enum(['open_world', 'closed_arena', 'dungeon', 'linear', 'battle_map']).optional(),
13
+ terrain: schema.enum(['auto', 'plains', 'desert_canyon', 'forest', 'snow', 'island', 'volcanic']).optional(),
14
+ size: schema.enum(['small', 'medium', 'large']).optional(),
15
+ dimensions: schema.object({ width: schema.number().min(100).max(5000), depth: schema.number().min(100).max(5000) }).optional().describe('Explicit ground footprint in metres. Overrides size presets and world multipliers. Example: {width:500,depth:500}. Four-team maps require a square.'),
16
+ boundary: schema.enum(['fixed', 'infinite']).optional(), spawn: schema.enum(['single', 'teams']).optional(),
17
+ teams: schema.union([schema.literal(2), schema.literal(4)]).optional(),
18
+ boss: schema.boolean().optional(), safeZone: schema.boolean().optional(), extraction: schema.boolean().optional(),
19
+ }).passthrough();
7
20
  const workspace = { workspace_id: schema.string().max(100).optional().describe('Workspace authorized by the current key. Returned scene links open this workspace.') };
8
21
  async function call(path, args, method, signal) {
9
22
  const key = options.getApiKey();
10
23
  if (!key)
11
24
  return { isError: true, content: [{ type: 'text', text: 'GripForge API key required.' }] };
12
- const { workspace_id, id: _id, version: _version, include_preview, ...body } = args;
25
+ const { workspace_id, id: _id, version: _version, include_preview, preview_view, ...body } = args;
13
26
  try {
14
27
  const response = await fetch(`${options.apiUrl.replace(/\/$/, '')}/api/v1/${path}`, {
15
28
  method, headers: { 'content-type': 'application/json', 'x-api-key': key, 'x-gripforge-client': 'mcp', ...(typeof workspace_id === 'string' ? { 'x-workspace-id': workspace_id } : {}) },
@@ -23,16 +36,47 @@ export function registerSceneTools(register, options, schema = z) {
23
36
  if (result && typeof result === 'object' && typeof result.studio_url === 'string')
24
37
  result.studio_url = new URL(String(result.studio_url), options.apiUrl).href;
25
38
  const content = [{ type: 'text', text: JSON.stringify(data) }];
26
- const preview = data.result?.preview_url;
27
- if (response.ok && include_preview === true && typeof preview === 'string' && preview.startsWith('/api/v1/generation-jobs/')) {
28
- const image = await fetch(new URL(preview, options.apiUrl), { headers: { 'x-api-key': key, ...(typeof workspace_id === 'string' ? { 'x-workspace-id': workspace_id } : {}) }, signal: AbortSignal.any([AbortSignal.timeout(30_000), ...(signal ? [signal] : [])]) });
29
- if (!image.ok || !image.headers.get('content-type')?.startsWith('image/png'))
30
- throw Error('The job preview could not be read. The completed job remains available.');
31
- const bytes = await image.arrayBuffer();
32
- if (bytes.byteLength > 12 * 1024 * 1024)
33
- throw Error('Preview exceeds the image budget.');
34
- content.push({ type: 'image', mimeType: 'image/png', data: Buffer.from(bytes).toString('base64') });
39
+ const result = data.result;
40
+ if (response.ok && include_preview === true) {
41
+ let previews = Array.isArray(result?.concept_views) ? result.concept_views.slice(0, 4) : result?.preview_url ? [{ view: 'overview', label: 'Preview', preview_url: result.preview_url }] : [];
42
+ if (typeof preview_view === 'string') {
43
+ previews = previews.filter(view => view.view === preview_view);
44
+ if (!previews.length)
45
+ throw Error('This preview view is not available yet. The job remains available.');
46
+ }
47
+ const images = await Promise.all(previews.map(async (preview) => {
48
+ const path = preview.preview_url ?? ('image_url' in preview ? preview.image_url : undefined);
49
+ if (typeof path !== 'string' || !/^\/api\/v1\/(?:generation-jobs|scene-assets)\//.test(path))
50
+ throw Error('Invalid job preview URL.');
51
+ const image = await fetch(new URL(path, options.apiUrl), { headers: { 'x-api-key': key, ...(typeof workspace_id === 'string' ? { 'x-workspace-id': workspace_id } : {}) }, signal: AbortSignal.any([AbortSignal.timeout(30_000), ...(signal ? [signal] : [])]) });
52
+ if (!image.ok || !image.headers.get('content-type')?.startsWith('image/png'))
53
+ throw Error('The job preview could not be read. The completed job remains available.');
54
+ const bytes = await image.arrayBuffer();
55
+ if (bytes.byteLength > 12 * 1024 * 1024)
56
+ throw Error('Preview exceeds the image budget.');
57
+ return { label: preview.label ?? preview.view.replaceAll('_', ' '), data: Buffer.from(bytes).toString('base64') };
58
+ }));
59
+ for (const image of images) {
60
+ if (images.length > 1)
61
+ content.push({ type: 'text', text: image.label });
62
+ content.push({ type: 'image', mimeType: 'image/png', data: image.data });
63
+ }
35
64
  }
65
+ for (const result of [data, data.result, job?.result])
66
+ if (result && typeof result === 'object') {
67
+ const row = result;
68
+ for (const key of ['studio_url', 'review_url', 'file_url', 'source_url', 'concept_url', 'status_url'])
69
+ if (typeof row[key] === 'string' && row[key].startsWith('/'))
70
+ row[key] = new URL(row[key], options.apiUrl).href;
71
+ if (Array.isArray(row.concept_views))
72
+ for (const view of row.concept_views)
73
+ if (view && typeof view === 'object') {
74
+ for (const key of ['image_url', 'preview_url', 'studio_url'])
75
+ if (typeof view[key] === 'string' && view[key].startsWith('/'))
76
+ view[key] = new URL(view[key], options.apiUrl).href;
77
+ }
78
+ }
79
+ content[0] = { type: 'text', text: JSON.stringify(data) };
36
80
  return { ...(!response.ok ? { isError: true } : {}), structuredContent: data, content };
37
81
  }
38
82
  catch (error) {
@@ -40,6 +84,15 @@ export function registerSceneTools(register, options, schema = z) {
40
84
  }
41
85
  }
42
86
  const tool = (name, title, description, inputSchema, readOnly, callback) => register(name, { title, description, inputSchema: { ...inputSchema, ...workspace }, annotations: { readOnlyHint: readOnly, destructiveHint: false, idempotentHint: readOnly, openWorldHint: false } }, callback);
87
+ tool('gripforge_vehicle_doors', 'Open or close vehicle doors in the Studio', 'Save an independent door pose on one prepared vehicle instance in a saved scene. doors maps Door_FL/FR/RL/RR to 0 (closed)..1 (open). Unspecified doors retain their state; null clears the manual pose. Requires exact expectedRevision and named hinges/portable clips from prepare_vehicle. Pauses the global animation for manual control. Preserves the source asset, transforms, other instances and validated versions. Returns the actual Studio link. No generation charge.', { id: identifier, expectedRevision: revision, nodeId: identifier, doors: schema.record(schema.string().regex(/^Door_[FR][LR]$/), schema.number().min(0).max(1)).nullable() }, false, (args, extra) => call(`scenes/${args.id}/vehicle-doors`, args, 'POST', extra?.signal));
88
+ tool('gripforge_prepare_vehicle', 'Prepare segmented vehicle hinges in Blender', 'Durable Blender preparation of an owned static segmented GLB. Map each Door_FL/FR/RL/RR or Wheel_FL/FR/RL/RR to exact mesh node names from the segmentation report. Keeps geometry placement, UVs and PBR; normalizes +Z forward/metres using yaw and length; adds hinge origins, seat/entry sockets and portable GLB open/close/preview clips. Optional hinge coordinates are in normalized vehicle metres. thickness adds an inward door shell, not a full cabin. Optional cuts provide strictly convex door outlines [forwardZ,heightY] and depth [near,far] measured from the centre on that side, in normalized metres. cuts[].meshes restricts each cut to exact exterior/glazing meshes so seats and dashboard cannot be cut. removeMeshes discards explicitly named obsolete trim in the work copy only. Optional clearanceDepthM clears scoped inner walls behind the door skin; trim adds measured body frames, seals and moulded cards below cardTopY. framePoints follows outline in normalized [x,y,z]. Optional trim.fitToSurface projects frames, seals and the inner card onto only the explicitly selected opaque door skin, preserving the supplied outline. attachments adds scoped [min,max] [x,y,z] volumes for mirrors extending beyond the door profile. Blender cuts those explicit profiles while interpolating UVs. Does not infer outlines or guarantee clearance. Returns job_id, new private work GLB, Blender source and animated Studio review link. 0 credits; storage applies. Inspect before replacement.', { source: imageRef, parts: schema.record(schema.string(), schema.array(schema.string().min(1).max(160)).min(1).max(64)).optional(), cuts: schema.array(schema.object({ role: schema.enum(['Door_FL', 'Door_FR', 'Door_RL', 'Door_RR']), outline: schema.array(schema.tuple([schema.number(), schema.number()])).min(3).max(12), depth: schema.tuple([schema.number(), schema.number()]), meshes: schema.array(schema.string().min(1).max(160)).min(1).max(128).optional(), clearanceDepthM: schema.number().min(0).max(3).optional(), attachments: schema.array(schema.object({ bounds: schema.tuple([schema.tuple([schema.number(), schema.number(), schema.number()]), schema.tuple([schema.number(), schema.number(), schema.number()])]), meshes: schema.array(schema.string().min(1).max(160)).min(1).max(128) })).max(4).optional(), trim: schema.object({ framePoints: schema.array(schema.tuple([schema.number(), schema.number(), schema.number()])).min(3).max(12), frameRadiusM: schema.number().min(.003).max(.04).optional(), sealRadiusM: schema.number().min(.002).max(.02).optional(), cardTopY: schema.number().optional(), cardInsetM: schema.number().min(.01).max(.12).optional(), fitToSurface: schema.boolean().optional(), cardColor: schema.tuple([schema.number().min(0).max(1), schema.number().min(0).max(1), schema.number().min(0).max(1)]).optional() }).optional() })).max(4).optional(), removeMeshes: schema.array(schema.string().min(1).max(160)).max(128).optional(), yaw: schema.number().min(-180).max(180).optional(), length: schema.number().min(2.8).max(7).optional(), openAngle: schema.number().min(20).max(85).optional(), thickness: schema.number().min(0).max(.04).optional(), hinges: schema.record(schema.string(), schema.tuple([schema.number(), schema.number(), schema.number()])).optional(), idempotency_key: schema.string().min(8).max(160).optional() }, false, (args, extra) => call('vehicles/prepare', args, 'POST', extra?.signal));
89
+ tool('gripforge_vehicle_schema', 'Modular vehicle manufacturing contract', 'Read the concept-first vehicle recipe, canonical parts, hinge convention, limits, quotes and revision workflow.', {}, true, (args, extra) => call('vehicles', args, 'GET', extra?.signal));
90
+ tool('gripforge_generate_vehicle', 'Generate a vehicle from reviewed multi-view references', 'Read vehicle_schema first. Recommended recipe.workflow=body_wheels follows concept → references (five wheel-less body views + wheel) → geometry (stop/review) → topology (stop/review) → build (PBR + wheel + rig). Each geometry review uses scene_review with the returned assetRef and must be approved before the next paid step. Rigging preserves UVs and corner normals, GLB/FBX/Blend and measured Chaos setup. Top view is review-only. parts.body.geometry accepts unfinished Hunyuan/Blender GLB; parts.body.topology pins the reviewed reduced GLB; parts.<role>.source reuses finished meshes. Legacy modular_doors remains available for articulated doors. stage=plan quotes each stage without spending; paid stages require its budget. Durable job; poll generation_read. Work draft, real-render review and target-engine driving test required; no automatic publication/replacement.', { stage: schema.enum(['plan', 'concept', 'references', 'geometry', 'topology', 'build']).default('plan'), recipe: record, budget: schema.object({ usd: schema.number().nonnegative().optional(), credits: schema.number().int().nonnegative().optional() }).optional(), idempotency_key: schema.string().min(8).max(160).optional() }, false, (args, extra) => call('vehicles', args, 'POST', extra?.signal));
91
+ tool('gripforge_vehicle_segment', 'Separate an existing vehicle with Tripo', 'Quote then queue one durable Tripo v2 semantic segmentation of an owned immutable GLB, including Meshy or Blender sources. stage=plan is free; stage=build requires quoted budget. 40 Tripo provider credits, distinct from the GripForge quote. Returns job_id; inspect parts and texture preservation in generation_read. No automatic completion, retexture, articulation, publication or replacement. A segmented output still needs hinge preparation.', { stage: schema.enum(['plan', 'build']).default('plan'), source: imageRef, granularity: schema.enum(['simple', 'balanced', 'detailed']).optional(), budget: schema.object({ usd: schema.number().nonnegative().optional(), credits: schema.number().int().nonnegative().optional() }).optional(), idempotency_key: schema.string().min(8).max(160).optional() }, false, (args, extra) => call('vehicles/segment', args, 'POST', extra?.signal));
92
+ tool('gripforge_map_import_unreal_schema', 'Unreal to Studio import contract', 'Read the native export → binary upload → persistent map import workflow, limits and explicit conversion caveats. Native extraction requires the local MCP and an installed Unreal editor. Hosted MCP/API consume the resulting portable SceneDocument; they cannot read your local .umap/.uasset files.', {}, true, (args, extra) => call('maps/import/unreal', args, 'GET', extra?.signal));
93
+ tool('gripforge_map_import_unreal', 'Import a portable Unreal scene', 'Queue a persistent import of schema gripforge.unreal-studio-import.v1. Upload files via local gripforge_map_unreal_upload_local or POST /api/v1/maps/import/unreal/assets first. document is a common SceneDocument with workspace-owned immutable assets, separate props, static foliage patches and editable terrain. source={engine:unreal,level:/Game/...,sha256,warnings:[]}. Returns job_id; poll generation_read for studio_url. Always a NEW private work scene; no overwrite, automatic validation or Community publication. 0 credits; storage quota applies.', { schema: schema.literal('gripforge.unreal-studio-import.v1'), document: record, source: schema.object({ engine: schema.literal('unreal'), level: schema.string().regex(/^\/Game(?:\/[\w-]+)+$/), sha256: schema.string().regex(/^[a-f0-9]{64}$/), warnings: schema.array(schema.string().max(2000)).max(100) }), idempotency_key: schema.string().min(8).max(160).optional() }, false, (args, extra) => call('maps/import/unreal', args, 'POST', extra?.signal));
94
+ tool('gripforge_flexible_weapon_schema', 'Flexible weapon manufacturing recipes', 'Read the bounded recipe and concept-first workflow for Meshy + Blender flexible weapons, costs, pinned component reuse and source animation requirements.', {}, true, (args, extra) => call('flexible-weapons', args, 'GET', extra?.signal));
95
+ tool('gripforge_generate_flexible_weapon', 'Generate an articulated flexible weapon', 'Persistent concept → isolated handle/segment references → Meshy PBR components → FlexibleChain XPBD → Blender assembly/rig → actual GripForge renderer review. Read flexible_weapon_schema first. recipe stage=concept returns an image before 3D; build can reuse that concept and owned pinned components. Up to 12 existing source clips or sampled motions; character choreography is not generated by this tool. Returns job_id immediately; poll generation_read for weapon GLB, Blender source, physics reports, separate character/weapon review scene and Studio links. Max 23 credits when all art is new; reused components are free. Cancel/retry retains successful provider and fabrication steps. Always private work: no automatic replacement, promotion or Community publication.', { recipe: record, idempotency_key: schema.string().min(8).max(160).optional() }, false, (args, extra) => call('flexible-weapons', args, 'POST', extra?.signal));
43
96
  tool('gripforge_flexible_chain_schema', 'Flexible chain recipes and examples', 'Read the bounded recipe schema for the shared FlexibleChain solver. Presets: whip, rope, tail. Input can follow an exact named mount in a pinned GLB animation or sampled world-space motion. Includes units, collision capsules, limits and a runnable example.', {}, true, (args, extra) => call('flexible-chain', args, 'GET', extra?.signal));
44
97
  tool('gripforge_flexible_chain', 'Simulate and bake a flexible chain', 'Queue a persistent 240 Hz XPBD simulation using GripForge Scene Engine. Read flexible_chain_schema first. recipe version=1: preset whip|rope|tail, duration<=10s, fps=30|60, physics {length,segments,damping,bendCompliance,gravity,radius,floor,iterations}; choose motion [{time,position,direction,capsules}] OR source {asset:{assetId,revisionId},clip,mount_node,offset,axis,colliders}. Source assets are immutable and workspace-scoped. Returns a job ID; poll generation_read for a new draft skinned-chain GLB, sampled joints, recipe, physics metrics and Character Studio link. This is a neutral physics preview, not a Meshy weapon redesign or automatic rebind of the source weapon. No runtime world collision or seamless loop guarantee. Cancel/retry keeps completed steps. Does not replace or visually validate an existing asset. 0 credits; storage quota applies.', { recipe: record, idempotency_key: schema.string().min(8).max(160).optional() }, false, (args, extra) => call('flexible-chain', args, 'POST', extra?.signal));
45
98
  tool('gripforge_armor_generate', 'Generate a modular armor kit', 'Queue a durable armor-kit job: one shared concept, isolated Imagine references, Meshy PBR models, bounded Blender interior preparation and paired GLB exports, then actual GripForge wearer review. Types: helmet, chest, belt, pauldron, bracer, glove, thigh, greave, boot, upperarm, gorget, tasset, undersuit. coverage=full adds a durable anatomical decomposition of the concept, rejects missing planned roles before mesh generation and records per-instance coverage fits. A piece count alone never proves body coverage. The optional undersuit is a locally manufactured adaptive textile which retains each wearer’s skin weights. Meshy pieces cost 11 credits per canonical type; the textile has no image/mesh charge. A new concept costs 1 credit; mirrors are included. concept_item must be an owned Library image. source_kit + explicit pieces regenerates only those types into a NEW work kit and pins unchanged pieces. Closing the call/page does not cancel. Poll gripforge_generation_read. Success is a work version, never automatic visual approval. The result includes a Grip Studio link, review scene, concepts, source models and Blender source.', {
@@ -52,19 +105,32 @@ export function registerSceneTools(register, options, schema = z) {
52
105
  tool('gripforge_armor_read', 'Read a modular armor kit', 'Read an owned kit manifest, semantic slots and immutable piece revisions. Equipment and instances remain independent. Returns the Grip Studio link. To correct selected types, call gripforge_armor_generate with source_kit and pieces.', { id: identifier }, true, (args, extra) => call(`armor-kits/${args.id}`, args, 'GET', extra?.signal));
53
106
  tool('gripforge_armor_repair', 'Repair unfinished armor pieces', 'Fork a failed/cancelled armor job into a new work kit. Preserve completed pieces and the exact shared concept; recreate only unfinished types by default. Optional pieces chooses types explicitly. Use prompt for targeted corrections. Original checkpoints and artifacts remain intact. Costs 11 credits per regenerated canonical type (plus a concept credit only if none survived). Use generation_retry instead for a transient provider or worker error to reuse the exact same steps without another charge.', { source_job: identifier, prompt: schema.string().min(3).max(2000).optional(), pieces: schema.array(schema.enum(['helmet', 'chest', 'belt', 'pauldron', 'bracer', 'glove', 'thigh', 'greave', 'boot', 'upperarm', 'gorget', 'tasset', 'undersuit'])).min(1).max(13).optional(), idempotency_key: schema.string().min(8).max(160).optional() }, false, (args, extra) => call('armor-kits', args, 'POST', extra?.signal));
54
107
  tool('gripforge_map_import', 'Migrate a saved terrain map', 'Queue a one-time materialization of an owned legacy terrain map into the shared Scene Engine. Preserve the saved spec, seed, resolution, family replacements, placed assets and removed props. Completed jobs return a canonical Studio link; source edits during migration cause a conflict rather than data loss.', { id: identifier }, false, (args, extra) => call('generation-jobs/map/import', { ...args, legacyMapId: args.id }, 'POST', extra?.signal));
55
- tool('gripforge_map_generate', 'Generate an editable map', 'Queue persistent map generation into the common Scene Engine. wizard: style realistic|stylized|lowpoly|handpainted; world open_world|closed_arena|dungeon|linear|battle_map; terrain plains|desert_canyon|forest|snow|island|volcanic; size small|medium|large; boundary fixed|infinite (open edge, finite terrain); spawn single|teams; teams 2|4. Result contains independent terrain, regions, paths, spawn zones and asset instances. Work is not automatically validated. Poll generation_read.', { prompt: schema.string().min(3).max(4000), wizard: record, seed: schema.number().int().optional(), idempotency_key: schema.string().min(8).max(160).optional() }, false, (args, extra) => call('generation-jobs/map', args, 'POST', extra?.signal));
108
+ tool('gripforge_map_schema', 'Map concept and generation workflow', 'Read the concept-first map workflow, immutable image reference format, costs and an example. Concepts, map work scenes and external engine delivery are separate steps.', {}, true, (args, extra) => call('generation-jobs/map', args, 'GET', extra?.signal));
109
+ tool('gripforge_map_generate', 'Create map concept views or an editable map', 'Queue a persistent map job. stage=concept creates a private master overview plus optional views and stops. views: overview,top_down,entrance,objective. All additional views are conditioned on the SAME pinned master; they are illustrations, not exact 3D renders. reference is an environment screenshot for a NEW layout. To add views to an existing concept, pass source_concept instead; the master is kept unchanged and is free. One credit per new image (full new 4-view set = 4); default is overview only. Poll generation_read with include_preview=true to see the views. After the user selects the concept, submit stage=build with the returned concept and a new idempotency key. Build costs 1 credit and is the default for existing callers; legacy concept_item accepts a Library image ID, while concept pins its exact version. wizard: look stylized|painted|toon|anime|realistic|lowpoly|pixel (rendering, separate from style; omit for the theme default); style realistic|stylized|lowpoly|handpainted (legacy wording); world open_world|closed_arena|dungeon|linear|battle_map; terrain auto|plains|desert_canyon|forest|snow|island|volcanic; size small|medium|large; boundary fixed|infinite (open edge, finite terrain); spawn single|teams; teams 2|4; boss,safeZone,extraction booleans. sources selects catalog/workspace/community props. Build uses the common Scene Engine with independent terrain, regions, paths, zones and asset instances. Cancel/retry keeps successful images and billing. No automatic visual validation, promotion or Unreal import.', {
110
+ prompt: schema.string().min(3).max(4000), wizard: mapWizard, stage: schema.enum(['concept', 'build']).optional(),
111
+ reference: imageRef.optional().describe('Pinned source-environment screenshot for stage=concept only. Upload to Library and pin first.'),
112
+ source_concept: imageRef.optional().describe('Existing immutable master to add views to, stage=concept only. Mutually exclusive with reference; never regenerate this master.'),
113
+ views: schema.array(schema.enum(['overview', 'top_down', 'entrance', 'objective'])).min(1).max(4).optional().describe('Unique requested views, stage=concept only. The master overview is always included; omitting views keeps overview only.'),
114
+ concept: imageRef.optional().describe('Exact chosen concept revision for stage=build. Mutually exclusive with concept_item.'),
115
+ concept_item: identifier.optional().describe('Legacy Library concept image ID. Prefer an immutable concept reference.'),
116
+ structure: record.optional().describe('Optional explicit gripforge.map-structures.v1 plan for stage=build: terraces with polygon/elevation and separate ramp/bridge connections. Read gripforge_map_schema for the contract. Preserves canyon voids; validates bridge clearance, endpoints and slopes. Uses durable per-piece assets and the common SceneDocument. Dimensions must match wizard.dimensions. Solo maps only; result is a structural work version requiring visual review.'),
117
+ sources: schema.array(schema.enum(['catalog', 'workspace', 'community'])).min(1).max(3).optional(),
118
+ seed: schema.number().int().min(-2147483647).max(2147483647).optional(), idempotency_key: schema.string().min(8).max(160).optional(),
119
+ }, false, (args, extra) => call('generation-jobs/map', args, 'POST', extra?.signal));
56
120
  tool('gripforge_scene_modify', 'Modify a selection with AI', 'Queue a scoped edit of saved scene nodes or a region. The server derives the allowed scope and preserves locks, gameplay zones, outside geometry and the last validated revision. Blender may fabricate missing assets. The result is a proposal requiring explicit application, never a silently replaced map.', { id: identifier, expectedRevision: revision, nodeIds: schema.array(identifier).min(1).max(100), prompt: schema.string().min(3).max(4000), blendDistance: schema.number().positive().max(1000).optional(), idempotency_key: schema.string().min(8).max(160).optional() }, false, (args, extra) => call(`scenes/${args.id}/modify`, args, 'POST', extra?.signal));
57
121
  tool('gripforge_scene_proposals', 'List proposed scene edits', 'List persistent proposals and their base revision, status and descriptions.', { id: identifier }, true, (args, extra) => call(`scenes/${args.id}/proposals`, args, 'GET', extra?.signal));
58
122
  tool('gripforge_scene_proposal_read', 'Read a proposed scene edit', 'Read the original scene, proposed document and scoped commands. Use the Studio link to inspect actual rendering before applying.', { id: identifier, proposal: identifier }, true, (args, extra) => call(`scenes/${args.id}/proposals/${args.proposal}`, args, 'GET', extra?.signal));
59
123
  tool('gripforge_scene_proposal_apply', 'Apply a reviewed proposal to work', 'Explicitly accept or discard a proposal after inspecting it. Accept requires expectedRevision to match the exact base and current work revision. Atomic: concurrent changes or discard abort the save. Creates work, never promotes validated.', { id: identifier, proposal: identifier, action: schema.enum(['accept', 'discard']), expectedRevision: revision.optional() }, false, (args, extra) => call(`scenes/${args.id}/proposals/${args.proposal}`, args, 'POST', extra?.signal));
60
124
  tool('gripforge_scene_schema', 'Shared scene engine format', 'Start here. Read the shared scene model, kinds, commands, limits and an editable example. Assets are immutable revisions; placed objects are independent instances. Scene saves create work revisions, never automatic visual approval.', {}, true, (args, extra) => call('scenes/schema', args, 'GET', extra?.signal));
125
+ tool('gripforge_scene_realism', 'Shared materials, automotive lighting and post-processing', 'Apply reusable architectural surface profiles and GTAO to a saved work scene. Read gripforge_scene_schema.realism first. assignments [{nodeId,material:"*" or a named slot,surface:{preset:preserve|brick|stone|metal|glass,detail:0..1,weathering:0..1,regions:[]}}]; surface:null removes only this local profile. Optional glass regions use explicit normalized mesh-local min/max bounds and interior parallax settings. Never guess masks from colours. ambientOcclusion {enabled,radius:0.02..3 metres,intensity:0..1,quality:low|medium|high}, or null to remove. Preserves transforms, source PBR maps, other instances and validated revisions. Returns Studio link for visual comparison. Optional quality {preset:performance|balanced|quality,resolutionScale:0.5..1,sharpness:0..0.5}, or null to restore the original presentation. Quality supports viewport resolution up to a bounded 4K budget. Optional automotive {look:studio|daylight,quality,lengthM,depthOfField:false,focusDistanceM} explicitly applies a whole-scene look: HDR strip reflections, contact AO, quality and restrained post-processing. Studio replaces an existing HDRI; daylight keeps it. Optional colorGrading/depthOfField overrides or null. DOF is perspective/capture only, disabled with volumetric passes. No generation charge, DLSS or automatic approval.', { id: identifier, expectedRevision: revision, assignments: schema.array(schema.object({ nodeId: identifier, material: schema.string().min(1).max(256).optional(), surface: record.nullable() })).max(100).optional(), ambientOcclusion: schema.object({ enabled: schema.boolean(), radius: schema.number().min(.02).max(3), intensity: schema.number().min(0).max(1), quality: schema.enum(['low', 'medium', 'high']) }).nullable().optional(), quality: schema.object({ preset: schema.enum(['performance', 'balanced', 'quality']), resolutionScale: schema.number().min(.5).max(1).optional(), sharpness: schema.number().min(0).max(.5).optional() }).nullable().optional(), automotive: schema.object({ look: schema.enum(['studio', 'daylight']).optional(), quality: schema.enum(['performance', 'balanced', 'quality']).optional(), lengthM: schema.number().min(1).max(25).optional(), ground: schema.boolean().optional(), depthOfField: schema.boolean().optional(), focusDistanceM: schema.number().min(.1).max(1000).optional() }).optional(), colorGrading: schema.object({ contrast: schema.number().min(.5).max(1.5), saturation: schema.number().min(0).max(1.5), temperature: schema.number().min(-1).max(1) }).nullable().optional(), depthOfField: schema.object({ enabled: schema.boolean(), focusDistanceM: schema.number().min(.1).max(1000), aperture: schema.number().min(0).max(.01), maxBlur: schema.number().min(0).max(.02) }).nullable().optional() }, false, (args, extra) => call(`scenes/${args.id}/realism`, args, 'POST', extra?.signal));
126
+ tool('gripforge_scene_optimize', 'Prepare a scene for fast browser loading', 'Queue resumable browser texture variants for a saved scene. Preserve original GLBs, geometry, transforms, PBR, animations and Library heads. Color textures up to 1024px, data maps up to 512px and terrain color up to 4096px; WebP encoding. Saves a new work revision only if expectedRevision still matches. Does not promote validated. Poll generation_read for byte savings and studio_url. 0 credits; storage quota applies. New Unreal imports do this automatically.', { id: identifier, expectedRevision: revision, idempotency_key: schema.string().min(8).max(160).optional() }, false, (args, extra) => call(`scenes/${args.id}/optimize`, args, 'POST', extra?.signal));
61
127
  tool('gripforge_scene_list', 'List workspace scenes', 'List scenes, work/validated revision pointers and links in the authenticated workspace.', {}, true, (args, extra) => call('scenes', args, 'GET', extra?.signal));
62
128
  tool('gripforge_scene_read', 'Read scene source', 'Read the editable SceneDocument at work, validated, or a numbered revision. Keep work_revision for optimistic writes. Validated can be absent.', { id: identifier, version: schema.union([schema.enum(['work', 'validated']), schema.number().int().positive()]).optional() }, true, (args, extra) => call(`scenes/${encodeURIComponent(String(args.id))}?version=${args.version ?? 'work'}`, args, 'GET', extra?.signal));
63
129
  tool('gripforge_scene_create', 'Create a workspace scene', 'Create a draft scene. Pin Library assets first and use returned assetId/revisionId references. The response contains a GripForge Studio link. Exporting a VFX definition must not include this test scene.', { document: record }, false, (args, extra) => call('scenes', args, 'POST', extra?.signal));
64
130
  tool('gripforge_scene_write', 'Save scene work revision', 'Save a complete SceneDocument. expectedRevision must equal the latest work revision. A 409 is a real conflict: reread before changing it. Does not change the validated revision or source Library assets.', { id: identifier, document: record, expectedRevision: schema.number().int().positive() }, false, (args, extra) => call(`scenes/${encodeURIComponent(String(args.id))}`, args, 'PUT', extra?.signal));
65
131
  tool('gripforge_scene_patch', 'Edit selected scene instances', 'Apply insert/remove/transform/replace/update commands atomically. Environment updates can configure shared 3D cloud layers (density, altitude, wind, colour and quality); see gripforge_scene_schema. Replace preserves id, transform, role, materials and attachments, and rejects incompatible named slots. Locked objects cannot be changed. Scope constrains regional edits; read the schema first.', { id: identifier, baseRevision: schema.number().int().positive(), commands: schema.array(record).min(1).max(20000), scope: record.optional() }, false, (args, extra) => call(`scenes/${encodeURIComponent(String(args.id))}`, args, 'PATCH', extra?.signal));
66
132
  tool('gripforge_scene_versions', 'Scene versions and visual reviews', 'Read work/current pointers, immutable revision history and trusted visual review evidence. A rejected or unavailable review does not change the validated version.', { id: identifier }, true, (args, extra) => call(`scenes/${args.id}/versions`, args, 'GET', extra?.signal));
67
- tool('gripforge_scene_review', 'Request an actual GripForge visual review', 'Queue a persistent render and independent visual review of the exact saved work revision. Poll generation_read. This does not promote or replace the validated version.', { id: identifier, expectedRevision: schema.number().int().positive(), prompt: schema.string().max(4000).optional(), idempotency_key: schema.string().min(8).max(160).optional() }, false, (args, extra) => call(`scenes/${args.id}/review`, args, 'POST', extra?.signal));
133
+ tool('gripforge_scene_review', 'Request an actual GripForge visual review', 'Queue a persistent render and independent visual review of the exact saved work revision. Poll generation_read. Optional assetRef records the review on the exact isolated asset revision as well; required for vehicle geometry/topology gates. This does not promote or replace the validated version.', { id: identifier, expectedRevision: schema.number().int().positive(), assetRef: imageRef.optional(), prompt: schema.string().max(4000).optional(), idempotency_key: schema.string().min(8).max(160).optional() }, false, (args, extra) => call(`scenes/${args.id}/review`, args, 'POST', extra?.signal));
68
134
  tool('gripforge_scene_promote', 'Set the visually approved scene version', 'Explicitly promote the latest work revision only if its trusted visual review matches the exact content, dependencies and current renderer. expectedCurrent must match the existing validated pointer or null. No client-supplied approval report is accepted.', { id: identifier, revision: schema.number().int().positive(), expectedCurrent: schema.number().int().positive().nullable() }, false, (args, extra) => call(`scenes/${args.id}/promote`, args, 'POST', extra?.signal));
69
135
  tool('gripforge_scene_asset_pin', 'Pin an immutable Library revision', 'Archive the owned Library resource bytes and metadata for use in scenes. Returns assetId/revisionId, capabilities, material/animation names and sockets. Repeating for identical content reuses the archive; it consumes workspace storage only once. Prefers the validated version for insertion; choose version=work to test the draft. Does not approve the asset visually.', { assetId: identifier, version: schema.enum(['current', 'work']).default('current') }, false, (args, extra) => call('scene-assets', args, 'POST', extra?.signal));
70
136
  tool('gripforge_scene_asset_versions', 'Read asset version and review', 'Read immutable asset work/current pointers and the trusted review of work. Does not create a version.', { assetId: identifier }, true, (args, extra) => call(`scene-assets/${args.assetId}/versions`, args, 'GET', extra?.signal));
@@ -72,7 +138,7 @@ export function registerSceneTools(register, options, schema = z) {
72
138
  tool('gripforge_scene_asset_promote', 'Set the visually approved asset version', 'Promote only when the exact work revision has a matching trusted review for the current renderer. Existing scene instances keep their pinned revision.', { assetId: identifier, revisionId: identifier, expectedCurrent: identifier.nullable() }, false, (args, extra) => call(`scene-assets/${args.assetId}/${args.revisionId}/promote`, args, 'POST', extra?.signal));
73
139
  tool('gripforge_fps_animation', 'Manufacture first-person weapon animations', 'Queue a persistent Blender job for paired FPS arms and an articulated pistol: idle, draw, fire, tactical reload and empty reload. Recipe: {version:1,preset:"pistol",name:"Pistol FPS",tempo:1}; tempo 0.7–1.3. Returns job_id immediately; poll gripforge_generation_read for the private Library asset, .blend source, event markers and Character Studio FPS link. Generated variants are work revisions, never automatically validated. Use gripforge_generation_cancel/retry; completed steps survive closing the page.', { recipe: record, idempotency_key: schema.string().min(8).max(160).optional() }, false, (args, extra) => call('fps-animations', args, 'POST', extra?.signal));
74
140
  tool('gripforge_generation_list', 'List persistent generation jobs', 'List workspace jobs. Jobs and completed steps survive closing the studio or restarting the web server and worker.', {}, true, (args, extra) => call('generation-jobs', args, 'GET', extra?.signal));
75
- tool('gripforge_generation_read', 'Read generation progress and result', 'Poll a job returned by a generation tool. Includes durable steps, progress, failure/retry state and the resulting work revision with a Studio link. include_preview returns an actual rendered VFX image when available. Success does not mean visual approval or promotion.', { id: identifier, include_preview: schema.boolean().optional() }, true, (args, extra) => call(`generation-jobs/${args.id}`, args, 'GET', extra?.signal));
141
+ tool('gripforge_generation_read', 'Read generation progress and result', 'Poll a job returned by a generation tool. Includes durable steps, progress, failure/retry state and the resulting work revision with a Studio link. include_preview returns saved map/building concept images (at most 4). Set preview_view to return one chosen view. Concepts are generated illustrations, not verified 3D renders. Success does not mean visual approval or promotion.', { id: identifier, include_preview: schema.boolean().optional(), preview_view: schema.enum(['overview', 'top_down', 'entrance', 'objective', 'front_right', 'front_left', 'rear_right', 'rear_left']).optional() }, true, (args, extra) => call(`generation-jobs/${args.id}`, args, 'GET', extra?.signal));
76
142
  tool('gripforge_generation_cancel', 'Cancel generation', 'Request worker cancellation while retaining completed steps and artifacts. Closing a page alone does not cancel a job.', { id: identifier }, false, (args, extra) => call(`generation-jobs/${args.id}`, { ...args, action: 'cancel' }, 'POST', extra?.signal));
77
143
  tool('gripforge_generation_retry', 'Retry interrupted generation', 'Retry a failed or cancelled job, reusing completed steps. An unconfirmed interrupted provider call may be executed again when explicitly retried.', { id: identifier }, false, (args, extra) => call(`generation-jobs/${args.id}`, { ...args, action: 'retry' }, 'POST', extra?.signal));
78
144
  tool('gripforge_blender_fabricate', 'Fabricate editable assets with Blender', 'Queue a bounded Blender recipe, never arbitrary Python. Saves GLB and .blend in the workspace as an unvalidated work version with a shared Scene Studio link. Read gripforge_scene_schema for recipe fields. Supports original primitive/custom meshes, PBR colors and keyed transforms. Poll gripforge_generation_read; final visual approval must use GripForge rendering, not Blender screenshots.', { recipe: record, idempotency_key: schema.string().min(8).max(160).optional() }, false, (args, extra) => call('generation-jobs/blender', args, 'POST', extra?.signal));
@@ -112,7 +112,7 @@ export function registerServerTools(register, options, schema = z) {
112
112
  }, { readOnly: false }, (args, extra) => api('servers', args, 'POST', extra?.signal));
113
113
  tool('gripforge_server_deploy', 'Deploy a game server', 'Create a Docker instance for this server and start it. Local demo uses a real container (python heartbeat unless a custom image is set). Mutating.', { id: serverId }, { readOnly: false }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}/deploy`, args, 'POST', extra?.signal));
114
114
  tool('gripforge_server_start', 'Start a game server', 'Start the existing instance. Mutating.', { id: serverId }, { readOnly: false }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}/start`, args, 'POST', extra?.signal));
115
- tool('gripforge_server_stop', 'Stop a game server', 'Stop the running instance. Mutating.', { id: serverId }, { readOnly: false }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}/stop`, args, 'POST', extra?.signal));
115
+ tool('gripforge_server_stop', 'Stop a game server', 'Stop the running instance. Mutating.', { id: serverId }, { readOnly: false, destructive: true }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}/stop`, args, 'POST', extra?.signal));
116
116
  tool('gripforge_server_restart', 'Restart a game server', 'Restart the running instance. Mutating.', { id: serverId }, { readOnly: false }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}/restart`, args, 'POST', extra?.signal));
117
117
  tool('gripforge_server_scale', 'Scale max players', 'Update desired max players. Does not resize cloud hardware (Docker provider has no node pool). Mutating.', { id: serverId, maxPlayers: schema.number().int().min(1).max(200) }, { readOnly: false }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}/scale`, args, 'POST', extra?.signal));
118
118
  tool('gripforge_server_delete', 'Delete a game server', 'HIGH IMPACT. Stops the instance and deletes the server. Requires confirm=true. Owner only.', { id: serverId, confirm: schema.literal(true).describe('Must be true. Production-destructive.') }, { readOnly: false, destructive: true }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}`, args, 'DELETE', extra?.signal, { confirm: true }));
@@ -127,7 +127,7 @@ export function registerServerTools(register, options, schema = z) {
127
127
  tool('gripforge_build_rollback', 'Rollback a build', 'Not implemented. Returns 501. Mutating / production-sensitive.', { id: buildId }, { readOnly: false, destructive: true }, (args, extra) => api(`game-builds/${encodeURIComponent(String(args.id))}/rollback`, args, 'POST', extra?.signal));
128
128
  tool('gripforge_players_list', 'List connected players', 'Sessions reported by the Unity SDK. Empty until the SDK calls player.joined.', { id: serverId }, { readOnly: true }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}/players`, args, 'GET', extra?.signal));
129
129
  tool('gripforge_player_get', 'Get a player session', 'Read one player session on a server.', { id: serverId, player_id: playerId }, { readOnly: true }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}/players/${encodeURIComponent(String(args.player_id))}`, args, 'GET', extra?.signal));
130
- tool('gripforge_player_kick', 'Kick a player', 'Requires the Unity SDK. Currently returns 501. Mutating.', { id: serverId, player_id: playerId }, { readOnly: false }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}/players/${encodeURIComponent(String(args.player_id))}/kick`, args, 'POST', extra?.signal));
130
+ tool('gripforge_player_kick', 'Kick a player', 'Requires the Unity SDK. Currently returns 501. Mutating.', { id: serverId, player_id: playerId }, { readOnly: false, destructive: true }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}/players/${encodeURIComponent(String(args.player_id))}/kick`, args, 'POST', extra?.signal));
131
131
  tool('gripforge_player_ban', 'Ban a player', 'HIGH IMPACT. Requires the Unity SDK. Currently returns 501. Owner only.', { id: serverId, player_id: playerId }, { readOnly: false, destructive: true }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}/players/${encodeURIComponent(String(args.player_id))}/ban`, args, 'POST', extra?.signal));
132
132
  tool('gripforge_game_broadcast', 'Broadcast to a match', 'Requires the Unity SDK. Currently returns 501. Never runs a shell on the host.', { id: serverId, message: schema.string().max(500).optional() }, { readOnly: false }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}/broadcast`, args, 'POST', extra?.signal));
133
133
  tool('gripforge_world_list', 'List worlds', 'Worlds stored for the workspace or a server’s project. Empty until worlds are implemented.', { id: serverId.optional() }, { readOnly: true }, (args, extra) => args.id
@@ -139,5 +139,5 @@ export function registerServerTools(register, options, schema = z) {
139
139
  tool('gripforge_match_list', 'List matches', 'Matches recorded for a server (SDK or API).', { id: serverId }, { readOnly: true }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}/matches`, args, 'GET', extra?.signal));
140
140
  tool('gripforge_match_get', 'Get a match', 'Read one match record.', { id: serverId, match_id: matchId }, { readOnly: true }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}/matches/${encodeURIComponent(String(args.match_id))}`, args, 'GET', extra?.signal));
141
141
  tool('gripforge_match_create', 'Create a match record', 'Records match.started. Does not spawn a new process. Mutating.', { id: serverId, map: schema.string().max(120).optional() }, { readOnly: false }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}/matches`, args, 'POST', extra?.signal));
142
- tool('gripforge_match_stop', 'Stop a match', 'Marks the match ended. Mutating.', { id: serverId, match_id: matchId }, { readOnly: false }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}/matches/${encodeURIComponent(String(args.match_id))}`, args, 'POST', extra?.signal));
142
+ tool('gripforge_match_stop', 'Stop a match', 'Marks the match ended. Mutating.', { id: serverId, match_id: matchId }, { readOnly: false, destructive: true }, (args, extra) => api(`servers/${encodeURIComponent(String(args.id))}/matches/${encodeURIComponent(String(args.match_id))}`, args, 'POST', extra?.signal));
143
143
  }
package/dist/server.js CHANGED
@@ -16,7 +16,22 @@ 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 { registerJoystickTools } from './joystick-tools.js';
20
+ import { registerAbilityTools } from './ability-tools.js';
21
+ import { registerAssetProductionTools } from './asset-production-tools.js';
22
+ import { registerCreatureRigTools } from './creature-rig-tools.js';
23
+ import { registerWeaponMotionTools } from './weapon-motion-tools.js';
24
+ import { registerShieldVfxTools } from './shield-vfx-tools.js';
25
+ import { registerProjectileTools } from './projectile-tools.js';
26
+ import { registerArchitectureTools } from './architecture-tools.js';
27
+ import { registerGameConceptTools } from './game-concept-tools.js';
28
+ import { registerMotionTools } from './motion-tools.js';
29
+ import { registerHeroAbilityTools } from './hero-ability-tools.js';
30
+ import { registerGameCreationTools } from './game-creation-tools.js';
19
31
  import { registerGameKitLocalTools } from './gamekit-deliver-local.js';
32
+ import { registerMapUnrealLocalTools } from './map-unreal-local.js';
33
+ import { registerMapUnrealImportLocalTools } from './map-unreal-import-local.js';
34
+ import { registerMapUnrealExportLocalTools } from './map-unreal-export-local.js';
20
35
  const API_URL = process.env.GRIPFORGE_API_URL ?? 'https://gripforge.ai';
21
36
  const API_KEY = process.env.GRIPFORGE_API_KEY;
22
37
  const MCP_SELF = '0.1.9';
@@ -27,6 +42,21 @@ registerSceneTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKe
27
42
  registerServerTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
28
43
  registerGameKitTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
29
44
  registerGameKitLocalTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY, userAgent: `gripforge-mcp/${MCP_SELF}` });
45
+ registerMapUnrealLocalTools(server.registerTool.bind(server));
46
+ registerMapUnrealImportLocalTools(server.registerTool.bind(server));
47
+ registerMapUnrealExportLocalTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
48
+ registerJoystickTools(server.registerTool.bind(server));
49
+ registerAbilityTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
50
+ registerCreatureRigTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
51
+ registerWeaponMotionTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
52
+ registerShieldVfxTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
53
+ registerProjectileTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
54
+ registerArchitectureTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
55
+ registerGameConceptTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
56
+ registerMotionTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
57
+ registerHeroAbilityTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
58
+ registerGameCreationTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
59
+ registerAssetProductionTools(server.registerTool.bind(server), { apiUrl: API_URL, getApiKey: () => API_KEY });
30
60
  const err = (text) => ({ content: [{ type: 'text', text }], isError: true });
31
61
  server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe…) onto a rigged character. ' +
32
62
  'Finds the hand bone across naming schemes, scales to body height, seats the grip, ' +
@@ -37,7 +67,9 @@ server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe
37
67
  'GripForge puts that point in the palm and skips all grip heuristics. ' +
38
68
  'Hand closing: rigs WITH finger bones get bind.gripPose rotations (applied by the snippets); ' +
39
69
  '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.', {
70
+ 'attached.glb with the fist baked in — the JSON bind alone cannot close a mitten hand. ' +
71
+ 'Attachment confidence is not visual validation. Inspect bind.review and preview the exported GLB ' +
72
+ 'in the target renderer at idle and during attacks; do not call a needs_review result validated.', {
41
73
  character_path: z.string().optional().describe('Absolute path to the rigged character (.glb .gltf .fbx .obj)'),
42
74
  prop_path: z.string().optional().describe('Absolute path to the prop mesh'),
43
75
  character_id: z.string().optional().describe('Library id of a character (lib_…)'),
@@ -218,7 +250,8 @@ server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe
218
250
  JSON.stringify({ bind: data.bind, confidence: data.confidence, exports: exportsObj, wrote, glbFile, library }, null, 2) +
219
251
  (credits ? `\ncredits: ${credits.remaining}/${credits.limit} remaining (${credits.plan})` : '') +
220
252
  (glbNotes.length ? `\nglb notes:\n- ${glbNotes.join('\n- ')}` : '') +
221
- mittenHint,
253
+ mittenHint +
254
+ '\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
255
  },
223
256
  ],
224
257
  };
@@ -450,6 +483,7 @@ server.tool('gripforge_generate_character', 'CREATE a Mixamo T-pose auto-rigged
450
483
  'Without an image, this is text-to-3D only. ' +
451
484
  'If the look may already be in the locker, call gripforge_style_kit FIRST. ' +
452
485
  '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.', {
486
+ idempotency_key: z.string().regex(/^[a-zA-Z0-9_.:-]{8,160}$/).optional().describe('Stable asset request key; reuse after an uncertain response.'),
453
487
  prompt: z.string().min(3).max(600).describe('Description. "Devil May Cry like enemy" → tagged devil-may-cry, kind=enemy.'),
454
488
  provider: z.enum(['tripo', 'meshy']).optional().describe('Default tripo. Forced meshy when concept_item / path / file_url is set.'),
455
489
  name: z.string().max(160).optional().describe('Library item name (default: the prompt)'),
@@ -460,7 +494,7 @@ server.tool('gripforge_generate_character', 'CREATE a Mixamo T-pose auto-rigged
460
494
  path: z.string().optional().describe('Absolute path to a local concept or T-pose image (PNG/JPG/WebP). Image-to-3D.'),
461
495
  file_url: z.string().optional().describe('https URL of a concept or T-pose image. Image-to-3D.'),
462
496
  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 }) => {
497
+ }, async ({ idempotency_key, prompt, provider, name, kind, polycount, underwear, concept_item, path, file_url, out_dir }) => {
464
498
  if (!API_KEY)
465
499
  return err('GRIPFORGE_API_KEY missing.');
466
500
  let image_base64;
@@ -488,6 +522,7 @@ server.tool('gripforge_generate_character', 'CREATE a Mixamo T-pose auto-rigged
488
522
  method: 'POST',
489
523
  headers: { ...apiHeaders(), 'content-type': 'application/json' },
490
524
  body: JSON.stringify({
525
+ idempotency_key,
491
526
  prompt,
492
527
  provider: image_base64 || concept_item ? 'meshy' : provider,
493
528
  name,
@@ -517,19 +552,21 @@ server.tool('gripforge_generate_character', 'CREATE a Mixamo T-pose auto-rigged
517
552
  server.tool('gripforge_generate_weapon', 'CREATE a game-ready weapon GLB (knife, gun, sword) and save it to Library as kind=weapon. ' +
518
553
  'Default Meshy, no T-pose, no rig. polycount default 4000. style=melee|gun for attach. 10 credits. ' +
519
554
  'Do NOT write one-off Meshy scripts.', {
555
+ idempotency_key: z.string().regex(/^[a-zA-Z0-9_.:-]{8,160}$/).optional().describe('Stable asset request key; reuse after an uncertain response.'),
520
556
  prompt: z.string().min(3).max(600).describe('e.g. CS2 default CT combat knife, silver blade, black grip'),
521
557
  name: z.string().max(160).optional(),
522
558
  polycount: z.number().optional().describe('Target triangles (default 4000)'),
523
559
  style: z.enum(['melee', 'gun', 'shield', 'staff']).optional().describe('Grip style for attach (inferred if omitted)'),
524
560
  provider: z.enum(['meshy', 'tripo']).optional().describe('Default meshy'),
525
561
  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 }) => {
562
+ }, async ({ idempotency_key, prompt, name, polycount, style, provider, out_dir }) => {
527
563
  if (!API_KEY)
528
564
  return err('GRIPFORGE_API_KEY missing.');
529
565
  const res = await fetch(`${API_URL}/api/v1/generate-character`, {
530
566
  method: 'POST',
531
567
  headers: { ...apiHeaders(), 'content-type': 'application/json' },
532
568
  body: JSON.stringify({
569
+ idempotency_key,
533
570
  prompt,
534
571
  name,
535
572
  kind: 'weapon',
@@ -554,24 +591,32 @@ server.tool('gripforge_generate_weapon', 'CREATE a game-ready weapon GLB (knife,
554
591
  }
555
592
  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
593
  });
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.', {
594
+ 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.', {
595
+ idempotency_key: z.string().regex(/^[a-zA-Z0-9_.:-]{8,160}$/).optional().describe('Stable asset request key; reuse after an uncertain response.'),
558
596
  prompt: z.string().min(3).max(600).describe('e.g. compact orange open-cockpit racing kart, isolated'),
559
597
  name: z.string().max(160).optional(),
560
598
  polycount: z.number().optional().describe('Target triangles (default 4000)'),
561
599
  provider: z.enum(['meshy', 'tripo']).optional().describe('Default meshy'),
600
+ concept_item: z.string().min(1).max(160).optional().describe('Owned concept image in this workspace; forces Meshy image-to-3D.'),
601
+ 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.'),
602
+ 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.'),
603
+ optimize: z.boolean().optional().describe('Default true; false retains the provider mesh for close-up review.'),
562
604
  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 }) => {
605
+ 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.'),
606
+ }, async ({ idempotency_key, prompt, name, polycount, provider, concept_item, concept_items, meshQuality, optimize, out_dir, subtype }) => {
564
607
  if (!API_KEY)
565
608
  return err('GRIPFORGE_API_KEY missing.');
566
609
  const res = await fetch(`${API_URL}/api/v1/generate-character`, {
567
610
  method: 'POST',
568
611
  headers: { ...apiHeaders(), 'content-type': 'application/json' },
569
612
  body: JSON.stringify({
613
+ idempotency_key,
570
614
  prompt,
571
615
  name,
572
616
  kind: 'prop',
617
+ subtype,
573
618
  polycount: polycount ?? 4000,
574
- provider: provider ?? 'meshy',
619
+ provider: provider ?? 'meshy', concept_item, concept_items, meshQuality, optimize,
575
620
  }),
576
621
  });
577
622
  const data = (await res.json().catch(() => ({})));
@@ -829,11 +874,13 @@ server.tool('gripforge_concept_correct', 'Turn a character concept image into a
829
874
  return err(String(data.error ?? res.status));
830
875
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
831
876
  });
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.', {
877
+ 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.', {
833
878
  kind: KIND.optional().describe('Filter by kind'),
834
879
  q: z.string().optional().describe('Search name/filename'),
835
880
  style: z.string().optional().describe('Game look (devil-may-cry, dmc, genshin)'),
836
- }, async ({ kind, q, style }) => {
881
+ sort: z.enum(['newest', 'rank']).optional().describe('newest (default) or rank: best fit first'),
882
+ target: z.enum(['mobile', 'desktop']).optional().describe('Platform for the rank budgets. Defaults to mobile.'),
883
+ }, async ({ kind, q, style, sort, target }) => {
837
884
  if (!API_KEY)
838
885
  return err('GRIPFORGE_API_KEY missing.');
839
886
  const qs = new URLSearchParams();
@@ -843,12 +890,34 @@ server.tool('gripforge_library_list', 'List the GripForge Library locker (charac
843
890
  qs.set('q', q);
844
891
  if (style)
845
892
  qs.set('style', style);
893
+ if (sort === 'rank')
894
+ qs.set('sort', 'rank');
895
+ if (target)
896
+ qs.set('target', target);
846
897
  const res = await fetch(`${API_URL}/api/v1/library?${qs}`, { headers: apiHeaders() });
847
898
  const data = await res.json().catch(() => ({}));
848
899
  if (!res.ok)
849
900
  return err(String(data.error ?? res.status));
850
901
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
851
902
  });
903
+ 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.', {
904
+ id: z.string().describe('Library id of the master model (lib_…)'),
905
+ triangles: z.number().int().min(500).max(200000).describe('Target triangles, e.g. 12000 for a mobile car, 40000 desktop'),
906
+ texture: z.union([z.literal(512), z.literal(1024), z.literal(2048), z.literal(4096)]).optional().describe('Max texture size in px (default 2048)'),
907
+ name: z.string().max(160).optional(),
908
+ }, async ({ id, triangles, texture, name }) => {
909
+ if (!API_KEY)
910
+ return err('GRIPFORGE_API_KEY missing.');
911
+ const res = await fetch(`${API_URL}/api/v1/library/${encodeURIComponent(id)}/optimize`, {
912
+ method: 'POST',
913
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
914
+ body: JSON.stringify({ triangles, texture, name }),
915
+ });
916
+ const data = await res.json().catch(() => ({}));
917
+ if (!res.ok)
918
+ return err(String(data.error ?? res.status));
919
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
920
+ });
852
921
  server.tool('gripforge_library_get', 'Get one Library item (metadata + signed file URL).', { id: z.string().describe('Library id (lib_…)') }, async ({ id }) => {
853
922
  if (!API_KEY)
854
923
  return err('GRIPFORGE_API_KEY missing.');
@@ -1110,7 +1179,7 @@ server.tool('gripforge_audio_kit', 'Search locker + Community SFX by description
1110
1179
  hits,
1111
1180
  wrote: [],
1112
1181
  hint: wanted
1113
- ? 'Pick by description/use. Locker ids: gripforge_library_pull. Community ids: clone first (1 credit), then pull.'
1182
+ ? '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
1183
  : 'Pass prompt to filter. Descriptions are on each hit.',
1115
1184
  };
1116
1185
  if (out_dir) {
@@ -1343,16 +1412,73 @@ server.tool('gripforge_hitbox', 'Body capsules + weapons[] (one per gf_prop_ wra
1343
1412
  return err(String(data.error ?? res.status));
1344
1413
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1345
1414
  });
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.', {
1415
+ 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
1416
  id: z.string().describe('Library id of the vehicle GLB (lib_…)'),
1348
1417
  apply: z.boolean().optional().describe('default true — rewrite the Library file with the split wheels'),
1349
- }, async ({ id, apply }) => {
1418
+ normalize: z.boolean().optional().describe('nose on +Z, metres, wheels on the ground'),
1419
+ reverse: z.boolean().optional().describe('the nose came out backwards: turn the vehicle 180° (with normalize)'),
1420
+ 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…)'),
1421
+ }, async ({ id, apply, normalize, length_m, reverse }) => {
1350
1422
  if (!API_KEY)
1351
1423
  return err('GRIPFORGE_API_KEY missing.');
1352
1424
  const res = await fetch(`${API_URL}/api/v1/vehicle-wheels`, {
1353
1425
  method: 'POST',
1354
1426
  headers: { ...apiHeaders(), 'content-type': 'application/json' },
1355
- body: JSON.stringify({ id, apply }),
1427
+ body: JSON.stringify({ id, apply, normalize, length_m, reverse }),
1428
+ });
1429
+ const data = await res.json().catch(() => ({}));
1430
+ if (!res.ok)
1431
+ return err(String(data.error ?? res.status));
1432
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1433
+ });
1434
+ 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. Poll/cancel/retry generation-jobs. 0 provider credits. Legacy id without repair uses one-time material/wheel finish and rewrites its Library file.', {
1435
+ id: z.string().optional().describe('Library id; with repair pins source, without repair uses legacy finish'),
1436
+ source: z.object({ assetId: z.string(), revisionId: z.string(), fileRole: z.string().optional() }).optional().describe('Pinned static source, instead of id'),
1437
+ repair: z.object({
1438
+ 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),
1439
+ 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'),
1440
+ cleanPaint: z.boolean().optional(), paintColor: z.tuple([z.number(), z.number(), z.number()]).optional(),
1441
+ calipers: z.object({ color: z.tuple([z.number(), z.number(), z.number()]), tolerance: z.number().min(.05).max(.4).optional() }).optional(),
1442
+ features: z.object({
1443
+ replaceBody: z.boolean().optional().describe('Explicitly replace coachwork in a new work copy using structural panels; preserve wheels and Body calipers.'),
1444
+ panels: z.array(z.object({
1445
+ name: z.string(), points: z.array(z.array(z.tuple([z.number(), z.number(), z.number()])).min(2).max(12)).min(2).max(12),
1446
+ structural: z.boolean().optional().describe('New coachwork shell, required with replaceBody; cannot fit or clip the discarded source.'),
1447
+ normal: z.tuple([z.number(), z.number(), z.number()]).optional(),
1448
+ segmentsU: z.number().int().min(4).max(96).optional(), segmentsV: z.number().int().min(4).max(96).optional(),
1449
+ 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() }).optional(),
1450
+ replace: z.object({ axis: z.enum(['x', 'y', 'z']), depthM: z.number().min(.001).max(.5).optional() }).optional(),
1451
+ 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(),
1452
+ })).max(32).optional(),
1453
+ curves: z.array(z.object({
1454
+ name: z.string(), points: z.array(z.tuple([z.number(), z.number(), z.number()])).min(2).max(128),
1455
+ 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(),
1456
+ radiusM: z.number().min(.0005).max(.08).optional(), closed: z.boolean().optional(), mirrorX: z.boolean().optional(),
1457
+ 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() }).optional(),
1458
+ })).max(256).optional(),
1459
+ marks: z.array(z.object({
1460
+ 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(),
1461
+ 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()]),
1462
+ 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(),
1463
+ outline: z.array(z.tuple([z.number().min(-.5).max(.5), z.number().min(-.5).max(.5)])).min(3).max(64).optional(),
1464
+ artwork: z.object({ assetId: z.string(), revisionId: z.string(), fileRole: z.string().optional() }).optional(), text: z.string().min(1).max(48).optional(),
1465
+ 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() }).optional(),
1466
+ })).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.'),
1467
+ }).optional().describe('Measured panels, curves, badges and decals; explicit PBR, clipping, symmetry, 250k vertex budget.'),
1468
+ rig: z.object({ wheelRadius: z.number(), wheelWidth: z.number() }).optional(),
1469
+ 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.'),
1470
+ }).optional().describe('Guided surface repair; never edits the source'),
1471
+ name: z.string().optional(), idempotency_key: z.string().min(8).max(160).optional(),
1472
+ smooth: z.number().int().min(0).max(60).optional().describe('body smoothing passes (default 15, 0 = none)'),
1473
+ wheels: z.boolean().optional().describe('false keeps the generated wheels'),
1474
+ segments: z.number().int().min(12).max(128).optional().describe('wheel roundness: 64 default, 24 for mobile'),
1475
+ }, async (args) => {
1476
+ if (!API_KEY)
1477
+ return err('GRIPFORGE_API_KEY missing.');
1478
+ const res = await fetch(`${API_URL}/api/v1/vehicle-finish`, {
1479
+ method: 'POST',
1480
+ headers: { ...apiHeaders(), 'content-type': 'application/json' },
1481
+ body: JSON.stringify(args),
1356
1482
  });
1357
1483
  const data = await res.json().catch(() => ({}));
1358
1484
  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
+ }