@gripforgeai/mcp 0.1.2 → 0.1.5
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 +59 -2
- package/dist/server.js +778 -34
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,6 +3,39 @@
|
|
|
3
3
|
Attach weapons & props to rigged characters — from Claude Code, Cursor or any
|
|
4
4
|
MCP client. Thin client for the [GripForge](https://gripforge.ai) API.
|
|
5
5
|
|
|
6
|
+
## Hosted endpoint (zero install)
|
|
7
|
+
|
|
8
|
+
No Node required — point any remote-capable MCP client at:
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
https://gripforge.ai/mcp
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Auth: `x-api-key: gf_...` header (or `Authorization: Bearer`). Assets are passed
|
|
15
|
+
as **URLs** (`character_url`, `prop_url`) — Tripo/Meshy download links work
|
|
16
|
+
directly.
|
|
17
|
+
|
|
18
|
+
```jsonc
|
|
19
|
+
// Cursor (.cursor/mcp.json)
|
|
20
|
+
{
|
|
21
|
+
"mcpServers": {
|
|
22
|
+
"gripforge": {
|
|
23
|
+
"url": "https://gripforge.ai/mcp",
|
|
24
|
+
"headers": { "x-api-key": "gf_..." }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Example
|
|
31
|
+
|
|
32
|
+
> "Attach this sword to my knight, right hand, then export the armed GLB."
|
|
33
|
+
|
|
34
|
+
The agent calls `gripforge_attach` with the two asset URLs (or local paths with
|
|
35
|
+
the npm package); GripForge finds the hand bone, scales the prop to the
|
|
36
|
+
character's hand, closes the fist around the grip and returns the bind JSON,
|
|
37
|
+
ready-to-paste Three.js / Unity / Godot snippets, and optionally the armed GLB.
|
|
38
|
+
|
|
6
39
|
## Install (Claude Code)
|
|
7
40
|
|
|
8
41
|
```bash
|
|
@@ -50,12 +83,36 @@ Also works with Cursor, Windsurf and any MCP-compatible client — same
|
|
|
50
83
|
|
|
51
84
|
## Tools
|
|
52
85
|
|
|
53
|
-
- `
|
|
86
|
+
- `gripforge_style_kit` — resolve "Devil May Cry like" / "genshin" to locker ids
|
|
87
|
+
already tagged with that look. **Call this before generating.** Reuse the ids.
|
|
88
|
+
- `gripforge_generate_character` — new T-pose + auto-rig into Library (10 credits).
|
|
89
|
+
`kind=enemy` or the word "enemy" in the prompt. Skip this if style_kit already
|
|
90
|
+
returned a character.
|
|
91
|
+
- `gripforge_concept_correct` — concept image → strict T-pose sheet (1 credit).
|
|
92
|
+
- `gripforge_attach` — character + prop (paths or Library ids) in, bone-local bind + Three.js / Unity /
|
|
54
93
|
Godot snippets out. Styles: melee, gun, shield, staff (scythe/polearm).
|
|
55
|
-
|
|
94
|
+
`attach_id` reuses an armed bind as the character; `prop_id_2` / `prop_path_2` attaches a second
|
|
95
|
+
held item (off-hand). With `export_glb: true` (+ `out_dir`) it also writes `attached.glb`: the
|
|
56
96
|
character with the fist closed and the prop attached, textures preserved —
|
|
57
97
|
use this for mitten-hand rigs, whose closed fist cannot travel in a JSON bind.
|
|
58
98
|
- `gripforge_formats` — supported formats & options.
|
|
99
|
+
- `gripforge_library_list` / `get` / `push` / `pull` / `tag` — the Library locker.
|
|
100
|
+
`kind=animation` is valid. `pull` writes the mesh **and VFX sidecars** (`tex0..png/json`)
|
|
101
|
+
into the open repo (`out_dir`, default `./gripforge-library`).
|
|
102
|
+
Saving a bind after attach is not a second credit.
|
|
103
|
+
- `gripforge_animate` — idle/walk/run/attack×3/hit/death/dodge onto a Mixamo Library char.
|
|
104
|
+
- `gripforge_retarget` — clip from skeleton A onto skeleton B.
|
|
105
|
+
- `gripforge_rest_pose` — arms-down rest computed against this mesh (weapons stay on an armed bind).
|
|
106
|
+
- `gripforge_render` — server PNG preview so the agent can see without a browser.
|
|
107
|
+
- `gripforge_scene_kit` — locker props + suggested layout for a game look.
|
|
108
|
+
- `gripforge_hitbox` — body capsules + weapon reach from a bind.
|
|
109
|
+
- `gripforge_texture_prep` — local path or `texture_id` → seamless blend + faithful
|
|
110
|
+
lanczos upscale (1×/2×/4×, max 1024 or 2048). Writes the PNG into `out_dir` and
|
|
111
|
+
returns the Library albedo URL. Not a generator. No credit.
|
|
112
|
+
- `gripforge_vfx` / `gripforge_vfx_preview` — strike VFX (admin keys).
|
|
113
|
+
- `gripforge_shaders` — list the authorized shader catalog (`{ id, name, engines }`). Search `q=slash_reveal`.
|
|
114
|
+
- `gripforge_shader_pull` — `id` + `engine` (`godot`|`unity`|`three`) + `out_dir` →
|
|
115
|
+
write sources into the repo and return an assignment snippet. **v1 pull is free**.
|
|
59
116
|
|
|
60
117
|
## Env
|
|
61
118
|
|
package/dist/server.js
CHANGED
|
@@ -15,18 +15,25 @@ import { z } from 'zod';
|
|
|
15
15
|
const API_URL = process.env.GRIPFORGE_API_URL ?? 'https://gripforge.ai';
|
|
16
16
|
const API_KEY = process.env.GRIPFORGE_API_KEY;
|
|
17
17
|
const SUPPORTED = ['.glb', '.gltf', '.fbx', '.obj'];
|
|
18
|
-
const server = new McpServer({ name: 'gripforge', version: '0.1.
|
|
18
|
+
const server = new McpServer({ name: 'gripforge', version: '0.1.5' });
|
|
19
19
|
const err = (text) => ({ content: [{ type: 'text', text }], isError: true });
|
|
20
20
|
server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe…) onto a rigged character. ' +
|
|
21
21
|
'Finds the hand bone across naming schemes, scales to body height, seats the grip, ' +
|
|
22
22
|
'and returns a bind + ready-to-paste Three.js / Unity / Godot snippets. ' +
|
|
23
|
+
'Pass local paths or Library ids (character_id / prop_id). After attach the bind ' +
|
|
24
|
+
'(+ armed GLB when export_glb) is saved in Library — not a second credit. ' +
|
|
23
25
|
'Exact placement: add an Empty/locator named "grip" on the handle inside the weapon file — ' +
|
|
24
26
|
'GripForge puts that point in the palm and skips all grip heuristics. ' +
|
|
25
27
|
'Hand closing: rigs WITH finger bones get bind.gripPose rotations (applied by the snippets); ' +
|
|
26
28
|
'rigs WITHOUT finger bones (mitten hands) need export_glb: true + out_dir to receive ' +
|
|
27
29
|
'attached.glb with the fist baked in — the JSON bind alone cannot close a mitten hand.', {
|
|
28
|
-
character_path: z.string().describe('Absolute path to the rigged character (.glb .gltf .fbx .obj)'),
|
|
29
|
-
prop_path: z.string().describe('Absolute path to the prop mesh'),
|
|
30
|
+
character_path: z.string().optional().describe('Absolute path to the rigged character (.glb .gltf .fbx .obj)'),
|
|
31
|
+
prop_path: z.string().optional().describe('Absolute path to the prop mesh'),
|
|
32
|
+
character_id: z.string().optional().describe('Library id of a character (lib_…)'),
|
|
33
|
+
attach_id: z.string().optional().describe('Library bind id — use the armed GLB as the character'),
|
|
34
|
+
prop_id: z.string().optional().describe('Library id of a prop (lib_…)'),
|
|
35
|
+
prop_id_2: z.string().optional().describe('Second held item (off-hand), attached after the first'),
|
|
36
|
+
prop_path_2: z.string().optional().describe('Local path of a second held item (off-hand)'),
|
|
30
37
|
style: z.enum(['melee', 'gun', 'shield', 'staff']).optional().describe('Grip style (default: guessed from the filename)'),
|
|
31
38
|
hand: z.enum(['right', 'left']).optional().describe('Hand side (default right)'),
|
|
32
39
|
height_ratio: z.number().min(0.05).max(1.5).optional().describe('Prop size as a fraction of body height'),
|
|
@@ -46,30 +53,48 @@ server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe
|
|
|
46
53
|
.describe('Bake character + closed fist + prop into one attached.glb (requires out_dir; .glb/.gltf inputs only). ' +
|
|
47
54
|
'Use this for mitten-hand rigs: the closed fist cannot travel in the bind JSON.'),
|
|
48
55
|
out_dir: z.string().optional().describe('Write bind.json + engine snippets into this folder'),
|
|
49
|
-
}, async ({ character_path, prop_path, style, hand, height_ratio, fist, grip_offset, ai_refine, export_glb, out_dir }) => {
|
|
56
|
+
}, async ({ character_path, prop_path, character_id, attach_id, prop_id, prop_id_2, prop_path_2, style, hand, height_ratio, fist, grip_offset, ai_refine, export_glb, out_dir }) => {
|
|
50
57
|
if (!API_KEY) {
|
|
51
58
|
return err('GRIPFORGE_API_KEY missing. Create a free account at ' +
|
|
52
59
|
API_URL +
|
|
53
60
|
'/login then set the key in this MCP server env:\n' +
|
|
54
61
|
'"env": { "GRIPFORGE_API_KEY": "gf_..." }');
|
|
55
62
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
if (!character_id && !character_path && !attach_id)
|
|
64
|
+
return err('Provide character_path, character_id or attach_id.');
|
|
65
|
+
if (!prop_id && !prop_path)
|
|
66
|
+
return err('Provide prop_path or prop_id.');
|
|
67
|
+
if (export_glb && !out_dir) {
|
|
68
|
+
return err('export_glb needs out_dir — that is where attached.glb will be written.');
|
|
69
|
+
}
|
|
70
|
+
const form = new FormData();
|
|
71
|
+
if (attach_id) {
|
|
72
|
+
form.append('attach_id', attach_id);
|
|
73
|
+
}
|
|
74
|
+
else if (character_id) {
|
|
75
|
+
form.append('character_id', character_id);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
const charPath = resolve(character_path);
|
|
79
|
+
if (!SUPPORTED.includes(extname(charPath).toLowerCase())) {
|
|
80
|
+
return err(`Unsupported format: ${charPath} — use ${SUPPORTED.join(' ')}`);
|
|
61
81
|
}
|
|
82
|
+
form.append('character', new Blob([await readFile(charPath)]), basename(charPath));
|
|
83
|
+
}
|
|
84
|
+
if (prop_id) {
|
|
85
|
+
form.append('prop_id', prop_id);
|
|
62
86
|
}
|
|
63
|
-
|
|
87
|
+
else {
|
|
88
|
+
const propPath = resolve(prop_path);
|
|
89
|
+
if (!SUPPORTED.includes(extname(propPath).toLowerCase())) {
|
|
90
|
+
return err(`Unsupported format: ${propPath} — use ${SUPPORTED.join(' ')}`);
|
|
91
|
+
}
|
|
92
|
+
form.append('prop', new Blob([await readFile(propPath)]), basename(propPath));
|
|
93
|
+
}
|
|
94
|
+
if (character_path && prop_path && resolve(character_path) === resolve(prop_path)) {
|
|
64
95
|
return err('character_path and prop_path point to the same file. Export the weapon as its own GLB ' +
|
|
65
96
|
'(Blender: select the weapon only, File > Export > glTF, check "Selected Objects") and pass that as prop_path.');
|
|
66
97
|
}
|
|
67
|
-
if (export_glb && !out_dir) {
|
|
68
|
-
return err('export_glb needs out_dir — that is where attached.glb will be written.');
|
|
69
|
-
}
|
|
70
|
-
const form = new FormData();
|
|
71
|
-
form.append('character', new Blob([await readFile(charPath)]), basename(charPath));
|
|
72
|
-
form.append('prop', new Blob([await readFile(propPath)]), basename(propPath));
|
|
73
98
|
if (style)
|
|
74
99
|
form.append('style', style);
|
|
75
100
|
if (hand)
|
|
@@ -82,7 +107,7 @@ server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe
|
|
|
82
107
|
form.append('grip_offset', grip_offset.join(','));
|
|
83
108
|
if (ai_refine)
|
|
84
109
|
form.append('refine', 'ai');
|
|
85
|
-
if (export_glb)
|
|
110
|
+
if (export_glb || prop_id_2 || prop_path_2)
|
|
86
111
|
form.append('export', 'glb');
|
|
87
112
|
form.append('fingers', '1');
|
|
88
113
|
let res;
|
|
@@ -92,7 +117,7 @@ server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe
|
|
|
92
117
|
headers: {
|
|
93
118
|
'x-api-key': API_KEY,
|
|
94
119
|
'x-gripforge-client': 'mcp',
|
|
95
|
-
'user-agent': 'gripforge-mcp/0.1',
|
|
120
|
+
'user-agent': 'gripforge-mcp/0.1.5',
|
|
96
121
|
},
|
|
97
122
|
body: form,
|
|
98
123
|
});
|
|
@@ -100,7 +125,7 @@ server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe
|
|
|
100
125
|
catch {
|
|
101
126
|
return err(`GripForge API unreachable at ${API_URL} — check GRIPFORGE_API_URL.`);
|
|
102
127
|
}
|
|
103
|
-
|
|
128
|
+
let data = (await res.json().catch(() => ({})));
|
|
104
129
|
if (res.status === 401)
|
|
105
130
|
return err('Invalid GRIPFORGE_API_KEY — check it on ' + API_URL + '/account#api');
|
|
106
131
|
if (res.status === 403) {
|
|
@@ -112,6 +137,31 @@ server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe
|
|
|
112
137
|
}
|
|
113
138
|
if (!res.ok)
|
|
114
139
|
return err(`Attach failed (${res.status}): ${String(data.error ?? 'unknown error')}`);
|
|
140
|
+
if (prop_id_2 || prop_path_2) {
|
|
141
|
+
const first = data.library;
|
|
142
|
+
if (!first?.id)
|
|
143
|
+
return err('first attach did not save a Library bind — cannot attach second prop');
|
|
144
|
+
const form2 = new FormData();
|
|
145
|
+
form2.append('attach_id', first.id);
|
|
146
|
+
if (prop_id_2)
|
|
147
|
+
form2.append('prop_id', prop_id_2);
|
|
148
|
+
else {
|
|
149
|
+
const p2 = resolve(prop_path_2);
|
|
150
|
+
form2.append('prop', new Blob([await readFile(p2)]), basename(p2));
|
|
151
|
+
}
|
|
152
|
+
form2.append('export', 'glb');
|
|
153
|
+
form2.append('fingers', '1');
|
|
154
|
+
form2.append('hand', hand === 'left' ? 'right' : 'left');
|
|
155
|
+
const res2 = await fetch(`${API_URL}/api/v1/attach`, {
|
|
156
|
+
method: 'POST',
|
|
157
|
+
headers: { 'x-api-key': API_KEY, 'x-gripforge-client': 'mcp', 'user-agent': 'gripforge-mcp/0.1.5' },
|
|
158
|
+
body: form2,
|
|
159
|
+
});
|
|
160
|
+
const data2 = (await res2.json().catch(() => ({})));
|
|
161
|
+
if (!res2.ok)
|
|
162
|
+
return err(`second attach failed (${res2.status}): ${String(data2.error ?? 'unknown error')}`);
|
|
163
|
+
data = { ...data2, first_attach: first.id };
|
|
164
|
+
}
|
|
115
165
|
const exportsObj = (data.exports ?? {});
|
|
116
166
|
let wrote = null;
|
|
117
167
|
let glbFile = null;
|
|
@@ -136,6 +186,7 @@ server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe
|
|
|
136
186
|
wrote = dir;
|
|
137
187
|
}
|
|
138
188
|
const credits = data.credits;
|
|
189
|
+
const library = data.library;
|
|
139
190
|
// Mitten rigs (no real finger bones): the fist is a mesh swap that cannot
|
|
140
191
|
// travel in the bind JSON — steer the caller toward export_glb.
|
|
141
192
|
const bind = data.bind;
|
|
@@ -147,11 +198,17 @@ server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe
|
|
|
147
198
|
'Re-run gripforge_attach with export_glb: true and an out_dir to get attached.glb ' +
|
|
148
199
|
'(character with the fist closed + prop attached, textures preserved).'
|
|
149
200
|
: '';
|
|
201
|
+
const upgrade = typeof data.mcp_upgrade === 'string'
|
|
202
|
+
? data.mcp_upgrade
|
|
203
|
+
: typeof exportsObj.mcp_upgrade === 'string'
|
|
204
|
+
? exportsObj.mcp_upgrade
|
|
205
|
+
: '';
|
|
150
206
|
return {
|
|
151
207
|
content: [
|
|
152
208
|
{
|
|
153
209
|
type: 'text',
|
|
154
|
-
text:
|
|
210
|
+
text: (upgrade ? upgrade + '\n\n' : '') +
|
|
211
|
+
JSON.stringify({ bind: data.bind, confidence: data.confidence, exports: exportsObj, wrote, glbFile, library }, null, 2) +
|
|
155
212
|
(credits ? `\ncredits: ${credits.remaining}/${credits.limit} remaining (${credits.plan})` : '') +
|
|
156
213
|
(glbNotes.length ? `\nglb notes:\n- ${glbNotes.join('\n- ')}` : '') +
|
|
157
214
|
mittenHint,
|
|
@@ -159,19 +216,706 @@ server.tool('gripforge_attach', 'Attach a prop (sword, shield, gun, staff/scythe
|
|
|
159
216
|
],
|
|
160
217
|
};
|
|
161
218
|
});
|
|
162
|
-
server.tool('
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
},
|
|
174
|
-
|
|
175
|
-
})
|
|
219
|
+
server.tool('getAttachmentFrame', 'Skeleton attachment frame. Outward = R_Thigh−L_Thigh (right) or opposite (left). Up = Knee→Hip / Ankle→Knee. No Euler.', {
|
|
220
|
+
character_path: z.string().describe('Absolute path to the rigged character'),
|
|
221
|
+
bodyPart: z.enum(['right_thigh', 'left_thigh', 'right_shin', 'left_shin']),
|
|
222
|
+
}, async ({ character_path, bodyPart }) => {
|
|
223
|
+
if (!API_KEY)
|
|
224
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
225
|
+
const form = new FormData();
|
|
226
|
+
form.append('character', new Blob([await readFile(resolve(character_path))]), basename(character_path));
|
|
227
|
+
form.append('bodyPart', bodyPart);
|
|
228
|
+
const res = await fetch(`${API_URL}/api/v1/attachment-frame`, {
|
|
229
|
+
method: 'POST',
|
|
230
|
+
headers: { 'x-api-key': API_KEY, 'x-gripforge-client': 'mcp' },
|
|
231
|
+
body: form,
|
|
232
|
+
});
|
|
233
|
+
const data = await res.json().catch(() => ({}));
|
|
234
|
+
if (!res.ok)
|
|
235
|
+
return err(String(data.error ?? res.status));
|
|
236
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
237
|
+
});
|
|
238
|
+
server.tool('getWearableFrame', 'Analyze a wearable once: up, bodyFacing, outward, anchor. Never rotate X = 180°.', {
|
|
239
|
+
asset_path: z.string().describe('Absolute path to the armor piece'),
|
|
240
|
+
type: z.enum(['thigh', 'greave']).optional(),
|
|
241
|
+
side: z.enum(['left', 'right']).optional(),
|
|
242
|
+
}, async ({ asset_path, type, side }) => {
|
|
243
|
+
if (!API_KEY)
|
|
244
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
245
|
+
const form = new FormData();
|
|
246
|
+
form.append('asset', new Blob([await readFile(resolve(asset_path))]), basename(asset_path));
|
|
247
|
+
if (type)
|
|
248
|
+
form.append('type', type);
|
|
249
|
+
if (side)
|
|
250
|
+
form.append('side', side);
|
|
251
|
+
const res = await fetch(`${API_URL}/api/v1/wearable-frame`, {
|
|
252
|
+
method: 'POST',
|
|
253
|
+
headers: { 'x-api-key': API_KEY, 'x-gripforge-client': 'mcp' },
|
|
254
|
+
body: form,
|
|
255
|
+
});
|
|
256
|
+
const data = await res.json().catch(() => ({}));
|
|
257
|
+
if (!res.ok)
|
|
258
|
+
return err(String(data.error ?? res.status));
|
|
259
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
260
|
+
});
|
|
261
|
+
const MCP_SELF = '0.1.5';
|
|
262
|
+
server.tool('gripforge_formats', 'List supported mesh formats, grip styles and hands. Also reports this MCP client version vs latest.', {}, async () => {
|
|
263
|
+
let latest = null;
|
|
264
|
+
let hosted = null;
|
|
265
|
+
let outdated = false;
|
|
266
|
+
let mcp_upgrade = null;
|
|
267
|
+
try {
|
|
268
|
+
const res = await fetch(`${API_URL}/api/v1/mcp`, { headers: apiHeaders() });
|
|
269
|
+
const cat = (await res.json().catch(() => ({})));
|
|
270
|
+
if (res.ok && typeof cat.latest === 'string') {
|
|
271
|
+
latest = cat.latest;
|
|
272
|
+
hosted = typeof cat.hosted === 'string' ? cat.hosted : null;
|
|
273
|
+
const [a, b, c] = MCP_SELF.split('.').map((n) => parseInt(n, 10) || 0);
|
|
274
|
+
const [x, y, z] = latest.split('.').map((n) => parseInt(n, 10) || 0);
|
|
275
|
+
outdated = a < x || (a === x && (b < y || (b === y && c < z)));
|
|
276
|
+
if (outdated) {
|
|
277
|
+
mcp_upgrade =
|
|
278
|
+
`GripForge MCP npm ${MCP_SELF} is outdated (latest ${latest}). ` +
|
|
279
|
+
`Upgrade: npx -y @gripforgeai/mcp@${latest} then restart Claude. ` +
|
|
280
|
+
`Or hosted: ${hosted ?? 'https://gripforge.ai/mcp'}`;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
/* offline — still return formats */
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
content: [
|
|
289
|
+
{
|
|
290
|
+
type: 'text',
|
|
291
|
+
text: JSON.stringify({
|
|
292
|
+
formats: SUPPORTED,
|
|
293
|
+
styles: ['melee', 'gun', 'shield', 'staff'],
|
|
294
|
+
hands: ['right', 'left'],
|
|
295
|
+
api: API_URL,
|
|
296
|
+
docs: API_URL + '/docs',
|
|
297
|
+
mcp: { client: MCP_SELF, latest: latest ?? MCP_SELF, outdated, npm: '@gripforgeai/mcp' },
|
|
298
|
+
...(mcp_upgrade ? { mcp_upgrade } : {}),
|
|
299
|
+
}, null, 2),
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
};
|
|
303
|
+
});
|
|
304
|
+
function apiHeaders() {
|
|
305
|
+
return {
|
|
306
|
+
'x-api-key': API_KEY ?? '',
|
|
307
|
+
'x-gripforge-client': 'mcp',
|
|
308
|
+
'user-agent': 'gripforge-mcp/0.1.5',
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
const KIND = z.enum(['character', 'enemy', 'weapon', 'prop', 'texture', 'bind', 'vfx', 'animation']);
|
|
312
|
+
const TEX_EXTS = ['.png', '.jpg', '.jpeg', '.webp'];
|
|
313
|
+
server.tool('gripforge_texture_prep', 'Prep a game texture (terrain / props): optional 50% wrap + seam blend, then a faithful ' +
|
|
314
|
+
'lanczos upscale (1× / 2× / 4×, capped at 1024 or 2048). The pattern is PRESERVED — ' +
|
|
315
|
+
'nothing is generated. Pass a local path or a Library texture_id. Writes the PNG into ' +
|
|
316
|
+
'out_dir and returns the Library albedo URL. No credit consumed.', {
|
|
317
|
+
path: z.string().optional().describe('Absolute path to a PNG/JPG/WebP'),
|
|
318
|
+
texture_id: z.string().optional().describe('Library id of a texture (lib_…)'),
|
|
319
|
+
seamless: z.boolean().optional().describe('50% offset + seam blend (default true)'),
|
|
320
|
+
scale: z.union([z.literal(1), z.literal(2), z.literal(4)]).optional().describe('Upscale factor (default 1)'),
|
|
321
|
+
size: z.union([z.literal(1024), z.literal(2048)]).optional().describe('Max output side (default 1024)'),
|
|
322
|
+
name: z.string().optional().describe('Library / file name stem'),
|
|
323
|
+
out_dir: z.string().optional().describe('Write the prepped PNG here (default: ./gripforge-library)'),
|
|
324
|
+
save: z.boolean().optional().describe('Save to Library (default true)'),
|
|
325
|
+
}, async ({ path, texture_id, seamless, scale, size, name, out_dir, save }) => {
|
|
326
|
+
if (!API_KEY)
|
|
327
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
328
|
+
if (!path && !texture_id)
|
|
329
|
+
return err('Provide path or texture_id.');
|
|
330
|
+
const form = new FormData();
|
|
331
|
+
if (path) {
|
|
332
|
+
const abs = resolve(path);
|
|
333
|
+
const ext = extname(abs).toLowerCase();
|
|
334
|
+
if (!TEX_EXTS.includes(ext))
|
|
335
|
+
return err(`Unsupported image: ${abs} — use ${TEX_EXTS.join(' ')}`);
|
|
336
|
+
form.append('file', new Blob([await readFile(abs)]), basename(abs));
|
|
337
|
+
}
|
|
338
|
+
if (texture_id)
|
|
339
|
+
form.append('texture_id', texture_id);
|
|
340
|
+
form.append('seamless', seamless === false ? '0' : '1');
|
|
341
|
+
form.append('scale', String(scale ?? 1));
|
|
342
|
+
form.append('size', String(size ?? 1024));
|
|
343
|
+
form.append('save', save === false ? '0' : '1');
|
|
344
|
+
if (name)
|
|
345
|
+
form.append('name', name);
|
|
346
|
+
let res;
|
|
347
|
+
try {
|
|
348
|
+
res = await fetch(`${API_URL}/api/v1/textures/prep`, {
|
|
349
|
+
method: 'POST',
|
|
350
|
+
headers: { ...apiHeaders() },
|
|
351
|
+
body: form,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return err(`GripForge API unreachable at ${API_URL}`);
|
|
356
|
+
}
|
|
357
|
+
const data = (await res.json().catch(() => ({})));
|
|
358
|
+
if (!res.ok)
|
|
359
|
+
return err(String(data.error ?? `texture_prep failed (${res.status})`));
|
|
360
|
+
const dir = resolve(out_dir || join(process.cwd(), 'gripforge-library'));
|
|
361
|
+
await mkdir(dir, { recursive: true });
|
|
362
|
+
const wrote = [];
|
|
363
|
+
if (data.urls?.albedo) {
|
|
364
|
+
const fileRes = await fetch(data.urls.albedo);
|
|
365
|
+
if (fileRes.ok) {
|
|
366
|
+
const buf = Buffer.from(await fileRes.arrayBuffer());
|
|
367
|
+
const stem = (name || (path ? basename(path).replace(/\.[^.]+$/, '') : data.id) || 'texture').replace(/[/\\]/g, '');
|
|
368
|
+
const dest = join(dir, `${stem}_prep.png`);
|
|
369
|
+
await writeFile(dest, buf);
|
|
370
|
+
wrote.push(dest);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
content: [
|
|
375
|
+
{
|
|
376
|
+
type: 'text',
|
|
377
|
+
text: JSON.stringify({
|
|
378
|
+
id: data.id,
|
|
379
|
+
library_id: data.library_id ?? null,
|
|
380
|
+
file_url: data.urls?.albedo ?? null,
|
|
381
|
+
width: data.width,
|
|
382
|
+
height: data.height,
|
|
383
|
+
wrote,
|
|
384
|
+
}, null, 2),
|
|
385
|
+
},
|
|
386
|
+
],
|
|
387
|
+
};
|
|
388
|
+
});
|
|
389
|
+
server.tool('gripforge_style_kit', 'Resolve "Devil May Cry like" / "genshin" to the locker kit already tagged with that game look (characters, enemies, weapons, props). Call this BEFORE generating. Reuse the returned ids.', { prompt: z.string().min(2).max(240).describe('e.g. "devil may cry like", "un ennemi genshin"') }, async ({ prompt }) => {
|
|
390
|
+
if (!API_KEY)
|
|
391
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
392
|
+
const qs = new URLSearchParams({ q: prompt, limit: '200' });
|
|
393
|
+
// Hosted matcher lives on the API: pass style= the prompt; the server canonicalizes aliases.
|
|
394
|
+
qs.set('style', prompt);
|
|
395
|
+
const res = await fetch(`${API_URL}/api/v1/library?${qs}`, { headers: apiHeaders() });
|
|
396
|
+
const data = (await res.json().catch(() => ({})));
|
|
397
|
+
if (!res.ok)
|
|
398
|
+
return err(String(data.error ?? res.status));
|
|
399
|
+
const items = Array.isArray(data.items) ? data.items : [];
|
|
400
|
+
const of = (k) => items
|
|
401
|
+
.filter((it) => it.kind === k)
|
|
402
|
+
.map((it) => ({ id: it.id, kind: it.kind, name: it.name, file_url: it.file_url }));
|
|
403
|
+
const out = {
|
|
404
|
+
prompt,
|
|
405
|
+
characters: of('character'),
|
|
406
|
+
enemies: of('enemy'),
|
|
407
|
+
weapons: of('weapon'),
|
|
408
|
+
props: of('prop'),
|
|
409
|
+
textures: of('texture'),
|
|
410
|
+
binds: of('bind'),
|
|
411
|
+
vfx: of('vfx'),
|
|
412
|
+
animations: of('animation'),
|
|
413
|
+
hint: items.length > 0
|
|
414
|
+
? of('weapon').length
|
|
415
|
+
? 'Reuse these Library ids. Do not regenerate unless the user wants a NEW mesh.'
|
|
416
|
+
: '0 weapons — grip style is melee/gun on meta.style. Tag swords with gripforge_library_tag(id, style) (writes the look into tags, does not overwrite the grip).'
|
|
417
|
+
: 'No locker assets for this look yet.',
|
|
418
|
+
};
|
|
419
|
+
return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] };
|
|
420
|
+
});
|
|
421
|
+
server.tool('gripforge_generate_character', 'CREATE a new game-ready mesh (T-pose, auto-rigged) and save it to Library. ' +
|
|
422
|
+
'If the user wants an existing game look ("Devil May Cry like", "genshin"), call gripforge_style_kit FIRST and reuse those ids — do not regenerate. ' +
|
|
423
|
+
'Put "enemy" / "ennemi" in the prompt (or kind=enemy) to save as kind=enemy, not character. Costs 10 credits; 2–8 minutes.', {
|
|
424
|
+
prompt: z.string().min(3).max(600).describe('Description. "Devil May Cry like enemy" → tagged devil-may-cry, kind=enemy.'),
|
|
425
|
+
provider: z.enum(['tripo', 'meshy']).optional().describe('Generation provider (default tripo)'),
|
|
426
|
+
name: z.string().max(160).optional().describe('Library item name (default: the prompt)'),
|
|
427
|
+
kind: z.enum(['character', 'enemy']).optional().describe('character (playable) or enemy (foe). Inferred from the prompt if omitted.'),
|
|
428
|
+
out_dir: z.string().optional().describe('Write the generated GLB here (default: skip, Library URL only)'),
|
|
429
|
+
}, async ({ prompt, provider, name, kind, out_dir }) => {
|
|
430
|
+
if (!API_KEY)
|
|
431
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
432
|
+
const res = await fetch(`${API_URL}/api/v1/generate-character`, {
|
|
433
|
+
method: 'POST',
|
|
434
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
435
|
+
body: JSON.stringify({ prompt, provider, name, kind }),
|
|
436
|
+
});
|
|
437
|
+
const data = (await res.json().catch(() => ({})));
|
|
438
|
+
if (!res.ok)
|
|
439
|
+
return err(String(data.error ?? `generate failed (${res.status})`));
|
|
440
|
+
const wrote = [];
|
|
441
|
+
if (out_dir && data.item?.file_url) {
|
|
442
|
+
const dir = resolve(out_dir);
|
|
443
|
+
await mkdir(dir, { recursive: true });
|
|
444
|
+
const fileRes = await fetch(data.item.file_url);
|
|
445
|
+
if (fileRes.ok) {
|
|
446
|
+
const dest = join(dir, data.item.filename || `${data.item.id}.glb`);
|
|
447
|
+
await writeFile(dest, Buffer.from(await fileRes.arrayBuffer()));
|
|
448
|
+
wrote.push(dest);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote }, null, 2) }] };
|
|
452
|
+
});
|
|
453
|
+
server.tool('gripforge_concept_correct', 'Turn a character concept image into a strict T-pose sheet (optional: remove flowing cape/train only — sleeved jacket stays; outfit or sport base). Saves PNG to Library. 1 credit.', {
|
|
454
|
+
path: z.string().optional().describe('Absolute path to a local concept image'),
|
|
455
|
+
file_url: z.string().optional().describe('https URL of the concept image'),
|
|
456
|
+
clothes: z.enum(['outfit', 'base']).optional().describe('outfit = keep clothes; base = grey athletic sportswear'),
|
|
457
|
+
cape: z.boolean().optional().describe('Remove flowing cape/train only; keep sleeved jacket (outfit only, default true)'),
|
|
458
|
+
notes: z.string().optional().describe('Extra direction'),
|
|
459
|
+
name: z.string().optional().describe('Library name'),
|
|
460
|
+
}, async ({ path, file_url, clothes, cape, notes, name }) => {
|
|
461
|
+
if (!API_KEY)
|
|
462
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
463
|
+
if (!path && !file_url)
|
|
464
|
+
return err('Provide path or file_url.');
|
|
465
|
+
const fd = new FormData();
|
|
466
|
+
if (path) {
|
|
467
|
+
const abs = resolve(path);
|
|
468
|
+
fd.append('file', new Blob([await readFile(abs)]), basename(abs));
|
|
469
|
+
}
|
|
470
|
+
else if (file_url) {
|
|
471
|
+
let parsed;
|
|
472
|
+
try {
|
|
473
|
+
parsed = new URL(file_url);
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
return err('file_url: invalid URL');
|
|
477
|
+
}
|
|
478
|
+
const got = await fetch(parsed, { signal: AbortSignal.timeout(60_000) });
|
|
479
|
+
if (!got.ok)
|
|
480
|
+
return err(`file_url: download failed (${got.status})`);
|
|
481
|
+
fd.append('file', new Blob([await got.arrayBuffer()]), parsed.pathname.split('/').pop() || 'concept.jpg');
|
|
482
|
+
}
|
|
483
|
+
fd.append('clothes', clothes === 'base' ? 'base' : 'outfit');
|
|
484
|
+
fd.append('cape', cape === false ? '0' : '1');
|
|
485
|
+
if (notes)
|
|
486
|
+
fd.append('notes', notes);
|
|
487
|
+
if (name)
|
|
488
|
+
fd.append('name', name);
|
|
489
|
+
const res = await fetch(`${API_URL}/api/v1/concept-correct`, {
|
|
490
|
+
method: 'POST',
|
|
491
|
+
headers: apiHeaders(),
|
|
492
|
+
body: fd,
|
|
493
|
+
});
|
|
494
|
+
const data = await res.json().catch(() => ({}));
|
|
495
|
+
if (!res.ok)
|
|
496
|
+
return err(String(data.error ?? res.status));
|
|
497
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
498
|
+
});
|
|
499
|
+
server.tool('gripforge_library_list', 'List the GripForge Library locker (characters, enemies, weapons, props, textures, binds). Filter by kind and/or game style.', {
|
|
500
|
+
kind: KIND.optional().describe('Filter by kind'),
|
|
501
|
+
q: z.string().optional().describe('Search name/filename'),
|
|
502
|
+
style: z.string().optional().describe('Game look (devil-may-cry, dmc, genshin)'),
|
|
503
|
+
}, async ({ kind, q, style }) => {
|
|
504
|
+
if (!API_KEY)
|
|
505
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
506
|
+
const qs = new URLSearchParams();
|
|
507
|
+
if (kind)
|
|
508
|
+
qs.set('kind', kind);
|
|
509
|
+
if (q)
|
|
510
|
+
qs.set('q', q);
|
|
511
|
+
if (style)
|
|
512
|
+
qs.set('style', style);
|
|
513
|
+
const res = await fetch(`${API_URL}/api/v1/library?${qs}`, { headers: apiHeaders() });
|
|
514
|
+
const data = await res.json().catch(() => ({}));
|
|
515
|
+
if (!res.ok)
|
|
516
|
+
return err(String(data.error ?? res.status));
|
|
517
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
518
|
+
});
|
|
519
|
+
server.tool('gripforge_library_get', 'Get one Library item (metadata + signed file URL).', { id: z.string().describe('Library id (lib_…)') }, async ({ id }) => {
|
|
520
|
+
if (!API_KEY)
|
|
521
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
522
|
+
const res = await fetch(`${API_URL}/api/v1/library/${encodeURIComponent(id)}`, { headers: apiHeaders() });
|
|
523
|
+
const data = await res.json().catch(() => ({}));
|
|
524
|
+
if (!res.ok)
|
|
525
|
+
return err(String(data.error ?? res.status));
|
|
526
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
527
|
+
});
|
|
528
|
+
server.tool('gripforge_library_push', 'Upload a local file into the GripForge Library locker (character, weapon, prop, texture, or bind).', {
|
|
529
|
+
path: z.string().describe('Absolute path of the file to upload'),
|
|
530
|
+
kind: KIND.describe('Locker kind'),
|
|
531
|
+
name: z.string().optional().describe('Display name (defaults to the filename stem)'),
|
|
532
|
+
}, async ({ path, kind, name }) => {
|
|
533
|
+
if (!API_KEY)
|
|
534
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
535
|
+
const abs = resolve(path);
|
|
536
|
+
const form = new FormData();
|
|
537
|
+
form.append('file', new Blob([await readFile(abs)]), basename(abs));
|
|
538
|
+
form.append('kind', kind);
|
|
539
|
+
form.append('name', name || basename(abs).replace(/\.[^.]+$/, ''));
|
|
540
|
+
const res = await fetch(`${API_URL}/api/v1/library`, {
|
|
541
|
+
method: 'POST',
|
|
542
|
+
headers: apiHeaders(),
|
|
543
|
+
body: form,
|
|
544
|
+
});
|
|
545
|
+
const data = await res.json().catch(() => ({}));
|
|
546
|
+
if (!res.ok)
|
|
547
|
+
return err(String(data.error ?? res.status));
|
|
548
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
549
|
+
});
|
|
550
|
+
server.tool('gripforge_library_pull', 'Download a Library item into the open repo. This is the sync: writes bind.json and/or the mesh next to the working tree.', {
|
|
551
|
+
id: z.string().describe('Library id (lib_…)'),
|
|
552
|
+
out_dir: z
|
|
553
|
+
.string()
|
|
554
|
+
.optional()
|
|
555
|
+
.describe('Folder to write into (default: ./gripforge-library in the current working directory)'),
|
|
556
|
+
}, async ({ id, out_dir }) => {
|
|
557
|
+
if (!API_KEY)
|
|
558
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
559
|
+
const res = await fetch(`${API_URL}/api/v1/library/${encodeURIComponent(id)}`, { headers: apiHeaders() });
|
|
560
|
+
const data = (await res.json().catch(() => ({})));
|
|
561
|
+
if (!res.ok || !data.item)
|
|
562
|
+
return err(String(data.error ?? res.status));
|
|
563
|
+
const dir = resolve(out_dir || join(process.cwd(), 'gripforge-library'));
|
|
564
|
+
await mkdir(dir, { recursive: true });
|
|
565
|
+
const wrote = [];
|
|
566
|
+
const item = data.item;
|
|
567
|
+
if (item.kind === 'bind' && item.meta?.bind) {
|
|
568
|
+
const bindPath = join(dir, `${item.id}-bind.json`);
|
|
569
|
+
await writeFile(bindPath, JSON.stringify(item.meta.bind, null, 2));
|
|
570
|
+
wrote.push(bindPath);
|
|
571
|
+
}
|
|
572
|
+
if (item.file_url) {
|
|
573
|
+
const fileRes = await fetch(item.file_url);
|
|
574
|
+
if (!fileRes.ok)
|
|
575
|
+
return err(`file download failed (${fileRes.status})`);
|
|
576
|
+
const buf = Buffer.from(await fileRes.arrayBuffer());
|
|
577
|
+
const isJsonBind = item.kind === 'bind' && (item.filename ?? '').endsWith('.json');
|
|
578
|
+
if (!isJsonBind || !item.meta?.bind) {
|
|
579
|
+
const fname = item.filename || `${item.id}.bin`;
|
|
580
|
+
const dest = join(dir, `${item.id}-${fname}`);
|
|
581
|
+
await writeFile(dest, buf);
|
|
582
|
+
wrote.push(dest);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
const sidecars = item.sidecars;
|
|
586
|
+
if (Array.isArray(sidecars) && sidecars.length) {
|
|
587
|
+
for (const sc of sidecars) {
|
|
588
|
+
if (!sc?.url || !sc.file)
|
|
589
|
+
continue;
|
|
590
|
+
const extra = await fetch(sc.url);
|
|
591
|
+
if (!extra.ok)
|
|
592
|
+
continue;
|
|
593
|
+
const dest = join(dir, sc.file === 'json' ? `${item.id}.json` : sc.file === 'png' ? `${item.id}.png` : `${item.id}.${sc.file}`);
|
|
594
|
+
let bytes = Buffer.from(await extra.arrayBuffer());
|
|
595
|
+
if (sc.file === 'json') {
|
|
596
|
+
try {
|
|
597
|
+
const text = bytes.toString('utf8').replace(/sidecar:(tex\d+|png|json)/g, `${item.id}.$1`);
|
|
598
|
+
bytes = Buffer.from(text, 'utf8');
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
/* keep original */
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
await writeFile(dest, bytes);
|
|
605
|
+
wrote.push(dest);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
else if (item.kind === 'vfx') {
|
|
609
|
+
for (const [url, fname] of [
|
|
610
|
+
[item.json_url, `${item.id}.json`],
|
|
611
|
+
[item.thumb_url, `${item.id}.png`],
|
|
612
|
+
]) {
|
|
613
|
+
if (!url)
|
|
614
|
+
continue;
|
|
615
|
+
const extra = await fetch(url);
|
|
616
|
+
if (!extra.ok)
|
|
617
|
+
continue;
|
|
618
|
+
const dest = join(dir, fname);
|
|
619
|
+
await writeFile(dest, Buffer.from(await extra.arrayBuffer()));
|
|
620
|
+
wrote.push(dest);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return {
|
|
624
|
+
content: [
|
|
625
|
+
{
|
|
626
|
+
type: 'text',
|
|
627
|
+
text: JSON.stringify({ id: item.id, kind: item.kind, name: item.name, wrote }, null, 2),
|
|
628
|
+
},
|
|
629
|
+
],
|
|
630
|
+
};
|
|
631
|
+
});
|
|
632
|
+
server.tool('gripforge_library_tag', 'Add a game look to tags so style_kit can find the item. Does not overwrite grip style melee/gun.', {
|
|
633
|
+
id: z.string().describe('Library id (lib_…)'),
|
|
634
|
+
style: z.string().optional().describe('Game look id or alias (devil-may-cry, dmc)'),
|
|
635
|
+
tags: z.array(z.string()).optional().describe('Replace tags. Omit to keep existing and just add style.'),
|
|
636
|
+
}, async ({ id, style, tags }) => {
|
|
637
|
+
if (!API_KEY)
|
|
638
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
639
|
+
const res = await fetch(`${API_URL}/api/v1/library/${encodeURIComponent(id)}`, {
|
|
640
|
+
method: 'PATCH',
|
|
641
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
642
|
+
body: JSON.stringify({ style, tags }),
|
|
643
|
+
});
|
|
644
|
+
const data = await res.json().catch(() => ({}));
|
|
645
|
+
if (!res.ok)
|
|
646
|
+
return err(String(data.error ?? res.status));
|
|
647
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
648
|
+
});
|
|
649
|
+
server.tool('gripforge_animate', 'Retarget the standard clip pack (idle, walk, run, attack1-3, hit, death, dodge) onto a Mixamo-named Library character/enemy/armed bind. Saves kind=animation. 0 credits.', {
|
|
650
|
+
character_id: z.string().describe('Library character, enemy, or armed bind'),
|
|
651
|
+
slots: z
|
|
652
|
+
.array(z.enum(['idle', 'walk', 'run', 'attack1', 'attack2', 'attack3', 'hit', 'death', 'dodge']))
|
|
653
|
+
.optional(),
|
|
654
|
+
name: z.string().optional(),
|
|
655
|
+
out_dir: z.string().optional().describe('Also write the animated GLB here'),
|
|
656
|
+
}, async ({ character_id, slots, name, out_dir }) => {
|
|
657
|
+
if (!API_KEY)
|
|
658
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
659
|
+
const res = await fetch(`${API_URL}/api/v1/animate-pack`, {
|
|
660
|
+
method: 'POST',
|
|
661
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
662
|
+
body: JSON.stringify({ character_id, slots, name }),
|
|
663
|
+
});
|
|
664
|
+
const data = (await res.json().catch(() => ({})));
|
|
665
|
+
if (!res.ok)
|
|
666
|
+
return err(String(data.error ?? res.status));
|
|
667
|
+
const wrote = [];
|
|
668
|
+
if (out_dir && data.item?.file_url) {
|
|
669
|
+
const dir = resolve(out_dir);
|
|
670
|
+
await mkdir(dir, { recursive: true });
|
|
671
|
+
const bin = Buffer.from(await (await fetch(data.item.file_url)).arrayBuffer());
|
|
672
|
+
const dest = join(dir, data.item.filename || `${data.item.id}.glb`);
|
|
673
|
+
await writeFile(dest, bin);
|
|
674
|
+
wrote.push(dest);
|
|
675
|
+
}
|
|
676
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...data, wrote }, null, 2) }] };
|
|
677
|
+
});
|
|
678
|
+
server.tool('gripforge_retarget', 'Convert clips from skeleton A (source_id) onto skeleton B (target_id). Saves kind=animation.', {
|
|
679
|
+
source_id: z.string(),
|
|
680
|
+
target_id: z.string(),
|
|
681
|
+
clip: z.string().optional(),
|
|
682
|
+
name: z.string().optional(),
|
|
683
|
+
}, async ({ source_id, target_id, clip, name }) => {
|
|
684
|
+
if (!API_KEY)
|
|
685
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
686
|
+
const res = await fetch(`${API_URL}/api/v1/retarget`, {
|
|
687
|
+
method: 'POST',
|
|
688
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
689
|
+
body: JSON.stringify({ source_id, target_id, clip, name }),
|
|
690
|
+
});
|
|
691
|
+
const data = await res.json().catch(() => ({}));
|
|
692
|
+
if (!res.ok)
|
|
693
|
+
return err(String(data.error ?? res.status));
|
|
694
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
695
|
+
});
|
|
696
|
+
server.tool('gripforge_rest_pose', 'Lower arms from T-pose until they would clip this mesh. Saves arms-down rest (clip idle). Armed binds keep weapons in the hands. 0 credits.', { character_id: z.string(), name: z.string().optional() }, async ({ character_id, name }) => {
|
|
697
|
+
if (!API_KEY)
|
|
698
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
699
|
+
const res = await fetch(`${API_URL}/api/v1/rest-pose`, {
|
|
700
|
+
method: 'POST',
|
|
701
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
702
|
+
body: JSON.stringify({ character_id, name }),
|
|
703
|
+
});
|
|
704
|
+
const data = await res.json().catch(() => ({}));
|
|
705
|
+
if (!res.ok)
|
|
706
|
+
return err(String(data.error ?? res.status));
|
|
707
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
708
|
+
});
|
|
709
|
+
server.tool('gripforge_render', 'Server PNG preview of a Library GLB so the agent can see pose/attach without a browser.', { id: z.string(), width: z.number().optional(), height: z.number().optional() }, async ({ id, width, height }) => {
|
|
710
|
+
if (!API_KEY)
|
|
711
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
712
|
+
const res = await fetch(`${API_URL}/api/v1/render`, {
|
|
713
|
+
method: 'POST',
|
|
714
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
715
|
+
body: JSON.stringify({ id, width, height }),
|
|
716
|
+
});
|
|
717
|
+
if (!res.ok) {
|
|
718
|
+
const data = await res.json().catch(() => ({}));
|
|
719
|
+
return err(String(data.error ?? res.status));
|
|
720
|
+
}
|
|
721
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
722
|
+
return {
|
|
723
|
+
content: [
|
|
724
|
+
{ type: 'text', text: JSON.stringify({ id, bytes: buf.length, mime: 'image/png' }) },
|
|
725
|
+
{ type: 'image', data: buf.toString('base64'), mimeType: 'image/png' },
|
|
726
|
+
],
|
|
727
|
+
};
|
|
728
|
+
});
|
|
729
|
+
server.tool('gripforge_scene_kit', 'Locker props for a game look plus a suggested layout (metres, Y-up). Call after style_kit. Empty slots → generate that dressing prop, tag it, retry.', { prompt: z.string().min(2).max(240).describe('e.g. devil may cry like') }, async ({ prompt }) => {
|
|
730
|
+
if (!API_KEY)
|
|
731
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
732
|
+
const res = await fetch(`${API_URL}/api/v1/scene-kit?${new URLSearchParams({ prompt })}`, { headers: apiHeaders() });
|
|
733
|
+
const data = await res.json().catch(() => ({}));
|
|
734
|
+
if (!res.ok)
|
|
735
|
+
return err(String(data.error ?? res.status));
|
|
736
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
737
|
+
});
|
|
738
|
+
server.tool('gripforge_hitbox', 'Body capsules + weapon reach from a Library character or armed bind. 0 credits.', {
|
|
739
|
+
character_id: z.string().optional().describe('Library character/enemy id'),
|
|
740
|
+
attach_id: z.string().optional().describe('Library armed bind id'),
|
|
741
|
+
}, async ({ character_id, attach_id }) => {
|
|
742
|
+
if (!API_KEY)
|
|
743
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
744
|
+
if (!character_id && !attach_id)
|
|
745
|
+
return err('Provide character_id or attach_id.');
|
|
746
|
+
const res = await fetch(`${API_URL}/api/v1/hitbox`, {
|
|
747
|
+
method: 'POST',
|
|
748
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
749
|
+
body: JSON.stringify({ character_id, attach_id }),
|
|
750
|
+
});
|
|
751
|
+
const data = await res.json().catch(() => ({}));
|
|
752
|
+
if (!res.ok)
|
|
753
|
+
return err(String(data.error ?? res.status));
|
|
754
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
755
|
+
});
|
|
756
|
+
server.tool('gripforge_vfx', 'ADMIN ONLY. Generate + export 3D slash/thrust/spin VFX on an existing Library bind, or a world-space fire meteor (head + trail + impact). Pass prompt to fill type/style/color/emitters. Additive PNG, vfx.json + snippets. Refused for non-admin keys.', {
|
|
757
|
+
attach_id: z.string().optional().describe('Library bind id (lib_…). Not required for meteor.'),
|
|
758
|
+
prompt: z.string().optional().describe('Natural-language VFX prompt. Fills type/style/color/emitters when omitted.'),
|
|
759
|
+
type: z.enum(['slash', 'thrust', 'spin', 'meteor', 'beam', 'swing', 'claw', 'charge', 'shield']).optional().describe('Strike type (default slash)'),
|
|
760
|
+
style: z.enum(['energy', 'metal', 'magic']).optional().describe('Look (default energy)'),
|
|
761
|
+
look: z.enum(['toon', 'solid']).optional().describe('Beam look: toon lightning or solid filled kamehameha'),
|
|
762
|
+
color: z.string().optional().describe('Hex color'),
|
|
763
|
+
duration: z.number().optional().describe('Seconds (default 0.25)'),
|
|
764
|
+
width: z.number().optional().describe('Ribbon width in metres'),
|
|
765
|
+
out_dir: z.string().optional().describe('Write vfx.glb, additive.png, vfx.json here'),
|
|
766
|
+
}, async ({ attach_id, prompt, type, style, look, color, duration, width, out_dir }) => {
|
|
767
|
+
if (!API_KEY)
|
|
768
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
769
|
+
const res = await fetch(`${API_URL}/api/v1/vfx`, {
|
|
770
|
+
method: 'POST',
|
|
771
|
+
headers: { 'x-api-key': API_KEY, 'x-gripforge-client': 'mcp', 'content-type': 'application/json' },
|
|
772
|
+
body: JSON.stringify({
|
|
773
|
+
attach_id,
|
|
774
|
+
prompt,
|
|
775
|
+
type,
|
|
776
|
+
style,
|
|
777
|
+
look,
|
|
778
|
+
color,
|
|
779
|
+
duration,
|
|
780
|
+
width,
|
|
781
|
+
}),
|
|
782
|
+
});
|
|
783
|
+
const data = (await res.json().catch(() => ({})));
|
|
784
|
+
if (res.status === 403)
|
|
785
|
+
return err('admin only — gripforge_vfx is not available on this key.');
|
|
786
|
+
if (!res.ok)
|
|
787
|
+
return err(String(data.error ?? res.status));
|
|
788
|
+
const wrote = [];
|
|
789
|
+
if (out_dir) {
|
|
790
|
+
const dir = resolve(out_dir);
|
|
791
|
+
await mkdir(dir, { recursive: true });
|
|
792
|
+
if (data.urls?.glb) {
|
|
793
|
+
const bin = Buffer.from(await (await fetch(data.urls.glb)).arrayBuffer());
|
|
794
|
+
await writeFile(join(dir, 'vfx.glb'), bin);
|
|
795
|
+
wrote.push(join(dir, 'vfx.glb'));
|
|
796
|
+
}
|
|
797
|
+
if (data.urls?.textures?.additive) {
|
|
798
|
+
const bin = Buffer.from(await (await fetch(data.urls.textures.additive)).arrayBuffer());
|
|
799
|
+
await writeFile(join(dir, 'additive.png'), bin);
|
|
800
|
+
wrote.push(join(dir, 'additive.png'));
|
|
801
|
+
}
|
|
802
|
+
if (data.snippets?.json) {
|
|
803
|
+
await writeFile(join(dir, 'vfx.json'), data.snippets.json);
|
|
804
|
+
wrote.push(join(dir, 'vfx.json'));
|
|
805
|
+
}
|
|
806
|
+
const snip = data.snippets;
|
|
807
|
+
if (snip?.three) {
|
|
808
|
+
await writeFile(join(dir, 'three.js.txt'), snip.three);
|
|
809
|
+
wrote.push(join(dir, 'three.js.txt'));
|
|
810
|
+
}
|
|
811
|
+
if (snip?.unity) {
|
|
812
|
+
await writeFile(join(dir, 'unity.cs.txt'), snip.unity);
|
|
813
|
+
wrote.push(join(dir, 'unity.cs.txt'));
|
|
814
|
+
}
|
|
815
|
+
if (snip?.godot) {
|
|
816
|
+
await writeFile(join(dir, 'godot.gd.txt'), snip.godot);
|
|
817
|
+
wrote.push(join(dir, 'godot.gd.txt'));
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return {
|
|
821
|
+
content: [{ type: 'text', text: JSON.stringify({ ...data, wrote }, null, 2) }],
|
|
822
|
+
};
|
|
823
|
+
});
|
|
824
|
+
server.tool('gripforge_vfx_preview', 'ADMIN ONLY. Evaluate the VFX timeline at time t (t=0 empty). No credits. Play/scrub pose.', {
|
|
825
|
+
bind: z.record(z.string(), z.unknown()).describe('Attach bind.json object'),
|
|
826
|
+
t: z.number().optional().describe('Seconds (default 0)'),
|
|
827
|
+
type: z.enum(['slash', 'thrust', 'spin', 'meteor', 'beam', 'swing', 'claw', 'charge', 'shield']).optional(),
|
|
828
|
+
duration: z.number().optional(),
|
|
829
|
+
}, async ({ bind, t, type, duration }) => {
|
|
830
|
+
if (!API_KEY)
|
|
831
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
832
|
+
const res = await fetch(`${API_URL}/api/v1/vfx/preview`, {
|
|
833
|
+
method: 'POST',
|
|
834
|
+
headers: { 'x-api-key': API_KEY, 'x-gripforge-client': 'mcp', 'content-type': 'application/json' },
|
|
835
|
+
body: JSON.stringify({ bind, t: t ?? 0, type, duration }),
|
|
836
|
+
});
|
|
837
|
+
const data = (await res.json().catch(() => ({})));
|
|
838
|
+
if (res.status === 403)
|
|
839
|
+
return err('admin only — gripforge_vfx_preview is not available on this key.');
|
|
840
|
+
if (!res.ok)
|
|
841
|
+
return err(String(data.error ?? res.status));
|
|
842
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
843
|
+
});
|
|
844
|
+
server.tool('gripforge_shaders', 'List shaders from the GripForge catalog (authorized GodotShaders.com mirror). ' +
|
|
845
|
+
'Returns { id, name, engines }. Search q=slash_reveal (alias) or a name. ' +
|
|
846
|
+
'Then pull with gripforge_shader_pull. Listing is free.', {
|
|
847
|
+
q: z.string().optional().describe('Search id/name/alias (e.g. slash_reveal)'),
|
|
848
|
+
type: z
|
|
849
|
+
.enum(['canvas_item', 'spatial', 'sky', 'particles', 'fog'])
|
|
850
|
+
.optional()
|
|
851
|
+
.describe('Godot shader_type filter'),
|
|
852
|
+
limit: z.number().optional().describe('Page size (default 50, max 200)'),
|
|
853
|
+
offset: z.number().optional().describe('Offset (default 0)'),
|
|
854
|
+
}, async ({ q, type, limit, offset }) => {
|
|
855
|
+
if (!API_KEY)
|
|
856
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
857
|
+
const qs = new URLSearchParams();
|
|
858
|
+
if (q)
|
|
859
|
+
qs.set('q', q);
|
|
860
|
+
if (type)
|
|
861
|
+
qs.set('type', type);
|
|
862
|
+
if (limit != null)
|
|
863
|
+
qs.set('limit', String(limit));
|
|
864
|
+
if (offset != null)
|
|
865
|
+
qs.set('offset', String(offset));
|
|
866
|
+
const res = await fetch(`${API_URL}/api/v1/shaders?${qs}`, { headers: apiHeaders() });
|
|
867
|
+
const data = await res.json().catch(() => ({}));
|
|
868
|
+
if (!res.ok)
|
|
869
|
+
return err(String(data.error ?? res.status));
|
|
870
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
871
|
+
});
|
|
872
|
+
server.tool('gripforge_shader_pull', 'Pull a catalog shader into the open repo and return an assignment snippet. ' +
|
|
873
|
+
'id = slug or alias (slash_reveal). engine = godot | unity | three. ' +
|
|
874
|
+
'Writes sources into out_dir. Godot → ShaderMaterial (SpatialMaterial/StandardMaterial3D cannot run custom code). ' +
|
|
875
|
+
'Unity → ShaderLab Material. Three → ShaderMaterial. Example: "put slash_reveal Godot on my arc". ' +
|
|
876
|
+
'v1 pull is free (no credit).', {
|
|
877
|
+
id: z.string().describe('Shader id or alias (slash_reveal, circular_cresent_slash_2d_mask, …)'),
|
|
878
|
+
engine: z.enum(['godot', 'unity', 'three']).describe('Target engine'),
|
|
879
|
+
out_dir: z.string().describe('Folder to write shader files into (repo path)'),
|
|
880
|
+
}, async ({ id, engine, out_dir }) => {
|
|
881
|
+
if (!API_KEY)
|
|
882
|
+
return err('GRIPFORGE_API_KEY missing.');
|
|
883
|
+
const res = await fetch(`${API_URL}/api/v1/shaders/pull`, {
|
|
884
|
+
method: 'POST',
|
|
885
|
+
headers: { ...apiHeaders(), 'content-type': 'application/json' },
|
|
886
|
+
body: JSON.stringify({ id, engine, out_dir }),
|
|
887
|
+
});
|
|
888
|
+
const data = (await res.json().catch(() => ({})));
|
|
889
|
+
if (!res.ok)
|
|
890
|
+
return err(String(data.error ?? res.status));
|
|
891
|
+
const dir = resolve(out_dir);
|
|
892
|
+
await mkdir(dir, { recursive: true });
|
|
893
|
+
const wrote = [];
|
|
894
|
+
for (const f of data.files ?? []) {
|
|
895
|
+
const rel = (f.relative || f.path || '').replace(/^.*[/\\]/, '');
|
|
896
|
+
if (!rel || typeof f.content !== 'string')
|
|
897
|
+
continue;
|
|
898
|
+
const dest = join(dir, rel);
|
|
899
|
+
await writeFile(dest, f.content);
|
|
900
|
+
wrote.push(dest);
|
|
901
|
+
}
|
|
902
|
+
return {
|
|
903
|
+
content: [
|
|
904
|
+
{
|
|
905
|
+
type: 'text',
|
|
906
|
+
text: JSON.stringify({
|
|
907
|
+
id: data.id,
|
|
908
|
+
name: data.name,
|
|
909
|
+
engine: data.engine,
|
|
910
|
+
credit: 0,
|
|
911
|
+
note: data.note,
|
|
912
|
+
wrote,
|
|
913
|
+
snippet: data.snippet,
|
|
914
|
+
warnings: data.warnings ?? [],
|
|
915
|
+
}, null, 2),
|
|
916
|
+
},
|
|
917
|
+
],
|
|
918
|
+
};
|
|
919
|
+
});
|
|
176
920
|
const transport = new StdioServerTransport();
|
|
177
921
|
await server.connect(transport);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gripforgeai/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "GripForge MCP server \u2014 attach weapons & props to rigged characters from Claude Code, Cursor or any MCP client. Calls the GripForge API.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|